@michaelthielemann/kestrel 2.0.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 (55) hide show
  1. package/README.md +15 -8
  2. package/layers/admin/app/components/CollectionList.vue +5 -1
  3. package/layers/admin/app/components/PageFields.vue +5 -0
  4. package/layers/admin/app/components/SeoFields.vue +42 -0
  5. package/layers/admin/app/composables/useEditForm.ts +9 -3
  6. package/layers/admin/app/utils/edit-form.ts +9 -2
  7. package/layers/core/modules/kestrel/index.ts +11 -0
  8. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  9. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  10. package/layers/core/server/utils/collection-types.ts +4 -3
  11. package/layers/core/server/utils/defineCollection.ts +8 -1
  12. package/layers/core/server/utils/kestrel-config.ts +35 -1
  13. package/layers/core/server/utils/seo.ts +18 -0
  14. package/layers/core/server/utils/write-effects.ts +40 -0
  15. package/layers/fields/server/utils/buildCollection.ts +9 -7
  16. package/layers/media/app/components/KestrelImg.vue +29 -0
  17. package/layers/media/app/components/MediaLibrary.vue +7 -5
  18. package/layers/media/app/components/MediaViewer.vue +56 -7
  19. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  20. package/layers/media/app/utils/library.ts +2 -0
  21. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  22. package/layers/media/server/api/media/[id].patch.ts +25 -1
  23. package/layers/media/server/api/media/index.post.ts +19 -3
  24. package/layers/media/server/collections/media.ts +14 -0
  25. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  26. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  27. package/layers/media/server/utils/library.ts +2 -1
  28. package/layers/media/server/utils/record.ts +8 -0
  29. package/layers/media/server/utils/resolve.ts +12 -0
  30. package/layers/public/app/pages/[...slug].vue +30 -1
  31. package/layers/public/app/utils/json-ld.ts +139 -0
  32. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  33. package/layers/public/modules/prerender-routes/index.ts +5 -2
  34. package/layers/public/server/api/route.get.ts +9 -1
  35. package/layers/public/server/collections/redirects.ts +75 -0
  36. package/layers/public/server/plugins/03.redirects.ts +37 -0
  37. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  38. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  39. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  40. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  41. package/layers/public/server/utils/llms-full.ts +125 -0
  42. package/layers/public/server/utils/llms.ts +13 -0
  43. package/layers/public/server/utils/page-resolve.ts +112 -4
  44. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  45. package/layers/public/server/utils/publish/publisher.ts +11 -5
  46. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  47. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  48. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  49. package/layers/public/server/utils/site-url.ts +8 -0
  50. package/layers/public/server/utils/sitemap.ts +5 -3
  51. package/layers/ui/app/components/field/Choice.vue +6 -1
  52. package/layers/ui/app/i18n/de.ts +13 -0
  53. package/layers/ui/app/i18n/en.ts +13 -0
  54. package/package.json +2 -1
  55. package/templates/starter/nuxt.config.ts +3 -0
@@ -15,6 +15,9 @@ interface RenderedPage {
15
15
  description?: string
16
16
  noindex?: boolean
17
17
  $media?: { image?: { src: string; width: number | null; height: number | null } | null }
18
+ author?: string
19
+ publishedDate?: string
20
+ keywords?: string
18
21
  }
19
22
  content?: unknown[]
20
23
  status?: string
