@michaelthielemann/kestrel 1.7.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 (62) hide show
  1. package/README.md +8 -4
  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 +8 -4
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/SeoFields.vue +1 -0
  8. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  9. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  10. package/layers/admin/app/composables/useEditForm.ts +6 -3
  11. package/layers/admin/app/composables/useListColumns.ts +1 -1
  12. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  13. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  14. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  15. package/layers/admin/app/utils/editor-expose.ts +8 -0
  16. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  17. package/layers/core/modules/kestrel/index.ts +1 -0
  18. package/layers/core/server/schema/introspect.ts +1 -1
  19. package/layers/core/server/schema/sync.ts +1 -1
  20. package/layers/core/server/utils/crud.ts +5 -4
  21. package/layers/core/server/utils/kestrel-config.ts +7 -0
  22. package/layers/fields/server/field-registry/index.ts +2 -1
  23. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  24. package/layers/media/app/components/MediaLibrary.vue +4 -1
  25. package/layers/media/app/components/MediaToolbar.vue +1 -1
  26. package/layers/media/app/components/field/Media.vue +2 -0
  27. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  28. package/layers/public/app/pages/[...slug].vue +27 -6
  29. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  30. package/layers/public/app/utils/preview-protocol.ts +36 -0
  31. package/layers/public/server/api/preview.get.ts +28 -0
  32. package/layers/public/server/api/preview.post.ts +93 -0
  33. package/layers/public/server/api/publish-status.get.ts +23 -9
  34. package/layers/public/server/api/publish.post.ts +84 -0
  35. package/layers/public/server/plugins/zz.publish.ts +12 -3
  36. package/layers/public/server/tasks/publish/run.ts +2 -1
  37. package/layers/public/server/utils/preview-token.ts +109 -0
  38. package/layers/public/server/utils/publish/invalidation.ts +24 -0
  39. package/layers/public/server/utils/publish/pending.ts +74 -0
  40. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  41. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  42. package/layers/public/server/utils/publish/publisher.ts +74 -9
  43. package/layers/ui/app/components/field/Datetime.vue +2 -0
  44. package/layers/ui/app/components/field/Repeater.vue +2 -0
  45. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  46. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  47. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  48. package/layers/ui/app/components/ui/Field.vue +1 -0
  49. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  50. package/layers/ui/app/components/ui/Icon.vue +2 -2
  51. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  52. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  53. package/layers/ui/app/components/ui/Select.vue +1 -0
  54. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  55. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  56. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  57. package/layers/ui/app/i18n/de.ts +10 -0
  58. package/layers/ui/app/i18n/en.ts +10 -0
  59. package/package.json +6 -1
  60. package/scripts/kestrel.mjs +7 -3
  61. package/scripts/lib/scaffold.mjs +23 -1
  62. package/templates/starter/app/blocks/Prose.vue +1 -0
@@ -23,4 +23,12 @@ export interface EditorExpose {
23
23
  live: PublishStatusData | null
24
24
  /** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
25
25
  recordTitle: string
26
+ /** Save, then write the static output (ADR-0008) — a draft is promoted to published on the way. */
27
+ publish: () => Promise<void>
28
+ publishing: boolean
29
+ /** False when `output.publishOnSave` is on — a save republishes, so the hosts hide the Publish button. */
30
+ canPublish: boolean
31
+ /** Open the record in a new tab: the saved URL, or the unsaved state carried by a preview ticket. */
32
+ openPreview: () => Promise<void>
33
+ previewOpening: boolean
26
34
  }
@@ -100,7 +100,9 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
100
100
  const src = content.slice(node.start, node.end)
101
101
  const names = Object.keys(scope)
102
102
  try {
103
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
103
+ // `src` is a slice of the CONSUMER's own block-SFC source, evaluated once here at build/discovery time
104
+ // to recover the object literal's runtime shape — never attacker- or request-supplied, so this is not
105
+ // an eval-injection surface.
104
106
  const fn = new Function(...names, `return (${src})`)
105
107
  const out = fn(...names.map((k) => scope[k]))
106
108
  if (!out || typeof out !== 'object') throw new Error('expected an object literal')
@@ -109,6 +111,7 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
109
111
  throw new Error(
110
112
  `${where}: could not evaluate the block declaration. Field/block args must be self-contained literals + ` +
111
113
  `field-factory calls (no imported constants, computed values, or TS type-args). Cause: ${(e as Error).message}`,
114
+ { cause: e },
112
115
  )
113
116
  }
