@michaelthielemann/kestrel 1.7.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -12
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +13 -5
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/PageFields.vue +5 -0
- package/layers/admin/app/components/SeoFields.vue +43 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +15 -6
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +12 -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/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/defineCollection.ts +8 -1
- package/layers/core/server/utils/kestrel-config.ts +42 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- 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 +11 -6
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/MediaViewer.vue +56 -7
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- 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 +57 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/app/utils/preview-protocol.ts +36 -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/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- 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/plugins/zz.publish.ts +12 -3
- 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/tasks/publish/run.ts +2 -1
- 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/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +72 -3
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +85 -14
- 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/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +23 -0
- package/layers/ui/app/i18n/en.ts +23 -0
- package/package.json +7 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -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. ` ` 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
|
-
//
|
|
93
|
-
// agent-oriented site map at /llms.txt
|
|
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
|
-
|
|
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
|
}
|
|
@@ -59,10 +59,12 @@ const rangeEnd = computed<string | null>({
|
|
|
59
59
|
<UiFieldset v-else :id="id" :label="name" :error="error" :required="required">
|
|
60
60
|
<template #default="f">
|
|
61
61
|
<template v-if="isTime">
|
|
62
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
|
|
62
63
|
<label class="field-datetime__sub">
|
|
63
64
|
<span class="field-datetime__sublabel">{{ t('field.datetime.range_start') }}</span>
|
|
64
65
|
<UiTimeInput v-model="rangeStart" :disabled="disabled" v-bind="f" />
|
|
65
66
|
</label>
|
|
67
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
|
|
66
68
|
<label class="field-datetime__sub">
|
|
67
69
|
<span class="field-datetime__sublabel">{{ t('field.datetime.range_end') }}</span>
|
|
68
70
|
<UiTimeInput v-model="rangeEnd" :disabled="disabled" v-bind="f" />
|
|
@@ -126,6 +126,7 @@ function insertRowAt(at: number) {
|
|
|
126
126
|
{{ name }}<span v-if="required" aria-hidden="true">*</span>
|
|
127
127
|
</legend>
|
|
128
128
|
|
|
129
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
|
|
129
130
|
<div ref="rowsEl" class="ui-repeater__rows" @dragleave="onDragLeave">
|
|
130
131
|
<template v-for="(row, i) in rows" :key="keys[i]">
|
|
131
132
|
<div class="ui-repeater__insert-zone">
|
|
@@ -140,6 +141,7 @@ function insertRowAt(at: number) {
|
|
|
140
141
|
</button>
|
|
141
142
|
</div>
|
|
142
143
|
|
|
144
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
|
|
143
145
|
<div
|
|
144
146
|
class="ui-repeater__row-wrap"
|
|
145
147
|
:class="{ 'ui-repeater__row-wrap--over': overIndex === i && dragIndex !== i }"
|
|
@@ -25,6 +25,7 @@ watch(() => props.indeterminate, syncIndeterminate)
|
|
|
25
25
|
</script>
|
|
26
26
|
|
|
27
27
|
<template>
|
|
28
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via UiField's attrs fallthrough (single root element), invisible to static analysis -->
|
|
28
29
|
<input
|
|
29
30
|
ref="input"
|
|
30
31
|
v-model="model"
|
|
@@ -23,6 +23,7 @@ function toggle(value: string, checked: boolean) {
|
|
|
23
23
|
|
|
24
24
|
<template>
|
|
25
25
|
<div class="ui-checkbox-group" role="group">
|
|
26
|
+
<!-- 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 -->
|
|
26
27
|
<label v-for="o in options" :key="o.value" class="ui-checkbox-group__item">
|
|
27
28
|
<UiCheckbox
|
|
28
29
|
:model-value="selected.includes(o.value)"
|
|
@@ -117,7 +117,9 @@ function moveChip(from: number, to: number) {
|
|
|
117
117
|
class="ui-combobox"
|
|
118
118
|
>
|
|
119
119
|
<ComboboxAnchor class="ui-combobox__anchor" :data-multiple="multiple || undefined" :aria-invalid="invalid || undefined">
|
|
120
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions, vuejs-accessibility/no-redundant-roles -- @dragleave is a mouse-only progressive enhancement (the chip-move buttons below give the same reorder fully keyboard access); role="list" is NOT redundant here, `list-style: none` below strips the implicit list semantics in WebKit, see the "WebKit list-semantics fix" test -->
|
|
120
121
|
<ul v-if="multiple && selected.length" ref="chips" class="ui-combobox__chips" role="list" @dragleave="onDragLeave">
|
|
122
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the chip-move buttons below give the same reorder fully keyboard access -->
|
|
121
123
|
<li
|
|
122
124
|
v-for="(s, i) in selected"
|
|
123
125
|
:key="s.value"
|
|
@@ -22,6 +22,7 @@ const slotProps = computed(() => ({
|
|
|
22
22
|
|
|
23
23
|
<template>
|
|
24
24
|
<div class="ui-field" :data-invalid="error ? 'true' : undefined">
|
|
25
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- the matching control is the default slot's content, invisible to static analysis across the slot boundary -->
|
|
25
26
|
<label v-if="label" :for="fieldId" class="ui-field__label">
|
|
26
27
|
{{ label }}<span v-if="required" class="ui-field__required" aria-hidden="true">*</span>
|
|
27
28
|
</label>
|
|
@@ -10,7 +10,8 @@ const props = defineProps<{
|
|
|
10
10
|
id?: string
|
|
11
11
|
}>()
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// No `fieldId`: a group has no single control to point a label `for` at (see below).
|
|
14
|
+
const { hintId, errId, describedby, ariaInvalid, ariaRequired } = useFieldA11y(props)
|
|
14
15
|
|
|
15
16
|
// No `id` — a group has no single control to point a label `for` at; the
|
|
16
17
|
// <legend> labels the group natively. Consumers spread these onto the group root.
|
|
@@ -17,7 +17,8 @@ const hasA11yName = computed(() => props.label != null || attrs.role != null ||
|
|
|
17
17
|
<template>
|
|
18
18
|
<!-- v-html body is either a static registry icon or developer-supplied SVG allowlist-sanitised by
|
|
19
19
|
resolveIconBody → sanitizeIconSvg (no script-capable elements/attributes survive). -->
|
|
20
|
-
|
|
20
|
+
<!-- eslint-disable-next-line vue/no-v-html -- sanitized via resolveIconBody -> sanitizeIconSvg (allowlist-only tags/attrs) -->
|
|
21
|
+
<svg v-html="body"
|
|
21
22
|
class="ui-icon"
|
|
22
23
|
:data-icon="dataIcon"
|
|
23
24
|
:width="dim"
|
|
@@ -31,7 +32,6 @@ const hasA11yName = computed(() => props.label != null || attrs.role != null ||
|
|
|
31
32
|
:role="label ? 'img' : undefined"
|
|
32
33
|
:aria-label="label || undefined"
|
|
33
34
|
:aria-hidden="hasA11yName ? undefined : 'true'"
|
|
34
|
-
v-html="body"
|
|
35
35
|
/>
|
|
36
36
|
</template>
|
|
37
37
|
|
|
@@ -55,6 +55,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
55
55
|
|
|
56
56
|
<template>
|
|
57
57
|
<div v-if="suffix" class="ui-number-wrap" :class="{ 'ui-number-wrap--slim': slim }" :data-disabled="disabled || undefined">
|
|
58
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
58
59
|
<input
|
|
59
60
|
v-model="text"
|
|
60
61
|
type="number"
|
|
@@ -70,6 +71,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
70
71
|
>
|
|
71
72
|
<span class="ui-number-wrap__suffix" aria-hidden="true">{{ suffix }}</span>
|
|
72
73
|
</div>
|
|
74
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
73
75
|
<input
|
|
74
76
|
v-else
|
|
75
77
|
v-model="text"
|
|
@@ -8,6 +8,7 @@ import { Superscript } from '@tiptap/extension-superscript'
|
|
|
8
8
|
import { TextAlign } from '@tiptap/extension-text-align'
|
|
9
9
|
import RichtextToolbar from './RichtextToolbar.vue'
|
|
10
10
|
import { RichtextSpanClass, RichtextBlockClass } from '../../utils/richtext-preserve-class'
|
|
11
|
+
import { RICHTEXT_LINK_SCHEME } from '../../../../fields/app/utils/richtext-links'
|
|
11
12
|
|
|
12
13
|
const props = withDefaults(
|
|
13
14
|
defineProps<{
|
|
@@ -24,6 +25,11 @@ const props = withDefaults(
|
|
|
24
25
|
)
|
|
25
26
|
const model = defineModel<string | null>()
|
|
26
27
|
|
|
28
|
+
// TipTap emits `update` for non-content changes too — `setEditable`/`setOptions` emit directly, past the
|
|
29
|
+
// transaction pipeline and its docChanged guard — and that echo reaches the edit form as a user edit.
|
|
30
|
+
// Only a changed doc is one, so every emit is checked against the doc it last saw.
|
|
31
|
+
let lastDoc: unknown = null
|
|
32
|
+
|
|
27
33
|
// StarterKit (v3) already bundles Link + Underline; only the rest are added here.
|
|
28
34
|
const editor = useEditor({
|
|
29
35
|
content: model.value ?? '',
|
|
@@ -33,7 +39,13 @@ const editor = useEditor({
|
|
|
33
39
|
extensions: [
|
|
34
40
|
StarterKit.configure({
|
|
35
41
|
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
36
|
-
|
|
42
|
+
// `protocols`: the Link mark validates every href against its own scheme allowlist, which has no
|
|
43
|
+
// `kestrel:`. Without this it drops the whole anchor when stored content is loaded and makes the
|
|
44
|
+
// toolbar's `setLink` a silent no-op — opening and saving a record would destroy its internal links.
|
|
45
|
+
// `target`/`rel` null: the sanitizer owns that policy — it decorates absolute http(s) links and
|
|
46
|
+
// strips the attributes from every other kind. The mark's own defaults would stamp them on ANY
|
|
47
|
+
// anchor, so a stored internal/relative/mailto link came back changed and read as an unsaved edit.
|
|
48
|
+
link: { openOnClick: false, protocols: [RICHTEXT_LINK_SCHEME], HTMLAttributes: { target: null, rel: null } },
|
|
37
49
|
}),
|
|
38
50
|
Highlight,
|
|
39
51
|
Subscript,
|
|
@@ -41,7 +53,12 @@ const editor = useEditor({
|
|
|
41
53
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
42
54
|
RichtextSpanClass, RichtextBlockClass, // preserve consumer presentational classes on round-trip
|
|
43
55
|
],
|
|
56
|
+
onCreate: ({ editor }) => {
|
|
57
|
+
lastDoc = editor.state.doc
|
|
58
|
+
},
|
|
44
59
|
onUpdate: ({ editor }) => {
|
|
60
|
+
if (editor.state.doc === lastDoc) return
|
|
61
|
+
lastDoc = editor.state.doc
|
|
45
62
|
model.value = editor.isEmpty ? '' : editor.getHTML()
|
|
46
63
|
},
|
|
47
64
|
})
|
|
@@ -53,10 +70,15 @@ watch(model, (value) => {
|
|
|
53
70
|
const current = e.isEmpty ? '' : e.getHTML()
|
|
54
71
|
// `emitUpdate: false`: this value came FROM the model, so re-emitting it would reach the edit form as
|
|
55
72
|
// a user edit — clearing the redo stack and turning a restored null back into ''.
|
|
56
|
-
if (next !== current)
|
|
73
|
+
if (next !== current) {
|
|
74
|
+
e.commands.setContent(next, { emitUpdate: false })
|
|
75
|
+
lastDoc = e.state.doc
|
|
76
|
+
}
|
|
57
77
|
})
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
// `false` — the second arg suppresses the update event; `disabled` tracks the form's `saving` flag, so
|
|
80
|
+
// the post-save toggle would otherwise re-dirty the form that was just rebaselined.
|
|
81
|
+
watch(() => props.disabled, (d) => editor.value?.setEditable(!d, false))
|
|
60
82
|
|
|
61
83
|
// Apply the accessible name + textbox semantics directly to the ProseMirror surface, reactively (the
|
|
62
84
|
// error-driven describedby/invalid arrive after mount). `editor.view.dom` is the contenteditable element.
|
|
@@ -17,6 +17,7 @@ const model = defineModel<string | null>()
|
|
|
17
17
|
|
|
18
18
|
<template>
|
|
19
19
|
<span class="ui-select">
|
|
20
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
20
21
|
<select v-model="model" :disabled="disabled" class="ui-select__field" v-bind="$attrs">
|
|
21
22
|
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
|
|
22
23
|
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
@@ -31,6 +31,7 @@ const inputType = computed(() => (revealable.value && revealed.value ? 'text' :
|
|
|
31
31
|
|
|
32
32
|
<template>
|
|
33
33
|
<div class="ui-input-wrap">
|
|
34
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
34
35
|
<input
|
|
35
36
|
v-model="model"
|
|
36
37
|
:type="inputType"
|
|
@@ -12,6 +12,7 @@ const model = defineModel<string | null>()
|
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
14
|
<template>
|
|
15
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via Vue's default attrs fallthrough (single root element) from the wrapping UiField -->
|
|
15
16
|
<textarea
|
|
16
17
|
v-model="model"
|
|
17
18
|
:rows="rows"
|
|
@@ -5,6 +5,7 @@ const model = defineModel<string | null>()
|
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
7
|
<template>
|
|
8
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via Vue's default attrs fallthrough (single root element) from the wrapping UiField -->
|
|
8
9
|
<input v-model="model" type="time" :disabled="disabled" class="ui-time">
|
|
9
10
|
</template>
|
|
10
11
|
|
package/layers/ui/app/i18n/de.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Catalog } from '../composables/useT'
|
|
|
4
4
|
export const de: Catalog = {
|
|
5
5
|
'common.new': '{label} anlegen',
|
|
6
6
|
'common.save': 'Speichern',
|
|
7
|
+
'common.publish': 'Veröffentlichen',
|
|
7
8
|
'common.cancel': 'Abbrechen',
|
|
8
9
|
'common.create': 'Erstellen',
|
|
9
10
|
'common.delete': 'Löschen',
|
|
@@ -142,6 +143,11 @@ export const de: Catalog = {
|
|
|
142
143
|
'pageSettings.statusPublished': 'Veröffentlicht',
|
|
143
144
|
|
|
144
145
|
'editor.openInNewTab': 'In neuem Tab öffnen',
|
|
146
|
+
'editor.previewUnsaved': 'Ungespeicherte Änderungen in neuem Tab ansehen',
|
|
147
|
+
'editor.previewFailed': 'Vorschau konnte nicht geöffnet werden.',
|
|
148
|
+
'editor.publishFailed': 'Veröffentlichen fehlgeschlagen.',
|
|
149
|
+
'editor.publishNotGenerated': 'Statischer Output ist in dieser Umgebung aus — es wurde nichts geschrieben.',
|
|
150
|
+
'editor.publishDraft': 'Ein Entwurf hat keine öffentliche Seite. Zum Veröffentlichen den Status auf Veröffentlicht setzen.',
|
|
145
151
|
// editor status light — LEFT lamp: ONE word + details tooltip (save/publish status)
|
|
146
152
|
'editorStatus.word.saving': 'Speichert…',
|
|
147
153
|
'editorStatus.word.unsaved': 'Ungespeichert',
|
|
@@ -156,12 +162,16 @@ export const de: Catalog = {
|
|
|
156
162
|
// editor status light — RIGHT lamp: ONE word + details tooltip (static-page generation status)
|
|
157
163
|
'editorStatus.word.live': 'Live',
|
|
158
164
|
'editorStatus.word.generating': 'Generiert…',
|
|
165
|
+
'editorStatus.word.notPublished': 'Nicht veröffentlicht',
|
|
159
166
|
'editorStatus.word.notBuilt': 'Nicht gebaut',
|
|
160
167
|
'editorStatus.word.notLive': 'Nicht live',
|
|
161
168
|
'editorStatus.word.error': 'Fehler',
|
|
169
|
+
'editorStatus.word.outdated': 'Veraltet',
|
|
162
170
|
'editorStatus.live.liveS3': 'Live — Seite auf S3 abgelegt.',
|
|
163
171
|
'editorStatus.live.liveLocal': 'Live — Seite in den statischen Output geschrieben.',
|
|
164
172
|
'editorStatus.live.pending': 'Wird veröffentlicht…',
|
|
173
|
+
'editorStatus.live.outdated': 'Gespeichert, aber nicht veröffentlicht — live steht noch die zuletzt veröffentlichte Fassung.',
|
|
174
|
+
'editorStatus.live.notPublished': 'Nie veröffentlicht — mit Veröffentlichen kommt die Seite auf die Live-Site.',
|
|
165
175
|
'editorStatus.live.notBuilt': 'In dieser Umgebung nicht generiert — die statische Seite wird beim Deploy gebaut.',
|
|
166
176
|
'editorStatus.live.error': 'Generieren der statischen Seite fehlgeschlagen.',
|
|
167
177
|
'editorStatus.live.draft': 'Entwurf — nicht generiert. Erscheint erst nach Veröffentlichung auf der Live-Seite.',
|
|
@@ -178,6 +188,12 @@ export const de: Catalog = {
|
|
|
178
188
|
'editor.unsupported': 'Für den Typ „{type}“ ist kein Editor registriert.',
|
|
179
189
|
'seo.socialImage': 'Social-Share-Bild',
|
|
180
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.',
|
|
181
197
|
|
|
182
198
|
'localeBar.fieldLabel': 'Sprache',
|
|
183
199
|
'localeBar.groupLabel': 'Inhaltssprache',
|
|
@@ -303,6 +319,13 @@ export const de: Catalog = {
|
|
|
303
319
|
'mediaViewer.alt': 'Alt-Text',
|
|
304
320
|
'mediaViewer.altHint': 'Beschreibt das Bild für Screenreader und SEO.',
|
|
305
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.',
|
|
306
329
|
|
|
307
330
|
// richtext toolbar
|
|
308
331
|
'richtext.bold': 'Fett',
|