@michaelthielemann/kestrel 2.0.0 → 2.1.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 (55) hide show
  1. package/README.md +15 -8
  2. package/layers/admin/app/components/CollectionList.vue +5 -1
  3. package/layers/admin/app/components/PageFields.vue +5 -0
  4. package/layers/admin/app/components/SeoFields.vue +42 -0
  5. package/layers/admin/app/composables/useEditForm.ts +9 -3
  6. package/layers/admin/app/utils/edit-form.ts +9 -2
  7. package/layers/core/modules/kestrel/index.ts +11 -0
  8. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  9. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  10. package/layers/core/server/utils/collection-types.ts +4 -3
  11. package/layers/core/server/utils/defineCollection.ts +8 -1
  12. package/layers/core/server/utils/kestrel-config.ts +35 -1
  13. package/layers/core/server/utils/seo.ts +18 -0
  14. package/layers/core/server/utils/write-effects.ts +40 -0
  15. package/layers/fields/server/utils/buildCollection.ts +9 -7
  16. package/layers/media/app/components/KestrelImg.vue +29 -0
  17. package/layers/media/app/components/MediaLibrary.vue +7 -5
  18. package/layers/media/app/components/MediaViewer.vue +56 -7
  19. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  20. package/layers/media/app/utils/library.ts +2 -0
  21. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  22. package/layers/media/server/api/media/[id].patch.ts +25 -1
  23. package/layers/media/server/api/media/index.post.ts +19 -3
  24. package/layers/media/server/collections/media.ts +14 -0
  25. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  26. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  27. package/layers/media/server/utils/library.ts +2 -1
  28. package/layers/media/server/utils/record.ts +8 -0
  29. package/layers/media/server/utils/resolve.ts +12 -0
  30. package/layers/public/app/pages/[...slug].vue +30 -1
  31. package/layers/public/app/utils/json-ld.ts +139 -0
  32. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  33. package/layers/public/modules/prerender-routes/index.ts +5 -2
  34. package/layers/public/server/api/route.get.ts +9 -1
  35. package/layers/public/server/collections/redirects.ts +75 -0
  36. package/layers/public/server/plugins/03.redirects.ts +37 -0
  37. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  38. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  39. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  40. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  41. package/layers/public/server/utils/llms-full.ts +125 -0
  42. package/layers/public/server/utils/llms.ts +13 -0
  43. package/layers/public/server/utils/page-resolve.ts +112 -4
  44. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  45. package/layers/public/server/utils/publish/publisher.ts +11 -5
  46. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  47. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  48. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  49. package/layers/public/server/utils/site-url.ts +8 -0
  50. package/layers/public/server/utils/sitemap.ts +5 -3
  51. package/layers/ui/app/components/field/Choice.vue +6 -1
  52. package/layers/ui/app/i18n/de.ts +13 -0
  53. package/layers/ui/app/i18n/en.ts +13 -0
  54. package/package.json +2 -1
  55. package/templates/starter/nuxt.config.ts +3 -0
package/README.md CHANGED
@@ -23,7 +23,8 @@ static host. It is deliberately **not**:
23
23
  - **SQLite only** — one database, single instance. (A Postgres `Dialect` seam exists; no impl is active.)
24
24
  - **No public data API** — only published *page-like* records have a read surface (mirroring the static
25
25
  output); everything else stays behind the admin guard. No per-request rate limiting / live API.
26
- - **No runtime redirect engine** — handle redirects at the edge (NGINX / CDN).
26
+ - **No runtime redirect engine** — redirects are *authored* in the CMS and published as a `redirects.json`
27
+ artifact, but Kestrel never answers a 30x itself; an edge (NGINX / njs / CloudFront) has to read it.
27
28
  - **No per-file access control on uploads.** The admin guard protects the media *library* — listing,
28
29
  editing, deleting — but not the bytes. With `media.driver: 'local'` the files are served from the app
29
30
  origin by Nitro's static handler, which runs ahead of every middleware, so anyone who knows a URL can
@@ -49,13 +50,18 @@ static host. It is deliberately **not**:
49
50
  - **Block page-builder** — a 3-pane editor (tree · live preview · fields) with nestable block slots; each
50
51
  block is a single `app/blocks/*.vue` SFC (schema via field-factory `defineProps` + the display template).