@@ -42,6 +45,7 @@ const { data: resolved, error: resolveError } = await useAsyncData(`page:${local
42
45
  collection: string | null
43
46
  page: (RenderedPage & Record<string, unknown>) | null
44
47
  alternates?: Array<{ locale: string; path: string }>
48
+ ancestors?: Array<{ path: string; title?: string; locale?: string }>
45
49
  site?: SiteHead | null
46
50
  }),
47
51
  )
@@ -113,7 +117,7 @@ if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMess
113
117
  // Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
114
118
  // page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
115
119
  // og:image) require a configured siteUrl and degrade away without one.
116
- const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string }
120
+ const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string; seoArticleMeta?: boolean }
117
121
  const seo = page.value?.seo ?? {}
118
122
  const siteHead = resolved.value?.site ?? null
119
123
  const fallbacks = siteHeadFallbacks(seo, siteHead)
@@ -133,11 +137,36 @@ const head = buildPageHead({
133
137
  alternates: resolved.value?.alternates ?? [],
134
138
  })
135
139
 
140
+ // schema.org JSON-LD — the one grounding signal every major answer engine documents. Same inputs as the
141
+ // head above, so the two can never disagree; it degrades away without a siteUrl and is suppressed for a
142
+ // noindex page or an unsaved ticket preview. Article metadata is published ONLY with `seo.articleMeta`
143
+ // on: the fields may hold values (the column always round-trips them) that this installation must not
144
+ // disclose, so the flag gates emission, not storage.
145
+ const jsonLd = buildJsonLd({
146
+ siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
147
+ siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
148
+ canonical: head.canonical,
149
+ locale,
150
+ primary,
151
+ prefixPrimary,
152
+ title: pageTitle,
153
+ description: fallbacks.description,
154
+ imageUrl: head.meta.ogImage,
155
+ noindex: previewingTicket.value || seo.noindex,
156
+ ancestors: resolved.value?.ancestors ?? [],
157
+ article: publicRc.seoArticleMeta === true
158
+ ? { author: seo.author, publishedDate: seo.publishedDate, keywords: seo.keywords }
159
+ : null,
160
+ })
161
+
136
162
  // Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
137
163
  // (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
138
164
  // Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
139
165
  useHead({
140
166
  htmlAttrs: { lang: locale },
167
+ // `textContent` (not innerHTML) is unhead's XSS-safe arm for a data script: it takes the object and
168
+ // serializes it itself, so editor-authored strings can never close the <script>.
169
+ script: jsonLd ? [{ type: 'application/ld+json' as const, textContent: jsonLd }] : [],
141
170
  link: [
142
171
  // `rel` needs the literal type: unhead keys its link union on it, and inside an array literal that
143
172
  // reaches `link:` through a spread there is no contextual type to stop TS widening it to `string`.
@@ -0,0 +1,139 @@
1
+ import { localePath } from '../../../core/app/utils/locale-path'
2
+
3
+ /** One published ancestor of the rendered page, outermost first — the breadcrumb trail. */
4
+ export interface JsonLdAncestor {
5
+ /** The ancestor's own path, unprefixed (the emitter locale-prefixes it, as `buildPageHead` does). */
6
+ path: string
7
+ title?: string
8
+ /** The ancestor's OWN locale. Absent for a record in a non-translatable collection, whose single
9
+ * published URL is the primary-locale one — the same `row.locale ?? primary` rule the sitemap uses. */
10
+ locale?: string
11
+ }
12
+
13
+ /** The opt-in article metadata (`kestrel.seo.articleMeta`). Absent ⇒ the page stays a plain `WebPage`
14
+ * and no authorship or date is published — the default, and the only behaviour a consumer who never
15
+ * turns the flag on can get. */
16
+ export interface JsonLdArticle {
17
+ author?: string
18
+ /** ISO date (`YYYY-MM-DD`) or ISO datetime; anything else is dropped rather than emitted invalid. */
19
+ publishedDate?: string
20
+ /** Free-form comma-separated list — schema.org accepts that spelling for `keywords` verbatim. */
21
+ keywords?: string
22
+ }
23
+
24
+ export interface JsonLdInput {
25
+ /** Absolute site origin ('' when unconfigured). */
26
+ siteUrl: string
27
+ siteName?: string
28
+ /** The page's absolute canonical URL (from `buildPageHead`); absent ⇒ nothing is emitted. */
29
+ canonical?: string
30
+ locale: string
31
+ primary: string
32
+ prefixPrimary: boolean
33
+ title?: string
34
+ description?: string
35
+ /** The already-absolute og:image URL, so the image-resolution rules live in one place. */
36
+ imageUrl?: string
37
+ /** Excluded from the graph entirely — see `buildJsonLd`. */
38
+ noindex?: boolean
39
+ ancestors?: JsonLdAncestor[]
40
+ article?: JsonLdArticle | null
41
+ }
42
+
43
+ /** A type alias, not an interface, for the same reason as `PageHeadLink`: unhead's `script` entry types
44
+ * `textContent` as `string | Record<string, unknown>`, and TS derives the implicit index signature that
45
+ * needs for an alias but never for an interface. */
46
+ export type JsonLd = {
47
+ '@context': 'https://schema.org'
48
+ '@graph': Record<string, unknown>[]
49
+ }
50
+
51
+ const trimmed = (v: unknown): string | undefined => {
52
+ const s = typeof v === 'string' ? v.trim() : ''
53
+ return s || undefined
54
+ }
55
+
56
+ // schema.org dates are ISO 8601; an editor free-text value that is not one would be published as a
57
+ // broken `datePublished`, which is worse for a consuming engine than no date at all.
58
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]|$)/
59
+
60
+ /**
61
+ * The JSON-LD graph for a rendered public page: a site-wide `WebSite`, the page itself as a `WebPage`
62
+ * (or an `Article`, when the consumer opted into article metadata AND the record carries some), and a
63
+ * `BreadcrumbList` built from the page's real published ancestors. Pure, like `buildPageHead`, and fed
64
+ * the same already-resolved values — the precedence chain stays outside (ADR-0007).
65
+ *
66
+ * Two whole-graph veto rules, both because the alternative is a false signal rather than a missing one:
67
+ * without an absolute canonical every `@id`/`url` would be a relative path that resolves against
68
+ * whatever host fetched it, and a `noindex` page asking to be excluded from search has no business
69
+ * shipping structured data that exists to be indexed.
70
+ *
71
+ * Breadcrumb items are REAL pages only (the resolver hands over the published ancestors it found), so a
72
+ * trail never advertises an intermediate URL that 404s; a trail of one is dropped, since "you are here"
73
+ * carries no information.
74
+ */
75
+ export function buildJsonLd(input: JsonLdInput): JsonLd | null {
76
+ const canonical = trimmed(input.canonical)
77
+ if (!canonical || input.noindex) return null
78
+ const base = input.siteUrl.replace(/\/+$/, '')
79
+ const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
80
+
81
+ const graph: Record<string, unknown>[] = []
82
+ const siteName = trimmed(input.siteName)
83
+ // A nameless WebSite node is an empty assertion — omit it, and with it the isPartOf edge that would
84
+ // otherwise dangle at an @id nothing defines.
85
+ const websiteId = siteName ? `${base}/#website` : undefined
86
+ if (websiteId) graph.push({ '@type': 'WebSite', '@id': websiteId, url: `${base}/`, name: siteName })
87
+
88
+ const article = usableArticle(input.article)
89
+ const page: Record<string, unknown> = {
90
+ '@type': article ? 'Article' : 'WebPage',
91
+ '@id': `${canonical}#webpage`,
92
+ url: canonical,
93
+ }
94
+ const title = trimmed(input.title)
95
+ // `headline` is the Article spelling of the same value; keeping them apart avoids a node that claims
96
+ // both and matches what validators expect per type.
97
+ if (title) page[article ? 'headline' : 'name'] = title
98
+ const description = trimmed(input.description)
99
+ if (description) page.description = description
100
+ page.inLanguage = input.locale
101
+ if (websiteId) page.isPartOf = { '@id': websiteId }
102
+ const imageUrl = trimmed(input.imageUrl)
103
+ if (imageUrl) page.image = imageUrl
104
+ if (article) {
105
+ if (article.author) page.author = { '@type': 'Person', name: article.author }
106
+ if (article.publishedDate) page.datePublished = article.publishedDate
107
+ if (article.keywords) page.keywords = article.keywords
108
+ }
109
+
110
+ const crumbs = [
111
+ ...(input.ancestors ?? []).map((a) => ({ name: trimmed(a.title) ?? a.path, item: abs(a.path, a.locale ?? input.primary) })),
112
+ { name: title ?? canonical, item: canonical },
113
+ ]
114
+ if (crumbs.length >= 2) {
115
+ const breadcrumbId = `${canonical}#breadcrumb`
116
+ page.breadcrumb = { '@id': breadcrumbId }
117
+ graph.push(page, {
118
+ '@type': 'BreadcrumbList',
119
+ '@id': breadcrumbId,
120
+ itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.item })),
121
+ })
122
+ } else {
123
+ graph.push(page)
124
+ }
125
+
126
+ return { '@context': 'https://schema.org', '@graph': graph }
127
+ }
128
+
129
+ /** The article bag reduced to the values worth publishing, or null — which keeps the node a `WebPage`.
130
+ * A bag of blanks (an editor who opened the fields and typed nothing) must not upgrade the type. */
131
+ function usableArticle(article: JsonLdArticle | null | undefined): Required<JsonLdArticle> | null {
132
+ if (!article) return null
133
+ const author = trimmed(article.author) ?? ''
134
+ const rawDate = trimmed(article.publishedDate) ?? ''
135
+ const publishedDate = ISO_DATE.test(rawDate) ? rawDate : ''
136
+ const keywords = trimmed(article.keywords) ?? ''
137
+ // An unparseable date alone must not upgrade the type either — nothing would be emitted from it.
138
+ return author || publishedDate || keywords ? { author, publishedDate, keywords } : null
139
+ }
@@ -69,12 +69,26 @@ export function precompressedEncoding(filename: string, siblingNames: string[]):
69
69
  const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
