@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
@@ -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'
@@ -12,8 +12,9 @@ import { withResolveScope } from '../../../../core/server/utils/resolve-scope'
12
12
  import { runAsRenderer } from '../../../../access/server/utils/render-context'
13
13
  import { htmlKeyForRoute } from './route-keys'
14
14
  import { staleRoutes, type DepsStore } from './deps'
15
- import { recordPublishStatus, clearPublishStatus, renderOutcome } from './publish-status'
15
+ import { recordPublishStatus, clearPublishStatus, renderOutcome, lastPublishedAt } from './publish-status'
16
16
  import { routesToPrune, type Invalidation } from './invalidation'
17
+ import { pendingRoutes, heldRoutes } from './pending'
17
18
 
18
19
  /**
19
20
  * The runtime static publisher: renders public routes from the LIVE server (`localFetch`, the same
@@ -28,6 +29,7 @@ interface OutputRc {
28
29
  dir: string
29
30
  publicDir: string
30
31
  auto: boolean
32
+ publishOnSave: boolean
31
33
  reconcileMinutes: number
32
34
  verbose: boolean
33
35
  s3: { bucket: string; region: string; endpoint: string; prefix: string; accessKeyId: string; secretAccessKey: string; sessionToken: string }
@@ -69,6 +71,11 @@ export async function renderRoute(route: string): Promise<{ body: Buffer | null;
69
71
  * route set is INCOMPLETE, so it must never be used as the authority for what to delete. */
70
72
  export interface PublishedRoutes {
71
73
  routes: string[]
74
+ /** Each route's record `updatedAt` in ms — the "last saved" half of the saved-vs-published comparison. */
75
+ savedAt: Map<string, number>
76
+ /** Each route's owning record as its deps tag (`<coll>:<id>`) — route strings move on a rename, records
77
+ * do not, so withholding a renamed page needs the identity behind the route. */
78
+ recordTag: Map<string, string>
72
79
  /** Names of collections whose route query threw (drifted schema, locked DB) — routes are missing. */
73
80
  failed: string[]
74
81
  }
