@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.
- package/README.md +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +6 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/kestrel-config.ts +7 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/public/app/pages/[...slug].vue +27 -6
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/plugins/zz.publish.ts +12 -3
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +24 -0
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +74 -9
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +6 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- 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
|
-
//
|
|
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')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColumnShape, IndexShape,
|
|
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
|
|
@@ -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` —
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
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 ()
|
|
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', '
|
|
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)
|
|
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)
|
|
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 '
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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="
|
|
172
|
+
<div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
|
|
152
173
|
<span class="kestrel-draft-badge__dot" aria-hidden="true" />
|
|
153
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
})
|