70
70
  const REVALIDATE_CACHE = 'public, max-age=0, must-revalidate'
71
71
 
72
+ /** Artifacts served at a LITERAL key (not `<path>/index.html`) and rendered from the live DB rather than
73
+ * copied from the build. One list, because each of them has to be handled the same way in four places:
74
+ * re-rendered on every publish, excluded from the build-asset mirror (a stale copy must never overwrite
75
+ * a fresh one), seeded into the prerender routes, and cached as revalidate-always below.
76
+ * `llms-full.txt` is opt-in (`kestrel.seo.llmsFull`) and simply renders to a 404 when off — it stays in
77
+ * this list either way, so the rules that are about the FILENAME hold regardless of the flag; only the
78
+ * prerender seeding, which asks for the ROUTE, has to consult it. */
79
+ export const META_KEYS = ['sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt', 'redirects.json'] as const
80
+
81
+ export function isMetaKey(key: string): boolean {
82
+ return (META_KEYS as readonly string[]).includes(key)
83
+ }
84
+
72
85
  /**
73
86
  * `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
74
- * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML,
75
- * the sitemap and `robots.txt` live at *stable* URLs whose content changes on any deploy, so they get
76
- * `max-age=0, must-revalidate` (cacheable but always revalidated). Everything else (favicons, fonts,
77
- * un-hashed media) is left to the host default.
87
+ * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML and
88
+ * the `META_KEYS` artifacts live at *stable* URLs whose content changes on any deploy, so they get
89
+ * `max-age=0, must-revalidate` (cacheable but always revalidated) `redirects.json` especially, since a
90
+ * cached copy keeps serving withdrawn redirects. Everything else (favicons, fonts, un-hashed media) is
91
+ * left to the host default.
78
92
  */
