@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.
- package/README.md +15 -8
- package/layers/admin/app/components/CollectionList.vue +5 -1
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +42 -0
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/core/modules/kestrel/index.ts +11 -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/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- 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 +7 -5
- package/layers/media/app/components/MediaViewer.vue +56 -7
- 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 +30 -1
- package/layers/public/app/utils/json-ld.ts +139 -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/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/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/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/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- 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/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/package.json +2 -1
- 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
|
|
76
|
-
* `max-age=0, must-revalidate` (cacheable but always revalidated)
|
|
77
|
-
* un-hashed media) is
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
+
})
|