@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
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ static host. It is deliberately **not**:
|
|
|
23
23
|
- **SQLite only** — one database, single instance. (A Postgres `Dialect` seam exists; no impl is active.)
|
|
24
24
|
- **No public data API** — only published *page-like* records have a read surface (mirroring the static
|
|
25
25
|
output); everything else stays behind the admin guard. No per-request rate limiting / live API.
|
|
26
|
-
- **No runtime redirect engine** —
|
|
26
|
+
- **No runtime redirect engine** — redirects are *authored* in the CMS and published as a `redirects.json`
|
|
27
|
+
artifact, but Kestrel never answers a 30x itself; an edge (NGINX / njs / CloudFront) has to read it.
|
|
27
28
|
- **No per-file access control on uploads.** The admin guard protects the media *library* — listing,
|
|
28
29
|
editing, deleting — but not the bytes. With `media.driver: 'local'` the files are served from the app
|
|
29
30
|
origin by Nitro's static handler, which runs ahead of every middleware, so anyone who knows a URL can
|
|
@@ -49,13 +50,18 @@ static host. It is deliberately **not**:
|
|
|
49
50
|
- **Block page-builder** — a 3-pane editor (tree · live preview · fields) with nestable block slots; each
|
|
50
51
|
block is a single `app/blocks/*.vue` SFC (schema via field-factory `defineProps` + the display template).
|
|
51
52
|
- **Media library** — upload (magic-byte sniff + SVG sanitize), local or S3 storage, responsive WebP
|
|
52
|
-
derivatives + thumbhash, folders, and a full-screen asset viewer.
|
|
53
|
+
derivatives + thumbhash, folders, and a full-screen asset viewer. Optional **EU AI Act (Art. 50)
|
|
54
|
+
disclosure** fields per asset, with an upload-time scan for AI-origin signals (off by default).
|
|
53
55
|
- **Multilingual content** — optional per-record translations, an editor locale flow, locale-prefixed
|
|
54
56
|
routing, hreflang alternates in the sitemap.
|
|
55
57
|
- **Static output** — `nuxt generate` (full rebuild) or a runtime **incremental publisher** (re-renders
|
|
56
|
-
only the pages a write affects), to a local dir or S3; emits `sitemap.xml`, `robots.txt`,
|
|
57
|
-
**`llms.txt`** (an [llmstxt.org](https://llmstxt.org) site map so AI agents grasp the site)
|
|
58
|
-
in-dashboard preview renders straight from the origin.
|
|
58
|
+
only the pages a write affects), to a local dir or S3; emits `sitemap.xml`, `robots.txt`,
|
|
59
|
+
**`llms.txt`** (an [llmstxt.org](https://llmstxt.org) site map so AI agents grasp the site) and
|
|
60
|
+
`redirects.json`. A live in-dashboard preview renders straight from the origin.
|
|
61
|
+
- **Search + answer engines** — canonical / Open Graph / hreflang tags and a schema.org JSON-LD graph
|
|
62
|
+
(`WebSite` + `WebPage`/`Article` + `BreadcrumbList`) on every page, no wiring. Two opt-in extras publish
|
|
63
|
+
more than the page already showed: `seo.articleMeta` (author / publication date / keywords) and
|
|
64
|
+
`seo.llmsFull` (`/llms-full.txt`, every published page's body in one document).
|
|
59
65
|
- **Reference integrity** — writes invalidate exactly the affected static pages; dead-reference warnings;
|
|
60
66
|
required + globally-unique page slugs per locale.
|
|
61
67
|
|
|
@@ -126,9 +132,9 @@ where to start reading, the cross-layer seams, the gotchas). Then the per-topic
|
|
|
126
132
|
| [consuming-kestrel.md](docs/consuming-kestrel.md) | Using Kestrel in your own app: defining collections/fields/blocks, auto-discovery, the schema lifecycle |
|
|
127
133
|
| [configuration.md](docs/configuration.md) | The single config source (`kestrel.config.ts`), every `KESTREL_*` env var, the auth/session env split |
|
|
128
134
|
| [block-editing.md](docs/block-editing.md) | The block content model + the 3-pane block editor (tree · preview · fields) |
|
|
129
|
-
| [media-uploads.md](docs/media-uploads.md) | Ingest security, storage drivers (local/S3), responsive-image derivation |
|
|
135
|
+
| [media-uploads.md](docs/media-uploads.md) | Ingest security, storage drivers (local/S3), responsive-image derivation, EU AI Act disclosure |
|
|
130
136
|
| [multilingual.md](docs/multilingual.md) | Content locales, the editor locale flow, locale-prefixed routing |
|
|
131
|
-
| [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt`, the optional S3 deploy |
|
|
137
|
+
| [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt` / `llms-full.txt`, the JSON-LD structured data, CMS-managed redirects, the optional S3 deploy |
|
|
132
138
|
| [reference-integrity.md](docs/reference-integrity.md) | How writes invalidate the static site precisely, dead-reference warnings (a dead link renders `#`, and the editor is warned), and required/unique page slugs |
|
|
133
139
|
| [architecture-decisions.md](docs/architecture-decisions.md) | ADRs (the collection-derived schema engine, reference integrity, the auth/password choice) |
|
|
134
140
|
|
|
@@ -147,7 +153,8 @@ The CMS is split into Nuxt layers under `layers/`:
|
|
|
147
153
|
lives in the repo root, dev-only, and is **not** shipped in the package.)
|
|
148
154
|
- **`media`** — uploads, pluggable storage, image derivation, the media library + asset viewer.
|
|
149
155
|
- **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
|
|
150
|
-
- **`public`** — the SSG render path: the catch-all page, `BlockRenderer`,
|
|
156
|
+
- **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, the JSON-LD head, the literal-key
|
|
157
|
+
artifacts (sitemap / robots / llms.txt / llms-full.txt / redirects.json), deploy.
|
|
151
158
|
|
|
152
159
|
`playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
|
|
153
160
|
`scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
|
|
3
|
+
import type { Localized } from '../../../core/server/utils/defineCollection'
|
|
3
4
|
import { sortDirection, type FilterCell } from '../utils/list-query'
|
|
4
5
|
import type { ListColumn } from '../utils/list-columns'
|
|
5
6
|
import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
|
|
@@ -74,7 +75,10 @@ function enumOptions(c: ListColumn): { label: string; value: string }[] {
|
|
|
74
75
|
{ value: 'draft', label: t('pageSettings.statusDraft') },
|
|
75
76
|
{ value: 'published', label: t('pageSettings.statusPublished') },
|
|
76
77
|
]
|
|
77
|
-
|
|
78
|
+
// A choice label is `Localized`, so resolve it here as the editor widget does — `{{ o.label }}` would
|
|
79
|
+
// stringify a `{ en, de }` map into the filter dropdown.
|
|
80
|
+
return ((c.field?.options?.choices ?? []) as { label: Localized; value: string }[])
|
|
81
|
+
.map((o) => ({ value: o.value, label: resolveLocalized(o.label, lang.value) ?? o.value }))
|
|
78
82
|
}
|
|
79
83
|
function displayValue(kind: FilterKind, value: string): string {
|
|
80
84
|
if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
|
|
@@ -30,6 +30,10 @@ const { t } = useT()
|
|
|
30
30
|
const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
|
|
31
31
|
const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
|
|
32
32
|
|
|
33
|
+
// Article metadata is an installation-wide policy (`kestrel.seo.articleMeta`), not a per-collection one,
|
|
34
|
+
// so it comes from runtimeConfig rather than the serialized collection schema.
|
|
35
|
+
const articleMeta = computed(() => (useRuntimeConfig().public as { seoArticleMeta?: boolean }).seoArticleMeta === true)
|
|
36
|
+
|
|
33
37
|
// Live preview of the slug the server will auto-generate from the title while the field is left blank
|
|
34
38
|
// (the server slugifies the title on save). Falls back to '/' when there is no title yet.
|
|
35
39
|
const slugPlaceholder = computed(() => {
|
|
@@ -121,6 +125,7 @@ const slugPlaceholder = computed(() => {
|
|
|
121
125
|
:path="(values.path as string) ?? ''"
|
|
122
126
|
:locale="locale"
|
|
123
127
|
:disabled="disabled"
|
|
128
|
+
:article-meta="articleMeta"
|
|
124
129
|
@update="(v) => emit('update', 'seo', v)"
|
|
125
130
|
/>
|
|
126
131
|
</template>
|
|
@@ -12,6 +12,10 @@ const props = defineProps<{
|
|
|
12
12
|
path?: string
|
|
13
13
|
locale: string
|
|
14
14
|
disabled?: boolean
|
|
15
|
+
/** `kestrel.seo.articleMeta`. Off (the default) hides author/date/keywords entirely: an installation
|
|
16
|
+
* that must not attribute content should never be offered the fields, not merely stop publishing
|
|
17
|
+
* them. Stored values are hidden, never cleared — turning the flag back on restores them. */
|
|
18
|
+
articleMeta?: boolean
|
|
15
19
|
}>()
|
|
16
20
|
const emit = defineEmits<{ update: [seo: SeoMeta] }>()
|
|
17
21
|
const { t } = useT()
|
|
@@ -90,6 +94,44 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
90
94
|
</template>
|
|
91
95
|
</UiField>
|
|
92
96
|
|
|
97
|
+
<!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
|
|
98
|
+
<template v-if="articleMeta">
|
|
99
|
+
<UiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
|
|
100
|
+
<template #default="f">
|
|
101
|
+
<UiTextInput
|
|
102
|
+
:model-value="value.author ?? ''"
|
|
103
|
+
:disabled="disabled"
|
|
104
|
+
v-bind="f"
|
|
105
|
+
@update:model-value="(v) => patch({ author: v ?? '' })"
|
|
106
|
+
/>
|
|
107
|
+
</template>
|
|
108
|
+
</UiField>
|
|
109
|
+
|
|
110
|
+
<UiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
|
|
111
|
+
<template #default="f">
|
|
112
|
+
<UiDatePicker
|
|
113
|
+
:model-value="value.publishedDate || null"
|
|
114
|
+
precision="date"
|
|
115
|
+
:disabled="disabled"
|
|
116
|
+
:aria-label="t('seo.publishedDate')"
|
|
117
|
+
:describedby="f['aria-describedby']"
|
|
118
|
+
@update:model-value="(v) => patch({ publishedDate: v ?? '' })"
|
|
119
|
+
/>
|
|
120
|
+
</template>
|
|
121
|
+
</UiField>
|
|
122
|
+
|
|
123
|
+
<UiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
|
|
124
|
+
<template #default="f">
|
|
125
|
+
<UiTextInput
|
|
126
|
+
:model-value="value.keywords ?? ''"
|
|
127
|
+
:disabled="disabled"
|
|
128
|
+
v-bind="f"
|
|
129
|
+
@update:model-value="(v) => patch({ keywords: v ?? '' })"
|
|
130
|
+
/>
|
|
131
|
+
</template>
|
|
132
|
+
</UiField>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
93
135
|
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
|
|
94
136
|
<label class="seo-fields__noindex">
|
|
95
137
|
<UiCheckbox
|
|
@@ -278,7 +278,11 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
function handleError(e: unknown) {
|
|
281
|
-
const { statusCode, statusMessage, issues } = readFetchError(e)
|
|
281
|
+
const { statusCode, statusMessage, issues, data } = readFetchError(e)
|
|
282
|
+
// The row WAS written and a follow-up step failed (a write effect — see write-effects.ts), so the
|
|
283
|
+
// server hands back the new `updatedAt`. Take it as the baseline: the client only rebaselines on
|
|
284
|
+
// success, and without this the "save again" those errors ask for would 409 on a stale precondition.
|
|
285
|
+
if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
|
|
282
286
|
if (statusCode === 400) {
|
|
283
287
|
const mapped = mapServerErrors(issues)
|
|
284
288
|
for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
|
|
@@ -292,9 +296,11 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
292
296
|
? t('editor.fixBlockContent')
|
|
293
297
|
: Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
|
|
294
298
|
} else if (statusCode === 409) {
|
|
295
|
-
|
|
299
|
+
// `||`, not `??`: an absent reason phrase arrives as '' rather than undefined, and an empty banner
|
|
300
|
+
// is a silent failure — the Save button simply stops spinning.
|
|
301
|
+
formError.value = statusMessage || t('editor.saveConflict')
|
|
296
302
|
} else {
|
|
297
|
-
formError.value = statusMessage
|
|
303
|
+
formError.value = statusMessage || t('editor.saveFailed')
|
|
298
304
|
}
|
|
299
305
|
}
|
|
300
306
|
|
|
@@ -17,6 +17,8 @@ export interface FetchErrorInfo {
|
|
|
17
17
|
statusCode?: number
|
|
18
18
|
statusMessage?: string
|
|
19
19
|
issues: ServerIssue[]
|
|
20
|
+
/** The error body when it is NOT a Zod-issue array — e.g. `{ savedUpdatedAt }` from a failed write effect. */
|
|
21
|
+
data?: Record<string, unknown>
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// The wire `SerializedField` is structurally what every Field* widget and `validateField` read
|
|
@@ -26,13 +28,18 @@ export function asFieldDef(field: SerializedField): FieldDef {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
|
|
31
|
+
// The BODY's statusMessage wins over the response's: ofetch maps `statusMessage` from `response.statusText`,
|
|
32
|
+
// and HTTP/2 has no reason phrase — behind any h2 proxy that arrives as '' and the real message is only in
|
|
33
|
+
// the JSON. Anything else under `data` (not an issue array) is handed back for the caller to act on.
|
|
29
34
|
export function readFetchError(e: unknown): FetchErrorInfo {
|
|
30
35
|
const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
|
|
31
|
-
const
|
|
36
|
+
const body = Array.isArray(err.data) ? undefined : err.data
|
|
37
|
+
const raw = body?.data ?? err.data
|
|
32
38
|
return {
|
|
33
39
|
statusCode: err.statusCode,
|
|
34
|
-
statusMessage: err.statusMessage,
|
|
40
|
+
statusMessage: (body?.statusMessage as string | undefined) || err.statusMessage,
|
|
35
41
|
issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
|
|
42
|
+
...(body && !Array.isArray(body.data) ? { data: body } : {}),
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -56,6 +56,13 @@ export default defineNuxtModule<KestrelConfig>({
|
|
|
56
56
|
pub.siteName = c.siteName ?? ''
|
|
57
57
|
// Reference desktop width (px) for the admin page-builder preset; the editor's scale-to-fit shrinks it.
|
|
58
58
|
pub.previewDesktopWidth = c.preview.desktopWidth
|
|
59
|
+
// Whether the admin shows the EU AI Act disclosure controls on a media asset; the data itself is
|
|
60
|
+
// always resolved server-side, so flipping this off only hides the editor.
|
|
61
|
+
pub.aiDisclosureEnabled = c.aiDisclosure.enabled
|
|
62
|
+
// Article metadata is an APP-side concern on both ends — the admin SEO panel decides whether to offer
|
|
63
|
+
// the fields, the public page decides whether to publish them as JSON-LD — so the flag has to be
|
|
64
|
+
// client-visible. It gates a disclosure, not a secret.
|
|
65
|
+
pub.seoArticleMeta = c.seo.articleMeta
|
|
59
66
|
|
|
60
67
|
// Server-only resolved settings, so server utils read the CONSUMER's `kestrel: {}` (via this module)
|
|
61
68
|
// rather than importing Kestrel's own `kestrel.config.ts` file. Essential when Kestrel is consumed as
|
|
@@ -67,6 +74,10 @@ export default defineNuxtModule<KestrelConfig>({
|
|
|
67
74
|
srv.siteDescription = c.siteDescription
|
|
68
75
|
// Built-in collection toggles (pages/media) — read by the register plugin to gate built-ins.
|
|
69
76
|
srv.collections = c.collections
|
|
77
|
+
// Read by the upload route to decide whether to run the AI-signal scan at all.
|
|
78
|
+
srv.aiDisclosure = c.aiDisclosure
|
|
79
|
+
// Answer-engine toggles; `llmsFull` is server-only (it gates a Nitro route and what the publisher writes).
|
|
80
|
+
srv.seo = c.seo
|
|
70
81
|
// Static-publish target for the runtime publisher (server-only). S3 creds are env-only; prefer the
|
|
71
82
|
// output-specific creds, fall back to the shared media creds so a single S3 account "just works".
|
|
72
83
|
srv.output = {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import Database from 'better-sqlite3'
|
|
4
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
5
|
+
import { buildCollection } from '../../../../fields/server/utils/buildCollection'
|
|
6
|
+
import { defineCollection } from '../../utils/defineCollection'
|
|
7
|
+
import { putSingleton } from '../../utils/crud'
|
|
8
|
+
import { requireCollection } from '../../utils/http'
|
|
9
|
+
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
+
import { clearWriteListeners } from '../../utils/write-events'
|
|
11
|
+
import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
|
|
12
|
+
import { desiredSchema } from '../../schema/desired'
|
|
13
|
+
import { diffSchema } from '../../schema/diff'
|
|
14
|
+
import { renderSqlite } from '../../schema/render-sqlite'
|
|
15
|
+
|
|
16
|
+
const settings = buildCollection(defineCollection({
|
|
17
|
+
name: 'settings', mode: 'single',
|
|
18
|
+
fields: { title: { type: 'text', required: true } },
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
|
|
22
|
+
|
|
23
|
+
let db: ReturnType<typeof drizzle>
|
|
24
|
+
|
|
25
|
+
// Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
|
|
26
|
+
// than a stub (same rationale as options.get.test.ts).
|
|
27
|
+
Object.assign(globalThis, {
|
|
28
|
+
defineEventHandler: (handler: unknown) => handler,
|
|
29
|
+
createError,
|
|
30
|
+
getQuery: (event: FakeEvent) => event.query,
|
|
31
|
+
readBody: (event: FakeEvent) => Promise.resolve(event.body),
|
|
32
|
+
useDb: () => db,
|
|
33
|
+
requireAdmin: () => {},
|
|
34
|
+
requireCollection,
|
|
35
|
+
readIfUnmodifiedSince: () => undefined,
|
|
36
|
+
putSingleton,
|
|
37
|
+
runWriteEffects,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
|
|
41
|
+
const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
const sqlite = new Database(':memory:')
|
|
45
|
+
for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
|
|
46
|
+
db = drizzle(sqlite)
|
|
47
|
+
clearRegistry()
|
|
48
|
+
registerCollection(settings)
|
|
49
|
+
clearWriteListeners()
|
|
50
|
+
clearWriteEffects()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('PUT /api/[collection] — singleton save', () => {
|
|
54
|
+
it('writes the row and returns it', async () => {
|
|
55
|
+
await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('runs post-write effects with the saved row', async () => {
|
|
59
|
+
const seen: unknown[] = []
|
|
60
|
+
registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
|
|
61
|
+
await put('settings', { title: 'Hello' })
|
|
62
|
+
expect(seen).toEqual([['settings', 'Hello']])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
|
|
66
|
+
registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
|
|
67
|
+
await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not run effects when validation rejects the body', async () => {
|
|
71
|
+
let ran = false
|
|
72
|
+
registerWriteEffect(() => { ran = true })
|
|
73
|
+
await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
|
|
74
|
+
expect(ran).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -3,5 +3,22 @@ export default defineEventHandler(async (event) => {
|
|
|
3
3
|
const collection = requireCollection(event)
|
|
4
4
|
const query = getQuery(event)
|
|
5
5
|
const body = await readBody(event)
|
|
6
|
-
|
|
6
|
+
const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
7
|
+
// Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
|
|
8
|
+
// (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
|
|
9
|
+
// write-event bus swallows throws by design. See write-effects.ts.
|
|
10
|
+
try {
|
|
11
|
+
await runWriteEffects(collection.def, saved)
|
|
12
|
+
} catch (error) {
|
|
13
|
+
// The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
|
|
14
|
+
// hand the new baseline back with the error, or the retry these failures ask for would 409 on a
|
|
15
|
+
// precondition that is stale by construction.
|
|
16
|
+
const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
|
|
17
|
+
throw createError({
|
|
18
|
+
statusCode: err.statusCode ?? 500,
|
|
19
|
+
statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
|
|
20
|
+
data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
return saved
|
|
7
24
|
})
|
|
@@ -20,8 +20,9 @@ export interface BuiltCollection {
|
|
|
20
20
|
insert: CollectionSchema
|
|
21
21
|
update: CollectionSchema
|
|
22
22
|
select: CollectionSchema
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
/** Every pre-write check the per-field schema can't do because it sees one field at a time: `required`
|
|
24
|
+
* re-enforced for conditional fields whose condition is met against the whole record, plus the
|
|
25
|
+
* collection's own `def.validate`. Returns Zod-shaped issues keyed by the field's def name. Present
|
|
26
|
+
* only when the collection has conditional required fields or a `validate`. */
|
|
26
27
|
applyConditions?: (record: Record<string, unknown>) => { issues: ConditionIssue[] }
|
|
27
28
|
}
|
|
@@ -97,7 +97,7 @@ export type FieldDef =
|
|
|
97
97
|
| (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
|
|
98
98
|
| (BaseFieldDef & { type: 'boolean' })
|
|
99
99
|
| (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
|
|
100
|
-
| (BaseFieldDef & { type: 'choice'; options: { choices: { label:
|
|
100
|
+
| (BaseFieldDef & { type: 'choice'; options: { choices: { label: Localized; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
|
|
101
101
|
| (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
|
|
102
102
|
| (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
|
|
103
103
|
| (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
|
|
@@ -158,6 +158,13 @@ export interface CollectionDef {
|
|
|
158
158
|
* so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
|
|
159
159
|
* nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
|
|
160
160
|
fieldLayout?: FieldLayoutDSL
|
|
161
|
+
/** Whole-record validation the per-field Zod schema cannot express, because a field validator only ever
|
|
162
|
+
* sees its own value — e.g. a rule set whose rows have to compile as a unit. Runs server-side BEFORE
|
|
163
|
+
* the write, next to the conditional-required check, and its issues become the same field-scoped 400
|
|
164
|
+
* the editor already renders. Server-only: a function, never serialized.
|
|
165
|
+
* Note the asymmetry: `record` is keyed by COLUMN name (`authorId` for a single relation/media field —
|
|
166
|
+
* see `resolveColumnName`), while an issue's `path[0]` is the FIELD key the editor renders against. */
|
|
167
|
+
validate?: (record: Record<string, unknown>) => Array<{ path: (string | number)[]; message: string }>
|
|
161
168
|
/** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
|
|
162
169
|
* `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
|
|
163
170
|
* `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
|
|
@@ -138,10 +138,32 @@ export interface KestrelConfig {
|
|
|
138
138
|
/** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
|
|
139
139
|
* `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
|
|
140
140
|
collections?: { pages?: boolean; media?: boolean }
|
|
141
|
+
/** Search / answer-engine surface. Both flags default to **false** and stay off until a consumer turns
|
|
142
|
+
* them on: each one publishes something the site did not publish before, and an upgrade must never
|
|
143
|
+
* start disclosing it. Everything else on this surface (canonical, OG, hreflang, sitemap, robots,
|
|
144
|
+
* llms.txt, the WebSite/WebPage JSON-LD graph) is unconditional — it only restates what the page head
|
|
145
|
+
* already says. */
|
|
146
|
+
seo?: {
|
|
147
|
+
/** Offer editors `author` / `publishedDate` / `keywords` on the SEO panel and publish them as
|
|
148
|
+
* schema.org `author` / `datePublished` / `keywords` (which also promotes the page's JSON-LD node
|
|
149
|
+
* from `WebPage` to `Article`). Some consumers must not attribute content at all — internal
|
|
150
|
+
* authorship, confidentiality — so nothing is emitted and the fields are not even shown unless this
|
|
151
|
+
* is on. Env `KESTREL_SEO_ARTICLE_META`. */
|
|
152
|
+
articleMeta?: boolean
|
|
153
|
+
/** Serve and publish `/llms-full.txt`: the full Markdown body of every published, indexable page in
|
|
154
|
+
* one file (the llmstxt.org long form). Off by default — it aggregates the whole site into a single
|
|
155
|
+
* scrapeable artifact, and it reads every page's block content on every publish, which the link-only
|
|
156
|
+
* `llms.txt` deliberately avoids. Env `KESTREL_SEO_LLMS_FULL`. */
|
|
157
|
+
llmsFull?: boolean
|
|
158
|
+
}
|
|
141
159
|
/** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
|
|
142
160
|
* preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
|
|
143
161
|
* `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
|
|
144
162
|
preview?: { desktopWidth?: number }
|
|
163
|
+
/** EU AI Act Art. 50 disclosure fields on media assets — OFF by default. Kestrel only stores/manages
|
|
164
|
+
* this metadata; it never burns a label/watermark into an image and never auto-emits it into public
|
|
165
|
+
* output — see docs/media-uploads.md. Env: `KESTREL_AI_DISCLOSURE`. */
|
|
166
|
+
aiDisclosure?: { enabled?: boolean }
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
export interface ResolvedKestrel {
|
|
@@ -170,8 +192,13 @@ export interface ResolvedKestrel {
|
|
|
170
192
|
}
|
|
171
193
|
/** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
|
|
172
194
|
collections: { pages: boolean; media: boolean }
|
|
195
|
+
/** Resolved answer-engine toggles, both default-off (see `KestrelConfig.seo`). */
|
|
196
|
+
seo: { articleMeta: boolean; llmsFull: boolean }
|
|
173
197
|
/** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
|
|
174
198
|
preview: { desktopWidth: number }
|
|
199
|
+
/** Gates the media-disclosure UI in the admin ONLY. `ResolvedMedia.aiDisclosure` is always resolved from
|
|
200
|
+
* the columns, so turning this off hides the editor controls without touching existing data. */
|
|
201
|
+
aiDisclosure: { enabled: boolean }
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
type Env = Record<string, string | undefined>
|
|
@@ -411,7 +438,14 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
|
|
|
411
438
|
media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
|
|
412
439
|
}
|
|
413
440
|
|
|
441
|
+
const seo = {
|
|
442
|
+
articleMeta: envBool(env.KESTREL_SEO_ARTICLE_META, c.seo?.articleMeta ?? false),
|
|
443
|
+
llmsFull: envBool(env.KESTREL_SEO_LLMS_FULL, c.seo?.llmsFull ?? false),
|
|
444
|
+
}
|
|
445
|
+
|
|
414
446
|
const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
|
|
415
447
|
|
|
416
|
-
|
|
448
|
+
const aiDisclosure = { enabled: envBool(env.KESTREL_AI_DISCLOSURE, c.aiDisclosure?.enabled ?? false) }
|
|
449
|
+
|
|
450
|
+
return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, seo, preview, aiDisclosure }
|
|
417
451
|
}
|
|
@@ -7,13 +7,31 @@ export interface SeoMeta {
|
|
|
7
7
|
noindex?: boolean
|
|
8
8
|
/** Media id of the social-share image (og:image / twitter card); resolved under `seo.$media.image`. */
|
|
9
9
|
image?: number | null
|
|
10
|
+
/** Article metadata — schema.org `author` / `datePublished` / `keywords` on the page's JSON-LD node.
|
|
11
|
+
* Only offered and only published when `kestrel.seo.articleMeta` is on; the column always ROUND-TRIPS
|
|
12
|
+
* them, so turning the flag off hides and unpublishes existing values instead of destroying them. */
|
|
13
|
+
author?: string
|
|
14
|
+
/** ISO date (`YYYY-MM-DD`) or ISO datetime; `''` is the editor's cleared state. */
|
|
15
|
+
publishedDate?: string
|
|
16
|
+
/** Free-form comma-separated list (the spelling schema.org accepts verbatim). */
|
|
17
|
+
keywords?: string
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
// A date is validated here rather than at emission time so a mistyped value surfaces in the editor,
|
|
21
|
+
// where it can be fixed — not silently as a missing `datePublished` in an artifact nobody looks at.
|
|
22
|
+
const isoDateish = z.string().refine(
|
|
23
|
+
(v) => v === '' || /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(v),
|
|
24
|
+
{ message: 'Expected a date (YYYY-MM-DD)' },
|
|
25
|
+
)
|
|
26
|
+
|
|
12
27
|
export const seoSchema = z.object({
|
|
13
28
|
title: z.string().optional(),
|
|
14
29
|
description: z.string().optional(),
|
|
15
30
|
noindex: z.boolean().optional(),
|
|
16
31
|
image: z.number().int().positive().nullish(),
|
|
32
|
+
author: z.string().optional(),
|
|
33
|
+
publishedDate: isoDateish.optional(),
|
|
34
|
+
keywords: z.string().optional(),
|
|
17
35
|
})
|
|
18
36
|
|
|
19
37
|
// The synthetic field set the row populator walks over the `seo` system column (PROPS key-mode), so the
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CollectionDef } from './defineCollection'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-write EFFECTS — the fail-able sibling of the write-event bus (`write-events.ts`).
|
|
5
|
+
*
|
|
6
|
+
* A write LISTENER is fire-and-forget on purpose: a publish failure must never break a content write, so
|
|
7
|
+
* `emitWrite` swallows throws. That is exactly wrong for a write whose external side effect is part of
|
|
8
|
+
* the contract — a redirects artifact the edge serves is stale the moment it fails, and an editor who saw
|
|
9
|
+
* a green save has no way to know. An EFFECT is awaited by the route and its rejection becomes the save's
|
|
10
|
+
* error response.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately narrow: only the singleton PUT runs effects. Widening it to create/update/delete would make
|
|
13
|
+
* every content write fail-able, which is the invariant the listener bus exists to protect.
|
|
14
|
+
*
|
|
15
|
+
* The DB row is already committed when effects run (better-sqlite3 writes are synchronous and CRUD holds
|
|
16
|
+
* no transaction), so a failing effect means "saved, but the side effect is stale" — never a rollback.
|
|
17
|
+
* The message an effect throws has to say so.
|
|
18
|
+
*/
|
|
19
|
+
export interface WriteEffectEvent {
|
|
20
|
+
def: CollectionDef
|
|
21
|
+
/** The row as saved. */
|
|
22
|
+
row: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type WriteEffect = (event: WriteEffectEvent) => Promise<void> | void
|
|
26
|
+
|
|
27
|
+
const effects: WriteEffect[] = []
|
|
28
|
+
|
|
29
|
+
export function registerWriteEffect(fn: WriteEffect): void {
|
|
30
|
+
effects.push(fn)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearWriteEffects(): void {
|
|
34
|
+
effects.length = 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run the registered effects in order. Rejects on the first failure, leaving the rest unrun. */
|
|
38
|
+
export async function runWriteEffects(def: CollectionDef, row: Record<string, unknown>): Promise<void> {
|
|
39
|
+
for (const fn of effects) await fn({ def, row })
|
|
40
|
+
}
|
|
@@ -51,22 +51,24 @@ export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Build the `applyConditions` hook
|
|
55
|
-
* when
|
|
56
|
-
* (it can't see siblings)
|
|
57
|
-
*
|
|
58
|
-
*
|
|
54
|
+
* Build the `applyConditions` hook — every pre-write check the per-field schema can't do — or `undefined`
|
|
55
|
+
* when the collection needs none (so CRUD skips it). Two sources, both of which need the whole record:
|
|
56
|
+
* the per-field schema relaxed every conditional field to optional (it can't see siblings), so `required`
|
|
57
|
+
* is re-enforced here for the ones whose condition is met; and the collection's own `def.validate`, for a
|
|
58
|
+
* rule spanning fields. Issues are keyed by the field's def name (so they map to the editor's per-field
|
|
59
|
+
* errors), resolving each value at its column key (`<name>Id` for single relation/media).
|
|
59
60
|
*/
|
|
60
61
|
function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
|
|
61
62
|
const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
|
|
62
|
-
if (!required.length) return undefined
|
|
63
|
+
if (!required.length && !def.validate) return undefined
|
|
64
|
+
if (!required.length) return (record) => ({ issues: def.validate!(record) })
|
|
63
65
|
return (record) => {
|
|
64
66
|
const scope: Record<string, unknown> = {}
|
|
65
67
|
for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
|
|
66
68
|
const issues = required
|
|
67
69
|
.filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
|
|
68
70
|
.map(([key]) => ({ path: [key], message: 'This field is required.' }))
|
|
69
|
-
return { issues }
|
|
71
|
+
return { issues: [...issues, ...(def.validate?.(record) ?? [])] }
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useBlurUp } from '../composables/useBlurUp'
|
|
3
3
|
import { useMediaVariant } from '../composables/useMediaVariant'
|
|
4
|
+
import { aiSourceTypeLabel } from '../utils/ai-disclosure'
|
|
4
5
|
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
5
6
|
import type { VariantFit, VariantFormat } from '../../../core/server/utils/kestrel-config'
|
|
6
7
|
|
|
@@ -17,6 +18,11 @@ const props = defineProps<{
|
|
|
17
18
|
formats?: VariantFormat[]
|
|
18
19
|
sizes?: string
|
|
19
20
|
priority?: boolean
|
|
21
|
+
/** Opt into a structurally-present but UNSTYLED EU AI Act disclosure badge (`.kestrel-img__ai-badge`),
|
|
22
|
+
* rendered only when `media.aiDisclosure` is set. Off by default: Kestrel must never publish a claim
|
|
23
|
+
* the consumer did not ask for, in a place/language/style it cannot know. The alternative escape hatch
|
|
24
|
+
* is to read `media.aiDisclosure` and render your own element — see docs/media-uploads.md. */
|
|
25
|
+
aiBadge?: boolean
|
|
20
26
|
}>()
|
|
21
27
|
|
|
22
28
|
const { model } = useMediaVariant(
|
|
@@ -44,6 +50,11 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
|
|
|
44
50
|
decoding="async"
|
|
45
51
|
@load="onLoad"
|
|
46
52
|
>
|
|
53
|
+
<span
|
|
54
|
+
v-if="aiBadge && media?.aiDisclosure"
|
|
55
|
+
class="kestrel-img__ai-badge"
|
|
56
|
+
:data-ai-source-type="media.aiDisclosure.sourceType"
|
|
57
|
+
>{{ media.aiDisclosure.note ?? aiSourceTypeLabel(media.aiDisclosure.sourceType) }}</span>
|
|
47
58
|
</picture>
|
|
48
59
|
</template>
|
|
49
60
|
|
|
@@ -63,3 +74,21 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
|
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
</style>
|
|
77
|
+
|
|
78
|
+
<!-- Deliberately UNSCOPED: a scoped rule carries the `[data-v-*]` attribute selector, which out-specifies
|
|
79
|
+
a consumer stylesheet targeting `.kestrel-img__ai-badge` and would make the badge un-restylable.
|
|
80
|
+
Layout only — no color, background, border, radius or font — so nothing is visible as a "badge" until
|
|
81
|
+
the consumer's own CSS says so. Scoped to pictures that actually contain one, so a consumer who never
|
|
82
|
+
sets `ai-badge` gets no rule at all. -->
|
|
83
|
+
<style>
|
|
84
|
+
picture:has(> .kestrel-img__ai-badge) {
|
|
85
|
+
position: relative;
|
|
86
|
+
display: inline-block;
|
|
87
|
+
}
|
|
88
|
+
.kestrel-img__ai-badge {
|
|
89
|
+
position: absolute;
|
|
90
|
+
inset-block-end: 0;
|
|
91
|
+
inset-inline-start: 0;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
</style>
|