51
52
  - **Media library** — upload (magic-byte sniff + SVG sanitize), local or S3 storage, responsive WebP
52
- derivatives + thumbhash, folders, and a full-screen asset viewer.
53
+ derivatives + thumbhash, folders, and a full-screen asset viewer. Optional **EU AI Act (Art. 50)
54
+ disclosure** fields per asset, with an upload-time scan for AI-origin signals (off by default).
53
55
  - **Multilingual content** — optional per-record translations, an editor locale flow, locale-prefixed
54
56
  routing, hreflang alternates in the sitemap.
55
57
  - **Static output** — `nuxt generate` (full rebuild) or a runtime **incremental publisher** (re-renders
56
- only the pages a write affects), to a local dir or S3; emits `sitemap.xml`, `robots.txt`, and
57
- **`llms.txt`** (an [llmstxt.org](https://llmstxt.org) site map so AI agents grasp the site). A live
58
- in-dashboard preview renders straight from the origin.
58
+ only the pages a write affects), to a local dir or S3; emits `sitemap.xml`, `robots.txt`,
59
+ **`llms.txt`** (an [llmstxt.org](https://llmstxt.org) site map so AI agents grasp the site) and
60
+ `redirects.json`. A live in-dashboard preview renders straight from the origin.
61
+ - **Search + answer engines** — canonical / Open Graph / hreflang tags and a schema.org JSON-LD graph
62
+ (`WebSite` + `WebPage`/`Article` + `BreadcrumbList`) on every page, no wiring. Two opt-in extras publish
63
+ more than the page already showed: `seo.articleMeta` (author / publication date / keywords) and
64
+ `seo.llmsFull` (`/llms-full.txt`, every published page's body in one document).
59
65
  - **Reference integrity** — writes invalidate exactly the affected static pages; dead-reference warnings;
60
66
  required + globally-unique page slugs per locale.
61
67
 
@@ -126,9 +132,9 @@ where to start reading, the cross-layer seams, the gotchas). Then the per-topic
126
132
  | [consuming-kestrel.md](docs/consuming-kestrel.md) | Using Kestrel in your own app: defining collections/fields/blocks, auto-discovery, the schema lifecycle |
127
133
  | [configuration.md](docs/configuration.md) | The single config source (`kestrel.config.ts`), every `KESTREL_*` env var, the auth/session env split |
128
134
  | [block-editing.md](docs/block-editing.md) | The block content model + the 3-pane block editor (tree · preview · fields) |
129
- | [media-uploads.md](docs/media-uploads.md) | Ingest security, storage drivers (local/S3), responsive-image derivation |
135
+ | [media-uploads.md](docs/media-uploads.md) | Ingest security, storage drivers (local/S3), responsive-image derivation, EU AI Act disclosure |
130
136
  | [multilingual.md](docs/multilingual.md) | Content locales, the editor locale flow, locale-prefixed routing |
131
- | [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt`, the optional S3 deploy |
137
+ | [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt` / `llms-full.txt`, the JSON-LD structured data, CMS-managed redirects, the optional S3 deploy |
132
138
  | [reference-integrity.md](docs/reference-integrity.md) | How writes invalidate the static site precisely, dead-reference warnings (a dead link renders `#`, and the editor is warned), and required/unique page slugs |
133
139
  | [architecture-decisions.md](docs/architecture-decisions.md) | ADRs (the collection-derived schema engine, reference integrity, the auth/password choice) |
134
140
 
@@ -147,7 +153,8 @@ The CMS is split into Nuxt layers under `layers/`:
147
153
  lives in the repo root, dev-only, and is **not** shipped in the package.)
148
154
  - **`media`** — uploads, pluggable storage, image derivation, the media library + asset viewer.
149
155
  - **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
150
- - **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, sitemap / robots / llms.txt, deploy.
156
+ - **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, the JSON-LD head, the literal-key
157
+ artifacts (sitemap / robots / llms.txt / llms-full.txt / redirects.json), deploy.
151
158
 
152
159
  `playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
153
160
  `scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
3
+ import type { Localized } from '../../../core/server/utils/defineCollection'
3
4
  import { sortDirection, type FilterCell } from '../utils/list-query'
4
5
  import type { ListColumn } from '../utils/list-columns'
5
6
  import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
@@ -74,7 +75,10 @@ function enumOptions(c: ListColumn): { label: string; value: string }[] {
74
75
  { value: 'draft', label: t('pageSettings.statusDraft') },
75
76
  { value: 'published', label: t('pageSettings.statusPublished') },
76
77
  ]
77
- return ((c.field?.options?.choices ?? []) as { label: string; value: string }[])
78
+ // A choice label is `Localized`, so resolve it here as the editor widget does `{{ o.label }}` would
79
+ // stringify a `{ en, de }` map into the filter dropdown.
80
+ return ((c.field?.options?.choices ?? []) as { label: Localized; value: string }[])
81
+ .map((o) => ({ value: o.value, label: resolveLocalized(o.label, lang.value) ?? o.value }))
78
82
  }
79
83
  function displayValue(kind: FilterKind, value: string): string {
80
84
  if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
@@ -30,6 +30,10 @@ const { t } = useT()
30
30
  const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
31
31
  const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
32
32
 
33
+ // Article metadata is an installation-wide policy (`kestrel.seo.articleMeta`), not a per-collection one,
34
+ // so it comes from runtimeConfig rather than the serialized collection schema.
35
+ const articleMeta = computed(() => (useRuntimeConfig().public as { seoArticleMeta?: boolean }).seoArticleMeta === true)
36
+
33
37
  // Live preview of the slug the server will auto-generate from the title while the field is left blank
34
38
  // (the server slugifies the title on save). Falls back to '/' when there is no title yet.
35
39
  const slugPlaceholder = computed(() => {
@@ -121,6 +125,7 @@ const slugPlaceholder = computed(() => {
121
125
  :path="(values.path as string) ?? ''"
122
126
  :locale="locale"
123
127
  :disabled="disabled"
128
+ :article-meta="articleMeta"
124
129
  @update="(v) => emit('update', 'seo', v)"
125
130
  />
126
131
  </template>
@@ -12,6 +12,10 @@ const props = defineProps<{
12
12
  path?: string
13
13
  locale: string
14
14
  disabled?: boolean
15
+ /** `kestrel.seo.articleMeta`. Off (the default) hides author/date/keywords entirely: an installation
16
+ * that must not attribute content should never be offered the fields, not merely stop publishing
17
+ * them. Stored values are hidden, never cleared — turning the flag back on restores them. */
18
+ articleMeta?: boolean
15
19
  }>()
16
20
  const emit = defineEmits<{ update: [seo: SeoMeta] }>()
17
21
  const { t } = useT()
@@ -90,6 +94,44 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
90
94
  </template>
91
95
  </UiField>
92
96
 
97
+ <!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
98
+ <template v-if="articleMeta">
99
+ <UiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
100
+ <template #default="f">
101
+ <UiTextInput
102
+ :model-value="value.author ?? ''"
103
+ :disabled="disabled"
104
+ v-bind="f"
105
+ @update:model-value="(v) => patch({ author: v ?? '' })"
106
+ />
107
+ </template>
108
+ </UiField>
109
+
110
+ <UiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
111
+ <template #default="f">
112
+ <UiDatePicker
113
+ :model-value="value.publishedDate || null"
114
+ precision="date"
115
+ :disabled="disabled"
116
+ :aria-label="t('seo.publishedDate')"
117
+ :describedby="f['aria-describedby']"
118
+ @update:model-value="(v) => patch({ publishedDate: v ?? '' })"
119
+ />
120
+ </template>
121
+ </UiField>
122
+
123
+ <UiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
124
+ <template #default="f">
125
+ <UiTextInput
126
+ :model-value="value.keywords ?? ''"
127
+ :disabled="disabled"
128
+ v-bind="f"
129
+ @update:model-value="(v) => patch({ keywords: v ?? '' })"
130
+ />
131
+ </template>
132
+ </UiField>
133
+ </template>
134
+
93
135
  <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
94
136
  <label class="seo-fields__noindex">
95
137
  <UiCheckbox
@@ -278,7 +278,11 @@ export function useEditForm(opts: UseEditFormOptions) {
278
278
  }
279
279
 
280
280
  function handleError(e: unknown) {
281
- const { statusCode, statusMessage, issues } = readFetchError(e)
281
+ const { statusCode, statusMessage, issues, data } = readFetchError(e)
282
+ // The row WAS written and a follow-up step failed (a write effect — see write-effects.ts), so the
283
+ // server hands back the new `updatedAt`. Take it as the baseline: the client only rebaselines on
284
+ // success, and without this the "save again" those errors ask for would 409 on a stale precondition.
285
+ if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
282
286
  if (statusCode === 400) {
283
287
  const mapped = mapServerErrors(issues)
284
288
  for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
@@ -292,9 +296,11 @@ export function useEditForm(opts: UseEditFormOptions) {
292
296
  ? t('editor.fixBlockContent')
293
297
  : Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
294
298
  } else if (statusCode === 409) {
295
- formError.value = statusMessage ?? t('editor.saveConflict')
299
+ // `||`, not `??`: an absent reason phrase arrives as '' rather than undefined, and an empty banner
300
+ // is a silent failure — the Save button simply stops spinning.
301
+ formError.value = statusMessage || t('editor.saveConflict')
296
302
  } else {
297
- formError.value = statusMessage ?? t('editor.saveFailed')
303
+ formError.value = statusMessage || t('editor.saveFailed')
298
304
  }
299
305
  }
300
306
 
@@ -17,6 +17,8 @@ export interface FetchErrorInfo {
17
17
  statusCode?: number
18
18
  statusMessage?: string
19
19
  issues: ServerIssue[]
20
+ /** The error body when it is NOT a Zod-issue array — e.g. `{ savedUpdatedAt }` from a failed write effect. */
21
+ data?: Record<string, unknown>
20
22
  }
21
23
 
22
24
  // The wire `SerializedField` is structurally what every Field* widget and `validateField` read
@@ -26,13 +28,18 @@ export function asFieldDef(field: SerializedField): FieldDef {
26
28
  }
27
29
 
28
30
  // Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
31
+ // The BODY's statusMessage wins over the response's: ofetch maps `statusMessage` from `response.statusText`,
32
+ // and HTTP/2 has no reason phrase — behind any h2 proxy that arrives as '' and the real message is only in
33
+ // the JSON. Anything else under `data` (not an issue array) is handed back for the caller to act on.
29
34
  export function readFetchError(e: unknown): FetchErrorInfo {
30
35
  const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
31
- const raw = err.data?.data ?? err.data
36
+ const body = Array.isArray(err.data) ? undefined : err.data
37
+ const raw = body?.data ?? err.data
32
38
  return {
33
39
  statusCode: err.statusCode,
34
- statusMessage: err.statusMessage,
40
+ statusMessage: (body?.statusMessage as string | undefined) || err.statusMessage,
35
41
  issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
42
+ ...(body && !Array.isArray(body.data) ? { data: body } : {}),
36
43
  }
37
44
  }
38
45
 
@@ -56,6 +56,13 @@ export default defineNuxtModule<KestrelConfig>({
56
56
  pub.siteName = c.siteName ?? ''
57
57
  // Reference desktop width (px) for the admin page-builder preset; the editor's scale-to-fit shrinks it.
58
58
  pub.previewDesktopWidth = c.preview.desktopWidth
59
+ // Whether the admin shows the EU AI Act disclosure controls on a media asset; the data itself is
60
+ // always resolved server-side, so flipping this off only hides the editor.
61
+ pub.aiDisclosureEnabled = c.aiDisclosure.enabled
62
+ // Article metadata is an APP-side concern on both ends — the admin SEO panel decides whether to offer
63
+ // the fields, the public page decides whether to publish them as JSON-LD — so the flag has to be
64
+ // client-visible. It gates a disclosure, not a secret.
65
+ pub.seoArticleMeta = c.seo.articleMeta
59
66
 
60
67
  // Server-only resolved settings, so server utils read the CONSUMER's `kestrel: {}` (via this module)
61
68
  // rather than importing Kestrel's own `kestrel.config.ts` file. Essential when Kestrel is consumed as
@@ -67,6 +74,10 @@ export default defineNuxtModule<KestrelConfig>({
67
74
  srv.siteDescription = c.siteDescription
68
75
  // Built-in collection toggles (pages/media) — read by the register plugin to gate built-ins.
69
76
  srv.collections = c.collections
77
+ // Read by the upload route to decide whether to run the AI-signal scan at all.
78
+ srv.aiDisclosure = c.aiDisclosure
79
+ // Answer-engine toggles; `llmsFull` is server-only (it gates a Nitro route and what the publisher writes).
80
+ srv.seo = c.seo
70
81
  // Static-publish target for the runtime publisher (server-only). S3 creds are env-only; prefer the
71
82
  // output-specific creds, fall back to the shared media creds so a single S3 account "just works".
72
83
  srv.output = {
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { putSingleton } from '../../utils/crud'
8
+ import { requireCollection } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { clearWriteListeners } from '../../utils/write-events'
11
+ import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
12
+ import { desiredSchema } from '../../schema/desired'
13
+ import { diffSchema } from '../../schema/diff'
14
+ import { renderSqlite } from '../../schema/render-sqlite'
15
+
16
+ const settings = buildCollection(defineCollection({
17
+ name: 'settings', mode: 'single',
18
+ fields: { title: { type: 'text', required: true } },
19
+ }))
20
+
21
+ interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
22
+
23
+ let db: ReturnType<typeof drizzle>
24
+
25
+ // Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
26
+ // than a stub (same rationale as options.get.test.ts).
27
+ Object.assign(globalThis, {
28
+ defineEventHandler: (handler: unknown) => handler,
29
+ createError,
30
+ getQuery: (event: FakeEvent) => event.query,
31
+ readBody: (event: FakeEvent) => Promise.resolve(event.body),
32
+ useDb: () => db,
33
+ requireAdmin: () => {},
34
+ requireCollection,
35
+ readIfUnmodifiedSince: () => undefined,
36
+ putSingleton,
37
+ runWriteEffects,
38
+ })
39
+
40
+ const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
41
+ const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
42
+
43
+ beforeEach(() => {
44
+ const sqlite = new Database(':memory:')
45
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
46
+ db = drizzle(sqlite)
47
+ clearRegistry()
48
+ registerCollection(settings)
49
+ clearWriteListeners()
50
+ clearWriteEffects()
51
+ })
52
+
53
+ describe('PUT /api/[collection] — singleton save', () => {
54
+ it('writes the row and returns it', async () => {
55
+ await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
56
+ })
57
+
58
+ it('runs post-write effects with the saved row', async () => {
59
+ const seen: unknown[] = []
60
+ registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
61
+ await put('settings', { title: 'Hello' })
62
+ expect(seen).toEqual([['settings', 'Hello']])
63
+ })
64
+
65
+ it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
66
+ registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
67
+ await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
68
+ })
69
+
70
+ it('does not run effects when validation rejects the body', async () => {
71
+ let ran = false
72
+ registerWriteEffect(() => { ran = true })
73
+ await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
74
+ expect(ran).toBe(false)
75
+ })
76
+ })
@@ -3,5 +3,22 @@ export default defineEventHandler(async (event) => {
3
3
  const collection = requireCollection(event)
4
4
  const query = getQuery(event)
5
5
  const body = await readBody(event)
6
- return putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
6
+ const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
7
+ // Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
8
+ // (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
9
+ // write-event bus swallows throws by design. See write-effects.ts.
10
+ try {
11
+ await runWriteEffects(collection.def, saved)
12
+ } catch (error) {
13
+ // The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
14
+ // hand the new baseline back with the error, or the retry these failures ask for would 409 on a
15
+ // precondition that is stale by construction.
16
+ const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
17
+ throw createError({
18
+ statusCode: err.statusCode ?? 500,
19
+ statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
20
+ data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
21
+ })
22
+ }
23
+ return saved
7
24
  })
@@ -20,8 +20,9 @@ export interface BuiltCollection {
20
20
  insert: CollectionSchema
21
21
  update: CollectionSchema
22
22
  select: CollectionSchema
23
- /** Re-enforce `required` for conditional fields whose condition is met against the whole record
24
- * (the per-field schema can't see siblings). Returns Zod-shaped issues keyed by the field's def
25
- * name. Present only when the collection has conditional required fields. */
23
+ /** Every pre-write check the per-field schema can't do because it sees one field at a time: `required`
24
+ * re-enforced for conditional fields whose condition is met against the whole record, plus the
25
+ * collection's own `def.validate`. Returns Zod-shaped issues keyed by the field's def name. Present
26
+ * only when the collection has conditional required fields or a `validate`. */
26
27
  applyConditions?: (record: Record<string, unknown>) => { issues: ConditionIssue[] }
27
28
  }
@@ -97,7 +97,7 @@ export type FieldDef =
97
97
  | (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
98
98
  | (BaseFieldDef & { type: 'boolean' })
99
99
  | (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
100
- | (BaseFieldDef & { type: 'choice'; options: { choices: { label: string; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
100
+ | (BaseFieldDef & { type: 'choice'; options: { choices: { label: Localized; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
101
101
  | (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
102
102
  | (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
103
103
  | (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
@@ -158,6 +158,13 @@ export interface CollectionDef {
158
158
  * so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
159
159
  * nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
160
160
  fieldLayout?: FieldLayoutDSL
161
+ /** Whole-record validation the per-field Zod schema cannot express, because a field validator only ever
162
+ * sees its own value — e.g. a rule set whose rows have to compile as a unit. Runs server-side BEFORE
163
+ * the write, next to the conditional-required check, and its issues become the same field-scoped 400
164
+ * the editor already renders. Server-only: a function, never serialized.
165
+ * Note the asymmetry: `record` is keyed by COLUMN name (`authorId` for a single relation/media field —
166
+ * see `resolveColumnName`), while an issue's `path[0]` is the FIELD key the editor renders against. */
167
+ validate?: (record: Record<string, unknown>) => Array<{ path: (string | number)[]; message: string }>
161
168
  /** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
162
169
  * `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
163
170
  * `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
@@ -138,10 +138,32 @@ export interface KestrelConfig {
138
138
  /** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
139
139
  * `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
140
140
  collections?: { pages?: boolean; media?: boolean }
141
+ /** Search / answer-engine surface. Both flags default to **false** and stay off until a consumer turns
142
+ * them on: each one publishes something the site did not publish before, and an upgrade must never
143
+ * start disclosing it. Everything else on this surface (canonical, OG, hreflang, sitemap, robots,
144
+ * llms.txt, the WebSite/WebPage JSON-LD graph) is unconditional — it only restates what the page head
145
+ * already says. */
146
+ seo?: {
147
+ /** Offer editors `author` / `publishedDate` / `keywords` on the SEO panel and publish them as
148
+ * schema.org `author` / `datePublished` / `keywords` (which also promotes the page's JSON-LD node
149
+ * from `WebPage` to `Article`). Some consumers must not attribute content at all — internal
150
+ * authorship, confidentiality — so nothing is emitted and the fields are not even shown unless this
151
+ * is on. Env `KESTREL_SEO_ARTICLE_META`. */
152
+ articleMeta?: boolean
153
+ /** Serve and publish `/llms-full.txt`: the full Markdown body of every published, indexable page in
154
+ * one file (the llmstxt.org long form). Off by default — it aggregates the whole site into a single
155
+ * scrapeable artifact, and it reads every page's block content on every publish, which the link-only
156
+ * `llms.txt` deliberately avoids. Env `KESTREL_SEO_LLMS_FULL`. */
157
+ llmsFull?: boolean
158
+ }
141
159
  /** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
142
160
  * preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
143
161
  * `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
144
162
  preview?: { desktopWidth?: number }
163
+ /** EU AI Act Art. 50 disclosure fields on media assets — OFF by default. Kestrel only stores/manages
164
+ * this metadata; it never burns a label/watermark into an image and never auto-emits it into public
165
+ * output — see docs/media-uploads.md. Env: `KESTREL_AI_DISCLOSURE`. */
166
+ aiDisclosure?: { enabled?: boolean }
145
167
  }
146
168
 
147
169
  export interface ResolvedKestrel {
@@ -170,8 +192,13 @@ export interface ResolvedKestrel {
170
192
  }
171
193
  /** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
172
194
  collections: { pages: boolean; media: boolean }
195
+ /** Resolved answer-engine toggles, both default-off (see `KestrelConfig.seo`). */
196
+ seo: { articleMeta: boolean; llmsFull: boolean }
173
197
  /** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
174
198
  preview: { desktopWidth: number }
199
+ /** Gates the media-disclosure UI in the admin ONLY. `ResolvedMedia.aiDisclosure` is always resolved from
200
+ * the columns, so turning this off hides the editor controls without touching existing data. */
201
+ aiDisclosure: { enabled: boolean }
175
202
  }
176
203
 
177
204
  type Env = Record<string, string | undefined>
@@ -411,7 +438,14 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
411
438
  media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
412
439
  }
413
440
 
441
+ const seo = {
442
+ articleMeta: envBool(env.KESTREL_SEO_ARTICLE_META, c.seo?.articleMeta ?? false),
443
+ llmsFull: envBool(env.KESTREL_SEO_LLMS_FULL, c.seo?.llmsFull ?? false),
444
+ }
445
+
414
446
  const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
415
447
 
416
- return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, preview }
448
+ const aiDisclosure = { enabled: envBool(env.KESTREL_AI_DISCLOSURE, c.aiDisclosure?.enabled ?? false) }
449
+
450
+ return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, seo, preview, aiDisclosure }
417
451
  }
@@ -7,13 +7,31 @@ export interface SeoMeta {
7
7
  noindex?: boolean
8
8
  /** Media id of the social-share image (og:image / twitter card); resolved under `seo.$media.image`. */
9
9
  image?: number | null
10
+ /** Article metadata — schema.org `author` / `datePublished` / `keywords` on the page's JSON-LD node.
11
+ * Only offered and only published when `kestrel.seo.articleMeta` is on; the column always ROUND-TRIPS
12
+ * them, so turning the flag off hides and unpublishes existing values instead of destroying them. */
13
+ author?: string
14
+ /** ISO date (`YYYY-MM-DD`) or ISO datetime; `''` is the editor's cleared state. */
15
+ publishedDate?: string
16
+ /** Free-form comma-separated list (the spelling schema.org accepts verbatim). */
17
+ keywords?: string
10
18
  }
11
19
 
20
+ // A date is validated here rather than at emission time so a mistyped value surfaces in the editor,
21
+ // where it can be fixed — not silently as a missing `datePublished` in an artifact nobody looks at.
22
+ const isoDateish = z.string().refine(
23
+ (v) => v === '' || /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(v),
24
+ { message: 'Expected a date (YYYY-MM-DD)' },
25
+ )
26
+
12
27
  export const seoSchema = z.object({
13
28
  title: z.string().optional(),
14
29
  description: z.string().optional(),
15
30
  noindex: z.boolean().optional(),
16
31
  image: z.number().int().positive().nullish(),
32
+ author: z.string().optional(),
33
+ publishedDate: isoDateish.optional(),
34
+ keywords: z.string().optional(),
17
35
  })
18
36
 
19
37
  // The synthetic field set the row populator walks over the `seo` system column (PROPS key-mode), so the
@@ -0,0 +1,40 @@
1
+ import type { CollectionDef } from './defineCollection'
2
+
3
+ /**
4
+ * Post-write EFFECTS — the fail-able sibling of the write-event bus (`write-events.ts`).
5
+ *
6
+ * A write LISTENER is fire-and-forget on purpose: a publish failure must never break a content write, so
7
+ * `emitWrite` swallows throws. That is exactly wrong for a write whose external side effect is part of
8
+ * the contract — a redirects artifact the edge serves is stale the moment it fails, and an editor who saw
9
+ * a green save has no way to know. An EFFECT is awaited by the route and its rejection becomes the save's
10
+ * error response.
11
+ *
12
+ * Deliberately narrow: only the singleton PUT runs effects. Widening it to create/update/delete would make
13
+ * every content write fail-able, which is the invariant the listener bus exists to protect.
14
+ *
15
+ * The DB row is already committed when effects run (better-sqlite3 writes are synchronous and CRUD holds
16
+ * no transaction), so a failing effect means "saved, but the side effect is stale" — never a rollback.
17
+ * The message an effect throws has to say so.
18
+ */
19
+ export interface WriteEffectEvent {
20
+ def: CollectionDef
21
+ /** The row as saved. */
22
+ row: Record<string, unknown>
23
+ }
24
+
25
+ type WriteEffect = (event: WriteEffectEvent) => Promise<void> | void
26
+
27
+ const effects: WriteEffect[] = []
28
+
29
+ export function registerWriteEffect(fn: WriteEffect): void {
30
+ effects.push(fn)
31
+ }
32
+
33
+ export function clearWriteEffects(): void {
34
+ effects.length = 0
35
+ }
36
+
37
+ /** Run the registered effects in order. Rejects on the first failure, leaving the rest unrun. */
38
+ export async function runWriteEffects(def: CollectionDef, row: Record<string, unknown>): Promise<void> {
39
+ for (const fn of effects) await fn({ def, row })
40
+ }
@@ -51,22 +51,24 @@ export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection
51
51
  }
52
52
 
53
53
  /**
54
- * Build the `applyConditions` hook for a collection with conditional required fields, or `undefined`
55
- * when it has none (so CRUD skips it). The per-field schema relaxed every conditional field to optional
56
- * (it can't see siblings); this re-enforces `required` for the ones whose condition is met against the
57
- * whole record. Issues are keyed by the field's def name (so they map to the editor's per-field errors),
58
- * resolving each value at its column key (`<name>Id` for single relation/media).
54
+ * Build the `applyConditions` hook every pre-write check the per-field schema can't do — or `undefined`
55
+ * when the collection needs none (so CRUD skips it). Two sources, both of which need the whole record:
56
+ * the per-field schema relaxed every conditional field to optional (it can't see siblings), so `required`
57
+ * is re-enforced here for the ones whose condition is met; and the collection's own `def.validate`, for a
58
+ * rule spanning fields. Issues are keyed by the field's def name (so they map to the editor's per-field
59
+ * errors), resolving each value at its column key (`<name>Id` for single relation/media).
59
60
  */
60
61
  function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
61
62
  const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
62
- if (!required.length) return undefined
63
+ if (!required.length && !def.validate) return undefined
64
+ if (!required.length) return (record) => ({ issues: def.validate!(record) })
63
65
  return (record) => {
64
66
  const scope: Record<string, unknown> = {}
65
67
  for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
66
68
  const issues = required
67
69
  .filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
68
70
  .map(([key]) => ({ path: [key], message: 'This field is required.' }))
69
- return { issues }
71
+ return { issues: [...issues, ...(def.validate?.(record) ?? [])] }
70
72
  }
71
73
  }
72
74
 
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useBlurUp } from '../composables/useBlurUp'
3
3
  import { useMediaVariant } from '../composables/useMediaVariant'
4
+ import { aiSourceTypeLabel } from '../utils/ai-disclosure'
4
5
  import type { ResolvedMedia } from '../../server/utils/resolve'
5
6
  import type { VariantFit, VariantFormat } from '../../../core/server/utils/kestrel-config'
6
7
 
@@ -17,6 +18,11 @@ const props = defineProps<{
17
18
  formats?: VariantFormat[]
18
19
  sizes?: string
19
20
  priority?: boolean
21
+ /** Opt into a structurally-present but UNSTYLED EU AI Act disclosure badge (`.kestrel-img__ai-badge`),
22
+ * rendered only when `media.aiDisclosure` is set. Off by default: Kestrel must never publish a claim
23
+ * the consumer did not ask for, in a place/language/style it cannot know. The alternative escape hatch
24
+ * is to read `media.aiDisclosure` and render your own element — see docs/media-uploads.md. */
25
+ aiBadge?: boolean
20
26
  }>()
21
27
 
22
28
  const { model } = useMediaVariant(
@@ -44,6 +50,11 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
44
50
  decoding="async"
45
51
  @load="onLoad"
46
52
  >
53
+ <span
54
+ v-if="aiBadge && media?.aiDisclosure"
55
+ class="kestrel-img__ai-badge"
56
+ :data-ai-source-type="media.aiDisclosure.sourceType"
57
+ >{{ media.aiDisclosure.note ?? aiSourceTypeLabel(media.aiDisclosure.sourceType) }}</span>
47
58
  </picture>
48
59
  </template>
49
60
 
@@ -63,3 +74,21 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
63
74
  }
64
75
  }
65
76
  </style>
77
+
78
+ <!-- Deliberately UNSCOPED: a scoped rule carries the `[data-v-*]` attribute selector, which out-specifies
79
+ a consumer stylesheet targeting `.kestrel-img__ai-badge` and would make the badge un-restylable.
80
+ Layout only — no color, background, border, radius or font — so nothing is visible as a "badge" until
81
+ the consumer's own CSS says so. Scoped to pictures that actually contain one, so a consumer who never
82
+ sets `ai-badge` gets no rule at all. -->
83
+ <style>
84
+ picture:has(> .kestrel-img__ai-badge) {
85
+ position: relative;
86
+ display: inline-block;
87
+ }
88
+ .kestrel-img__ai-badge {
89
+ position: absolute;
90
+ inset-block-end: 0;
91
+ inset-inline-start: 0;
92
+ pointer-events: none;
93
+ }
94
+ </style>