@michaelthielemann/kestrel 1.7.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +23 -12
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/admin/app/components/BlocksBody.vue +2 -1
  4. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  5. package/layers/admin/app/components/CollectionList.vue +13 -5
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/PageFields.vue +5 -0
  8. package/layers/admin/app/components/SeoFields.vue +43 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +15 -6
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/edit-form.ts +9 -2
  17. package/layers/admin/app/utils/editor-expose.ts +8 -0
  18. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  19. package/layers/core/modules/kestrel/index.ts +12 -0
  20. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  21. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  22. package/layers/core/server/schema/introspect.ts +1 -1
  23. package/layers/core/server/schema/sync.ts +1 -1
  24. package/layers/core/server/utils/collection-types.ts +4 -3
  25. package/layers/core/server/utils/crud.ts +5 -4
  26. package/layers/core/server/utils/defineCollection.ts +8 -1
  27. package/layers/core/server/utils/kestrel-config.ts +42 -1
  28. package/layers/core/server/utils/seo.ts +18 -0
  29. package/layers/core/server/utils/write-effects.ts +40 -0
  30. package/layers/fields/server/field-registry/index.ts +2 -1
  31. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  32. package/layers/fields/server/utils/buildCollection.ts +9 -7
  33. package/layers/media/app/components/KestrelImg.vue +29 -0
  34. package/layers/media/app/components/MediaLibrary.vue +11 -6
  35. package/layers/media/app/components/MediaToolbar.vue +1 -1
  36. package/layers/media/app/components/MediaViewer.vue +56 -7
  37. package/layers/media/app/components/field/Media.vue +2 -0
  38. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  39. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  40. package/layers/media/app/utils/library.ts +2 -0
  41. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  42. package/layers/media/server/api/media/[id].patch.ts +25 -1
  43. package/layers/media/server/api/media/index.post.ts +19 -3
  44. package/layers/media/server/collections/media.ts +14 -0
  45. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  46. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  47. package/layers/media/server/utils/library.ts +2 -1
  48. package/layers/media/server/utils/record.ts +8 -0
  49. package/layers/media/server/utils/resolve.ts +12 -0
  50. package/layers/public/app/pages/[...slug].vue +57 -7
  51. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  52. package/layers/public/app/utils/json-ld.ts +139 -0
  53. package/layers/public/app/utils/preview-protocol.ts +36 -0
  54. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  55. package/layers/public/modules/prerender-routes/index.ts +5 -2
  56. package/layers/public/server/api/preview.get.ts +28 -0
  57. package/layers/public/server/api/preview.post.ts +93 -0
  58. package/layers/public/server/api/publish-status.get.ts +23 -9
  59. package/layers/public/server/api/publish.post.ts +84 -0
  60. package/layers/public/server/api/route.get.ts +9 -1
  61. package/layers/public/server/collections/redirects.ts +75 -0
  62. package/layers/public/server/plugins/03.redirects.ts +37 -0
  63. package/layers/public/server/plugins/zz.publish.ts +12 -3
  64. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  65. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  66. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  67. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  68. package/layers/public/server/tasks/publish/run.ts +2 -1
  69. package/layers/public/server/utils/llms-full.ts +125 -0
  70. package/layers/public/server/utils/llms.ts +13 -0
  71. package/layers/public/server/utils/page-resolve.ts +112 -4
  72. package/layers/public/server/utils/preview-token.ts +109 -0
  73. package/layers/public/server/utils/publish/invalidation.ts +72 -3
  74. package/layers/public/server/utils/publish/pending.ts +74 -0
  75. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  76. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  77. package/layers/public/server/utils/publish/publisher.ts +85 -14
  78. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  79. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  80. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  81. package/layers/public/server/utils/site-url.ts +8 -0
  82. package/layers/public/server/utils/sitemap.ts +5 -3
  83. package/layers/ui/app/components/field/Choice.vue +6 -1
  84. package/layers/ui/app/components/field/Datetime.vue +2 -0
  85. package/layers/ui/app/components/field/Repeater.vue +2 -0
  86. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  87. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  88. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  89. package/layers/ui/app/components/ui/Field.vue +1 -0
  90. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  91. package/layers/ui/app/components/ui/Icon.vue +2 -2
  92. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  93. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  94. package/layers/ui/app/components/ui/Select.vue +1 -0
  95. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  96. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  97. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  98. package/layers/ui/app/i18n/de.ts +23 -0
  99. package/layers/ui/app/i18n/en.ts +23 -0
  100. package/package.json +7 -1
  101. package/scripts/kestrel.mjs +7 -3
  102. package/scripts/lib/scaffold.mjs +23 -1
  103. package/templates/starter/app/blocks/Prose.vue +1 -0
  104. package/templates/starter/nuxt.config.ts +3 -0
@@ -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
- export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
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 { page: { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }, failed }
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
- return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
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
- const tags = recordTag ? [coll, recordTag, ...groupTags] : [coll, ...groupTags]
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 {