@michaelthielemann/kestrel 1.7.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +23 -12
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/admin/app/components/BlocksBody.vue +2 -1
  4. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  5. package/layers/admin/app/components/CollectionList.vue +13 -5
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/PageFields.vue +5 -0
  8. package/layers/admin/app/components/SeoFields.vue +43 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +15 -6
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/edit-form.ts +9 -2
  17. package/layers/admin/app/utils/editor-expose.ts +8 -0
  18. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  19. package/layers/core/modules/kestrel/index.ts +12 -0
  20. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  21. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  22. package/layers/core/server/schema/introspect.ts +1 -1
  23. package/layers/core/server/schema/sync.ts +1 -1
  24. package/layers/core/server/utils/collection-types.ts +4 -3
  25. package/layers/core/server/utils/crud.ts +5 -4
  26. package/layers/core/server/utils/defineCollection.ts +8 -1
  27. package/layers/core/server/utils/kestrel-config.ts +42 -1
  28. package/layers/core/server/utils/seo.ts +18 -0
  29. package/layers/core/server/utils/write-effects.ts +40 -0
  30. package/layers/fields/server/field-registry/index.ts +2 -1
  31. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  32. package/layers/fields/server/utils/buildCollection.ts +9 -7
  33. package/layers/media/app/components/KestrelImg.vue +29 -0
  34. package/layers/media/app/components/MediaLibrary.vue +11 -6
  35. package/layers/media/app/components/MediaToolbar.vue +1 -1
  36. package/layers/media/app/components/MediaViewer.vue +56 -7
  37. package/layers/media/app/components/field/Media.vue +2 -0
  38. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  39. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  40. package/layers/media/app/utils/library.ts +2 -0
  41. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  42. package/layers/media/server/api/media/[id].patch.ts +25 -1
  43. package/layers/media/server/api/media/index.post.ts +19 -3
  44. package/layers/media/server/collections/media.ts +14 -0
  45. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  46. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  47. package/layers/media/server/utils/library.ts +2 -1
  48. package/layers/media/server/utils/record.ts +8 -0
  49. package/layers/media/server/utils/resolve.ts +12 -0
  50. package/layers/public/app/pages/[...slug].vue +57 -7
  51. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  52. package/layers/public/app/utils/json-ld.ts +139 -0
  53. package/layers/public/app/utils/preview-protocol.ts +36 -0
  54. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  55. package/layers/public/modules/prerender-routes/index.ts +5 -2
  56. package/layers/public/server/api/preview.get.ts +28 -0
  57. package/layers/public/server/api/preview.post.ts +93 -0
  58. package/layers/public/server/api/publish-status.get.ts +23 -9
  59. package/layers/public/server/api/publish.post.ts +84 -0
  60. package/layers/public/server/api/route.get.ts +9 -1
  61. package/layers/public/server/collections/redirects.ts +75 -0
  62. package/layers/public/server/plugins/03.redirects.ts +37 -0
  63. package/layers/public/server/plugins/zz.publish.ts +12 -3
  64. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  65. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  66. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  67. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  68. package/layers/public/server/tasks/publish/run.ts +2 -1
  69. package/layers/public/server/utils/llms-full.ts +125 -0
  70. package/layers/public/server/utils/llms.ts +13 -0
  71. package/layers/public/server/utils/page-resolve.ts +112 -4
  72. package/layers/public/server/utils/preview-token.ts +109 -0
  73. package/layers/public/server/utils/publish/invalidation.ts +72 -3
  74. package/layers/public/server/utils/publish/pending.ts +74 -0
  75. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  76. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  77. package/layers/public/server/utils/publish/publisher.ts +85 -14
  78. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  79. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  80. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  81. package/layers/public/server/utils/site-url.ts +8 -0
  82. package/layers/public/server/utils/sitemap.ts +5 -3
  83. package/layers/ui/app/components/field/Choice.vue +6 -1
  84. package/layers/ui/app/components/field/Datetime.vue +2 -0
  85. package/layers/ui/app/components/field/Repeater.vue +2 -0
  86. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  87. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  88. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  89. package/layers/ui/app/components/ui/Field.vue +1 -0
  90. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  91. package/layers/ui/app/components/ui/Icon.vue +2 -2
  92. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  93. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  94. package/layers/ui/app/components/ui/Select.vue +1 -0
  95. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  96. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  97. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  98. package/layers/ui/app/i18n/de.ts +23 -0
  99. package/layers/ui/app/i18n/en.ts +23 -0
  100. package/package.json +7 -1
  101. package/scripts/kestrel.mjs +7 -3
  102. package/scripts/lib/scaffold.mjs +23 -1
  103. package/templates/starter/app/blocks/Prose.vue +1 -0
  104. package/templates/starter/nuxt.config.ts +3 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Upload-time scan for signals that a file was AI-generated or -manipulated (EU AI Act Art. 50).
