@michaelthielemann/kestrel 1.6.0 → 2.0.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 +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/access/server/utils/ip-allowlist.ts +4 -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 +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -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 +6 -3
- 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/editor-expose.ts +8 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
- package/layers/collections/server/utils/populate-relations.ts +18 -5
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/api/[collection]/[id].get.ts +4 -1
- package/layers/core/server/api/[collection]/index.get.ts +7 -2
- package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
- package/layers/core/server/api/references/broken.get.ts +7 -1
- package/layers/core/server/schema/dialect.ts +5 -3
- package/layers/core/server/schema/introspect.ts +4 -3
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +11 -10
- package/layers/core/server/utils/kestrel-config.ts +10 -3
- package/layers/core/server/utils/populate.ts +3 -1
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/server/utils/backfill.ts +24 -12
- package/layers/media/server/utils/variants.ts +27 -12
- package/layers/public/app/pages/[...slug].vue +35 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- 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 +26 -6
- package/layers/public/server/plugins/02.register-links.ts +4 -4
- package/layers/public/server/plugins/zz.publish.ts +13 -4
- package/layers/public/server/routes/llms.txt.get.ts +5 -2
- package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/link-resolve.ts +5 -2
- package/layers/public/server/utils/page-resolve.ts +23 -9
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +43 -4
- 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 +74 -9
- 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 +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +9 -4
- package/scripts/kestrel.mjs +48 -19
- package/scripts/lib/scaffold.mjs +39 -8
- package/templates/starter/app/blocks/Prose.vue +1 -0
|
@@ -2,6 +2,7 @@ import { eq, getTableColumns } from 'drizzle-orm'
|
|
|
2
2
|
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
3
|
import { publishStatus } from '../database/publish-status'
|
|
4
4
|
import { routeForRecord } from '../utils/publish/route-for-record'
|
|
5
|
+
import { hasPendingChanges } from '../utils/publish/pending'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
|
|
@@ -17,28 +18,41 @@ export default defineEventHandler((event) => {
|
|
|
17
18
|
// (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
|
|
18
19
|
// (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
|
|
19
20
|
// "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
|
|
20
|
-
const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
|
|
21
|
-
|
|
21
|
+
const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
|
|
22
|
+
// `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
|
|
23
|
+
// off there is nothing left for it to do.
|
|
24
|
+
const publishOnSave = !!output?.publishOnSave
|
|
25
|
+
const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
|
|
22
26
|
|
|
23
27
|
const q = getQuery(event)
|
|
24
28
|
const name = typeof q.collection === 'string' ? q.collection : ''
|
|
25
29
|
const id = Number(typeof q.id === 'string' ? q.id : NaN)
|
|
26
30
|
const c = getCollection(name)
|
|
27
|
-
if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
|
|
31
|
+
if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
|
|
28
32
|
|
|
29
33
|
const db = useDb()
|
|
30
34
|
const table = c.table as AnySQLiteTable
|
|
31
35
|
const cols = getTableColumns(table) as Record<string, never>
|
|
32
|
-
const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
|
|
36
|
+
const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
|
|
33
37
|
const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
|
|
34
|
-
if (!route) return { route: null, status: null, ...env }
|
|
38
|
+
if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
|
|
35
39
|
|
|
40
|
+
// Saved after it was last published: with publishing deferred to an explicit action, that is the normal
|
|
41
|
+
// working state of a page being edited — the live file is the previous version until someone publishes.
|
|
42
|
+
const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
|
|
36
43
|
try {
|
|
37
44
|
const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// A routable page with no row was never published. Before the split that was indistinguishable from
|
|
46
|
+
// "a publish is running" — a save always enqueued one — but now nothing is in flight and nothing will be
|
|
47
|
+
// until someone presses Publish, so the lamp must not claim progress that is not happening.
|
|
48
|
+
if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
|
|
49
|
+
// With the split off, a save republishes on its own, so a newer save means a republish is in flight —
|
|
50
|
+
// reporting that as "unpublished changes" would ask the user to act on something already happening.
|
|
51
|
+
const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
|
|
52
|
+
return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
|
|
40
53
|
} catch {
|
|
41
|
-
// publish_status not migrated yet → treat as "no status" rather than a 500.
|
|
42
|
-
|
|
54
|
+
// publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
|
|
55
|
+
// either: the table is unreadable, so the page's real state is unknown, not known to be absent.
|
|
56
|
+
return { route, status: null, pending: false, neverPublished: false, ...env }
|
|
43
57
|
}
|
|
44
58
|
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { MAX_BULK_IDS } from '../../../core/app/utils/list-limits'
|
|
4
|
+
import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
|
|
5
|
+
import { staleRoutes } from '../utils/publish/deps'
|
|
6
|
+
import { usePublishRuntime } from '../utils/publish/publish-runtime'
|
|
7
|
+
import { allPublishedRoutes } from '../utils/publish/publisher'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publish records: write their static files (and everything whose baked output embeds them) to the
|
|
11
|
+
* configured output — the deliberate second half of the split ADR-0008 introduced. Saving persists to the
|
|
12
|
+
* DB and leaves the live site alone; THIS is what changes what visitors see.
|
|
13
|
+
*
|
|
14
|
+
* body: { collection: string, ids?: number[], id?: number }
|
|
15
|
+
* 200: { queued, generates, routes, pruned, drafts }
|
|
16
|
+
*
|
|
17
|
+
* The record's publish INTENT (`status`) is not touched here — that is a field the editor saves like any
|
|
18
|
+
* other, so a page goes live by being published while published. A draft is therefore reported back
|
|
19
|
+
* (`drafts`) rather than silently promoted: it has no public output to write.
|
|
20
|
+
*
|
|
21
|
+
* All-or-nothing on lookup (an unknown id 404s before anything is enqueued), like the bulk write actions.
|
|
22
|
+
*/
|
|
23
|
+
export default defineEventHandler(async (event) => {
|
|
24
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
25
|
+
const body = await readBody(event)
|
|
26
|
+
const name = typeof (body as { collection?: unknown })?.collection === 'string' ? (body as { collection: string }).collection : ''
|
|
27
|
+
const c = getCollection(name)
|
|
28
|
+
if (!c) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
29
|
+
|
|
30
|
+
const raw = (body as { ids?: unknown; id?: unknown })?.ids ?? [(body as { id?: unknown })?.id]
|
|
31
|
+
const ids = parseIdList(raw, MAX_BULK_IDS)
|
|
32
|
+
|
|
33
|
+
const db = useDb()
|
|
34
|
+
const table = c.table as AnySQLiteTable
|
|
35
|
+
const cols = getTableColumns(table) as Record<string, never>
|
|
36
|
+
const rows = db.select().from(table).where(inArray(cols.id, ids)).all() as Record<string, unknown>[]
|
|
37
|
+
const found = new Set(rows.map((r) => r.id as number))
|
|
38
|
+
const missing = ids.filter((id) => !found.has(id))
|
|
39
|
+
if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.def.name} not found: ${missing.join(', ')}` })
|
|
40
|
+
|
|
41
|
+
// Where the next publish would go, and whether one happens here at all: in dev (or with `output.auto`
|
|
42
|
+
// off) there is no runtime publisher, and saying so is more useful than a queued run that never runs.
|
|
43
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean } }).output
|
|
44
|
+
const runtime = usePublishRuntime()
|
|
45
|
+
const generates = !import.meta.dev && !!output?.auto && !!runtime
|
|
46
|
+
|
|
47
|
+
// The live route set answers "is this tracked route still somebody's page?" — the question a rename
|
|
48
|
+
// leaves open. `failed` means the enumeration was incomplete, and an incomplete read must never drive a
|
|
49
|
+
// delete (the standing rule from the 2026-07-25 audit), so the prune is skipped wholesale.
|
|
50
|
+
const liveNow = runtime ? allPublishedRoutes() : { routes: [], failed: ['*'] }
|
|
51
|
+
const prunable = liveNow.failed.length === 0
|
|
52
|
+
|
|
53
|
+
const primary = primaryLocale()
|
|
54
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
55
|
+
const routes: string[] = []
|
|
56
|
+
const pruned: string[] = []
|
|
57
|
+
const drafts: number[] = []
|
|
58
|
+
let queued = false
|
|
59
|
+
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
// before === after: nothing about the record is changing, this is a re-render of its current state.
|
|
62
|
+
// A draft classifies as not-published, so `planInvalidation` returns a noop for it — reported below.
|
|
63
|
+
const ev = classifyWrite(c.def, row, row, primary, prefixPrimary)
|
|
64
|
+
const inv = planInvalidation(ev)
|
|
65
|
+
if (inv.type !== 'tags') {
|
|
66
|
+
drafts.push(row.id as number)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// Abandoned URLs: routes the publisher baked FROM this record (tagged with its id) that no live page
|
|
70
|
+
// claims any more — in practice the old file a published rename left behind. Same rule a full publish
|
|
71
|
+
// applies globally, scoped to this record's tag; a referrer or listing carrying the tag is a live route
|
|
72
|
+
// and therefore never in this set.
|
|
73
|
+
const tagged = ev.id != null ? (runtime?.deps.routesForTags([`${c.def.name}:${ev.id}`]) ?? []) : []
|
|
74
|
+
const stale = prunable ? staleRoutes(tagged, liveNow.routes) : []
|
|
75
|
+
routes.push(...inv.render)
|
|
76
|
+
pruned.push(...stale)
|
|
77
|
+
if (runtime) {
|
|
78
|
+
runtime.queue.enqueue({ ...inv, prune: [...inv.prune, ...stale] })
|
|
79
|
+
queued = true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { queued, generates, routes, pruned, drafts }
|
|
84
|
+
})
|
|
@@ -14,13 +14,33 @@ export default defineEventHandler((event) => {
|
|
|
14
14
|
const isStaticRender = import.meta.prerender === true || isRendererContext()
|
|
15
15
|
const publishedOnly = isStaticRender || event.context.readScope !== 'all'
|
|
16
16
|
const db = useDb()
|
|
17
|
-
const resolved = resolvePage(db, allCollections(), path, locale, publishedOnly)
|
|
17
|
+
const { page: resolved, failed } = resolvePage(db, allCollections(), path, locale, publishedOnly)
|
|
18
18
|
// The site-wide head tier rides along on the fetch the page already awaits, so it reaches SSR and the
|
|
19
|
-
// prerender on a path that is known to work. Looked up through the registry, not imported, so
|
|
20
|
-
//
|
|
21
|
-
// `depth: 1` resolves the sharing image into `$media`;
|
|
22
|
-
// re-publishes every route that embedded it.
|
|
19
|
+
// prerender on a path that is known to work. Looked up through the registry, not imported, so an
|
|
20
|
+
// installation whose registry never received the built-in simply has the tier off (`null`) instead of
|
|
21
|
+
// querying a table the schema never created. `depth: 1` resolves the sharing image into `$media`;
|
|
22
|
+
// `getSingleton` captures the read, so an edit re-publishes every route that embedded it.
|
|
23
23
|
const siteCollection = getCollection('site')
|
|
24
|
-
|
|
24
|
+
let site: ReturnType<typeof getSingleton> = null
|
|
25
|
+
let siteUnreadable = false
|
|
26
|
+
if (siteCollection) {
|
|
27
|
+
try { site = getSingleton(db, siteCollection, locale, false, 1) }
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Registered but unreadable (its migration hasn't been run) — indistinguishable in the response from
|
|
30
|
+
// the off state above, so it joins the incomplete-read channel rather than degrading silently.
|
|
31
|
+
siteUnreadable = true
|
|
32
|
+
console.error('[kestrel] route: the site singleton could not be read:', (error as Error)?.message ?? error)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// One rule for every incomplete read: never answer 200. The publisher classifies a 200-with-body as a
|
|
36
|
+
// successful render, writes it over the live file and records success — so an unreadable page collection
|
|
37
|
+
// would bake the catch-all's empty document over a real page (the record may well live in the collection
|
|
38
|
+
// that failed, which is why this is not a 404), and an unreadable head tier would strip the composed
|
|
39
|
+
// title, default description and sharing image from every route it touches. Both are unrecoverable
|
|
40
|
+
// without a full re-publish and neither leaves a mark. A 5xx keeps the existing artifact and turns the
|
|
41
|
+
// editor's status red. The head tier is site-wide, so it fails the request even when a page did resolve.
|
|
42
|
+
if (siteUnreadable || (failed.length && !resolved)) {
|
|
43
|
+
throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
|
|
44
|
+
}
|
|
25
45
|
return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [], site }
|
|
26
46
|
})
|
|
@@ -3,10 +3,10 @@ import { buildLinkFieldPopulators } from '../utils/populate-links'
|
|
|
3
3
|
import { resolveInternalHref } from '../utils/link-resolve'
|
|
4
4
|
|
|
5
5
|
// Resolve internal links to the target record's localized public path at read time (page-like targets;
|
|
6
|
-
// external/email/tel pass through).
|
|
7
|
-
//
|
|
8
|
-
// is
|
|
9
|
-
//
|
|
6
|
+
// external/email/tel pass through). The resolver IS status-gated: a link to a DRAFT resolves to nothing and
|
|
7
|
+
// bakes `'#'`, so an unpublished slug never reaches published HTML. The href therefore encodes availability,
|
|
8
|
+
// which is why flipping a target's status re-renders its referrers; the editor warns about the resulting
|
|
9
|
+
// draft/dead links separately. (See link-resolve.ts.)
|
|
10
10
|
//
|
|
11
11
|
// Registered as `link` + `richtext` per-type populators; the shared field-tree walker dispatches them over
|
|
12
12
|
// top-level fields, block props, slots, and repeater entries.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createPublishQueue } from '../utils/publish/queue'
|
|
2
|
+
import { setPublishRuntime } from '../utils/publish/publish-runtime'
|
|
2
3
|
import { DepsStore } from '../utils/publish/deps'
|
|
3
4
|
import { createSqlitePersistence } from '../utils/publish/deps-persistence'
|
|
4
5
|
import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
|
|
5
|
-
import { classifyWrite,
|
|
6
|
+
import { classifyWrite, planWrite } from '../utils/publish/invalidation'
|
|
6
7
|
import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,11 +16,11 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
|
15
16
|
* DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
|
|
16
17
|
* run + before the server listens), so it never blocks boot.
|
|
17
18
|
* 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
|
|
18
|
-
* invalidation
|
|
19
|
+
* invalidation.
|
|
19
20
|
*/
|
|
20
21
|
export default defineNitroPlugin(() => {
|
|
21
22
|
if (import.meta.dev) return
|
|
22
|
-
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
24
|
if (!output?.auto) return
|
|
24
25
|
|
|
25
26
|
// `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
|
|
@@ -48,8 +49,16 @@ export default defineNitroPlugin(() => {
|
|
|
48
49
|
onError: (error) => console.error('[kestrel] publish run failed:', error),
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
// The explicit publish action (`POST /api/publish`) enqueues through this same queue, so a publish and
|
|
53
|
+
// a write-driven prune are serialized by one single-flight run rather than racing each other.
|
|
54
|
+
setPublishRuntime({ queue, deps })
|
|
55
|
+
|
|
56
|
+
// A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
|
|
57
|
+
// unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
|
|
58
|
+
// publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
|
|
59
|
+
const publishOnSave = output.publishOnSave ?? false
|
|
51
60
|
registerWriteListener(({ def, before, after }) => {
|
|
52
|
-
queue.enqueue(
|
|
61
|
+
queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
|
|
53
62
|
})
|
|
54
63
|
|
|
55
64
|
// Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
|
|
@@ -44,8 +44,11 @@ export default defineEventHandler((event) => {
|
|
|
44
44
|
let rows: Record<string, unknown>[]
|
|
45
45
|
try {
|
|
46
46
|
rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
|
|
47
|
-
} catch {
|
|
48
|
-
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Skipping keeps a bare prerender DB publishable, but a drifted table drops the whole section — a
|
|
49
|
+
// silent gap the publisher would write straight over the live artifact.
|
|
50
|
+
console.error(`[kestrel] llms.txt: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
51
|
+
continue
|
|
49
52
|
}
|
|
50
53
|
const entries: LlmsEntry[] = []
|
|
51
54
|
for (const row of rows) {
|
|
@@ -39,8 +39,11 @@ export default defineEventHandler((event) => {
|
|
|
39
39
|
let rows: Record<string, unknown>[]
|
|
40
40
|
try {
|
|
41
41
|
rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Skipping keeps a bare prerender DB publishable, but a drifted table de-indexes every page of the
|
|
44
|
+
// collection — a silent gap the publisher would write straight over the live sitemap.
|
|
45
|
+
console.error(`[kestrel] sitemap.xml: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
46
|
+
continue
|
|
44
47
|
}
|
|
45
48
|
for (const row of rows) {
|
|
46
49
|
if (c.def.status && row.status !== 'published') continue
|
|
@@ -5,7 +5,8 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
|
5
5
|
/**
|
|
6
6
|
* The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
|
|
7
7
|
* via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
|
|
8
|
-
* (output ≡ DB).
|
|
8
|
+
* (output ≡ DB). Like every full publish it HOLDS BACK routes with unpublished changes (ADR-0008) — a
|
|
9
|
+
* resync must not push work in progress live; those pages go out when they are published.
|
|
9
10
|
*
|
|
10
11
|
* Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
|
|
11
12
|
* - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
|
|
@@ -27,8 +27,11 @@ export function resolveInternalHref(collection: string, id: number, db = useDb()
|
|
|
27
27
|
let row: Record<string, unknown> | undefined
|
|
28
28
|
try {
|
|
29
29
|
row = db.select().from(c.table).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Still null (a throw would 500 every page holding an internal link on a bare prerender DB), but the
|
|
32
|
+
// memo caches that null run-wide as "not linkable", so the dead link needs a trace to be diagnosable.
|
|
33
|
+
console.error(`[kestrel] resolveInternalHref: ${collection}:${id} unreadable:`, (error as Error)?.message ?? error)
|
|
34
|
+
return null
|
|
32
35
|
}
|
|
33
36
|
if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
|
|
34
37
|
return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { 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
5
|
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
5
6
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
6
7
|
|
|
7
8
|
export interface PageAlternate { locale: string; path: string }
|
|
8
9
|
export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
|
|
9
10
|
|
|
11
|
+
/** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
|
|
12
|
+
* was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
|
|
13
|
+
export interface PageResolution {
|
|
14
|
+
page: ResolvedPage | null
|
|
15
|
+
failed: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
/**
|
|
11
19
|
* The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
|
|
12
20
|
* set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
|
|
@@ -14,12 +22,15 @@ export interface ResolvedPage { collection: string; page: Record<string, unknown
|
|
|
14
22
|
* hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
|
|
15
23
|
* returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
|
|
16
24
|
* filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
|
|
17
|
-
* renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href.
|
|
25
|
+
* renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href. The GROUP
|
|
26
|
+
* itself is tagged too: a sibling that does not exist yet has no id to have been captured, so the group tag
|
|
27
|
+
* is the only edge a later CREATE can match.
|
|
18
28
|
*/
|
|
19
29
|
function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
|
|
20
30
|
if (c.def.mode !== 'multi' || !c.def.translatable) return []
|
|
21
31
|
const group = page.translationGroup
|
|
22
32
|
if (typeof group !== 'string' || !group) return []
|
|
33
|
+
captureRead(translationGroupTag(c.def.name, group))
|
|
23
34
|
const cols = getTableColumns(c.table) as Record<string, never>
|
|
24
35
|
// A status-less pageLike collection has no draft state — every sibling is "published".
|
|
25
36
|
const hasStatus = Object.hasOwn(cols, 'status')
|
|
@@ -54,13 +65,15 @@ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page
|
|
|
54
65
|
|
|
55
66
|
/**
|
|
56
67
|
* The first page-like record (across all collections, in registration order) whose `path` matches,
|
|
57
|
-
* populated at depth 1 — or null
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* page-like collections happen to share
|
|
68
|
+
* populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
|
|
69
|
+
* access-scoped `list()` so media/links populate exactly as a direct collection read would.
|
|
70
|
+
* `publishedOnly` defaults true: a static render (prerender / runtime publisher) and an anonymous live
|
|
71
|
+
* request must never see drafts. The authenticated-admin live preview passes false to surface a draft at
|
|
72
|
+
* its real URL. Registration order is the precedence rule when two page-like collections happen to share
|
|
73
|
+
* a path.
|
|
62
74
|
*/
|
|
63
|
-
export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true):
|
|
75
|
+
export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): PageResolution {
|
|
76
|
+
const failed: string[] = []
|
|
64
77
|
for (const c of collections) {
|
|
65
78
|
if (!c.def.pageLike) continue
|
|
66
79
|
// withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
|
|
@@ -73,14 +86,15 @@ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollect
|
|
|
73
86
|
// Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
|
|
74
87
|
// but never silently: an unread collection is indistinguishable from one with no matching page, and
|
|
75
88
|
// the publisher would write a 404 over every one of its routes.
|
|
89
|
+
failed.push(c.def.name)
|
|
76
90
|
console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
77
91
|
continue
|
|
78
92
|
}
|
|
79
93
|
const { data } = result
|
|
80
94
|
if (data.length) {
|
|
81
95
|
captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
|
|
82
|
-
return { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }
|
|
96
|
+
return { page: { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }, failed }
|
|
83
97
|
}
|
|
84
98
|
}
|
|
85
|
-
return null
|
|
99
|
+
return { page: null, failed }
|
|
86
100
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived tickets carrying the editor's UNSAVED form state to a real page render. The editor's own
|
|
6
|
+
* iframe gets unsaved content over postMessage, but an external tab has no parent window to talk to — so
|
|
7
|
+
* instead of saving (which would publish intent the user never expressed) it mints a ticket and opens
|
|
8
|
+
* `<url>?kestrel-preview-token=…`. Nothing is written to the DB; the ticket lives in this process only.
|
|
9
|
+
*
|
|
10
|
+
* The admin session is the actual gate (both endpoints are admin-only under the default-deny API guard).
|
|
11
|
+
* The owner binding on top is NOT per-session isolation today: Kestrel has one shared admin credential and
|
|
12
|
+
* `derivePrincipal` (access layer) never mints more than one admin identity, so every caller that reaches
|
|
13
|
+
* this store has already been narrowed by `requireAdmin` to the same principal, and `previewOwner()`
|
|
14
|
+
* resolves to the literal string `'admin'` every time — `t.owner === owner` cannot currently be false for
|
|
15
|
+
* an admin caller. The binding is kept because it is the seam that makes the check meaningful the moment
|
|
16
|
+
* (if ever) a per-user identity is added upstream; until then it costs nothing and documents the intent.
|
|
17
|
+
* Tickets stay readable until they expire — a preview tab may be reloaded — and the store bounds itself in
|
|
18
|
+
* both directions: a sweep on every mint, and a hard cap that evicts the oldest ticket.
|
|
19
|
+
*
|
|
20
|
+
* In-memory by design: previewing is a per-editor, per-minute affair, and a second server instance would
|
|
21
|
+
* simply re-mint. Nothing durable depends on it.
|
|
22
|
+
*/
|
|
23
|
+
export interface PreviewPayload {
|
|
24
|
+
collection: string
|
|
25
|
+
/** The record being previewed, or null for one that has never been saved. */
|
|
26
|
+
id: number | null
|
|
27
|
+
locale?: string
|
|
28
|
+
/** The editor's populated values — the same tree the live-preview bridge posts into the iframe. */
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PreviewTicket {
|
|
33
|
+
token: string
|
|
34
|
+
expiresAt: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreviewStore {
|
|
38
|
+
mint: (owner: string, payload: PreviewPayload) => PreviewTicket
|
|
39
|
+
read: (token: string, owner: string) => PreviewPayload | null
|
|
40
|
+
size: () => number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PreviewStoreOptions {
|
|
44
|
+
ttlMs?: number
|
|
45
|
+
max?: number
|
|
46
|
+
now?: () => number
|
|
47
|
+
randomToken?: () => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TTL_MS = 10 * 60 * 1000
|
|
51
|
+
const MAX_TICKETS = 32
|
|
52
|
+
|
|
53
|
+
export function createPreviewStore(opts: PreviewStoreOptions = {}): PreviewStore {
|
|
54
|
+
const ttlMs = opts.ttlMs ?? TTL_MS
|
|
55
|
+
const max = opts.max ?? MAX_TICKETS
|
|
56
|
+
const now = opts.now ?? Date.now
|
|
57
|
+
const randomToken = opts.randomToken ?? (() => randomBytes(24).toString('base64url'))
|
|
58
|
+
// Insertion-ordered, which is what makes "evict the oldest" a single `keys().next()`.
|
|
59
|
+
const tickets = new Map<string, { owner: string; payload: PreviewPayload; expiresAt: number }>()
|
|
60
|
+
|
|
61
|
+
function sweep(at: number): void {
|
|
62
|
+
for (const [token, t] of tickets) if (t.expiresAt <= at) tickets.delete(token)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
mint(owner, payload) {
|
|
67
|
+
const at = now()
|
|
68
|
+
sweep(at)
|
|
69
|
+
while (tickets.size >= max) tickets.delete(tickets.keys().next().value as string)
|
|
70
|
+
const token = randomToken()
|
|
71
|
+
const expiresAt = at + ttlMs
|
|
72
|
+
tickets.set(token, { owner, payload, expiresAt })
|
|
73
|
+
return { token, expiresAt }
|
|
74
|
+
},
|
|
75
|
+
read(token, owner) {
|
|
76
|
+
const t = tickets.get(token)
|
|
77
|
+
if (!t) return null
|
|
78
|
+
if (t.expiresAt <= now()) {
|
|
79
|
+
tickets.delete(token)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
// Bound to the minting owner rather than trusted on token possession alone — inert while every admin
|
|
83
|
+
// caller resolves to the same owner (see the module docstring), but the check a future per-user
|
|
84
|
+
// identity would need is already the one being run, not one that would need to be added later.
|
|
85
|
+
return t.owner === owner ? t.payload : null
|
|
86
|
+
},
|
|
87
|
+
size: () => tickets.size,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Who a ticket belongs to. In production this only ever runs after `requireAdmin(event)` has already
|
|
93
|
+
* thrown for anyone but the admin principal, and `derivePrincipal` (access layer) always gives that
|
|
94
|
+
* principal a fixed `userId: 'admin'` — so the first branch always wins and this always returns the
|
|
95
|
+
* literal `'admin'`. The `role` / `'anonymous'` fallbacks are unreached by any principal shape
|
|
96
|
+
* `derivePrincipal` produces today; kept as a defensive default rather than a non-null assertion, since
|
|
97
|
+
* this function has no way to enforce that invariant itself.
|
|
98
|
+
*/
|
|
99
|
+
export function previewOwner(event: H3Event): string {
|
|
100
|
+
const principal = event.context.principal as { userId?: string | null; role?: string } | undefined
|
|
101
|
+
return principal?.userId ?? principal?.role ?? 'anonymous'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The process-wide store the two `/api/preview` handlers share. */
|
|
105
|
+
let shared: PreviewStore | null = null
|
|
106
|
+
export function usePreviewStore(): PreviewStore {
|
|
107
|
+
shared ??= createPreviewStore()
|
|
108
|
+
return shared
|
|
109
|
+
}
|
|
@@ -27,6 +27,12 @@ export interface WriteClassification {
|
|
|
27
27
|
/** The page's own public route from the OLD row (pageLike + had a path), else null — the route whose
|
|
28
28
|
* static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
|
|
29
29
|
oldRoute: string | null
|
|
30
|
+
groupTag: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The data tag naming a translation group. `#` keeps it clear of the `<coll>:<id>` record namespace. */
|
|
34
|
+
export function translationGroupTag(coll: string, group: string): string {
|
|
35
|
+
return `${coll}#group:${group}`
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
|
|
@@ -71,13 +77,43 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
|
|
|
71
77
|
// change (path unchanged) still moves the route — the old-locale file must be pruned.
|
|
72
78
|
const pathChanged = pageLike && status === 'updated' && oldRoute !== selfRoute
|
|
73
79
|
|
|
74
|
-
|
|
80
|
+
// `update` refuses to move a row between groups, so the surviving row's group is the group either way.
|
|
81
|
+
const group = row?.translationGroup
|
|
82
|
+
const groupTag = typeof group === 'string' && group ? translationGroupTag(def.name, group) : null
|
|
83
|
+
|
|
84
|
+
return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What the write listener enqueues for a content write. `publishOnSave` (`output.publishOnSave`) is the
|
|
89
|
+
* documented way back to the pre-2.0 model where a save WAS a publish: with it on, a write plans exactly
|
|
90
|
+
* what it always did. Off (the default), only removals pass — see `planSaveInvalidation`.
|
|
91
|
+
*/
|
|
92
|
+
export function planWrite(ev: WriteClassification, publishOnSave: boolean): Invalidation {
|
|
93
|
+
return publishOnSave ? planInvalidation(ev) : planSaveInvalidation(ev)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a plain SAVE may do to the static output. Saving persists to the DB; writing a page's file is the
|
|
98
|
+
* explicit publish action's job (`planInvalidation`, driven by `POST /api/publish`), so a save renders
|
|
99
|
+
* nothing — the live site keeps serving the last published version while the editor works on the next one.
|
|
100
|
+
*
|
|
101
|
+
* REMOVAL is the asymmetry, and it is deliberate: an unpublished or deleted record must not keep a live
|
|
102
|
+
* page, so those two branches act immediately. Their referrer/listing re-renders come along, because a
|
|
103
|
+
* baked link to a page that just went offline is stale the moment it goes — the same "availability" rule
|
|
104
|
+
* `planInvalidation` documents, minus everything that would put NEW content on the live site.
|
|
105
|
+
*/
|
|
106
|
+
export function planSaveInvalidation(ev: WriteClassification): Invalidation {
|
|
107
|
+
const removal = ev.status === 'deleted' || (ev.statusChanged && !ev.isPublished)
|
|
108
|
+
return removal ? planInvalidation(ev) : { type: 'noop' }
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
/**
|
|
78
|
-
* Decide what a write invalidates, per the maintainer-agreed model.
|
|
112
|
+
* Decide what a write invalidates, per the maintainer-agreed model. Three notions of "dependent":
|
|
79
113
|
* - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
|
|
80
114
|
* - EXPLICIT REFERRERS — pages that LINK/EMBED/relate-to a specific record → captured as `<coll>:<id>`.
|
|
115
|
+
* - TRANSLATION SIBLINGS — every member of a group bakes the group's hreflang set → captured as the
|
|
116
|
+
* group tag, the only edge that reaches members which rendered before this row existed.
|
|
81
117
|
*
|
|
82
118
|
* Two principles drive the split:
|
|
83
119
|
* 1. FRESHENING (content or path changed) re-renders BOTH listings and explicit referrers (`[coll, coll:id]`)
|
|
@@ -94,7 +130,10 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
|
|
|
94
130
|
export function planInvalidation(ev: WriteClassification): Invalidation {
|
|
95
131
|
const coll = ev.collection
|
|
96
132
|
const recordTag = ev.id != null ? `${coll}:${ev.id}` : null
|
|
97
|
-
|
|
133
|
+
// Unlike recordTag (dropped on create — no referrer can target a brand-new id), groupTag is included even
|
|
134
|
+
// there: a new sibling still changes every existing member's hreflang set.
|
|
135
|
+
const groupTags = ev.groupTag ? [ev.groupTag] : []
|
|
136
|
+
const tags = recordTag ? [coll, recordTag, ...groupTags] : [coll, ...groupTags]
|
|
98
137
|
const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
|
|
99
138
|
|
|
100
139
|
// DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
|
|
@@ -108,7 +147,7 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
|
|
|
108
147
|
// record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
|
|
109
148
|
if (ev.status === 'created') {
|
|
110
149
|
if (!ev.isPublished) return { type: 'noop' }
|
|
111
|
-
return { type: 'tags', tags: [coll], render: selfRender, prune: [] }
|
|
150
|
+
return { type: 'tags', tags: [coll, ...groupTags], render: selfRender, prune: [] }
|
|
112
151
|
}
|
|
113
152
|
|
|
114
153
|
// UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
|