@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,28 @@
1
+ import { usePreviewStore, previewOwner } from '../utils/preview-token'
2
+
3
+ /**
4
+ * Read back a preview ticket (ADR-0008) — the public page fetches it during SSR when the URL carries
5
+ * `?kestrel-preview-token=…` and renders the editor's unsaved state instead of the stored record. Admin-
6
+ * only and session-bound; an expired, foreign or unknown token is `null`, which the page treats as
7
+ * "nothing to preview" and falls back to the saved content rather than failing the render.
8
+ */
9
+ export default defineEventHandler((event) => {
10
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
11
+ const token = getQuery(event).token
12
+ if (typeof token !== 'string' || !token) return null
13
+ const payload = usePreviewStore().read(token, previewOwner(event))
14
+ if (!payload) return null
15
+
16
+ // The editor sends what a SAVE would send — raw ids for media and relations — so the ticket goes through
17
+ // the same read population a stored record does. Without it the preview would render a page with the
18
+ // images and internal links stripped, which is worse than useless for judging a layout.
19
+ const c = getCollection(payload.collection)
20
+ if (!c) return { payload }
21
+ const locale = payload.locale || primaryLocale()
22
+ const values = withResolveScope(
23
+ () => populateRow({ ...payload.values }, { depth: 1, locale, def: c.def }),
24
+ resolveBudgetFor(1),
25
+ `preview ${payload.collection}`,
26
+ )
27
+ return { payload: { ...payload, values } }
28
+ })
@@ -0,0 +1,93 @@
1
+ import { usePreviewStore, previewOwner, type PreviewPayload } from '../utils/preview-token'
2
+ import type { BuiltCollection } from '../../../core/server/utils/collection-types'
3
+ import { fieldIs, type FieldDef } from '../../../core/server/utils/defineCollection'
4
+ import { resolveColumnName } from '../../../fields/server/field-registry/naming'
5
+ import { sanitizeRichtext } from '../../../fields/server/field-registry/sanitize'
6
+ import { getBlock } from '../../../fields/server/utils/defineBlock'
7
+
8
+ /** Editor payloads are form state, not uploads — a block tree with populated media is far below this. */
9
+ const MAX_PAYLOAD_BYTES = 2_000_000
10
+
11
+ /**
12
+ * Sanitize every richtext leaf reachable from `scope` (top-level fields + nested repeater entries), in
13
+ * place. Mirrors the WALK shape of crud.ts's `applyFieldTransforms`/`transformNested`, but not the
14
+ * mechanism: richtext's sanitizer is wired into the Zod validator (`z.string().transform(sanitizeRichtext)`
15
+ * in field-registry/index.ts), not the field-type's `transform` hook that walk calls — and running the
16
+ * collection's Zod schema here isn't an option anyway, see `sanitizePreviewValues` below. So this calls
17
+ * `sanitizeRichtext` directly wherever a `richtext` field is present, and only there; every other field
18
+ * passes through untouched.
19
+ */
20
+ function sanitizeRichtextFields(fields: Record<string, FieldDef>, scope: Record<string, unknown>): void {
21
+ for (const [key, fieldDef] of Object.entries(fields)) {
22
+ const { jsKey } = resolveColumnName(key, fieldDef)
23
+ if (!Object.hasOwn(scope, jsKey)) continue
24
+ if (fieldIs(fieldDef, 'richtext')) {
25
+ if (typeof scope[jsKey] === 'string') scope[jsKey] = sanitizeRichtext(scope[jsKey] as string)
26
+ } else if (fieldIs(fieldDef, 'repeater')) {
27
+ const entries = scope[jsKey]
28
+ if (Array.isArray(entries)) {
29
+ for (const entry of entries) if (entry && typeof entry === 'object') sanitizeRichtextFields(fieldDef.options.fields, entry as Record<string, unknown>)
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Same walk as `transformBlocks` in crud.ts: each block's props (by its registered field defs) + its
36
+ * slots' nested blocks, recursively. An unregistered block type is left as-is (matches crud.ts). */
37
+ function sanitizeRichtextInBlocks(blocks: unknown): void {
38
+ if (!Array.isArray(blocks)) return
39
+ for (const block of blocks) {
40
+ if (!block || typeof block !== 'object') continue
41
+ const b = block as { type?: string; props?: unknown; slots?: unknown }
42
+ const def = typeof b.type === 'string' ? getBlock(b.type) : undefined
43
+ if (def && b.props && typeof b.props === 'object') sanitizeRichtextFields(def.fields, b.props as Record<string, unknown>)
44
+ if (b.slots && typeof b.slots === 'object') for (const sub of Object.values(b.slots as Record<string, unknown>)) sanitizeRichtextInBlocks(sub)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Apply the richtext sanitizer to a preview ticket's `values` — the fix for the ticket bypassing it
50
+ * entirely (it normally runs as a Zod `.transform()` on WRITE, `create`/`update`/`putSingleton` in
51
+ * crud.ts; a ticket never reaches those). Deliberately NOT a schema parse: `values` is the editor's
52
+ * in-progress, possibly-invalid draft (a missing required field, an out-of-range number, …), and a preview
53
+ * exists precisely so a draft can be inspected BEFORE it would pass a save — a strict or even a `.partial()`
54
+ * parse would legitimately reject some of what it must accept. Sanitizing is not a validity constraint
55
+ * though (it only narrows stored bytes, the same way a save would), so a direct field-tree walk gets the
56
+ * safety without the rejection risk. Walks top-level fields, repeater entries, and (when the collection
57
+ * uses the block editor) block props + nested slots — everywhere a richtext leaf can live.
58
+ */
59
+ function sanitizePreviewValues(collection: BuiltCollection, values: Record<string, unknown>): void {
60
+ sanitizeRichtextFields(collection.def.fields, values)
61
+ if (collection.def.blocks?.enabled && Object.hasOwn(values, 'content')) sanitizeRichtextInBlocks(values.content)
62
+ }
63
+
64
+ /**
65
+ * Mint a preview ticket for the editor's UNSAVED state (ADR-0008). The editor posts what it currently
66
+ * holds and opens the page at `?kestrel-preview-token=<token>` in a new tab; nothing is written to the DB,
67
+ * so previewing never doubles as an unasked-for save. Admin-only (default-deny API guard + the backstop),
68
+ * and the ticket is bound to this session.
69
+ *
70
+ * body: { collection, id: number | null, locale?, values }
71
+ * 200: { token, expiresAt }
72
+ */
73
+ export default defineEventHandler(async (event) => {
74
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
75
+ const body = (await readBody(event)) as Partial<PreviewPayload> | null
76
+
77
+ const name = typeof body?.collection === 'string' ? body.collection : ''
78
+ const collection = getCollection(name) as BuiltCollection | undefined
79
+ if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
80
+
81
+ const values = body?.values
82
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
83
+ throw createError({ statusCode: 400, statusMessage: 'preview requires a `values` object' })
84
+ }
85
+ if (JSON.stringify(values).length > MAX_PAYLOAD_BYTES) {
86
+ throw createError({ statusCode: 413, statusMessage: 'Preview payload too large' })
87
+ }
88
+ sanitizePreviewValues(collection, values as Record<string, unknown>)
89
+
90
+ const id = typeof body?.id === 'number' && Number.isInteger(body.id) ? body.id : null
91
+ const locale = typeof body?.locale === 'string' ? body.locale : undefined
92
+ return usePreviewStore().mint(previewOwner(event), { collection: name, id, locale, values })
93
+ })
@@ -2,6 +2,7 @@ import { eq, getTableColumns } from 'drizzle-orm'
2
2
  import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
3
  import { publishStatus } from '../database/publish-status'
4
4
  import { routeForRecord } from '../utils/publish/route-for-record'
5
+ import { hasPendingChanges } from '../utils/publish/pending'
5
6
 
6
7
  /**
7
8
  * Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
@@ -17,28 +18,41 @@ export default defineEventHandler((event) => {
17
18
  // (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
18
19
  // (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
19
20
  // "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
20
- const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
21
- const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto }
21
+ const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
22
+ // `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
23
+ // off there is nothing left for it to do.
24
+ const publishOnSave = !!output?.publishOnSave
25
+ const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
22
26
 
23
27
  const q = getQuery(event)
24
28
  const name = typeof q.collection === 'string' ? q.collection : ''
25
29
  const id = Number(typeof q.id === 'string' ? q.id : NaN)
26
30
  const c = getCollection(name)
27
- if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
31
+ if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
28
32
 
29
33
  const db = useDb()
30
34
  const table = c.table as AnySQLiteTable
31
35
  const cols = getTableColumns(table) as Record<string, never>
32
- const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
36
+ const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
33
37
  const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
34
- if (!route) return { route: null, status: null, ...env }
38
+ if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
35
39
 
40
+ // Saved after it was last published: with publishing deferred to an explicit action, that is the normal
41
+ // working state of a page being edited — the live file is the previous version until someone publishes.
42
+ const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
36
43
  try {
37
44
  const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
38
- if (!st) return { route, status: null, ...env }
39
- return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, ...env }
45
+ // A routable page with no row was never published. Before the split that was indistinguishable from
46
+ // "a publish is running" a save always enqueued one but now nothing is in flight and nothing will be
47
+ // until someone presses Publish, so the lamp must not claim progress that is not happening.
48
+ if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
49
+ // With the split off, a save republishes on its own, so a newer save means a republish is in flight —
50
+ // reporting that as "unpublished changes" would ask the user to act on something already happening.
51
+ const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
52
+ return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
40
53
  } catch {
41
- // publish_status not migrated yet → treat as "no status" rather than a 500.
42
- return { route, status: null, ...env }
54
+ // publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
55
+ // either: the table is unreadable, so the page's real state is unknown, not known to be absent.
56
+ return { route, status: null, pending: false, neverPublished: false, ...env }
43
57
  }
44
58
  })
@@ -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 { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [], site }
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, planInvalidation } from '../utils/publish/invalidation'
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(planInvalidation(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale())))
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: headingFor(c.def), entries })
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
+ })
@@ -4,5 +4,6 @@ export default defineEventHandler((event) => {
4
4
  return buildRobots({
5
5
  sitemapUrl: base ? `${base}/sitemap.xml` : undefined,
6
6
  llmsUrl: base ? `${base}/llms.txt` : undefined,
7
+ llmsFullUrl: base && llmsFullEnabled() ? `${base}/llms-full.txt` : undefined,
7
8
  })
8
9
  })
@@ -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)