3
+ *
4
+ * **This produces EVIDENCE, never a classification.** Everything it finds is quoted into the free-text
5
+ * `aiNote`; the legal `aiSourceType` stays a deliberate human decision, because a signal is neither proof
6
+ * nor its absence a disproof: metadata survives no re-save, screenshot or re-encode, and an upstream file
7
+ * can be mislabeled or forged. In particular a C2PA manifest is reported as PRESENT only — verifying its
8
+ * signature needs the full C2PA SDK plus a trust list, which Kestrel deliberately does not ship.
9
+ *
10
+ * Pure (no DB, no Nitro) so it stays unit-testable, and never throws: an unreadable file is simply no
11
+ * evidence, which must not fail the upload it came with.
12
+ */
13
+
14
+ /** Tools that name themselves in EXIF `Software`. Matched case-insensitively as whole phrases, so an
15
+ * ordinary "Adobe Photoshop" is not caught by the "Adobe Firefly" entry. */
16
+ const KNOWN_GENERATORS = [
17
+ 'midjourney', 'dall-e', 'dall·e', 'adobe firefly', 'stable diffusion', 'leonardo.ai',
18
+ 'nightcafe', 'bing image creator', 'google imagefx', 'imagen', 'flux.1', 'ideogram',
19
+ ]
20
+
21
+ /** PNG text-chunk keywords the Stable-Diffusion / ComfyUI families write their generation graph under. */
22
+ const GENERATION_KEYWORDS = ['parameters', 'prompt', 'workflow']
23
+
24
+ export interface AiSignal {
25
+ /** One human-readable line per signal found, in a stable order. Never empty (the result is null instead). */
26
+ evidence: string[]
27
+ }
28
+
29
+ /** Walk PNG chunks after the 8-byte signature, yielding `[type, data]`. Bails out on any malformed length. */
30
+ function* pngChunks(bytes: Buffer): Generator<[string, Buffer]> {
31
+ if (bytes.length < 8 || bytes.readUInt32BE(0) !== 0x89504E47) return
32
+ let at = 8
33
+ while (at + 8 <= bytes.length) {
34
+ const len = bytes.readUInt32BE(at)
35
+ const end = at + 8 + len
36
+ if (len > bytes.length || end + 4 > bytes.length) return
37
+ yield [bytes.toString('latin1', at + 4, at + 8), bytes.subarray(at + 8, end)]
38
+ at = end + 4 // skip the trailing CRC
39
+ }
40
+ }
41
+
42
+ /** Walk JPEG marker segments after SOI, yielding `[marker, payload]`. Stops at SOS (entropy-coded data). */
43
+ function* jpegSegments(bytes: Buffer): Generator<[number, Buffer]> {
44
+ if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return
45
+ let at = 2
46
+ while (at + 4 <= bytes.length) {
47
+ if (bytes[at] !== 0xFF) return
48
+ const marker = bytes[at + 1]!
49
+ if (marker === 0xDA || marker === 0xD9) return
50
+ const len = bytes.readUInt16BE(at + 2)
51
+ if (len < 2 || at + 2 + len > bytes.length) return
52
+ yield [marker, bytes.subarray(at + 4, at + 2 + len)]
53
+ at += 2 + len
54
+ }
55
+ }
56
+
57
+ /** Walk RIFF/WebP chunks, yielding `[fourcc, data]`. Chunks are padded to an even length. */
58
+ function* riffChunks(bytes: Buffer): Generator<[string, Buffer]> {
59
+ if (bytes.length < 12 || bytes.toString('latin1', 0, 4) !== 'RIFF') return
60
+ let at = 12
61
+ while (at + 8 <= bytes.length) {
62
+ const len = bytes.readUInt32LE(at + 4)
63
+ const end = at + 8 + len
64
+ if (len > bytes.length || end > bytes.length) return
65
+ yield [bytes.toString('latin1', at, at + 4), bytes.subarray(at + 8, end)]
66
+ at = end + (len % 2)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The IPTC Digital Source Type a generator self-declared, read straight out of the embedded XMP packet.
72
+ * Deliberately a byte-level scan rather than a metadata library: XMP is the same XML in every container,
73
+ * so this also covers the ones exifr cannot open (WebP), and it costs one `indexOf` when absent.
74
+ */
75
+ function findDigitalSourceType(bytes: Buffer): string | null {
76
+ const at = bytes.indexOf('DigitalSourceType', 0, 'latin1')
77
+ if (at < 0) return null
78
+ const window = bytes.toString('latin1', at, Math.min(at + 512, bytes.length))
79
+ // Both XMP serializations: as an rdf:Description attribute, or as its own element.
80
+ const value = /^DigitalSourceType\s*=\s*"([^"]*)"/.exec(window)?.[1]
81
+ ?? /^DigitalSourceType[^>]*>([^<]*)</.exec(window)?.[1]
82
+ const trimmed = value?.trim()
83
+ return trimmed ? trimmed : null
84
+ }
85
+
86
+ /** True when the file structurally carries a C2PA/JUMBF manifest store. Presence only — never verified. */
87
+ function hasC2paManifest(bytes: Buffer): boolean {
88
+ for (const [type] of pngChunks(bytes)) if (type === 'caBX') return true
89
+ // C2PA-in-JPEG lives in APP11 segments whose payload starts with the JUMBF "JP" identifier.
90
+ for (const [marker, payload] of jpegSegments(bytes)) {
91
+ if (marker === 0xEB && payload.length >= 2 && payload.toString('latin1', 0, 2) === 'JP') return true
92
+ }
93
+ for (const [fourcc] of riffChunks(bytes)) if (fourcc === 'C2PA') return true
94
+ return false
95
+ }
96
+
97
+ /** The generation-parameter keyword a PNG text chunk is stored under, or null. Covers tEXt/zTXt/iTXt —
98
+ * in all three the keyword is the leading null-terminated string, so a compressed payload needs no
99
+ * inflating to be recognised (exifr only decodes the uncompressed tEXt form). */
100
+ function findPngGenerationChunk(bytes: Buffer): string | null {
101
+ for (const [type, data] of pngChunks(bytes)) {
102
+ if (type !== 'tEXt' && type !== 'zTXt' && type !== 'iTXt') continue
103
+ const nul = data.indexOf(0)
104
+ const keyword = data.toString('latin1', 0, nul < 0 ? data.length : nul).toLowerCase()
105
+ if (GENERATION_KEYWORDS.includes(keyword)) return keyword
106
+ }
107
+ return null
108
+ }
109
+
110
+ /** The EXIF `Software`/`ProcessingSoftware` value, when it names a tool on the known-generator list. */
111
+ async function findGeneratorSoftware(bytes: Buffer): Promise<string | null> {
112
+ let tags: Record<string, unknown> | undefined
113
+ try {
114
+ // Loaded lazily: consumers who never turn the feature on never pay the module's parse cost.
115
+ const exifr = (await import('exifr')).default
116
+ tags = await exifr.parse(bytes, { tiff: true, exif: true, mergeOutput: true }) as Record<string, unknown> | undefined
117
+ } catch {
118
+ return null // an unsupported container or a corrupt header is simply no evidence
119
+ }
120
+ for (const key of ['Software', 'ProcessingSoftware']) {
121
+ const value = tags?.[key]
122
+ if (typeof value !== 'string' || !value.trim()) continue
123
+ const haystack = value.toLowerCase()
124
+ if (KNOWN_GENERATORS.some((g) => haystack.includes(g))) return value.trim()
125
+ }
126
+ return null
127
+ }
128
+
129
+ /**
130
+ * Scan an uploaded file for AI-origin signals. Returns null when nothing matched — which is NOT evidence
131
+ * of non-AI origin, only the absence of a declaration.
132
+ */
133
+ export async function detectAiSignal(bytes: Buffer, _mime: string): Promise<AiSignal | null> {
134
+ const evidence: string[] = []
135
+
136
+ const sourceType = findDigitalSourceType(bytes)
137
+ if (sourceType) evidence.push(`IPTC/XMP Digital Source Type: ${sourceType}`)
138
+
139
+ const software = await findGeneratorSoftware(bytes)
140
+ if (software) evidence.push(`EXIF Software: ${software}`)
141
+
142
+ if (hasC2paManifest(bytes)) {
143
+ evidence.push('C2PA content-credentials manifest present (unverified — presence only, no signature check)')
144
+ }
145
+
146
+ const keyword = findPngGenerationChunk(bytes)
147
+ if (keyword) evidence.push(`PNG text chunk "${keyword}" present (Stable-Diffusion-style generation parameters)`)
148
+
149
+ return evidence.length ? { evidence } : null
150
+ }
151
+
152
+ /** How the scan's findings are worded into `aiNote`. Kept here so the wording has one home. */
153
+ export function aiSignalNote(signal: AiSignal): string {
154
+ return `Detected at upload: ${signal.evidence.join('; ')}`
155
+ }
@@ -56,7 +56,8 @@ export function listLibrary(db: BetterSQLite3Database, q: LibraryQuery, publicUr
56
56
  return {
57
57
  id: m.id, filename: r.filename as string, mime: m.mime, folder: r.folder as string,
58
58
  size: r.size as number, width: m.width, height: m.height, thumbhash: m.thumbhash,
59
- src: m.src, srcset, alt: m.alt, createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
59
+ src: m.src, srcset, alt: m.alt, aiDisclosure: m.aiDisclosure,
60
+ createdAt: r.createdAt as Date, updatedAt: r.updatedAt as Date,
60
61
  }
61
62
  })
