@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.
- package/README.md +33 -3
- package/layers/admin/app/components/PageFields.vue +25 -0
- package/layers/admin/app/composables/useEditForm.ts +5 -0
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
- package/layers/auth/nuxt.config.ts +0 -0
- package/layers/auth/server/api/auth/session.get.ts +0 -0
- package/layers/auth/server/utils/password.ts +0 -0
- package/layers/auth/server/utils/session.ts +5 -2
- package/layers/collections/nuxt.config.ts +0 -0
- package/layers/core/app/composables/layouts.ts +5 -0
- package/layers/core/app/utils/layouts.ts +38 -0
- package/layers/core/modules/auto-discovery/index.ts +11 -1
- package/layers/core/modules/auto-discovery/virtual.d.ts +3 -0
- package/layers/core/modules/kestrel/app-shell.ts +55 -0
- package/layers/core/modules/kestrel/index.ts +17 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
- package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
- package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
- package/layers/core/server/utils/blocks.ts +0 -0
- package/layers/core/server/utils/seo.ts +0 -0
- package/layers/fields/nuxt.config.ts +0 -0
- package/layers/fields/server/utils/buildTable.ts +8 -2
- package/layers/media/server/api/media/[id].get.ts +0 -0
- package/layers/media/server/api/media/index.get.ts +0 -0
- package/layers/public/app/app.vue +0 -0
- package/layers/public/app/error.vue +0 -0
- package/layers/public/app/layouts/default.vue +0 -0
- package/layers/public/app/pages/[...slug].vue +31 -15
- package/layers/public/app/utils/page-layout.ts +18 -0
- package/layers/ui/app/assets/scss/_reset.scss +0 -0
- package/layers/ui/app/assets/scss/main.scss +0 -0
- package/layers/ui/app/components/ui/Alert.vue +0 -0
- package/layers/ui/app/i18n/de.ts +3 -0
- package/layers/ui/app/i18n/en.ts +3 -0
- package/package.json +6 -2
- package/scripts/copy-create-payload.mjs +49 -0
- package/scripts/hash-password.mjs +6 -15
- package/scripts/kestrel.mjs +216 -0
- package/scripts/lib/cli.mjs +114 -0
- package/scripts/lib/password.mjs +19 -0
- package/scripts/lib/scaffold.mjs +174 -0
- package/templates/starter/README.md +65 -0
- package/templates/starter/_env.example +17 -0
- package/templates/starter/_gitignore +26 -0
- package/templates/starter/_package.json +22 -0
- package/templates/starter/app/app.vue +7 -0
- package/templates/starter/app/blocks/Prose.vue +12 -0
- package/templates/starter/app/layouts/default.vue +6 -0
- package/templates/starter/nuxt.config.ts +20 -0
- package/templates/starter/pnpm-workspace.yaml +8 -0
- 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,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,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
|