@michaelthielemann/kestrel 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +33 -3
  2. package/layers/admin/app/components/PageFields.vue +25 -0
  3. package/layers/admin/app/composables/useEditForm.ts +5 -0
  4. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
  5. package/layers/auth/nuxt.config.ts +0 -0
  6. package/layers/auth/server/api/auth/session.get.ts +0 -0
  7. package/layers/auth/server/utils/password.ts +0 -0
  8. package/layers/auth/server/utils/session.ts +5 -2
  9. package/layers/collections/nuxt.config.ts +0 -0
  10. package/layers/core/app/composables/layouts.ts +5 -0
  11. package/layers/core/app/utils/layouts.ts +38 -0
  12. package/layers/core/modules/auto-discovery/index.ts +11 -1
  13. package/layers/core/modules/auto-discovery/virtual.d.ts +3 -0
  14. package/layers/core/modules/kestrel/app-shell.ts +55 -0
  15. package/layers/core/modules/kestrel/index.ts +17 -0
  16. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
  17. package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
  18. package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
  19. package/layers/core/server/utils/blocks.ts +0 -0
  20. package/layers/core/server/utils/seo.ts +0 -0
  21. package/layers/fields/nuxt.config.ts +0 -0
  22. package/layers/fields/server/utils/buildTable.ts +8 -2
  23. package/layers/media/server/api/media/[id].get.ts +0 -0
  24. package/layers/media/server/api/media/index.get.ts +0 -0
  25. package/layers/public/app/app.vue +0 -0
  26. package/layers/public/app/error.vue +0 -0
  27. package/layers/public/app/layouts/default.vue +0 -0
  28. package/layers/public/app/pages/[...slug].vue +31 -15
  29. package/layers/public/app/utils/page-layout.ts +18 -0
  30. package/layers/ui/app/assets/scss/_reset.scss +0 -0
  31. package/layers/ui/app/assets/scss/main.scss +0 -0
  32. package/layers/ui/app/components/ui/Alert.vue +0 -0
  33. package/layers/ui/app/i18n/de.ts +3 -0
  34. package/layers/ui/app/i18n/en.ts +3 -0
  35. package/package.json +6 -2
  36. package/scripts/copy-create-payload.mjs +49 -0
  37. package/scripts/hash-password.mjs +6 -15
  38. package/scripts/kestrel.mjs +216 -0
  39. package/scripts/lib/cli.mjs +114 -0
  40. package/scripts/lib/password.mjs +19 -0
  41. package/scripts/lib/scaffold.mjs +174 -0
  42. package/templates/starter/README.md +65 -0
  43. package/templates/starter/_env.example +17 -0
  44. package/templates/starter/_gitignore +26 -0
  45. package/templates/starter/_package.json +22 -0
  46. package/templates/starter/app/app.vue +7 -0
  47. package/templates/starter/app/blocks/Prose.vue +12 -0
  48. package/templates/starter/app/layouts/default.vue +6 -0
  49. package/templates/starter/nuxt.config.ts +20 -0
  50. package/templates/starter/pnpm-workspace.yaml +8 -0
  51. package/templates/starter/tsconfig.json +3 -0