@@ -82,6 +89,8 @@ export function allPublishedRoutes(): PublishedRoutes {
82
89
  const prefixPrimary = prefixPrimaryLocale()
83
90
  const pub = publicReadableResources()
84
91
  const routes = new Set<string>([localePath('/', primary, primary, prefixPrimary)]) // `/` or `/<primary>`
92
+ const savedAt = new Map<string, number>()
93
+ const recordTag = new Map<string, string>()
85
94
  const failed: string[] = []
86
95
  for (const c of allCollections()) {
87
96
  if (!c.def.pageLike || !isPubliclyReadable(c.def.name, pub)) continue
@@ -94,6 +103,10 @@ export function allPublishedRoutes(): PublishedRoutes {
94
103
  const proj: Record<string, unknown> = { path: cols.path }
95
104
  if (c.def.translatable) proj.locale = cols.locale
96
105
  if (c.def.status) proj.status = cols.status
106
+ // `updatedAt` is a system column on every built collection, but this projection also runs against
107
+ // hand-rolled tables in tests — guard it exactly like `locale`, or its absence throws the select.
108
+ if (Object.hasOwn(cols, 'updatedAt')) proj.updatedAt = cols.updatedAt
109
+ if (Object.hasOwn(cols, 'id')) proj.id = cols.id
97
110
  let rows: Record<string, unknown>[]
98
111
  try { rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[] }
99
112
  catch (error) {
@@ -106,10 +119,17 @@ export function allPublishedRoutes(): PublishedRoutes {
106
119
  for (const row of rows) {
107
120
  if (c.def.status && row.status !== 'published') continue
108
121
  const route = pageRowHref(row, primary, prefixPrimary) // the shared (path, locale) → route rule
109
- if (route) routes.add(route)
122
+ if (!route) continue
123
+ routes.add(route)
124
+ const saved = row.updatedAt
125
+ if (saved instanceof Date) savedAt.set(route, saved.getTime())
126
+ else if (typeof saved === 'number') savedAt.set(route, saved)
127
+ // The same tag the publisher records against a rendered route, so a route can be traced back to its
128
+ // record even after a rename moved the route string.
129
+ if (typeof row.id === 'number') recordTag.set(route, `${c.def.name}:${row.id}`)
110
130
  }
111
131
  }
112
- return { routes: [...routes], failed }
132
+ return { routes: [...routes], savedAt, recordTag, failed }
113
133
  }
114
134
 
115
135
  /** Render + write the given routes (skips non-200). When `deps` is given, each render is wrapped in a
@@ -165,11 +185,17 @@ export async function prunePages(routes: string[], driver: StorageDriver): Promi
165
185
  }
166
186
  }
167
187
 
168
- /** 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. */
169
194
  async function publishMeta(driver: StorageDriver): Promise<void> {
170
- for (const key of ['sitemap.xml', 'robots.txt', 'llms.txt']) {
171
- const { body } = await renderRoute(`/${key}`)
195
+ for (const key of META_KEYS) {
196
+ const { body, status } = await renderRoute(`/${key}`)
172
197
  if (body) await driver.put(key, body, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
198
+ else if (status === 404) await driver.delete(key)
173
199
  }
174
200
  }
175
201
 
@@ -198,7 +224,7 @@ async function syncStaticAssets(driver: StorageDriver, publicDir: string): Promi
198
224
  // exactly like its uncompressed sibling, or it ships as a live sidecar for the fresh HTML rendered
199
225
  // below — served to any Accept-Encoding-negotiating client instead of the page just published.
200
226
  const base = rel.replace(/\.(?:br|gz)$/i, '')
201
- 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
202
228
  const bytes = await readFile(resolve(dir, e.name))
203
229
  // Tag a precompressed sibling (`.br`/`.gz` beside its base) with Content-Encoding so a proxy over S3
204
230
  // serves it directly; a standalone archive stays unencoded so browsers don't decode + corrupt it.
@@ -226,7 +252,7 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
226
252
  // runs late (layer-then-filename order) — finish populating the registry first. Moving the read before
227
253
  // an await would silently render an empty registry. See docs/architecture.md → "Server plugins".
228
254
  await syncStaticAssets(driver, cfg.publicDir)
229
- const { routes, failed } = allPublishedRoutes()
255
+ const { routes, savedAt, recordTag, failed } = allPublishedRoutes()
230
256
  if (failed.length) {
231
257
  // Keep on doubt: with a collection missing from the enumeration, every one of its live pages looks
232
258
  // stale, so a prune would wipe it from the output. Rendering still proceeds — a stale extra file is
@@ -234,12 +260,27 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
234
260
  console.error(`[kestrel] publish: prune skipped — routes of ${failed.join(', ')} could not be enumerated; existing files kept`)
235
261
  }
236
262
 
263
+ // A full run resynchronizes the output with the DB, so without this it would push every saved-but-
264
+ // unpublished edit live — exactly what deferring the publish exists to prevent. Those routes keep the
265
+ // file their last publish wrote.
266
+ // …unless the consumer opted out of the split (`output.publishOnSave`): there, a save IS a publish, so
267
+ // "saved after the last publish" means a republish is merely in flight, not deliberately withheld.
268
+ // Computed BEFORE the prune, because a held record's live file may sit at a route the DB no longer names
269
+ // (an unpublished rename), and that file is what the site is still serving. Without `deps` there is no
270
+ // way to find those prior routes, so only same-route withholding applies — the pre-rename behaviour.
271
+ const { hold, keep } = cfg.publishOnSave
272
+ ? { hold: new Set<string>(), keep: new Set<string>() }
273
+ : heldRoutes(savedAt, lastPublishedAt(useDb()), recordTag, (tag) => deps?.routesForTags([tag]) ?? [])
274
+ if (hold.size) {
275
+ console.info(`[kestrel] publish: ${hold.size} route(s) held at their published version (unpublished changes): ${[...hold].join(', ')}`)
276
+ }
277
+
237
278
  // Targeted prune: a route we previously published that is no longer in the published set — a page
238
279
  // unpublished, deleted, or whose slug changed — must lose its static file. Safe because it only deletes
239
280
  // files this publisher wrote (tracked in deps, durable across restarts). Output ≡ DB; no opt-in toggle.
240
281
  let pruned = 0
241
282
  if (deps && !failed.length) {
242
- const stale = staleRoutes(deps.routes(), routes)
283
+ const stale = staleRoutes(deps.routes(), routes).filter((route) => !keep.has(route))
243
284
  if (stale.length) {
244
285
  await prunePages(stale, driver)
245
286
  for (const route of stale) deps.forget(route)
@@ -247,11 +288,13 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
247
288
  }
248
289
  }
249
290
 
291
+ const renderRoutes = routes.filter((route) => !hold.has(route))
292
+
250
293
  // Reset the discovery accumulator so this full run reconciles ONLY what it actually renders — an earlier
251
294
  // incremental (tag) publish also feeds the accumulator, and a variant it recorded whose usage was later
252
295
  // removed would otherwise survive and be re-registered here (defeating usage-driven narrowing).
253
296
  clearVariants()
254
- const written = await publishRoutes(routes, driver, deps)
297
+ const written = await publishRoutes(renderRoutes, driver, deps)
255
298
  const rendered = written.length
256
299
  await publishMeta(driver)
257
300
  // Auto-discovery: a FULL render just visited every published route, so the capture accumulator now holds
@@ -260,8 +303,10 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
260
303
  // by the un-rendered routes. ONLY narrow when EVERY route rendered: a partial failure leaves the accumulator
261
304
  // incomplete, so reconciling would deregister variants still referenced by the stale (kept) published HTML,
262
305
  // which a later backfill would then delete out from under the live page. An un-enumerated collection is
263
- // the same partial-coverage case: its pages were never visited, so their variants are missing too.
264
- if (!failed.length && rendered === routes.length) saveDiscoveredVariants(useDb())
306
+ // the same partial-coverage case: its pages were never visited, so their variants are missing too — and
307
+ // so is a route held back at its published version: its live file still references the variants this run
308
+ // never saw.
309
+ if (!failed.length && !hold.size && rendered === renderRoutes.length) saveDiscoveredVariants(useDb())
265
310
  return { rendered, pruned }
266
311
  }
267
312
 
@@ -270,13 +315,39 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
270
315
  * (their `<lastmod>` may have changed). */
271
316
  export interface PublishResult { rendered: string[]; pruned: string[]; counts: { rendered: number; pruned: number } }
272
317
 
318
+ /**
319
+ * Drop the routes a tag match dragged in that are holding their published version back. Withholding is a
320
+ * property of the ROUTE, not of the full publish: a route whose record was saved after its last publish
321
+ * serves that published file until someone publishes it. Without this, publishing one record re-renders
322
+ * every route tagged with the collection — and every route reads the `site` singleton — from the live DB,
323
+ * so a routine Publish writes an unrelated record's withheld body to the live site.
324
+ *
325
+ * A route named in `render` is exempt: it IS what the publish was for, and pressing Publish is what clears
326
+ * the withholding. The prune set is untouched — removal has no publish intent left to protect, so an
327
+ * unpublished or deleted record's page still goes at once (ADR-0008).
328
+ *
329
+ * The cost, deliberately accepted: a withheld route keeps the baked links and hreflang of its last
330
+ * publish, so a link to a record that has since been unpublished stays stale until the referrer itself is
331
+ * published. That is the same staleness its body already carries — a frozen route is one publish
332
+ * generation throughout, rather than a mix of two. Rendering a referrer from its published state while
333
+ * resolving fresh links needs a published snapshot per record, which is ADR-0008's "Future".
334
+ */
335
+ function withheldRemoved(inv: Extract<Invalidation, { type: 'tags' }>, routes: string[]): string[] {
336
+ if (outputConfig().publishOnSave) return routes // that mode never defers a publish in the first place
337
+ // An un-enumerable collection contributes no `savedAt` entry, so its routes are simply not withheld —
338
+ // the same direction publishFull takes, and the non-destructive one (a stale re-render, never a delete).
339
+ const held = new Set(pendingRoutes(allPublishedRoutes().savedAt, lastPublishedAt(useDb())))
340
+ const explicit = new Set(inv.render)
341
+ return routes.filter((route) => explicit.has(route) || !held.has(route))
342
+ }
343
+
273
344
  export async function publishInvalidation(inv: Invalidation, driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<PublishResult> {
274
345
  if (inv.type === 'noop') return { rendered: [], pruned: [], counts: { rendered: 0, pruned: 0 } }
275
346
  if (inv.type === 'full') {
276
347
  const r = await publishFull(driver, deps) // full: counts only (don't list every route)
277
348
  return { rendered: [], pruned: [], counts: { rendered: r.rendered, pruned: r.pruned } }
278
349
  }
279
- const routes = [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])]
350
+ const routes = withheldRemoved(inv, [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])])
280
351
  const rendered = await publishRoutes(routes, driver, deps)
281
352
  let pruned: string[] = []
282
353
  // Never prune a route we just wrote live — render wins a coalesced render+prune collision (see routesToPrune).
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ import type { StorageDriver } from '../../../../core/server/utils/storage'
2
+ import { contentTypeFor, cacheControlFor } from '../../../modules/deploy-output/deploy-output'
3
+ import { compileRedirects, serializeRedirects } from './redirect-rules'
4
+
5
+ /** The collection and the repeater field the artifact is compiled from, named once. */
6
+ export const REDIRECTS_COLLECTION = 'redirects'
7
+ export const REDIRECTS_FIELD = 'rules'
8
+
9
+ /** Literal key at the output root — a sibling of `index.html`, not a child of it. The driver's root IS
10
+ * the output root (local `output.dir`, or the S3 prefix), so a key can never sit *beside* that tree. */
11
+ export const REDIRECTS_KEY = 'redirects.json'
12
+
13
+ /**
14
+ * Compile the editor's rows and publish them. Compilation runs first so an unpublishable rule fails
15
+ * before the driver is touched, and the writer never swallows: a rejection is the caller's to surface.
16
+ */
17
+ export async function writeRedirectsArtifact(rows: unknown, driver: StorageDriver): Promise<void> {
18
+ const body = Buffer.from(serializeRedirects(compileRedirects(rows)))
19
+ await driver.put(REDIRECTS_KEY, body, contentTypeFor(REDIRECTS_KEY), { cacheControl: cacheControlFor(REDIRECTS_KEY) })
20
+ }