114
117
  }
@@ -126,7 +129,7 @@ export function extractBlockDef(sfcSource: string, fileBase: string): ExtractedB
126
129
  try {
127
130
  ast = parse(content, { sourceType: 'module', plugins: ['typescript'] })
128
131
  } catch (e) {
129
- throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`)
132
+ throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`, { cause: e })
130
133
  }
131
134
 
132
135
  const props = macroCall(ast, 'defineProps')
@@ -74,6 +74,7 @@ export default defineNuxtModule<KestrelConfig>({
74
74
  dir: c.output.dir,
75
75
  publicDir: c.output.publicDir,
76
76
  auto: c.output.auto,
77
+ publishOnSave: c.output.publishOnSave,
77
78
  reconcileMinutes: c.output.reconcileMinutes,
78
79
  verbose: c.output.verbose,
79
80
  s3: {
@@ -1,4 +1,4 @@
1
- import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
1
+ import type { ColumnShape, IndexShape, SchemaSnapshot } from './model'
2
2
 
3
3
  // Read the *actual* schema of a live SQLite database into the normalized model, so `diffSchema` can
4
4
  // compare it against the desired schema (ADR-0002). Typed structurally (prepare + pragma) to keep the
@@ -1,4 +1,4 @@
1
- import { type IntrospectDb } from './introspect'
1
+ import type { IntrospectDb } from './introspect'
2
2
  import { diffSchema } from './diff'
3
3
  import { sqlite, type Dialect } from './dialect'
4
4
  import type { SchemaSnapshot, SchemaOp } from './model'
@@ -465,10 +465,11 @@ export function removeMany(db: DB, c: BuiltCollection, ids: number[]): { count:
465
465
  }
466
466
 
467
467
  /**
468
- * Publish / unpublish a batch of rows by persisting their `status` — NOT a separate publish path. Writing
469
- * `status` and emitting the SAME write event the editor save emits IS the publish: classifyWrite
470
- * planInvalidation the publish queue coalesces N emits into one incremental publish (PUBLISH renders the
471
- * self route, UNPUBLISH prunes the old route). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
468
+ * Publish / unpublish a batch of rows by persisting their `status` — the record's public INTENT, not the
469
+ * static file. Since ADR-0008 those are two steps: this write emits the same event an editor save emits,
470
+ * and the public layer's listener acts on the removal half only (UNPUBLISH prunes the route at once, so a
471
+ * page taken offline can never stay live). Making a page appear is the explicit publish action
472
+ * (`POST /api/publish`). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
472
473
  * any write). Validation (`assertConditions`) runs on PUBLISH ONLY — unpublishing must never be blockable
473
474
  * (you must always be able to take a broken page offline). Omits `update()`'s slug/transform branches,
474
475
  * which are provably inert for a status-only change.
@@ -123,6 +123,11 @@ export interface KestrelConfig {
123
123
  publicDir?: string
124
124
  /** Auto-publish affected pages on every content write (default true). */
125
125
  auto?: boolean
126
+ /** Opt out of the save/publish split (ADR-0008): `true` makes every content write republish the pages
127
+ * it affects, as before 2.0 — the editor's Publish button then has nothing left to do and is hidden.
128
+ * Default false: saving writes the DB, publishing writes the static files.
129
+ * Env `KESTREL_OUTPUT_PUBLISH_ON_SAVE`. */
130
+ publishOnSave?: boolean
126
131
  /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals a missed invalidation. */
127
132
  reconcileMinutes?: number
128
133
  /** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
@@ -158,6 +163,7 @@ export interface ResolvedKestrel {
158
163
  dir: string
159
164
  publicDir: string
160
165
  auto: boolean
166
+ publishOnSave: boolean
161
167
  reconcileMinutes: number
162
168
  verbose: boolean
163
169
  s3: ResolvedS3Settings
@@ -394,6 +400,7 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
394
400
  dir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_DIR) ?? clean(o.dir) ?? '.data/published'),
395
401
  publicDir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_PUBLIC_DIR) ?? clean(o.publicDir) ?? '.output/public'),
396
402
  auto: envBool(env.KESTREL_OUTPUT_AUTO, o.auto ?? true),
403
+ publishOnSave: envBool(env.KESTREL_OUTPUT_PUBLISH_ON_SAVE, o.publishOnSave ?? false),
397
404
  reconcileMinutes: resolveNonNegInt(o.reconcileMinutes, env.KESTREL_OUTPUT_RECONCILE_MINUTES),
398
405
  verbose: envBool(env.KESTREL_OUTPUT_VERBOSE, o.verbose ?? false),
399
406
  s3: resolveS3Settings(o.s3, env, 'KESTREL_OUTPUT_S3'),
@@ -161,6 +161,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
161
161
  type: z.literal('external'),
162
162
  // http(s) only, no control chars, no embedded credentials — the value ends up in a static <a href>.
163
163
  url: z.string().trim().pipe(z.url({ protocol: /^https?$/ })).refine((v) => {
164
+ // eslint-disable-next-line no-control-regex -- deliberately rejects control characters embedded in a URL destined for a static <a href>
164
165
  if (/[\u0000-\u001f]/.test(v)) return false
165
166
  const u = new URL(v)
166
167
  return !u.username && !u.password
@@ -168,7 +169,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
168
169
  label,
169
170
  }),
170
171
  z.object({ type: z.literal('email'), email: z.string().trim().pipe(z.email()), label }),
171
- z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-.\/]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
+ z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-./]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
173
  ]),
173
174
  f,
174
175
  )
@@ -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) }
@@ -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
@@ -45,7 +45,20 @@ const { data: resolved, error: resolveError } = await useAsyncData(`page:${local
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.
@@ -129,7 +149,8 @@ useHead({
129
149
  useSeoMeta({
130
150
  title: documentTitle,
131
151
  description: fallbacks.description,
132
- 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,
133
154
  ogTitle: head.meta.ogTitle,
134
155
  ogDescription: head.meta.ogDescription,
135
156
  ogUrl: head.meta.ogUrl,
@@ -148,9 +169,9 @@ useSeoMeta({
148
169
  <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
149
170
  for anonymous visitors or the static render), so it never ships to the public/static site.
150
171
  Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
151
- <div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
172
+ <div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
152
173
  <span class="kestrel-draft-badge__dot" aria-hidden="true" />
153
- Draft preview — not published
174
+ {{ previewNotice }}
154
175
  </div>
155
176
  <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
156
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
+ })
@@ -2,6 +2,7 @@ import { eq, getTableColumns } from 'drizzle-orm'
2
2
  import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
3
  import { publishStatus } from '../database/publish-status'
4
4
  import { routeForRecord } from '../utils/publish/route-for-record'
5
+ import { hasPendingChanges } from '../utils/publish/pending'
5
6
 
6
7
  /**
7
8
  * Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
@@ -17,28 +18,41 @@ export default defineEventHandler((event) => {
17
18
  // (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
18
19
  // (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
19
20
  // "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
20
- const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
21
- const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto }
21
+ const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
22
+ // `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
23
+ // off there is nothing left for it to do.
24
+ const publishOnSave = !!output?.publishOnSave
25
+ const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
22
26
 
23
27
  const q = getQuery(event)
24
28
  const name = typeof q.collection === 'string' ? q.collection : ''
25
29
  const id = Number(typeof q.id === 'string' ? q.id : NaN)
26
30
  const c = getCollection(name)
27
- if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
31
+ if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
28
32
 
29
33
  const db = useDb()
30
34
  const table = c.table as AnySQLiteTable
31
35
  const cols = getTableColumns(table) as Record<string, never>
32
- const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
36
+ const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
33
37
  const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
34
- if (!route) return { route: null, status: null, ...env }
38
+ if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
35
39
 
40
+ // Saved after it was last published: with publishing deferred to an explicit action, that is the normal
41
+ // working state of a page being edited — the live file is the previous version until someone publishes.
42
+ const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
36
43
  try {
37
44
  const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
38
- if (!st) return { route, status: null, ...env }
39
- return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, ...env }
45
+ // A routable page with no row was never published. Before the split that was indistinguishable from
46
+ // "a publish is running" a save always enqueued one but now nothing is in flight and nothing will be
47
+ // until someone presses Publish, so the lamp must not claim progress that is not happening.
48
+ if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
49
+ // With the split off, a save republishes on its own, so a newer save means a republish is in flight —
50
+ // reporting that as "unpublished changes" would ask the user to act on something already happening.
51
+ const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
52
+ return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
40
53
  } catch {
41
- // publish_status not migrated yet → treat as "no status" rather than a 500.
42
- return { route, status: null, ...env }
54
+ // publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
55
+ // either: the table is unreadable, so the page's real state is unknown, not known to be absent.
56
+ return { route, status: null, pending: false, neverPublished: false, ...env }
43
57
  }
44
58
  })