diff --git a/astro.config.mjs b/astro.config.mjs index 8cdf238..2b54224 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,6 +5,8 @@ import keystatic from '@keystatic/astro' import node from '@astrojs/node' import react from '@astrojs/react' +import { contentReload } from './src/integrations/content-reload' + const devOnly = (integration) => ({ name: `${integration.name}:dev-only`, hooks: { @@ -17,7 +19,7 @@ const devOnly = (integration) => ({ export default defineConfig({ site: 'https://dave-dmg.de', - integrations: [react(), devOnly(keystatic())], + integrations: [react(), devOnly(keystatic()), contentReload()], adapter: node({ mode: 'standalone', diff --git a/package.json b/package.json index 7b3bd12..41b0897 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@astrojs/react": "^6.0.0", "@keystatic/astro": "^6.0.0", "@keystatic/core": "^0.6.7", - "@markdoc/markdoc": "^0.5.10", + "@markdoc/markdoc": "^0.4.0", "astro": "^7.0.4", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c63a45..3b2d627 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.6.7 version: 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) '@markdoc/markdoc': - specifier: ^0.5.10 - version: 0.5.10(@types/react@19.3.0)(react@19.3.0) + specifier: ^0.4.0 + version: 0.4.0(@types/react@19.3.0)(react@19.3.0) astro: specifier: ^7.0.4 version: 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) @@ -995,18 +995,6 @@ packages: react: optional: true - '@markdoc/markdoc@0.5.10': - resolution: {integrity: sha512-5HwkG2cLG/ivffeJlxGmraJ9erYfsaYz9rjG1KCf46PLy+yhrBQWw26h69GewNUHbyKNW6FrXuDYgbl2kvIYdA==} - engines: {node: '>=14.7.0'} - peerDependencies: - '@types/react': '*' - react: '*' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -4399,13 +4387,6 @@ snapshots: '@types/react': 19.3.0 react: 19.3.0 - '@markdoc/markdoc@0.5.10(@types/react@19.3.0)(react@19.3.0)': - optionalDependencies: - '@types/linkify-it': 3.0.5 - '@types/markdown-it': 12.2.3 - '@types/react': 19.3.0 - react: 19.3.0 - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 diff --git a/src/components/dev/DevBanner.astro b/src/components/dev/DevBanner.astro new file mode 100644 index 0000000..6cce0cf --- /dev/null +++ b/src/components/dev/DevBanner.astro @@ -0,0 +1,47 @@ +--- +import type { Status } from '@/lib/constants/status' +import { forEntry, formatDiagnostics } from '@/lib/diagnostics/report' +import type { Diagnostic } from '@/lib/diagnostics/types' +import { getSite } from '@/lib/site' + +type Props = { + collection: string + id: string + status?: Status + diagnostics?: Diagnostic[] +} + +const { collection, id, status, diagnostics = [] } = Astro.props + +const NOTICE: Partial> = { + draft: 'Draft – not build in production', + unlisted: 'Unlisted – has page, but appears in no list', + archived: 'Archived - has page, but appears in no list', +} + +const site = import.meta.env.Dev ? await getSite() : null +const here = site ? [...forEntry(site.diagnostics, collection, id), ...diagnostics] : [] +const elsewhere = site ? site.diagnostics.filter((d) => !here.includes(d)) : [] +const notice = status ? NOTICE[status] : undefined +--- + +{site && (here.length || elsewhere.length || notice) && ( + +)} diff --git a/src/components/markdoc/Node.astro b/src/components/markdoc/Node.astro new file mode 100644 index 0000000..6c593d7 --- /dev/null +++ b/src/components/markdoc/Node.astro @@ -0,0 +1,20 @@ +--- +import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc' + +type Props = { node: RenderableTreeNode } + +const { node } = Astro.props +const Element = Markdoc.Tag.isTag(node) ? node.name : null +--- + +{Array.isArray(node) ? ( + node.map((child) => ) +) : Markdoc.Tag.isTag(node) && Element ? ( + + {node.children.map((child) => ( + + ))} + +) : typeof node === 'string' || typeof node === 'number' ? ( + node +) : null} diff --git a/src/integrations/content-reload.ts b/src/integrations/content-reload.ts new file mode 100644 index 0000000..12d32d1 --- /dev/null +++ b/src/integrations/content-reload.ts @@ -0,0 +1,18 @@ +import type { AstroIntegration } from 'astro' + +export const contentReload = (): AstroIntegration => ({ + name: 'content-reload', + hooks: { + 'astro:server:setup': ({ server }) => { + server.watcher.on('all', (_event, file) => { + if (!file.includes('/src/content')) return + for (const name of ['ssr', 'prerender']) + server.environments[name]?.hot.send('astro:content-changed', {}) + server.environments.client.hot.send({ + type: 'full-reload', + path: '*', + }) + }) + }, + }, +}) diff --git a/src/layouts/ArticleLayout.astro b/src/layouts/ArticleLayout.astro new file mode 100644 index 0000000..3ad5783 --- /dev/null +++ b/src/layouts/ArticleLayout.astro @@ -0,0 +1,30 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import Node from '@/components/markdoc/Node.astro' +import type { Rendered } from '@/lib/markdoc/types' +import type { Article } from '@/lib/site/types' + +import BaseLayout from './BaseLayout.astro' + +type Props = { + article: Article + rendered: Rendered +} + +const { article, rendered } = Astro.props +--- + + + +
+

{article.title}

+
+ +
+
+
diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro new file mode 100644 index 0000000..b66c9ad --- /dev/null +++ b/src/layouts/PageLayout.astro @@ -0,0 +1,29 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import Node from '@/components/markdoc/Node.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import type { Rendered } from '@/lib/markdoc/types' +import type { Page } from '@/lib/site/types' + +type Props = { + page: Page + rendered: Rendered +} + +const { page, rendered } = Astro.props +--- + + + +
+

{page.title}

+
+ +
+
+
diff --git a/src/layouts/TagLayout.astro b/src/layouts/TagLayout.astro new file mode 100644 index 0000000..ca72db2 --- /dev/null +++ b/src/layouts/TagLayout.astro @@ -0,0 +1,25 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import type { Tag } from '@/lib/site/types' + +type Props = { tag: Tag } + +const { tag } = Astro.props +--- + + + +
+

{tag.title}

+ {tag.description &&

{tag.description}

} +
    + {tag.articles.map((article) => ( +
  • + {article.title} +

    {article.summary.excerpt}

    +
  • + ))} +
+
+
diff --git a/src/lib/diagnostics/report.ts b/src/lib/diagnostics/report.ts index 2047bdc..0bdefd8 100644 --- a/src/lib/diagnostics/report.ts +++ b/src/lib/diagnostics/report.ts @@ -21,3 +21,6 @@ export function assertNoErrors(list: Diagnostic[]): void { const errors = list.filter((d) => d.level === 'error').length if (errors) throw new Error(`Content has ${errors} error(s):\n${formatDiagnostics(list)}`) } + +export const forEntry = (list: Diagnostic[], collection: string, id: string) => + list.filter((d) => d.collection === collection && d.id === id) diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts new file mode 100644 index 0000000..aeb4c1a --- /dev/null +++ b/src/lib/markdoc/index.ts @@ -0,0 +1,33 @@ +import Markdoc from '@markdoc/markdoc' + +import { createDiagnostics } from '../diagnostics' +import { createNodes } from './nodes' +import type { NodeState, Renderable, Rendered } from './types' + +const LEVEL = { + critical: 'error', + error: 'error', + warning: 'warning', +} as const + +export async function render({ collection, id, content }: Renderable): Promise { + const { node } = await content() + const state: NodeState = { + headings: [], + ids: new Set(), + } + const config = { + nodes: createNodes(state), + } + 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}`) + } + return { + tree: Markdoc.transform(node, config), + headings: state.headings, + diagnostics: problems.list, + } +} diff --git a/src/lib/markdoc/nodes.ts b/src/lib/markdoc/nodes.ts new file mode 100644 index 0000000..a5e7746 --- /dev/null +++ b/src/lib/markdoc/nodes.ts @@ -0,0 +1,49 @@ +import Markdoc, { type Node, type Schema } from '@markdoc/markdoc' + +import { slugify } from '../content/slug' +import type { NodeState } from './types' + +const { Tag } = Markdoc + +const textOf = (node: Node): string => + node.type === 'text' || node.type === 'code' + ? String(node.attributes.content ?? '') + : node.children.map(textOf).join('') + +function uniqueId(text: string, ids: Set): string { + const base = slugify(text) || 'section' + let id = base + for (let n = 2; ids.has(id); n++) id = `${base}-${n}` + ids.add(id) + return id +} + +export function createNodes(state: NodeState): Record { + return { + document: { + ...Markdoc.nodes.document, + transform: (node, config) => node.transformChildren(config), + }, + heading: { + ...Markdoc.nodes.heading, + transform(node, config) { + const level = Number(node.attributes.level) + const text = textOf(node) + const id = uniqueId(text, state.ids) + state.headings.push({ id, level, text }) + return new Tag(`h${level}`, { id }, node.transformChildren(config)) + }, + }, + table: { + ...Markdoc.nodes.table, + transform(node, config) { + const table = new Tag( + 'table', + node.transformAttributes(config), + node.transformChildren(config), + ) + return new Tag('div', { class: 'table-scroll', tabindex: 0 }, [table]) + }, + }, + } +} diff --git a/src/lib/markdoc/types.ts b/src/lib/markdoc/types.ts new file mode 100644 index 0000000..107b323 --- /dev/null +++ b/src/lib/markdoc/types.ts @@ -0,0 +1,26 @@ +import type { Node, RenderableTreeNode } from '@markdoc/markdoc' + +import type { Diagnostic } from '../diagnostics/types' + +export type Heading = { + id: string + level: number + text: string +} + +export type NodeState = { + headings: Heading[] + ids: Set +} + +export type Rendered = { + tree: RenderableTreeNode + headings: Heading[] + diagnostics: Diagnostic[] +} + +export type Renderable = { + collection: string + id: string + content: () => Promise<{ node: Node }> +} diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro new file mode 100644 index 0000000..d4318ec --- /dev/null +++ b/src/pages/[...path].astro @@ -0,0 +1,28 @@ +--- +import type { GetStaticPaths } from 'astro' + +import ArticleLayout from '@/layouts/ArticleLayout.astro' +import PageLayout from '@/layouts/PageLayout.astro' +import { assertNoErrors } from '@/lib/diagnostics/report' +import { render } from '@/lib/markdoc' +import { getSite } from '@/lib/site' + +export const getStaticPaths = (async () => { + const site = await getSite() + if (import.meta.env.PROD) assertNoErrors + return [...site.byPath.keys()].map((path) => ({ params: { path: path.slice(1) } })) +}) satisfies GetStaticPaths + +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(rendered.diagnostics) +--- + +{node.collection === 'articles' ? ( + +) : ( + +)} diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro new file mode 100644 index 0000000..a6e8b8a --- /dev/null +++ b/src/pages/tags/[tag].astro @@ -0,0 +1,17 @@ +--- +import type { GetStaticPaths } from 'astro' + +import TagLayout from '@/layouts/TagLayout.astro' +import { getSite } from '@/lib/site' + +export const getStaticPaths = (async () => { + const site = await getSite() + return [...site.tags.keys()].map((tag) => ({ params: { tag } })) +}) satisfies GetStaticPaths + +const site = await getSite() +const tag = site.tags.get(Astro.params.tag) +if (!tag) return new Response(null, { status: 404 }) +--- + +