@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.
Files changed (79) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/access/server/utils/ip-allowlist.ts +4 -1
  4. package/layers/admin/app/components/BlocksBody.vue +2 -1
  5. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  6. package/layers/admin/app/components/CollectionList.vue +8 -4
  7. package/layers/admin/app/components/EditorStatus.vue +11 -0
  8. package/layers/admin/app/components/SeoFields.vue +1 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +6 -3
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/editor-expose.ts +8 -0
  17. package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
  18. package/layers/collections/server/utils/populate-relations.ts +18 -5
  19. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  20. package/layers/core/modules/kestrel/index.ts +1 -0
  21. package/layers/core/server/api/[collection]/[id].get.ts +4 -1
  22. package/layers/core/server/api/[collection]/index.get.ts +7 -2
  23. package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
  24. package/layers/core/server/api/references/broken.get.ts +7 -1
  25. package/layers/core/server/schema/dialect.ts +5 -3
  26. package/layers/core/server/schema/introspect.ts +4 -3
  27. package/layers/core/server/schema/sync.ts +1 -1
  28. package/layers/core/server/utils/crud.ts +11 -10
  29. package/layers/core/server/utils/kestrel-config.ts +10 -3
  30. package/layers/core/server/utils/populate.ts +3 -1
  31. package/layers/fields/server/field-registry/index.ts +2 -1
  32. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  33. package/layers/media/app/components/MediaLibrary.vue +4 -1
  34. package/layers/media/app/components/MediaToolbar.vue +1 -1
  35. package/layers/media/app/components/field/Media.vue +2 -0
  36. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  37. package/layers/media/server/utils/backfill.ts +24 -12
  38. package/layers/media/server/utils/variants.ts +27 -12
  39. package/layers/public/app/pages/[...slug].vue +35 -7
  40. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  41. package/layers/public/app/utils/preview-protocol.ts +36 -0
  42. package/layers/public/server/api/preview.get.ts +28 -0
  43. package/layers/public/server/api/preview.post.ts +93 -0
  44. package/layers/public/server/api/publish-status.get.ts +23 -9
  45. package/layers/public/server/api/publish.post.ts +84 -0
  46. package/layers/public/server/api/route.get.ts +26 -6
  47. package/layers/public/server/plugins/02.register-links.ts +4 -4
  48. package/layers/public/server/plugins/zz.publish.ts +13 -4
  49. package/layers/public/server/routes/llms.txt.get.ts +5 -2
  50. package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
  51. package/layers/public/server/tasks/publish/run.ts +2 -1
  52. package/layers/public/server/utils/link-resolve.ts +5 -2
  53. package/layers/public/server/utils/page-resolve.ts +23 -9
  54. package/layers/public/server/utils/preview-token.ts +109 -0
  55. package/layers/public/server/utils/publish/invalidation.ts +43 -4
  56. package/layers/public/server/utils/publish/pending.ts +74 -0
  57. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  58. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  59. package/layers/public/server/utils/publish/publisher.ts +74 -9
  60. package/layers/ui/app/components/field/Datetime.vue +2 -0
  61. package/layers/ui/app/components/field/Repeater.vue +2 -0
  62. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  63. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  64. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  65. package/layers/ui/app/components/ui/Field.vue +1 -0
  66. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  67. package/layers/ui/app/components/ui/Icon.vue +2 -2
  68. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  69. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  70. package/layers/ui/app/components/ui/Select.vue +1 -0
  71. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  72. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  73. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  74. package/layers/ui/app/i18n/de.ts +10 -0
  75. package/layers/ui/app/i18n/en.ts +10 -0
  76. package/package.json +9 -4
  77. package/scripts/kestrel.mjs +48 -19
  78. package/scripts/lib/scaffold.mjs +39 -8
  79. 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": "1.6.0",
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",
@@ -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 === 'error' ? red('✖ error') : yellow('▲ warn ')} ${d.message}`)
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
- // Validate before touching disk: a half-scaffolded project is worse than a refused one.
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
- let password = typeof flags.password === 'string' ? flags.password : undefined
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
- try {
74
- password = await promptPassword({ warn: (m) => out(yellow(m)), note: (m) => out(dim(m)) })
75
- } catch (err) {
76
- if (err instanceof Cancelled) fail('cancelled')
77
- throw err
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 envPath = join(target, '.env')
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 = hadEnv ? readFileSync(envPath, 'utf8') : (readIf(join(target, '.env.example')) ?? '')
117
- const { text, written } = mergeEnv(seed, envEntries)
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
- if (!found.length) {
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 { flags, positional } = parseArgs(process.argv.slice(2), ['yes', 'force', 'help', 'version'])
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)
@@ -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
- const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
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] = isObject(existing[key]) ? { ...template[key], ...existing[key] } : template[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 (!isObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
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
- // 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()
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>