@michaelthielemann/kestrel 1.7.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 (104) hide show
  1. package/README.md +23 -12
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/admin/app/components/BlocksBody.vue +2 -1
  4. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  5. package/layers/admin/app/components/CollectionList.vue +13 -5
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/PageFields.vue +5 -0
  8. package/layers/admin/app/components/SeoFields.vue +43 -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 +15 -6
  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/edit-form.ts +9 -2
  17. package/layers/admin/app/utils/editor-expose.ts +8 -0
  18. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  19. package/layers/core/modules/kestrel/index.ts +12 -0
  20. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  21. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  22. package/layers/core/server/schema/introspect.ts +1 -1
  23. package/layers/core/server/schema/sync.ts +1 -1
  24. package/layers/core/server/utils/collection-types.ts +4 -3
  25. package/layers/core/server/utils/crud.ts +5 -4
  26. package/layers/core/server/utils/defineCollection.ts +8 -1
  27. package/layers/core/server/utils/kestrel-config.ts +42 -1
  28. package/layers/core/server/utils/seo.ts +18 -0
  29. package/layers/core/server/utils/write-effects.ts +40 -0
  30. package/layers/fields/server/field-registry/index.ts +2 -1
  31. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  32. package/layers/fields/server/utils/buildCollection.ts +9 -7
  33. package/layers/media/app/components/KestrelImg.vue +29 -0
  34. package/layers/media/app/components/MediaLibrary.vue +11 -6
  35. package/layers/media/app/components/MediaToolbar.vue +1 -1
  36. package/layers/media/app/components/MediaViewer.vue +56 -7
  37. package/layers/media/app/components/field/Media.vue +2 -0
  38. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  39. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  40. package/layers/media/app/utils/library.ts +2 -0
  41. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  42. package/layers/media/server/api/media/[id].patch.ts +25 -1
  43. package/layers/media/server/api/media/index.post.ts +19 -3
  44. package/layers/media/server/collections/media.ts +14 -0
  45. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  46. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  47. package/layers/media/server/utils/library.ts +2 -1
  48. package/layers/media/server/utils/record.ts +8 -0
  49. package/layers/media/server/utils/resolve.ts +12 -0
  50. package/layers/public/app/pages/[...slug].vue +57 -7
  51. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  52. package/layers/public/app/utils/json-ld.ts +139 -0
  53. package/layers/public/app/utils/preview-protocol.ts +36 -0
  54. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  55. package/layers/public/modules/prerender-routes/index.ts +5 -2
  56. package/layers/public/server/api/preview.get.ts +28 -0
  57. package/layers/public/server/api/preview.post.ts +93 -0
  58. package/layers/public/server/api/publish-status.get.ts +23 -9
  59. package/layers/public/server/api/publish.post.ts +84 -0
  60. package/layers/public/server/api/route.get.ts +9 -1
  61. package/layers/public/server/collections/redirects.ts +75 -0
  62. package/layers/public/server/plugins/03.redirects.ts +37 -0
  63. package/layers/public/server/plugins/zz.publish.ts +12 -3
  64. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  65. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  66. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  67. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  68. package/layers/public/server/tasks/publish/run.ts +2 -1
  69. package/layers/public/server/utils/llms-full.ts +125 -0
  70. package/layers/public/server/utils/llms.ts +13 -0
  71. package/layers/public/server/utils/page-resolve.ts +112 -4
  72. package/layers/public/server/utils/preview-token.ts +109 -0
  73. package/layers/public/server/utils/publish/invalidation.ts +72 -3
  74. package/layers/public/server/utils/publish/pending.ts +74 -0
  75. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  76. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  77. package/layers/public/server/utils/publish/publisher.ts +85 -14
  78. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  79. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  80. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  81. package/layers/public/server/utils/site-url.ts +8 -0
  82. package/layers/public/server/utils/sitemap.ts +5 -3
  83. package/layers/ui/app/components/field/Choice.vue +6 -1
  84. package/layers/ui/app/components/field/Datetime.vue +2 -0
  85. package/layers/ui/app/components/field/Repeater.vue +2 -0
  86. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  87. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  88. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  89. package/layers/ui/app/components/ui/Field.vue +1 -0
  90. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  91. package/layers/ui/app/components/ui/Icon.vue +2 -2
  92. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  93. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  94. package/layers/ui/app/components/ui/Select.vue +1 -0
  95. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  96. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  97. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  98. package/layers/ui/app/i18n/de.ts +23 -0
  99. package/layers/ui/app/i18n/en.ts +23 -0
  100. package/package.json +7 -1
  101. package/scripts/kestrel.mjs +7 -3
  102. package/scripts/lib/scaffold.mjs +23 -1
  103. package/templates/starter/app/blocks/Prose.vue +1 -0
  104. package/templates/starter/nuxt.config.ts +3 -0
