@michaelthielemann/kestrel 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/access/server/utils/ip-allowlist.ts +4 -1
  4. package/layers/admin/app/components/BlocksBody.vue +2 -1
  5. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  6. package/layers/admin/app/components/CollectionList.vue +8 -4
  7. package/layers/admin/app/components/EditorStatus.vue +11 -0
  8. package/layers/admin/app/components/SeoFields.vue +1 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +6 -3
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/editor-expose.ts +8 -0
  17. package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
  18. package/layers/collections/server/utils/populate-relations.ts +18 -5
  19. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  20. package/layers/core/modules/kestrel/index.ts +1 -0
  21. package/layers/core/server/api/[collection]/[id].get.ts +4 -1
  22. package/layers/core/server/api/[collection]/index.get.ts +7 -2
  23. package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
  24. package/layers/core/server/api/references/broken.get.ts +7 -1
  25. package/layers/core/server/schema/dialect.ts +5 -3
  26. package/layers/core/server/schema/introspect.ts +4 -3
  27. package/layers/core/server/schema/sync.ts +1 -1
  28. package/layers/core/server/utils/crud.ts +11 -10
  29. package/layers/core/server/utils/kestrel-config.ts +10 -3
  30. package/layers/core/server/utils/populate.ts +3 -1
  31. package/layers/fields/server/field-registry/index.ts +2 -1
  32. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  33. package/layers/media/app/components/MediaLibrary.vue +4 -1
  34. package/layers/media/app/components/MediaToolbar.vue +1 -1
  35. package/layers/media/app/components/field/Media.vue +2 -0
  36. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  37. package/layers/media/server/utils/backfill.ts +24 -12
  38. package/layers/media/server/utils/variants.ts +27 -12
  39. package/layers/public/app/pages/[...slug].vue +35 -7
  40. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  41. package/layers/public/app/utils/preview-protocol.ts +36 -0
  42. package/layers/public/server/api/preview.get.ts +28 -0
  43. package/layers/public/server/api/preview.post.ts +93 -0
  44. package/layers/public/server/api/publish-status.get.ts +23 -9
  45. package/layers/public/server/api/publish.post.ts +84 -0
  46. package/layers/public/server/api/route.get.ts +26 -6
  47. package/layers/public/server/plugins/02.register-links.ts +4 -4
  48. package/layers/public/server/plugins/zz.publish.ts +13 -4
  49. package/layers/public/server/routes/llms.txt.get.ts +5 -2
  50. package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
  51. package/layers/public/server/tasks/publish/run.ts +2 -1
  52. package/layers/public/server/utils/link-resolve.ts +5 -2
  53. package/layers/public/server/utils/page-resolve.ts +23 -9
  54. package/layers/public/server/utils/preview-token.ts +109 -0
  55. package/layers/public/server/utils/publish/invalidation.ts +43 -4
  56. package/layers/public/server/utils/publish/pending.ts +74 -0
  57. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  58. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  59. package/layers/public/server/utils/publish/publisher.ts +74 -9
  60. package/layers/ui/app/components/field/Datetime.vue +2 -0
  61. package/layers/ui/app/components/field/Repeater.vue +2 -0
  62. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  63. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  64. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  65. package/layers/ui/app/components/ui/Field.vue +1 -0
  66. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  67. package/layers/ui/app/components/ui/Icon.vue +2 -2
  68. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  69. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  70. package/layers/ui/app/components/ui/Select.vue +1 -0
  71. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  72. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  73. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  74. package/layers/ui/app/i18n/de.ts +10 -0
  75. package/layers/ui/app/i18n/en.ts +10 -0
  76. package/package.json +9 -4
  77. package/scripts/kestrel.mjs +48 -19
  78. package/scripts/lib/scaffold.mjs +39 -8
  79. package/templates/starter/app/blocks/Prose.vue +1 -0
package/README.md CHANGED
@@ -167,6 +167,7 @@ pnpm generate # render the static public site to .output/public
167
167
  pnpm test # node + happy-dom unit tests
168
168
  pnpm test:nuxt # Nuxt-environment component tests
169
169
  pnpm test:e2e # end-to-end tests (real dev server)
170
+ pnpm lint # ESLint (Nuxt-aware, generated from the playground)
170
171
 
171
172
  pnpm db:generate # drizzle-kit: generate a migration
172
173
  pnpm db:migrate # drizzle-kit: apply migrations
@@ -186,14 +187,17 @@ Two ways to produce the static output:
186
187
 
187
188
  - **One-shot:** `pnpm generate` → `.output/public` (the classic full rebuild).
188
189
  - **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`.
190
+ `node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages when
191
+ you press **Publish** in the editor, into `output.dir` (default `.data/published`). Saving is a DB write
192
+ and leaves the live page alone (unpublishing and deleting still take a page down at once) — see
193
+ [ADR-0008](docs/architecture-decisions.md), or set `output.publishOnSave: true` for the pre-2.0 behaviour
194
+ where every save republished. Serve that dir with any static server, e.g. `npx serve .data/published`.
192
195
 