62
63
 
@@ -14,6 +14,10 @@ export interface MediaInput {
14
14
  checksum: string
15
15
  derived?: DerivedImage
16
16
  translations?: Record<string, { alt?: string; title?: string; description?: string }>
17
+ /** EU AI Act disclosure to write. Omitted/null ⇒ the column is left out of the values entirely, which is
18
+ * what keeps an overwrite from wiping a disclosure the re-upload did not re-send. */
19
+ aiSourceType?: string | null
20
+ aiNote?: string | null
17
21
  }
18
22
 
19
23
  /**
@@ -46,6 +50,10 @@ export function buildMediaValues(input: MediaInput): Record<string, unknown> {
46
50
  thumbhash: input.derived?.thumbhash ?? null,
47
51
  derivatives: manifest,
48
52
  translations: input.translations ?? {},
53
+ // Written only when there is something to write: the overwrite path feeds these same values to an
54
+ // UPDATE, and a null here would silently clear a disclosure an editor set on the existing row.
55
+ ...(input.aiSourceType != null ? { aiSourceType: input.aiSourceType } : {}),
56
+ ...(input.aiNote != null ? { aiNote: input.aiNote } : {}),
49
57
  }
50
58
  }
51
59
 
@@ -7,6 +7,9 @@ import type { DerivativeManifest } from './record'
7
7
  /** One derivative, tagged with its parsed name + format so `<picture>` rendering can group by format. */