@@ -43,12 +43,10 @@ const heading = computed(() =>
43
43
  : t('editor.editRecord', { collection: singular.value, id }),
44
44
  )
45
45
 
46
- // Open the record's real public URL in a new tab the live preview (drafts included, served to the
47
- // authenticated admin by the public render path). Withheld until there's a saved, routable page.
48
- function openPreview() {
49
- const url = editorRef.value?.previewUrl
50
- if (url) window.open(url, '_blank', 'noopener,noreferrer')
51
- }
46
+ // The external-tab button always works now: with unsaved edits it opens a preview TICKET (no save, no
47
+ // publish), otherwise the record's own URL — so the tooltip has to say which of the two you'd get.
48
+ const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
49
+
52
50
  const skipGuard = ref(false)
53
51
 
54
52
  // Delete flows through the shared batch op + the same confirm dialog the list uses, so the delete logic
@@ -120,10 +118,12 @@ async function confirmDelete() {
120
118
  <div class="record__actions">
121
119
  <UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
122
120
  <UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
123
- <UiButton v-if="editorRef?.previewUrl" type="button" variant="ghost" size="sm" icon="external-link" :title="t('editor.openInNewTab')" :aria-label="t('editor.openInNewTab')" @click="openPreview" />
121
+ <UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
124
122
  <UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
125
123
  <UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
126
124
  <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
125
+ <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
126
+ <UiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</UiButton>
127
127
  <EditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
128
128
  </div>
129
129
  </div>
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useState } from '#imports'
3
+ import { flushPromises } from '@vue/test-utils'
4
+ import { readBody } from 'h3'
5
+ import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
6
+ import RecordPage from './[id].vue'
7
+
8
+ // A pageLike, status-bearing collection — the page-builder case the Save/Publish split is for.
9
+ const pagesSchema = {
10
+ name: 'pages', mode: 'multi', translatable: false, pageLike: true, seo: false, status: true,
11
+ blocks: { enabled: false }, label: { singular: 'Page', plural: 'Pages' },
12
+ fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
13
+ }
14
+ registerEndpoint('/api/collections', () => ({ data: [pagesSchema] }))
15
+
16
+ const calls = vi.hoisted(() => ({
17
+ publishOnSave: false,
18
+ writes: [] as Record<string, unknown>[],
19
+ publishes: [] as Record<string, unknown>[],
20
+ tickets: [] as Record<string, unknown>[],
21
+ }))
22
+
23
+ registerEndpoint('/api/pages/1', async (event) => {
24
+ if (event.method === 'PATCH') {
25
+ const b = await readBody(event)
26
+ calls.writes.push(b)
27
+ return { id: 1, ...b, updatedAt: '2026-08-13T10:00:00.000Z' }
28
+ }
29
+ return { id: 1, title: 'Existing', path: '/existing', status: 'draft', updatedAt: '2026-08-13T09:00:00.000Z' }
30
+ })
31
+ registerEndpoint('/api/pages', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
32
+ registerEndpoint('/api/pages/1/dead-refs', () => [])
33
+ registerEndpoint('/api/publish-status', () => ({ route: '/existing', status: 'success', pending: !calls.publishOnSave, generates: true, publishOnSave: calls.publishOnSave, updatedAt: '2026-08-13T09:30:00.000Z' }))
34
+ registerEndpoint('/api/publish', async (event) => {
35
+ calls.publishes.push(await readBody(event))
36
+ return { queued: true, generates: true, routes: ['/existing'], pruned: [], drafts: [] }
37
+ })
38
+ registerEndpoint('/api/preview', async (event) => {
39
+ if (event.method === 'POST') {
40
+ calls.tickets.push(await readBody(event))
41
+ return { token: 'pv_abc', expiresAt: Date.now() + 600_000 }
42
+ }
43
+ return null
44
+ })
45
+
46
+ const h = vi.hoisted(() => ({ params: { collection: 'pages', id: '1' }, nav: [] as unknown[] }))
47
+ mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
48
+ mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
49
+
50
+ let opened: Array<{ url: string }>
51
+ let lastTab: { location: { replace: (url: string) => void }; close: () => void } | null
52
+
53
+ beforeEach(() => {
54
+ h.params = { collection: 'pages', id: '1' }
55
+ h.nav.length = 0
56
+ calls.publishOnSave = false
57
+ calls.writes.length = 0
58
+ calls.publishes.length = 0
59
+ calls.tickets.length = 0
60
+ opened = []
61
+ lastTab = null
62
+ useState('kestrel-collections').value = null
63
+ useState('kestrel-blocks').value = null
64
+ vi.spyOn(window, 'open').mockImplementation(((url?: string) => {
65
+ // The real code opens a blank tab synchronously and redirects it once the ticket is minted.
66
+ if (url) { opened.push({ url }); return null }
67
+ lastTab = { location: { replace: (to: string) => opened.push({ url: to }) }, close: () => {} }
68
+ return lastTab as unknown as Window
69
+ }) as typeof window.open)
70
+ })
71
+
72
+ const settle = async () => {
73
+ await new Promise((r) => setTimeout(r, 20))
74
+ await flushPromises()
75
+ }
76
+ const button = (w: Awaited<ReturnType<typeof mountSuspended>>, label: string) =>
77
+ w.findAll('.ui-button').find((b) => b.text() === label)
78
+
79
+ describe('record editor — Save and Publish are separate actions', () => {
80
+ it('offers Publish next to Save', async () => {
81
+ const w = await mountSuspended(RecordPage)
82
+ await flushPromises()
83
+ const labels = w.find('.record__actions').findAll('.ui-button').map((b) => b.text()).filter(Boolean)
84
+ expect(labels).toContain('Publish')
85
+ expect(labels.indexOf('Publish')).toBe(labels.indexOf('Save') + 1)
86
+ })
87
+
88
+ // `output.publishOnSave` is the way back to the pre-2.0 model, where a save republished on its own.
89
+ it('drops the Publish button when the consumer turned the split off', async () => {
90
+ calls.publishOnSave = true
91
+ const w = await mountSuspended(RecordPage)
92
+ await settle()
93
+ expect(w.find('.record__actions').findAll('.ui-button').map((b) => b.text())).not.toContain('Publish')
94
+ })
95
+
96
+ it('Save writes the record and asks for no publish at all', async () => {
97
+ const w = await mountSuspended(RecordPage)
98
+ await flushPromises()
99
+ await w.find('input[type="text"]').setValue('Edited')
100
+ // The header Save is a `type=submit` bound to the form by id — which only the real browser follows.
101
+ await w.find('form.editor').trigger('submit')
102
+ await settle()
103
+ expect(calls.writes).toHaveLength(1)
104
+ expect(calls.writes[0]).toMatchObject({ title: 'Edited', status: 'draft' }) // status untouched by a save
105
+ expect(calls.publishes).toEqual([])
106
+ })
107
+
108
+ it('Publish saves first, promotes the draft, and then writes the static output', async () => {
109
+ const w = await mountSuspended(RecordPage)
110
+ await flushPromises()
111
+ await w.find('input[type="text"]').setValue('Ready to go')
112
+ await button(w, 'Publish')!.trigger('click')
113
+ await settle()
114
+ expect(calls.writes[0]).toMatchObject({ title: 'Ready to go', status: 'published' })
115
+ expect(calls.publishes).toEqual([{ collection: 'pages', id: 1 }])
116
+ })
117
+ })
118
+
119
+ describe('record editor — previewing in a new tab', () => {
120
+ it('opens the saved URL directly when there is nothing unsaved', async () => {
121
+ const w = await mountSuspended(RecordPage)
122
+ await flushPromises()
123
+ const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Open in new tab')!
124
+ await preview.trigger('click')
125
+ await settle()
126
+ expect(opened.map((o) => o.url)).toContain('/existing')
127
+ expect(calls.tickets).toEqual([])
128
+ })
129
+
130
+ it('mints a ticket for unsaved changes instead of saving them', async () => {
131
+ const w = await mountSuspended(RecordPage)
132
+ await flushPromises()
133
+ await w.find('input[type="text"]').setValue('Not saved')
134
+ await flushPromises()
135
+ const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Preview unsaved changes in a new tab')!
136
+ await preview.trigger('click')
137
+ await settle()
138
+ expect(calls.writes).toEqual([]) // the whole point: no save
139
+ expect(calls.tickets[0]).toMatchObject({ collection: 'pages', id: 1, values: { title: 'Not saved' } })
140
+ expect(opened.at(-1)!.url).toBe('/existing?kestrel-preview-token=pv_abc')
141
+ })
142
+ })
@@ -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
 
@@ -23,4 +23,12 @@ export interface EditorExpose {
23
23
  live: PublishStatusData | null
24
24
  /** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
25
25
  recordTitle: string
26
+ /** Save, then write the static output (ADR-0008) — a draft is promoted to published on the way. */
27
+ publish: () => Promise<void>
28
+ publishing: boolean
29
+ /** False when `output.publishOnSave` is on — a save republishes, so the hosts hide the Publish button. */
30
+ canPublish: boolean
31
+ /** Open the record in a new tab: the saved URL, or the unsaved state carried by a preview ticket. */
32
+ openPreview: () => Promise<void>
33
+ previewOpening: boolean
26
34
  }
@@ -100,7 +100,9 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
100
100
  const src = content.slice(node.start, node.end)
101
101
  const names = Object.keys(scope)
102
102
  try {
103
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
103
+ // `src` is a slice of the CONSUMER's own block-SFC source, evaluated once here at build/discovery time
104
+ // to recover the object literal's runtime shape — never attacker- or request-supplied, so this is not
105
+ // an eval-injection surface.
104
106
  const fn = new Function(...names, `return (${src})`)
105
107
  const out = fn(...names.map((k) => scope[k]))
106
108
  if (!out || typeof out !== 'object') throw new Error('expected an object literal')
@@ -109,6 +111,7 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
109
111
  throw new Error(
110
112
  `${where}: could not evaluate the block declaration. Field/block args must be self-contained literals + ` +
111
113
  `field-factory calls (no imported constants, computed values, or TS type-args). Cause: ${(e as Error).message}`,
114
+ { cause: e },
112
115
  )
113
116
  }
114
117
  }
