Blog #1

Merged
Dave merged 22 commits from blog into main 2026-09-30 16:48:38 +02:00
186 changed files with 8157 additions and 145 deletions

View File

@@ -15,6 +15,7 @@
"css": {
"lint": {
"propertyIgnoredDueToDisplay": "ignore",
"unknownAtRules": "ignore",
},
},
},

View File

@@ -2,13 +2,24 @@ import { defineConfig, fontProviders } from 'astro/config'
import keystatic from '@keystatic/astro'
import markdoc from '@astrojs/markdoc'
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: {
'astro:config:setup': async (params) => {
if (params.command !== 'dev') return
await integration.hooks['astro:config:setup']?.(params)
},
},
})
export default defineConfig({
site: 'https://dave-dmg.de',
integrations: [react(), markdoc(), keystatic()],
integrations: [react(), devOnly(keystatic()), contentReload()],
adapter: node({
mode: 'standalone',
@@ -18,11 +29,13 @@ export default defineConfig({
provider: fontProviders.google(),
name: 'Geist',
cssVariable: '--font-geist',
weights: ['100 900'],
},
{
provider: fontProviders.google(),
name: 'Geist Mono',
cssVariable: '--font-geist-mono',
weights: ['100 900'],
},
{
provider: fontProviders.google(),

View File

@@ -3,6 +3,7 @@ import prettier from 'eslint-config-prettier/flat'
import astro from 'eslint-plugin-astro'
import reactHooks from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default [
@@ -11,7 +12,10 @@ export default [
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs.recommended,
{
name: 'dave-dmg/node',
languageOptions: { globals: globals.node },
},
{
name: 'dave-dmg/unused',
files: ['**/*.{js,mjs,cjs,ts,tsx,astro}'],

View File

@@ -1,8 +1,21 @@
import { config } from '@keystatic/core'
import { contentCollections } from '@/keystatic/collections/general'
import { siteCollections } from '@/keystatic/collections/site'
import { ARTICLES, PAGES, RELEASES, TAGS } from '@/lib/constants/collections'
export default config({
storage: {
kind: 'local',
},
collections: {},
ui: {
navigation: {
Content: [ARTICLES, PAGES, TAGS],
Site: [RELEASES],
},
},
collections: {
...contentCollections,
...siteCollections,
},
})

View File

@@ -12,20 +12,24 @@
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdoc": "^2.0.1",
"@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",
"astro": "^7.0.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"sharp": "^0.35.4",
"shiki": "^4.4.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@csstools/postcss-global-data": "^4.0.0",
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^10.11.0",
@@ -33,6 +37,7 @@
"eslint-plugin-astro": "^3.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.12.0",
"postcss": "^8.5.28",
"postcss-custom-media": "^12.0.1",
"postcss-mixins": "^12.1.2",
@@ -44,7 +49,8 @@
"stylelint-config-html": "^2.0.0",
"stylelint-config-standard": "^40.0.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0"
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
},
"packageManager": "pnpm@10.15.0",
"engines": {

173
pnpm-lock.yaml generated
View File

@@ -11,30 +11,39 @@ importers:
.:
dependencies:
'@astrojs/markdoc':
specifier: ^2.0.1
version: 2.0.9(@types/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@19.3.0)
'@astrojs/node':
specifier: ^11.0.0
version: 11.1.6(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))
version: 11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))
'@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)
version: 6.0.6(@types/node@22.20.4)(@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)
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)(@types/node@22.20.4)(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)
'@keystatic/core':
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.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)
version: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
react:
specifier: ^19.2.8
version: 19.3.0
react-dom:
specifier: ^19.2.8
version: 19.3.0(react@19.3.0)
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@22.20.4)
shiki:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@astrojs/check':
specifier: ^0.9.10
@@ -48,6 +57,9 @@ importers:
'@ianvs/prettier-plugin-sort-imports':
specifier: ^4.7.1
version: 4.7.1(prettier@3.9.8)
'@types/node':
specifier: ^22.20.4
version: 22.20.4
'@types/react':
specifier: ^19.2.18
version: 19.3.0
@@ -69,6 +81,9 @@ importers:
eslint-plugin-unused-imports:
specifier: ^4.4.1
version: 4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0)(typescript@6.0.3))(eslint@10.11.0)(typescript@6.0.3))(eslint@10.11.0)
globals:
specifier: ^17.12.0
version: 17.12.0
postcss:
specifier: ^8.5.28
version: 8.5.28
@@ -105,6 +120,9 @@ importers:
typescript-eslint:
specifier: ^8.70.0
version: 8.70.0(eslint@10.11.0)(typescript@6.0.3)
vite:
specifier: ^8.3.0
version: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
packages:
@@ -208,12 +226,6 @@ packages:
prettier-plugin-astro:
optional: true
'@astrojs/markdoc@2.0.9':
resolution: {integrity: sha512-1w9Zg9czdfZXjBx9xv/eUy+65cOEr5AG6nsD36ZF3Eytir5Uip/0WpALLdOg6uN35wNbws+ZaibH5aAdC/6Jyg==}
engines: {node: '>=22.12.0'}
peerDependencies:
astro: ^7.0.0
'@astrojs/markdown-satteri@0.4.1':
resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==}
@@ -1001,18 +1013,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}
@@ -1259,6 +1259,9 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
'@types/node@22.20.4':
resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==}
'@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
@@ -1694,32 +1697,16 @@ packages:
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom-serializer@3.1.1:
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
engines: {node: '>=20.19.0'}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domelementtype@3.0.0:
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
engines: {node: '>=20.19.0'}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domhandler@6.0.1:
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
engines: {node: '>=20.19.0'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
domutils@4.0.2:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
dset@3.1.4:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
@@ -1750,10 +1737,6 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@8.1.0:
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
engines: {node: '>=20.19.0'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -2073,10 +2056,6 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
htmlparser2@9.1.0:
resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==}
@@ -3154,6 +3133,9 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@8.10.2:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
engines: {node: '>=22.19.0'}
@@ -3658,19 +3640,6 @@ snapshots:
- '@emnapi/runtime'
- typescript
'@astrojs/markdoc@2.0.9(@types/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@19.3.0)':
dependencies:
'@astrojs/internal-helpers': 0.11.0
'@astrojs/prism': 4.0.2
'@markdoc/markdoc': 0.5.10(@types/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)
esbuild: 0.28.2
github-slugger: 2.0.0
htmlparser2: 12.0.0
transitivePeerDependencies:
- '@types/react'
- react
'@astrojs/markdown-satteri@0.4.1':
dependencies:
'@astrojs/internal-helpers': 0.11.0
@@ -3678,10 +3647,10 @@ snapshots:
github-slugger: 2.0.0
satteri: 0.10.5
'@astrojs/node@11.1.6(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))':
'@astrojs/node@11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))':
dependencies:
'@astrojs/internal-helpers': 0.11.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)
astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
send: 1.2.1
server-destroy: 1.0.1
transitivePeerDependencies:
@@ -3691,17 +3660,17 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/react@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)':
'@astrojs/react@6.0.6(@types/node@22.20.4)(@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)':
dependencies:
'@astrojs/internal-helpers': 0.11.0
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
'@vitejs/plugin-react': 5.2.0(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))
'@vitejs/plugin-react': 5.2.0(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))
devalue: 5.9.4
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
ultrahtml: 1.7.0
vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
@@ -4190,8 +4159,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@img/colour@1.1.0':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
@@ -4350,12 +4318,12 @@ snapshots:
react-dom: 19.3.0(react@19.3.0)
react-stately: 3.48.0(react@19.3.0)
'@keystatic/astro@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)':
'@keystatic/astro@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)(@types/node@22.20.4)(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)':
dependencies:
'@babel/runtime': 7.29.7
'@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)
'@types/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)
astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
@@ -4442,13 +4410,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
@@ -4673,6 +4634,10 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@22.20.4':
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies:
'@types/react': 19.3.0
@@ -4803,7 +4768,7 @@ snapshots:
'@urql/core': 5.2.0(graphql@16.14.2)
wonka: 6.3.6
'@vitejs/plugin-react@5.2.0(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))':
'@vitejs/plugin-react@5.2.0(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -4811,7 +4776,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
@@ -4938,7 +4903,7 @@ snapshots:
- '@emnapi/runtime'
- supports-color
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):
astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1):
dependencies:
'@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@astrojs/internal-helpers': 0.11.0
@@ -4988,13 +4953,13 @@ snapshots:
unifont: 0.7.5
unstorage: 1.17.5(idb-keyval@6.3.0)
verkit: 0.4.1
vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
vitefu: 1.1.3(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))
vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
vitefu: 1.1.3(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
zod: 4.6.5
optionalDependencies:
sharp: 0.35.4
sharp: 0.35.4(@types/node@22.20.4)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -5212,36 +5177,18 @@ snapshots:
domhandler: 5.0.3
entities: 4.5.0
dom-serializer@3.1.1:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
entities: 8.1.0
domelementtype@2.3.0: {}
domelementtype@3.0.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domhandler@6.0.1:
dependencies:
domelementtype: 3.0.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils@4.0.2:
dependencies:
dom-serializer: 3.1.1
domelementtype: 3.0.0
domhandler: 6.0.1
dset@3.1.4: {}
ee-first@1.1.1: {}
@@ -5263,8 +5210,6 @@ snapshots:
entities@4.5.0: {}
entities@8.1.0: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -5613,13 +5558,6 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
domutils: 4.0.2
entities: 8.1.0
htmlparser2@9.1.0:
dependencies:
domelementtype: 2.3.0
@@ -6630,7 +6568,7 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.35.4:
sharp@0.35.4(@types/node@22.20.4):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -6661,7 +6599,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.4
'@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.4
optional: true
'@types/node': 22.20.4
shebang-command@2.0.0:
dependencies:
@@ -6939,6 +6877,8 @@ snapshots:
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
undici@8.10.2: {}
unicorn-magic@0.4.0: {}
@@ -7033,7 +6973,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1):
vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -7041,14 +6981,15 @@ snapshots:
rolldown: 1.2.9
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.20.4
esbuild: 0.28.2
fsevents: 2.3.3
sugarss: 5.0.1(postcss@8.5.28)
yaml: 2.9.1
vitefu@1.1.3(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)):
vitefu@1.1.3(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)):
optionalDependencies:
vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)
volar-service-css@0.0.71(@volar/language-service@2.4.28):
dependencies:

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dave-dmg.de/sitemap.xml

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -0,0 +1,54 @@
---
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 built in production',
unlisted: 'Unlisted – 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
const label = [status && status !== 'published' ? status : null, here.length || null]
.filter(Boolean)
.join(' · ')
---
{site && (here.length || elsewhere.length || notice) && (
<details class="dev-banner dark" open>
<summary class="dev-banner__bar">
Dev{label && <span class="dev-banner__crumb">{label}</span>}
</summary>
<div class="dev-banner__body">
{notice && <p class="dev-banner__notice">{notice}</p>}
{here.length > 0 && (
<ul class="dev-banner__list" role="list">
{here.map((d) => (
<li class:list={['dev-banner__item', `dev-banner__item--${d.level}`]}>
{d.message}
</li>
))}
</ul>
)}
{elsewhere.length > 0 && (
<details class="dev-banner__elsewhere">
<summary class="dev-banner__toggle">{elsewhere.length} elsewhere</summary>
<pre class="dev-banner__log">{formatDiagnostics(elsewhere)}</pre>
</details>
)}
</div>
</details>
)}

