Foundation + Schemas

This commit is contained in:
2026-09-21 16:59:08 +02:00
parent 3a27b60be4
commit f82d631ba6
25 changed files with 391 additions and 78 deletions

View File

@@ -2,13 +2,22 @@ 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'
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())],
adapter: node({
mode: 'standalone',

View File

@@ -1,8 +1,12 @@
import { config } from '@keystatic/core'
import { contentCollections } from '@/keystatic/collections/general'
export default config({
storage: {
kind: 'local',
},
collections: {},
collections: {
...contentCollections,
},
})

View File

@@ -12,11 +12,11 @@
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdoc": "^2.0.1",
"@astrojs/node": "^11.0.0",
"@astrojs/react": "^6.0.0",
"@keystatic/astro": "^6.0.0",
"@keystatic/core": "^0.6.7",
"@markdoc/markdoc": "^0.5.10",
"astro": "^7.0.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"

76
pnpm-lock.yaml generated
View File

@@ -11,9 +11,6 @@ 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))
@@ -26,6 +23,9 @@ importers:
'@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.5.10
version: 0.5.10(@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)
@@ -208,12 +208,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==}
@@ -1694,32 +1688,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 +1728,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 +2047,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==}
@@ -3658,19 +3628,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
@@ -5212,36 +5169,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 +5202,6 @@ snapshots:
entities@4.5.0: {}
entities@8.1.0: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -5613,13 +5550,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

View File

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

View File

@@ -0,0 +1,12 @@
---
title: Prima Materia
status: draft
noindex: false
route:
parent: stages
summary:
cover: {}
dates:
published: 2026-09-21T12:35:00.000Z
tags: []
---

View File

@@ -0,0 +1,12 @@
---
title: Stages
status: draft
noindex: false
route:
parent: the-crucible
summary:
cover: {}
dates:
published: 2026-09-21T12:34:00.000Z
tags: []
---

View File

@@ -0,0 +1,15 @@
---
title: The Crucible
status: draft
noindex: false
route: {}
summary:
excerpt: >-
Where we explore the crucible – a materialistic alchemist Worldbuilding
Framework
cover: {}
dates:
published: 2026-09-21T12:33:00.000Z
tags:
- the-crucible
---

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { collection, fields } from '@keystatic/core'
import { base } from '@/keystatic/fields/content/base'
import { content } from '@/keystatic/fields/content/content'
import { dates } from '@/keystatic/fields/content/dates'
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'],
schema: {
...base,
route: articleRoute,
summary: articleSummary(ARTICLES),
dates,
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,40 @@
import { fields } from '@keystatic/core'
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 status = fields.select({
label: 'Status',
options: STATUS_OPTIONS,
defaultValue: 'draft',
})
export const noindex = fields.checkbox({
label: 'Hide from search engines',
})
export const base = {
title,
status,
noindex,
}

View File

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

View File

@@ -0,0 +1,25 @@
import { fields } from '@keystatic/core'
import { assets } from '@/lib/content/assets'
export const cover = (collection: string) =>
fields.object(
{
image: fields.image({
label: 'Image',
...assets(collection),
}),
alt: fields.text({
label: 'Alt text',
}),
caption: fields.text({
label: 'Caption',
}),
credit: fields.text({
label: 'Credit',
}),
},
{
label: 'Cover',
},
)

View File

@@ -0,0 +1,18 @@
import { fields } from '@keystatic/core'
export const dates = fields.object(
{
published: fields.datetime({
label: 'Published',
defaultValue: {
kind: 'now',
},
}),
updated: fields.datetime({
label: 'Updated',
}),
},
{
label: 'Dates',
},
)

View File

@@ -0,0 +1,42 @@
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',
}),
},
{
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

@@ -0,0 +1,5 @@
/* === CONTENT COLLECTIONS === */
export const ARTICLES = 'articles'
export const PAGES = 'pages'
export const TAGS = 'tags'

View File

@@ -0,0 +1,20 @@
export const STATUS_OPTIONS = [
{
label: 'Draft',
value: 'draft',
},
{
label: 'Published',
value: 'published',
},
{
label: 'Unlisted',
value: 'unlisted',
},
{
label: 'Archived',
value: 'archived',
},
] as const
export type Status = (typeof STATUS_OPTIONS)[number]['value']

View File

@@ -0,0 +1,6 @@
export const ASSET_ROOT = 'src/assets/content'
export const assets = (collection: string) => ({
directory: `${ASSET_ROOT}/${collection}`,
publicPath: `/${ASSET_ROOT}/${collection}/`,
})

34
src/lib/content/slug.ts Normal file
View File

@@ -0,0 +1,34 @@
export const SEGMENT = /^[a-z0-9][a-z0-9_-]*$/
export const OPTIONAL_SEGMENT = /^([a-z0-9][a-z0-9_-]*)?$/
export const SEGMENT_MESSAGE = 'Lowercase letters, digits, - and _'
const UMLAUTS: Record<string, string> = {
ä: 'ae',
ö: 'oe',
ü: 'ue',
ß: 'ss',
Ä: 'ae',
Ö: 'oe',
Ü: 'ue',
å: 'aa',
æ: 'ae',
ø: 'oe',
Å: 'aa',
Æ: 'ae',
Ø: 'oe',
}
export const slugify = (text: string): string => {
return text
.split('')
.map((char) => UMLAUTS[char] || char)
.join('')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9 _-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
}

View File

@@ -7,6 +7,7 @@
"jsxImportSource": "react",
"paths": {
"@/*": ["./src/*"],
"~/*": ["./*"],
},
},
}