@michaelthielemann/kestrel 1.6.0 → 2.0.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 +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/access/server/utils/ip-allowlist.ts +4 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +6 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
- package/layers/collections/server/utils/populate-relations.ts +18 -5
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/api/[collection]/[id].get.ts +4 -1
- package/layers/core/server/api/[collection]/index.get.ts +7 -2
- package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
- package/layers/core/server/api/references/broken.get.ts +7 -1
- package/layers/core/server/schema/dialect.ts +5 -3
- package/layers/core/server/schema/introspect.ts +4 -3
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +11 -10
- package/layers/core/server/utils/kestrel-config.ts +10 -3
- package/layers/core/server/utils/populate.ts +3 -1
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/server/utils/backfill.ts +24 -12
- package/layers/media/server/utils/variants.ts +27 -12
- package/layers/public/app/pages/[...slug].vue +35 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/api/route.get.ts +26 -6
- package/layers/public/server/plugins/02.register-links.ts +4 -4
- package/layers/public/server/plugins/zz.publish.ts +13 -4
- package/layers/public/server/routes/llms.txt.get.ts +5 -2
- package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/link-resolve.ts +5 -2
- package/layers/public/server/utils/page-resolve.ts +23 -9
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +43 -4
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +74 -9
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +9 -4
- package/scripts/kestrel.mjs +48 -19
- package/scripts/lib/scaffold.mjs +39 -8
- package/templates/starter/app/blocks/Prose.vue +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michaelthielemann/kestrel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=22.12.0"
|
|
46
46
|
},
|
|
47
|
+
"//lint": "The root eslint.config.mjs imports the generated playground/.nuxt/eslint.config.mjs (Nuxt-aware: auto-import globals for every layer + extension) — `nuxt prepare` must run first or that import 404s on a fresh checkout. Cheap on a warm .nuxt cache, so it's chained rather than a separate step.",
|
|
47
48
|
"scripts": {
|
|
48
49
|
"dev": "nuxt dev",
|
|
49
50
|
"build": "nuxt build",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
57
58
|
"test:nuxt": "vitest run --config vitest.nuxt.config.ts",
|
|
58
59
|
"typecheck": "node scripts/typecheck.mjs",
|
|
60
|
+
"lint": "nuxt prepare playground && eslint .",
|
|
59
61
|
"hash-password": "node scripts/hash-password.mjs"
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
@@ -68,6 +70,9 @@
|
|
|
68
70
|
"@tiptap/pm": "^3.29.2",
|
|
69
71
|
"@tiptap/starter-kit": "^3.29.2",
|
|
70
72
|
"@tiptap/vue-3": "^3.29.2",
|
|
73
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
74
|
+
"@types/jsdom": "^28.0.3",
|
|
75
|
+
"@types/sanitize-html": "^2.16.1",
|
|
71
76
|
"aws4fetch": "^1.0.20",
|
|
72
77
|
"better-sqlite3": "^12.11.1",
|
|
73
78
|
"dompurify": "^3.4.13",
|
|
@@ -107,14 +112,14 @@
|
|
|
107
112
|
}
|
|
108
113
|
},
|
|
109
114
|
"devDependencies": {
|
|
115
|
+
"@nuxt/eslint": "^1.9.0",
|
|
110
116
|
"@nuxt/kit": "^4.5.2",
|
|
111
117
|
"@nuxt/test-utils": "^4.1.0",
|
|
112
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
113
|
-
"@types/jsdom": "^28.0.3",
|
|
114
|
-
"@types/sanitize-html": "^2.16.1",
|
|
115
118
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
116
119
|
"@vue/test-utils": "^2.4.11",
|
|
117
120
|
"drizzle-kit": "^0.31.10",
|
|
121
|
+
"eslint": "^9.39.1",
|
|
122
|
+
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
|
118
123
|
"h3": "^1.15.11",
|
|
119
124
|
"happy-dom": "^20.11.2",
|
|
120
125
|
"playwright-core": "^1.62.1",
|
package/scripts/kestrel.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { join, resolve, basename, relative } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { hashPassword, sessionSecret } from './lib/password.mjs'
|
|
6
|
-
import { PACKAGE_NAME, diagnoseProject, mergeEnv, mergePackageJson, renderTemplate, targetName, toPackageName } from './lib/scaffold.mjs'
|
|
6
|
+
import { PACKAGE_NAME, diagnoseProject, envValue, isPlainObject, mergeEnv, mergePackageJson, renderTemplate, targetName, toPackageName } from './lib/scaffold.mjs'
|
|
7
7
|
import { Cancelled, MIN_PASSWORD_LENGTH, makePaint, out, parseArgs, promptPassword, readIf, readStdin, walk, write } from './lib/cli.mjs'
|
|
8
8
|
|
|
9
9
|
// Node builtins only, no build step: runs the same from a checkout, from node_modules and via `pnpm dlx`.
|
|
@@ -30,13 +30,16 @@ function inspect(target) {
|
|
|
30
30
|
packageJson: readIf(join(target, 'package.json')),
|
|
31
31
|
nuxtConfig: readIf(join(target, 'nuxt.config.ts')) ?? readIf(join(target, 'nuxt.config.js')),
|
|
32
32
|
appVue: readIf(join(target, 'app', 'app.vue')) ?? readIf(join(target, 'app.vue')),
|
|
33
|
+
kestrelConfig: readIf(join(target, 'kestrel.config.ts')) ?? readIf(join(target, 'kestrel.config.js')),
|
|
33
34
|
env: readIf(join(target, '.env')),
|
|
34
35
|
})
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
const LEVEL_LABEL = { error: () => red('✖ error'), info: () => dim('· note '), warn: () => yellow('▲ warn ') }
|
|
39
|
+
|
|
37
40
|
function printFindings(found) {
|
|
38
41
|
for (const d of found) {
|
|
39
|
-
out(`${d.level
|
|
42
|
+
out(`${(LEVEL_LABEL[d.level] ?? LEVEL_LABEL.warn)()} ${d.message}`)
|
|
40
43
|
out()
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -46,18 +49,25 @@ async function init(positional, flags) {
|
|
|
46
49
|
const templateDir = join(TEMPLATES, 'starter')
|
|
47
50
|
if (!existsSync(templateDir)) fail(`template payload missing at ${templateDir} — reinstall ${PACKAGE_NAME}.`)
|
|
48
51
|
|
|
49
|
-
//
|
|
52
|
+
// Read the target before touching disk: a half-scaffolded project is worse than a refused one.
|
|
50
53
|
const manifestPath = join(target, 'package.json')
|
|
51
54
|
const existingManifest = readIf(manifestPath)
|
|
52
55
|
if (existingManifest !== null) {
|
|
56
|
+
let parsed
|
|
53
57
|
try {
|
|
54
|
-
JSON.parse(existingManifest)
|
|
58
|
+
parsed = JSON.parse(existingManifest)
|
|
55
59
|
} catch {
|
|
56
60
|
fail(`${manifestPath} is not valid JSON — fix it first; refusing to scaffold over a broken manifest.`)
|
|
57
61
|
}
|
|
62
|
+
if (!isPlainObject(parsed)) fail(`${manifestPath} is not a JSON object — fix it first; refusing to scaffold over a broken manifest.`)
|
|
58
63
|
}
|
|
64
|
+
const envPath = join(target, '.env')
|
|
65
|
+
const existingEnv = readIf(envPath)
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
// No flag swallows a `-`-prefixed token, so a dash-leading password reaches init as no value at all.
|
|
68
|
+
if (flags.password === true) fail('--password needs a value — write --password=<pw> if it starts with a dash.')
|
|
69
|
+
const supplied = typeof flags.password === 'string'
|
|
70
|
+
let password = supplied ? flags.password : undefined
|
|
61
71
|
if (password !== undefined && password.length < MIN_PASSWORD_LENGTH) {
|
|
62
72
|
fail(`--password must be at least ${MIN_PASSWORD_LENGTH} characters (an empty one would leave /admin open).`)
|
|
63
73
|
}
|
|
@@ -70,13 +80,21 @@ async function init(positional, flags) {
|
|
|
70
80
|
out()
|
|
71
81
|
|
|
72
82
|
if (password === undefined && !flags.yes && process.stdin.isTTY) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// The prompt loops until a valid password is typed twice, so it cannot be declined, and the hash is
|
|
84
|
+
// folded into the session signing key — asking on a project that already has one would rotate the
|
|
85
|
+
// password, and sign everyone out, without ever offering the operator a way to say no.
|
|
86
|
+
if (envValue(existingEnv ?? '', 'KESTREL_ADMIN_PASSWORD_HASH')) {
|
|
87
|
+
out(dim('KESTREL_ADMIN_PASSWORD_HASH is already set — pass --password to change it.'))
|
|
88
|
+
out()
|
|
89
|
+
} else {
|
|
90
|
+
try {
|
|
91
|
+
password = await promptPassword({ warn: (m) => out(yellow(m)), note: (m) => out(dim(m)) })
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof Cancelled) fail('cancelled')
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
out()
|
|
78
97
|
}
|
|
79
|
-
out()
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
const vars = templateVars(projectName)
|
|
@@ -109,12 +127,14 @@ async function init(positional, flags) {
|
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
// Seed from `.env.example` so the generated file keeps its per-key comments.
|
|
112
|
-
const
|
|
113
|
-
const hadEnv = existsSync(envPath)
|
|
130
|
+
const hadEnv = existingEnv !== null
|
|
114
131
|
const envEntries = { KESTREL_SESSION_SECRET: sessionSecret(), KESTREL_SECURE_COOKIES: 'false' }
|
|
115
132
|
if (password !== undefined) envEntries.KESTREL_ADMIN_PASSWORD_HASH = hashPassword(password)
|
|
116
|
-
const seed =
|
|
117
|
-
|
|
133
|
+
const seed = existingEnv ?? readIf(join(target, '.env.example')) ?? ''
|
|
134
|
+
// Only an explicitly supplied password replaces a hash that is already set. That is not session-safety:
|
|
135
|
+
// the hash is folded into the cookie signing key, so any rotation signs every user out. What keeps a
|
|
136
|
+
// plain re-run harmless is that it changes neither the hash nor the secret.
|
|
137
|
+
const { text, written } = mergeEnv(seed, envEntries, supplied ? ['KESTREL_ADMIN_PASSWORD_HASH'] : [])
|
|
118
138
|
if (written.length) {
|
|
119
139
|
write(envPath, text, 0o600)
|
|
120
140
|
;(hadEnv ? merged : created).push(`.env ${dim(`(${written.join(', ')})`)}`)
|
|
@@ -153,10 +173,11 @@ function doctor(positional) {
|
|
|
153
173
|
const found = inspect(target)
|
|
154
174
|
|
|
155
175
|
out()
|
|
156
|
-
|
|
176
|
+
// An `info` finding is a note about how this project is configured, not a problem with it — so it prints,
|
|
177
|
+
// but it must not withhold the all-clear the way a warning does.
|
|
178
|
+
if (!found.some((d) => d.level !== 'info')) {
|
|
157
179
|
out(`${green('✔')} ${relative(process.cwd(), target) || '.'} looks like a working Kestrel project.`)
|
|
158
180
|
out()
|
|
159
|
-
return 0
|
|
160
181
|
}
|
|
161
182
|
printFindings(found)
|
|
162
183
|
return found.some((d) => d.level === 'error') ? 1 : 0
|
|
@@ -191,7 +212,7 @@ ${bold('kestrel')} ${dim(`v${pkg.version}`)}
|
|
|
191
212
|
|
|
192
213
|
${bold('init flags')}
|
|
193
214
|
--name <name> package name (default: the directory name, slugified)
|
|
194
|
-
--password <pw> set the admin password without prompting
|
|
215
|
+
--password <pw> set or change the admin password without prompting
|
|
195
216
|
--yes never prompt; leaves KESTREL_ADMIN_PASSWORD_HASH for you to fill in
|
|
196
217
|
--force overwrite existing files (package.json and .env are always merged, never replaced)
|
|
197
218
|
|
|
@@ -199,9 +220,17 @@ ${dim('Existing files are kept and re-running init is safe. To create a NEW proj
|
|
|
199
220
|
`)
|
|
200
221
|
}
|
|
201
222
|
|
|
202
|
-
const
|
|
223
|
+
const argv = process.argv.slice(2)
|
|
224
|
+
const { flags, positional } = parseArgs(argv, ['yes', 'force', 'help', 'version'])
|
|
203
225
|
const command = positional.shift()
|
|
204
226
|
|
|
227
|
+
// Only these two read a positional as a directory, and an option that took no value lands there: a
|
|
228
|
+
// `--password -pw` would otherwise put the cleartext on disk as a directory name. `--` is how the caller
|
|
229
|
+
// says a dash-leading positional is what they meant.
|
|
230
|
+
if (!argv.includes('--') && (command === 'init' || command === 'doctor') && positional[0]?.startsWith('-')) {
|
|
231
|
+
fail(`unknown option "${positional[0]}" — run \`kestrel help\`.`)
|
|
232
|
+
}
|
|
233
|
+
|
|
205
234
|
if (flags.version || command === 'version') out(pkg.version)
|
|
206
235
|
else if (flags.help || !command || command === 'help') help()
|
|
207
236
|
else if (command === 'init') process.exitCode = await init(positional, flags)
|
package/scripts/lib/scaffold.mjs
CHANGED
|
@@ -31,9 +31,12 @@ const KEY_RE = /^\s*([A-Z][A-Z0-9_]*)\s*=/
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Fills only keys that are absent or empty, so re-running `init` never rotates a live session secret.
|
|
34
|
+
* A key in `force` replaces a value that is already there — for a secret the caller supplied by hand,
|
|
35
|
+
* where being handed one at all is the instruction to change it.
|
|
34
36
|
* Returns the new text plus the keys that changed.
|
|
35
37
|
*/
|
|
36
|
-
export function mergeEnv(existing, entries) {
|
|
38
|
+
export function mergeEnv(existing, entries, force = []) {
|
|
39
|
+
const forced = new Set(force)
|
|
37
40
|
const pending = new Map(Object.entries(entries))
|
|
38
41
|
const lines = existing === '' ? [] : existing.split('\n')
|
|
39
42
|
const written = []
|
|
@@ -51,7 +54,7 @@ export function mergeEnv(existing, entries) {
|
|
|
51
54
|
if (!key || !pending.has(key) || lastIndex.get(key) !== i) return line
|
|
52
55
|
const value = pending.get(key)
|
|
53
56
|
pending.delete(key)
|
|
54
|
-
if (line.slice(line.indexOf('=') + 1).trim() !== '') return line
|
|
57
|
+
if (line.slice(line.indexOf('=') + 1).trim() !== '' && !forced.has(key)) return line
|
|
55
58
|
written.push(key)
|
|
56
59
|
return `${key}=${value}`
|
|
57
60
|
})
|
|
@@ -68,7 +71,8 @@ export function mergeEnv(existing, entries) {
|
|
|
68
71
|
return { text: out.join('\n'), written }
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
/** `typeof null === 'object'`, and arrays are objects too — both need explicit exclusion for a plain object check. */
|
|
75
|
+
export const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
72
76
|
|
|
73
77
|
const NESTED = ['scripts', 'dependencies', 'devDependencies']
|
|
74
78
|
// `type` decides whether every .js in the project is ESM or CJS, so injecting it silently could break a
|
|
@@ -87,24 +91,41 @@ export function mergePackageJson(existing, template) {
|
|
|
87
91
|
}
|
|
88
92
|
for (const key of NESTED) {
|
|
89
93
|
if (!template[key]) continue
|
|
90
|
-
merged[key] =
|
|
94
|
+
merged[key] = isPlainObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
|
|
91
95
|
for (const name of Object.keys(template[key])) {
|
|
92
|
-
if (!
|
|
96
|
+
if (!isPlainObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
return { merged, added }
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The value a dotenv key carries, or `undefined`/`''` when it carries none.
|
|
104
|
+
* Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
|
|
105
|
+
*/
|
|
106
|
+
export const envValue = (env, key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
|
|
107
|
+
|
|
98
108
|
const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
|
|
99
109
|
// `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
|
|
100
110
|
const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
|
|
101
111
|
export const usesComponent = (src, tag) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
|
|
102
112
|
|
|
113
|
+
/** Whether `output.publishOnSave` resolves to `true` — config or its env override, same precedence as
|
|
114
|
+
* `resolveKestrel` (env wins when it names a valid boolean; otherwise the config file's own value). */
|
|
115
|
+
function publishOnSaveEnabled(kestrelConfig, env) {
|
|
116
|
+
const fromEnv = env !== null ? envValue(env, 'KESTREL_OUTPUT_PUBLISH_ON_SAVE')?.toLowerCase() : undefined
|
|
117
|
+
if (fromEnv !== undefined) {
|
|
118
|
+
if (['true', '1', 'yes', 'on'].includes(fromEnv)) return true
|
|
119
|
+
if (['false', '0', 'no', 'off'].includes(fromEnv)) return false
|
|
120
|
+
}
|
|
121
|
+
return typeof kestrelConfig === 'string' && /\bpublishOnSave\s*:\s*true\b/.test(kestrelConfig)
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/**
|
|
104
125
|
* `kestrel doctor`, as a pure function of what was read; `null` means the file is absent.
|
|
105
126
|
* The `app.vue` rules mirror `layers/core/modules/kestrel/app-shell.ts`; a test pins the two together.
|
|
106
127
|
*/
|
|
107
|
-
export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
128
|
+
export function diagnoseProject({ packageJson, nuxtConfig, appVue, env, kestrelConfig }) {
|
|
108
129
|
const found = []
|
|
109
130
|
const add = (level, message) => found.push({ level, message })
|
|
110
131
|
|
|
@@ -160,8 +181,7 @@ export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
|
160
181
|
if (env === null) {
|
|
161
182
|
add('error', 'no .env — sign-in at /admin answers 503 until KESTREL_ADMIN_PASSWORD_HASH is set. Run `kestrel init`.')
|
|
162
183
|
} else {
|
|
163
|
-
|
|
164
|
-
const value = (key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
|
|
184
|
+
const value = (key) => envValue(env, key)
|
|
165
185
|
if (!value('KESTREL_ADMIN_PASSWORD_HASH')) {
|
|
166
186
|
add('error', 'KESTREL_ADMIN_PASSWORD_HASH is unset — /admin renders but sign-in answers 503. Run `kestrel hash-password`.')
|
|
167
187
|
}
|
|
@@ -170,5 +190,16 @@ export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
// Informational, not a misconfiguration: `publishOnSave: false` is the correct 2.0 default. Named
|
|
194
|
+
// because it is a silent behaviour change from 1.x — a headless consumer that only PATCHes content and
|
|
195
|
+
// never opens /admin gets no other signal that saves stopped reaching the static output.
|
|
196
|
+
if (!publishOnSaveEnabled(kestrelConfig, env)) {
|
|
197
|
+
add(
|
|
198
|
+
'info',
|
|
199
|
+
'saving a record no longer publishes it by default — only an explicit Publish action does (ADR-0008). ' +
|
|
200
|
+
'Set `output.publishOnSave: true` in kestrel.config.ts, or KESTREL_OUTPUT_PUBLISH_ON_SAVE=1, to restore the pre-2.0 behaviour.',
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
173
204
|
return found
|
|
174
205
|
}
|
|
@@ -8,5 +8,6 @@ defineBlock({ label: { en: 'Prose' }, icon: 'file-text' })
|
|
|
8
8
|
|
|
9
9
|
<template>
|
|
10
10
|
<!-- richtext is sanitized server-side on write -->
|
|
11
|
+
<!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in layers/fields/server/field-registry/index.ts -->
|
|
11
12
|
<div class="block-prose" v-html="body" />
|
|
12
13
|
</template>
|