193
196
  The runtime publisher is **intentionally disabled in `pnpm dev`** (a dev render would write un-hashed Vite
194
197
  HTML), so the static files only appear on a production run. In dev you instead get the **live preview**:
195
198
  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
199
+ page at its real URL (the "open in new tab" button in the editor) — with unsaved changes that button
200
+ carries them along in a preview ticket rather than saving them. See
197
201
  [static-output.md](docs/static-output.md) for the full picture.
198
202
 
199
203
  ### 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
  }
@@ -42,7 +42,10 @@ function parseCidr(token: string): Cidr | null {
42
42
  const slash = token.indexOf('/')
43
43
  const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
44
44
  if (base === null) return null
45
- const bits = slash === -1 ? 32 : Number(token.slice(slash + 1))
45
+ // `Number()` alone would widen a malformed prefix into a mask: '' → 0 → 0.0.0.0/0, and '0x10'/'1e1' → 16/10.
46
+ const bitsRaw = slash === -1 ? '32' : token.slice(slash + 1).trim()
47
+ if (!/^\d+$/.test(bitsRaw)) return null
48
+ const bits = Number(bitsRaw)
46
49
  if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
47
50
  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
48
51
  return { base: (base & mask) >>> 0, mask }
@@ -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,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
3
3
  import { sortDirection, type FilterCell } from '../utils/list-query'
4
- import { type ListColumn } from '../utils/list-columns'
4
+ import type { ListColumn } from '../utils/list-columns'
5
5
  import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
6
6
  import type { BatchDeleteReport } from '../utils/collection-ops'
7
7
  import { OPS_BY_KIND, DEFAULT_OP, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
@@ -19,7 +19,6 @@ const collection = computed(() => props.schema.name)
19
19
  const label = computed(() => resolveLocalized(props.schema.label?.singular, lang.value) ?? props.schema.name)
20
20
  // Prefer the collection's complete per-locale "create" phrase; fall back to the generic template.
21
21
  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
22
  const localeQuery = computed(() => (props.locale ? `?locale=${props.locale}` : ''))
24
23
 
25
24
  // Configurable columns (persisted per-collection). The select + row-action columns are CHROME — fixed
@@ -56,7 +55,7 @@ watch([filterableColumns, () => JSON.stringify(filter.value)], () => {
56
55
  const cm = filter.value[c.key]
57
56
  draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
58
57
  }
59
- for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) delete draft[key]
58
+ for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
60
59
  }, { immediate: true })
61
60
 
62
61
  function opsFor(c: ListColumn): readonly FilterOp[] {
@@ -205,6 +204,9 @@ async function fetchRows() {
205
204
  }
206
205
 
207
206
  const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
207
+ // The per-page `<select>` is hand-rolled directly in this template (no UiField/UiCheckbox wrapper), so the
208
+ // label↔control association needs an explicit pair for static a11y analysis to see it.
209
+ const perPageId = useId()
208
210
 
209
211
  // Filters: edited in the Filter panel (a value change debounces; an operator change commits at once), shown
210
212
  // as removable chips in the bar. `commitDrafts` builds a fresh clause map from the non-empty draft cells and
@@ -460,6 +462,7 @@ await fetchRows()
460
462
  @click="togglePanel('columns', $event)"
461
463
  >{{ t('list.columns') }}</UiButton>
462
464
  <div v-if="openPanel === 'columns'" id="list-columns-panel" class="list__panel">
465
+ <!-- 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
466
  <label v-for="c in available" :key="c.key" class="list__col-row">
464
467
  <UiCheckbox
465
468
  class="list__col-check"
@@ -620,9 +623,10 @@ await fetchRows()
620
623
  <UiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</UiButton>
621
624
  <span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
622
625
  <UiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</UiButton>
623
- <label class="list__perpage">
626
+ <label class="list__perpage" :for="perPageId">
624
627
  <span class="list__perpage-label">{{ t('list.perPage') }}</span>
625
628
  <select
629
+ :id="perPageId"
626
630
  class="list__perpage-select"
627
631
  :value="perPage"
628
632
  :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>
@@ -90,6 +90,7 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
90
90
  </template>
91
91
  </UiField>
92
92
 
93
+ <!-- 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
94
  <label class="seo-fields__noindex">
94
95
  <UiCheckbox
95
96
  :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 = ''
@@ -304,7 +304,7 @@ export function useEditForm(opts: UseEditFormOptions) {
304
304
  // Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
305
305
  // (never in `fields.value`), so a stale server error on one of them would otherwise survive a
306
306
  // later successful save. `validateAll` below repopulates every declared-field error fresh.
307
- for (const k of Object.keys(errors)) delete errors[k]
307
+ for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
308
308
  // Client-side validation failed: surface the same banner the server path raises, so the failure is
309
309
  // never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
310
310
  if (!validateAll()) {
@@ -366,6 +366,9 @@ export function useEditForm(opts: UseEditFormOptions) {
366
366
  ready,
367
367
  setField,
368
368
  validateAll,
369
+ // The wire body a save would send — also what a preview ticket carries, so an external tab renders
370
+ // exactly what a save would have stored (ADR-0008).
371
+ buildBody,
369
372
  submit,
370
373
  undo,
371
374
  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
@@ -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>