@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
@@ -0,0 +1,20 @@
1
+ import type { StorageDriver } from '../../../../core/server/utils/storage'
2
+ import { contentTypeFor, cacheControlFor } from '../../../modules/deploy-output/deploy-output'
3
+ import { compileRedirects, serializeRedirects } from './redirect-rules'
4
+
5
+ /** The collection and the repeater field the artifact is compiled from, named once. */
6
+ export const REDIRECTS_COLLECTION = 'redirects'
7
+ export const REDIRECTS_FIELD = 'rules'
8
+
9
+ /** Literal key at the output root — a sibling of `index.html`, not a child of it. The driver's root IS
10
+ * the output root (local `output.dir`, or the S3 prefix), so a key can never sit *beside* that tree. */
11
+ export const REDIRECTS_KEY = 'redirects.json'
12
+
13
+ /**
14
+ * Compile the editor's rows and publish them. Compilation runs first so an unpublishable rule fails
15
+ * before the driver is touched, and the writer never swallows: a rejection is the caller's to surface.
16
+ */
17
+ export async function writeRedirectsArtifact(rows: unknown, driver: StorageDriver): Promise<void> {
18
+ const body = Buffer.from(serializeRedirects(compileRedirects(rows)))
19
+ await driver.put(REDIRECTS_KEY, body, contentTypeFor(REDIRECTS_KEY), { cacheControl: cacheControlFor(REDIRECTS_KEY) })
20
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Richtext HTML → Markdown, for the agent-facing `llms-full.txt`. Answer engines consume Markdown, not
3
+ * markup, and a stripped-tags dump would lose exactly the structure they retrieve on (headings, lists,
4
+ * links).
5
+ *
6
+ * A hand-rolled parser rather than a DOM library because the input dialect is CLOSED and already
7
+ * well-formed: every stored richtext value passed `sanitizeRichtext` on write, so it is sanitize-html's
8
+ * own re-serialization of a parsed tree, restricted to `RICHTEXT_ALLOWLIST`. That keeps this pure — no
9
+ * DOM, no Nuxt, no new dependency in the public layer's server bundle — so it unit-tests hard.
10
+ *
11
+ * Deliberately minimal escaping: only link text (whose brackets would break the link syntax) and a
12
+ * leading block marker (which would silently turn a paragraph into a list item or heading). Escaping
13
+ * every markdown metacharacter would make the output markedly harder to read for the model that reads it.
14
+ */
15
+
16
+ export interface RichtextMarkdownOptions {
17
+ /** Push every heading down by this many levels (clamped at h6), so a body nests under the document
18
+ * headings the caller already emitted. */
19
+ headingOffset?: number
20
+ }
21
+
22
+ type MdNode =
23
+ | { kind: 'text'; value: string }
24
+ | { kind: 'el'; tag: string; attrs: Record<string, string>; children: MdNode[] }
25
+
26
+ const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link'])
27
+ const HEADINGS: Record<string, number> = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 }
28
+ const BLOCK_TAGS = new Set(['p', 'blockquote', 'pre', 'ul', 'ol', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
29
+
30
+ const TAG = /<(\/)?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/)?>/g
31
+ const ATTR = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
32
+
33
+ const NAMED_ENTITIES: Record<string, string> = {
34
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
35
+ }
36
+
37
+ /** Decode the entity set sanitize-html emits, plus numeric references. `&nbsp;` becomes an ordinary
38
+ * space on purpose: a U+00A0 survives whitespace collapsing and shows up as a stray gap. */
39
+ function decodeEntities(raw: string): string {
40
+ return raw.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, body: string) => {
41
+ if (body[0] === '#') {
42
+ const code = body[1] === 'x' || body[1] === 'X' ? Number.parseInt(body.slice(2), 16) : Number(body.slice(1))
43
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
44
+ }
45
+ return NAMED_ENTITIES[body.toLowerCase()] ?? match
46
+ })
47
+ }
48
+
49
+ function parseAttrs(raw: string): Record<string, string> {
50
+ const attrs: Record<string, string> = {}
51
+ if (!raw.trim()) return attrs
52
+ ATTR.lastIndex = 0
53
+ let m: RegExpExecArray | null
54
+ while ((m = ATTR.exec(raw))) {
55
+ attrs[m[1]!.toLowerCase()] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')
56
+ }
57
+ return attrs
58
+ }
59
+
60
+ /** Tag soup → a node tree. A stray close tag with no matching open is dropped; anything still open at
61
+ * the end is closed implicitly, so a truncated value degrades to the content it did carry. */
62
+ function parse(html: string): MdNode[] {
63
+ const root: MdNode[] = []
64
+ const stack: Array<{ tag: string; children: MdNode[] }> = []
65
+ const top = (): MdNode[] => (stack.length ? stack[stack.length - 1]!.children : root)
66
+ const pushText = (raw: string): void => {
67
+ if (raw) top().push({ kind: 'text', value: decodeEntities(raw) })
68
+ }
69
+
70
+ TAG.lastIndex = 0
71
+ let cursor = 0
72
+ let m: RegExpExecArray | null
73
+ while ((m = TAG.exec(html))) {
74
+ pushText(html.slice(cursor, m.index))
75
+ cursor = TAG.lastIndex
76
+ const tag = m[2]!.toLowerCase()
77
+ if (m[1]) {
78
+ // Close the innermost matching element; an unmatched close tag is ignored rather than unwinding
79
+ // the whole stack, which would drop every sibling that followed it.
80
+ const at = stack.map((s) => s.tag).lastIndexOf(tag)
81
+ if (at >= 0) stack.length = at
82
+ continue
83
+ }
84
+ const node: MdNode = { kind: 'el', tag, attrs: parseAttrs(m[3] ?? ''), children: [] }
85
+ top().push(node)
86
+ if (!m[4] && !VOID_TAGS.has(tag)) stack.push({ tag, children: node.children })
87
+ }
88
+ pushText(html.slice(cursor))
89
+ return root
90
+ }
91
+
92
+ /** Raw text of a subtree, entities already decoded and whitespace untouched (code blocks). */
93
+ function rawText(nodes: MdNode[]): string {
94
+ return nodes.map((n) => (n.kind === 'text' ? n.value : rawText(n.children))).join('')
95
+ }
96
+
97
+ const escapeLinkText = (s: string): string => s.replace(/[[\]]/g, '\\$&')
98
+
99
+ const INLINE_WRAP: Record<string, string> = { strong: '**', b: '**', em: '*', i: '*', s: '~~' }
100
+
101
+ /** The longest run of `char` anywhere in `text` — the width a fence/delimiter has to beat to contain it. */
102
+ function longestRun(text: string, char: string): number {
103
+ let longest = 0
104
+ let run = 0
105
+ for (const c of text) {
106
+ run = c === char ? run + 1 : 0
107
+ if (run > longest) longest = run
108
+ }
109
+ return longest
110
+ }
111
+
112
+ /** `` `code` `` with a delimiter wider than any backtick run inside it (CommonMark's own escape hatch),
113
+ * padded with a space when the content would otherwise fuse with the delimiter. */
114
+ function inlineCode(text: string): string {
115
+ const delimiter = '`'.repeat(longestRun(text, '`') + 1)
116
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : ''
117
+ return `${delimiter}${pad}${text}${pad}${delimiter}`
118
+ }
119
+
120
+ function renderInline(nodes: MdNode[]): string {
121
+ let out = ''
122
+ for (const node of nodes) {
123
+ if (node.kind === 'text') { out += node.value.replace(/\s+/g, ' '); continue }
124
+ if (node.tag === 'br') { out += '\n'; continue }
125
+ if (node.tag === 'a') {
126
+ const text = renderInline(node.children).trim()
127
+ const href = node.attrs.href
128
+ // `#` is what the internal-link resolver writes for a target it declined (missing or unpublished) —
129
+ // a dead anchor is worth less than the words it wraps, so keep the words and drop the link.
130
+ out += !href || href === '#' ? text : `[${escapeLinkText(text)}](${href})`
131
+ continue
132
+ }
133
+ const inner = renderInline(node.children)
134
+ if (node.tag === 'code') { out += inner.trim() ? inlineCode(inner.trim()) : inner; continue }
135
+ const wrap = INLINE_WRAP[node.tag]
136
+ if (!wrap || !inner.trim()) { out += inner; continue }
137
+ // CommonMark will not open emphasis on `** bold **` — whitespace immediately inside the delimiter
138
+ // makes it literal asterisks. Editors leave it there routinely (selecting a word plus its space), so
139
+ // move it outside rather than emitting markup that does not parse.
140
+ const [, lead = '', body = '', trail = ''] = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner) ?? []
141
+ out += `${lead}${wrap}${body}${wrap}${trail}`
142
+ }
143
+ return out
144
+ }
145
+
146
+ const prefixLines = (text: string, first: string, rest: string): string =>
147
+ text.split('\n').map((line, i) => (i === 0 ? first + line : (line ? rest + line : rest.trimEnd()))).join('\n')
148
+
149
+ /** Escape a line that would otherwise open a Markdown block: a heading, a list item, a quote, a code
150
+ * fence or a thematic break. One backslash on the first marker character defuses all of them (every one
151
+ * is ASCII punctuation, so the escape is valid CommonMark and renders as the literal character). */
152
+ function escapeMarkerLine(line: string): string {
153
+ // CommonMark allows a block marker up to three spaces in; deeper is an indented code block, which no
154
+ // backslash can defuse — and which whitespace collapsing has already removed from converted text.
155
+ const [, indent = '', rest = ''] = /^(\s{0,3})([\s\S]*)$/.exec(line) ?? []
156
+ const ordered = /^(\d{1,9})[.)](\s|$)/.exec(rest)
157
+ if (ordered) return `${indent}${ordered[1]}\\${rest.slice(ordered[1]!.length)}`
158
+ const opensBlock = /^#{1,6}(\s|$)/.test(rest) // heading, any level
159
+ || /^[-+*](\s|$)/.test(rest) // bullet list
160
+ || rest.startsWith('>') // block quote
161
+ || /^(`{3,}|~{3,})/.test(rest) // fenced code
162
+ || /^([-*_])[\s]*(\1[\s]*){2,}$/.test(rest) // thematic break (and the setext h2 underline)
163
+ || /^=+\s*$/.test(rest) // setext h1 underline
164
+ return opensBlock ? `${indent}\\${rest}` : line
165
+ }
166
+
167
+ /**
168
+ * Escape every line of a text block that would open a Markdown construct. Applied per LINE, not once per
169
+ * block: a `<br>` becomes a real newline, so an anchored first-line-only check guards nothing after it.
170
+ *
171
+ * This is the whole defence between editor-authored prose and the structure of `llms-full.txt`, whose own
172
+ * document uses `##` for collection sections and `###` for pages — an unescaped `## …` in a body would
173
+ * not merely add a heading, it would forge a sibling of the generator's own and re-parent every page
174
+ * after it.
175
+ */
176
+ export function escapeMarkdownBlock(text: string): string {
177
+ return text.split('\n').map(escapeMarkerLine).join('\n')
178
+ }
179
+
180
+ function renderList(node: MdNode & { kind: 'el' }, opts: RichtextMarkdownOptions): string {
181
+ const ordered = node.tag === 'ol'
182
+ const items = node.children.filter((c): c is MdNode & { kind: 'el' } => c.kind === 'el' && c.tag === 'li')
183
+ const lines: string[] = []
184
+ let n = 0
185
+ for (const item of items) {
186
+ const blocks = renderBlocks(item.children, opts)
187
+ n += 1
188
+ const marker = ordered ? `${n}. ` : '- '
189
+ const indent = ' '.repeat(marker.length)
190
+ // The first block is the item's own text; anything after it (a nested list, a second paragraph)
191
+ // continues the item and must be indented to the marker's width to stay inside it.
192
+ const [head = '', ...rest] = blocks
193
+ lines.push(prefixLines(head, marker, indent))
194
+ for (const block of rest) lines.push(prefixLines(block, indent, indent))
195
+ }
196
+ return lines.join('\n')
197
+ }
198
+
199
+ /** Render a node list as block-level markdown: an array of blocks, joined by a blank line by the caller. */
200
+ function renderBlocks(nodes: MdNode[], opts: RichtextMarkdownOptions): string[] {
201
+ const blocks: string[] = []
202
+ let run: MdNode[] = []
203
+ const flushRun = (): void => {
204
+ if (!run.length) return
205
+ const text = renderInline(run).trim()
206
+ run = []
207
+ if (text) blocks.push(escapeMarkdownBlock(text))
208
+ }
209
+
210
+ for (const node of nodes) {
211
+ // An unrecognised wrapper is transparent: splice its children into this level rather than dropping
212
+ // the content or forcing it inline, so a consumer's stray <div>/<section> keeps its structure.
213
+ if (node.kind === 'el' && !BLOCK_TAGS.has(node.tag) && !VOID_TAGS.has(node.tag) && !INLINE_WRAP[node.tag]
214
+ && node.tag !== 'a' && node.tag !== 'li' && node.children.some(isBlockNode)) {
215
+ flushRun()
216
+ blocks.push(...renderBlocks(node.children, opts))
217
+ continue
218
+ }
219
+ if (node.kind !== 'el' || !BLOCK_TAGS.has(node.tag)) { run.push(node); continue }
220
+ flushRun()
221
+
222
+ const level = HEADINGS[node.tag]
223
+ if (level !== undefined) {
224
+ // A heading is one line by definition: a `<br>` inside it would end the heading and leave the rest
225
+ // as a bare paragraph, silently splitting the text an editor wrote as one title.
226
+ const text = renderInline(node.children).replace(/\s+/g, ' ').trim()
227
+ if (text) blocks.push(`${'#'.repeat(Math.min(6, level + (opts.headingOffset ?? 0)))} ${text}`)
228
+ } else if (node.tag === 'hr') {
229
+ blocks.push('---')
230
+ } else if (node.tag === 'pre') {
231
+ const code = rawText(node.children).replace(/^\n+|\n+$/g, '')
232
+ // Fence wider than any backtick run in the code, or the code closes the fence it sits in and
233
+ // everything after it — the rest of the page, and the pages after that — reparses as prose.
234
+ const fence = '`'.repeat(Math.max(3, longestRun(code, '`') + 1))
235
+ if (code) blocks.push(`${fence}\n${code}\n${fence}`)
236
+ } else if (node.tag === 'blockquote') {
237
+ const inner = renderBlocks(node.children, opts).join('\n\n')
238
+ if (inner) blocks.push(prefixLines(inner, '> ', '> '))
239
+ } else if (node.tag === 'ul' || node.tag === 'ol') {
240
+ const list = renderList(node, opts)
241
+ if (list) blocks.push(list)
242
+ } else {
243
+ const text = renderInline(node.children).trim()
244
+ if (text) blocks.push(escapeMarkdownBlock(text))
245
+ }
246
+ }
247
+ flushRun()
248
+ return blocks
249
+ }
250
+
251
+ function isBlockNode(node: MdNode): boolean {
252
+ return node.kind === 'el' && (BLOCK_TAGS.has(node.tag) || node.tag === 'li')
253
+ }
254
+
255
+ /** Sanitized richtext HTML → Markdown. Non-string / empty input, and markup that carries no words at
256
+ * all, both yield `''` so a caller can drop the field without a second emptiness test. */
257
+ export function richtextToMarkdown(html: string | null | undefined, opts: RichtextMarkdownOptions = {}): string {
258
+ if (typeof html !== 'string' || !html.trim()) return ''
259
+ return renderBlocks(parse(html), opts).join('\n\n')
260
+ }
@@ -30,3 +30,11 @@ export function siteDescription(): string {
30
30
  const fromRc = serverRuntimeConfig()?.kestrel as { siteDescription?: string } | undefined
31
31
  return (fromRc?.siteDescription ?? resolveServerKestrel().siteDescription ?? '').trim()
32
32
  }
33
+
34
+ /** Whether `/llms-full.txt` is served, prerendered and published (`kestrel.seo.llmsFull`, default off).
35
+ * Read the same two-tier way as every other server setting, so a consumed layer sees the CONSUMER's
36
+ * config rather than Kestrel's own. */
37
+ export function llmsFullEnabled(): boolean {
38
+ const fromRc = serverRuntimeConfig()?.kestrel as { seo?: { llmsFull?: boolean } } | undefined
39
+ return (fromRc?.seo?.llmsFull ?? resolveServerKestrel().seo.llmsFull) === true
40
+ }
@@ -87,11 +87,13 @@ export function buildSitemap(entries: SitemapEntry[]): string {
87
87
  return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>${urls}</urlset>`
88
88
  }
89
89
 
90
- export function buildRobots(opts: { sitemapUrl?: string; llmsUrl?: string } = {}): string {
90
+ export function buildRobots(opts: { sitemapUrl?: string; llmsUrl?: string; llmsFullUrl?: string } = {}): string {
91
91
  const lines = ['User-agent: *', 'Allow: /']
92
- // A comment (robots.txt has no standard llms directive) so crawlers that read robots also find the
93
- // agent-oriented site map at /llms.txt. Kept above the blank-line-separated Sitemap directive.
92
+ // Comments (robots.txt has no standard llms directive) so crawlers that read robots also find the
93
+ // agent-oriented site map at /llms.txt and its full-text long form when that is switched on.
94
+ // Kept above the blank-line-separated Sitemap directive.
94
95
  if (opts.llmsUrl) lines.push(`# llms.txt: ${opts.llmsUrl}`)
96
+ if (opts.llmsFullUrl) lines.push(`# llms-full.txt: ${opts.llmsFullUrl}`)
95
97
  if (opts.sitemapUrl) lines.push('', `Sitemap: ${opts.sitemapUrl}`)
96
98
  return `${lines.join('\n')}\n`
97
99
  }
@@ -5,17 +5,22 @@ import UiFieldset from '../ui/Fieldset.vue'
5
5
  import UiSelect from '../ui/Select.vue'
6
6
  import UiButtonGroup from '../ui/ButtonGroup.vue'
7
7
  import UiCheckboxGroup from '../ui/CheckboxGroup.vue'
8
+ import { resolveLocalized } from '../../utils/localized'
8
9
  import type { FieldComponentProps } from '../../utils/field-component'
9
10
  import type { FieldOf } from '../../../../core/server/utils/defineCollection'
10
11
 
11
12
  const props = defineProps<FieldComponentProps>()
12
13
  const model = defineModel<string | string[] | null>()
13
14
 
15
+ const { lang } = useT()
16
+
14
17
  const cfg = computed(() => {
15
18
  if (props.field.type !== 'choice') return { choices: [] as { label: string; value: string }[], multiple: false, buttons: false }
16
19
  const o = (props.field as FieldOf<'choice'>).options
17
20
  return {
18
- choices: o.choices,
21
+ // A choice label is `Localized` like every other author-supplied label. Resolving it HERE (not in the
22
+ // controls) is what keeps it out of the markup as a JSON blob — `{{ o.label }}` would stringify the map.
23
+ choices: o.choices.map((c) => ({ ...c, label: resolveLocalized(c.label, lang.value) ?? c.value })),
19
24
  multiple: !!o.multiple,
20
25
  buttons: o.display === 'buttons',
21
26
  }
@@ -188,6 +188,12 @@ export const de: Catalog = {
188
188
  'editor.unsupported': 'Für den Typ „{type}“ ist kein Editor registriert.',
189
189
  'seo.socialImage': 'Social-Share-Bild',
190
190
  'seo.socialImageHint': 'Wird als Vorschaubild beim Teilen der Seite angezeigt (og:image).',
191
+ 'seo.author': 'Autor/in',
192
+ 'seo.authorHint': 'Wird in den strukturierten Daten der Seite als Verfasser/in veröffentlicht.',
193
+ 'seo.publishedDate': 'Veröffentlichungsdatum',
194
+ 'seo.publishedDateHint': 'Zeigt Such- und Antwortmaschinen, wie aktuell diese Seite ist.',
195
+ 'seo.keywords': 'Schlagwörter',
196
+ 'seo.keywordsHint': 'Themen dieser Seite, durch Kommas getrennt.',
191
197
 
192
198
  'localeBar.fieldLabel': 'Sprache',
193
199
  'localeBar.groupLabel': 'Inhaltssprache',
@@ -313,6 +319,13 @@ export const de: Catalog = {
313
319
  'mediaViewer.alt': 'Alt-Text',
314
320
  'mediaViewer.altHint': 'Beschreibt das Bild für Screenreader und SEO.',
315
321
  'mediaViewer.saveFailed': 'Alt-Text konnte nicht gespeichert werden.',
322
+ 'mediaViewer.aiSourceTypeLabel': 'KI-Kennzeichnung',
323
+ 'mediaViewer.aiSourceTypeHint': 'Wie diese Datei entstanden ist (EU-KI-Verordnung Art. 50). Leer lassen, wenn nichts zu kennzeichnen ist.',
324
+ 'mediaViewer.aiSourceType.trainedAlgorithmicMedia': 'Vollständig KI-generiert',
325
+ 'mediaViewer.aiSourceType.compositeWithTrainedAlgorithmicMedia': 'KI-Inhalt in echtes Material montiert',
326
+ 'mediaViewer.aiSourceType.algorithmicallyEnhanced': 'KI-bearbeitet / algorithmisch verändert',
327
+ 'mediaViewer.aiNote': 'Hinweis zur Kennzeichnung',
328
+ 'mediaViewer.aiNoteHint': 'Freitext, z. B. das verwendete Werkzeug. Beim Upload gefundene KI-Signale werden vorausgefüllt — nur Indizien, kein geprüfter Nachweis.',
316
329
 
317
330
  // richtext toolbar
318
331
  'richtext.bold': 'Fett',
@@ -201,6 +201,12 @@ export const en: Catalog = {
201
201
  'editor.unsupported': 'No editor is registered for type “{type}”.',
202
202
  'seo.socialImage': 'Social share image',
203
203
  'seo.socialImageHint': 'Shown as the preview image when the page is shared (og:image).',
204
+ 'seo.author': 'Author',
205
+ 'seo.authorHint': 'Published as the article author in the page’s structured data.',
206
+ 'seo.publishedDate': 'Publication date',
207
+ 'seo.publishedDateHint': 'Tells search and answer engines how current this page is.',
208
+ 'seo.keywords': 'Keywords',
209
+ 'seo.keywordsHint': 'Comma-separated topics for this page.',
204
210
 
205
211
  // locale bar (translations)
206
212
  'localeBar.fieldLabel': 'Locale',
@@ -337,6 +343,13 @@ export const en: Catalog = {
337
343
  'mediaViewer.alt': 'Alt text',
338
344
  'mediaViewer.altHint': 'Describes the image for screen readers and SEO.',
339
345
  'mediaViewer.saveFailed': "Couldn't save the alt text.",
346
+ 'mediaViewer.aiSourceTypeLabel': 'AI disclosure',
347
+ 'mediaViewer.aiSourceTypeHint': 'How this asset was produced (EU AI Act Art. 50). Leave empty when nothing needs disclosing.',
348
+ 'mediaViewer.aiSourceType.trainedAlgorithmicMedia': 'Fully AI-generated',
349
+ 'mediaViewer.aiSourceType.compositeWithTrainedAlgorithmicMedia': 'AI content composited into real media',
350
+ 'mediaViewer.aiSourceType.algorithmicallyEnhanced': 'AI-enhanced / algorithmically edited',
351
+ 'mediaViewer.aiNote': 'Disclosure note',
352
+ 'mediaViewer.aiNoteHint': 'Free text, e.g. the tool used. Pre-filled with any AI signals found in the file at upload — evidence only, not a verified claim.',
340
353
 
341
354
  // richtext toolbar
342
355
  'richtext.bold': 'Bold',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
@@ -78,6 +78,7 @@
78
78
  "dompurify": "^3.4.13",
79
79
  "drizzle-orm": "^0.45.2",
80
80
  "drizzle-zod": "^0.8.3",
81
+ "exifr": "^7.1.3",
81
82
  "file-type": "^22.0.1",
82
83
  "jsdom": "^29.1.1",
83
84
  "nanoid": "^5.1.16",
@@ -16,5 +16,8 @@ export default defineNuxtConfig({
16
16
  media: { uploadDir: '.data/uploads' },
17
17
  // locales: ['en', 'de'],
18
18
  // collections: { pages: false },
19
+ // Answer-engine extras, both off until you opt in: articleMeta publishes author/date/keywords,
20
+ // llmsFull serves /llms-full.txt (every published page's full body in one file).
21
+ // seo: { articleMeta: true, llmsFull: true },
19
22
  },
20
23
  })