Added general content blocks

This commit is contained in:
2026-09-23 12:44:07 +02:00
parent 6bd917e81b
commit 9ecea06209
27 changed files with 474 additions and 21 deletions

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@astrojs/node": "^11.0.0", "@astrojs/node": "^11.0.0",
"@astrojs/react": "^6.0.0", "@astrojs/react": "^6.0.0",
"@keystar/ui": "^0.10.0",
"@keystatic/astro": "^6.0.0", "@keystatic/astro": "^6.0.0",
"@keystatic/core": "^0.6.7", "@keystatic/core": "^0.6.7",
"@markdoc/markdoc": "^0.4.0", "@markdoc/markdoc": "^0.4.0",

3
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@astrojs/react': '@astrojs/react':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) version: 6.0.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
'@keystar/ui':
specifier: ^0.10.0
version: 0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0)
'@keystatic/astro': '@keystatic/astro':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)

View File

@@ -6,20 +6,27 @@ import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc'
import ContentImage from '@/components/media/ContentImage.astro' import ContentImage from '@/components/media/ContentImage.astro'
import Figure from '../media/Figure.astro' import Figure from '../media/Figure.astro'
import Callout from './blocks/general/Callout.astro'
import Quote from './blocks/general/Quote.astro'
import TableWrapper from './blocks/general/TableWrapper.astro'
type Props = { node: RenderableTreeNode } type Props = { node: RenderableTreeNode | RenderableTreeNode[] }
const COMPONENTS: Record<string, AstroComponentFactory> = { const COMPONENTS: Record<string, AstroComponentFactory> = {
Callout,
Figure, Figure,
TableWrapper,
Quote,
Image: ContentImage, Image: ContentImage,
} }
const { node } = Astro.props const { node } = Astro.props
const Element = Markdoc.Tag.isTag(node) ? node.name : null const isTag = Markdoc.Tag.isTag(node)
const Element = isTag ? (COMPONENTS[node.name] ?? node.name) : null
--- ---
{Array.isArray(node) ? ( {Array.isArray(node) ? (
node.map((child) => <Astro.self node={child} />) node.map((child) => <Astro.self node={child} />)
) : Markdoc.Tag.isTag(node) && Element ? ( ) : isTag && Element ? (
<Element {...node.attributes}> <Element {...node.attributes}>
{node.children.map((child) => ( {node.children.map((child) => (
<Astro.self node={child} /> <Astro.self node={child} />

View File

@@ -0,0 +1,17 @@
---
import type { CalloutType } from '@/lib/constants/blocks/notes'
type Props = {
type?: CalloutType
title?: string
}
const { type = 'default', title } = Astro.props
---
<aside class:list={['callout', `callout--${type}`]}>
{title && <p class="callout__title">{title}</p>}
<div class="callout__body">
<slot />
</div>
</aside>

View File

@@ -0,0 +1,23 @@
---
import Node from '@/components/markdoc/Node.astro'
import type { Footnote } from '@/lib/markdoc/types'
type Props = { footnotes: Footnote[] }
const { footnotes } = Astro.props
---
{footnotes.length > 0 && (
<section class="footnotes">
<h2 class="footnotes__title">Notes</h2>
<ol>
{footnotes.map((footnote) => (
<li id={`fn-${footnote.n}`}>
<Node node={footnote.children} />{' '}
<a href={`#fnref-${footnote.n}`} class="footnotes__back" aria-label="Back to text">
↩
</a>
</li>
))}
</ol>
</section>
)}

View File

@@ -0,0 +1,22 @@
---
type Props = {
cite?: string
source?: string
href?: string
}
const { cite, source, href } = Astro.props
const label = source || href
---
<figure class="quote">
<blockquote cite={href}>
<slot />
</blockquote>
{(cite || label) && (
<figcaption class="quote__citation">
{cite && <span class="quote__who">{cite}</span>}
{label && <cite class="quote__source">{href ? <a href={href}>{label}</a> : label}</cite>}
</figcaption>
)}
</figure>

View File

@@ -0,0 +1,24 @@
---
import Node from '@/components/markdoc/Node.astro'
import type { Sidenote } from '@/lib/markdoc/types'
type Props = { sidenotes: Sidenote[] }
const { sidenotes } = Astro.props
---
{sidenotes.map((sidenote) => (
<aside
class:list={['sidenote', `sidenote--${sidenote.type}`]}
id={`sn-${sidenote.n}`}
style={`position-anchor: --sn-${sidenote.n}`}
role="note"
>
<a class="sidenote__marker" href={`#snref-${sidenote.n}`} aria-label="Back to text">
{sidenote.marker}
</a>
<div class="sidenote__note">
<Node node={sidenote.children} />
</div>
</aside>
))}

View File

@@ -0,0 +1,15 @@
---
import type { TableVariant } from '@/lib/constants/blocks/table'
type Props = {
variant?: TableVariant
caption?: string
}
const { variant = 'default', caption } = Astro.props
---
<figure class:list={['table-wrapper', `table-wrapper--${variant}`]}>
<slot />
{caption && <figcaption>{caption}</figcaption>}
</figure>

View File

@@ -0,0 +1,19 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { NOTE_TYPES } from '@/lib/constants/blocks/notes'
export const callout = wrapper({
label: 'Callout',
description: 'An aside around any content',
schema: {
type: fields.select({
label: 'Type',
options: NOTE_TYPES,
defaultValue: 'default',
}),
title: fields.text({
label: 'Title',
}),
},
})

View File

@@ -0,0 +1,16 @@
import { fields } from '@keystatic/core'
import { inline } from '@keystatic/core/content-components'
export const footnote = inline({
label: 'Footnote',
description: 'Numbered automatically; the note is collected at the end of the article',
schema: {
note: fields.text({
label: 'Note',
multiline: true,
validation: {
isRequired: true,
},
}),
},
})

View File

@@ -1,5 +1,17 @@
import { callout } from './callout'
import { figure } from './figure' import { figure } from './figure'
import { footnote } from './footnote'
import { quote } from './quote'
import { sidenote } from './sidenote'
import { tableWrapper } from './table-wrapper'
import { xref } from './xref'
export const generalBlocks = (collection: string) => ({ export const generalBlocks = (collection: string) => ({
figure: figure(collection), figure: figure(collection),
callout,
footnote,
sidenote,
tableWrapper,
quote,
xref: xref(),
}) })

View File

@@ -0,0 +1,17 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
export const quote = wrapper({
label: 'Quote',
schema: {
cite: fields.text({
label: 'Cite',
}),
source: fields.text({
label: 'Source',
}),
href: fields.url({
label: 'Source Link',
}),
},
})

View File

@@ -0,0 +1,33 @@
import { fields } from '@keystatic/core'
import { inline } from '@keystatic/core/content-components'
import { NOTE_TYPES } from '@/lib/constants/blocks/notes'
export const sidenote = inline({
label: 'Sidenote',
schema: {
type: fields.select({
label: 'Type',
options: NOTE_TYPES,
defaultValue: 'default',
}),
marker: fields.text({
label: 'Marker',
description: 'One or two characters, e.g. * † ‡ a.',
defaultValue: '▾',
validation: {
isRequired: true,
length: {
max: 3,
},
},
}),
note: fields.text({
label: 'Note',
multiline: true,
validation: {
isRequired: true,
},
}),
},
})

View File

@@ -0,0 +1,19 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { TABLE_VARIANTS } from '@/lib/constants/blocks/table'
export const tableWrapper = wrapper({
label: 'Table Wrapper',
description: 'Styling and caption for one table',
schema: {
variant: fields.select({
label: 'Variant',
options: TABLE_VARIANTS,
defaultValue: 'default',
}),
caption: fields.text({
label: 'Caption',
}),
},
})

View File

@@ -0,0 +1,23 @@
import { fields } from '@keystatic/core'
import { mark } from '@keystatic/core/content-components'
import { linkIcon } from '@keystar/ui/icon/icons/linkIcon'
import { ARTICLES } from '@/lib/constants/collections'
export const xref = (collection: string = ARTICLES) =>
mark({
label: 'Internal link',
icon: linkIcon,
tag: 'a',
schema: {
reference: fields.relationship({
label: 'Reference',
collection,
validation: { isRequired: true },
}),
anchor: fields.text({
label: 'Heading id',
}),
},
})

View File

@@ -1,5 +1,7 @@
--- ---
import DevBanner from '@/components/dev/DevBanner.astro' import DevBanner from '@/components/dev/DevBanner.astro'
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
import SidenoteList from '@/components/markdoc/blocks/general/SidenoteList.astro'
import Node from '@/components/markdoc/Node.astro' import Node from '@/components/markdoc/Node.astro'
import Figure from '@/components/media/Figure.astro' import Figure from '@/components/media/Figure.astro'
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
@@ -35,5 +37,7 @@ const { article, rendered } = Astro.props
<div class="prose"> <div class="prose">
<Node node={rendered.tree} /> <Node node={rendered.tree} />
</div> </div>
<SidenoteList sidenotes={rendered.sidenotes} />
<FootnoteList footnotes={rendered.footnotes} />
</article> </article>
</BaseLayout> </BaseLayout>

View File

@@ -0,0 +1,12 @@
export const NOTE_TYPES = [
{
label: 'Default',
value: 'default',
},
{
label: 'Example',
value: 'example',
},
] as const
export type CalloutType = (typeof NOTE_TYPES)[number]['value']

View File

@@ -0,0 +1,16 @@
export const TABLE_VARIANTS = [
{
label: 'Default',
value: 'default',
},
{
label: 'Random',
value: 'random',
},
{
label: 'Icon',
value: 'icon',
},
]
export type TableVariant = (typeof TABLE_VARIANTS)[number]['value']

View File

@@ -2,8 +2,8 @@ import Markdoc from '@markdoc/markdoc'
import { createDiagnostics } from '../diagnostics' import { createDiagnostics } from '../diagnostics'
import { createNodes } from './nodes' import { createNodes } from './nodes'
import { tags } from './tags' import { createTags } from './tags'
import type { NodeState, Renderable, Rendered } from './types' import type { NodeState, Renderable, Rendered, ResolveLink } from './types'
const LEVEL = { const LEVEL = {
critical: 'error', critical: 'error',
@@ -11,25 +11,37 @@ const LEVEL = {
warning: 'warning', warning: 'warning',
} as const } as const
export async function render({ collection, id, content }: Renderable): Promise<Rendered> { export async function render(
{ collection, id, content }: Renderable,
resolveLink: ResolveLink,
): Promise<Rendered> {
const { node } = await content() const { node } = await content()
const state: NodeState = { const state: NodeState = {
headings: [], headings: [],
ids: new Set(), ids: new Set(),
footnotes: [],
sidenotes: [],
markers: new Set(),
problems: [],
} }
const config = { const config = {
nodes: createNodes(state), nodes: createNodes(state),
tags, tags: createTags(state, resolveLink),
} }
const problems = createDiagnostics() const problems = createDiagnostics()
for (const { lines, error } of Markdoc.validate(node, config)) { for (const { lines, error } of Markdoc.validate(node, config)) {
const level = LEVEL[error.level as keyof typeof LEVEL] const level = LEVEL[error.level as keyof typeof LEVEL]
if (level) problems[level](collection, id, `Line ${lines[0] + 1} : ${error.message}`) if (level) problems[level](collection, id, `Text line ${lines[0] + 1}: ${error.message}`)
} }
const tree = Markdoc.transform(node, config)
for (const { level, message } of state.problems) problems[level](collection, id, message)
return { return {
tree: Markdoc.transform(node, config), tree,
headings: state.headings, headings: state.headings,
footnotes: state.footnotes,
sidenotes: state.sidenotes,
diagnostics: problems.list, diagnostics: problems.list,
} }
} }

17
src/lib/markdoc/notes.ts Normal file
View File

@@ -0,0 +1,17 @@
import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc'
const unwrap: Schema = { transform: (node, config) => node.transformChildren(config) }
const INLINE = {
nodes: {
...Markdoc.nodes,
document: unwrap,
paragraph: unwrap,
},
tags: {},
}
export function inlineOf(text: string): RenderableTreeNode[] {
const rendered = Markdoc.transform(Markdoc.parse(text), INLINE)
return Array.isArray(rendered) ? rendered : [rendered]
}

View File

@@ -1,12 +1,113 @@
import { fields } from '@keystatic/core' import { fields } from '@keystatic/core'
import Markdoc, { type Node, type Schema } from '@markdoc/markdoc'
import { generalBlocks } from '@/keystatic/blocks/general' import { generalBlocks } from '@/keystatic/blocks/general'
export const tags = fields.markdoc.createMarkdocConfig({ import { ARTICLES } from '../constants/collections'
components: generalBlocks('articles'), import { inlineOf } from './notes'
import type { NodeState, ResolveLink } from './types'
const { Tag } = Markdoc
const generated =
fields.markdoc.createMarkdocConfig({
components: generalBlocks(ARTICLES),
render: { render: {
tags: { tags: {
callout: 'Callout',
figure: 'Figure', figure: 'Figure',
tableWrapper: 'TableWrapper',
quote: 'Quote',
}, },
}, },
}).tags }).tags ?? {}
const text = (value: unknown) => String(value ?? '')
export function createTags(state: NodeState, resolveLink: ResolveLink): Record<string, Schema> {
return {
...generated,
footnote: {
...generated.footnote,
transform(node) {
const n = state.footnotes.length + 1
state.footnotes.push({ n, children: inlineOf(text(node.attributes.note)) })
return new Tag('sup', { class: 'footnote-ref' }, [
new Tag('a', { href: `#fn-${n}`, id: `fnref-${n}` }, [String(n)]),
])
},
},
sidenote: {
...generated.sidenote,
transform(node, config) {
const { marker, type } = node.transformAttributes(config) as Record<string, string>
const noteType = type || 'default'
if (state.markers.has(marker))
state.problems.push({
level: 'warning',
message: `Two sidenotes use the marker "${marker}".`,
})
state.markers.add(marker)
const n = state.sidenotes.length + 1
state.sidenotes.push({
n,
marker,
type: noteType,
children: inlineOf(text(node.attributes.note)),
})
return new Tag(
'a',
{
class: 'sidenote-ref',
id: `snref-${n}`,
href: `#sn-${n}`,
style: `anchor-name: --sn-${n}`,
},
[marker],
)
},
},
xref: {
...generated.xref,
transform(node: Node, config) {
const id = text(node.attributes.reference)
const anchor = text(node.attributes.anchor)
const children = node.transformChildren(config)
const target = resolveLink(id)
if (!target) {
state.problems.push({ level: 'error', message: `Link to "${id}": no such article.` })
return children
}
if (!target.path) {
state.problems.push({
level: 'warning',
message: `Link to "${id}": that article has no page yet, so the link is plain text.`,
})
return children
}
return new Tag(
'a',
{ href: anchor ? `${target.path}#${anchor}` : target.path },
children,
)
},
},
tableWrapper: {
...generated.tableWrapper,
validate(node) {
const tables = node.children.filter((child) => child.tag === 'table').length
return tables === 1
? []
: [
{
id: 'table-wrapper-children',
level: 'warning' as const,
message: `A table wrapper should hold exactly one table, this one holds ${tables}.`,
},
]
},
},
}
}

View File

@@ -1,6 +1,6 @@
import type { Node, RenderableTreeNode } from '@markdoc/markdoc' import type { Node, RenderableTreeNode } from '@markdoc/markdoc'
import type { Diagnostic } from '../diagnostics/types' import type { Diagnostic, Level } from '../diagnostics/types'
export type Heading = { export type Heading = {
id: string id: string
@@ -8,14 +8,41 @@ export type Heading = {
text: string text: string
} }
export type Footnote = {
n: number
children: RenderableTreeNode[]
}
export type Sidenote = {
n: number
marker: string
type: string
children: RenderableTreeNode[]
}
export type Problem = {
level: Level
message: string
}
export type NodeState = { export type NodeState = {
headings: Heading[] headings: Heading[]
ids: Set<string> ids: Set<string>
footnotes: Footnote[]
sidenotes: Sidenote[]
markers: Set<string>
problems: Problem[]
} }
export type LinkTarget = { path: string | null } | undefined
export type ResolveLink = (id: string) => LinkTarget
export type Rendered = { export type Rendered = {
tree: RenderableTreeNode tree: RenderableTreeNode
headings: Heading[] headings: Heading[]
footnotes: Footnote[]
sidenotes: Sidenote[]
diagnostics: Diagnostic[] diagnostics: Diagnostic[]
} }

View File

@@ -69,6 +69,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
pages, pages,
tags, tags,
byPath, byPath,
known: new Set(content.articles.keys()),
roots, roots,
diagnostics: [...content.diagnostics, ...problems.list], diagnostics: [...content.diagnostics, ...problems.list],
} }

10
src/lib/site/links.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { LinkTarget } from '../markdoc/types'
import type { Site } from './types'
export const linkTo =
(site: Site) =>
(id: string): LinkTarget => {
const article = site.articles.get(id)
if (article) return { path: article.path }
return site.known.has(id) ? { path: null } : undefined
}

View File

@@ -33,6 +33,7 @@ export type Site = {
pages: Map<string, Page> pages: Map<string, Page>
tags: Map<string, Tag> tags: Map<string, Tag>
byPath: Map<string, Routed> byPath: Map<string, Routed>
known: Set<string>
roots: Article[] roots: Article[]
diagnostics: Diagnostic[] diagnostics: Diagnostic[]
} }

View File

@@ -6,10 +6,11 @@ import PageLayout from '@/layouts/general/PageLayout.astro'
import { assertNoErrors } from '@/lib/diagnostics/report' import { assertNoErrors } from '@/lib/diagnostics/report'
import { render } from '@/lib/markdoc' import { render } from '@/lib/markdoc'
import { getSite } from '@/lib/site' import { getSite } from '@/lib/site'
import { linkTo } from '@/lib/site/links'
export const getStaticPaths = (async () => { export const getStaticPaths = (async () => {
const site = await getSite() const site = await getSite()
if (import.meta.env.PROD) assertNoErrors if (import.meta.env.PROD) assertNoErrors(site.diagnostics)
return [...site.byPath.keys()].map((path) => ({ params: { path: path.slice(1) } })) return [...site.byPath.keys()].map((path) => ({ params: { path: path.slice(1) } }))
}) satisfies GetStaticPaths }) satisfies GetStaticPaths
@@ -17,8 +18,8 @@ const site = await getSite()
const node = site.byPath.get(`/${Astro.params.path}`) const node = site.byPath.get(`/${Astro.params.path}`)
if (!node) return new Response(null, { status: 404 }) if (!node) return new Response(null, { status: 404 })
const rendered = await render(node) const rendered = await render(node, linkTo(site))
if (import.meta.env.PROD) assertNoErrors(site.diagnostics) if (import.meta.env.PROD) assertNoErrors(rendered.diagnostics)
--- ---
{node.collection === 'articles' ? ( {node.collection === 'articles' ? (

View File

@@ -1,7 +1,7 @@
--- ---
import type { GetStaticPaths } from 'astro' import type { GetStaticPaths } from 'astro'
import TagLayout from '@/layouts/TagLayout.astro' import TagLayout from '@/layouts/general/TagLayout.astro'
import { getSite } from '@/lib/site' import { getSite } from '@/lib/site'
export const getStaticPaths = (async () => { export const getStaticPaths = (async () => {