diff --git a/package.json b/package.json index b6b5e64..f357484 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", + "@keystar/ui": "^0.10.0", "@keystatic/astro": "^6.0.0", "@keystatic/core": "^0.6.7", "@markdoc/markdoc": "^0.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb356c..32c014a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@astrojs/react': 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) + '@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': 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) diff --git a/src/components/markdoc/Node.astro b/src/components/markdoc/Node.astro index 5e2cac6..b25d4f4 100644 --- a/src/components/markdoc/Node.astro +++ b/src/components/markdoc/Node.astro @@ -6,20 +6,27 @@ import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc' import ContentImage from '@/components/media/ContentImage.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 = { + Callout, Figure, + TableWrapper, + Quote, Image: ContentImage, } 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) ? ( node.map((child) => ) -) : Markdoc.Tag.isTag(node) && Element ? ( +) : isTag && Element ? ( {node.children.map((child) => ( diff --git a/src/components/markdoc/blocks/general/Callout.astro b/src/components/markdoc/blocks/general/Callout.astro new file mode 100644 index 0000000..7250942 --- /dev/null +++ b/src/components/markdoc/blocks/general/Callout.astro @@ -0,0 +1,17 @@ +--- +import type { CalloutType } from '@/lib/constants/blocks/notes' + +type Props = { + type?: CalloutType + title?: string +} + +const { type = 'default', title } = Astro.props +--- + + diff --git a/src/components/markdoc/blocks/general/FootnoteList.astro b/src/components/markdoc/blocks/general/FootnoteList.astro new file mode 100644 index 0000000..58764f0 --- /dev/null +++ b/src/components/markdoc/blocks/general/FootnoteList.astro @@ -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 && ( +
+

Notes

+
    + {footnotes.map((footnote) => ( +
  1. + {' '} + + ↩ + +
  2. + ))} +