8
8
  export interface MediaVariant { name: string; format: string; url: string; width: number; height: number }
9
9
 
10
+ /** The disclosure-relevant subset of the IPTC Digital Source Type vocabulary (EU AI Act Art. 50). */
11
+ export type AiSourceType = 'trainedAlgorithmicMedia' | 'compositeWithTrainedAlgorithmicMedia' | 'algorithmicallyEnhanced'
12
+
10
13
  export interface ResolvedMedia {
11
14
  id: number
12
15
  /** The media's storage folder ('' at the media root) — lets the field open the picker there. */
@@ -23,6 +26,10 @@ export interface ResolvedMedia {
23
26
  srcset: { url: string; width: number }[]
24
27
  /** Every derivative, name+format-tagged — the source `KestrelImg`/`useMediaVariant` build `<picture>` from. */
25
28
  variants: MediaVariant[]
29
+ /** EU AI Act Art. 50 disclosure, set by an editor — null when unset. Always resolved regardless of
30
+ * `kestrel.config.ts`'s `aiDisclosure.enabled` (that flag only gates the admin UI). Kestrel never
31
+ * renders this automatically; read it directly or opt into `KestrelImg`'s `aiBadge` prop. */
32
+ aiDisclosure: { sourceType: AiSourceType; note: string | null } | null
26
33
  }
27
34
 
28
35
  interface MediaRow {
@@ -35,6 +42,8 @@ interface MediaRow {
35
42
  thumbhash: string | null
36
43
  derivatives: DerivativeManifest | null
37
44
  translations: Record<string, { alt?: string; title?: string; description?: string }> | null
45
+ aiSourceType: string | null
46
+ aiNote: string | null
38
47
  }
39
48
 
40
49
  export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: string) => string): ResolvedMedia {
@@ -69,6 +78,9 @@ export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: str
69
78
  src: publicUrl(row.storageKey),
70
79
  srcset,
71
80
  variants,
81
+ // A note without a source type is only evidence (e.g. the upload scan's pre-fill), not a disclosure —
82
+ // never emit a half-filled object a consumer might render as one.
83
+ aiDisclosure: row.aiSourceType ? { sourceType: row.aiSourceType as AiSourceType, note: row.aiNote ?? null } : null,
72
84
  }
