@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.
- package/README.md +15 -8
- package/layers/admin/app/components/CollectionList.vue +5 -1
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +42 -0
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/core/modules/kestrel/index.ts +11 -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/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- 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 +7 -5
- package/layers/media/app/components/MediaViewer.vue +56 -7
- 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 +30 -1
- package/layers/public/app/utils/json-ld.ts +139 -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/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/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/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/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- 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/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/package.json +2 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|