+
+)} diff --git a/src/components/markdoc/blocks/general/Quote.astro b/src/components/markdoc/blocks/general/Quote.astro new file mode 100644 index 0000000..651c9a9 --- /dev/null +++ b/src/components/markdoc/blocks/general/Quote.astro @@ -0,0 +1,22 @@ +--- +type Props = { + cite?: string + source?: string + href?: string +} + +const { cite, source, href } = Astro.props +const label = source || href +--- + +
+
+ +
+ {(cite || label) && ( +
+ {cite && {cite}} + {label && {href ? {label} : label}} +
+ )} +
diff --git a/src/components/markdoc/blocks/general/SidenoteList.astro b/src/components/markdoc/blocks/general/SidenoteList.astro new file mode 100644 index 0000000..3c736db --- /dev/null +++ b/src/components/markdoc/blocks/general/SidenoteList.astro @@ -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) => ( + +))} diff --git a/src/components/markdoc/blocks/general/TableWrapper.astro b/src/components/markdoc/blocks/general/TableWrapper.astro new file mode 100644 index 0000000..0727ee6 --- /dev/null +++ b/src/components/markdoc/blocks/general/TableWrapper.astro @@ -0,0 +1,15 @@ +--- +import type { TableVariant } from '@/lib/constants/blocks/table' + +type Props = { + variant?: TableVariant + caption?: string +} + +const { variant = 'default', caption } = Astro.props +--- + +
+ + {caption &&
{caption}
} +
diff --git a/src/keystatic/blocks/general/callout.ts b/src/keystatic/blocks/general/callout.ts new file mode 100644 index 0000000..8c6135d --- /dev/null +++ b/src/keystatic/blocks/general/callout.ts @@ -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', + }), + }, +}) diff --git a/src/keystatic/blocks/general/footnote.ts b/src/keystatic/blocks/general/footnote.ts new file mode 100644 index 0000000..ed8f7b2 --- /dev/null +++ b/src/keystatic/blocks/general/footnote.ts @@ -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, + }, + }), + }, +}) diff --git a/src/keystatic/blocks/general/index.ts b/src/keystatic/blocks/general/index.ts index 4739de9..02e8e26 100644 --- a/src/keystatic/blocks/general/index.ts +++ b/src/keystatic/blocks/general/index.ts @@ -1,5 +1,17 @@ +import { callout } from './callout' 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) => ({ figure: figure(collection), + callout, + footnote, + sidenote, + tableWrapper, + quote, + xref: xref(), }) diff --git a/src/keystatic/blocks/general/quote.ts b/src/keystatic/blocks/general/quote.ts new file mode 100644 index 0000000..bc5be3a --- /dev/null +++ b/src/keystatic/blocks/general/quote.ts @@ -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', + }), + }, +}) diff --git a/src/keystatic/blocks/general/sidenote.ts b/src/keystatic/blocks/general/sidenote.ts new file mode 100644 index 0000000..19232b0 --- /dev/null +++ b/src/keystatic/blocks/general/sidenote.ts @@ -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, + }, + }), + }, +}) diff --git a/src/keystatic/blocks/general/table-wrapper.ts b/src/keystatic/blocks/general/table-wrapper.ts new file mode 100644 index 0000000..1423843 --- /dev/null +++ b/src/keystatic/blocks/general/table-wrapper.ts @@ -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', + }), + }, +}) diff --git a/src/keystatic/blocks/general/xref.ts b/src/keystatic/blocks/general/xref.ts new file mode 100644 index 0000000..ab44cf9 --- /dev/null +++ b/src/keystatic/blocks/general/xref.ts @@ -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', + }), + }, + }) diff --git a/src/layouts/general/ArticleLayout.astro b/src/layouts/general/ArticleLayout.astro index 0637311..85ccec3 100644 --- a/src/layouts/general/ArticleLayout.astro +++ b/src/layouts/general/ArticleLayout.astro @@ -1,5 +1,7 @@ --- 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 Figure from '@/components/media/Figure.astro' import BaseLayout from '@/layouts/BaseLayout.astro' @@ -35,5 +37,7 @@ const { article, rendered } = Astro.props
+ + diff --git a/src/lib/constants/blocks/notes.ts b/src/lib/constants/blocks/notes.ts new file mode 100644 index 0000000..5b4e439 --- /dev/null +++ b/src/lib/constants/blocks/notes.ts @@ -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'] diff --git a/src/lib/constants/blocks/table.ts b/src/lib/constants/blocks/table.ts new file mode 100644 index 0000000..4fc37ed --- /dev/null +++ b/src/lib/constants/blocks/table.ts @@ -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'] diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts index a08a1c4..66441ff 100644 --- a/src/lib/markdoc/index.ts +++ b/src/lib/markdoc/index.ts @@ -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 { +export async function render( + { collection, id, content }: Renderable, + resolveLink: ResolveLink, +): Promise { 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, } } diff --git a/src/lib/markdoc/notes.ts b/src/lib/markdoc/notes.ts new file mode 100644 index 0000000..3533731 --- /dev/null +++ b/src/lib/markdoc/notes.ts @@ -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] +} diff --git a/src/lib/markdoc/tags.ts b/src/lib/markdoc/tags.ts index 9b45716..e58218d 100644 --- a/src/lib/markdoc/tags.ts +++ b/src/lib/markdoc/tags.ts @@ -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 { + 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 + 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}.`, + }, + ] + }, + }, + } +} diff --git a/src/lib/markdoc/types.ts b/src/lib/markdoc/types.ts index 107b323..9a118e5 100644 --- a/src/lib/markdoc/types.ts +++ b/src/lib/markdoc/types.ts @@ -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 + footnotes: Footnote[] + sidenotes: Sidenote[] + markers: Set + 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[] } diff --git a/src/lib/site/index.ts b/src/lib/site/index.ts index 236f892..193642c 100644 --- a/src/lib/site/index.ts +++ b/src/lib/site/index.ts @@ -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], } diff --git a/src/lib/site/links.ts b/src/lib/site/links.ts new file mode 100644 index 0000000..f8b026b --- /dev/null +++ b/src/lib/site/links.ts @@ -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 + } diff --git a/src/lib/site/types.ts b/src/lib/site/types.ts index 6fb393c..9b4a039 100644 --- a/src/lib/site/types.ts +++ b/src/lib/site/types.ts @@ -33,6 +33,7 @@ export type Site = { pages: Map tags: Map byPath: Map + known: Set roots: Article[] diagnostics: Diagnostic[] } diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro index a9af333..596c851 100644 --- a/src/pages/[...path].astro +++ b/src/pages/[...path].astro @@ -6,10 +6,11 @@ import PageLayout from '@/layouts/general/PageLayout.astro' import { assertNoErrors } from '@/lib/diagnostics/report' import { render } from '@/lib/markdoc' import { getSite } from '@/lib/site' +import { linkTo } from '@/lib/site/links' export const getStaticPaths = (async () => { 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) } })) }) satisfies GetStaticPaths @@ -17,8 +18,8 @@ const site = await getSite() const node = site.byPath.get(`/${Astro.params.path}`) if (!node) return new Response(null, { status: 404 }) -const rendered = await render(node) -if (import.meta.env.PROD) assertNoErrors(site.diagnostics) +const rendered = await render(node, linkTo(site)) +if (import.meta.env.PROD) assertNoErrors(rendered.diagnostics) --- {node.collection === 'articles' ? ( diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro index a6e8b8a..eef5ab9 100644 --- a/src/pages/tags/[tag].astro +++ b/src/pages/tags/[tag].astro @@ -1,7 +1,7 @@ --- import type { GetStaticPaths } from 'astro' -import TagLayout from '@/layouts/TagLayout.astro' +import TagLayout from '@/layouts/general/TagLayout.astro' import { getSite } from '@/lib/site' export const getStaticPaths = (async () => {