@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
@@ -1,16 +1,19 @@
1
1
  import sanitizeHtml from 'sanitize-html'
2
2
  import { RICHTEXT_LINK_SCHEME } from '../../app/utils/richtext-links'
3
3
 
4
+ // Kept deliberately in step with the editor's schema (`ui/Richtext.vue`): a tag allowed here that no
5
+ // extension can parse is not "supported", it is a delayed deletion — the editor drops it on load and the
6
+ // next save persists the loss. `richtext.dom.test.ts` asserts the two lists agree, so widening this one
7
+ // means teaching the editor the tag in the same change. Images belong in a media field or an image
8
+ // block, not in flow text; tables await an editor that can hold them.
4
9
  export const RICHTEXT_ALLOWLIST: sanitizeHtml.IOptions = {
5
10
  allowedTags: [
6
11
  'p', 'br', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'mark',
7
12
  'blockquote', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
8
- 'ul', 'ol', 'li', 'a', 'img', 'figure', 'figcaption', 'hr',
9
- 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
13
+ 'ul', 'ol', 'li', 'a', 'hr',
10
14
  ],
11
15
  allowedAttributes: {
12
16
  a: ['href', 'title', 'target', 'rel'],
13
- img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
14
17
  '*': ['class', 'style'],
15
18
  },
16
19
  allowedStyles: {
@@ -82,7 +82,8 @@ function onSelect(item: LibraryItem, mods: { toggle: boolean; range: boolean })
82
82
  if (props.pick && props.multiple) {
83
83
  if (item.type !== 'file') return
84
84
  const s = new Set(picked.value)
85
- s.has(item.file.id) ? s.delete(item.file.id) : s.add(item.file.id)
85
+ if (s.has(item.file.id)) s.delete(item.file.id)
86
+ else s.add(item.file.id)
86
87
  picked.value = s
87
88
  return
88
89
  }
@@ -288,6 +289,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
288
289
  </script>
289
290
 
290
291
  <template>
292
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -- @click.self clears selection as a mouse-only bulk convenience; Space-toggle on each MediaGrid/MediaTable item already gives keyboard users the same end state -->
291
293
  <section class="media-library" @click.self="lib.clear()" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
292
294
  <MediaPathBar :folder="folder" @navigate="lib.navigate" />
293
295
  <MediaToolbar
@@ -301,6 +303,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
301
303
  />
302
304
  <UiMenu :items="localizedMenu" @select="onMenuSelect">
303
305
  <!-- @contextmenu.capture sets the menu target before Reka's own handler opens the menu, so menuItems is correct when the menu renders -->
306
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- contextmenu fires natively via Shift+F10/the Menu key when a MediaGrid/MediaTable item has focus, so this capture handler is already keyboard-reachable -->
304
307
  <div class="media-library__items" @contextmenu.capture="onContextMenu">
305
308
  <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
306
309
  <p v-else-if="!loading && !items.length" class="media-library__empty">{{ t('media.folderEmpty') }}</p>
@@ -47,7 +47,7 @@ function onFiles(e: Event) {
47
47
  <div class="media-toolbar__actions">
48
48
  <UiButtonGroup v-model="viewModel" :options="VIEW_OPTIONS" :aria-label="t('mediaToolbar.viewAriaLabel')" />
49
49
  <span class="media-toolbar__divider" aria-hidden="true"></span>
50
- <input ref="fileInput" type="file" multiple class="media-toolbar__file" @change="onFiles" />
50
+ <input ref="fileInput" type="file" multiple class="media-toolbar__file" :aria-label="t('mediaToolbar.upload')" @change="onFiles" />
51
51
  <UiButton :disabled="disabled" @click="fileInput?.click()"><UiIcon name="upload" :size="16" /> {{ t('mediaToolbar.upload') }}</UiButton>
52
52
  <UiButton :disabled="disabled" @click="emit('new-folder')"><UiIcon name="folder-plus" :size="16" /> {{ t('mediaToolbar.newFolder') }}</UiButton>
53
53
  </div>
@@ -117,7 +117,9 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
117
117
  <UiField :id="id" :label="name" :error="error" :required="required">
118
118
  <template #default="f">
119
119
  <div class="field-media">
120
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
120
121
  <ul v-if="resolved.length" ref="itemsEl" class="field-media__items" @dragleave="onDragLeave">
122
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
121
123
  <li
122
124
  v-for="(r, i) in resolved"
123
125
  :key="r.id"
@@ -119,7 +119,8 @@ export function useMediaLibrary(opts: { urlSync?: boolean; accept?: 'image' | 'a
119
119
  function select(item: LibraryItem) { const k = itemKey(item); selected.value = new Set([k]); anchorKey = k }
120
120
  function toggle(item: LibraryItem) {
121
121
  const k = itemKey(item); const s = new Set(selected.value)
122
- s.has(k) ? s.delete(k) : s.add(k)
122
+ if (s.has(k)) s.delete(k)
123
+ else s.add(k)
123
124
  selected.value = s; anchorKey = k
124
125
  }
125
126
  function range(item: LibraryItem) { selected.value = computeRange(orderedKeys.value, anchorKey ?? itemKey(item), itemKey(item), selected.value) }
@@ -5,7 +5,7 @@ import type { StorageDriver } from '../../../core/server/utils/storage'
5
5
  import { media } from '../collections/media'
6
6
  import { deriveImage, RASTER } from './derive'
7
7
  import { derivativeKey, type DerivativeManifest } from './record'
8
- import { activeVariants } from './variants'
8
+ import { readVariantRegistry, resolveActiveVariants } from './variants'
9
9
  import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
10
10
  import { emitMediaWrite } from './media-write'
11
11
 
@@ -16,7 +16,8 @@ const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
16
16
  export interface BackfillPlan {
17
17
  /** Specs, each carrying ONLY its missing formats, to derive + add. */
18
18
  missing: ResolvedVariant[]
19
- /** Object keys of manifest entries no longer in the active set (deregistered) — to prune. */
19
+ /** Object keys of manifest entries no longer in the active set (deregistered) — to prune. Always empty
20
+ * when the caller passes `prune: false`, i.e. the active set is a fallback rather than the registry. */
20
21
  orphanKeys: string[]
21
22
  }
22
23
 
@@ -26,7 +27,9 @@ export interface BackfillPlan {
26
27
  * never be satisfied). Orphans are keyed per `<name>.<format>`, so a deregistered FORMAT of a still-active
27
28
  * name is pruned too.
28
29
  */
29
- export function planBackfill(row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[]): BackfillPlan {
30
+ export function planBackfill(
31
+ row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[], prune = true,
32
+ ): BackfillPlan {
30
33
  const manifest = row.derivatives ?? {}
31
34
  const activeKeys = new Set<string>()
32
35
  const missing: ResolvedVariant[] = []
@@ -40,7 +43,7 @@ export function planBackfill(row: { width: number | null; derivatives: Derivativ
40
43
  if (missingFormats.length) missing.push({ ...spec, formats: missingFormats })
41
44
  }
42
45
  const orphanKeys: string[] = []
43
- for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
46
+ if (prune) for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
44
47
  return { missing, orphanKeys }
45
48
  }
46
49
 
@@ -53,9 +56,9 @@ interface BackfillRow { id: number; storageKey: string; mime: string; width: num
53
56
  * (that is Slice 9's published-media GC, a different concern).
54
57
  */
55
58
  export async function backfillRow(
56
- db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy,
59
+ db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy, prune = true,
57
60
  ): Promise<{ generated: number; pruned: number }> {
58
- const plan = planBackfill(row, specs)
61
+ const plan = planBackfill(row, specs, prune)
59
62
  if (!plan.missing.length && !plan.orphanKeys.length) return { generated: 0, pruned: 0 }
60
63
 
61
64
  const orphaned = new Set(plan.orphanKeys)
@@ -84,7 +87,12 @@ export async function backfillRow(
84
87
  return { generated, pruned: plan.orphanKeys.length }
85
88
  }
86
89
 
87
- export interface BackfillReport { rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean }
90
+ export interface BackfillReport {
91
+ rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean
92
+ /** The run declined to prune because the active set is the config fallback, not the registry — so a
93
+ * `pruned: 0` here means "could not tell what is registered", not "nothing was deregistered". */
94
+ pruneWithheld: boolean
95
+ }
88
96
 
89
97
  /**
90
98
  * Iterate every raster media row, reconciling each to the active variant set. Sequential (sharp CPU + a
@@ -94,14 +102,18 @@ export interface BackfillReport { rows: number; rowsChanged: number; generated:
94
102
  export async function runBackfill(
95
103
  db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, opts: { check?: boolean } = {},
96
104
  ): Promise<BackfillReport> {
97
- const specs = activeVariants(db, policy.variants, policy.presets)
105
+ // Deriving a superset is harmless, deleting against one is not, so the prune follows the resolver's own
106
+ // verdict: an unmigrated, unreadable, empty or wholly-rejected registry resolves to the CONFIG fallback,
107
+ // under which every registered variant of every row looks deregistered.
108
+ const { specs, fromRegistry: prune } = resolveActiveVariants(readVariantRegistry(db), policy.variants, policy.presets)
109
+ if (!prune) console.warn('[kestrel] backfill: no usable variant registry — generating against the config fallback and withholding the prune. Run a full publish so the prerender scan populates media_settings.')
98
110
  const rows = db.select().from(media).all() as BackfillRow[]
99
- const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check }
111
+ const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check, pruneWithheld: !prune }
100
112
  for (const snapshot of rows) {
101
113
  if (!RASTER.has(snapshot.mime)) continue
102
114
  if (opts.check) {
103
115
  // Dry-run reads only (against the snapshot) — no mutation, so no lock.
104
- const plan = planBackfill(snapshot, specs)
116
+ const plan = planBackfill(snapshot, specs, prune)
105
117
  const toGenerate = plan.missing.reduce((n, s) => n + s.formats.length, 0)
106
118
  if (!toGenerate && !plan.orphanKeys.length) continue
107
119
  report.rowsChanged++
@@ -116,11 +128,11 @@ export async function runBackfill(
116
128
  const cols = getTableColumns(media) as Record<string, never>
117
129
  const row = db.select().from(media).where(eq(cols.id, snapshot.id)).get() as BackfillRow | undefined
118
130
  if (!row || !RASTER.has(row.mime)) return
119
- const plan = planBackfill(row, specs)
131
+ const plan = planBackfill(row, specs, prune)
120
132
  if (!plan.missing.length && !plan.orphanKeys.length) return
121
133
  report.rowsChanged++
122
134
  try {
123
- const r = await backfillRow(db, driver, row, specs, policy)
135
+ const r = await backfillRow(db, driver, row, specs, policy, prune)
124
136
  report.generated += r.generated
125
137
  report.pruned += r.pruned
126
138
  } catch (error) {
@@ -28,11 +28,16 @@ const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ?
28
28
  * config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
29
29
  * (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
30
30
  * active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
31
+ *
32
+ * `fromRegistry` is the verdict a DELETING caller (the backfill prune) must gate on: true only when at least
33
+ * one stored entry SURVIVED validation, so the set really is the registered one. Absent, unread, empty and
34
+ * wholly-rejected registries all resolve to the same superset fallback, under which every registered
35
+ * derivative looks deregistered — the row count alone cannot tell those apart from a narrowed set.
31
36
  * Pure — the load-bearing logic, unit-tested without a DB.
32
37
  */
33
38
  export function resolveActiveVariants(
34
39
  stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
35
- ): ResolvedVariant[] {
40
+ ): { specs: ResolvedVariant[]; fromRegistry: boolean } {
36
41
  // A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
37
42
  // derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
38
43
  // referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
@@ -41,7 +46,7 @@ export function resolveActiveVariants(
41
46
  (v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
42
47
  )
43
48
  // Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
44
- if (!list.length) return fallback
49
+ if (!list.length) return { specs: fallback, fromRegistry: false }
45
50
  const byName = new Map<string, StoredVariant>()
46
51
  for (const v of list) {
47
52
  const prev = byName.get(v.name)
@@ -50,7 +55,7 @@ export function resolveActiveVariants(
50
55
  for (const p of presets) {
51
56
  if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
52
57
  }
53
- return [...byName.values()].map((v): ResolvedVariant => ({
58
+ const specs = [...byName.values()].map((v): ResolvedVariant => ({
54
59
  name: v.name,
55
60
  width: Math.floor(v.width),
56
61
  // coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
@@ -62,6 +67,7 @@ export function resolveActiveVariants(
62
67
  position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
63
68
  formats: v.formats?.length ? v.formats : ['webp'],
64
69
  }))
70
+ return { specs, fromRegistry: true }
65
71
  }
66
72
 
67
73
  /**
@@ -87,12 +93,12 @@ export function reconcileVariants(existing: StoredVariant[], discovered: Resolve
87
93
  }
88
94
 
89
95
  /**
90
- * Read the persisted variant registry (the `media_settings` singleton) and resolve the active set,
91
- * falling back to `fallback` (the resolved config policy variants) when nothing is stored yet. `presets`
92
- * (config-authored named variants) stay active regardless of the stored set. The upload path calls this
93
- * so it derives exactly the currently-registered set + presets (narrow generation).
96
+ * The raw stored registry rows (the `media_settings` singleton), or `null` when there are none to resolve
97
+ * from the read threw (media_settings not migrated) or nothing is stored. Whether the resolved set may be
98
+ * DELETED against is not decidable here: that is `resolveActiveVariants`' `fromRegistry` verdict, which only
99
+ * a validation pass over these rows can give.
94
100
  */
95
- export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
101
+ export function readVariantRegistry(db: BetterSQLite3Database): StoredVariant[] | null {
96
102
  const cols = getTableColumns(mediaSettings) as Record<string, never>
97
103
  let row: { variants?: StoredVariant[] | null } | undefined
98
104
  try {
@@ -100,9 +106,18 @@ export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVari
100
106
  | { variants?: StoredVariant[] | null }
101
107
  | undefined
102
108
  } catch {
103
- // media_settings not migrated yet (a DB provisioned by committed migrations alone) — degrade to the
104
- // config fallback rather than 500 the upload. Mirrors attachDeadRefs tolerating a missing record_refs.
105
- row = undefined
109
+ return null
106
110
  }
107
- return resolveActiveVariants(row?.variants ?? null, fallback, presets)
111
+ const stored = row?.variants
112
+ return Array.isArray(stored) ? stored : null
113
+ }
114
+
115
+ /**
116
+ * Resolves the active variant set for the upload path (narrow generation: registered set + presets).
117
+ * Deliberately forgiving: an unreadable registry degrades to the config fallback rather than 500 the
118
+ * upload (mirrors attachDeadRefs tolerating a missing record_refs). Discards `fromRegistry` — never use
119
+ * this to decide what may be pruned; a deleting caller needs `resolveActiveVariants` directly.
120
+ */
121
+ export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
122
+ return resolveActiveVariants(readVariantRegistry(db), fallback, presets).specs
108
123
  }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { LayoutKey } from 'nuxt/app'
2
+ import type { LayoutKey } from '#app'
3
3
  import type { SiteHead } from '../utils/site-head'
4
4
 
5
5
  // The record decides its own layout, so route-meta resolution is opted out of and this page renders the
@@ -35,7 +35,7 @@ const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean
35
35
  // `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
36
36
  // stay published-only (the handler enforces it).
37
37
  const requestFetch = useRequestFetch()
38
- const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
38
+ const { data: resolved, error: resolveError } = await useAsyncData(`page:${locale}:${path}`, () =>
39
39
  requestFetch('/api/route', {
40
40
  query: { path, locale },
41
41
  }).then((r) => r as {
@@ -45,7 +45,20 @@ const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
45
45
  site?: SiteHead | null
46
46
  }),
47
47
  )
48
- const page = computed(() => resolved.value?.page ?? null)
48
+ // Ticket preview (ADR-0008): `?kestrel-preview-token=…` carries the editor's UNSAVED state, so an external
49
+ // tab can show work in progress without a save and without publishing. The ticket is admin-only and
50
+ // session-bound server-side; an expired/foreign/unknown one reads as null and the saved record renders.
51
+ const previewToken = route.query[PREVIEW_TOKEN_QUERY]
52
+ const { data: ticket } = typeof previewToken === 'string' && previewToken
53
+ ? await useAsyncData(`kestrel-preview-ticket:${previewToken}`, () =>
54
+ requestFetch('/api/preview', { query: { token: previewToken } })
55
+ .then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
56
+ .catch(() => null))
57
+ : { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
58
+ const previewValues = computed(() => ticket.value?.payload?.values ?? null)
59
+ const previewingTicket = computed(() => previewValues.value !== null)
60
+
61
+ const page = computed(() => previewPage(resolved.value?.page ?? null, previewValues.value) as (RenderedPage & Record<string, unknown>) | null)
49
62
  // `fallback` below only rescues a truthy name that is missing from the layout map, so the empty cases have
50
63
  // to be coalesced here — see resolvePageLayout. The cast is the one honest bridge in this file: the stored
51
64
  // name is arbitrary editor data, while `NuxtLayout` types `name` as the union of layouts that existed at
@@ -60,7 +73,7 @@ const pageState = usePublicPageState()
60
73
  watchEffect(() => {
61
74
  pageState.value = {
62
75
  collection: resolved.value?.collection ?? null,
63
- page: resolved.value?.page ?? null,
76
+ page: page.value,
64
77
  }
65
78
  })
66
79
 
@@ -68,6 +81,13 @@ watchEffect(() => {
68
81
  // published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
69
82
  const isDraftPreview = computed(() => page.value?.status === 'draft')
70
83
 
84
+ // What this tab is actually showing, when it is not the live page. A ticket outranks the draft notice: the
85
+ // content on screen was never saved at all, which is the stronger caveat.
86
+ const previewNotice = computed(() => {
87
+ if (previewingTicket.value) return 'Preview — unsaved changes, not published'
88
+ return isDraftPreview.value ? 'Draft preview — not published' : ''
89
+ })
90
+
71
91
  // Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
72
92
  // checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
73
93
  // agree on which branch renders; an anonymous visitor with the query param gets the normal page.
@@ -79,6 +99,13 @@ const { data: previewSession } = previewRequested
79
99
  : { data: ref<{ authenticated: boolean } | null>(null) }
80
100
  const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
81
101
 
102
+ // `useAsyncData` resolves even when the fetch threw, so the resolver's own failure has to be re-raised here
103
+ // or the root's empty document (a 200 WITH a body) reads as a successful render and the publisher bakes it
104
+ // over the live page. Unconditional on purpose, not just for `/`: on any other path the failure would fall
105
+ // through to the 404 below, which asserts "no such page" from a lookup that never completed — a claim
106
+ // crawlers and caches act on, and one the publisher files as a skip instead of the error the editor shows.
107
+ if (resolveError.value) throw resolveError.value
108
+
82
109
  // The site root stays reachable (empty document) before a home page is published;
83
110
  // any other unmatched path is a genuine 404.
84
111
  if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
@@ -122,7 +149,8 @@ useHead({
122
149
  useSeoMeta({
123
150
  title: documentTitle,
124
151
  description: fallbacks.description,
125
- robots: seo.noindex ? 'noindex, nofollow' : undefined,
152
+ // A ticket preview is unsaved content at a real URL — never indexable, whatever the record's own SEO says.
153
+ robots: previewingTicket.value || seo.noindex ? 'noindex, nofollow' : undefined,
126
154
  ogTitle: head.meta.ogTitle,
127
155
  ogDescription: head.meta.ogDescription,
128
156
  ogUrl: head.meta.ogUrl,
@@ -141,9 +169,9 @@ useSeoMeta({
141
169
  <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
142
170
  for anonymous visitors or the static render), so it never ships to the public/static site.
143
171
  Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
144
- <div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
172
+ <div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
145
173
  <span class="kestrel-draft-badge__dot" aria-hidden="true" />
146
- Draft preview — not published
174
+ {{ previewNotice }}
147
175
  </div>
148
176
  <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
149
177
  blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
@@ -3,8 +3,10 @@
3
3
  * Dedicated editor live-preview page for records WITHOUT a public URL — a new/unsaved record, a blank
4
4
  * slug, or a blocks-enabled non-pageLike collection. It renders the real public app (default layout,
5
5
  * the consumer's CSS/fonts/breakpoints) around an empty BlockRenderer that the editor fills over the
6
- * postMessage bridge. Saved pageLike records preview at their REAL URL instead (higher fidelity);
7
- * this page is the graceful fallback so previews never regress to "save first".
6
+ * postMessage bridge or, when opened in a separate tab with `?kestrel-preview-token=…`, around the
7
+ * ticket's unsaved content (ADR-0008), since a tab with no parent window has no bridge to listen to.
8
+ * Saved pageLike records preview at their REAL URL instead (higher fidelity); this page is the graceful
9
+ * fallback so previews never regress to "save first".
8
10
  *
9
11
  * Admin-gated server-side: `useRequestFetch` forwards the incoming cookies to `/api/auth/session`
10
12
  * (the same seam the catch-all uses for draft rendering), and anonymous requests get a 404 — the
@@ -20,6 +22,16 @@ if (!session.value?.authenticated) throw createError({ statusCode: 404, statusMe
20
22
  // Content locale from the editor (drives <html lang> for faithful per-locale rendering).
21
23
  const route = useRoute()
22
24
  const lang = typeof route.query.locale === 'string' && route.query.locale ? route.query.locale : undefined
25
+
26
+ // Ticket content for the external-tab case; null (→ the bridge's own empty tree) without one.
27
+ const token = route.query[PREVIEW_TOKEN_QUERY]
28
+ const { data: ticket } = typeof token === 'string' && token
29
+ ? await useAsyncData(`kestrel-preview-ticket:${token}`, () =>
30
+ requestFetch('/api/preview', { query: { token } })
31
+ .then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
32
+ .catch(() => null))
33
+ : { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
34
+ const ticketBlocks = computed(() => (ticket.value?.payload?.values?.content as unknown[] | undefined) ?? [])
23
35
  useHead({
24
36
  title: 'Preview',
25
37
  meta: [{ name: 'robots', content: 'noindex, nofollow' }],
@@ -28,7 +40,7 @@ useHead({
28
40
  </script>
29
41
 
30
42
  <template>
31
- <KestrelPreviewBridge v-slot="{ blocks }">
43
+ <KestrelPreviewBridge :blocks="ticketBlocks" v-slot="{ blocks }">
32
44
  <BlockRenderer :blocks="(blocks as any[])" />
33
45
  </KestrelPreviewBridge>
34
46
  </template>
@@ -13,6 +13,8 @@
13
13
 
14
14
  /** Query flag that switches the public page into preview mode (value `1`). */
15
15
  export const PREVIEW_QUERY = 'kestrel-preview'
16
+ /** Query carrying a preview TICKET — the editor's unsaved state, rendered in a normal tab (ADR-0008). */
17
+ export const PREVIEW_TOKEN_QUERY = 'kestrel-preview-token'
16
18
  /** Dedicated preview page for records without a public URL (new/unsaved, non-pageLike). Admin-gated. */
17
19
  export const PREVIEW_FALLBACK_PATH = '/__kestrel/preview'
18
20
 
@@ -74,6 +76,40 @@ export function parseFrameMessage(data: unknown): FrameToEditorMessage | null {
74
76
  * pageLike record — server-populated first paint, drafts included for the admin session), else the
75
77
  * dedicated fallback page (new/unsaved records, non-pageLike collections). Both carry the preview flag.
76
78
  */
79
+ /**
80
+ * The record a ticket preview renders: the saved row with the editor's unsaved values laid over it. Both
81
+ * halves are column-keyed (the editor sends what a save would send), so this is a shallow override — a
82
+ * field the editor did not touch keeps the stored value, and a page that does not exist yet (an unsaved
83
+ * slug) renders from the payload alone. Pure.
84
+ *
85
+ * One override is not shallow: a single-valued relation/media field is column-keyed `<name>Id` (the
86
+ * `resolveColumnName`/`isSingleRefColumn` convention — many-relations and multi-media stay bare-keyed, so
87
+ * they're untouched by this), while its populated sidecar sits ALONGSIDE the id, not under it — `$<name>`
88
+ * for a relation (`buildRelationFieldPopulator`), `$media.<name>` for media (`populate.ts`'s `attach`).
89
+ * `values` here is raw unsaved editor state, never itself populated, so clearing such a field to null
90
+ * leaves `saved`'s old sidecar with nothing to overwrite it — a plain spread would let a removed
91
+ * author/cover keep rendering. Drop it explicitly for every `<name>Id` key the editor cleared.
92
+ */
93
+ export function previewPage(
94
+ saved: Record<string, unknown> | null,
95
+ values: Record<string, unknown> | null | undefined,
96
+ ): Record<string, unknown> | null {
97
+ if (!values) return saved
98
+ const merged: Record<string, unknown> = { ...(saved ?? {}), ...values }
99
+ for (const key of Object.keys(values)) {
100
+ if (values[key] !== null || !key.endsWith('Id')) continue
101
+ const name = key.slice(0, -2)
102
+ if (!name) continue
103
+ if (Object.hasOwn(merged, `$${name}`)) delete merged[`$${name}`]
104
+ const media = merged.$media as Record<string, unknown> | undefined
105
+ if (media && Object.hasOwn(media, name)) {
106
+ const { [name]: _dropped, ...rest } = media
107
+ merged.$media = rest
108
+ }
109
+ }
110
+ return merged
111
+ }
112
+
77
113
  export function previewSrc(publicUrl: string | null, locale: string): string {
78
114
  if (publicUrl) return `${publicUrl}?${PREVIEW_QUERY}=1`
79
115
  const loc = locale ? `&locale=${encodeURIComponent(locale)}` : ''
@@ -0,0 +1,28 @@
1
+ import { usePreviewStore, previewOwner } from '../utils/preview-token'
2
+
3
+ /**
4
+ * Read back a preview ticket (ADR-0008) — the public page fetches it during SSR when the URL carries
5
+ * `?kestrel-preview-token=…` and renders the editor's unsaved state instead of the stored record. Admin-
6
+ * only and session-bound; an expired, foreign or unknown token is `null`, which the page treats as
7
+ * "nothing to preview" and falls back to the saved content rather than failing the render.
8
+ */
9
+ export default defineEventHandler((event) => {
10
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
11
+ const token = getQuery(event).token
12
+ if (typeof token !== 'string' || !token) return null
13
+ const payload = usePreviewStore().read(token, previewOwner(event))
14
+ if (!payload) return null
15
+
16
+ // The editor sends what a SAVE would send — raw ids for media and relations — so the ticket goes through
17
+ // the same read population a stored record does. Without it the preview would render a page with the
18
+ // images and internal links stripped, which is worse than useless for judging a layout.
19
+ const c = getCollection(payload.collection)
20
+ if (!c) return { payload }
21
+ const locale = payload.locale || primaryLocale()
22
+ const values = withResolveScope(
23
+ () => populateRow({ ...payload.values }, { depth: 1, locale, def: c.def }),
24
+ resolveBudgetFor(1),
25
+ `preview ${payload.collection}`,
26
+ )
27
+ return { payload: { ...payload, values } }
28
+ })
@@ -0,0 +1,93 @@
1
+ import { usePreviewStore, previewOwner, type PreviewPayload } from '../utils/preview-token'
2
+ import type { BuiltCollection } from '../../../core/server/utils/collection-types'
3
+ import { fieldIs, type FieldDef } from '../../../core/server/utils/defineCollection'
4
+ import { resolveColumnName } from '../../../fields/server/field-registry/naming'
5
+ import { sanitizeRichtext } from '../../../fields/server/field-registry/sanitize'
6
+ import { getBlock } from '../../../fields/server/utils/defineBlock'
7
+
8
+ /** Editor payloads are form state, not uploads — a block tree with populated media is far below this. */
9
+ const MAX_PAYLOAD_BYTES = 2_000_000
10
+
11
+ /**
12
+ * Sanitize every richtext leaf reachable from `scope` (top-level fields + nested repeater entries), in
13
+ * place. Mirrors the WALK shape of crud.ts's `applyFieldTransforms`/`transformNested`, but not the
14
+ * mechanism: richtext's sanitizer is wired into the Zod validator (`z.string().transform(sanitizeRichtext)`
15
+ * in field-registry/index.ts), not the field-type's `transform` hook that walk calls — and running the
16
+ * collection's Zod schema here isn't an option anyway, see `sanitizePreviewValues` below. So this calls
17
+ * `sanitizeRichtext` directly wherever a `richtext` field is present, and only there; every other field
18
+ * passes through untouched.
19
+ */
20
+ function sanitizeRichtextFields(fields: Record<string, FieldDef>, scope: Record<string, unknown>): void {
21
+ for (const [key, fieldDef] of Object.entries(fields)) {
22
+ const { jsKey } = resolveColumnName(key, fieldDef)
23
+ if (!Object.hasOwn(scope, jsKey)) continue
24
+ if (fieldIs(fieldDef, 'richtext')) {
25
+ if (typeof scope[jsKey] === 'string') scope[jsKey] = sanitizeRichtext(scope[jsKey] as string)
26
+ } else if (fieldIs(fieldDef, 'repeater')) {
27
+ const entries = scope[jsKey]
28
+ if (Array.isArray(entries)) {
29
+ for (const entry of entries) if (entry && typeof entry === 'object') sanitizeRichtextFields(fieldDef.options.fields, entry as Record<string, unknown>)
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Same walk as `transformBlocks` in crud.ts: each block's props (by its registered field defs) + its
36
+ * slots' nested blocks, recursively. An unregistered block type is left as-is (matches crud.ts). */
37
+ function sanitizeRichtextInBlocks(blocks: unknown): void {
38
+ if (!Array.isArray(blocks)) return
39
+ for (const block of blocks) {
40
+ if (!block || typeof block !== 'object') continue
41
+ const b = block as { type?: string; props?: unknown; slots?: unknown }
42
+ const def = typeof b.type === 'string' ? getBlock(b.type) : undefined
43
+ if (def && b.props && typeof b.props === 'object') sanitizeRichtextFields(def.fields, b.props as Record<string, unknown>)
44
+ if (b.slots && typeof b.slots === 'object') for (const sub of Object.values(b.slots as Record<string, unknown>)) sanitizeRichtextInBlocks(sub)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Apply the richtext sanitizer to a preview ticket's `values` — the fix for the ticket bypassing it
50
+ * entirely (it normally runs as a Zod `.transform()` on WRITE, `create`/`update`/`putSingleton` in
51
+ * crud.ts; a ticket never reaches those). Deliberately NOT a schema parse: `values` is the editor's
52
+ * in-progress, possibly-invalid draft (a missing required field, an out-of-range number, …), and a preview
53
+ * exists precisely so a draft can be inspected BEFORE it would pass a save — a strict or even a `.partial()`
54
+ * parse would legitimately reject some of what it must accept. Sanitizing is not a validity constraint
55
+ * though (it only narrows stored bytes, the same way a save would), so a direct field-tree walk gets the
56
+ * safety without the rejection risk. Walks top-level fields, repeater entries, and (when the collection
57
+ * uses the block editor) block props + nested slots — everywhere a richtext leaf can live.
58
+ */
59
+ function sanitizePreviewValues(collection: BuiltCollection, values: Record<string, unknown>): void {
60
+ sanitizeRichtextFields(collection.def.fields, values)
61
+ if (collection.def.blocks?.enabled && Object.hasOwn(values, 'content')) sanitizeRichtextInBlocks(values.content)
62
+ }
63
+
64
+ /**
65
+ * Mint a preview ticket for the editor's UNSAVED state (ADR-0008). The editor posts what it currently
66
+ * holds and opens the page at `?kestrel-preview-token=<token>` in a new tab; nothing is written to the DB,
67
+ * so previewing never doubles as an unasked-for save. Admin-only (default-deny API guard + the backstop),
68
+ * and the ticket is bound to this session.
69
+ *
70
+ * body: { collection, id: number | null, locale?, values }
71
+ * 200: { token, expiresAt }
72
+ */
73
+ export default defineEventHandler(async (event) => {
74
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
75
+ const body = (await readBody(event)) as Partial<PreviewPayload> | null
76
+
77
+ const name = typeof body?.collection === 'string' ? body.collection : ''
78
+ const collection = getCollection(name) as BuiltCollection | undefined
79
+ if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
80
+
81
+ const values = body?.values
82
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
83
+ throw createError({ statusCode: 400, statusMessage: 'preview requires a `values` object' })
84
+ }
85
+ if (JSON.stringify(values).length > MAX_PAYLOAD_BYTES) {
86
+ throw createError({ statusCode: 413, statusMessage: 'Preview payload too large' })
87
+ }
88
+ sanitizePreviewValues(collection, values as Record<string, unknown>)
89
+
90
+ const id = typeof body?.id === 'number' && Number.isInteger(body.id) ? body.id : null
91
+ const locale = typeof body?.locale === 'string' ? body.locale : undefined
92
+ return usePreviewStore().mint(previewOwner(event), { collection: name, id, locale, values })
93
+ })