View File

@@ -0,0 +1,31 @@
---
import type { Activity } from '@/lib/site/types'
type Props = {
activity: Activity[]
}
const { activity } = Astro.props
const total = activity.length
const touched = activity.filter((cell) => cell.touched).length
const latest = activity.at(-1)?.touched ?? false
const summary = `Updated in ${touched} of ${total} recent release${total === 1 ? '' : 's'}${latest ? ', including the latest' : ''}`
---
{total > 0 && (
<p class="activity-strip">
<span class="activity-strip__summary">{summary}</span>
<span class="activity-strip__cells" aria-hidden="true">
{activity.map((cell, i) => (
<span
class:list={[
'activity-strip__cell',
cell.touched && 'activity-strip__cell--touched',
cell.touched && i === total - 1 && 'activity-strip__cell--latest',
]}
title={`v${cell.release.version}`}
/>
))}
</span>
</p>
)}

View File

@@ -0,0 +1,47 @@
---
import { CHANGE_TYPES } from '@/lib/constants/changelog'
import { toMilitaryDTG } from '@/lib/content/dates'
import { trailOf } from '@/lib/site/navigation'
import type { Change, Release } from '@/lib/site/types'
type Props = {
releases: Release[]
}
const LIMIT = 4
const { releases } = Astro.props
const labelOf = (kind: Change['kind']) =>
CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind
const workOf = (change: Change) => (change.article ? trailOf(change.article)[0].title : 'Site')
---
<ol class="changelog" role="list">
{releases.map((release) => {
const more = release.changes.length - LIMIT
return (
<li class="changelog__release">
<div class="changelog__head">
<h3 class="changelog__version">v{release.version}</h3>
<time class="changelog__date" datetime={release.date.toISOString()}>
{toMilitaryDTG(release.date)}
</time>
</div>
<ul class="changelog__changes" role="list">
{release.changes.slice(0, LIMIT).map((change) => (
<li class="changelog__change">
<span class="changelog__kind">{labelOf(change.kind)}</span>
<span class="changelog__work">{workOf(change)}</span>
<p class="changelog__text">
{change.article ? <a href={change.article.path}>{change.text}</a> : change.text}
</p>
</li>
))}
</ul>
{more > 0 && <p class="changelog__more">+{more} more</p>}
</li>
)
})}
</ol>

View File

@@ -0,0 +1,67 @@
---
import { SITE } from '@/lib/constants/site'
import { toMilitaryDTG } from '@/lib/content/dates'
import type { Release } from '@/lib/site/types'
type Props = {
works: number
entries: number
release?: Release
}
const { works, entries, release } = Astro.props
---
<header class="home-hero dark">
<div class="home-hero__inner">
<p class="home-hero__eyebrow">Index</p>
<h1 class="home-hero__title">{SITE.title}</h1>
<dl class="home-hero__stats">
<div class="home-hero__stat home-hero__stat--about">
<dt class="home-hero__label">
<span class="home-hero__index" aria-hidden="true">
01
</span>
About
</dt>
<dd class="home-hero__tagline">{SITE.tagline}</dd>
</div>
<div class="home-hero__stat">
<dt class="home-hero__label">
<span class="home-hero__index" aria-hidden="true">
02
</span>
Works
</dt>
<dd class="home-hero__value">{works}</dd>
<dd class="home-hero__note">Top-level</dd>
</div>
<div class="home-hero__stat">
<dt class="home-hero__label">
<span class="home-hero__index" aria-hidden="true">
03
</span>
Entries
</dt>
<dd class="home-hero__value">{entries}</dd>
<dd class="home-hero__note">Articles</dd>
</div>
<div class="home-hero__stat">
<dt class="home-hero__label">
<span class="home-hero__index" aria-hidden="true">
04
</span>
Latest release
</dt>
<dd class="home-hero__value">{release ? `v${release.version}` : '—'}</dd>
<dd class="home-hero__note">
{release ? (
<time datetime={release.date.toISOString()}>{toMilitaryDTG(release.date)}</time>
) : (
'None yet'
)}
</dd>
</div>
</dl>
</div>
</header>

View File

@@ -0,0 +1,21 @@
---
import { toRoman } from '@/lib/content/numerals'
type Props = {
index: number
title: string
}
const { index, title } = Astro.props
const id = `home-section-${index}`
---
<section class="home-section" aria-labelledby={id}>
<h2 id={id} class="home-section__heading">
<span class="home-section__number" aria-hidden="true">
§ {toRoman(index)}
</span>
{title}
</h2>
<slot />
</section>

View File

@@ -0,0 +1,44 @@
---
import { toRoman } from '@/lib/content/numerals'
import { activityOf } from '@/lib/site/activity'
import type { Article, Release } from '@/lib/site/types'
import ActivityStrip from './ActivityStrip.astro'
type Props = {
works: Article[]
releases: Release[]
}
const { works, releases } = Astro.props
---
<ol class="work-list" role="list">
{works.map((work, i) => (
<li class="work-list__item">
<span class="work-list__numeral" aria-hidden="true">
{toRoman(i + 1)}
</span>
<div class="work-list__main">
<h3 class="work-list__title">
<a href={work.path}>{work.title}</a>
</h3>
{work.summary.excerpt && <p class="work-list__excerpt">{work.summary.excerpt}</p>}
</div>
<div class="work-list__meta">
{work.children.length > 0 ? (
<ul class="work-list__sections" role="list">
{work.children.map((section) => (
<li>
<a href={section.path}>{section.title}</a>
</li>
))}
</ul>
) : (
<p class="work-list__sections">—</p>
)}
<ActivityStrip activity={activityOf(work, releases)} />
</div>
</li>
))}
</ol>

View File