@@ -126,7 +129,7 @@ export function extractBlockDef(sfcSource: string, fileBase: string): ExtractedB
126
129
  try {
127
130
  ast = parse(content, { sourceType: 'module', plugins: ['typescript'] })
128
131
  } catch (e) {
129
- throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`)
132
+ throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`, { cause: e })
130
133
  }
131
134
 
132
135
  const props = macroCall(ast, 'defineProps')
@@ -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 = {
@@ -74,6 +85,7 @@ export default defineNuxtModule<KestrelConfig>({
74
85
  dir: c.output.dir,
75
86
  publicDir: c.output.publicDir,
76
87
  auto: c.output.auto,
88
+ publishOnSave: c.output.publishOnSave,
77
89
  reconcileMinutes: c.output.reconcileMinutes,
78
90
  verbose: c.output.verbose,
79
91
  s3: {
@@ -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
  })
@@ -1,4 +1,4 @@
1
- import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
1
+ import type { ColumnShape, IndexShape, SchemaSnapshot } from './model'
2
2
 
3
3
  // Read the *actual* schema of a live SQLite database into the normalized model, so `diffSchema` can
4
4
  // compare it against the desired schema (ADR-0002). Typed structurally (prepare + pragma) to keep the
@@ -1,4 +1,4 @@
1
- import { type IntrospectDb } from './introspect'
1
+ import type { IntrospectDb } from './introspect'
2
2
  import { diffSchema } from './diff'
3
3
  import { sqlite, type Dialect } from './dialect'
4
4
  import type { SchemaSnapshot, SchemaOp } from './model'
@@ -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
  }
@@ -465,10 +465,11 @@ export function removeMany(db: DB, c: BuiltCollection, ids: number[]): { count:
465
465
  }
466
466
 
467
467
  /**
468
- * Publish / unpublish a batch of rows by persisting their `status` — NOT a separate publish path. Writing
469
- * `status` and emitting the SAME write event the editor save emits IS the publish: classifyWrite
470
- * planInvalidation the publish queue coalesces N emits into one incremental publish (PUBLISH renders the
471
- * self route, UNPUBLISH prunes the old route). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
468
+ * Publish / unpublish a batch of rows by persisting their `status` — the record's public INTENT, not the
469
+ * static file. Since ADR-0008 those are two steps: this write emits the same event an editor save emits,
470
+ * and the public layer's listener acts on the removal half only (UNPUBLISH prunes the route at once, so a
471
+ * page taken offline can never stay live). Making a page appear is the explicit publish action
472
+ * (`POST /api/publish`). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
472
473
  * any write). Validation (`assertConditions`) runs on PUBLISH ONLY — unpublishing must never be blockable
473
474
  * (you must always be able to take a broken page offline). Omits `update()`'s slug/transform branches,
474
475
  * which are provably inert for a status-only change.
@@ -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. */
@@ -123,6 +123,11 @@ export interface KestrelConfig {
123
123
  publicDir?: string
124
124
  /** Auto-publish affected pages on every content write (default true). */
125
125
  auto?: boolean
126
+ /** Opt out of the save/publish split (ADR-0008): `true` makes every content write republish the pages
127
+ * it affects, as before 2.0 — the editor's Publish button then has nothing left to do and is hidden.
128
+ * Default false: saving writes the DB, publishing writes the static files.
129
+ * Env `KESTREL_OUTPUT_PUBLISH_ON_SAVE`. */
130
+ publishOnSave?: boolean
126
131
  /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals a missed invalidation. */
127
132
  reconcileMinutes?: number
128
133
  /** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
@@ -133,10 +138,32 @@ export interface KestrelConfig {
133
138
  /** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
134
139
  * `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
135
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
+ }
136
159
  /** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
137
160
  * preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
138
161
  * `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
139
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 }
140
167
  }
141
168
 
142
169
  export interface ResolvedKestrel {
@@ -158,14 +185,20 @@ export interface ResolvedKestrel {
158
185
  dir: string
159
186
  publicDir: string
160
187
  auto: boolean
188
+ publishOnSave: boolean
161
189
  reconcileMinutes: number
162
190
  verbose: boolean
163
191
  s3: ResolvedS3Settings
164
192
  }
165
193
  /** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
166
194
  collections: { pages: boolean; media: boolean }
195
+ /** Resolved answer-engine toggles, both default-off (see `KestrelConfig.seo`). */
196
+ seo: { articleMeta: boolean; llmsFull: boolean }
167
197
  /** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
168
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 }
169
202
  }
170
203
 
171
204
  type Env = Record<string, string | undefined>
@@ -394,6 +427,7 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
394
427
  dir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_DIR) ?? clean(o.dir) ?? '.data/published'),
395
428
  publicDir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_PUBLIC_DIR) ?? clean(o.publicDir) ?? '.output/public'),
396
429
  auto: envBool(env.KESTREL_OUTPUT_AUTO, o.auto ?? true),
430
+ publishOnSave: envBool(env.KESTREL_OUTPUT_PUBLISH_ON_SAVE, o.publishOnSave ?? false),
397
431
  reconcileMinutes: resolveNonNegInt(o.reconcileMinutes, env.KESTREL_OUTPUT_RECONCILE_MINUTES),
398
432
  verbose: envBool(env.KESTREL_OUTPUT_VERBOSE, o.verbose ?? false),
399
433
  s3: resolveS3Settings(o.s3, env, 'KESTREL_OUTPUT_S3'),
@@ -404,7 +438,14 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
404
438
  media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
405
439
  }
406
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
+
407
446
  const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
408
447
 
409
- 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 }
410
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
+ }
@@ -161,6 +161,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
161
161
  type: z.literal('external'),
162
162
  // http(s) only, no control chars, no embedded credentials — the value ends up in a static <a href>.
163
163
  url: z.string().trim().pipe(z.url({ protocol: /^https?$/ })).refine((v) => {
164
+ // eslint-disable-next-line no-control-regex -- deliberately rejects control characters embedded in a URL destined for a static <a href>
164
165
  if (/[\u0000-\u001f]/.test(v)) return false
165
166
  const u = new URL(v)
166
167
  return !u.username && !u.password
@@ -168,7 +169,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
168
169
  label,
169
170
  }),
170
171
  z.object({ type: z.literal('email'), email: z.string().trim().pipe(z.email()), label }),
171
- z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-.\/]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
+ z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-./]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
173
  ]),
173
174
  f,
174
175
  )