@@ -0,0 +1,114 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+
4
+ export const MIN_PASSWORD_LENGTH = 8
5
+
6
+ /**
7
+ * `booleans` never consume the following token, so `init --force my-site` keeps `my-site` as the target
8
+ * rather than scaffolding over the current directory. `--` ends flag parsing.
9
+ */
10
+ export function parseArgs(argv, booleans = []) {
11
+ const isBoolean = new Set(booleans)
12
+ const flags = {}
13
+ const positional = []
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i]
16
+ if (a === '--') {
17
+ positional.push(...argv.slice(i + 1))
18
+ break
19
+ }
20
+ if (a === '-h') {
21
+ flags.help = true
22
+ continue
23
+ }
24
+ if (a === '-v') {
25
+ flags.version = true
26
+ continue
27
+ }
28
+ if (!a.startsWith('--')) {
29
+ positional.push(a)
30
+ continue
31
+ }
32
+ const [key, inline] = a.slice(2).split(/=(.*)/s)
33
+ if (inline !== undefined) flags[key] = inline
34
+ else if (!isBoolean.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) flags[key] = argv[++i]
35
+ else flags[key] = true
36
+ }
37
+ return { flags, positional }
38
+ }
39
+
40
+ export function makePaint(stream = process.stdout) {
41
+ const on = stream.isTTY && !process.env.NO_COLOR
42
+ const wrap = (code) => (s) => (on ? `\u001b[${code}m${s}\u001b[0m` : s)
43
+ return { dim: wrap(2), bold: wrap(1), red: wrap(31), yellow: wrap(33), green: wrap(32) }
44
+ }
45
+
46
+ export const out = (s = '') => process.stdout.write(`${s}\n`)
47
+
48
+ export const readIf = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : null)
49
+
50
+ /** `mode` is applied even when the file already exists, which `writeFileSync` alone does not do. */
51
+ export function write(file, content, mode) {
52
+ mkdirSync(dirname(file), { recursive: true })
53
+ writeFileSync(file, content, mode === undefined ? undefined : { mode })
54
+ if (mode !== undefined) chmodSync(file, mode)
55
+ }
56
+
57
+ /** Every file under `dir`, as `/`-separated paths relative to it. */
58
+ export function walk(dir, base = dir) {
59
+ const found = []
60
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
61
+ const full = join(dir, entry.name)
62
+ if (entry.isDirectory()) found.push(...walk(full, base))
63
+ else found.push(relative(base, full).split('\\').join('/'))
64
+ }
65
+ return found
66
+ }
67
+
68
+ /**
69
+ * Reads a line with the echo suppressed. readline echoes before we see the data event, so the fix is to
70
+ * repaint the prompt over whatever was written — unconditionally, because a pasted secret arrives as one
71
+ * chunk that ends in a newline and would otherwise be left on screen and in the scrollback.
72
+ */
73
+ async function askHidden(rl, question) {
74
+ const repaint = () => process.stdout.write(`\u001b[2K\u001b[200D${question}`)
75
+ process.stdin.on('data', repaint)
76
+ try {
77
+ return (await rl.question(question)).trim()
78
+ } finally {
79
+ process.stdin.off('data', repaint)
80
+ repaint()
81
+ process.stdout.write('\n')
82
+ }
83
+ }
84
+
85
+ export class Cancelled extends Error {}
86
+
87
+ /** Throws `Cancelled` on Ctrl-C / Ctrl-D so a caller can exit cleanly instead of on an AbortError stack. */
88
+ export async function promptPassword(rl, { warn = out } = {}) {
89
+ for (;;) {
90
+ let first
91
+ try {
92
+ first = await askHidden(rl, 'Admin password: ')
93
+ if (first.length < MIN_PASSWORD_LENGTH) {
94
+ warn(` at least ${MIN_PASSWORD_LENGTH} characters, please`)
95
+ continue
96
+ }
97
+ if (first !== (await askHidden(rl, 'Repeat password: '))) {
98
+ warn(' the two entries differ — try again')
99
+ continue
100
+ }
101
+ } catch (err) {
102
+ throw err?.code === 'ABORT_ERR' ? new Cancelled() : err
103
+ }
104
+ if (first === undefined) throw new Cancelled()
105
+ return first
106
+ }
107
+ }
108
+
109
+ /** All of stdin as a string; `''` at EOF, which the callers turn into a clean error. */
110
+ export async function readStdin() {
111
+ let text = ''
112
+ for await (const chunk of process.stdin) text += chunk
113
+ return text.split('\n')[0].trim()
114
+ }
@@ -0,0 +1,19 @@
1
+ import { scryptSync, randomBytes } from 'node:crypto'
2
+
3
+ // Must match what layers/auth parses back out. ADR-0001 covers the parameter choice.
4
+ const N = 2 ** 17
5
+ const r = 8
6
+ const p = 1
7
+ const KEYLEN = 64
8
+ const MAXMEM = 256 * 1024 * 1024
9
+
10
+ /** `scrypt$N$r$p$salt$hash`, the `KESTREL_ADMIN_PASSWORD_HASH` format. */
11
+ export function hashPassword(password) {
12
+ const salt = randomBytes(16)
13
+ const hash = scryptSync(password, salt, KEYLEN, { N, r, p, maxmem: MAXMEM })
14
+ return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`
15
+ }
16
+
17
+ export function sessionSecret() {
18
+ return randomBytes(32).toString('base64url')
19
+ }
@@ -0,0 +1,174 @@
1
+ export const PACKAGE_NAME = '@michaelthielemann/kestrel'
2
+
3
+ // Template dotfiles are `_`-prefixed because npm strips a literal `.gitignore` from a tarball. ADR-0005.
4
+ const RENAMES = {
5
+ _gitignore: '.gitignore',
6
+ '_env.example': '.env.example',
7
+ '_package.json': 'package.json',
8
+ }
9
+
10
+ export function targetName(rel) {
11
+ const parts = rel.split('/')
12
+ const base = parts[parts.length - 1]
13
+ parts[parts.length - 1] = RENAMES[base] ?? base
14
+ return parts.join('/')
15
+ }
16
+
17
+ /** An unknown placeholder is left as-is so a typo is visible rather than silently blank. */
18
+ export function renderTemplate(src, vars) {
19
+ return src.replace(/\{\{(\w+)\}\}/g, (whole, key) => (key in vars ? String(vars[key]) : whole))
20
+ }
21
+
22
+ export function toPackageName(dirName) {
23
+ const slug = dirName
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9._-]+/g, '-')
26
+ .replace(/^[-._]+|[-._]+$/g, '')
27
+ return slug || 'kestrel-site'
28
+ }
29
+
30
+ const KEY_RE = /^\s*([A-Z][A-Z0-9_]*)\s*=/
31
+
32
+ /**
33
+ * Fills only keys that are absent or empty, so re-running `init` never rotates a live session secret.
34
+ * Returns the new text plus the keys that changed.
35
+ */
36
+ export function mergeEnv(existing, entries) {
37
+ const pending = new Map(Object.entries(entries))
38
+ const lines = existing === '' ? [] : existing.split('\n')
39
+ const written = []
40
+
41
+ // A dotenv loader takes the LAST assignment of a key, so filling the first would leave the file
42
+ // reporting a value the app never sees.
43
+ const lastIndex = new Map()
44
+ lines.forEach((line, i) => {
45
+ const key = KEY_RE.exec(line)?.[1]
46
+ if (key) lastIndex.set(key, i)
47
+ })
48
+
49
+ const out = lines.map((line, i) => {
50
+ const key = KEY_RE.exec(line)?.[1]
51
+ if (!key || !pending.has(key) || lastIndex.get(key) !== i) return line
52
+ const value = pending.get(key)
53
+ pending.delete(key)
54
+ if (line.slice(line.indexOf('=') + 1).trim() !== '') return line
55
+ written.push(key)
56
+ return `${key}=${value}`
57
+ })
58
+
59
+ if (pending.size) {
60
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
61
+ for (const [key, value] of pending) {
62
+ out.push(`${key}=${value}`)
63
+ written.push(key)
64
+ }
65
+ out.push('')
66
+ }
67
+
68
+ return { text: out.join('\n'), written }
69
+ }
70
+
71
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
72
+
73
+ const NESTED = ['scripts', 'dependencies', 'devDependencies']
74
+ // `type` decides whether every .js in the project is ESM or CJS, so injecting it silently could break a
75
+ // CommonJS project outright. Only these top-level keys may be introduced, and each is reported.
76
+ const TOP_LEVEL = ['name', 'private', 'type']
77
+
78
+ /** Additive: the project's own values always win. Returns the manifest plus every key it introduced. */
79
+ export function mergePackageJson(existing, template) {
80
+ const merged = { ...existing }
81
+ const added = []
82
+
83
+ for (const key of TOP_LEVEL) {
84
+ if (!(key in template) || key in existing) continue
85
+ merged[key] = template[key]
86
+ added.push(key)
87
+ }
88
+ for (const key of NESTED) {
89
+ if (!template[key]) continue
90
+ merged[key] = isObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
91
+ for (const name of Object.keys(template[key])) {
92
+ if (!isObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
93
+ }
94
+ }
95
+ return { merged, added }
96
+ }
97
+
98
+ const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
99
+ // `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
100
+ const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
101
+ export const usesComponent = (src, tag) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
102
+
103
+ /**
104
+ * `kestrel doctor`, as a pure function of what was read; `null` means the file is absent.
105
+ * The `app.vue` rules mirror `layers/core/modules/kestrel/app-shell.ts`; a test pins the two together.
106
+ */
107
+ export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
108
+ const found = []
109
+ const add = (level, message) => found.push({ level, message })
110
+
111
+ let manifest
112
+ if (packageJson !== null) {
113
+ try {
114
+ manifest = JSON.parse(packageJson)
115
+ } catch {
116
+ add('error', 'package.json is not valid JSON — nothing can read it, including pnpm.')
117
+ }
118
+ }
119
+
120
+ if (packageJson === null) {
121
+ add('error', 'no package.json — run this inside a project directory, or `kestrel init <dir>` to make one.')
122
+ } else if (manifest !== undefined) {
123
+ const deps = { ...manifest.dependencies, ...manifest.devDependencies }
124
+ if (!(PACKAGE_NAME in deps)) add('error', `${PACKAGE_NAME} is not a dependency — run \`pnpm add ${PACKAGE_NAME}\`.`)
125
+ if (!('nuxt' in deps)) {
126
+ add(
127
+ 'error',
128
+ 'nuxt is not a direct dependency. Kestrel depends on it, but a strict node_modules layout (pnpm) does not link a transitive package\'s `nuxt` binary, so `nuxt dev` will not resolve — run `pnpm add -D nuxt`.',
129
+ )
130
+ }
131
+ if (!manifest.scripts?.dev) add('warn', 'no `dev` script — add `"dev": "nuxt dev"` so `pnpm dev` works.')
132
+ }
133
+
134
+ if (nuxtConfig === null) {
135
+ add(
136
+ 'error',
137
+ 'no nuxt.config.ts. Installing the package does nothing on its own: Nuxt only loads Kestrel when the config extends it. Without this file every route, /admin included, serves the default Nuxt welcome page.',
138
+ )
139
+ } else if (!nuxtConfig.includes(PACKAGE_NAME)) {
140
+ add('error', `nuxt.config.ts does not extend ${PACKAGE_NAME} — add \`extends: ['${PACKAGE_NAME}']\`.`)
141
+ } else if (/extends\s*:\s*\[[^\]]*['"]\.\.[/\\]\.\./.test(nuxtConfig)) {
142
+ add(
143
+ 'warn',
144
+ "a relative `extends` path of '../..' or deeper silently drops every Kestrel sub-layer (c12 treats it as a file, not a directory, because pathe reports its extension as '.'). Use the package name.",
145
+ )
146
+ }
147
+
148
+ if (appVue !== null) {
149
+ const src = withoutComments(appVue)
150
+ if (!usesComponent(src, 'NuxtPage')) {
151
+ add(
152
+ 'error',
153
+ 'app/app.vue renders no <NuxtPage />, so no route renders — including /admin. It shadows the one Kestrel ships. Delete it, or wrap <NuxtPage /> in <NuxtLayout>.',
154
+ )
155
+ } else if (!usesComponent(src, 'NuxtLayout')) {
156
+ add('warn', 'app/app.vue renders no <NuxtLayout />, so the admin loses its navigation shell.')
157
+ }
158
+ }
159
+
160
+ if (env === null) {
161
+ add('error', 'no .env — sign-in at /admin answers 503 until KESTREL_ADMIN_PASSWORD_HASH is set. Run `kestrel init`.')
162
+ } else {
163
+ // Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
164
+ const value = (key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
165
+ if (!value('KESTREL_ADMIN_PASSWORD_HASH')) {
166
+ add('error', 'KESTREL_ADMIN_PASSWORD_HASH is unset — /admin renders but sign-in answers 503. Run `kestrel hash-password`.')
167
+ }
168
+ if (!value('KESTREL_SESSION_SECRET')) {
169
+ add('warn', 'KESTREL_SESSION_SECRET is unset — dev falls back to a random per-process secret (sessions drop on restart) and production refuses to boot.')
170
+ }
171
+ }
172
+
173
+ return found
174
+ }
@@ -0,0 +1,65 @@
1
+ # {{name}}
2
+
3
+ A [Kestrel](https://github.com/MichaelThielemann/kestrel) site — a collection-driven CMS that renders
4
+ published content to static HTML.
5
+
6
+ ```bash
7
+ pnpm install
8
+ pnpm dev
9
+ ```
10
+
11
+ Admin: <http://localhost:3000/admin> — sign in with the password you chose during `kestrel init`.
12
+ Forgot it? `pnpm hash-password` prints a fresh hash for `KESTREL_ADMIN_PASSWORD_HASH` in `.env`.
13
+
14
+ ## What is here
15
+
16
+ | Path | What it does |
17
+ | --- | --- |
18
+ | `nuxt.config.ts` | Composes Kestrel (`extends`) and holds every non-secret setting under `kestrel: {}` |
19
+ | `.env` | Session secret + admin password hash. **Never commit it** — `.env.example` is the committed copy |
20
+ | `app/app.vue` | The app root. Keep `<NuxtLayout>` and `<NuxtPage />` or the admin stops rendering |
21
+ | `app/layouts/default.vue` | Your public site frame — header, nav, footer |
22
+ | `app/blocks/Prose.vue` | One block type for the page builder: schema and display in a single file |
23
+ | `.data/` | The SQLite database, uploads and published output. Gitignored |
24
+
25
+ ## Add a collection
26
+
27
+ Drop a file in `server/collections/`; the table is created on the next dev start and the collection
28
+ shows up in the admin. Nothing to register.
29
+
30
+ ```ts
31
+ // server/collections/posts.ts
32
+ export default defineCollection({
33
+ name: 'posts',
34
+ mode: 'multi',
35
+ pageLike: true, // gives records a `path` so they render to static HTML
36
+ status: true, // draft / published
37
+ seo: true,
38
+ blocks: { enabled: true },
39
+ fields: { title: { type: 'text', required: true } },
40
+ })
41
+ ```
42
+
43
+ ## Publish
44
+
45
+ Run `pnpm dev` at least once first: the database schema is derived from your collections and created on
46
+ a dev boot, and a production build never touches it. Generating against a database that does not exist
47
+ yet succeeds but emits an empty site plus `no such table` errors.
48
+
49
+ There are two ways to get static files out, and this project has **both** enabled:
50
+
51
+ - **One-shot:** `pnpm generate` renders everything published into `.output/public/`.
52
+ - **Incremental (the default at runtime):** a production run (`pnpm build && pnpm preview`) publishes on
53
+ boot and re-renders only the pages each content write affects, into `.data/published/`. Turn it off with
54
+ `kestrel: { output: { auto: false } }` in `nuxt.config.ts` if you only want the one-shot flow.
55
+
56
+ Either way, serve the resulting directory from any static host. The editing origin is not meant to be
57
+ public. Set `siteUrl` in `nuxt.config.ts` to the real public origin first — it is baked into canonical
58
+ URLs, `sitemap.xml` and `llms.txt` at build time.
59
+
60
+ ## Health check
61
+
62
+ `pnpm doctor` checks this project for the things that silently break a Kestrel site: a missing
63
+ `extends`, an `app.vue` that renders no pages, unset auth env.
64
+
65
+ Full documentation: <https://github.com/MichaelThielemann/kestrel#documentation>
@@ -0,0 +1,17 @@
1
+ # Auth/session is env-only — secrets never belong in a committed config file. `.env.example` is the
2
+ # committed copy of this template; `.env` holds the real values and is gitignored.
3
+ #
4
+ # The two below are read per request. Every other KESTREL_* var is read once when the app is BUILT
5
+ # (dev start / build / generate) and frozen into runtimeConfig — see docs/configuration.md.
6
+
7
+ # Session signing secret, >= 32 bytes. Required in production. Generate: kestrel secret
8
+ KESTREL_SESSION_SECRET=
9
+
10
+ # Admin password hash. Without it /admin renders but sign-in answers 503. Generate: kestrel hash-password
11
+ KESTREL_ADMIN_PASSWORD_HASH=
12
+
13
+ # Drops the __Host- cookie prefix and the Secure flag. Plain-HTTP localhost ONLY; rejected in production.
14
+ KESTREL_SECURE_COOKIES=false
15
+
16
+ # Optional: session lifetime in seconds (default 604800 = 7 days)
17
+ # KESTREL_SESSION_MAX_AGE=604800
@@ -0,0 +1,26 @@
1
+ # Dependencies
2
+ node_modules
3
+
4
+ # Nuxt / Nitro build output
5
+ .nuxt
6
+ .nitro
7
+ .cache
8
+ .output
9
+ dist
10
+
11
+ # Secrets
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+ # Runtime database, uploads and published output
17
+ .data
18
+ *.sqlite
19
+ *.sqlite-shm
20
+ *.sqlite-wal
21
+
22
+ # Logs
23
+ *.log
24
+
25
+ # OS / editor
26
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "nuxt dev",
7
+ "build": "nuxt build",
8
+ "preview": "nuxt preview",
9
+ "generate": "nuxt generate",
10
+ "typecheck": "nuxt prepare && nuxt typecheck && tsc -p .nuxt/tsconfig.server.json --noEmit",
11
+ "hash-password": "kestrel hash-password",
12
+ "doctor": "kestrel doctor"
13
+ },
14
+ "dependencies": {
15
+ "@michaelthielemann/kestrel": "{{version}}"
16
+ },
17
+ "devDependencies": {
18
+ "nuxt": "{{nuxtVersion}}",
19
+ "typescript": "{{typescriptVersion}}",
20
+ "vue-tsc": "{{vueTscVersion}}"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ <template>
2
+ <!-- Both wrappers are load-bearing: without <NuxtPage /> no route renders, without <NuxtLayout /> the
3
+ admin loses its shell. Delete this file to fall back to the one Kestrel ships. -->
4
+ <NuxtLayout>
5
+ <NuxtPage />
6
+ </NuxtLayout>
7
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ // One SFC is one block type: the filename names it, defineProps is its schema. Add a sibling to add one.
3
+ defineProps({
4
+ body: richtextField({ required: true }),
5
+ })
6
+ defineBlock({ label: { en: 'Prose' }, icon: 'file-text' })
7
+ </script>
8
+
9
+ <template>
10
+ <!-- richtext is sanitized server-side on write -->
11
+ <div class="block-prose" v-html="body" />
12
+ </template>
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <!-- The public site's frame. The admin uses its own layout and is unaffected by anything here. -->
3
+ <main>
4
+ <slot />
5
+ </main>
6
+ </template>
@@ -0,0 +1,20 @@
1
+ export default defineNuxtConfig({
2
+ compatibilityDate: '2026-06-02',
3
+ future: { compatibilityVersion: 4 },
4
+ // This one line composes the whole CMS. Opt-in extension layers go after it.
5
+ extends: ['@michaelthielemann/kestrel'],
6
+ // Not inherited from an extended layer, so it has to be repeated here.
7
+ typescript: { tsConfig: { compilerOptions: { noUncheckedIndexedAccess: false } } },
8
+ nitro: { typescript: { tsConfig: { compilerOptions: { noUncheckedIndexedAccess: false } } } },
9
+ // Auth/session is env-only, see `.env`. Every key here is optional.
10
+ kestrel: {
11
+ db: '.data/db.sqlite',
12
+ // Baked into canonical URLs, hreflang, sitemap.xml and llms.txt at build time. Set it to the public
13
+ // origin before the first real deploy — or leave it to KESTREL_SITE_URL per environment.
14
+ siteUrl: 'http://localhost:3000',
15
+ siteName: '{{name}}',
16
+ media: { uploadDir: '.data/uploads' },
17
+ // locales: ['en', 'de'],
18
+ // collections: { pages: false },
19
+ },
20
+ })
@@ -0,0 +1,8 @@
1
+ # pnpm blocks dependency build scripts unless they are listed here, and Kestrel's SQLite driver and image
2
+ # pipeline are native — without this `pnpm install` completes but the app cannot start. Delete this file
3
+ # if you use npm or yarn. (`pnpm.onlyBuiltDependencies` in package.json is silently ignored on pnpm 11.)
4
+ allowBuilds:
5
+ '@parcel/watcher': true
6
+ better-sqlite3: true
7
+ esbuild: true
8
+ sharp: true
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.nuxt/tsconfig.json"
3
+ }