@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CollectionDef, FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import { getBlock } from '../../../fields/server/utils/defineBlock'
|
|
4
|
+
import { resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
|
|
5
|
+
import { escapeMarkdownBlock, richtextToMarkdown } from './richtext-markdown'
|
|
6
|
+
|
|
7
|
+
/** One published page rendered in full: what `llms.txt` links to, plus the body it links to. */
|
|
8
|
+
export interface LlmsFullPage {
|
|
9
|
+
title: string
|
|
10
|
+
url: string
|
|
11
|
+
description?: string
|
|
12
|
+
/** Markdown; already heading-shifted to nest under the page's own `###`. */
|
|
13
|
+
body: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A titled group of pages — one `## heading` block, mirroring `llms.txt`'s sections. */
|
|
17
|
+
export interface LlmsFullSection {
|
|
18
|
+
heading: string
|
|
19
|
+
pages: LlmsFullPage[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pages sit at `###`, so a body's own `<h1>` has to start at `####` to keep the outline valid. */
|
|
23
|
+
export const LLMS_FULL_HEADING_OFFSET = 3
|
|
24
|
+
|
|
25
|
+
// Same defence as `llms.txt`: a title, heading or description is editor-authored text, and a newline in
|
|
26
|
+
// it would forge a second document line — here, a heading that invents a page.
|
|
27
|
+
const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render an `llms-full.txt` (the long form of https://llmstxt.org): the site header, then every
|
|
31
|
+
* published, indexable page's full Markdown body under its own `###` heading, grouped by collection.
|
|
32
|
+
* Where `llms.txt` is a map, this is the territory — one document an answer engine can retrieve without
|
|
33
|
+
* crawling. Pure + deterministic; the route feeds it published, indexable pages only.
|
|
34
|
+
*/
|
|
35
|
+
export function buildLlmsFullTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsFullSection[] }): string {
|
|
36
|
+
const blocks: string[] = [`# ${oneLine(opts.siteName)}`]
|
|
37
|
+
// A description is a whole block of its own, so flattening its newlines is not enough — a leading `##`
|
|
38
|
+
// would still open a section beside the ones this generator writes.
|
|
39
|
+
if (opts.siteDescription) blocks.push(`> ${escapeMarkdownBlock(oneLine(opts.siteDescription))}`)
|
|
40
|
+
for (const section of opts.sections) {
|
|
41
|
+
if (!section.pages.length) continue
|
|
42
|
+
blocks.push(`## ${oneLine(section.heading)}`)
|
|
43
|
+
for (const page of section.pages) {
|
|
44
|
+
blocks.push(`### ${oneLine(page.title)}`)
|
|
45
|
+
blocks.push(`Source: ${page.url}`)
|
|
46
|
+
if (page.description) blocks.push(escapeMarkdownBlock(oneLine(page.description)))
|
|
47
|
+
if (page.body) blocks.push(page.body)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return `${blocks.join('\n\n')}\n`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RecordMarkdownOptions {
|
|
54
|
+
/** Push every body heading down by this many levels — see `LLMS_FULL_HEADING_OFFSET`. */
|
|
55
|
+
headingOffset?: number
|
|
56
|
+
/** Top-level field keys the caller already rendered (the title it used as the heading). */
|
|
57
|
+
skipFields?: string[]
|
|
58
|
+
/** `kestrel:<collection>:<id>` → absolute URL, or null for a target that must not be linked (a draft,
|
|
59
|
+
* a noindexed page, a non-routable record). Unresolved links keep their text and lose the anchor. */
|
|
60
|
+
resolveLink?: (collection: string, id: number) => string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The prose in one flat value bag (record columns | block props | a repeater entry), in field order.
|
|
64
|
+
* Only `text` and `richtext` carry prose; `repeater` recurses. Everything else is data, not content —
|
|
65
|
+
* a consumer's own field type is skipped rather than guessed at. */
|
|
66
|
+
function bagMarkdown(fields: Record<string, FieldDef>, bag: Record<string, unknown>, opts: RecordMarkdownOptions, skip?: Set<string>): string[] {
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
69
|
+
if (skip?.has(key)) continue
|
|
70
|
+
const value = bag[key]
|
|
71
|
+
if (field.type === 'text') {
|
|
72
|
+
// `fieldIs` rather than a switch: the open consumer-type arm makes `type` a non-discriminant, so a
|
|
73
|
+
// switch would not narrow (the same reason `extract-refs` walks this way).
|
|
74
|
+
// A text field is raw editor input with no markup to constrain it, so it is the one value that
|
|
75
|
+
// reaches the document verbatim — it needs the same block-marker escaping converted prose gets.
|
|
76
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
77
|
+
if (text) out.push(escapeMarkdownBlock(text))
|
|
78
|
+
} else if (field.type === 'richtext') {
|
|
79
|
+
// Always run the marker rewrite, resolver or not: an unrewritten `kestrel:<coll>:<id>` href would
|
|
80
|
+
// ship a private storage token as a link target. With no resolver every marker declines to `#`,
|
|
81
|
+
// which the converter renders as plain text.
|
|
82
|
+
const html = typeof value === 'string' ? resolveRichtextLinks(value, opts.resolveLink ?? (() => null)) : ''
|
|
83
|
+
const md = richtextToMarkdown(html, { headingOffset: opts.headingOffset })
|
|
84
|
+
if (md) out.push(md)
|
|
85
|
+
} else if (fieldIs(field, 'repeater')) {
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
for (const entry of value) {
|
|
88
|
+
if (entry && typeof entry === 'object') out.push(...bagMarkdown(field.options.fields, entry as Record<string, unknown>, opts))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The prose in a block `content` array, recursing slots — the same walk `extract-refs` does for refs. */
|
|
97
|
+
function blockMarkdown(content: unknown, opts: RecordMarkdownOptions): string[] {
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
const walk = (nodes: unknown): void => {
|
|
100
|
+
if (!Array.isArray(nodes)) return
|
|
101
|
+
for (const node of nodes) {
|
|
102
|
+
if (!node || typeof node !== 'object') continue
|
|
103
|
+
const n = node as { type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
104
|
+
// An unregistered block type has no field defs, so there is no way to tell its prose from its
|
|
105
|
+
// configuration — emitting every string prop would put layout tokens into the document.
|
|
106
|
+
const def = n.type ? getBlock(n.type) : undefined
|
|
107
|
+
if (def && n.props && typeof n.props === 'object') out.push(...bagMarkdown(def.fields, n.props, opts))
|
|
108
|
+
if (n.slots && typeof n.slots === 'object') for (const slotNodes of Object.values(n.slots)) walk(slotNodes)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
walk(content)
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A record's readable content as Markdown: its own text/richtext fields (in declaration order), then its
|
|
117
|
+
* block tree if the collection enables blocks. Pure apart from the block registry — the same dependency
|
|
118
|
+
* `extractRecordRefs` has, and for the same reason: a block's prose is only identifiable through its
|
|
119
|
+
* registered field defs.
|
|
120
|
+
*/
|
|
121
|
+
export function recordMarkdown(def: CollectionDef, row: Record<string, unknown>, opts: RecordMarkdownOptions = {}): string {
|
|
122
|
+
const blocks = bagMarkdown(def.fields, row, opts, opts.skipFields?.length ? new Set(opts.skipFields) : undefined)
|
|
123
|
+
if (def.blocks?.enabled) blocks.push(...blockMarkdown(row.content, opts))
|
|
124
|
+
return blocks.join('\n\n')
|
|
125
|
+
}
|
|
@@ -24,6 +24,19 @@ const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
|
24
24
|
// Additionally escape the brackets that would otherwise break the `[title](url)` markdown link.
|
|
25
25
|
const linkText = (s: string): string => oneLine(s).replace(/[[\]]/g, '\\$&')
|
|
26
26
|
|
|
27
|
+
/** The `## heading` for a collection's section: its plural label in the primary locale, else any locale's,
|
|
28
|
+
* else its capitalised name. Shared by `llms.txt` and `llms-full.txt` so the two never disagree. */
|
|
29
|
+
export function collectionHeading(def: { name: string; label?: { plural?: unknown } }, primaryLocale: string): string {
|
|
30
|
+
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
|
|
31
|
+
const plural = def.label?.plural
|
|
32
|
+
if (typeof plural === 'string') return plural
|
|
33
|
+
if (plural && typeof plural === 'object') {
|
|
34
|
+
const byLocale = plural as Record<string, string>
|
|
35
|
+
return byLocale[primaryLocale] ?? Object.values(byLocale)[0] ?? cap(def.name)
|
|
36
|
+
}
|
|
37
|
+
return cap(def.name)
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
export function buildLlmsTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsSection[] }): string {
|
|
28
41
|
const lines: string[] = [`# ${oneLine(opts.siteName)}`]
|
|
29
42
|
if (opts.siteDescription) lines.push('', `> ${oneLine(opts.siteDescription)}`)
|
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
import { asc, eq, getTableColumns } from 'drizzle-orm'
|
|
1
|
+
import { and, asc, eq, getTableColumns } from 'drizzle-orm'
|
|
2
2
|
import { list } from '../../../core/server/utils/crud'
|
|
3
3
|
import { captureRead } from '../../../core/server/utils/read-capture'
|
|
4
|
-
import { translationGroupTag } from './publish/invalidation'
|
|
4
|
+
import { pagePathTag, translationGroupTag } from './publish/invalidation'
|
|
5
5
|
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
6
6
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
7
7
|
|
|
8
8
|
export interface PageAlternate { locale: string; path: string }
|
|
9
|
-
|
|
9
|
+
/** One published page above this one in the path hierarchy — a real, linkable breadcrumb step. */
|
|
10
|
+
export interface PageAncestor {
|
|
11
|
+
path: string
|
|
12
|
+
title?: string
|
|
13
|
+
/** The ancestor's OWN locale, so the crumb builds the URL that was actually published. Absent for a
|
|
14
|
+
* non-translatable collection, whose rows have a single unprefixed (primary-locale) URL — prefixing
|
|
15
|
+
* one of those with the reader's locale would link a page `nuxt generate` never wrote. */
|
|
16
|
+
locale?: string
|
|
17
|
+
}
|
|
18
|
+
export interface ResolvedPage {
|
|
19
|
+
collection: string
|
|
20
|
+
page: Record<string, unknown>
|
|
21
|
+
alternates: PageAlternate[]
|
|
22
|
+
ancestors: PageAncestor[]
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
/** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
|
|
12
26
|
* was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
|
|
@@ -63,6 +77,92 @@ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page
|
|
|
63
77
|
return alternates.length >= 2 ? alternates : []
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
/** Every strict path prefix of a page's path, outermost first: `/blog/hello` → `['/', '/blog']`. The
|
|
81
|
+
* site root is an ancestor of everything except itself. */
|
|
82
|
+
function ancestorPaths(path: string): string[] {
|
|
83
|
+
if (path === '/' || !path.startsWith('/')) return []
|
|
84
|
+
const segments = path.split('/').filter(Boolean)
|
|
85
|
+
const paths = ['/']
|
|
86
|
+
for (let i = 1; i < segments.length; i += 1) paths.push(`/${segments.slice(0, i).join('/')}`)
|
|
87
|
+
return paths
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The page's breadcrumb trail: the published, INDEXABLE page at each ancestor path, in the page's own
|
|
92
|
+
* locale. A path segment with no page behind it is SKIPPED rather than synthesised — schema.org
|
|
93
|
+
* breadcrumb items are links, and a trail that points at a 404 is a worse signal than a shorter trail.
|
|
94
|
+
* The filters mirror the sitemap's, so a breadcrumb never advertises what the sitemap withholds.
|
|
95
|
+
*
|
|
96
|
+
* TWO dependency edges are captured per ancestor, because neither covers the other:
|
|
97
|
+
* - the PATH (`pagePathTag`), for every path looked in — including those with no page at all and those
|
|
98
|
+
* whose lookup threw. Kestrel has no parent/child relation between pages, so an ancestor is a
|
|
99
|
+
* path-prefix match, and a page CREATED at `/blog` after `/blog/hello` was published has no id anything
|
|
100
|
+
* could have captured beforehand. A path is knowable before its page exists.
|
|
101
|
+
* - the RECORD (`<coll>:<id>`) of whatever row sits there, captured before the filters below so a
|
|
102
|
+
* currently-invisible one counts too — the edge that carries a rename, a `noindex` or an unpublish,
|
|
103
|
+
* none of which `crumbTags` can see (see the comment at that capture).
|
|
104
|
+
* `publishedAlternates` pairs a group tag with a record tag for exactly the same reason.
|
|
105
|
+
*/
|
|
106
|
+
function publishedAncestors(
|
|
107
|
+
db: BetterSQLite3Database,
|
|
108
|
+
collections: BuiltCollection[],
|
|
109
|
+
path: string,
|
|
110
|
+
locale: string | undefined,
|
|
111
|
+
publishedOnly: boolean,
|
|
112
|
+
): PageAncestor[] {
|
|
113
|
+
const out: PageAncestor[] = []
|
|
114
|
+
for (const ancestorPath of ancestorPaths(path)) {
|
|
115
|
+
// Before the scan, so the edge exists whatever the scan finds — no page, a draft, a noindexed one, or
|
|
116
|
+
// a collection whose table could not be read. A write at this path emits the same tag (`crumbTags` in
|
|
117
|
+
// classifyWrite), which is what repairs this page's trail.
|
|
118
|
+
captureRead(pagePathTag(ancestorPath))
|
|
119
|
+
for (const c of collections) {
|
|
120
|
+
if (!c.def.pageLike) continue
|
|
121
|
+
// Project only what a crumb needs — never the row: this runs once per path segment per render, and
|
|
122
|
+
// pulling every page's block JSON to read a title would multiply the cost of a full publish.
|
|
123
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
124
|
+
const hasStatus = Object.hasOwn(cols, 'status')
|
|
125
|
+
const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
|
|
126
|
+
if (hasStatus) proj.status = cols.status
|
|
127
|
+
if (c.def.seo) proj.seo = cols.seo
|
|
128
|
+
if (c.def.translatable) proj.locale = cols.locale
|
|
129
|
+
if (Object.hasOwn(cols, 'title')) proj.title = cols.title
|
|
130
|
+
const scoped = c.def.translatable && locale ? and(eq(cols.path, ancestorPath), eq(cols.locale, locale)) : eq(cols.path, ancestorPath)
|
|
131
|
+
let row: { id: number; status?: string; seo?: { title?: string; noindex?: boolean } | null; title?: unknown; locale?: string } | undefined
|
|
132
|
+
try {
|
|
133
|
+
// Order by locale so an unscoped lookup (no locale requested) still answers deterministically
|
|
134
|
+
// rather than with whatever row the table happens to yield first.
|
|
135
|
+
const q = db.select(proj as never).from(c.table).where(scoped)
|
|
136
|
+
row = (c.def.translatable ? q.orderBy(asc(cols.locale)) : q).limit(1).get() as typeof row
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Same isolation rule as the main scan: a drifted collection loses its crumbs, loudly, instead of
|
|
139
|
+
// failing the whole render.
|
|
140
|
+
console.error(`[kestrel] resolvePage: skipped ancestor lookup in ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (!row) continue
|
|
144
|
+
// BOTH edges are needed, and neither subsumes the other — the same pairing `publishedAlternates`
|
|
145
|
+
// uses. The record tag covers every change to the row sitting here, INCLUDING the ones that make it
|
|
146
|
+
// stop being a crumb: the explicit publish action classifies its write as `before === after` (the
|
|
147
|
+
// record's current state), so a rename, a `noindex` or an unpublish is invisible in `crumbTags`,
|
|
148
|
+
// which only ever names where the record is NOW. `<coll>:<id>` is in that write's tag list whatever
|
|
149
|
+
// the row looks like, so it is what repairs the trail. Captured BEFORE the filters below, so a
|
|
150
|
+
// draft/noindexed/shadowing row — one that is currently NOT the crumb — still carries the edge that
|
|
151
|
+
// fires when it goes away.
|
|
152
|
+
captureRead(c.def.name, row.id)
|
|
153
|
+
if (publishedOnly && hasStatus && row.status !== 'published') break
|
|
154
|
+
if (row.seo?.noindex) break
|
|
155
|
+
const ancestor: PageAncestor = { path: ancestorPath }
|
|
156
|
+
const title = row.seo?.title || (typeof row.title === 'string' ? row.title : undefined)
|
|
157
|
+
if (title) ancestor.title = title
|
|
158
|
+
if (c.def.translatable && row.locale) ancestor.locale = row.locale
|
|
159
|
+
out.push(ancestor)
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
66
166
|
/**
|
|
67
167
|
* The first page-like record (across all collections, in registration order) whose `path` matches,
|
|
68
168
|
* populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
|
|
@@ -93,7 +193,15 @@ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollect
|
|
|
93
193
|
const { data } = result
|
|
94
194
|
if (data.length) {
|
|
95
195
|
captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
|
|
96
|
-
return {
|
|
196
|
+
return {
|
|
197
|
+
page: {
|
|
198
|
+
collection: c.def.name,
|
|
199
|
+
page: data[0]!,
|
|
200
|
+
alternates: publishedAlternates(db, c, data[0]!),
|
|
201
|
+
ancestors: publishedAncestors(db, collections, path, locale, publishedOnly),
|
|
202
|
+
},
|
|
203
|
+
failed,
|
|
204
|
+
}
|
|
97
205
|
}
|
|
98
206
|
}
|
|
99
207
|
return { page: null, failed }
|
|
@@ -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
|
+
}
|
|
@@ -28,6 +28,9 @@ export interface WriteClassification {
|
|
|
28
28
|
* static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
|
|
29
29
|
oldRoute: string | null
|
|
30
30
|
groupTag: string | null
|
|
31
|
+
/** Page-path tags for the breadcrumb edge — this record's own path before and/or after the write (both,
|
|
32
|
+
* on a rename); empty when it is nobody's crumb step on either side. See `pagePathTag`. */
|
|
33
|
+
crumbTags: string[]
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** The data tag naming a translation group. `#` keeps it clear of the `<coll>:<id>` record namespace. */
|
|
@@ -35,6 +38,27 @@ export function translationGroupTag(coll: string, group: string): string {
|
|
|
35
38
|
return `${coll}#group:${group}`
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The data tag naming a page-like PATH rather than a record — the edge a DESCENDANT's breadcrumb hangs on.
|
|
43
|
+
*
|
|
44
|
+
* It has to be the path, because Kestrel has no parent/child relation between pages: `path` is a plain
|
|
45
|
+
* column, a slug is flat unless an editor types slashes into it, and "descendant" is nothing but a
|
|
46
|
+
* path-prefix match. So the case that matters most — a page CREATED at `/blog` after `/blog/hello` was
|
|
47
|
+
* already published — has no record id that anything could have captured beforehand. A path, by contrast,
|
|
48
|
+
* is knowable before its page exists, so a descendant subscribes to the path it looked in.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately locale-LESS: a non-translatable record has no locale to name, and a descendant looking up
|
|
51
|
+
* `/blog` in its own locale must still be reached when the page that appears there is a locale-less one.
|
|
52
|
+
* It therefore over-approximates across locales (an `en` `/blog` write also re-renders a `de` descendant
|
|
53
|
+
* of the same spelling) — extra renders, never a stale page.
|
|
54
|
+
*
|
|
55
|
+
* The leading `#` keeps it clear of the `<coll>` and `<coll>:<id>` namespaces (a collection name never
|
|
56
|
+
* starts with one).
|
|
57
|
+
*/
|
|
58
|
+
export function pagePathTag(path: string): string {
|
|
59
|
+
return `#path:${path}`
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
/** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
|
|
39
63
|
export type Invalidation =
|
|
40
64
|
| { type: 'full' }
|
|
@@ -59,6 +83,18 @@ function isPublic(def: WriteCollection, row: Row): boolean {
|
|
|
59
83
|
return !!row && row.status === 'published'
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The path this record contributes as a breadcrumb step, or null when it contributes none. Mirrors
|
|
88
|
+
* `publishedAncestors` exactly — that lookup skips anything unpublished, `noindex`ed or path-less, so a
|
|
89
|
+
* record in one of those states is in nobody's trail and its writes owe nobody a re-render.
|
|
90
|
+
*/
|
|
91
|
+
function crumbPath(def: WriteCollection, row: Row): string | null {
|
|
92
|
+
if (!def.pageLike || !row) return null
|
|
93
|
+
if (!isPublic(def, row)) return null
|
|
94
|
+
if ((row.seo as { noindex?: boolean } | null | undefined)?.noindex) return null
|
|
95
|
+
return typeof row.path === 'string' && row.path ? row.path : null
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
/** Reduce a before/after row pair (either may be null) to a `WriteClassification`. Pure (routes via the shared `pageRowHref`). */
|
|
63
99
|
export function classifyWrite(def: WriteCollection, before: Row, after: Row, primaryLocale: string, prefixPrimary = false): WriteClassification {
|
|
64
100
|
const status: WriteClassification['status'] = before === null ? 'created' : after === null ? 'deleted' : 'updated'
|
|
@@ -81,7 +117,38 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
|
|
|
81
117
|
const group = row?.translationGroup
|
|
82
118
|
const groupTag = typeof group === 'string' && group ? translationGroupTag(def.name, group) : null
|
|
83
119
|
|
|
84
|
-
|
|
120
|
+
// Both sides, so a rename repairs the descendants of the OLD path as well as the new one. Deliberately
|
|
121
|
+
// NOT narrowed to "did the crumb's label actually change": the explicit publish action classifies its
|
|
122
|
+
// write as before === after (`publish.post.ts` — a re-render of the record's current state), so a diff
|
|
123
|
+
// would see nothing on the very publish that makes the crumb appear.
|
|
124
|
+
const crumbTags = [...new Set([crumbPath(def, before), crumbPath(def, after)].filter((p): p is string => p !== null))]
|
|
125
|
+
.map(pagePathTag)
|
|
126
|
+
|
|
127
|
+
return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag, crumbTags }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What the write listener enqueues for a content write. `publishOnSave` (`output.publishOnSave`) is the
|
|
132
|
+
* documented way back to the pre-2.0 model where a save WAS a publish: with it on, a write plans exactly
|
|
133
|
+
* what it always did. Off (the default), only removals pass — see `planSaveInvalidation`.
|
|
134
|
+
*/
|
|
135
|
+
export function planWrite(ev: WriteClassification, publishOnSave: boolean): Invalidation {
|
|
136
|
+
return publishOnSave ? planInvalidation(ev) : planSaveInvalidation(ev)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a plain SAVE may do to the static output. Saving persists to the DB; writing a page's file is the
|
|
141
|
+
* explicit publish action's job (`planInvalidation`, driven by `POST /api/publish`), so a save renders
|
|
142
|
+
* nothing — the live site keeps serving the last published version while the editor works on the next one.
|
|
143
|
+
*
|
|
144
|
+
* REMOVAL is the asymmetry, and it is deliberate: an unpublished or deleted record must not keep a live
|
|
145
|
+
* page, so those two branches act immediately. Their referrer/listing re-renders come along, because a
|
|
146
|
+
* baked link to a page that just went offline is stale the moment it goes — the same "availability" rule
|
|
147
|
+
* `planInvalidation` documents, minus everything that would put NEW content on the live site.
|
|
148
|
+
*/
|
|
149
|
+
export function planSaveInvalidation(ev: WriteClassification): Invalidation {
|
|
150
|
+
const removal = ev.status === 'deleted' || (ev.statusChanged && !ev.isPublished)
|
|
151
|
+
return removal ? planInvalidation(ev) : { type: 'noop' }
|
|
85
152
|
}
|
|
86
153
|
|
|
87
154
|
/**
|
|
@@ -109,7 +176,9 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
|
|
|
109
176
|
// Unlike recordTag (dropped on create — no referrer can target a brand-new id), groupTag is included even
|
|
110
177
|
// there: a new sibling still changes every existing member's hreflang set.
|
|
111
178
|
const groupTags = ev.groupTag ? [ev.groupTag] : []
|
|
112
|
-
|
|
179
|
+
// Like groupTag, the crumb tags ride EVERY branch including create — a page appearing at an ancestor
|
|
180
|
+
// path is exactly what a descendant's breadcrumb was waiting for.
|
|
181
|
+
const tags = recordTag ? [coll, recordTag, ...groupTags, ...ev.crumbTags] : [coll, ...groupTags, ...ev.crumbTags]
|
|
113
182
|
const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
|
|
114
183
|
|
|
115
184
|
// DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
|
|
@@ -123,7 +192,7 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
|
|
|
123
192
|
// record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
|
|
124
193
|
if (ev.status === 'created') {
|
|
125
194
|
if (!ev.isPublished) return { type: 'noop' }
|
|
126
|
-
return { type: 'tags', tags: [coll, ...groupTags], render: selfRender, prune: [] }
|
|
195
|
+
return { type: 'tags', tags: [coll, ...groupTags, ...ev.crumbTags], render: selfRender, prune: [] }
|
|
127
196
|
}
|
|
128
197
|
|
|
129
198
|
// UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Saved but not published": the state a deferred-publish model needs to name. Saving writes the DB,
|
|
3
|
+
* publishing writes the static file, so the two stamps drift apart on purpose — a record edited after its
|
|
4
|
+
* page was last published keeps serving the published version until someone publishes again.
|
|
5
|
+
*
|
|
6
|
+
* The tolerance is not cosmetic: `publish_status.updated_at` is stored in whole seconds while a record's
|
|
7
|
+
* `updatedAt` is milliseconds, so the publish that directly followed a save can carry a stamp up to a
|
|
8
|
+
* second BEHIND it. Without the slack every freshly published page would report unpublished changes.
|
|
9
|
+
*/
|
|
10
|
+
const TOLERANCE_MS = 1000
|
|
11
|
+
|
|
12
|
+
export function hasPendingChanges(savedAtMs: number | null | undefined, publishedAtMs: number | null | undefined, toleranceMs = TOLERANCE_MS): boolean {
|
|
13
|
+
// Never published (no status row) → nothing to protect: there is no older artifact this edit could
|
|
14
|
+
// overtake, so the route is a normal render candidate, not a pending change.
|
|
15
|
+
if (savedAtMs == null || publishedAtMs == null) return false
|
|
16
|
+
return savedAtMs > publishedAtMs + toleranceMs
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The subset of `savedAt` routes whose record moved on after the route's last publish. Pure. */
|
|
20
|
+
export function pendingRoutes(savedAt: Map<string, number>, publishedAt: Map<string, number>): string[] {
|
|
21
|
+
const out: string[] = []
|
|
22
|
+
for (const [route, saved] of savedAt) {
|
|
23
|
+
if (hasPendingChanges(saved, publishedAt.get(route) ?? null)) out.push(route)
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeldRoutes {
|
|
29
|
+
/** Routes to leave un-rendered: their record is serving an older published version somewhere. */
|
|
30
|
+
hold: Set<string>
|
|
31
|
+
/** Previously-published routes that are still the live artifact of a held record — never prune these. */
|
|
32
|
+
keep: Set<string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Withholding by RECORD rather than by route string, which is what a rename needs. `pendingRoutes` compares
|
|
37
|
+
* a route against its own publish stamp, so a renamed record — whose new route has no stamp at all — falls
|
|
38
|
+
* through the "never published, nothing to protect" carve-out: the unpublished rename gets rendered and the
|
|
39
|
+
* old route, still the live one, is left looking abandoned to the prune. The carve-out is about a FIRST
|
|
40
|
+
* deploy having no older version; a rename has one, at the previous route.
|
|
41
|
+
*
|
|
42
|
+
* So a record's prior published routes are consulted too: if the record has moved on since the newest of
|
|
43
|
+
* them, the new route is held back and those prior routes are protected from the prune. A record with no
|
|
44
|
+
* prior published route keeps the carve-out — otherwise a first deploy would produce an empty site.
|
|
45
|
+
*
|
|
46
|
+
* Pure: `routesForTag` is the deps index's lookup, passed in.
|
|
47
|
+
*/
|
|
48
|
+
export function heldRoutes(
|
|
49
|
+
savedAt: Map<string, number>,
|
|
50
|
+
publishedAt: Map<string, number>,
|
|
51
|
+
recordTag: Map<string, string>,
|
|
52
|
+
routesForTag: (tag: string) => Iterable<string>,
|
|
53
|
+
toleranceMs = TOLERANCE_MS,
|
|
54
|
+
): HeldRoutes {
|
|
55
|
+
const hold = new Set<string>()
|
|
56
|
+
const keep = new Set<string>()
|
|
57
|
+
for (const [route, saved] of savedAt) {
|
|
58
|
+
const own = publishedAt.get(route) ?? null
|
|
59
|
+
if (own != null) {
|
|
60
|
+
if (hasPendingChanges(saved, own, toleranceMs)) hold.add(route)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
// No stamp of its own: either genuinely never published, or published under a previous route.
|
|
64
|
+
const tag = recordTag.get(route)
|
|
65
|
+
if (!tag) continue
|
|
66
|
+
const priors = [...routesForTag(tag)].filter((r) => r !== route && publishedAt.has(r))
|
|
67
|
+
if (!priors.length) continue // first publish of this record — nothing to protect
|
|
68
|
+
const newest = Math.max(...priors.map((r) => publishedAt.get(r)!))
|
|
69
|
+
if (!hasPendingChanges(saved, newest, toleranceMs)) continue
|
|
70
|
+
hold.add(route)
|
|
71
|
+
for (const prior of priors) keep.add(prior)
|
|
72
|
+
}
|
|
73
|
+
return { hold, keep }
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PublishQueue } from './queue'
|
|
2
|
+
import type { DepsStore } from './deps'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The process-wide handle on the running publish machinery. The `zz.publish` Nitro plugin owns the queue
|
|
6
|
+
* and the deps index (it builds the output driver and does the logging), but the explicit publish action —
|
|
7
|
+
* `POST /api/publish` — has to reach both from inside a request: the queue to enqueue the run, the deps
|
|
8
|
+
* index to find the routes this record was baked into. Mirrors core's `write-events` registry: module
|
|
9
|
+
* state, set once at boot.
|
|
10
|
+
*
|
|
11
|
+
* `null` wherever the runtime publisher does not run at all (dev, or `output.auto` off). That is not an
|
|
12
|
+
* error — it is what the endpoint reports back so the editor can say "nothing is generated here" instead
|
|
13
|
+
* of pretending a publish happened.
|
|
14
|
+
*/
|
|
15
|
+
export interface PublishRuntime {
|
|
16
|
+
queue: PublishQueue
|
|
17
|
+
deps: DepsStore
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let runtime: PublishRuntime | null = null
|
|
21
|
+
|
|
22
|
+
export function setPublishRuntime(next: PublishRuntime | null): void {
|
|
23
|
+
runtime = next
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function usePublishRuntime(): PublishRuntime | null {
|
|
27
|
+
return runtime
|
|
28
|
+
}
|
|
@@ -43,6 +43,24 @@ export function renderOutcome(status: number, hasBody: boolean): 'success' | 'er
|
|
|
43
43
|
return 'skip'
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Every route's last successful-or-failed publish time, in ms — the "last published" half of the
|
|
48
|
+
* saved-vs-published comparison a deferred publish needs. Same missing-table resilience as the writers:
|
|
49
|
+
* an unmigrated deploy yields an empty map, which reads as "nothing was ever published here" and so
|
|
50
|
+
* holds nothing back.
|
|
51
|
+
*/
|
|
52
|
+
export function lastPublishedAt(db: BetterSQLite3Database): Map<string, number> {
|
|
53
|
+
const out = new Map<string, number>()
|
|
54
|
+
try {
|
|
55
|
+
for (const row of db.select({ route: publishStatus.route, updatedAt: publishStatus.updatedAt }).from(publishStatus).all()) {
|
|
56
|
+
if (row.updatedAt instanceof Date) out.set(row.route, row.updatedAt.getTime())
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn('[kestrel] could not read publish status:', (error as Error).message)
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/** Clear a route's status row — its static file was pruned (unpublish / delete / slug change), so it is no
|
|
47
65
|
* longer live. Idempotent; same missing-table resilience as `recordPublishStatus`. */
|
|
48
66
|
export function clearPublishStatus(db: BetterSQLite3Database, route: string): void {
|