73
85
  }
74
86
 
@@ -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
@@ -15,6 +15,9 @@ interface RenderedPage {
15
15
  description?: string
16
16
  noindex?: boolean
17
17
  $media?: { image?: { src: string; width: number | null; height: number | null } | null }
18
+ author?: string
19
+ publishedDate?: string
20
+ keywords?: string
18
21
  }
19
22
  content?: unknown[]
20
23
  status?: string
@@ -42,10 +45,24 @@ const { data: resolved, error: resolveError } = await useAsyncData(`page:${local
42
45
  collection: string | null
43
46
  page: (RenderedPage & Record<string, unknown>) | null
44
47
  alternates?: Array<{ locale: string; path: string }>
48
+ ancestors?: Array<{ path: string; title?: string; locale?: string }>
45
49
  site?: SiteHead | null
46
50
  }),
47
51
  )
48
- const page = computed(() => resolved.value?.page ?? null)
52
+ // Ticket preview (ADR-0008): `?kestrel-preview-token=…` carries the editor's UNSAVED state, so an external
53
+ // tab can show work in progress without a save and without publishing. The ticket is admin-only and
54
+ // session-bound server-side; an expired/foreign/unknown one reads as null and the saved record renders.
55
+ const previewToken = route.query[PREVIEW_TOKEN_QUERY]
56
+ const { data: ticket } = typeof previewToken === 'string' && previewToken
57
+ ? await useAsyncData(`kestrel-preview-ticket:${previewToken}`, () =>
58
+ requestFetch('/api/preview', { query: { token: previewToken } })
59
+ .then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
60
+ .catch(() => null))
61
+ : { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
62
+ const previewValues = computed(() => ticket.value?.payload?.values ?? null)
63
+ const previewingTicket = computed(() => previewValues.value !== null)
64
+
65
+ const page = computed(() => previewPage(resolved.value?.page ?? null, previewValues.value) as (RenderedPage & Record<string, unknown>) | null)
49
66
  // `fallback` below only rescues a truthy name that is missing from the layout map, so the empty cases have
50
67
  // to be coalesced here — see resolvePageLayout. The cast is the one honest bridge in this file: the stored
51
68
  // name is arbitrary editor data, while `NuxtLayout` types `name` as the union of layouts that existed at
@@ -60,7 +77,7 @@ const pageState = usePublicPageState()
60
77
  watchEffect(() => {
61
78
  pageState.value = {
62
79
  collection: resolved.value?.collection ?? null,
63
- page: resolved.value?.page ?? null,
80
+ page: page.value,
64
81
  }
65
82
  })
66
83
 
@@ -68,6 +85,13 @@ watchEffect(() => {
68
85
  // published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
69
86
  const isDraftPreview = computed(() => page.value?.status === 'draft')
70
87
 
88
+ // What this tab is actually showing, when it is not the live page. A ticket outranks the draft notice: the
89
+ // content on screen was never saved at all, which is the stronger caveat.
90
+ const previewNotice = computed(() => {
91
+ if (previewingTicket.value) return 'Preview — unsaved changes, not published'
92
+ return isDraftPreview.value ? 'Draft preview — not published' : ''
93
+ })
94
+
71
95
  // Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
72
96
  // checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
73
97
  // agree on which branch renders; an anonymous visitor with the query param gets the normal page.
@@ -93,7 +117,7 @@ if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMess
93
117
  // Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
94
118
  // page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
95
119
  // og:image) require a configured siteUrl and degrade away without one.
96
- const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string }
120
+ const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string; seoArticleMeta?: boolean }
97
121
  const seo = page.value?.seo ?? {}
98
122
  const siteHead = resolved.value?.site ?? null
99
123
  const fallbacks = siteHeadFallbacks(seo, siteHead)
@@ -113,11 +137,36 @@ const head = buildPageHead({
113
137
  alternates: resolved.value?.alternates ?? [],
114
138
  })
115
139
 
140
+ // schema.org JSON-LD — the one grounding signal every major answer engine documents. Same inputs as the
141
+ // head above, so the two can never disagree; it degrades away without a siteUrl and is suppressed for a
142
+ // noindex page or an unsaved ticket preview. Article metadata is published ONLY with `seo.articleMeta`
143
+ // on: the fields may hold values (the column always round-trips them) that this installation must not
144
+ // disclose, so the flag gates emission, not storage.
145
+ const jsonLd = buildJsonLd({
146
+ siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
147
+ siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
148
+ canonical: head.canonical,
149
+ locale,
150
+ primary,
151
+ prefixPrimary,
152
+ title: pageTitle,
153
+ description: fallbacks.description,
154
+ imageUrl: head.meta.ogImage,
155
+ noindex: previewingTicket.value || seo.noindex,
156
+ ancestors: resolved.value?.ancestors ?? [],
157
+ article: publicRc.seoArticleMeta === true
158
+ ? { author: seo.author, publishedDate: seo.publishedDate, keywords: seo.keywords }
159
+ : null,
160
+ })
161
+
116
162
  // Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
117
163
  // (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
118
164
  // Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
119
165
  useHead({
120
166
  htmlAttrs: { lang: locale },
167
+ // `textContent` (not innerHTML) is unhead's XSS-safe arm for a data script: it takes the object and
168
+ // serializes it itself, so editor-authored strings can never close the <script>.
169
+ script: jsonLd ? [{ type: 'application/ld+json' as const, textContent: jsonLd }] : [],
121
170
  link: [
122
171
  // `rel` needs the literal type: unhead keys its link union on it, and inside an array literal that
123
172
  // reaches `link:` through a spread there is no contextual type to stop TS widening it to `string`.
@@ -129,7 +178,8 @@ useHead({
129
178
  useSeoMeta({
130
179
  title: documentTitle,
131
180
  description: fallbacks.description,
132
- robots: seo.noindex ? 'noindex, nofollow' : undefined,
181
+ // A ticket preview is unsaved content at a real URL — never indexable, whatever the record's own SEO says.
182
+ robots: previewingTicket.value || seo.noindex ? 'noindex, nofollow' : undefined,
133
183
  ogTitle: head.meta.ogTitle,
134
184
  ogDescription: head.meta.ogDescription,
135
185
  ogUrl: head.meta.ogUrl,
@@ -148,9 +198,9 @@ useSeoMeta({
148
198
  <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
149
199
  for anonymous visitors or the static render), so it never ships to the public/static site.
150
200
  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">
201
+ <div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
152
202
  <span class="kestrel-draft-badge__dot" aria-hidden="true" />
153
- Draft preview — not published
203
+ {{ previewNotice }}
154
204
  </div>
155
205
  <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
156
206
  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>
@@ -0,0 +1,139 @@
1
+ import { localePath } from '../../../core/app/utils/locale-path'
2
+
3
+ /** One published ancestor of the rendered page, outermost first — the breadcrumb trail. */
4
+ export interface JsonLdAncestor {
5
+ /** The ancestor's own path, unprefixed (the emitter locale-prefixes it, as `buildPageHead` does). */
6
+ path: string
7
+ title?: string
8
+ /** The ancestor's OWN locale. Absent for a record in a non-translatable collection, whose single
9
+ * published URL is the primary-locale one — the same `row.locale ?? primary` rule the sitemap uses. */
10
+ locale?: string
11
+ }
12
+
13
+ /** The opt-in article metadata (`kestrel.seo.articleMeta`). Absent ⇒ the page stays a plain `WebPage`
14
+ * and no authorship or date is published — the default, and the only behaviour a consumer who never
15
+ * turns the flag on can get. */
16
+ export interface JsonLdArticle {
17
+ author?: string
18
+ /** ISO date (`YYYY-MM-DD`) or ISO datetime; anything else is dropped rather than emitted invalid. */
19
+ publishedDate?: string
20
+ /** Free-form comma-separated list — schema.org accepts that spelling for `keywords` verbatim. */
21
+ keywords?: string
22
+ }
23
+
24
+ export interface JsonLdInput {
25
+ /** Absolute site origin ('' when unconfigured). */
26
+ siteUrl: string
27
+ siteName?: string
28
+ /** The page's absolute canonical URL (from `buildPageHead`); absent ⇒ nothing is emitted. */
29
+ canonical?: string
30
+ locale: string
31
+ primary: string
32
+ prefixPrimary: boolean
33
+ title?: string
34
+ description?: string
35
+ /** The already-absolute og:image URL, so the image-resolution rules live in one place. */
36
+ imageUrl?: string
37
+ /** Excluded from the graph entirely — see `buildJsonLd`. */
38
+ noindex?: boolean
39
+ ancestors?: JsonLdAncestor[]
40
+ article?: JsonLdArticle | null
41
+ }
42
+
43
+ /** A type alias, not an interface, for the same reason as `PageHeadLink`: unhead's `script` entry types
44
+ * `textContent` as `string | Record<string, unknown>`, and TS derives the implicit index signature that
45
+ * needs for an alias but never for an interface. */
46
+ export type JsonLd = {
47
+ '@context': 'https://schema.org'
48
+ '@graph': Record<string, unknown>[]
49
+ }
50
+
51
+ const trimmed = (v: unknown): string | undefined => {
52
+ const s = typeof v === 'string' ? v.trim() : ''
53
+ return s || undefined
54
+ }
55
+
56
+ // schema.org dates are ISO 8601; an editor free-text value that is not one would be published as a
57
+ // broken `datePublished`, which is worse for a consuming engine than no date at all.
58
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]|$)/
59
+
60
+ /**
61
+ * The JSON-LD graph for a rendered public page: a site-wide `WebSite`, the page itself as a `WebPage`
62
+ * (or an `Article`, when the consumer opted into article metadata AND the record carries some), and a
63
+ * `BreadcrumbList` built from the page's real published ancestors. Pure, like `buildPageHead`, and fed
64
+ * the same already-resolved values — the precedence chain stays outside (ADR-0007).
65
+ *
66
+ * Two whole-graph veto rules, both because the alternative is a false signal rather than a missing one:
67
+ * without an absolute canonical every `@id`/`url` would be a relative path that resolves against
68
+ * whatever host fetched it, and a `noindex` page asking to be excluded from search has no business
69
+ * shipping structured data that exists to be indexed.
70
+ *
71
+ * Breadcrumb items are REAL pages only (the resolver hands over the published ancestors it found), so a
72
+ * trail never advertises an intermediate URL that 404s; a trail of one is dropped, since "you are here"
73
+ * carries no information.
74
+ */
75
+ export function buildJsonLd(input: JsonLdInput): JsonLd | null {
76
+ const canonical = trimmed(input.canonical)
77
+ if (!canonical || input.noindex) return null
78
+ const base = input.siteUrl.replace(/\/+$/, '')
79
+ const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
80
+
81
+ const graph: Record<string, unknown>[] = []
82
+ const siteName = trimmed(input.siteName)
83
+ // A nameless WebSite node is an empty assertion — omit it, and with it the isPartOf edge that would
84
+ // otherwise dangle at an @id nothing defines.
85
+ const websiteId = siteName ? `${base}/#website` : undefined
86
+ if (websiteId) graph.push({ '@type': 'WebSite', '@id': websiteId, url: `${base}/`, name: siteName })
87
+
88
+ const article = usableArticle(input.article)
89
+ const page: Record<string, unknown> = {
90
+ '@type': article ? 'Article' : 'WebPage',
91
+ '@id': `${canonical}#webpage`,
92
+ url: canonical,
93
+ }
94
+ const title = trimmed(input.title)
95
+ // `headline` is the Article spelling of the same value; keeping them apart avoids a node that claims
96
+ // both and matches what validators expect per type.
97
+ if (title) page[article ? 'headline' : 'name'] = title
98
+ const description = trimmed(input.description)
99
+ if (description) page.description = description
100
+ page.inLanguage = input.locale
101
+ if (websiteId) page.isPartOf = { '@id': websiteId }
102
+ const imageUrl = trimmed(input.imageUrl)
103
+ if (imageUrl) page.image = imageUrl
104
+ if (article) {
105
+ if (article.author) page.author = { '@type': 'Person', name: article.author }
106
+ if (article.publishedDate) page.datePublished = article.publishedDate
107
+ if (article.keywords) page.keywords = article.keywords
108
+ }
109
+
110
+ const crumbs = [
111
+ ...(input.ancestors ?? []).map((a) => ({ name: trimmed(a.title) ?? a.path, item: abs(a.path, a.locale ?? input.primary) })),
112
+ { name: title ?? canonical, item: canonical },
113
+ ]
114
+ if (crumbs.length >= 2) {
115
+ const breadcrumbId = `${canonical}#breadcrumb`
116
+ page.breadcrumb = { '@id': breadcrumbId }
117
+ graph.push(page, {
118
+ '@type': 'BreadcrumbList',
119
+ '@id': breadcrumbId,
120
+ itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.item })),
121
+ })
122
+ } else {
123
+ graph.push(page)
124
+ }
125
+
126
+ return { '@context': 'https://schema.org', '@graph': graph }
127
+ }
128
+
129
+ /** The article bag reduced to the values worth publishing, or null — which keeps the node a `WebPage`.
130
+ * A bag of blanks (an editor who opened the fields and typed nothing) must not upgrade the type. */
131
+ function usableArticle(article: JsonLdArticle | null | undefined): Required<JsonLdArticle> | null {
132
+ if (!article) return null
133
+ const author = trimmed(article.author) ?? ''
134
+ const rawDate = trimmed(article.publishedDate) ?? ''
135
+ const publishedDate = ISO_DATE.test(rawDate) ? rawDate : ''
136
+ const keywords = trimmed(article.keywords) ?? ''
137
+ // An unparseable date alone must not upgrade the type either — nothing would be emitted from it.
138
+ return author || publishedDate || keywords ? { author, publishedDate, keywords } : null
139
+ }
@@ -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)}` : ''
@@ -69,12 +69,26 @@ export function precompressedEncoding(filename: string, siblingNames: string[]):
69
69
  const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
70
70
  const REVALIDATE_CACHE = 'public, max-age=0, must-revalidate'
71
71
 
72
+ /** Artifacts served at a LITERAL key (not `<path>/index.html`) and rendered from the live DB rather than
73
+ * copied from the build. One list, because each of them has to be handled the same way in four places:
74
+ * re-rendered on every publish, excluded from the build-asset mirror (a stale copy must never overwrite
75
+ * a fresh one), seeded into the prerender routes, and cached as revalidate-always below.
76
+ * `llms-full.txt` is opt-in (`kestrel.seo.llmsFull`) and simply renders to a 404 when off — it stays in
77
+ * this list either way, so the rules that are about the FILENAME hold regardless of the flag; only the
78
+ * prerender seeding, which asks for the ROUTE, has to consult it. */
79
+ export const META_KEYS = ['sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt', 'redirects.json'] as const
80
+
81
+ export function isMetaKey(key: string): boolean {
82
+ return (META_KEYS as readonly string[]).includes(key)
83
+ }
84
+
72
85
  /**
73
86
  * `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
