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

@@ -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 { createNodes } from './nodes'
import { tags } from './tags'
import type { NodeState, Renderable, Rendered } from './types'
import { createTags } from './tags'
import type { NodeState, Renderable, Rendered, ResolveLink } from './types'
const LEVEL = {
critical: 'error',
@@ -11,25 +11,37 @@ const LEVEL = {
warning: 'warning',
} 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 state: NodeState = {
headings: [],
ids: new Set(),
footnotes: [],
sidenotes: [],
markers: new Set(),
problems: [],
}
const config = {
nodes: createNodes(state),
tags,
tags: createTags(state, resolveLink),
}
const problems = createDiagnostics()
for (const { lines, error } of Markdoc.validate(node, config)) {
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 {
tree: Markdoc.transform(node, config),
tree,
headings: state.headings,
footnotes: state.footnotes,
sidenotes: state.sidenotes,
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 Markdoc, { type Node, type Schema } from '@markdoc/markdoc'
import { generalBlocks } from '@/keystatic/blocks/general'
export const tags = fields.markdoc.createMarkdocConfig({
components: generalBlocks('articles'),
render: {
tags: {
figure: 'Figure',
import { ARTICLES } from '../constants/collections'
import { inlineOf } from './notes'
import type { NodeState, ResolveLink } from './types'
const { Tag } = Markdoc
const generated =
fields.markdoc.createMarkdocConfig({
components: generalBlocks(ARTICLES),
render: {
tags: {
callout: 'Callout',
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 { Diagnostic } from '../diagnostics/types'
import type { Diagnostic, Level } from '../diagnostics/types'
export type Heading = {
id: string
@@ -8,14 +8,41 @@ export type Heading = {
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 = {
headings: Heading[]
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 = {
tree: RenderableTreeNode
headings: Heading[]
footnotes: Footnote[]
sidenotes: Sidenote[]
diagnostics: Diagnostic[]
}

View File

@@ -69,6 +69,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
pages,
tags,
byPath,
known: new Set(content.articles.keys()),
roots,
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>
tags: Map<string, Tag>
byPath: Map<string, Routed>
known: Set<string>
roots: Article[]
diagnostics: Diagnostic[]
}