@@ -0,0 +1,47 @@
---
import type { AstroComponentFactory } from 'astro/runtime/server/index.js'
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 Code from './blocks/general/Code.astro'
import Definition from './blocks/general/Definitions/Definition.astro'
import DefinitionList from './blocks/general/Definitions/DefinitionList.astro'
import Lead from './blocks/general/Lead.astro'
import Quote from './blocks/general/Quote.astro'
import Reveal from './blocks/general/Reveal.astro'
import TableWrapper from './blocks/general/TableWrapper.astro'
type Props = { node: RenderableTreeNode | RenderableTreeNode[] }
const COMPONENTS: Record<string, AstroComponentFactory> = {
Callout,
Reveal,
Code,
Figure,
TableWrapper,
Quote,
Definition,
DefinitionList,
Lead,
Image: ContentImage,
}
const { node } = Astro.props
const isTag = Markdoc.Tag.isTag(node)
const Element = isTag ? (COMPONENTS[node.name] ?? node.name) : null
---
{Array.isArray(node) ? (
node.map((child) => <Astro.self node={child} />)
) : isTag && 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,21 @@
---
import { NOTE_TYPES, type CalloutType } from '@/lib/constants/blocks/notes'
type Props = {
type?: CalloutType
title?: string
}
const { type = 'default', title } = Astro.props
const kind = NOTE_TYPES.find((option) => option.value === type)?.label ?? type
---
<aside class:list={['callout', `callout--${type}`]}>
<div class:list={['callout__kind', type !== 'optional' && 'dark']}>{kind}</div>
<div class="callout__body">
{title && <p class="callout__title">{title}</p>}
<slot />
</div>
</aside>

View File

@@ -0,0 +1,14 @@
---
type Props = {
code: string
lang?: string
html?: string
}
const { code, lang, html } = Astro.props
---
<div class="code" data-language={lang}>
{lang && <span class="code__lang">{lang}</span>}
{html ? <Fragment set:html={html} /> : <pre tabindex="0"><code>{code}</code></pre>}
</div>

View File

@@ -0,0 +1,23 @@
---
import TermEntry, { type SeeLink } from '@/components/terms/TermEntry.astro'
import type { TermKind } from '@/lib/constants/terms'
import { slugify } from '@/lib/content/slug'
type Props = {
term: string
kind?: TermKind
see?: string
}
const { term, kind = 'entry', see = '' } = Astro.props
const links: SeeLink[] = see
.split(',')
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ title: name, href: `#${slugify(name)}` }))
---
<TermEntry id={slugify(term)} title={term} kind={kind} see={links} ink>
<slot />
</TermEntry>

View File

@@ -0,0 +1,3 @@
<dl class="terms terms--cross">
<slot />
</dl>

View File

@@ -0,0 +1,25 @@
---
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 && (
<section class="footnotes">
<h2 class="footnotes__title">Notes</h2>
<ol>
{footnotes.map((footnote) => (
<li id={`fn-${footnote.n}`}>
<p class="footnotes__note">
<Node node={footnote.children} />{' '}
<a href={`#fnref-${footnote.n}`} class="footnotes__back" aria-label="Back to text">
↩
</a>
</p>
</li>
))}
</ol>
</section>
)}

View File

@@ -0,0 +1,3 @@
<div class="lead">
<slot />
</div>

View File

@@ -0,0 +1,22 @@
---
type Props = {
cite?: string
source?: string
href?: string
}
const { cite, source, href } = Astro.props
const label = source || href
---
<figure class="quote">
<blockquote cite={href}>
<slot />
</blockquote>
{(cite || label) && (
<figcaption class="quote__citation">
{cite && <span class="quote__who">{cite}</span>}
{label && <cite class="quote__source">{href ? <a href={href}>{label}</a> : label}</cite>}
</figcaption>
)}
</figure>

View File

@@ -0,0 +1,20 @@
---
type Props = { summary?: string }
const { summary = 'Spoiler' } = Astro.props
---
<details class="reveal">
<summary class="reveal__summary">
<span class="reveal__kind dark">Spoiler</span>
<span class="reveal__head">
<span class="reveal__title">{summary}</span>
<span class="reveal__action">Reveal</span>
</span>
</summary>
<div class="reveal__body">
<slot />
</div>
</details>

View File

@@ -0,0 +1,15 @@
---
import type { TableVariant } from '@/lib/constants/blocks/table'
type Props = {
variant?: TableVariant
caption?: string
}
const { variant = 'default', caption } = Astro.props
---
<figure class:list={['table-wrapper', `table-wrapper--${variant}`]}>
<slot />
{caption && <figcaption>{caption}</figcaption>}
</figure>

View File

