@michaelthielemann/kestrel 2.0.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 (55) hide show
  1. package/README.md +15 -8
  2. package/layers/admin/app/components/CollectionList.vue +5 -1
  3. package/layers/admin/app/components/PageFields.vue +5 -0
  4. package/layers/admin/app/components/SeoFields.vue +42 -0
  5. package/layers/admin/app/composables/useEditForm.ts +9 -3
  6. package/layers/admin/app/utils/edit-form.ts +9 -2
  7. package/layers/core/modules/kestrel/index.ts +11 -0
  8. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  9. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  10. package/layers/core/server/utils/collection-types.ts +4 -3
  11. package/layers/core/server/utils/defineCollection.ts +8 -1
  12. package/layers/core/server/utils/kestrel-config.ts +35 -1
  13. package/layers/core/server/utils/seo.ts +18 -0
  14. package/layers/core/server/utils/write-effects.ts +40 -0
  15. package/layers/fields/server/utils/buildCollection.ts +9 -7
  16. package/layers/media/app/components/KestrelImg.vue +29 -0
  17. package/layers/media/app/components/MediaLibrary.vue +7 -5
  18. package/layers/media/app/components/MediaViewer.vue +56 -7
  19. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  20. package/layers/media/app/utils/library.ts +2 -0
  21. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  22. package/layers/media/server/api/media/[id].patch.ts +25 -1
  23. package/layers/media/server/api/media/index.post.ts +19 -3
  24. package/layers/media/server/collections/media.ts +14 -0
  25. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  26. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  27. package/layers/media/server/utils/library.ts +2 -1
  28. package/layers/media/server/utils/record.ts +8 -0
  29. package/layers/media/server/utils/resolve.ts +12 -0
  30. package/layers/public/app/pages/[...slug].vue +30 -1
  31. package/layers/public/app/utils/json-ld.ts +139 -0
  32. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  33. package/layers/public/modules/prerender-routes/index.ts +5 -2
  34. package/layers/public/server/api/route.get.ts +9 -1
  35. package/layers/public/server/collections/redirects.ts +75 -0
  36. package/layers/public/server/plugins/03.redirects.ts +37 -0
  37. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  38. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  39. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  40. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  41. package/layers/public/server/utils/llms-full.ts +125 -0
  42. package/layers/public/server/utils/llms.ts +13 -0
  43. package/layers/public/server/utils/page-resolve.ts +112 -4
  44. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  45. package/layers/public/server/utils/publish/publisher.ts +11 -5
  46. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  47. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  48. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  49. package/layers/public/server/utils/site-url.ts +8 -0
  50. package/layers/public/server/utils/sitemap.ts +5 -3
  51. package/layers/ui/app/components/field/Choice.vue +6 -1
  52. package/layers/ui/app/i18n/de.ts +13 -0
  53. package/layers/ui/app/i18n/en.ts +13 -0
  54. package/package.json +2 -1
  55. package/templates/starter/nuxt.config.ts +3 -0