79
93
  export function cacheControlFor(key: string): string | undefined {
80
94
  // Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
@@ -82,7 +96,7 @@ export function cacheControlFor(key: string): string | undefined {
82
96
  if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
83
97
  if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
84
98
  const base = key.split('/').pop() ?? key
85
- if (base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') return REVALIDATE_CACHE
99
+ if (base.endsWith('.html') || isMetaKey(base)) return REVALIDATE_CACHE
86
100
  return undefined
87
101
  }
88
102
 
@@ -4,7 +4,7 @@ import { defineNuxtModule } from '@nuxt/kit'
4
4
  import Database from 'better-sqlite3'
5
5
  import { collectPageRoutes, pageLikeTables } from './discover'
6
6
  import { localePath } from '../../../core/app/utils/locale-path'
7
- import { recordRouteDiscovery, type RouteDiscovery } from '../deploy-output/deploy-output'
7
+ import { recordRouteDiscovery, META_KEYS, type RouteDiscovery } from '../deploy-output/deploy-output'
8
8
  import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
9
9
 
10
10
  // Read published paths from EVERY page-like collection straight from the DB at build time so
@@ -73,7 +73,10 @@ export default defineNuxtModule({
73
73
  // Hand the deploy module the completeness of THIS enumeration — the only step that knows it.
74
74
  recordRouteDiscovery(nuxt, discovery)
75
75
  nitro.prerender ||= {}
76
- nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, '/sitemap.xml', '/robots.txt', '/llms.txt'])]
76
+ // Every meta artifact EXCEPT `llms-full.txt`, which is seeded only when the consumer opted in — the
77
+ // route 404s otherwise, and a prerender error fails the whole `nuxt generate`.
78
+ const meta = META_KEYS.filter((k) => k !== 'llms-full.txt' || c.seo.llmsFull).map((k) => `/${k}`)
79
+ nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, ...meta])]
77
80
  // Render pages in parallel — the dominant lever on `nuxt generate` wall-clock as page count grows.