@@ -0,0 +1,32 @@
---
import { Image } from 'astro:assets'
import { resolveImage } from '@/lib/images/resolve'
type Props = {
src: string
alt: string
eager?: boolean
widths?: number[]
sizes?: string
fit?: boolean
}
const { src, alt, eager, widths, sizes, fit } = Astro.props
const image = resolveImage(src)
if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`)
---
{image ? (
<Image
src={image}
alt={alt}
widths={widths}
sizes={sizes}
class:list={[fit && 'fit']}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : undefined}
/>
) : (
<span class="missing-image">Missing image: {src}</span>
)}

View File

@@ -0,0 +1,47 @@
---
import type { AspectRatio, FigureSize, ImageEffect } from '@/lib/constants/figure'
import ContentImage from './ContentImage.astro'
type Props = {
image: string
alt: string
caption?: string
credit?: string
size?: FigureSize
effect?: ImageEffect
ratio?: AspectRatio
}
const LAYOUT: Record<FigureSize, { widths: number[]; sizes: string }> = {
inline: { widths: [400, 800, 1200], sizes: '(min-width: 80rem) 37rem, 100vw' },
wide: { widths: [600, 1200, 1800], sizes: '(min-width: 80rem) 59rem, 100vw' },
}
const {
image,
alt,
caption,
credit,
size = 'inline',
effect = 'none',
ratio = 'original',
} = Astro.props
---
<figure
class:list={[
'figure',
`figure--${size}`,
effect !== 'none' && `effect--${effect}`,
ratio !== 'original' && `ratio--${ratio}`,
]}
>
<ContentImage src={image} alt={alt} {...LAYOUT[size]} />
{(caption || credit) && (
<figcaption class:list={['figure__caption', size !== 'inline' && 'dark']}>
{caption && <span class="figure__text">{caption}</span>}
{credit && <span class="figure__credit">{credit}</span>}
</figcaption>
)}
</figure>

View File

@@ -0,0 +1,26 @@
---
import type { Article } from '@/lib/site/types'
import ArticleCardItem from './ArticleCardItem.astro'
type Props = {
articles: Article[]
title?: string
}
const { articles, title } = Astro.props
const level = title ? 3 : 2
---
{articles.length > 0 && (
<section class="article-card-grid">
{title && <h2 class="article-card-grid__title">{title}</h2>}
<ul class="article-card-grid__list" role="list">
{articles.map((article) => (
<li class="article-card-grid__item">
<ArticleCardItem article={article} level={level} />
</li>
))}
</ul>
</section>
)}

View File

@@ -0,0 +1,41 @@
---
import ContentImage from '@/components/media/ContentImage.astro'
import { toMilitaryDTG } from '@/lib/content/dates'
import type { Article } from '@/lib/site/types'
type Props = {
article: Article
level?: 2 | 3
}
const { article, level = 2 } = Astro.props
const { cover, excerpt } = article.summary
const date = article.publishedAt
const Heading = level === 2 ? 'h2' : 'h3'
---
<article class:list={['article-card', cover.effect !== 'none' && `effect--${cover.effect}`]}>
{cover.image && (
<div class="article-card__cover">
<ContentImage
src={cover.image}
alt=""
widths={[400, 800, 1200]}
sizes="(min-width: 80rem) 24rem, (min-width: 48rem) 50vw, 100vw"
/>
</div>
)}
<div class="article-card__body">
<Heading class="article-card__title">
<a class="article-card__link" href={article.path}>
{article.title}
</a>
</Heading>
{excerpt && <p class="article-card__excerpt">{excerpt}</p>}
{date && (
<time class="article-card__date" datetime={date.toISOString()}>
{toMilitaryDTG(date)}
</time>
)}
</div>
</article>

View File

@@ -0,0 +1,22 @@
---
import type { Sequence } from '@/lib/site/navigation'
import type { Article } from '@/lib/site/types'
import RevisionCard from './RevisionCard.astro'
import SequenceNav from './SequenceNav.astro'
type Props = {
article: Article
sequence: Sequence | null
}
const { article, sequence } = Astro.props
const { title, changes } = article
---
{(changes.length > 0 || sequence) && (
<footer class="article-footer">
{changes.length > 0 && <RevisionCard title={title} changes={changes} />}
{sequence && <SequenceNav sequence={sequence} />}
</footer>
)}

View File

@@ -0,0 +1,38 @@
---
import { CHANGE_TYPES } from '@/lib/constants/changelog'
import { toMilitaryDTG } from '@/lib/content/dates'
import type { Change } from '@/lib/site/types'
type Props = {
title: string
changes: Change[]
}
const LIMIT = 5
const { title, changes } = Astro.props
const labelOf = (kind: Change['kind']) =>
CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind
---
<section class="revision-card" aria-labelledby="revisions-heading">
<div class="revision-card__head">
<h2 id="revisions-heading" class="revision-card__heading">
Revisions
</h2>
<p class="revision-card__subject">{title}</p>
</div>
<ol class="revision-card__list">
{changes.slice(0, LIMIT).map(({ kind, text, release }) => (
<li class="revision-card__row">
<span class="revision-card__version">v{release.version}</span>
<time class="revision-card__date" datetime={release.date.toISOString()}>
{toMilitaryDTG(release.date)}
</time>
<span class="revision-card__kind">{labelOf(kind)}</span>
<p class="revision-card__text">{text}</p>
</li>
))}
</ol>
</section>

View File

@@ -0,0 +1,66 @@
---
import { toRoman } from '@/lib/content/numerals'
import type { Sequence } from '@/lib/site/navigation'
type Props = {
sequence: Sequence
}
const { parent, siblings, position, previous, next } = Astro.props.sequence
---
<nav class="sequence-nav" aria-label={`${parent.title} sequence`}>
<div class="sequence-nav__step sequence-nav__step--previous">
{previous && (
<a class="sequence-nav__link" href={previous.path} rel="prev">
<span class="sequence-nav__label">
Previous
<span class="sequence-nav__numeral" aria-hidden="true">
{toRoman(position)}
</span>
</span>
<span class="sequence-nav__title">{previous.title}</span>
</a>
)}
</div>
<div class="sequence-nav__overview">
<p class="sequence-nav__label">
<a class="sequence-nav__parent" href={parent.path}>
{parent.title}
</a>
<span class="sequence-nav__count">
{toRoman(position + 1)} of {toRoman(siblings.length)}
</span>
</p>
<ol class="sequence-nav__squares">
{siblings.map((sibling, i) => (
<li>
<a
class="sequence-nav__square"
href={sibling.path}
aria-label={sibling.title}
title={sibling.title}
aria-current={i === position ? 'page' : undefined}
>
{toRoman(i + 1)}
</a>
</li>
))}
</ol>
</div>
<div class="sequence-nav__step sequence-nav__step--next">
{next && (
<a class="sequence-nav__link" href={next.path} rel="next">
<span class="sequence-nav__label">
<span class="sequence-nav__numeral" aria-hidden="true">
{toRoman(position + 2)}
</span>
Next
</span>
<span class="sequence-nav__title">{next.title}</span>
</a>
)}
</div>
</nav>

View File

@@ -0,0 +1,32 @@
---
import { toMilitaryDTG } from '@/lib/content/dates'
import { trailOf } from '@/lib/site/navigation'
import type { Article } from '@/lib/site/types'
import Cover from './Cover.astro'
import Meta from './Meta.astro'
import Overline from './Overline.astro'
import Title from './Title.astro'
type Props = {
article: Article
}
const { article } = Astro.props
const crumbs = trailOf(article)
const published = article.publishedAt
---
<header class="article-header">
<Overline
crumbs={crumbs}
stamp={published ? toMilitaryDTG(published) : 'No publication date set'}
datetime={published?.toISOString()}
/>
<div class="article-header__panel">
<Title title={article.title} subtitle={article.subtitle} />
<Meta article={article} />
<Cover cover={article.summary.cover} />
</div>
</header>

View File

@@ -0,0 +1,34 @@
---
import ContentImage from '@/components/media/ContentImage.astro'
import type { Article } from '@/lib/site/types'
type Props = {
cover: Article['summary']['cover']
}
const { image, alt, caption, credit, effect, ratio } = Astro.props.cover
---
{image && (
<figure
class:list={[
'article-header__cover',
effect !== 'none' && `effect--${effect}`,
ratio !== 'original' && `ratio--${ratio}`,
]}
>
<ContentImage
src={image}
alt={alt}
widths={[800, 1600, 2400]}
sizes="(min-width: 80rem) 74rem, 100vw"
eager
/>
{(caption || credit) && (
<figcaption class="article-header__caption dark">
{caption && <span>{caption}</span>}
{credit && <span class="article-header__credit">{credit}</span>}
</figcaption>
)}
</figure>
)}

View File

@@ -0,0 +1,75 @@
---
import StageBadge from '@/components/post/Header/StageBadge.astro'
import { toMilitaryDTG } from '@/lib/content/dates'
import { getSite } from '@/lib/site'
import type { Article } from '@/lib/site/types'
type Props = {
article: Article
}
const { article } = Astro.props
const site = await getSite()
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
const latest = article.changes[0]?.release.date
const updated = latest && article.publishedAt && latest > article.publishedAt ? latest : null
const date = updated ?? article.publishedAt
const dateLabel = updated ? 'Updated' : 'Published'
---
<dl class="article-header__meta">
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
02
</span>
State
</dt>
<dd>
<StageBadge stage={article.stage} />
</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
03
</span>
{dateLabel}
</dt>
<dd class="article-header__value">
{date ? <time datetime={date.toISOString()}>{toMilitaryDTG(date)}</time> : 'Undated'}
</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
04
</span>
Author
</dt>
<dd class="article-header__value">Dave Damage</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
05
</span>
Tags
</dt>
<dd class="article-header__value">
{tags.length > 0 ? (
<ul class="article-header__tags">
{tags.map((tag) => (
<li>
<a class="article-header__tag" href={tag.path} rel="tag">
{tag.title}
</a>
</li>
))}
</ul>
) : (
'None'
)}
</dd>
</div>
</dl>

View File

@@ -0,0 +1,95 @@
---
export type Crumb = {
title: string
path?: string
}
type Props = {
crumbs: Crumb[]
stamp?: string
datetime?: string
}
const { crumbs, stamp, datetime } = Astro.props
---
<div class="overline dark">
<div class="wrapper">
{crumbs.length > 1 && (
<nav aria-label="Breadcrumb" class="breadcrumbs">
<ol>
{crumbs.map((crumb, index) => (
<li>
{crumb.path && index < crumbs.length - 1 ? (
<a href={crumb.path}>{crumb.title}</a>
) : (
<span aria-current="page">{crumb.title}</span>
)}
</li>
))}
</ol>
</nav>
)}
{datetime ? (
<time class="stamp" datetime={datetime}>
<slot name="stamp">{stamp}</slot>
</time>
) : (
<span class="stamp">
<slot name="stamp">{stamp}</slot>
</span>
)}
</div>
</div>
<style>
.overline {
font-family: var(--font-mono);
font-size: var(--type--1);
line-height: 1;
color: var(--color-text-secondary);
letter-spacing: var(--tracking-0);
background-color: var(--color-surface-elevated-2);
}
.wrapper {
@mixin layout-wrapper;
@mixin py var(--size-4);
display: flex;
gap: var(--size-4);
align-items: baseline;
justify-content: space-between; /* breadcrumbs left, stamp right */
}
.breadcrumbs {
& ol {
display: flex;
gap: var(--size-2);
margin: 0;
padding: 0;
list-style: none;
& li {
&:not(:last-child) {
&::after {
content: '⟫' / '';
margin-inline-start: var(--size-2);
}
}
}
}
}
.stamp {
font-weight: 700;
&::before {
content: '⟫' / '';
margin-inline-end: var(--size-2);
}
}
</style>

View File

@@ -0,0 +1,28 @@
---
type Props = {
title: string
eyebrow?: string
standfirst?: string
count?: number
}
const { title, eyebrow, standfirst, count } = Astro.props
---
<header class="simple-header dark">
<div class="simple-header__inner">
<div class="simple-header__text">
{eyebrow && <p class="simple-header__eyebrow">{eyebrow}</p>}
<h1 class="simple-header__title">{title}</h1>
{standfirst && <p class="simple-header__standfirst">{standfirst}</p>}
</div>
{count !== undefined && (
<p class="simple-header__count">
<span aria-hidden="true">{String(count).padStart(2, '0')}</span>
<span class="simple-header__hidden">
{count} {count === 1 ? 'entry' : 'entries'}
</span>
</p>
)}
</div>
</header>

View File

@@ -0,0 +1,13 @@
---
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
type Props = {
stage: Stage
}
const { stage } = Astro.props
const label = STAGE_OPTIONS.find((option) => option.value === stage)?.label ?? stage
---
<span class:list={['stage-badge', `stage-badge--${stage}`]}>{label}</span>

View File

@@ -0,0 +1,17 @@
---
type Props = {
title: string
subtitle?: string
}
const { title, subtitle } = Astro.props
---
<div class="article-header__designation">
<p class="article-header__label" aria-hidden="true">
<span class="article-header__index">01</span>
Designation
</p>
<h1 class="article-header__title">{title}</h1>
{subtitle && <p class="article-header__subtitle">{subtitle}</p>}
</div>

View File

@@ -0,0 +1,99 @@
---
import type { Heading } from '@/lib/markdoc/types'
type Props = {
headings: Heading[]
min?: number
level?: number
}
const { headings, min = 3, level = 3 } = Astro.props
const entries = headings.filter((heading) => heading.level <= level)
---
{entries.length >= min && (
<nav class="toc" aria-labelledby="toc-title">
<h2 class="title" id="toc-title">
Contents
</h2>
<ol>
{entries.map((heading) => (
<li class:list={['entry', `entry--${heading.level}`]}>
<a href={`#${heading.id}`}>
<span class="n" aria-hidden="true" />
<span class="label">{heading.text}</span>
</a>
</li>
))}
</ol>
</nav>
)}
<style>
.toc {
@mixin border-t var(--border-card), solid, var(--color-border-strong);
counter-reset: toc;
position: sticky;
top: calc(var(--layout-header-height) + var(--size-10));
}
.title {
@mixin typo_label;
@mixin my var(--size-0);
@mixin py var(--size-4);
@mixin border-b var(--border-hairline), solid, var(--color-border-normal);
font-size: var(--type--2);
}
ol {
list-style: none;
}
a {
@mixin py var(--size-3);
@mixin border-b var(--border-hairline), solid, var(--color-border-subtle);
display: grid;
grid-template-columns: 4ch minmax(0, 1fr);
font-family: var(--font-mono);
font-size: var(--type--1);
line-height: var(--leading-2);
color: var(--color-text-primary);
text-decoration: none;
&:hover,
&:focus-visible {
background-color: var(--color-surface-hover);
}
}
.n {
color: var(--color-text-quiet);
}
.entry--2 {
counter-increment: toc;
& .n {
&::before {
content: counter(toc, upper-roman);
}
}
}
.entry--3 {
& a {
@mixin ps var(--size-8);
}
& .label {
color: var(--color-text-secondary);
}
& .n {
&::before {
content: '⟫';
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
---
import { COLLECTION_LABELS } from '@/lib/constants/collections'
import { toRoman } from '@/lib/content/numerals'
import { getSite } from '@/lib/site'
import { contextOf } from '@/lib/site/navigation'
import type { Article } from '@/lib/site/types'
type Props = {
article?: Article
}
const { article } = Astro.props
const site = await getSite()
const groups = contextOf(article ?? null, site.roots)
const count = groups.reduce((sum, group) => sum + group.entries.length, 0)
---
<site-finder class="finder">
<button
class="finder__trigger light"
type="button"
commandfor="finder"
command="show-modal"
aria-haspopup="dialog"
>
<span class="finder__prompt" aria-hidden="true">
❯
</span>
<span class="finder__placeholder">Navigate…</span>
<kbd class="finder__key" data-finder-shortcut>
⌘K
</kbd>
</button>
<dialog id="finder" class="finder__dialog" aria-labelledby="finder-title" closedby="any">
<div class="finder__bar">
<h2 id="finder-title" class="finder__title">
Register<span class="finder__crumb">Query</span>
</h2>
<button
class="finder__close"
type="button"
commandfor="finder"
command="close"
aria-label="Close"
>
Esc
</button>
</div>
<div class="finder__field">
<span class="finder__prompt" aria-hidden="true">
❯
</span>
<input
class="finder__input"
type="text"
role="combobox"
aria-label="Search articles"
aria-controls="finder-results"
aria-expanded="true"
aria-autocomplete="list"
placeholder="Where do you want to go?"
autocomplete="off"
spellcheck="false"
data-finder-input
autofocus
/>
</div>
<div class="finder__status">
<p class="finder__scope" data-finder-scope>
<span data-finder-mode>Context</span>
<span class="finder__crumb" data-finder-subject>
{article?.title ?? 'Index'}
</span>
</p>
<p class="finder__count" data-finder-count>
{String(count).padStart(2, '0')} entries
</p>
</div>
<div id="finder-results" class="finder__results" role="listbox" aria-label="Articles">
<div data-finder-context>
{groups.map((group, g) => {
const heading = group.heading?.title ?? 'Index'
return (
<div class="finder__group" role="group" aria-label={heading}>
<p class="finder__heading" aria-hidden="true">
<span class="finder__section">§ {toRoman(g + 1)}</span>
{heading}
</p>
{group.entries.map((entry, i) => (
<a
id={`finder-option-${g}-${i}`}
class="finder__row"
href={entry.path}
role="option"
tabindex="-1"
>
<span class="finder__type">{COLLECTION_LABELS[entry.collection]}</span>
<span class="finder__label">{entry.title}</span>
<span class="finder__path">{entry.path}</span>
</a>
))}
</div>
)
})}
</div>
<div data-finder-matches hidden></div>
<p class="finder__empty" data-finder-empty hidden>
No entries found
</p>
<template data-finder-row>
<a class="finder__row" role="option" tabindex="-1">
<span class="finder__type" data-field="type"></span>
<span class="finder__label" data-field="label"></span>
<span class="finder__path" data-field="path"></span>
</a>
</template>
</div>
<div class="finder__footer" aria-hidden="true">
<span class="finder__hint">
<kbd>↑</kbd>
<kbd>↓</kbd> Move
</span>
<span class="finder__hint">
<kbd>↵</kbd> Open
</span>
<span class="finder__hint">
<kbd data-finder-shortcut>⌘K</kbd> Toggle
</span>
</div>
</dialog>
</site-finder>
<script>
import '@/lib/finder/element'
</script>

View File

@@ -0,0 +1,39 @@
---
import { toMilitaryDTG } from '@/lib/content/dates'
import { getSite } from '@/lib/site'
const LINKED_PAGES = ['imprint', 'privacy-policy', 'colophon']
const site = await getSite()
const release = site.releases[0]
const pages = LINKED_PAGES.map((id) => site.pages.get(id)).filter((page) => page !== undefined)
const year = new Date().getUTCFullYear()
---
<footer class="site-footer dark">
<div class="site-footer__inner">
<p class="site-footer__copyright">© {year} Dave Damage</p>
{release && (
<p class="site-footer__release">
<span class="site-footer__version">v{release.version}</span>
<time datetime={release.date.toISOString()}>{toMilitaryDTG(release.date)}</time>
</p>
)}
<nav class="site-footer__nav" aria-label="Site">
<ul class="site-footer__links">
{pages.map((page) => (
<li>
<a class="site-footer__link" href={page.path}>
{page.title}
</a>
</li>
))}
<li>
<a class="site-footer__link site-footer__top" href="#top">
Top
</a>
</li>
</ul>
</nav>
</div>
</footer>

View File

@@ -0,0 +1,26 @@
---
import type { Article } from '@/lib/site/types'
import Finder from './Finder.astro'
type Props = {
article?: Article
}
const { article } = Astro.props
---
<header class="site-header dark">
<div class="site-header__inner">
<a class="site-header__home" href="/" aria-label="dave dmg, home">
<span class="site-header__logo" aria-hidden="true">
◬
</span>
<span class="site-header__name">
dave<span class="site-header__bracket">[</span>dmg
<span class="site-header__bracket">]</span>
</span>
</a>
<Finder article={article} />
</div>
</header>

View File

@@ -0,0 +1,42 @@
---
import { TERM_KINDS, type TermKind } from '@/lib/constants/terms'
export type SeeLink = {
title: string
href: string
}
type Props = {
id: string
title: string
kind: TermKind
see?: SeeLink[]
ink?: boolean
}
const { id, title, kind, see = [], ink } = Astro.props
const label = TERM_KINDS.find((option) => option.value === kind)?.label ?? kind
---
<dt class:list={['term', ink && 'dark']} id={id}>
<span class="term__name">{title}</span>
<span class="term__kind">{label}</span>
</dt>
<dd class="term__body">
<p class="term__definition">
<slot />
</p>
{see.length > 0 && (
<p class="term__see">
<span class="term__see-label">See</span>
{see.map((related) => (
<a class="term__link" href={related.href}>
{related.title}
</a>
))}
</p>
)}
</dd>

View File

@@ -1 +0,0 @@
export const collections = {}

View File

@@ -0,0 +1,15 @@
---
title: Advanced Warhammer Quest
status: draft
noindex: false
stage: stub
route:
index: 3
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T16:46:00.000Z
tags: []
---

View File

@@ -0,0 +1,20 @@
---
title: Calcination
subtitle: Where we burn the people
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 2
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,16 @@
---
title: Chainbreaker
subtitle: We're gonna break down the walls
status: draft
noindex: false
stage: stub
route:
index: 2
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T14:27:00.000Z
tags: []
---

View File

@@ -0,0 +1,20 @@
---
title: Coagulation
subtitle: Where we burn the politicians
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 6
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,20 @@
---
title: Conjunction
subtitle: Where we burn the state
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 5
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,20 @@
---
title: Dissolution
subtitle: Where we act
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 7
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,20 @@
---
title: Fermentation
subtitle: Where we burn the priests
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 3
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,212 @@
---
title: Prima Materia
subtitle: Where we lay the ground
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 1
sequence: false
summary:
cover:
image: /src/assets/content/articles/prima-materia/summary/cover/image.jpg
alt: The Great Day of His Wrath
credit: John Martin – The Great Day of His Wrath
effect: xeroxed
ratio: 21-9
published: 2026-09-21T12:35:00.000Z
tags:
- the-crucible
- alchemical-materialism
- genesis
---
{% lead %}
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This first paragraph is the lead, so it should be larger and carry the drop cap. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. A sentence with *emphasis*, **strong text**, `inline code`, ~~struck text~~ and an [external link](https://example.com).
{% /lead %}
## Heading two, long enough to wrap onto a second line
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur{% footnote note="A footnote with *emphasis* and a [link](https://example.com)." /%}. Excepteur sint occaecat cupidatat non proident{% sidenote type="default" marker="†" note="A sidenote in the margin, beside the line it belongs to." /%}.
### Heading three
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam.
#### Heading four
At vero eos et accusamus et iusto odio dignissimos ducimus.
## Lists
- First item
- Second item, with enough text to run past the end of a line and wrap underneath the marker
- Third item
- Nested item
- Another nested item
- Third level
1. First step
1. Second step
1. Sub-step
1. Another sub-step
1. Third step
## Quotes and rules
> A plain blockquote, no attribution.
>
> With a second paragraph.
{% quote
cite="Paracelsus"
source="Die dritte Defension"
href="https://example.com" %}
All things are poison, and nothing is without poison; the dosage alone makes it so a thing is not a poison.
{% /quote %}
---
## Callouts
{% callout type="default" title="TITA TITLE" %}
I AM DONE WITH THIS SHIT! SO COMPLETELY AND TOTALLY DONE!
{% /callout %}
{% callout type="optional" title="Note" %}
A default callout with a [link](https://example.com) and `code` inside it.
{% /callout %}
{% callout type="example" title="Worked example" %}
An example callout containing a list:
- one
- two
{% /callout %}
{% reveal summary="Spoiler" %}
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This first paragraph is the lead, so it should be larger and carry the drop cap. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. A sentence with *emphasis*, **strong text**, `inline code`, ~~struck text~~ and an [external link](https://example.com).
{% /reveal %}
## Code
```js
const crucible = { stage: 'prima-materia', heat: 3 }
for (const [key, value] of Object.entries(crucible)) {
console.log(`${key}: ${value}`)
}
```
## Tables
{% tableWrapper
variant="default"
caption="Stages of the Work"
columns=["rowheader", "key", "num", "num", "text"] %}
{% table %}
- Stage
- No
- Heat
- Hours
- Notes
---
- Prima Materia
- I
- 3
- 0.25
- The undifferentiated ground; nothing is named yet.
---
- Calcination
- II
- 7
- 1.50
- Burned down to what will not burn.
---
- Solutio
- III
- 5
- 12
- Dissolved back into flow; forms loosen.
---
- Tincture
- VII
- 10
- 120.75
- The finished colour, carried into everything it touches.
{% /table %}
{% /tableWrapper %}
## Images
{% figure
image="/src/assets/content/articles/prima-materia/john_martin_-_the_great_day_of_his_wrath_-_google_art_project.jpg"
alt="A placeholder image"
caption="An inline figure with a caption"
credit="Dave Damage"
effect="xeroxed"
ratio="16-9"
size="inline" /%}
Aliquam erat volutpat. Fusce sed velit at arcu tincidunt vulputate{% sidenote type="example" marker="‡" note="A second sidenote, typed as an example." /%}. Nam vitae lacus a nisl ultrices dictum.
Finally, a link to another article: {% xref reference="the-crucible" %}The Crucible{% /xref %}.
## Code
```ts
const crucible = { stage: 'prima-materia', heat: 3 }
function temper(work: typeof crucible): 'calcined' | 'raw' {
return work.heat > 5 ? 'calcined' : 'raw'
}
```
A fence with no language, which should render unhighlighted and with no label:
```
Prima materia — the undifferentiated ground.
Nothing is named yet.
```
A long line, which should scroll inside the block rather than widen the article:
```css
.prose :is(h2, h3, h4) + .table-wrapper thead th { border-block-end: var(--border-card) solid var(--color-border-strong); color: inherit; background-color: transparent; }
```
An unknown language, which should render plain and raise a warning in the dev banner:
```brainfuck
+[----->+++<]>+.
```
{% callout type="example" title="Code inside a block" %}
The frame should sit inside the callout body, not fight it:
```js
export const isTag = (node) => Markdoc.Tag.isTag(node)
```
{% /callout %}
## Spoiler
Duis aute irure dolor in reprehenderit, and then {% spoiler %}the prima materia was the vessel all along{% /spoiler %}, which changes the reading of everything above.
A spoiler long enough to wrap across a line break: {% spoiler %}Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua{% /spoiler %}. And text after it.
## Definitions
{% definitions %}
{% definition term="Prima Materia" kind="prime" see="Calcination, Solutio" %}
The undifferentiated ground. Nothing is named yet, which is precisely what makes it workable.
{% /definition %}
{% definition term="Calcination" kind="stage" see="Prima Materia" %}
Burned down to what will not burn.
{% /definition %}
{% definition term="Solutio" kind="stage" %}
Dissolved back into flow, so that forms loosen.
{% /definition %}
{% /definitions %}

View File

@@ -0,0 +1,15 @@
---
title: Stages
status: draft
noindex: false
stage: stub
route:
parent: the-crucible
sequence: true
summary:
cover:
effect: none
ratio: original
published: 2026-09-21T12:34:00.000Z
tags: []
---

View File

@@ -0,0 +1,20 @@
---
title: Sublimation
subtitle: Where we burn the witches
status: draft
noindex: false
stage: stub
route:
parent: stages
index: 4
sequence: false
summary:
cover:
effect: none
ratio: original
published: 2026-09-30T12:27:00.000Z
tags:
- alchemical-materialism
- genesis
- the-crucible
---

View File

@@ -0,0 +1,20 @@
---
title: The Crucible
status: draft
noindex: false
stage: stub
route:
index: 1
sequence: false
summary:
excerpt: >-
Where we explore the crucible – a materialistic alchemist Worldbuilding
Framework
cover:
effect: none
ratio: original
published: 2026-09-21T12:33:00.000Z
tags:
- the-crucible
---
hmm, do you even sharp?

View File

@@ -0,0 +1,7 @@
---
title: Colophon
status: draft
noindex: false
route: {}
summary: {}
---

View File

@@ -0,0 +1,8 @@
---
title: Imprint
status: draft
noindex: false
route: {}
summary: {}
---
Set in Blaka{% footnote note="Jdajhdasljkh" /%}.

View File

@@ -0,0 +1,7 @@
---
title: Privacy Policy
status: draft
noindex: false
route: {}
summary: {}
---

View File

@@ -0,0 +1,3 @@
{
"title": "Alchemical Materialism"
}

View File

@@ -0,0 +1,3 @@
{
"title": "Genesis"
}

View File

@@ -0,0 +1,4 @@
{
"title": "The Crucible",
"description": "I AM JUST A ITSY-BITSY TEENY-WEENIE TEST"
}

View File

@@ -0,0 +1,6 @@
version: '0.12'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: prima-materia
text: Section on the ground state; first table of correspondences

View File

@@ -0,0 +1,12 @@
version: '0.13'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: calcination
text: Test
- kind: added
article: fermentation
text: Big test with a longer sentence
- kind: changed
article: coagulation
text: Lorem ipsum, blablabla ipbsum whorum

View File

@@ -0,0 +1,19 @@
version: '0.14'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: coagulation
text: >-
s the raw release exactly as Keystatic reads it, inferred from the schema
like the other three
- kind: changed
article: prima-materia
text: >-
releases are loaded, resolved and attached to articles. Six files; nothing
renders yet.
- kind: fixed
article: fermentation
text: We don't burn the priests anymore, we burn the gods
- kind: removed
article: the-crucible
text: AND EVERYTHING GOES BOOOOOOOM

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

View File

@@ -0,0 +1,27 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { TERM_KINDS } from '@/lib/constants/terms'
export const definition = wrapper({
label: 'Definition',
description: 'One term and its meaning',
forSpecificLocations: true,
schema: {
term: fields.text({
label: 'Term',
validation: {
isRequired: true,
},
}),
kind: fields.select({
label: 'Kind',
options: TERM_KINDS,
defaultValue: 'entry',
}),
see: fields.text({
label: 'See also',
description: 'Other terms in this list, comma separated',
}),
},
})

View File

@@ -0,0 +1,13 @@
import { repeating } from '@keystatic/core/content-components'
export const definitions = repeating({
label: 'Definitions',
description: 'A list of terms defined for this article only',
children: ['definition'],
validation: {
children: {
min: 1,
},
},
schema: {},
})

View File

@@ -0,0 +1,21 @@
import { fields } from '@keystatic/core'
import { block } from '@keystatic/core/content-components'
import { imageFields } from '@/keystatic/fields/content/image'
import { FIGURE_SIZES } from '@/lib/constants/figure'
export const figure = (collection: string) =>
block({
label: 'Figure',
description: 'An image with alt text, and optionally a caption and credit.',
schema: {
...imageFields(collection, {
required: true,
}),
size: fields.select({
label: 'Size',
options: FIGURE_SIZES,
defaultValue: 'inline',
}),
},
})

View File

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

View File

@@ -0,0 +1,27 @@
import { callout } from './callout'
import { definition } from './definition-list/definition'
import { definitions } from './definition-list/definitions'
import { figure } from './figure'
import { footnote } from './footnote'
import { lead } from './lead'
import { quote } from './quote'
import { reveal } from './reveal'
import { sidenote } from './sidenote'
import { spoiler } from './spoiler'
import { tableWrapper } from './table-wrapper'
import { xref } from './xref'
export const generalBlocks = (collection: string) => ({
figure: figure(collection),
callout,
definition,
definitions,
lead,
footnote,
sidenote,
tableWrapper,
quote,
reveal,
spoiler,
xref: xref(),
})

View File

@@ -0,0 +1,7 @@
import { wrapper } from '@keystatic/core/content-components'
export const lead = wrapper({
label: 'Lead',
description: 'The opening paragraph, set larger and with the drop cap',
schema: {},
})

View File

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

View File

@@ -0,0 +1,14 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
export const reveal = wrapper({
label: 'Reveal',
description: 'A spoiler around any content, closed until the reader opens it',
schema: {
summary: fields.text({
label: 'Label',
description: 'What the reader sees before opening.',
defaultValue: 'Spoiler',
}),
},
})

View File

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

View File

@@ -0,0 +1,15 @@
import { mark } from '@keystatic/core/content-components'
import { eyeOffIcon } from '@keystar/ui/icon/icons/eyeOffIcon'
export const spoiler = mark({
label: 'Spoiler',
icon: eyeOffIcon,
tag: 'span',
schema: {},
style: {
backgroundColor: '#2A2A2A',
color: '#F5F5F5',
padding: '0 2px',
},
})

View File

@@ -0,0 +1,31 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { COLUMN_TYPES, 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',
}),
columns: fields.array(
fields.select({
label: 'Type',
options: COLUMN_TYPES,
defaultValue: 'default',
}),
{
label: 'Columns',
description: 'One per column, left to right.',
itemLabel: (item) => item.value,
},
),
},
})

View File

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

View File

@@ -0,0 +1,30 @@
import { collection, fields } from '@keystatic/core'
import { base, published, stage } from '@/keystatic/fields/content/base'
import { content } from '@/keystatic/fields/content/content'
import { articleRoute } from '@/keystatic/fields/content/route'
import { articleSummary } from '@/keystatic/fields/content/summary'
import { ARTICLES, TAGS } from '@/lib/constants/collections'
export const articles = collection({
label: 'Articles',
slugField: 'title',
path: `src/content/general/${ARTICLES}/*`,
format: {
contentField: 'content',
},
entryLayout: 'content',
columns: ['title', 'status', 'stage'],
schema: {
...base,
stage,
route: articleRoute,
summary: articleSummary(ARTICLES),
published,
tags: fields.multiRelationship({
label: 'Tags',
collection: TAGS,
}),
content: content(ARTICLES),
},
})

View File

@@ -0,0 +1,11 @@
import { ARTICLES, PAGES, TAGS } from '@/lib/constants/collections'
import { articles } from './articles'
import { pages } from './pages'
import { tags } from './tags'
export const contentCollections = {
[ARTICLES]: articles,
[PAGES]: pages,
[TAGS]: tags,
}

View File

@@ -0,0 +1,23 @@
import { collection } from '@keystatic/core'
import { base } from '@/keystatic/fields/content/base'
import { content } from '@/keystatic/fields/content/content'
import { pageRoute } from '@/keystatic/fields/content/route'
import { pageSummary } from '@/keystatic/fields/content/summary'
import { PAGES } from '@/lib/constants/collections'
export const pages = collection({
label: 'Pages',
slugField: 'title',
path: `src/content/general/${PAGES}/*`,
format: {
contentField: 'content',
},
columns: ['title', 'status'],
schema: {
...base,
route: pageRoute,
summary: pageSummary,
content: content(PAGES),
},
})

View File

@@ -0,0 +1,21 @@
import { collection, fields } from '@keystatic/core'
import { title } from '@/keystatic/fields/content/base'
import { TAGS } from '@/lib/constants/collections'
export const tags = collection({
label: 'Tags',
slugField: 'title',
path: `src/content/general/${TAGS}/*`,
format: {
data: 'json',
},
columns: ['title'],
schema: {
title,
description: fields.text({
label: 'Description',
multiline: true,
}),
},
})

View File

@@ -0,0 +1,7 @@
import { RELEASES } from '@/lib/constants/collections'
import { releases } from './releases'
export const siteCollections = {
[RELEASES]: releases,
}

View File

@@ -0,0 +1,66 @@
import { collection, fields } from '@keystatic/core'
import { CHANGE_TYPES } from '@/lib/constants/changelog'
import { ARTICLES, RELEASES } from '@/lib/constants/collections'
const VERSION = /^\d+\.\d+(\.\d+)?$/
export const releases = collection({
label: 'Releases',
slugField: 'version',
path: `src/content/site/${RELEASES}/*`,
columns: ['version', 'date'],
schema: {
version: fields.slug({
name: {
label: 'Version',
description: 'Without the v: 0.12, 1.0, 1.0.3',
validation: {
isRequired: true,
pattern: {
regex: VERSION,
message: 'Numbers separated by dots, like 0.12 or 1.0.3',
},
},
},
slug: {
label: 'File ID',
generate: (version) => `v${version.replaceAll('.', '-')}`,
},
}),
date: fields.datetime({
label: 'Date',
defaultValue: {
kind: 'now',
},
validation: {
isRequired: true,
},
}),
changes: fields.array(
fields.object({
kind: fields.select({
label: 'Kind',
options: CHANGE_TYPES,
defaultValue: 'added',
}),
article: fields.relationship({
label: 'Article',
description: 'Empty for site-wide changes',
collection: ARTICLES,
}),
text: fields.text({
label: 'Text',
validation: {
isRequired: true,
},
}),
}),
{
label: 'Changes',
itemLabel: (props) =>
`${props.fields.kind.value} · ${props.fields.article.value ?? 'site'} · ${props.fields.text.value}`,
},
),
},
})

View File

@@ -0,0 +1,60 @@
import { fields } from '@keystatic/core'
import { STAGE_OPTIONS } from '@/lib/constants/stage'
import { STATUS_OPTIONS } from '@/lib/constants/status'
import { SEGMENT, SEGMENT_MESSAGE, slugify } from '@/lib/content/slug'
export const title = fields.slug({
name: {
label: 'Title',
validation: {
isRequired: true,
},
},
slug: {
label: 'File ID',
description: 'Internal and unique across the collection.',
generate: slugify,
validation: {
pattern: {
regex: SEGMENT,
message: SEGMENT_MESSAGE,
},
},
},
})
export const subtitle = fields.text({
label: 'Subtitle',
})
export const status = fields.select({
label: 'Status',
options: STATUS_OPTIONS,
defaultValue: 'draft',
})
export const stage = fields.select({
label: 'Stage',
description: 'How finished the text is. Separate from status, which controls visibility',
options: STAGE_OPTIONS,
defaultValue: 'stub',
})
export const noindex = fields.checkbox({
label: 'Hide from search engines',
})
export const published = fields.datetime({
label: 'Published',
defaultValue: {
kind: 'now',
},
})
export const base = {
title,
subtitle,
status,
noindex,
}

View File

@@ -0,0 +1,14 @@
import { fields } from '@keystatic/core'
import { generalBlocks } from '@/keystatic/blocks/general'
import { assets } from '@/lib/images/storage'
export const content = (collection: string) =>
fields.markdoc({
label: 'Content',
options: {
heading: [2, 3, 4],
image: assets(collection),
},
components: generalBlocks(collection),
})

View File

@@ -0,0 +1,6 @@
import { fields } from '@keystatic/core'
import { imageFields } from './image'
export const cover = (collection: string) =>
fields.object(imageFields(collection), { label: 'Cover' })

View File

@@ -0,0 +1,36 @@
import { fields } from '@keystatic/core'
import { ASPECT_RATIOS, IMAGE_EFFECTS } from '@/lib/constants/figure'
import { assets } from '@/lib/images/storage'
export const imageFields = (collection: string, { required = false } = {}) => ({
image: fields.image({
label: 'Image',
...assets(collection),
validation: {
isRequired: required,
},
}),
alt: fields.text({
label: 'Alt text',
validation: {
isRequired: required,
},
}),
caption: fields.text({
label: 'Caption',
}),
credit: fields.text({
label: 'Credit',
}),
effect: fields.select({
label: 'Effect',
options: IMAGE_EFFECTS,
defaultValue: 'none',
}),
ratio: fields.select({
label: 'Crop',
options: ASPECT_RATIOS,
defaultValue: 'original',
}),
})

View File

@@ -0,0 +1,47 @@
import { fields } from '@keystatic/core'
import { ARTICLES } from '@/lib/constants/collections'
import { OPTIONAL_SEGMENT, SEGMENT_MESSAGE } from '@/lib/content/slug'
const segment = fields.text({
label: 'URL segment',
description: 'Optional- Leave empty to use the slugified title',
validation: {
pattern: {
regex: OPTIONAL_SEGMENT,
message: SEGMENT_MESSAGE,
},
},
})
export const pageRoute = fields.object(
{
segment,
},
{
label: 'Route',
},
)
export const articleRoute = fields.object(
{
segment,
parent: fields.relationship({
label: 'Parent',
description: 'Empty makes this a root',
collection: ARTICLES,
}),
index: fields.integer({
label: 'Index',
description: 'Sorts among siblings. Empty sorts by date, newest first',
}),
sequence: fields.checkbox({
label: 'Sequence',
description:
'Children form a numbered series with previous/next navigation. Give each child an index',
}),
},
{
label: 'Route',
},
)

View File

@@ -0,0 +1,33 @@
import { fields } from '@keystatic/core'
import { cover } from './cover'
const excerpt = fields.text({
label: 'Excerpt',
multiline: true,
validation: {
length: {
max: 160,
},
},
})
export const pageSummary = fields.object(
{
excerpt,
},
{
label: 'Summary',
},
)
export const articleSummary = (collection: string) =>
fields.object(
{
excerpt,
cover: cover(collection),
},
{
label: 'Summary',
},
)

View File

@@ -1,14 +1,26 @@
---
import { Font } from 'astro:assets'
import type { Article } from '@/lib/site/types'
import '@/styles/global.css'
import SiteFooter from '@/components/site/SiteFooter.astro'
import SiteHeader from '@/components/site/SiteHeader.astro'
import { resolveImage } from '@/lib/images/resolve'
interface Props {
title: string
description?: string
image?: string
noindex?: boolean
article?: Article
}
const { title, description } = Astro.props
const { title, description, image, noindex, article } = Astro.props
const canonical = new URL(Astro.url.pathname, Astro.site)
const preview = image ? resolveImage(image) : undefined
const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
---
<!doctype html>
@@ -20,14 +32,30 @@ const { title, description } = Astro.props
<title>{title}</title>
{description && <meta name="description" content={description} />}
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:url" content={canonical} />
{description && <meta property="og:description" content={description} />}
{previewUrl && <meta property="og:image" content={previewUrl} />}
<meta name="twitter:card" content={previewUrl ? 'summary_large_image' : 'summary'} />
<slot name="head" />
<Font cssVariable="--font-geist" preload />
<Font cssVariable="--font-geist-mono" preload />
<Font cssVariable="--font-blaka" preload />
<Font cssVariable="--font-julia-mono" preload />
<Font cssVariable="--font-unigrim" />
<style is:inline>
@layer reset, tokens, base, components.prose, components.blocks, utilities;
</style>
</head>
<body class="ui">
<SiteHeader article={article} />
<main>
<slot />
</main>
<SiteFooter />
</body>
</html>

View File

@@ -0,0 +1,104 @@
---
import DevBanner from '@/components/dev/DevBanner.astro'
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
import Node from '@/components/markdoc/Node.astro'
import ArticleFooter from '@/components/post/Footer/ArticleFooter.astro'
import ArticleHeader from '@/components/post/Header/ArticleHeader.astro'
import ToC from '@/components/post/ToC.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import type { Rendered } from '@/lib/markdoc/types'
import { metaOf } from '@/lib/site/meta'
import { sequenceOf } from '@/lib/site/navigation'
import type { Article } from '@/lib/site/types'
type Props = {
article: Article
rendered: Rendered
}
const { article, rendered } = Astro.props
const meta = metaOf(article)
const sequence = sequenceOf(article)
---
<BaseLayout {...meta} article={article}>
<article class="article">
<ArticleHeader article={article} />
<div class="frame">
<div class="rail">
<ToC headings={rendered.headings} />
</div>
<div class="body content prose">
<Node node={rendered.tree} />
</div>
{Astro.slots.has('margin') && (
<aside class="margin">
<slot name="margin" />
</aside>
)}
{rendered.footnotes.length > 0 && (
<div class="notes content">
<FootnoteList footnotes={rendered.footnotes} />
</div>
)}
</div>
<ArticleFooter sequence={sequence} article={article} />
</article>
<DevBanner
collection={article.collection}
id={article.id}
status={article.status}
diagnostics={rendered.diagnostics}
/>
</BaseLayout>
<style>
.frame {
@mixin layout-wrapper;
@mixin my var(--size-12);
display: flex;
flex-direction: column;
gap: var(--layout-gutter);
@media (--bp-desktop) {
@mixin layout-columns;
display: grid;
align-items: start;
}
}
.body {
position: relative;
}
.rail,
.body,
.margin,
.notes {
min-width: 0;
}
@media (--bp-desktop) {
.rail {
grid-column: rail-start / rail-end;
grid-row: 1 / 3;
align-self: stretch;
}
.body {
grid-column: body-start / body-end;
}
.margin {
grid-column: margin-start / margin-end;
grid-row: 1 / 3;
}
.notes {
grid-column: body-start / body-end;
margin-top: var(--layout-gutter);
}
}
</style>

View File

@@ -0,0 +1,71 @@
---
import DevBanner from '@/components/dev/DevBanner.astro'
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
import Node from '@/components/markdoc/Node.astro'
import SimpleHeader from '@/components/post/Header/SimpleHeader.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import type { Rendered } from '@/lib/markdoc/types'
import { metaOf } from '@/lib/site/meta'
import type { Page } from '@/lib/site/types'
type Props = {
page: Page
rendered: Rendered
}
const { page, rendered } = Astro.props
const meta = metaOf(page)
---
<BaseLayout {...meta}>
<DevBanner
collection={page.collection}
id={page.id}
status={page.status}
diagnostics={rendered.diagnostics}
/>
<article class="page">
<SimpleHeader eyebrow="Page" title={page.title} standfirst={page.subtitle || undefined} />
<div class="frame">
<div class="body content prose">
<Node node={rendered.tree} />
</div>
{rendered.footnotes.length > 0 && (
<div class="notes content">
<FootnoteList footnotes={rendered.footnotes} />
</div>
)}
</div>
</article>
</BaseLayout>
<style>
.frame {
@mixin layout-wrapper;
@mixin my var(--size-12);
display: flex;
flex-direction: column;
gap: var(--layout-gutter);
@media (--bp-desktop) {
@mixin layout-columns;
display: grid;
row-gap: var(--layout-gutter);
}
}
.body {
position: relative;
}
.body,
.notes {
min-width: 0;
@media (--bp-desktop) {
grid-column: body-start / body-end;
}
}
</style>

View File

@@ -0,0 +1,22 @@
---
import DevBanner from '@/components/dev/DevBanner.astro'
import ArticleCardGrid from '@/components/post/ArticleCard/ArticleCardGrid.astro'
import SimpleHeader from '@/components/post/Header/SimpleHeader.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} />
<SimpleHeader
eyebrow="Tag"
title={tag.title}
standfirst={tag.description || undefined}
count={tag.articles.length}
/>
<ArticleCardGrid articles={tag.articles} />
</BaseLayout>

View File

@@ -0,0 +1,16 @@
export const NOTE_TYPES = [
{
label: 'Note',
value: 'default',
},
{
label: 'Example',
value: 'example',
},
{
label: 'Optional',
value: 'optional',
},
] as const
export type CalloutType = (typeof NOTE_TYPES)[number]['value']

View File

@@ -0,0 +1,37 @@
export const TABLE_VARIANTS = [
{
label: 'Default',
value: 'default',
},
{
label: 'Matrix',
value: 'matrix',
},
] as const
export type TableVariant = (typeof TABLE_VARIANTS)[number]['value']
export const COLUMN_TYPES = [
{
label: 'Default',
value: 'default',
},
{
label: 'Number',
value: 'num',
},
{
label: 'Key',
value: 'key',
},
{
label: 'Sentence',
value: 'text',
},
{
label: 'Row header',
value: 'rowheader',
},
] as const
export type ColumnType = (typeof COLUMN_TYPES)[number]['value']

View File

@@ -0,0 +1,20 @@
export const CHANGE_TYPES = [
{
label: 'Added',
value: 'added',
},
{
label: 'Changed',
value: 'changed',
},
{
label: 'Fixed',
value: 'fixed',
},
{
label: 'Removed',
value: 'removed',
},
] as const
export type ChangeType = (typeof CHANGE_TYPES)[number]['value']

View File

@@ -0,0 +1,14 @@
/* === CONTENT COLLECTIONS === */
export const ARTICLES = 'articles'
export const PAGES = 'pages'
export const TAGS = 'tags'
export const COLLECTION_LABELS = {
[ARTICLES]: 'Article',
[PAGES]: 'Page',
[TAGS]: 'Tag',
} as const
/* === SITE COLLECTIONS === */
export const RELEASES = 'releases'

View File

@@ -0,0 +1,23 @@
export const FIGURE_SIZES = [
{ label: 'Inline', value: 'inline' },
{ label: 'Wide', value: 'wide' },
] as const
export const IMAGE_EFFECTS = [
{ label: 'None', value: 'none' },
{ label: 'Xeroxed', value: 'xeroxed' },
] as const
export const ASPECT_RATIOS = [
{ label: 'Original', value: 'original' },
{ label: 'Panorama (21:9)', value: '21-9' },
{ label: 'Wide (16:9)', value: '16-9' },
{ label: 'Photo (3:2)', value: '3-2' },
{ label: 'Classic (4:3)', value: '4-3' },
{ label: 'Square (1:1)', value: '1-1' },
{ label: 'Portrait (4:5)', value: '4-5' },
] as const
export type FigureSize = (typeof FIGURE_SIZES)[number]['value']
export type ImageEffect = (typeof IMAGE_EFFECTS)[number]['value']
export type AspectRatio = (typeof ASPECT_RATIOS)[number]['value']

Some files were not shown because too many files have changed in this diff Show More