74
- * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML,
75
- * the sitemap and `robots.txt` live at *stable* URLs whose content changes on any deploy, so they get
76
- * `max-age=0, must-revalidate` (cacheable but always revalidated). Everything else (favicons, fonts,
77
- * un-hashed media) is left to the host default.
87
+ * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML and
88
+ * the `META_KEYS` artifacts live at *stable* URLs whose content changes on any deploy, so they get
89
+ * `max-age=0, must-revalidate` (cacheable but always revalidated) `redirects.json` especially, since a
90
+ * cached copy keeps serving withdrawn redirects. Everything else (favicons, fonts, un-hashed media) is
91
+ * left to the host default.
78
92
  */
79
93
  export function cacheControlFor(key: string): string | undefined {
80
94
  // Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
@@ -82,7 +96,7 @@ export function cacheControlFor(key: string): string | undefined {
82
96
  if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
83
97
  if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
84
98
  const base = key.split('/').pop() ?? key
85
- if (base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') return REVALIDATE_CACHE
99
+ if (base.endsWith('.html') || isMetaKey(base)) return REVALIDATE_CACHE
86
100
  return undefined
87
101
  }
88
102
 
@@ -4,7 +4,7 @@ import { defineNuxtModule } from '@nuxt/kit'
4
4
  import Database from 'better-sqlite3'
5
5
  import { collectPageRoutes, pageLikeTables } from './discover'
6
6
  import { localePath } from '../../../core/app/utils/locale-path'
7
- import { recordRouteDiscovery, type RouteDiscovery } from '../deploy-output/deploy-output'
7
+ import { recordRouteDiscovery, META_KEYS, type RouteDiscovery } from '../deploy-output/deploy-output'
8
8
  import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
9
9
 
10
10
  // Read published paths from EVERY page-like collection straight from the DB at build time so
@@ -73,7 +73,10 @@ export default defineNuxtModule({
73
73
  // Hand the deploy module the completeness of THIS enumeration — the only step that knows it.
74
74
  recordRouteDiscovery(nuxt, discovery)
75
75
  nitro.prerender ||= {}
76
- nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, '/sitemap.xml', '/robots.txt', '/llms.txt'])]
76
+ // Every meta artifact EXCEPT `llms-full.txt`, which is seeded only when the consumer opted in — the
77
+ // route 404s otherwise, and a prerender error fails the whole `nuxt generate`.
78
+ const meta = META_KEYS.filter((k) => k !== 'llms-full.txt' || c.seo.llmsFull).map((k) => `/${k}`)
79
+ nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, ...meta])]
77
80
  // Render pages in parallel — the dominant lever on `nuxt generate` wall-clock as page count grows.
78
81
  // Safe: page reads are synchronous better-sqlite3 (WAL + busy_timeout, can't interleave mid-statement);
79
82
  // the one WRITE on this path — the variant-registry capture — is an IMMEDIATE transaction, so