Added layouts + routes

This commit is contained in:
2026-09-22 15:21:19 +02:00
parent 5cad56000c
commit 3f9574dce6
15 changed files with 331 additions and 23 deletions

View File

@@ -5,6 +5,8 @@ import keystatic from '@keystatic/astro'
import node from '@astrojs/node' import node from '@astrojs/node'
import react from '@astrojs/react' import react from '@astrojs/react'
import { contentReload } from './src/integrations/content-reload'
const devOnly = (integration) => ({ const devOnly = (integration) => ({
name: `${integration.name}:dev-only`, name: `${integration.name}:dev-only`,
hooks: { hooks: {
@@ -17,7 +19,7 @@ const devOnly = (integration) => ({
export default defineConfig({ export default defineConfig({
site: 'https://dave-dmg.de', site: 'https://dave-dmg.de',
integrations: [react(), devOnly(keystatic())], integrations: [react(), devOnly(keystatic()), contentReload()],
adapter: node({ adapter: node({
mode: 'standalone', mode: 'standalone',

View File

@@ -16,7 +16,7 @@
"@astrojs/react": "^6.0.0", "@astrojs/react": "^6.0.0",
"@keystatic/astro": "^6.0.0", "@keystatic/astro": "^6.0.0",
"@keystatic/core": "^0.6.7", "@keystatic/core": "^0.6.7",
"@markdoc/markdoc": "^0.5.10", "@markdoc/markdoc": "^0.4.0",
"astro": "^7.0.4", "astro": "^7.0.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"

23
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^0.6.7 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) 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': '@markdoc/markdoc':
specifier: ^0.5.10 specifier: ^0.4.0
version: 0.5.10(@types/react@19.3.0)(react@19.3.0) version: 0.4.0(@types/react@19.3.0)(react@19.3.0)
astro: astro:
specifier: ^7.0.4 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) 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: react:
optional: true 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': '@napi-rs/wasm-runtime@1.2.4':
resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -4399,13 +4387,6 @@ snapshots:
'@types/react': 19.3.0 '@types/react': 19.3.0
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)': '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies: dependencies:
'@emnapi/core': 1.11.1 '@emnapi/core': 1.11.1

View File

@@ -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<Record<Status, string>> = {
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) && (
<aside class="dev-banner">
{notice && <p class="notice">{notice}</p>}
{here.length > 0 && (
<ul>
{here.map((d) => (
<li class={d.level}>
{d.level === 'error' ? '✖' : '▲'} {d.message}
</li>
))}
</ul>
)}
{elsewhere.length > 0 && (
<details>
<summary>{elsewhere.length} problem(s) elsewhere</summary>
<pre>{formatDiagnostics(elsewhere)}</pre>
</details>
)}
</aside>
)}

View File

@@ -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) => <Astro.self node={child} />)
) : Markdoc.Tag.isTag(node) && Element ? (
<Element {...node.attributes}>
{node.children.map((child) => (
<Astro.self node={child} />
))}
</Element>
) : typeof node === 'string' || typeof node === 'number' ? (
node
) : null}

View File

@@ -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: '*',
})
})
},
},
})

View File

@@ -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
---
<BaseLayout title={article.title} description={article.summary.excerpt || undefined}>
<DevBanner
collection={article.collection}
id={article.id}
status={article.status}
diagnostics={rendered.diagnostics}
/>
<article class="article">
<h1>{article.title}</h1>
<div class="prose">
<Node node={rendered.tree} />
</div>
</article>
</BaseLayout>

View File

@@ -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
---
<BaseLayout title={page.title} description={page.summary.excerpt || undefined}>
<DevBanner
collection={page.collection}
id={page.id}
status={page.status}
diagnostics={rendered.diagnostics}
/>
<article class="page">
<h1>{page.title}</h1>
<div class="prose">
<Node node={rendered.tree} />
</div>
</article>
</BaseLayout>

View File

@@ -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
---
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
<DevBanner collection={tag.collection} id={tag.id} />
<section class="tag">
<h1>{tag.title}</h1>
{tag.description && <p>{tag.description}</p>}
<ul>
{tag.articles.map((article) => (
<li>
<a href={article.path}>{article.title}</a>
<p>{article.summary.excerpt}</p>
</li>
))}
</ul>
</section>
</BaseLayout>

View File

@@ -21,3 +21,6 @@ export function assertNoErrors(list: Diagnostic[]): void {
const errors = list.filter((d) => d.level === 'error').length const errors = list.filter((d) => d.level === 'error').length
if (errors) throw new Error(`Content has ${errors} error(s):\n${formatDiagnostics(list)}`) 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)

33
src/lib/markdoc/index.ts Normal file
View File

@@ -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<Rendered> {
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,
}
}

49
src/lib/markdoc/nodes.ts Normal file
View File

@@ -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>): 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<string, Schema> {
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])
},
},
}
}

26
src/lib/markdoc/types.ts Normal file
View File

@@ -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<string>
}
export type Rendered = {
tree: RenderableTreeNode
headings: Heading[]
diagnostics: Diagnostic[]
}
export type Renderable = {
collection: string
id: string
content: () => Promise<{ node: Node }>
}

28
src/pages/[...path].astro Normal file
View File

@@ -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' ? (
<ArticleLayout article={node} rendered={rendered} />
) : (
<PageLayout page={node} rendered={rendered} />
)}

View File

@@ -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 })
---
<TagLayout tag={tag} />