@@ -0,0 +1,125 @@
1
+ import type { CollectionDef, FieldDef } from '../../../core/server/utils/defineCollection'
2
+ import { fieldIs } from '../../../core/server/utils/defineCollection'
3
+ import { getBlock } from '../../../fields/server/utils/defineBlock'
4
+ import { resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
5
+ import { escapeMarkdownBlock, richtextToMarkdown } from './richtext-markdown'
6
+
7
+ /** One published page rendered in full: what `llms.txt` links to, plus the body it links to. */
8
+ export interface LlmsFullPage {
9
+ title: string
10
+ url: string
11
+ description?: string
12
+ /** Markdown; already heading-shifted to nest under the page's own `###`. */
13
+ body: string
14
+ }
15
+
16
+ /** A titled group of pages — one `## heading` block, mirroring `llms.txt`'s sections. */
17
+ export interface LlmsFullSection {
18
+ heading: string
19
+ pages: LlmsFullPage[]
20
+ }
21
+
22
+ /** Pages sit at `###`, so a body's own `<h1>` has to start at `####` to keep the outline valid. */
23
+ export const LLMS_FULL_HEADING_OFFSET = 3
24
+
25
+ // Same defence as `llms.txt`: a title, heading or description is editor-authored text, and a newline in
26
+ // it would forge a second document line — here, a heading that invents a page.
27
+ const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
28
+
29
+ /**
30
+ * Render an `llms-full.txt` (the long form of https://llmstxt.org): the site header, then every
31
+ * published, indexable page's full Markdown body under its own `###` heading, grouped by collection.
32
+ * Where `llms.txt` is a map, this is the territory — one document an answer engine can retrieve without
33
+ * crawling. Pure + deterministic; the route feeds it published, indexable pages only.
34
+ */
35
+ export function buildLlmsFullTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsFullSection[] }): string {
36
+ const blocks: string[] = [`# ${oneLine(opts.siteName)}`]
37
+ // A description is a whole block of its own, so flattening its newlines is not enough — a leading `##`
38
+ // would still open a section beside the ones this generator writes.
39
+ if (opts.siteDescription) blocks.push(`> ${escapeMarkdownBlock(oneLine(opts.siteDescription))}`)
40
+ for (const section of opts.sections) {
41
+ if (!section.pages.length) continue
42
+ blocks.push(`## ${oneLine(section.heading)}`)
43
+ for (const page of section.pages) {
44
+ blocks.push(`### ${oneLine(page.title)}`)
45
+ blocks.push(`Source: ${page.url}`)
46
+ if (page.description) blocks.push(escapeMarkdownBlock(oneLine(page.description)))
47
+ if (page.body) blocks.push(page.body)
48
+ }
49
+ }
50
+ return `${blocks.join('\n\n')}\n`
51
+ }
52
+
53
+ export interface RecordMarkdownOptions {
54
+ /** Push every body heading down by this many levels — see `LLMS_FULL_HEADING_OFFSET`. */
55
+ headingOffset?: number
56
+ /** Top-level field keys the caller already rendered (the title it used as the heading). */
57
+ skipFields?: string[]
58
+ /** `kestrel:<collection>:<id>` → absolute URL, or null for a target that must not be linked (a draft,
59
+ * a noindexed page, a non-routable record). Unresolved links keep their text and lose the anchor. */
60
+ resolveLink?: (collection: string, id: number) => string | null
61
+ }
62
+
63
+ /** The prose in one flat value bag (record columns | block props | a repeater entry), in field order.
64
+ * Only `text` and `richtext` carry prose; `repeater` recurses. Everything else is data, not content —
65
+ * a consumer's own field type is skipped rather than guessed at. */
66
+ function bagMarkdown(fields: Record<string, FieldDef>, bag: Record<string, unknown>, opts: RecordMarkdownOptions, skip?: Set<string>): string[] {
67
+ const out: string[] = []
68
+ for (const [key, field] of Object.entries(fields)) {
69
+ if (skip?.has(key)) continue
70
+ const value = bag[key]
71
+ if (field.type === 'text') {
72
+ // `fieldIs` rather than a switch: the open consumer-type arm makes `type` a non-discriminant, so a
73
+ // switch would not narrow (the same reason `extract-refs` walks this way).
74
+ // A text field is raw editor input with no markup to constrain it, so it is the one value that
75
+ // reaches the document verbatim — it needs the same block-marker escaping converted prose gets.
76
+ const text = typeof value === 'string' ? value.trim() : ''
77
+ if (text) out.push(escapeMarkdownBlock(text))
78
+ } else if (field.type === 'richtext') {
79
+ // Always run the marker rewrite, resolver or not: an unrewritten `kestrel:<coll>:<id>` href would
80
+ // ship a private storage token as a link target. With no resolver every marker declines to `#`,
81
+ // which the converter renders as plain text.
82
+ const html = typeof value === 'string' ? resolveRichtextLinks(value, opts.resolveLink ?? (() => null)) : ''
83
+ const md = richtextToMarkdown(html, { headingOffset: opts.headingOffset })
84
+ if (md) out.push(md)
85
+ } else if (fieldIs(field, 'repeater')) {
86
+ if (Array.isArray(value)) {
87
+ for (const entry of value) {
88
+ if (entry && typeof entry === 'object') out.push(...bagMarkdown(field.options.fields, entry as Record<string, unknown>, opts))
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** The prose in a block `content` array, recursing slots — the same walk `extract-refs` does for refs. */
97
+ function blockMarkdown(content: unknown, opts: RecordMarkdownOptions): string[] {
98
+ const out: string[] = []
99
+ const walk = (nodes: unknown): void => {
100
+ if (!Array.isArray(nodes)) return
101
+ for (const node of nodes) {
102
+ if (!node || typeof node !== 'object') continue
103
+ const n = node as { type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
104
+ // An unregistered block type has no field defs, so there is no way to tell its prose from its
105
+ // configuration — emitting every string prop would put layout tokens into the document.
106
+ const def = n.type ? getBlock(n.type) : undefined
107
+ if (def && n.props && typeof n.props === 'object') out.push(...bagMarkdown(def.fields, n.props, opts))
108
+ if (n.slots && typeof n.slots === 'object') for (const slotNodes of Object.values(n.slots)) walk(slotNodes)
109
+ }
110
+ }
111
+ walk(content)
112
+ return out
113
+ }
114
+
115
+ /**
116
+ * A record's readable content as Markdown: its own text/richtext fields (in declaration order), then its
117
+ * block tree if the collection enables blocks. Pure apart from the block registry — the same dependency
118
+ * `extractRecordRefs` has, and for the same reason: a block's prose is only identifiable through its
119
+ * registered field defs.
120
+ */
121
+ export function recordMarkdown(def: CollectionDef, row: Record<string, unknown>, opts: RecordMarkdownOptions = {}): string {
122
+ const blocks = bagMarkdown(def.fields, row, opts, opts.skipFields?.length ? new Set(opts.skipFields) : undefined)
123
+ if (def.blocks?.enabled) blocks.push(...blockMarkdown(row.content, opts))
124
+ return blocks.join('\n\n')
125
+ }
@@ -24,6 +24,19 @@ const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
24
24
  // Additionally escape the brackets that would otherwise break the `[title](url)` markdown link.
25
25
  const linkText = (s: string): string => oneLine(s).replace(/[[\]]/g, '\\$&')
26
26
 
27
+ /** The `## heading` for a collection's section: its plural label in the primary locale, else any locale's,
28
+ * else its capitalised name. Shared by `llms.txt` and `llms-full.txt` so the two never disagree. */
29
+ export function collectionHeading(def: { name: string; label?: { plural?: unknown } }, primaryLocale: string): string {
30
+ const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
31
+ const plural = def.label?.plural
32
+ if (typeof plural === 'string') return plural
33
+ if (plural && typeof plural === 'object') {
34
+ const byLocale = plural as Record<string, string>
35
+ return byLocale[primaryLocale] ?? Object.values(byLocale)[0] ?? cap(def.name)
36
+ }
37
+ return cap(def.name)
38
+ }
39
+
27
40
  export function buildLlmsTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsSection[] }): string {
28
41
  const lines: string[] = [`# ${oneLine(opts.siteName)}`]
29
42
  if (opts.siteDescription) lines.push('', `> ${oneLine(opts.siteDescription)}`)
@@ -1,12 +1,26 @@
1
- import { asc, eq, getTableColumns } from 'drizzle-orm'
1
+ import { and, asc, eq, getTableColumns } from 'drizzle-orm'
2
2
  import { list } from '../../../core/server/utils/crud'
3
3
  import { captureRead } from '../../../core/server/utils/read-capture'
4
- import { translationGroupTag } from './publish/invalidation'
4
+ import { pagePathTag, translationGroupTag } from './publish/invalidation'
5
5
  import type { BuiltCollection } from '../../../core/server/utils/collection-types'
6
6
  import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
7
7
 
8
8
  export interface PageAlternate { locale: string; path: string }
9
- export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
9
+ /** One published page above this one in the path hierarchy — a real, linkable breadcrumb step. */
10
+ export interface PageAncestor {
11
+ path: string
12
+ title?: string
13
+ /** The ancestor's OWN locale, so the crumb builds the URL that was actually published. Absent for a
14
+ * non-translatable collection, whose rows have a single unprefixed (primary-locale) URL — prefixing
15
+ * one of those with the reader's locale would link a page `nuxt generate` never wrote. */
16
+ locale?: string
17
+ }
18
+ export interface ResolvedPage {
19
+ collection: string
20
+ page: Record<string, unknown>
21
+ alternates: PageAlternate[]
22
+ ancestors: PageAncestor[]
23
+ }
10
24
 
11
25
  /** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
12
26
  * was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
@@ -63,6 +77,92 @@ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page
63
77
  return alternates.length >= 2 ? alternates : []
64
78
  }
65
79
 
80
+ /** Every strict path prefix of a page's path, outermost first: `/blog/hello` → `['/', '/blog']`. The
81
+ * site root is an ancestor of everything except itself. */
82
+ function ancestorPaths(path: string): string[] {
83
+ if (path === '/' || !path.startsWith('/')) return []
84
+ const segments = path.split('/').filter(Boolean)
85
+ const paths = ['/']
86
+ for (let i = 1; i < segments.length; i += 1) paths.push(`/${segments.slice(0, i).join('/')}`)
87
+ return paths
88
+ }
89
+
90
+ /**
91
+ * The page's breadcrumb trail: the published, INDEXABLE page at each ancestor path, in the page's own
92
+ * locale. A path segment with no page behind it is SKIPPED rather than synthesised — schema.org
93
+ * breadcrumb items are links, and a trail that points at a 404 is a worse signal than a shorter trail.
94
+ * The filters mirror the sitemap's, so a breadcrumb never advertises what the sitemap withholds.
95
+ *
96
+ * TWO dependency edges are captured per ancestor, because neither covers the other:
97
+ * - the PATH (`pagePathTag`), for every path looked in — including those with no page at all and those
98
+ * whose lookup threw. Kestrel has no parent/child relation between pages, so an ancestor is a
99
+ * path-prefix match, and a page CREATED at `/blog` after `/blog/hello` was published has no id anything
100
+ * could have captured beforehand. A path is knowable before its page exists.
101
+ * - the RECORD (`<coll>:<id>`) of whatever row sits there, captured before the filters below so a
102
+ * currently-invisible one counts too — the edge that carries a rename, a `noindex` or an unpublish,
103
+ * none of which `crumbTags` can see (see the comment at that capture).
104
+ * `publishedAlternates` pairs a group tag with a record tag for exactly the same reason.
105
+ */
106
+ function publishedAncestors(
107
+ db: BetterSQLite3Database,
108
+ collections: BuiltCollection[],
109
+ path: string,
110
+ locale: string | undefined,
111
+ publishedOnly: boolean,
112
+ ): PageAncestor[] {
113
+ const out: PageAncestor[] = []
114
+ for (const ancestorPath of ancestorPaths(path)) {
115
+ // Before the scan, so the edge exists whatever the scan finds — no page, a draft, a noindexed one, or
116
+ // a collection whose table could not be read. A write at this path emits the same tag (`crumbTags` in
117
+ // classifyWrite), which is what repairs this page's trail.
118
+ captureRead(pagePathTag(ancestorPath))
119
+ for (const c of collections) {
120
+ if (!c.def.pageLike) continue
121
+ // Project only what a crumb needs — never the row: this runs once per path segment per render, and
122
+ // pulling every page's block JSON to read a title would multiply the cost of a full publish.
123
+ const cols = getTableColumns(c.table) as Record<string, never>
124
+ const hasStatus = Object.hasOwn(cols, 'status')
125
+ const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
126
+ if (hasStatus) proj.status = cols.status
127
+ if (c.def.seo) proj.seo = cols.seo
128
+ if (c.def.translatable) proj.locale = cols.locale
129
+ if (Object.hasOwn(cols, 'title')) proj.title = cols.title
130
+ const scoped = c.def.translatable && locale ? and(eq(cols.path, ancestorPath), eq(cols.locale, locale)) : eq(cols.path, ancestorPath)
131
+ let row: { id: number; status?: string; seo?: { title?: string; noindex?: boolean } | null; title?: unknown; locale?: string } | undefined
132
+ try {
133
+ // Order by locale so an unscoped lookup (no locale requested) still answers deterministically
134
+ // rather than with whatever row the table happens to yield first.
135
+ const q = db.select(proj as never).from(c.table).where(scoped)
136
+ row = (c.def.translatable ? q.orderBy(asc(cols.locale)) : q).limit(1).get() as typeof row
137
+ } catch (error) {
138
+ // Same isolation rule as the main scan: a drifted collection loses its crumbs, loudly, instead of
139
+ // failing the whole render.
140
+ console.error(`[kestrel] resolvePage: skipped ancestor lookup in ${c.def.name}:`, (error as Error)?.message ?? error)
141
+ continue
142
+ }
143
+ if (!row) continue
144
+ // BOTH edges are needed, and neither subsumes the other — the same pairing `publishedAlternates`
145
+ // uses. The record tag covers every change to the row sitting here, INCLUDING the ones that make it
146
+ // stop being a crumb: the explicit publish action classifies its write as `before === after` (the
147
+ // record's current state), so a rename, a `noindex` or an unpublish is invisible in `crumbTags`,
148
+ // which only ever names where the record is NOW. `<coll>:<id>` is in that write's tag list whatever
149
+ // the row looks like, so it is what repairs the trail. Captured BEFORE the filters below, so a
150
+ // draft/noindexed/shadowing row — one that is currently NOT the crumb — still carries the edge that
151
+ // fires when it goes away.
152
+ captureRead(c.def.name, row.id)
153
+ if (publishedOnly && hasStatus && row.status !== 'published') break
154
+ if (row.seo?.noindex) break
155
+ const ancestor: PageAncestor = { path: ancestorPath }
156
+ const title = row.seo?.title || (typeof row.title === 'string' ? row.title : undefined)
157
+ if (title) ancestor.title = title
158
+ if (c.def.translatable && row.locale) ancestor.locale = row.locale
159
+ out.push(ancestor)
160
+ break
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
66
166
  /**
67
167
  * The first page-like record (across all collections, in registration order) whose `path` matches,
68
168
  * populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
@@ -93,7 +193,15 @@ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollect
93
193
  const { data } = result
94
194
  if (data.length) {
95
195
  captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
96
- return { page: { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }, failed }
196
+ return {
197
+ page: {
198
+ collection: c.def.name,
199
+ page: data[0]!,
200
+ alternates: publishedAlternates(db, c, data[0]!),
201
+ ancestors: publishedAncestors(db, collections, path, locale, publishedOnly),
202
+ },
203
+ failed,
204
+ }
97
205
  }
98
206
  }
99
207
  return { page: null, failed }
@@ -28,6 +28,9 @@ export interface WriteClassification {
28
28
  * static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
29
29
  oldRoute: string | null
30
30
  groupTag: string | null
31
+ /** Page-path tags for the breadcrumb edge — this record's own path before and/or after the write (both,
32
+ * on a rename); empty when it is nobody's crumb step on either side. See `pagePathTag`. */
33
+ crumbTags: string[]
31
34
  }
32
35
 
33
36
  /** The data tag naming a translation group. `#` keeps it clear of the `<coll>:<id>` record namespace. */
@@ -35,6 +38,27 @@ export function translationGroupTag(coll: string, group: string): string {
35
38
  return `${coll}#group:${group}`
36
39
  }
37
40
 
41
+ /**
42
+ * The data tag naming a page-like PATH rather than a record — the edge a DESCENDANT's breadcrumb hangs on.
43
+ *
44
+ * It has to be the path, because Kestrel has no parent/child relation between pages: `path` is a plain
45
+ * column, a slug is flat unless an editor types slashes into it, and "descendant" is nothing but a
46
+ * path-prefix match. So the case that matters most — a page CREATED at `/blog` after `/blog/hello` was
47
+ * already published — has no record id that anything could have captured beforehand. A path, by contrast,
48
+ * is knowable before its page exists, so a descendant subscribes to the path it looked in.
49
+ *
50
+ * Deliberately locale-LESS: a non-translatable record has no locale to name, and a descendant looking up
51
+ * `/blog` in its own locale must still be reached when the page that appears there is a locale-less one.
52
+ * It therefore over-approximates across locales (an `en` `/blog` write also re-renders a `de` descendant
53
+ * of the same spelling) — extra renders, never a stale page.
54
+ *
55
+ * The leading `#` keeps it clear of the `<coll>` and `<coll>:<id>` namespaces (a collection name never
56
+ * starts with one).
57
+ */
58
+ export function pagePathTag(path: string): string {
59
+ return `#path:${path}`
60
+ }
61
+
38
62
  /** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
39
63
  export type Invalidation =
40
64
  | { type: 'full' }
@@ -59,6 +83,18 @@ function isPublic(def: WriteCollection, row: Row): boolean {
59
83
  return !!row && row.status === 'published'
60
84
  }
61
85
 
86
+ /**
87
+ * The path this record contributes as a breadcrumb step, or null when it contributes none. Mirrors
88
+ * `publishedAncestors` exactly — that lookup skips anything unpublished, `noindex`ed or path-less, so a
89
+ * record in one of those states is in nobody's trail and its writes owe nobody a re-render.
90
+ */
91
+ function crumbPath(def: WriteCollection, row: Row): string | null {
92
+ if (!def.pageLike || !row) return null
93
+ if (!isPublic(def, row)) return null
94
+ if ((row.seo as { noindex?: boolean } | null | undefined)?.noindex) return null
95
+ return typeof row.path === 'string' && row.path ? row.path : null
96
+ }
97
+
62
98
  /** Reduce a before/after row pair (either may be null) to a `WriteClassification`. Pure (routes via the shared `pageRowHref`). */
63
99
  export function classifyWrite(def: WriteCollection, before: Row, after: Row, primaryLocale: string, prefixPrimary = false): WriteClassification {
64
100
  const status: WriteClassification['status'] = before === null ? 'created' : after === null ? 'deleted' : 'updated'
@@ -81,7 +117,14 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
81
117
  const group = row?.translationGroup
82
118
  const groupTag = typeof group === 'string' && group ? translationGroupTag(def.name, group) : null
83
119
 
84
- return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
120
+ // Both sides, so a rename repairs the descendants of the OLD path as well as the new one. Deliberately
121
+ // NOT narrowed to "did the crumb's label actually change": the explicit publish action classifies its
122
+ // write as before === after (`publish.post.ts` — a re-render of the record's current state), so a diff
123
+ // would see nothing on the very publish that makes the crumb appear.
124
+ const crumbTags = [...new Set([crumbPath(def, before), crumbPath(def, after)].filter((p): p is string => p !== null))]
125
+ .map(pagePathTag)
126
+
127
+ return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag, crumbTags }
85
128
  }
86
129
 
87
130
  /**
@@ -133,7 +176,9 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
133
176
  // Unlike recordTag (dropped on create — no referrer can target a brand-new id), groupTag is included even
134
177
  // there: a new sibling still changes every existing member's hreflang set.
135
178
  const groupTags = ev.groupTag ? [ev.groupTag] : []
136
- const tags = recordTag ? [coll, recordTag, ...groupTags] : [coll, ...groupTags]
179
+ // Like groupTag, the crumb tags ride EVERY branch including create a page appearing at an ancestor
180
+ // path is exactly what a descendant's breadcrumb was waiting for.
181
+ const tags = recordTag ? [coll, recordTag, ...groupTags, ...ev.crumbTags] : [coll, ...groupTags, ...ev.crumbTags]
137
182
  const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
138
183
 
139
184
  // DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
@@ -147,7 +192,7 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
147
192
  // record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
148
193
  if (ev.status === 'created') {
149
194
  if (!ev.isPublished) return { type: 'noop' }
150
- return { type: 'tags', tags: [coll, ...groupTags], render: selfRender, prune: [] }
195
+ return { type: 'tags', tags: [coll, ...groupTags, ...ev.crumbTags], render: selfRender, prune: [] }
151
196
  }
152
197
 
153
198
  // UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
@@ -4,7 +4,7 @@ import { getTableColumns } from 'drizzle-orm'
4
4
  import type { StorageDriver } from '../../../../core/server/utils/storage'
5
5
  import { createLocalDriver } from '../../../../core/server/utils/storage.local'
6
6
  import { createS3Driver } from '../../../../core/server/utils/storage.s3'
7
- import { contentTypeFor, cacheControlFor, precompressedEncoding } from '../../../modules/deploy-output/deploy-output'
7
+ import { contentTypeFor, cacheControlFor, precompressedEncoding, META_KEYS, isMetaKey } from '../../../modules/deploy-output/deploy-output'
8
8
  import { localePath } from '../../../../core/app/utils/locale-path'
9
9
  import { pageRowHref } from '../../../../core/server/utils/page-route'
10
10
  import { withReadCapture } from '../../../../core/server/utils/read-capture'
@@ -185,11 +185,17 @@ export async function prunePages(routes: string[], driver: StorageDriver): Promi
185
185
  }
186
186
  }
187
187
 
188
- /** Render + write sitemap.xml, robots.txt and llms.txt (served at literal keys, not `<path>/index.html`). */
188
+ /** Render + write the crawler/agent artifacts — sitemap/robots/llms/redirects, served at literal keys
189
+ * rather than `<path>/index.html`.
190
+ * A 404 means the route is switched off (`llms-full.txt` without `kestrel.seo.llmsFull`) — the file it
191
+ * wrote while it WAS on must go, or the flag would stop publishing new content while the last full dump
192
+ * stayed live. That is a deterministic route-level answer, not the incomplete-read case a delete must
193
+ * never act on; any other non-200 leaves the existing file alone. */
189
194
  async function publishMeta(driver: StorageDriver): Promise<void> {
190
- for (const key of ['sitemap.xml', 'robots.txt', 'llms.txt']) {
191
- const { body } = await renderRoute(`/${key}`)
195
+ for (const key of META_KEYS) {
196
+ const { body, status } = await renderRoute(`/${key}`)
192
197
  if (body) await driver.put(key, body, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
198
+ else if (status === 404) await driver.delete(key)
193
199
  }
194
200
  }
195
201
 
@@ -218,7 +224,7 @@ async function syncStaticAssets(driver: StorageDriver, publicDir: string): Promi
218
224
  // exactly like its uncompressed sibling, or it ships as a live sidecar for the fresh HTML rendered
219
225
  // below — served to any Accept-Encoding-negotiating client instead of the page just published.
220
226
  const base = rel.replace(/\.(?:br|gz)$/i, '')
221
- if (!e.isFile() || base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') continue
227
+ if (!e.isFile() || base.endsWith('.html') || isMetaKey(base)) continue
222
228
  const bytes = await readFile(resolve(dir, e.name))
223
229
  // Tag a precompressed sibling (`.br`/`.gz` beside its base) with Content-Encoding so a proxy over S3
224
230
  // serves it directly; a standalone archive stays unencoded so browsers don't decode + corrupt it.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Compile the editor-authored redirect rows into the flat `redirects.json` artifact the edge consumes.
3
+ *
4
+ * Editors never write a regex: `from` is a path with `*` (one segment) or `**` (one or more) wildcards,
5
+ * and `to` may reference them positionally as `$1`, `$2`, … in authored order. The translation happens
6
+ * here — versioned, tested code — so the edge script only has to match and substitute.
7
+ *
8
+ * Pure by design (no Nuxt/Nitro imports): it is the executable spec for the edge as much as it is the
9
+ * artifact writer's input, which is why `matchRedirect` lives here even though nothing in Kestrel
10
+ * serves redirects at runtime.
11
+ */
12
+
13
+ /** One entry of the published artifact. `pattern` is a regex SOURCE string, anchored, path-only. */
14
+ export interface RedirectRule {
15
+ pattern: string
16
+ target: string
17
+ status: number
18
+ }
19
+
20
+ /** The statuses the editor offers. 301/302 cover SEO; 307/308 preserve the method on non-GET requests. */
21
+ export const REDIRECT_STATUSES = ['301', '302', '307', '308'] as const
22
+
23
+ /** A rule the editor has to fix. The message names the 1-based row so it is actionable in the UI. */
24
+ export class RedirectRuleError extends Error {}
25
+
26
+ const ESCAPE = /[.*+?^${}()|[\]\\-]/g
27
+ // eslint-disable-next-line no-control-regex -- deliberately rejects control characters headed for a Location header
28
+ const CONTROL = /[\u0000-\u001f\u007f]/
29
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i
30
+
31
+ function escapeLiteral(s: string): string {
32
+ return s.replace(ESCAPE, '\\$&')
33
+ }
34
+
35
+ /**
36
+ * What a wildcard may capture. This is a SECURITY boundary, not a convenience: a capture comes from the
37
+ * request, not from the editor, so `normalizeTarget`'s checks — which only ever saw the authored literal
38
+ * — say nothing about it. The pattern is the guard instead, and a request that would splice something
39
+ * dangerous into `Location` simply does not match and falls through to the origin.
40
+ *
41
+ * Excluded everywhere: a backslash (every browser resolves `Location: /\host` as `//host` — an open
42
+ * redirect) and the control characters that split a header (CR/LF) or terminate it (NUL, DEL).
43
+ * Additionally, a multi-segment capture may not START with `/`, or a target of `/$1` would become the
44
+ * protocol-relative `//host`. Anything a legitimate path contains still matches.
45
+ */
46
+ const SEGMENT_CHAR = '[^/\\\\\\x00-\\x1f\\x7f]'
47
+ const PATH_CHAR = '[^\\\\\\x00-\\x1f\\x7f]'
48
+ const ONE_SEGMENT = `(${SEGMENT_CHAR}+)`
49
+ const MANY_SEGMENTS = `(${SEGMENT_CHAR}${PATH_CHAR}*?)`
50
+
51
+ /**
52
+ * Translate an authored `from` into an anchored regex source. Matching is path-only and case-sensitive;
53
+ * an authored trailing slash is dropped and one is tolerated at match time, so `/blog` and `/blog/` are
54
+ * the same rule.
55
+ */
56
+ export function patternToRegexSource(from: string): string {
57
+ const raw = from.trim()
58
+ if (!raw) throw new RedirectRuleError('"From" must not be blank')
59
+ if (SCHEME.test(raw) || raw.startsWith('//')) {
60
+ throw new RedirectRuleError('"From" matches the request path only — drop the scheme and host')
61
+ }
62
+ if (/[?#]/.test(raw)) throw new RedirectRuleError('"From" must not contain a query string or fragment')
63
+ if (CONTROL.test(raw)) throw new RedirectRuleError('"From" must not contain control characters')
64
+ if (raw.includes('\\')) throw new RedirectRuleError('"From" must not contain a backslash')
65
+ if (raw.split('/').some((seg) => seg === '..')) throw new RedirectRuleError('"From" must not contain ".."')
66
+ // Two `**` with only a separator between them match the same thing through every split point, which is
67
+ // quadratic on a long path — seconds of CPU per request on a path an attacker chooses. It is also never
68
+ // what the author meant, so it is an authoring error rather than a limit to tune.
69
+ if (/\*\*\/?\*\*/.test(raw)) throw new RedirectRuleError('"From" has two `**` in a row — one already matches any number of segments')
70
+
71
+ const path = `/${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`
72
+ if (path === '/') return '^/$'
73
+
74
+ let body = ''
75
+ let last = 0
76
+ for (const m of path.matchAll(/\*\*|\*/g)) {
77
+ body += escapeLiteral(path.slice(last, m.index)) + (m[0] === '**' ? MANY_SEGMENTS : ONE_SEGMENT)
78
+ last = m.index + m[0].length
79
+ }
80
+ return `^${body + escapeLiteral(path.slice(last))}/?$`
81
+ }
82
+
83
+ /** Number of capture groups `patternToRegexSource` emits for an authored `from`. */
84
+ function wildcardCount(from: string): number {
85
+ return (from.trim().match(/\*\*|\*/g) ?? []).length
86
+ }
87
+
88
+ /**
89
+ * Normalize an authored `to` into a `Location` value. A path gets its leading slash; an absolute
90
+ * http(s) URL is kept verbatim (a moved domain is a legitimate target). Everything else is rejected —
91
+ * a `javascript:`/`data:` scheme or a protocol-relative `//host` would turn a redirect into a hazard.
92
+ */
93
+ export function normalizeTarget(to: string): string {
94
+ const raw = to.trim()
95
+ if (!raw) throw new RedirectRuleError('"To" must not be blank')
96
+ if (CONTROL.test(raw)) throw new RedirectRuleError('"To" must not contain control characters')
97
+ if (raw.includes('\\')) throw new RedirectRuleError('"To" must not contain a backslash')
98
+ if (raw.startsWith('//')) {
99
+ throw new RedirectRuleError('"To" must not be protocol-relative — write the full https:// URL')
100
+ }
101
+ if (!SCHEME.test(raw)) return `/${raw.replace(/^\/+/, '')}`
102
+
103
+ if (!/^https?:\/\//i.test(raw)) throw new RedirectRuleError('"To" may only use http:// or https://')
104
+ let url: URL
105
+ try {
106
+ url = new URL(raw)
107
+ } catch {
108
+ throw new RedirectRuleError(`"To" is not a valid URL: ${raw}`)
109
+ }
110
+ if (!url.host) throw new RedirectRuleError('"To" is missing a host')
111
+ if (url.username || url.password) throw new RedirectRuleError('"To" must not embed credentials')
112
+ return raw
113
+ }
114
+
115
+ /**
116
+ * A placeholder inside an absolute target's HOST would let a visitor choose the destination
117
+ * (`https://neu.example.com$1` + a request of `/blog/.evil.com` → `https://neu.example.com.evil.com`).
118
+ * The capture classes cannot prevent that one — the hazard is where `$n` sits, not what it holds — so it
119
+ * is rejected at authoring time.
120
+ */
121
+ function assertPlaceholdersAfterHost(target: string): void {
122
+ if (!SCHEME.test(target)) return
123
+ const firstPlaceholder = target.indexOf('$')
124
+ if (firstPlaceholder === -1) return
125
+ const pathStart = target.indexOf('/', target.indexOf('://') + 3)
126
+ if (pathStart === -1 || firstPlaceholder < pathStart) {
127
+ throw new RedirectRuleError('"To" may only use $1, $2, … after the host — a placeholder in the host lets a visitor pick the destination')
128
+ }
129
+ }
130
+
131
+ function readStatus(value: unknown): number {
132
+ const s = value === undefined || value === null || value === '' ? '301' : String(value)
133
+ if (!(REDIRECT_STATUSES as readonly string[]).includes(s)) {
134
+ throw new RedirectRuleError(`Status must be one of ${REDIRECT_STATUSES.join(', ')} (got ${s})`)
135
+ }
136
+ return Number(s)
137
+ }
138
+
139
+ function readText(value: unknown, what: string): string {
140
+ if (typeof value !== 'string' || !value.trim()) throw new RedirectRuleError(`${what} must not be blank`)
141
+ return value.trim()
142
+ }
143
+
144
+ /**
145
+ * Rows → artifact entries, in authored order (order IS priority: the edge takes the first match).
146
+ * An empty/absent field is zero redirects, which is a fully supported state — not an error.
147
+ * A row the edge could not honour throws instead of being dropped, so a typo fails the save loudly.
148
+ */
149
+ export function compileRedirects(rows: unknown): RedirectRule[] {
150
+ if (rows === null || rows === undefined) return []
151
+ if (!Array.isArray(rows)) throw new RedirectRuleError('Redirect rules must be a list')
152
+
153
+ return rows.map((raw, i) => {
154
+ try {
155
+ const entry = (raw ?? {}) as Record<string, unknown>
156
+ const from = readText(entry.from, '"From"')
157
+ const target = normalizeTarget(readText(entry.to, '"To"'))
158
+ const groups = wildcardCount(from)
159
+ for (const [, n] of target.matchAll(/\$(\d+)/g)) {
160
+ if (Number(n) < 1 || Number(n) > groups) {
161
+ throw new RedirectRuleError(`"To" references $${n} but "From" has ${groups} wildcard(s)`)
162
+ }
163
+ }
164
+ // `${1}` is the plausible typo — it compiles clean and then ships verbatim in every Location, a
165
+ // rule that silently 404s. A bare `$` is left alone: it is a legal path character (RFC 3986
166
+ // sub-delim), and only `$` followed by digits is reserved.
167
+ if (/\$\{/.test(target)) {
168
+ throw new RedirectRuleError('"To" writes a placeholder as $1, $2, … — not ${1}')
169
+ }
170
+ assertPlaceholdersAfterHost(target)
171
+ return { pattern: patternToRegexSource(from), target, status: readStatus(entry.status) }
172
+ } catch (err) {
173
+ throw new RedirectRuleError(`Row ${i + 1}: ${err instanceof Error ? err.message : String(err)}`)
174
+ }
175
+ })
176
+ }
177
+
178
+ /**
179
+ * The PUBLISHING counterpart of `compileRedirects`. Identical on data the save path accepted — which is
180
+ * all of it, since the collection's `validate` compiles every row before the write. The difference only
181
+ * shows after an upgrade that tightened a rule: a row stored by an older version would otherwise make
182
+ * this artifact unrenderable forever, taking every OTHER redirect down with it (and, at build time,
183
+ * suppressing the deploy's reconcile). Publishing the rest and naming what was dropped is the lesser
184
+ * failure; the editor learns about it the moment they next save, which is the strict path.
185
+ *
186
+ * A malformed `rows` still throws: that is a read bug, not a bad row, and `[]` would be a lie.
187
+ */
188
+ export function compilePublishableRedirects(rows: unknown): { rules: RedirectRule[]; skipped: string[] } {
189
+ if (rows === null || rows === undefined) return { rules: [], skipped: [] }
190
+ if (!Array.isArray(rows)) throw new RedirectRuleError('Redirect rules must be a list')
191
+
192
+ const rules: RedirectRule[] = []
193
+ const skipped: string[] = []
194
+ rows.forEach((row, i) => {
195
+ try {
196
+ rules.push(...compileRedirects([row]))
197
+ } catch (error) {
198
+ skipped.push(`Row ${i + 1}: ${(error as Error).message.replace(/^Row 1: /, '')}`)
199
+ }
200
+ })
201
+ return { rules, skipped }
202
+ }
203
+
204
+ /** The artifact body. An empty list is a valid document (`[]`), never an absent file. */
205
+ export function serializeRedirects(rules: RedirectRule[]): string {
206
+ return JSON.stringify(rules)
207
+ }
208
+
209
+ /**
210
+ * Reference implementation of the edge's match step — first rule that matches wins, `$n` substituted
211
+ * from the capture groups. Kestrel does not serve redirects; this pins the semantics the njs handler
212
+ * has to reproduce, and is what the tests assert against.
213
+ */
214
+ export function matchRedirect(rules: RedirectRule[], path: string): { target: string; status: number } | null {
215
+ for (const rule of rules) {
216
+ const m = new RegExp(rule.pattern).exec(path)
217
+ if (!m) continue
218
+ return { target: rule.target.replace(/\$(\d+)/g, (_, n: string) => m[Number(n)] ?? ''), status: rule.status }
219
+ }
220
+ return null
221
+ }