Added layouts + routes
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
47
src/components/dev/DevBanner.astro
Normal file
47
src/components/dev/DevBanner.astro
Normal 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>
|
||||
)}
|
||||
20
src/components/markdoc/Node.astro
Normal file
20
src/components/markdoc/Node.astro
Normal 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}
|
||||
18
src/integrations/content-reload.ts
Normal file
18
src/integrations/content-reload.ts
Normal 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: '*',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
30
src/layouts/ArticleLayout.astro
Normal file
30
src/layouts/ArticleLayout.astro
Normal 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>
|
||||
29
src/layouts/PageLayout.astro
Normal file
29
src/layouts/PageLayout.astro
Normal 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>
|
||||
25
src/layouts/TagLayout.astro
Normal file
25
src/layouts/TagLayout.astro
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
33
src/lib/markdoc/index.ts
Normal file
33
src/lib/markdoc/index.ts
Normal 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
49
src/lib/markdoc/nodes.ts
Normal 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
26
src/lib/markdoc/types.ts
Normal 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
28
src/pages/[...path].astro
Normal 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} />
|
||||
)}
|
||||
17
src/pages/tags/[tag].astro
Normal file
17
src/pages/tags/[tag].astro
Normal 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} />
|
||||
Reference in New Issue
Block a user