@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,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
|
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { MAX_BULK_IDS } from '../../../core/app/utils/list-limits'
|
|
4
|
+
import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
|
|
5
|
+
import { staleRoutes } from '../utils/publish/deps'
|
|
6
|
+
import { usePublishRuntime } from '../utils/publish/publish-runtime'
|
|
7
|
+
import { allPublishedRoutes } from '../utils/publish/publisher'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publish records: write their static files (and everything whose baked output embeds them) to the
|
|
11
|
+
* configured output — the deliberate second half of the split ADR-0008 introduced. Saving persists to the
|
|
12
|
+
* DB and leaves the live site alone; THIS is what changes what visitors see.
|
|
13
|
+
*
|
|
14
|
+
* body: { collection: string, ids?: number[], id?: number }
|
|
15
|
+
* 200: { queued, generates, routes, pruned, drafts }
|
|
16
|
+
*
|
|
17
|
+
* The record's publish INTENT (`status`) is not touched here — that is a field the editor saves like any
|
|
18
|
+
* other, so a page goes live by being published while published. A draft is therefore reported back
|
|
19
|
+
* (`drafts`) rather than silently promoted: it has no public output to write.
|
|
20
|
+
*
|
|
21
|
+
* All-or-nothing on lookup (an unknown id 404s before anything is enqueued), like the bulk write actions.
|
|
22
|
+
*/
|
|
23
|
+
export default defineEventHandler(async (event) => {
|
|
24
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
25
|
+
const body = await readBody(event)
|
|
26
|
+
const name = typeof (body as { collection?: unknown })?.collection === 'string' ? (body as { collection: string }).collection : ''
|
|
27
|
+
const c = getCollection(name)
|
|
28
|
+
if (!c) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
29
|
+
|
|
30
|
+
const raw = (body as { ids?: unknown; id?: unknown })?.ids ?? [(body as { id?: unknown })?.id]
|
|
31
|
+
const ids = parseIdList(raw, MAX_BULK_IDS)
|
|
32
|
+
|
|
33
|
+
const db = useDb()
|
|
34
|
+
const table = c.table as AnySQLiteTable
|
|
35
|
+
const cols = getTableColumns(table) as Record<string, never>
|
|
36
|
+
const rows = db.select().from(table).where(inArray(cols.id, ids)).all() as Record<string, unknown>[]
|
|
37
|
+
const found = new Set(rows.map((r) => r.id as number))
|
|
38
|
+
const missing = ids.filter((id) => !found.has(id))
|
|
39
|
+
if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.def.name} not found: ${missing.join(', ')}` })
|
|
40
|
+
|
|
41
|
+
// Where the next publish would go, and whether one happens here at all: in dev (or with `output.auto`
|
|
42
|
+
// off) there is no runtime publisher, and saying so is more useful than a queued run that never runs.
|
|
43
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean } }).output
|
|
44
|
+
const runtime = usePublishRuntime()
|
|
45
|
+
const generates = !import.meta.dev && !!output?.auto && !!runtime
|
|
46
|
+
|
|
47
|
+
// The live route set answers "is this tracked route still somebody's page?" — the question a rename
|
|
48
|
+
// leaves open. `failed` means the enumeration was incomplete, and an incomplete read must never drive a
|
|
49
|
+
// delete (the standing rule from the 2026-07-25 audit), so the prune is skipped wholesale.
|
|
50
|
+
const liveNow = runtime ? allPublishedRoutes() : { routes: [], failed: ['*'] }
|
|
51
|
+
const prunable = liveNow.failed.length === 0
|
|
52
|
+
|
|
53
|
+
const primary = primaryLocale()
|
|
54
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
55
|
+
const routes: string[] = []
|
|
56
|
+
const pruned: string[] = []
|
|
57
|
+
const drafts: number[] = []
|
|
58
|
+
let queued = false
|
|
59
|
+
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
// before === after: nothing about the record is changing, this is a re-render of its current state.
|
|
62
|
+
// A draft classifies as not-published, so `planInvalidation` returns a noop for it — reported below.
|
|
63
|
+
const ev = classifyWrite(c.def, row, row, primary, prefixPrimary)
|
|
64
|
+
const inv = planInvalidation(ev)
|
|
65
|
+
if (inv.type !== 'tags') {
|
|
66
|
+
drafts.push(row.id as number)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// Abandoned URLs: routes the publisher baked FROM this record (tagged with its id) that no live page
|
|
70
|
+
// claims any more — in practice the old file a published rename left behind. Same rule a full publish
|
|
71
|
+
// applies globally, scoped to this record's tag; a referrer or listing carrying the tag is a live route
|
|
72
|
+
// and therefore never in this set.
|
|
73
|
+
const tagged = ev.id != null ? (runtime?.deps.routesForTags([`${c.def.name}:${ev.id}`]) ?? []) : []
|
|
74
|
+
const stale = prunable ? staleRoutes(tagged, liveNow.routes) : []
|
|
75
|
+
routes.push(...inv.render)
|
|
76
|
+
pruned.push(...stale)
|
|
77
|
+
if (runtime) {
|
|
78
|
+
runtime.queue.enqueue({ ...inv, prune: [...inv.prune, ...stale] })
|
|
79
|
+
queued = true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { queued, generates, routes, pruned, drafts }
|
|
84
|
+
})
|
|
@@ -42,5 +42,13 @@ export default defineEventHandler((event) => {
|
|
|
42
42
|
if (siteUnreadable || (failed.length && !resolved)) {
|
|
43
43
|
throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
|
|
44
44
|
}
|
|
45
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
collection: resolved?.collection ?? null,
|
|
47
|
+
page: resolved?.page ?? null,
|
|
48
|
+
alternates: resolved?.alternates ?? [],
|
|
49
|
+
// The breadcrumb trail rides the same fetch as the hreflang set, for the same reason: it is resolved
|
|
50
|
+
// from the DB, and the publish-dep capture only works on a path the renderer actually awaits.
|
|
51
|
+
ancestors: resolved?.ancestors ?? [],
|
|
52
|
+
site,
|
|
53
|
+
}
|
|
46
54
|
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
+
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import { compileRedirects } from '../utils/publish/redirect-rules'
|
|
4
|
+
|
|
5
|
+
// Editorial SEO redirects, kept in the DB rather than in config because they outlive a deployment and are
|
|
6
|
+
// the editors' to change. Saving compiles them into the `redirects.json` artifact the edge serves — Kestrel
|
|
7
|
+
// itself never redirects at runtime (there is no live public SSR to redirect in).
|
|
8
|
+
const built = buildCollection(defineCollection({
|
|
9
|
+
name: 'redirects',
|
|
10
|
+
mode: 'single',
|
|
11
|
+
builtin: true,
|
|
12
|
+
label: { singular: { en: 'Redirects', de: 'Weiterleitungen' }, plural: { en: 'Redirects', de: 'Weiterleitungen' } },
|
|
13
|
+
icon: 'external-link',
|
|
14
|
+
fields: {
|
|
15
|
+
rules: {
|
|
16
|
+
type: 'repeater',
|
|
17
|
+
// Row order is priority and there is no per-field help text in the editor, so the rule and the
|
|
18
|
+
// wildcard syntax have to ride along in the labels — this is the only place an editor sees them.
|
|
19
|
+
label: {
|
|
20
|
+
en: 'Rules — the first matching rule wins, so put the most specific one first',
|
|
21
|
+
de: 'Regeln — die erste passende Regel gewinnt, spezifische zuerst',
|
|
22
|
+
},
|
|
23
|
+
options: {
|
|
24
|
+
fields: {
|
|
25
|
+
from: {
|
|
26
|
+
type: 'text',
|
|
27
|
+
required: true,
|
|
28
|
+
label: {
|
|
29
|
+
en: 'From — old path, * = one segment, ** = several',
|
|
30
|
+
de: 'Von — alter Pfad, * = ein Segment, ** = mehrere',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
to: {
|
|
34
|
+
type: 'text',
|
|
35
|
+
required: true,
|
|
36
|
+
label: {
|
|
37
|
+
en: 'To — new path or full URL, $1/$2 = the wildcards',
|
|
38
|
+
de: 'Nach — neuer Pfad oder vollständige URL, $1/$2 = die Platzhalter',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
status: {
|
|
42
|
+
type: 'choice',
|
|
43
|
+
label: { en: 'Status', de: 'Status' },
|
|
44
|
+
options: {
|
|
45
|
+
choices: [
|
|
46
|
+
{ label: { en: '301 — permanent', de: '301 — dauerhaft' }, value: '301' },
|
|
47
|
+
{ label: { en: '302 — temporary', de: '302 — vorübergehend' }, value: '302' },
|
|
48
|
+
{ label: { en: '307 — temporary, keeps the method', de: '307 — vorübergehend, Methode bleibt' }, value: '307' },
|
|
49
|
+
{ label: { en: '308 — permanent, keeps the method', de: '308 — dauerhaft, Methode bleibt' }, value: '308' },
|
|
50
|
+
],
|
|
51
|
+
display: 'select',
|
|
52
|
+
},
|
|
53
|
+
default: '301',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
fieldLayout: [['from|2', 'to|2', 'status|1']],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
// A row that cannot compile must never reach the DB: it would leave the artifact stale on every
|
|
61
|
+
// subsequent save and fail the prerender of `/redirects.json`. Zod validates one field at a time and
|
|
62
|
+
// cannot see that `to: '/x/$2'` needs a second wildcard in `from`, so the check lands here — before the
|
|
63
|
+
// write, as a field-scoped 400 the editor renders on the repeater.
|
|
64
|
+
validate: (record) => {
|
|
65
|
+
try {
|
|
66
|
+
compileRedirects(record.rules)
|
|
67
|
+
return []
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return [{ path: ['rules'], message: (error as Error).message }]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
export const redirects = built.table
|
|
75
|
+
export default built
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { registerWriteEffect } from '../../../core/server/utils/write-effects'
|
|
2
|
+
import { outputDriver } from '../utils/publish/publisher'
|
|
3
|
+
import { REDIRECTS_COLLECTION, REDIRECTS_FIELD, writeRedirectsArtifact } from '../utils/publish/redirects-artifact'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Publish `redirects.json` on every save of the redirects singleton — deliberately decoupled from the
|
|
7
|
+
* publish cycle, so a redirect goes live without a full republish (and without an editor pressing
|
|
8
|
+
* Publish, which after ADR-0008 they otherwise would have to).
|
|
9
|
+
*
|
|
10
|
+
* A write EFFECT, not a write listener: the bus swallows throws, and a save that reports success while
|
|
11
|
+
* the edge still serves the old rules is exactly the failure this feature cannot have. Registered in
|
|
12
|
+
* every environment, unlike the publish plugin — that one is dev-gated because a dev publish would write
|
|
13
|
+
* Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
|
|
14
|
+
* `JSON.stringify` of DB rows, byte-identical either way).
|
|
15
|
+
*
|
|
16
|
+
* Where it lands is `output.dir` / the S3 prefix — the same target the publisher uses. With the classic
|
|
17
|
+
* `output.auto: false` + `driver: 'local'` build model that is NOT the deployed tree, so there a
|
|
18
|
+
* redirect goes live with the next `nuxt generate` instead; documented under Redirects in
|
|
19
|
+
* `docs/static-output.md`.
|
|
20
|
+
*/
|
|
21
|
+
export default defineNitroPlugin(() => {
|
|
22
|
+
registerWriteEffect(async ({ def, row }) => {
|
|
23
|
+
if (def.name !== REDIRECTS_COLLECTION) return
|
|
24
|
+
try {
|
|
25
|
+
await writeRedirectsArtifact(row[REDIRECTS_FIELD], outputDriver())
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// The row is already committed (CRUD holds no transaction), so the only honest message is
|
|
28
|
+
// "saved, but not live". Short and ASCII on purpose: h3 truncates nothing but the reason phrase is
|
|
29
|
+
// the wire's, and the cause goes in `data` where nothing can strip it.
|
|
30
|
+
throw createError({
|
|
31
|
+
statusCode: 500,
|
|
32
|
+
statusMessage: 'Redirects saved, but publishing redirects.json failed. Save again to retry.',
|
|
33
|
+
data: { cause: (error as Error)?.message ?? String(error) },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createPublishQueue } from '../utils/publish/queue'
|
|
2
|
+
import { setPublishRuntime } from '../utils/publish/publish-runtime'
|
|
2
3
|
import { DepsStore } from '../utils/publish/deps'
|
|
3
4
|
import { createSqlitePersistence } from '../utils/publish/deps-persistence'
|
|
4
5
|
import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
|
|
5
|
-
import { classifyWrite,
|
|
6
|
+
import { classifyWrite, planWrite } from '../utils/publish/invalidation'
|
|
6
7
|
import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -19,7 +20,7 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
|
19
20
|
*/
|
|
20
21
|
export default defineNitroPlugin(() => {
|
|
21
22
|
if (import.meta.dev) return
|
|
22
|
-
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
24
|
if (!output?.auto) return
|
|
24
25
|
|
|
25
26
|
// `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
|
|
@@ -48,8 +49,16 @@ export default defineNitroPlugin(() => {
|
|
|
48
49
|
onError: (error) => console.error('[kestrel] publish run failed:', error),
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
// The explicit publish action (`POST /api/publish`) enqueues through this same queue, so a publish and
|
|
53
|
+
// a write-driven prune are serialized by one single-flight run rather than racing each other.
|
|
54
|
+
setPublishRuntime({ queue, deps })
|
|
55
|
+
|
|
56
|
+
// A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
|
|
57
|
+
// unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
|
|
58
|
+
// publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
|
|
59
|
+
const publishOnSave = output.publishOnSave ?? false
|
|
51
60
|
registerWriteListener(({ def, before, after }) => {
|
|
52
|
-
queue.enqueue(
|
|
61
|
+
queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
|
|
53
62
|
})
|
|
54
63
|
|
|
55
64
|
// Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
3
|
+
import { LLMS_FULL_HEADING_OFFSET } from '../utils/llms-full'
|
|
4
|
+
import type { LlmsFullSection, LlmsFullPage } from '../utils/llms-full'
|
|
5
|
+
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
6
|
+
|
|
7
|
+
// The long form of llms.txt: every published, indexable page's full Markdown body in one document, so an
|
|
8
|
+
// answer engine can ground on the site without crawling it. Same registry-driven public set and the same
|
|
9
|
+
// status/noindex filters as `llms.txt` and the sitemap (single source: the auth policy).
|
|
10
|
+
//
|
|
11
|
+
// OPT-IN (`kestrel.seo.llmsFull`, default off), for two reasons that both matter: it aggregates the whole
|
|
12
|
+
// site into one scrapeable artifact — a disclosure decision that belongs to the consumer, not to an
|
|
13
|
+
// upgrade — and unlike `llms.txt` it must read every row's block content, which the publisher would
|
|
14
|
+
// re-render on every incremental publish.
|
|
15
|
+
export default defineEventHandler((event) => {
|
|
16
|
+
if (!llmsFullEnabled()) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
|
17
|
+
const db = useDb()
|
|
18
|
+
const base = siteBaseUrl()
|
|
19
|
+
const primary = primaryLocale()
|
|
20
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
21
|
+
|
|
22
|
+
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
|
23
|
+
// Without an absolute origin every `Source:` line would be a relative path resolving against whatever
|
|
24
|
+
// host fetched the file — the same rule `llms.txt` applies to its link list.
|
|
25
|
+
if (!base) {
|
|
26
|
+
console.warn('[kestrel] llms-full.txt: siteUrl is unset — omitting page bodies (their URLs would be relative)')
|
|
27
|
+
return buildLlmsFullTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections: [] })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Loaded { c: BuiltCollection; rows: Record<string, unknown>[] }
|
|
31
|
+
const pub = publicReadableResources()
|
|
32
|
+
const loaded: Loaded[] = []
|
|
33
|
+
for (const c of allCollections()) {
|
|
34
|
+
if (!c.def.pageLike) continue
|
|
35
|
+
if (!isPubliclyReadable(c.def.name, pub)) continue
|
|
36
|
+
// Project the listing columns plus ONLY the prose-bearing fields — this route does have to read block
|
|
37
|
+
// content, but a media/relation/json column still has no business being loaded to render text.
|
|
38
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
39
|
+
const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
|
|
40
|
+
if (c.def.translatable) proj.locale = cols.locale
|
|
41
|
+
if (c.def.status) proj.status = cols.status
|
|
42
|
+
if (c.def.seo) proj.seo = cols.seo
|
|
43
|
+
if (c.def.blocks?.enabled) proj.content = cols.content
|
|
44
|
+
for (const [key, field] of Object.entries(c.def.fields)) {
|
|
45
|
+
if (field.type === 'text' || field.type === 'richtext' || field.type === 'repeater') proj[key] = cols[key]
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
loaded.push({ c, rows: db.select(proj as never).from(c.table).all() as Record<string, unknown>[] })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Skipping keeps a bare prerender DB publishable, but a drifted table drops the whole section — a
|
|
51
|
+
// silent gap the publisher would write straight over the live artifact.
|
|
52
|
+
console.error(`[kestrel] llms-full.txt: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const advertisable = (c: BuiltCollection, row: Record<string, unknown>): string | null => {
|
|
57
|
+
if (c.def.status && row.status !== 'published') return null
|
|
58
|
+
const path = row.path as string | null
|
|
59
|
+
if (!path) return null
|
|
60
|
+
if ((row.seo as { noindex?: boolean } | null)?.noindex) return null
|
|
61
|
+
return base + localePath(path, (row.locale as string | undefined) ?? primary, primary, prefixPrimary)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Internal richtext links are stored as `kestrel:<collection>:<id>` markers. Resolve them from the rows
|
|
65
|
+
// already loaded — the same status/noindex gate, so a marker pointing at a draft or a noindexed page
|
|
66
|
+
// declines and the link degrades to its own text instead of leaking an unpublished URL.
|
|
67
|
+
const linkTargets = new Map<string, string>()
|
|
68
|
+
for (const { c, rows } of loaded) {
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
const url = advertisable(c, row)
|
|
71
|
+
if (url) linkTargets.set(`${c.def.name}:${row.id as number}`, url)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const resolveLink = (collection: string, id: number): string | null => linkTargets.get(`${collection}:${id}`) ?? null
|
|
75
|
+
|
|
76
|
+
const sections: LlmsFullSection[] = []
|
|
77
|
+
for (const { c, rows } of loaded) {
|
|
78
|
+
const pages: LlmsFullPage[] = []
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const url = advertisable(c, row)
|
|
81
|
+
if (!url) continue
|
|
82
|
+
const seo = (row.seo ?? {}) as { title?: string; description?: string }
|
|
83
|
+
pages.push({
|
|
84
|
+
title: seo.title || (row.title as string | undefined) || (row.path as string),
|
|
85
|
+
url,
|
|
86
|
+
description: seo.description || undefined,
|
|
87
|
+
// `title` already IS this page's heading — emitting it again as body text would repeat every
|
|
88
|
+
// page title twice in the document.
|
|
89
|
+
body: recordMarkdown(c.def, row, { headingOffset: LLMS_FULL_HEADING_OFFSET, skipFields: ['title'], resolveLink }),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
if (pages.length) {
|
|
93
|
+
pages.sort((a, b) => a.url.localeCompare(b.url))
|
|
94
|
+
sections.push({ heading: collectionHeading(c.def, primary), pages })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return buildLlmsFullTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections })
|
|
99
|
+
})
|
|
@@ -11,17 +11,6 @@ export default defineEventHandler((event) => {
|
|
|
11
11
|
const primary = primaryLocale()
|
|
12
12
|
const prefixPrimary = prefixPrimaryLocale()
|
|
13
13
|
|
|
14
|
-
const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
15
|
-
const headingFor = (def: { name: string; label?: { plural?: unknown } }): string => {
|
|
16
|
-
const pl = def.label?.plural
|
|
17
|
-
if (typeof pl === 'string') return pl
|
|
18
|
-
if (pl && typeof pl === 'object') {
|
|
19
|
-
const m = pl as Record<string, string>
|
|
20
|
-
return m[primary] ?? Object.values(m)[0] ?? cap(def.name)
|
|
21
|
-
}
|
|
22
|
-
return cap(def.name)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
14
|
const pub = publicReadableResources()
|
|
26
15
|
const sections: LlmsSection[] = []
|
|
27
16
|
// Without an absolute origin, every resource URL would be a relative path — omit the page sections (keep
|
|
@@ -66,7 +55,7 @@ export default defineEventHandler((event) => {
|
|
|
66
55
|
}
|
|
67
56
|
if (entries.length) {
|
|
68
57
|
entries.sort((a, b) => a.url.localeCompare(b.url))
|
|
69
|
-
sections.push({ heading:
|
|
58
|
+
sections.push({ heading: collectionHeading(c.def, primary), entries })
|
|
70
59
|
}
|
|
71
60
|
}
|
|
72
61
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { compilePublishableRedirects, serializeRedirects } from '../utils/publish/redirect-rules'
|
|
3
|
+
import { REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '../utils/publish/redirects-artifact'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The redirect artifact the edge polls. A save publishes it directly (see `plugins/03.redirects.ts`);
|
|
7
|
+
* this route is what makes it survive everything else — it is prerendered into `.output/public`, so the
|
|
8
|
+
* build-time deploy's reconcile keeps it instead of pruning a key it cannot account for, and a full
|
|
9
|
+
* publish re-renders it from the live DB. Public and cheap: one row, no user input.
|
|
10
|
+
*
|
|
11
|
+
* Zero redirects is a supported state, and so is a collection a consumer toggled off — both serve `[]`,
|
|
12
|
+
* never a 404, because an edge that cannot fetch the file has to keep its last good state and would
|
|
13
|
+
* otherwise burn its cold-start budget retrying.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A table that does not exist yet — the release adds one, so a consumer can generate before their
|
|
17
|
+
* `db:migrate`. `[]` is the truth there, not a degrade. */
|
|
18
|
+
function tableIsAbsent(error: unknown): boolean {
|
|
19
|
+
return /no such table/i.test((error as Error)?.message ?? '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render the artifact, or fail loudly. The distinction is deliberate and narrow: only a missing table
|
|
24
|
+
* yields `[]`, because only then is "no redirects" a FACT. Any other read failure (a drifted column, an
|
|
25
|
+
* I/O error) throws — at build time an errored route costs the deploy its reconcile, which is the
|
|
26
|
+
* conservative direction, and at runtime `publishMeta` writes nothing on a non-200, where an empty body
|
|
27
|
+
* would instead overwrite a good live artifact with "no redirects". A failed read must never be an
|
|
28
|
+
* authoritative empty result.
|
|
29
|
+
*/
|
|
30
|
+
export function renderRedirects(readRows: () => unknown): string {
|
|
31
|
+
let rows: unknown
|
|
32
|
+
try {
|
|
33
|
+
rows = readRows()
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!tableIsAbsent(error)) throw error
|
|
36
|
+
console.warn('[kestrel] redirects.json: the redirects table does not exist yet — serving an empty list. Run `db:migrate`.')
|
|
37
|
+
return serializeRedirects([])
|
|
38
|
+
}
|
|
39
|
+
const { rules, skipped } = compilePublishableRedirects(rows)
|
|
40
|
+
for (const message of skipped) {
|
|
41
|
+
console.error(`[kestrel] redirects.json: skipped an unpublishable rule — ${message}`)
|
|
42
|
+
}
|
|
43
|
+
return serializeRedirects(rules)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default defineEventHandler((event) => {
|
|
47
|
+
setHeader(event, 'content-type', 'application/json; charset=utf-8')
|
|
48
|
+
const collection = getCollection(REDIRECTS_COLLECTION)
|
|
49
|
+
if (!collection) return serializeRedirects([])
|
|
50
|
+
|
|
51
|
+
const cols = collection.table as unknown as Record<string, never>
|
|
52
|
+
return renderRedirects(() => {
|
|
53
|
+
const row = useDb().select().from(collection.table).where(eq(cols.singletonKey, REDIRECTS_COLLECTION)).get() as
|
|
54
|
+
| Record<string, unknown>
|
|
55
|
+
| undefined
|
|
56
|
+
return row?.[REDIRECTS_FIELD]
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -5,7 +5,8 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
|
5
5
|
/**
|
|
6
6
|
* The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
|
|
7
7
|
* via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
|
|
8
|
-
* (output ≡ DB).
|
|
8
|
+
* (output ≡ DB). Like every full publish it HOLDS BACK routes with unpublished changes (ADR-0008) — a
|
|
9
|
+
* resync must not push work in progress live; those pages go out when they are published.
|
|
9
10
|
*
|
|
10
11
|
* Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
|
|
11
12
|
* - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
|