@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.
- package/README.md +23 -12
- 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 +13 -5
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +43 -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 +15 -6
- 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/edit-form.ts +9 -2
- 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 +12 -0
- package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
- package/layers/core/server/api/[collection]/index.put.ts +18 -1
- package/layers/core/server/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +42 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaLibrary.vue +11 -6
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/MediaViewer.vue +56 -7
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/server/api/media/[id].patch.test.ts +66 -0
- package/layers/media/server/api/media/[id].patch.ts +25 -1
- package/layers/media/server/api/media/index.post.ts +19 -3
- package/layers/media/server/collections/media.ts +14 -0
- package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
- package/layers/media/server/utils/ai-signal-detect.ts +155 -0
- package/layers/media/server/utils/library.ts +2 -1
- package/layers/media/server/utils/record.ts +8 -0
- package/layers/media/server/utils/resolve.ts +12 -0
- package/layers/public/app/pages/[...slug].vue +57 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
- package/layers/public/modules/prerender-routes/index.ts +5 -2
- 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/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/plugins/zz.publish.ts +12 -3
- package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
- package/layers/public/server/routes/llms.txt.get.ts +1 -12
- package/layers/public/server/routes/redirects.json.get.ts +58 -0
- package/layers/public/server/routes/robots.txt.get.ts +1 -0
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/llms-full.ts +125 -0
- package/layers/public/server/utils/llms.ts +13 -0
- package/layers/public/server/utils/page-resolve.ts +112 -4
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +72 -3
- 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 +85 -14
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- 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 +23 -0
- package/layers/ui/app/i18n/en.ts +23 -0
- package/package.json +7 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
- 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,
|
|
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 '
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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="
|
|
201
|
+
<div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
|
|
152
202
|
<span class="kestrel-draft-badge__dot" aria-hidden="true" />
|
|
153
|
-
|
|
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
|
|
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>
|
|
@@ -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
|
|
76
|
-
* `max-age=0, must-revalidate` (cacheable but always revalidated)
|
|
77
|
-
* un-hashed media) is
|
|
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
|
|
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
|
-
|
|
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
|