@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
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
@@ -167,6 +174,7 @@ pnpm generate # render the static public site to .output/public
167
174
  pnpm test # node + happy-dom unit tests
168
175
  pnpm test:nuxt # Nuxt-environment component tests
169
176
  pnpm test:e2e # end-to-end tests (real dev server)
177
+ pnpm lint # ESLint (Nuxt-aware, generated from the playground)
170
178
 
171
179
  pnpm db:generate # drizzle-kit: generate a migration
172
180
  pnpm db:migrate # drizzle-kit: apply migrations
@@ -186,14 +194,17 @@ Two ways to produce the static output:
186
194
 
187
195
  - **One-shot:** `pnpm generate` → `.output/public` (the classic full rebuild).
188
196
  - **Runtime publisher (default):** a **production** run (`pnpm build && pnpm preview`, or
189
- `node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages on
190
- every content write, into `output.dir` (default `.data/published`). Serve that dir with any static
191
- server, e.g. `npx serve .data/published`.
197
+ `node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages when
198
+ you press **Publish** in the editor, into `output.dir` (default `.data/published`). Saving is a DB write
199
+ and leaves the live page alone (unpublishing and deleting still take a page down at once) — see
200
+ [ADR-0008](docs/architecture-decisions.md), or set `output.publishOnSave: true` for the pre-2.0 behaviour
201
+ where every save republished. Serve that dir with any static server, e.g. `npx serve .data/published`.
192
202
 
193
203
  The runtime publisher is **intentionally disabled in `pnpm dev`** (a dev render would write un-hashed Vite
194
204
  HTML), so the static files only appear on a production run. In dev you instead get the **live preview**:
195
205
  public pages render straight from the running server, and an authenticated admin can open an unpublished
196
- page at its real URL (the "open in new tab" button in the editor). See
206
+ page at its real URL (the "open in new tab" button in the editor) — with unsaved changes that button
207
+ carries them along in a preview ticket rather than saving them. See
197
208
  [static-output.md](docs/static-output.md) for the full picture.
198
209
 
199
210
  ### Simulate a production deploy locally
@@ -32,5 +32,5 @@ export function registeredGrants(): Readonly<Partial<Record<Role, Grant[]>>> {
32
32
 
33
33
  /** Test helper: drop all registered grants (the registry is a module singleton). */
34
34
  export function clearAccessGrants(): void {
35
- for (const role of Object.keys(registered) as Role[]) delete registered[role]
35
+ for (const role of Object.keys(registered) as Role[]) Reflect.deleteProperty(registered, role)
36
36
  }
@@ -56,7 +56,8 @@ function onKeydown(e: KeyboardEvent) {
56
56
  const el = e.target as HTMLElement | null
57
57
  if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return
58
58
  e.preventDefault()
59
- isRedo ? ctx.redo() : ctx.undo()
59
+ if (isRedo) ctx.redo()
60
+ else ctx.undo()
60
61
  }
61
62
  onMounted(() => window.addEventListener('keydown', onKeydown))
62
63
  onUnmounted(() => window.removeEventListener('keydown', onKeydown))
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { localePath } from '../../../core/app/utils/locale-path'
3
+ import { PREVIEW_TOKEN_QUERY, PREVIEW_FALLBACK_PATH } from '../../../public/app/utils/preview-protocol'
3
4
  import { resolveCollectionEditor } from '../utils/editor-registry'
4
5
  import { editorFormContextKey } from '../utils/editor-form-context'
5
6
  import '../utils/register-builtin-editors'
@@ -17,6 +18,13 @@ const saved = ref(false)
17
18
  const { t } = useT()
18
19
  const toast = useToast()
19
20
  const f = useEditForm({ collection: props.collection, id: props.id, locale: props.localeParam, group: props.group })
21
+ // `useEditForm` returns its refs/computeds synchronously (only its internal `init()` is async), so this
22
+ // destructure is safe to read before `f.ready` resolves — pulled up here (rather than after the await
23
+ // below, where it originally lived) so `defineExpose` can also run before that same await.
24
+ const {
25
+ formError, saving, submit, dirty, editorType, pageLike, hasStatus,
26
+ savedStatus, undo, redo, canUndo, canRedo,
27
+ } = f
20
28
 
21
29
  // Editor → field-widget context. MUST run BEFORE the top-level `await` below — after an await the setup
22
30
  // instance context is gone and `provide` silently no-ops. `id`/`saved` let a widget clean up an abandoned
@@ -71,17 +79,6 @@ provide(editorFormContextKey, {
71
79
  registerRevealError: (fn: () => void) => { revealError = fn },
72
80
  })
73
81
 
74
- await f.ready
75
-
76
- const {
77
- formError, saving, submit, dirty, editorType, pageLike, hasStatus,
78
- savedStatus, undo, redo, canUndo, canRedo,
79
- } = f
80
-
81
- // Which editor body renders (fields · blocks · an extension type). Resolved from the registry; an
82
- // unregistered type falls back to the visible EditorUnsupported panel.
83
- const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
84
-
85
82
  // Right dot of the editor Ampel: the live / generated state of THIS record's static page. Only a saved
86
83
  // pageLike record has one (a non-pageLike collection produces no static page; an unsaved `new` has no row).
87
84
  // Refreshed on mount and after each save (a save may (re)publish the page).
@@ -94,28 +91,133 @@ const liveStatus = usePublishStatus({
94
91
  // Carries "poll the live status on mount" across the create→navigate remount (see usePendingPublishPoll).
95
92
  const pendingPoll = usePendingPublishPoll()
96
93
 
94
+ // ---- publish + external preview -------------------------------------------------------------------
95
+ // Saving persists to the DB and leaves the live site alone; publishing writes the static file(s). The two
96
+ // are separate buttons because they are separate decisions (ADR-0008) — you can save a page a dozen times
97
+ // while the published version stays exactly as it was.
98
+ const publishing = ref(false)
99
+ const previewOpening = ref(false)
100
+ // `output.publishOnSave` turns the split off again (a save republishes, as before 2.0) — then a Publish
101
+ // button would have nothing to do, so the hosts hide it. Reported by /api/publish-status; unknown (a
102
+ // never-saved record, which does not fetch it) reads as the default, i.e. the button stays.
103
+ const publishOnSave = computed(() => liveStatus.data.value.publishOnSave === true)
104
+ const canPublish = computed(() => !publishOnSave.value)
105
+
106
+ /** Save (a publish publishes what you SEE), promote a draft, then write the output. */
107
+ async function publish() {
108
+ if (publishing.value || saving.value) return
109
+ publishing.value = true
110
+ try {
111
+ // Pressing Publish IS the publish intent, so a draft is promoted here rather than sending the user to
112
+ // the status select first. A statusless collection has nothing to promote.
113
+ if (hasStatus.value && f.values.status !== 'published') f.setField('status', 'published')
114
+ const r = await submit()
115
+ if (!r.ok) {
116
+ revealError?.()
117
+ toast.error(formError.value || t('editor.saveFailed'))
118
+ return
119
+ }
120
+ saved.value = true
121
+ // The saved row's id, whatever the route param says — `single`/`new` are not ids.
122
+ const id = (r.record as { id?: number } | null)?.id ?? Number(props.id)
123
+ if (!Number.isInteger(id)) return
124
+ const since = liveStatus.data.value.updatedAt ?? null
125
+ // Awaited BEFORE `saved` is emitted: on a new record that emit navigates to the record's own URL and
126
+ // tears this instance down, which would cut the request short.
127
+ const res = await $fetch<{ generates: boolean; drafts: number[] }>('/api/publish', {
128
+ method: 'POST', body: { collection: props.collection, id },
129
+ }).catch(() => null)
130
+ // A create navigates to the new record's URL (a full remount), so this instance is gone before its
131
+ // poll could run — hand the intent to the arriving one instead.
132
+ if (props.id === 'new') pendingPoll.value = pageLike.value && !!res?.generates
133
+ emit('saved', r.record)
134
+ if (!res) { toast.error(t('editor.publishFailed')); return }
135
+ if (res.drafts.length) { toast.info(t('editor.publishDraft')); return }
136
+ if (!res.generates) { toast.info(t('editor.publishNotGenerated')); return }
137
+ toast.success(t('toast.published'))
138
+ void liveStatus.refreshUntilSettled({ since })
139
+ } finally {
140
+ publishing.value = false
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Open the record in a new tab. Unsaved edits travel as a preview TICKET (a token in the URL) instead of
146
+ * being written to the DB: previewing must never be an unasked-for save. A saved, unmodified record just
147
+ * opens its URL.
148
+ *
149
+ * The tab is opened SYNCHRONOUSLY (about:blank) and redirected once the ticket is minted — a popup opened
150
+ * after an await is a popup blocker's textbook case. That costs `noopener`, which the direct path keeps;
151
+ * the target is our own origin either way.
152
+ */
153
+ async function openPreview() {
154
+ const url = previewUrl.value
155
+ if (!dirty.value && props.id !== 'new' && url) {
156
+ window.open(url, '_blank', 'noopener,noreferrer')
157
+ return
158
+ }
159
+ const tab = window.open('', '_blank')
160
+ previewOpening.value = true
161
+ try {
162
+ const ticket = await $fetch<{ token: string }>('/api/preview', {
163
+ method: 'POST',
164
+ body: {
165
+ collection: props.collection,
166
+ id: Number.isInteger(Number(props.id)) ? Number(props.id) : null, // 'new' / 'single' have no id
167
+ locale: f.locale.value,
168
+ values: f.buildBody(),
169
+ },
170
+ }).catch(() => null)
171
+ if (!ticket) {
172
+ tab?.close()
173
+ toast.error(t('editor.previewFailed'))
174
+ return
175
+ }
176
+ // No public URL (never saved, blank slug, non-pageLike) → the dedicated preview page renders the
177
+ // ticket in the real public app instead.
178
+ const base = url ?? `${PREVIEW_FALLBACK_PATH}?locale=${encodeURIComponent(f.locale.value)}`
179
+ const target = `${base}${base.includes('?') ? '&' : '?'}${PREVIEW_TOKEN_QUERY}=${encodeURIComponent(ticket.token)}`
180
+ if (tab) tab.location.replace(target)
181
+ else window.open(target, '_blank', 'noopener,noreferrer') // popup blocked → try once more, directly
182
+ } finally {
183
+ previewOpening.value = false
184
+ }
185
+ }
186
+
97
187
  // The record-editor page renders the action toolbar (Save/Cancel/Delete + Undo/Redo) in its header: it
98
188
  // submits this form by id (formId), reads the in-flight `saving` for the button state, drives the
99
189
  // unsaved-changes guard off `dirty`, and the undo/redo controls off the history API exposed here.
100
190
  // The exposed shape IS the `EditorExpose` contract (utils/editor-expose.ts) — keep them in sync.
101
- defineExpose({ dirty, saving, undo, redo, canUndo, canRedo, hasStatus, status, savedStatus, previewUrl, pageLike, live: liveStatus.data, recordTitle: heading })
191
+ // MUST run BEFORE the `await f.ready` below Vue does not guarantee an async setup component's expose is
192
+ // wired if `defineExpose` runs after a top-level await (the parent's template ref may resolve first).
193
+ defineExpose({
194
+ dirty, saving, undo, redo, canUndo, canRedo, hasStatus, status, savedStatus, previewUrl, pageLike,
195
+ live: liveStatus.data, recordTitle: heading, publish, publishing, canPublish, openPreview, previewOpening,
196
+ })
197
+
198
+ await f.ready
199
+
200
+ // Which editor body renders (fields · blocks · an extension type). Resolved from the registry; an
201
+ // unregistered type falls back to the visible EditorUnsupported panel.
202
+ const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
102
203
 
103
204
  async function onSave() {
205
+ // Read BEFORE the save: a successful submit rebaselines, after which the saved status is the new one.
206
+ const wasPublished = savedStatus.value === 'published'
104
207
  const r = await submit()
105
208
  if (r.ok) {
106
209
  saved.value = true
107
- // The save may have (re)published the page. A PUBLISHED page republishes asynchronously (a debounced
108
- // queue) in prod, so poll the right lamp until it settles (Live / Error) rather than catching only the
109
- // in-flight state; `since` is the pre-save row timestamp so a stale prior success/error can't settle the
110
- // poll early. A DRAFT produces no file → a single refresh (to reflect the draft / cleared row).
111
- const wasNew = props.id === 'new'
112
- const isDraft = hasStatus.value && status.value === 'draft'
210
+ // A save publishes nothing (ADR-0008), so there is no in-flight republish to poll for — one refresh,
211
+ // which is what turns the right lamp to "Outdated": still live, now an older version than the record.
212
+ // An unpublish is the exception the write path still acts on, and its prune is enqueued the same
213
+ // debounced way, so that case polls until the row clears.
214
+ // …with one exception in each direction: an unpublish IS acted on at save time (its prune rides the same
215
+ // debounced queue), and with `publishOnSave` every save republishes, so both poll until the row settles.
216
+ const unpublished = hasStatus.value && wasPublished && status.value === 'draft'
217
+ const settles = unpublished || (publishOnSave.value && status.value !== 'draft')
113
218
  const since = liveStatus.data.value.updatedAt ?? null
114
- // A create navigates the editor to the new record's URL (a full remount), so this `new` instance is
115
- // torn down before its poll could run — hand the intent to the arriving instance instead.
116
- if (wasNew) pendingPoll.value = pageLike.value && !isDraft
117
219
  emit('saved', r.record)
118
- if (!wasNew) void (isDraft ? liveStatus.refresh() : liveStatus.refreshUntilSettled({ since }))
220
+ if (props.id !== 'new') void (settles ? liveStatus.refreshUntilSettled({ since }) : liveStatus.refresh())
119
221
  return
120
222
  }
121
223
  // Save failed — let the active body reveal the problem (the blocks body focuses the offending block /
@@ -1,7 +1,8 @@
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
- import { type ListColumn } from '../utils/list-columns'
5
+ import type { ListColumn } from '../utils/list-columns'
5
6
  import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
6
7
  import type { BatchDeleteReport } from '../utils/collection-ops'
7
8
  import { OPS_BY_KIND, DEFAULT_OP, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
@@ -19,7 +20,6 @@ const collection = computed(() => props.schema.name)
19
20
  const label = computed(() => resolveLocalized(props.schema.label?.singular, lang.value) ?? props.schema.name)
20
21
  // Prefer the collection's complete per-locale "create" phrase; fall back to the generic template.
21
22
  const newLabel = computed(() => resolveLocalized(props.schema.label?.new, lang.value) ?? t('common.new', { label: label.value }))
22
- const translatable = computed(() => !!props.schema.translatable && props.schema.mode === 'multi')
23
23
  const localeQuery = computed(() => (props.locale ? `?locale=${props.locale}` : ''))
24
24
 
25
25
  // Configurable columns (persisted per-collection). The select + row-action columns are CHROME — fixed
@@ -56,7 +56,7 @@ watch([filterableColumns, () => JSON.stringify(filter.value)], () => {
56
56
  const cm = filter.value[c.key]
57
57
  draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
58
58
  }
59
- for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) delete draft[key]
59
+ for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
60
60
  }, { immediate: true })
61
61
 
62
62
  function opsFor(c: ListColumn): readonly FilterOp[] {
@@ -75,7 +75,10 @@ function enumOptions(c: ListColumn): { label: string; value: string }[] {
75
75
  { value: 'draft', label: t('pageSettings.statusDraft') },
76
76
  { value: 'published', label: t('pageSettings.statusPublished') },
77
77
  ]
78
- 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 }))
79
82
  }
80
83
  function displayValue(kind: FilterKind, value: string): string {
81
84
  if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
@@ -205,6 +208,9 @@ async function fetchRows() {
205
208
  }
206
209
 
207
210
  const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
211
+ // The per-page `<select>` is hand-rolled directly in this template (no UiField/UiCheckbox wrapper), so the
212
+ // label↔control association needs an explicit pair for static a11y analysis to see it.
213
+ const perPageId = useId()
208
214
 
209
215
  // Filters: edited in the Filter panel (a value change debounces; an operator change commits at once), shown
210
216
  // as removable chips in the bar. `commitDrafts` builds a fresh clause map from the non-empty draft cells and
@@ -460,6 +466,7 @@ await fetchRows()
460
466
  @click="togglePanel('columns', $event)"
461
467
  >{{ t('list.columns') }}</UiButton>
462
468
  <div v-if="openPanel === 'columns'" id="list-columns-panel" class="list__panel">
469
+ <!-- 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 -->
463
470
  <label v-for="c in available" :key="c.key" class="list__col-row">
464
471
  <UiCheckbox
465
472
  class="list__col-check"
@@ -620,9 +627,10 @@ await fetchRows()
620
627
  <UiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</UiButton>
621
628
  <span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
622
629
  <UiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</UiButton>
623
- <label class="list__perpage">
630
+ <label class="list__perpage" :for="perPageId">
624
631
  <span class="list__perpage-label">{{ t('list.perPage') }}</span>
625
632
  <select
633
+ :id="perPageId"
626
634
  class="list__perpage-select"
627
635
  :value="perPage"
628
636
  :aria-label="t('list.perPage')"
@@ -63,6 +63,11 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
63
63
  if (s === 'error')
64
64
  return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
65
65
  if (s === 'success') {
66
+ // Saved after the last publish: the file IS live, but it is an older version of this record. Saving no
67
+ // longer republishes (ADR-0008), so this is the normal state of a page being worked on — amber, not red.
68
+ if (props.live?.pending) {
69
+ return { tone: 'amber', word: t('editorStatus.word.outdated'), detail: t('editorStatus.live.outdated'), when: updatedAtLabel.value }
70
+ }
66
71
  // Where the file actually landed — surface local vs S3 in the tooltip ("stored on S3").
67
72
  const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
68
73
  return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
@@ -71,6 +76,12 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
71
76
  // static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
72
77
  if (props.live?.generates === false)
73
78
  return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
79
+ // Never published, and a save no longer enqueues anything (ADR-0008) — so nothing is in flight and
80
+ // nothing will be until someone presses Publish. Reporting progress here would point the user at a
81
+ // spinner instead of at the one action that changes it. With `publishOnSave` a save DOES republish, so
82
+ // there the in-flight reading is the right one.
83
+ if (props.live?.neverPublished && !props.live?.publishOnSave)
84
+ return { tone: 'blue', word: t('editorStatus.word.notPublished'), detail: t('editorStatus.live.notPublished') }
74
85
  return { tone: 'amber', word: t('editorStatus.word.generating'), detail: t('editorStatus.live.pending') }
75
86
  })
76
87
  </script>
@@ -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,45 @@ 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
+
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 -->
93
136
  <label class="seo-fields__noindex">
94
137
  <UiCheckbox
95
138
  :model-value="!!value.noindex"
@@ -4,7 +4,7 @@
4
4
  // CollectionEditor's form by id; `saving`/`dirty` come from the editor's exposed state.
5
5
  import type { EditorExpose } from '../utils/editor-expose'
6
6
 
7
- const props = defineProps<{ collection: string; title: string; localeParam?: string }>()
7
+ defineProps<{ collection: string; title: string; localeParam?: string }>()
8
8
 
9
9
  const { t } = useT()
10
10
  const toast = useToast()
@@ -17,10 +17,8 @@ function onSaved() {
17
17
  toast.success(t('toast.saved'))
18
18
  }
19
19
 
20
- function openPreview() {
21
- const url = editorRef.value?.previewUrl
22
- if (url) window.open(url, '_blank', 'noopener,noreferrer')
23
- }
20
+ // Unsaved edits preview through a ticket instead of a save; the editor owns both paths (ADR-0008).
21
+ const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
24
22
 
25
23
  // Guard in-app navigation away from unsaved changes — both record-change AND same-record `?locale`
26
24
  // switches (the LocaleBar's singleton locale switch reuses this route record → onBeforeRouteUpdate).
@@ -35,8 +33,10 @@ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardCo
35
33
  <div class="singleton__actions">
36
34
  <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()" />
37
35
  <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()" />
38
- <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" />
36
+ <UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
39
37
  <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
38
+ <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
39
+ <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>
40
40
  <EditorStatus class="singleton__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" />
41
41
  </div>
42
42
  </div>
@@ -45,9 +45,21 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
45
45
 
46
46
  const confirmDelete = (ids: number[]) => bulk('delete', ids)
47
47
  const duplicate = (ids: number[]) => bulk('duplicate', ids)
48
- /** Persist a new status; the server's write pipeline (emitWrite) turns that into a publish/unpublish. */
49
- const setStatus = (ids: number[], status: 'published' | 'draft') =>
50
- bulk(status === 'published' ? 'publish' : 'unpublish', ids)
48
+ /**
49
+ * Persist a new status and, for a publish, write the static output too (ADR-0008). Two calls because
50
+ * they are two things: the bulk endpoint owns the DB write (validation, all-or-nothing, write events),
51
+ * `/api/publish` owns the render. Unpublishing needs no second call — the write event prunes the pages
52
+ * on its own, since taking a page offline must never wait for a separate action.
53
+ */
54
+ const setStatus = async (ids: number[], status: 'published' | 'draft'): Promise<BulkResult> => {
55
+ const res = await bulk(status === 'published' ? 'publish' : 'unpublish', ids)
56
+ if (status === 'published') {
57
+ // Best-effort: the records ARE published (the state the list shows); a failed render surfaces on the
58
+ // record's own status lamp, and re-pressing Publish retries it.
59
+ await $fetch('/api/publish', { method: 'POST', body: { collection: name(), ids } }).catch(() => null)
60
+ }
61
+ return res
62
+ }
51
63
 
52
64
  return { busy, error, previewDelete, confirmDelete, duplicate, setStatus }
53
65
  }
@@ -95,8 +95,8 @@ export function useEditForm(opts: UseEditFormOptions) {
95
95
  function restore(snap: Record<string, unknown>) {
96
96
  const fresh = snapshot(snap)
97
97
  for (const k of Object.keys(fresh)) values[k] = fresh[k]
98
- for (const k of Object.keys(values)) if (!(k in fresh)) delete values[k]
99
- for (const k of Object.keys(errors)) delete errors[k]
98
+ for (const k of Object.keys(values)) if (!(k in fresh)) Reflect.deleteProperty(values, k)
99
+ for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
100
100
  blockErrors.value = {}
101
101
  formError.value = ''
102
102
  coalesceKey = ''
@@ -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
 
@@ -304,7 +310,7 @@ export function useEditForm(opts: UseEditFormOptions) {
304
310
  // Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
305
311
  // (never in `fields.value`), so a stale server error on one of them would otherwise survive a
306
312
  // later successful save. `validateAll` below repopulates every declared-field error fresh.
307
- for (const k of Object.keys(errors)) delete errors[k]
313
+ for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
308
314
  // Client-side validation failed: surface the same banner the server path raises, so the failure is
309
315
  // never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
310
316
  if (!validateAll()) {
@@ -366,6 +372,9 @@ export function useEditForm(opts: UseEditFormOptions) {
366
372
  ready,
367
373
  setField,
368
374
  validateAll,
375
+ // The wire body a save would send — also what a preview ticket carries, so an external tab renders
376
+ // exactly what a save would have stored (ADR-0008).
377
+ buildBody,
369
378
  submit,
370
379
  undo,
371
380
  redo,
@@ -39,7 +39,7 @@ export function useListColumns(schema: Ref<SerializedCollection>) {
39
39
 
40
40
  function reset() {
41
41
  const next = { ...store.value }
42
- delete next[schema.value.name]
42
+ Reflect.deleteProperty(next, schema.value.name)
43
43
  store.value = next
44
44
  }
45
45
 
@@ -9,6 +9,15 @@ export interface PublishStatusData {
9
9
  updatedAt?: string | null
10
10
  /** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
11
11
  target?: 'local' | 's3' | null
12
+ /** The record was saved after its page was last published — the live file is an older version of it.
13
+ * The normal working state while editing, since a save no longer republishes (ADR-0008). */
14
+ pending?: boolean
15
+ /** The consumer opted out of the save/publish split (`output.publishOnSave`): a save republishes on its
16
+ * own, so the editor offers no Publish button and never reports unpublished changes. */
17
+ publishOnSave?: boolean
18
+ /** The page has no publish row at all. Distinct from "a publish is in flight": with the split on, a save
19
+ * enqueues nothing, so this state only ends when someone presses Publish. */
20
+ neverPublished?: boolean
12
21
  /** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
13
22
  * `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
14
23
  generates?: boolean