78
81
  // Safe: page reads are synchronous better-sqlite3 (WAL + busy_timeout, can't interleave mid-statement);
79
82
  // the one WRITE on this path — the variant-registry capture — is an IMMEDIATE transaction, so
@@ -42,5 +42,13 @@ export default defineEventHandler((event) => {
42
42
  if (siteUnreadable || (failed.length && !resolved)) {
43
43
  throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
44
44
  }
45
- return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [], site }
45
+ return {
46
+ collection: resolved?.collection ?? null,
47
+ page: resolved?.page ?? null,
48
+ alternates: resolved?.alternates ?? [],
49
+ // The breadcrumb trail rides the same fetch as the hreflang set, for the same reason: it is resolved
50
+ // from the DB, and the publish-dep capture only works on a path the renderer actually awaits.
51
+ ancestors: resolved?.ancestors ?? [],
52
+ site,
53
+ }
46
54
  })
@@ -0,0 +1,75 @@
1
+ import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
+ import { defineCollection } from '../../../core/server/utils/defineCollection'
3
+ import { compileRedirects } from '../utils/publish/redirect-rules'
4
+
5
+ // Editorial SEO redirects, kept in the DB rather than in config because they outlive a deployment and are
6
+ // the editors' to change. Saving compiles them into the `redirects.json` artifact the edge serves — Kestrel
7
+ // itself never redirects at runtime (there is no live public SSR to redirect in).
8
+ const built = buildCollection(defineCollection({
9
+ name: 'redirects',
10
+ mode: 'single',
11
+ builtin: true,
12
+ label: { singular: { en: 'Redirects', de: 'Weiterleitungen' }, plural: { en: 'Redirects', de: 'Weiterleitungen' } },
13
+ icon: 'external-link',
14
+ fields: {
15
+ rules: {
16
+ type: 'repeater',
17
+ // Row order is priority and there is no per-field help text in the editor, so the rule and the
18
+ // wildcard syntax have to ride along in the labels — this is the only place an editor sees them.
19
+ label: {
20
+ en: 'Rules — the first matching rule wins, so put the most specific one first',
21
+ de: 'Regeln — die erste passende Regel gewinnt, spezifische zuerst',
22
+ },
23
+ options: {
24
+ fields: {
25
+ from: {
26
+ type: 'text',
27
+ required: true,
28
+ label: {
29
+ en: 'From — old path, * = one segment, ** = several',
30
+ de: 'Von — alter Pfad, * = ein Segment, ** = mehrere',
31
+ },
32
+ },
33
+ to: {
34
+ type: 'text',
35
+ required: true,
36
+ label: {
37
+ en: 'To — new path or full URL, $1/$2 = the wildcards',
38
+ de: 'Nach — neuer Pfad oder vollständige URL, $1/$2 = die Platzhalter',
39
+ },
40
+ },
41
+ status: {
42
+ type: 'choice',
43
+ label: { en: 'Status', de: 'Status' },
44
+ options: {
45
+ choices: [
46
+ { label: { en: '301 — permanent', de: '301 — dauerhaft' }, value: '301' },
47
+ { label: { en: '302 — temporary', de: '302 — vorübergehend' }, value: '302' },
48
+ { label: { en: '307 — temporary, keeps the method', de: '307 — vorübergehend, Methode bleibt' }, value: '307' },
49
+ { label: { en: '308 — permanent, keeps the method', de: '308 — dauerhaft, Methode bleibt' }, value: '308' },
50
+ ],
51
+ display: 'select',
52
+ },
53
+ default: '301',
54
+ },
55
+ },
56
+ fieldLayout: [['from|2', 'to|2', 'status|1']],
57
+ },
58
+ },
59
+ },
60
+ // A row that cannot compile must never reach the DB: it would leave the artifact stale on every
61
+ // subsequent save and fail the prerender of `/redirects.json`. Zod validates one field at a time and
62
+ // cannot see that `to: '/x/$2'` needs a second wildcard in `from`, so the check lands here — before the
63
+ // write, as a field-scoped 400 the editor renders on the repeater.
64
+ validate: (record) => {
65
+ try {
66
+ compileRedirects(record.rules)
67
+ return []
68
+ } catch (error) {
69
+ return [{ path: ['rules'], message: (error as Error).message }]
70
+ }
71
+ },
72
+ }))
73
+
74
+ export const redirects = built.table
75
+ export default built
@@ -0,0 +1,37 @@
1
+ import { registerWriteEffect } from '../../../core/server/utils/write-effects'
2
+ import { outputDriver } from '../utils/publish/publisher'
3
+ import { REDIRECTS_COLLECTION, REDIRECTS_FIELD, writeRedirectsArtifact } from '../utils/publish/redirects-artifact'
4
+
5
+ /**
6
+ * Publish `redirects.json` on every save of the redirects singleton — deliberately decoupled from the
7
+ * publish cycle, so a redirect goes live without a full republish (and without an editor pressing
8
+ * Publish, which after ADR-0008 they otherwise would have to).
9
+ *
10
+ * A write EFFECT, not a write listener: the bus swallows throws, and a save that reports success while
11
+ * the edge still serves the old rules is exactly the failure this feature cannot have. Registered in
12
+ * every environment, unlike the publish plugin — that one is dev-gated because a dev publish would write
13
+ * Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
14
+ * `JSON.stringify` of DB rows, byte-identical either way).
15
+ *
16
+ * Where it lands is `output.dir` / the S3 prefix — the same target the publisher uses. With the classic
17
+ * `output.auto: false` + `driver: 'local'` build model that is NOT the deployed tree, so there a
18
+ * redirect goes live with the next `nuxt generate` instead; documented under Redirects in
19
+ * `docs/static-output.md`.
20
+ */
21
+ export default defineNitroPlugin(() => {
22
+ registerWriteEffect(async ({ def, row }) => {
23
+ if (def.name !== REDIRECTS_COLLECTION) return
24
+ try {
25
+ await writeRedirectsArtifact(row[REDIRECTS_FIELD], outputDriver())
26
+ } catch (error) {
27
+ // The row is already committed (CRUD holds no transaction), so the only honest message is
28
+ // "saved, but not live". Short and ASCII on purpose: h3 truncates nothing but the reason phrase is
29
+ // the wire's, and the cause goes in `data` where nothing can strip it.
30
+ throw createError({
31
+ statusCode: 500,
32
+ statusMessage: 'Redirects saved, but publishing redirects.json failed. Save again to retry.',
33
+ data: { cause: (error as Error)?.message ?? String(error) },
34
+ })
35
+ }
36
+ })
37
+ })
@@ -0,0 +1,99 @@
1
+ import { getTableColumns } from 'drizzle-orm'
2
+ import { localePath } from '../../../core/app/utils/locale-path'
3
+ import { LLMS_FULL_HEADING_OFFSET } from '../utils/llms-full'
4
+ import type { LlmsFullSection, LlmsFullPage } from '../utils/llms-full'
5
+ import type { BuiltCollection } from '../../../core/server/utils/collection-types'
6
+
7
+ // The long form of llms.txt: every published, indexable page's full Markdown body in one document, so an
8
+ // answer engine can ground on the site without crawling it. Same registry-driven public set and the same
9
+ // status/noindex filters as `llms.txt` and the sitemap (single source: the auth policy).
10
+ //
11
+ // OPT-IN (`kestrel.seo.llmsFull`, default off), for two reasons that both matter: it aggregates the whole
12
+ // site into one scrapeable artifact — a disclosure decision that belongs to the consumer, not to an
13
+ // upgrade — and unlike `llms.txt` it must read every row's block content, which the publisher would
14
+ // re-render on every incremental publish.
15
+ export default defineEventHandler((event) => {
16
+ if (!llmsFullEnabled()) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
17
+ const db = useDb()
18
+ const base = siteBaseUrl()
19
+ const primary = primaryLocale()
20
+ const prefixPrimary = prefixPrimaryLocale()
21
+
22
+ setHeader(event, 'content-type', 'text/plain; charset=utf-8')
23
+ // Without an absolute origin every `Source:` line would be a relative path resolving against whatever
24
+ // host fetched the file — the same rule `llms.txt` applies to its link list.
25
+ if (!base) {
26
+ console.warn('[kestrel] llms-full.txt: siteUrl is unset — omitting page bodies (their URLs would be relative)')
27
+ return buildLlmsFullTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections: [] })
28
+ }
29
+
30
+ interface Loaded { c: BuiltCollection; rows: Record<string, unknown>[] }
31
+ const pub = publicReadableResources()
32
+ const loaded: Loaded[] = []
33
+ for (const c of allCollections()) {
34
+ if (!c.def.pageLike) continue
35
+ if (!isPubliclyReadable(c.def.name, pub)) continue
36
+ // Project the listing columns plus ONLY the prose-bearing fields — this route does have to read block
37
+ // content, but a media/relation/json column still has no business being loaded to render text.
38
+ const cols = getTableColumns(c.table) as Record<string, never>
39
+ const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
40
+ if (c.def.translatable) proj.locale = cols.locale
41
+ if (c.def.status) proj.status = cols.status
42
+ if (c.def.seo) proj.seo = cols.seo
43
+ if (c.def.blocks?.enabled) proj.content = cols.content
44
+ for (const [key, field] of Object.entries(c.def.fields)) {
45
+ if (field.type === 'text' || field.type === 'richtext' || field.type === 'repeater') proj[key] = cols[key]
46
+ }
47
+ try {
48
+ loaded.push({ c, rows: db.select(proj as never).from(c.table).all() as Record<string, unknown>[] })
49
+ } catch (error) {
50
+ // Skipping keeps a bare prerender DB publishable, but a drifted table drops the whole section — a
51
+ // silent gap the publisher would write straight over the live artifact.
52
+ console.error(`[kestrel] llms-full.txt: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
53
+ }
54
+ }
55
+
56
+ const advertisable = (c: BuiltCollection, row: Record<string, unknown>): string | null => {
57
+ if (c.def.status && row.status !== 'published') return null
58
+ const path = row.path as string | null
59
+ if (!path) return null
60
+ if ((row.seo as { noindex?: boolean } | null)?.noindex) return null
61
+ return base + localePath(path, (row.locale as string | undefined) ?? primary, primary, prefixPrimary)
62
+ }
63
+
64
+ // Internal richtext links are stored as `kestrel:<collection>:<id>` markers. Resolve them from the rows
65
+ // already loaded — the same status/noindex gate, so a marker pointing at a draft or a noindexed page
66
+ // declines and the link degrades to its own text instead of leaking an unpublished URL.
67
+ const linkTargets = new Map<string, string>()
68
+ for (const { c, rows } of loaded) {
69
+ for (const row of rows) {
70
+ const url = advertisable(c, row)
71
+ if (url) linkTargets.set(`${c.def.name}:${row.id as number}`, url)
72
+ }
73
+ }
74
+ const resolveLink = (collection: string, id: number): string | null => linkTargets.get(`${collection}:${id}`) ?? null
75
+
76
+ const sections: LlmsFullSection[] = []
77
+ for (const { c, rows } of loaded) {
78
+ const pages: LlmsFullPage[] = []
79
+ for (const row of rows) {
80
+ const url = advertisable(c, row)
81
+ if (!url) continue
82
+ const seo = (row.seo ?? {}) as { title?: string; description?: string }
83
+ pages.push({
84
+ title: seo.title || (row.title as string | undefined) || (row.path as string),
85
+ url,
86
+ description: seo.description || undefined,
87
+ // `title` already IS this page's heading — emitting it again as body text would repeat every
88
+ // page title twice in the document.
89
+ body: recordMarkdown(c.def, row, { headingOffset: LLMS_FULL_HEADING_OFFSET, skipFields: ['title'], resolveLink }),
90
+ })
91
+ }
92
+ if (pages.length) {
93
+ pages.sort((a, b) => a.url.localeCompare(b.url))
94
+ sections.push({ heading: collectionHeading(c.def, primary), pages })
95
+ }
96
+ }
97
+
98
+ return buildLlmsFullTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections })
99
+ })
@@ -11,17 +11,6 @@ export default defineEventHandler((event) => {
11
11
  const primary = primaryLocale()
12
12
  const prefixPrimary = prefixPrimaryLocale()
13
13
 
14
- const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
15
- const headingFor = (def: { name: string; label?: { plural?: unknown } }): string => {
16
- const pl = def.label?.plural
17
- if (typeof pl === 'string') return pl
18
- if (pl && typeof pl === 'object') {
19
- const m = pl as Record<string, string>
20
- return m[primary] ?? Object.values(m)[0] ?? cap(def.name)
21
- }
22
- return cap(def.name)
23
- }
24
-
25
14
  const pub = publicReadableResources()
26
15
  const sections: LlmsSection[] = []
27
16
  // Without an absolute origin, every resource URL would be a relative path — omit the page sections (keep
@@ -66,7 +55,7 @@ export default defineEventHandler((event) => {
66
55
  }
67
56
  if (entries.length) {
68
57
  entries.sort((a, b) => a.url.localeCompare(b.url))
69
- sections.push({ heading: headingFor(c.def), entries })
58
+ sections.push({ heading: collectionHeading(c.def, primary), entries })
70
59
  }
71
60
  }
72
61
 
@@ -0,0 +1,58 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { compilePublishableRedirects, serializeRedirects } from '../utils/publish/redirect-rules'
3
+ import { REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '../utils/publish/redirects-artifact'
4
+
5
+ /**
6
+ * The redirect artifact the edge polls. A save publishes it directly (see `plugins/03.redirects.ts`);
7
+ * this route is what makes it survive everything else — it is prerendered into `.output/public`, so the
8
+ * build-time deploy's reconcile keeps it instead of pruning a key it cannot account for, and a full
9
+ * publish re-renders it from the live DB. Public and cheap: one row, no user input.
10
+ *
11
+ * Zero redirects is a supported state, and so is a collection a consumer toggled off — both serve `[]`,
12
+ * never a 404, because an edge that cannot fetch the file has to keep its last good state and would
13
+ * otherwise burn its cold-start budget retrying.
14
+ */
15
+
16
+ /** A table that does not exist yet — the release adds one, so a consumer can generate before their
17
+ * `db:migrate`. `[]` is the truth there, not a degrade. */
18
+ function tableIsAbsent(error: unknown): boolean {
19
+ return /no such table/i.test((error as Error)?.message ?? '')
20
+ }
21
+
22
+ /**
23
+ * Render the artifact, or fail loudly. The distinction is deliberate and narrow: only a missing table
24
+ * yields `[]`, because only then is "no redirects" a FACT. Any other read failure (a drifted column, an
25
+ * I/O error) throws — at build time an errored route costs the deploy its reconcile, which is the
26
+ * conservative direction, and at runtime `publishMeta` writes nothing on a non-200, where an empty body
27
+ * would instead overwrite a good live artifact with "no redirects". A failed read must never be an
28
+ * authoritative empty result.
29
+ */
30
+ export function renderRedirects(readRows: () => unknown): string {
31
+ let rows: unknown
32
+ try {
33
+ rows = readRows()
34
+ } catch (error) {
35
+ if (!tableIsAbsent(error)) throw error
36
+ console.warn('[kestrel] redirects.json: the redirects table does not exist yet — serving an empty list. Run `db:migrate`.')
37
+ return serializeRedirects([])
38
+ }
39
+ const { rules, skipped } = compilePublishableRedirects(rows)
40
+ for (const message of skipped) {
41
+ console.error(`[kestrel] redirects.json: skipped an unpublishable rule — ${message}`)
42
+ }
43
+ return serializeRedirects(rules)
44
+ }
45
+
46
+ export default defineEventHandler((event) => {
47
+ setHeader(event, 'content-type', 'application/json; charset=utf-8')
48
+ const collection = getCollection(REDIRECTS_COLLECTION)
49
+ if (!collection) return serializeRedirects([])
50
+
51
+ const cols = collection.table as unknown as Record<string, never>
52
+ return renderRedirects(() => {
53
+ const row = useDb().select().from(collection.table).where(eq(cols.singletonKey, REDIRECTS_COLLECTION)).get() as
54
+ | Record<string, unknown>
55
+ | undefined
56
+ return row?.[REDIRECTS_FIELD]
57
+ })
58
+ })
@@ -4,5 +4,6 @@ export default defineEventHandler((event) => {
4
4
  return buildRobots({
5
5
  sitemapUrl: base ? `${base}/sitemap.xml` : undefined,
6
6
  llmsUrl: base ? `${base}/llms.txt` : undefined,
7
+ llmsFullUrl: base && llmsFullEnabled() ? `${base}/llms-full.txt` : undefined,
7
8
  })
8
9
  })