@michaelthielemann/kestrel 1.7.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -12
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +13 -5
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +43 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +15 -6
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +12 -0
- package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
- package/layers/core/server/api/[collection]/index.put.ts +18 -1
- package/layers/core/server/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +42 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaLibrary.vue +11 -6
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/MediaViewer.vue +56 -7
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/server/api/media/[id].patch.test.ts +66 -0
- package/layers/media/server/api/media/[id].patch.ts +25 -1
- package/layers/media/server/api/media/index.post.ts +19 -3
- package/layers/media/server/collections/media.ts +14 -0
- package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
- package/layers/media/server/utils/ai-signal-detect.ts +155 -0
- package/layers/media/server/utils/library.ts +2 -1
- package/layers/media/server/utils/record.ts +8 -0
- package/layers/media/server/utils/resolve.ts +12 -0
- package/layers/public/app/pages/[...slug].vue +57 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
- package/layers/public/modules/prerender-routes/index.ts +5 -2
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/plugins/zz.publish.ts +12 -3
- package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
- package/layers/public/server/routes/llms.txt.get.ts +1 -12
- package/layers/public/server/routes/redirects.json.get.ts +58 -0
- package/layers/public/server/routes/robots.txt.get.ts +1 -0
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/llms-full.ts +125 -0
- package/layers/public/server/utils/llms.ts +13 -0
- package/layers/public/server/utils/page-resolve.ts +112 -4
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +72 -3
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +85 -14
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +23 -0
- package/layers/ui/app/i18n/en.ts +23 -0
- package/package.json +7 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|