@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
package/layers/ui/app/i18n/en.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const en: Catalog = {
|
|
|
7
7
|
// common / shared
|
|
8
8
|
'common.new': 'New {label}',
|
|
9
9
|
'common.save': 'Save',
|
|
10
|
+
'common.publish': 'Publish',
|
|
10
11
|
'common.cancel': 'Cancel',
|
|
11
12
|
'common.create': 'Create',
|
|
12
13
|
'common.delete': 'Delete',
|
|
@@ -154,6 +155,11 @@ export const en: Catalog = {
|
|
|
154
155
|
|
|
155
156
|
// editor action bar — live preview + status "Ampel"
|
|
156
157
|
'editor.openInNewTab': 'Open in new tab',
|
|
158
|
+
'editor.previewUnsaved': 'Preview unsaved changes in a new tab',
|
|
159
|
+
'editor.previewFailed': 'Could not open the preview.',
|
|
160
|
+
'editor.publishFailed': 'Publishing failed.',
|
|
161
|
+
'editor.publishNotGenerated': 'Static output is off in this environment — nothing was written.',
|
|
162
|
+
'editor.publishDraft': 'A draft has no public page. Publish it by setting its status to Published.',
|
|
157
163
|
// editor Ampel — LEFT lamp: one WORD + a details tooltip (save + publish lifecycle of this record)
|
|
158
164
|
'editorStatus.word.saving': 'Saving…',
|
|
159
165
|
'editorStatus.word.unsaved': 'Unsaved',
|
|
@@ -168,12 +174,16 @@ export const en: Catalog = {
|
|
|
168
174
|
// editor Ampel — RIGHT lamp: one WORD + a details tooltip (generated state of the static page)
|
|
169
175
|
'editorStatus.word.live': 'Live',
|
|
170
176
|
'editorStatus.word.generating': 'Generating…',
|
|
177
|
+
'editorStatus.word.notPublished': 'Not published',
|
|
171
178
|
'editorStatus.word.notBuilt': 'Not built',
|
|
172
179
|
'editorStatus.word.notLive': 'Not live',
|
|
173
180
|
'editorStatus.word.error': 'Error',
|
|
181
|
+
'editorStatus.word.outdated': 'Outdated',
|
|
174
182
|
'editorStatus.live.liveS3': 'Live — page uploaded to S3.',
|
|
175
183
|
'editorStatus.live.liveLocal': 'Live — page written to the static output.',
|
|
176
184
|
'editorStatus.live.pending': 'Publishing in progress…',
|
|
185
|
+
'editorStatus.live.outdated': 'Saved but not published — the live page still shows the last published version.',
|
|
186
|
+
'editorStatus.live.notPublished': 'Never published — press Publish to put this page on the live site.',
|
|
177
187
|
'editorStatus.live.notBuilt': 'Not generated in this environment — the static page is built on deploy.',
|
|
178
188
|
'editorStatus.live.error': 'Publishing the static page failed.',
|
|
179
189
|
'editorStatus.live.draft': 'Draft — not generated. It stays off the live site until published.',
|
|
@@ -191,6 +201,12 @@ export const en: Catalog = {
|
|
|
191
201
|
'editor.unsupported': 'No editor is registered for type “{type}”.',
|
|
192
202
|
'seo.socialImage': 'Social share image',
|
|
193
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.',
|
|
194
210
|
|
|
195
211
|
// locale bar (translations)
|
|
196
212
|
'localeBar.fieldLabel': 'Locale',
|
|
@@ -327,6 +343,13 @@ export const en: Catalog = {
|
|
|
327
343
|
'mediaViewer.alt': 'Alt text',
|
|
328
344
|
'mediaViewer.altHint': 'Describes the image for screen readers and SEO.',
|
|
329
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.',
|
|
330
353
|
|
|
331
354
|
// richtext toolbar
|
|
332
355
|
'richtext.bold': 'Bold',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michaelthielemann/kestrel",
|
|
3
|
-
"version": "1.
|
|
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>",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=22.12.0"
|
|
46
46
|
},
|
|
47
|
+
"//lint": "The root eslint.config.mjs imports the generated playground/.nuxt/eslint.config.mjs (Nuxt-aware: auto-import globals for every layer + extension) — `nuxt prepare` must run first or that import 404s on a fresh checkout. Cheap on a warm .nuxt cache, so it's chained rather than a separate step.",
|
|
47
48
|
"scripts": {
|
|
48
49
|
"dev": "nuxt dev",
|
|
49
50
|
"build": "nuxt build",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
57
58
|
"test:nuxt": "vitest run --config vitest.nuxt.config.ts",
|
|
58
59
|
"typecheck": "node scripts/typecheck.mjs",
|
|
60
|
+
"lint": "nuxt prepare playground && eslint .",
|
|
59
61
|
"hash-password": "node scripts/hash-password.mjs"
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
@@ -76,6 +78,7 @@
|
|
|
76
78
|
"dompurify": "^3.4.13",
|
|
77
79
|
"drizzle-orm": "^0.45.2",
|
|
78
80
|
"drizzle-zod": "^0.8.3",
|
|
81
|
+
"exifr": "^7.1.3",
|
|
79
82
|
"file-type": "^22.0.1",
|
|
80
83
|
"jsdom": "^29.1.1",
|
|
81
84
|
"nanoid": "^5.1.16",
|
|
@@ -110,11 +113,14 @@
|
|
|
110
113
|
}
|
|
111
114
|
},
|
|
112
115
|
"devDependencies": {
|
|
116
|
+
"@nuxt/eslint": "^1.9.0",
|
|
113
117
|
"@nuxt/kit": "^4.5.2",
|
|
114
118
|
"@nuxt/test-utils": "^4.1.0",
|
|
115
119
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
116
120
|
"@vue/test-utils": "^2.4.11",
|
|
117
121
|
"drizzle-kit": "^0.31.10",
|
|
122
|
+
"eslint": "^9.39.1",
|
|
123
|
+
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
|
118
124
|
"h3": "^1.15.11",
|
|
119
125
|
"happy-dom": "^20.11.2",
|
|
120
126
|
"playwright-core": "^1.62.1",
|
package/scripts/kestrel.mjs
CHANGED
|
@@ -30,13 +30,16 @@ function inspect(target) {
|
|
|
30
30
|
packageJson: readIf(join(target, 'package.json')),
|
|
31
31
|
nuxtConfig: readIf(join(target, 'nuxt.config.ts')) ?? readIf(join(target, 'nuxt.config.js')),
|
|
32
32
|
appVue: readIf(join(target, 'app', 'app.vue')) ?? readIf(join(target, 'app.vue')),
|
|
33
|
+
kestrelConfig: readIf(join(target, 'kestrel.config.ts')) ?? readIf(join(target, 'kestrel.config.js')),
|
|
33
34
|
env: readIf(join(target, '.env')),
|
|
34
35
|
})
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
const LEVEL_LABEL = { error: () => red('✖ error'), info: () => dim('· note '), warn: () => yellow('▲ warn ') }
|
|
39
|
+
|
|
37
40
|
function printFindings(found) {
|
|
38
41
|
for (const d of found) {
|
|
39
|
-
out(`${d.level
|
|
42
|
+
out(`${(LEVEL_LABEL[d.level] ?? LEVEL_LABEL.warn)()} ${d.message}`)
|
|
40
43
|
out()
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -170,10 +173,11 @@ function doctor(positional) {
|
|
|
170
173
|
const found = inspect(target)
|
|
171
174
|
|
|
172
175
|
out()
|
|
173
|
-
|
|
176
|
+
// An `info` finding is a note about how this project is configured, not a problem with it — so it prints,
|
|
177
|
+
// but it must not withhold the all-clear the way a warning does.
|
|
178
|
+
if (!found.some((d) => d.level !== 'info')) {
|
|
174
179
|
out(`${green('✔')} ${relative(process.cwd(), target) || '.'} looks like a working Kestrel project.`)
|
|
175
180
|
out()
|
|
176
|
-
return 0
|
|
177
181
|
}
|
|
178
182
|
printFindings(found)
|
|
179
183
|
return found.some((d) => d.level === 'error') ? 1 : 0
|
package/scripts/lib/scaffold.mjs
CHANGED
|
@@ -110,11 +110,22 @@ const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
|
|
|
110
110
|
const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
|
|
111
111
|
export const usesComponent = (src, tag) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
|
|
112
112
|
|
|
113
|
+
/** Whether `output.publishOnSave` resolves to `true` — config or its env override, same precedence as
|
|
114
|
+
* `resolveKestrel` (env wins when it names a valid boolean; otherwise the config file's own value). */
|
|
115
|
+
function publishOnSaveEnabled(kestrelConfig, env) {
|
|
116
|
+
const fromEnv = env !== null ? envValue(env, 'KESTREL_OUTPUT_PUBLISH_ON_SAVE')?.toLowerCase() : undefined
|
|
117
|
+
if (fromEnv !== undefined) {
|
|
118
|
+
if (['true', '1', 'yes', 'on'].includes(fromEnv)) return true
|
|
119
|
+
if (['false', '0', 'no', 'off'].includes(fromEnv)) return false
|
|
120
|
+
}
|
|
121
|
+
return typeof kestrelConfig === 'string' && /\bpublishOnSave\s*:\s*true\b/.test(kestrelConfig)
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
/**
|
|
114
125
|
* `kestrel doctor`, as a pure function of what was read; `null` means the file is absent.
|
|
115
126
|
* The `app.vue` rules mirror `layers/core/modules/kestrel/app-shell.ts`; a test pins the two together.
|
|
116
127
|
*/
|
|
117
|
-
export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
128
|
+
export function diagnoseProject({ packageJson, nuxtConfig, appVue, env, kestrelConfig }) {
|
|
118
129
|
const found = []
|
|
119
130
|
const add = (level, message) => found.push({ level, message })
|
|
120
131
|
|
|
@@ -179,5 +190,16 @@ export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
|
193
|
+
// Informational, not a misconfiguration: `publishOnSave: false` is the correct 2.0 default. Named
|
|
194
|
+
// because it is a silent behaviour change from 1.x — a headless consumer that only PATCHes content and
|
|
195
|
+
// never opens /admin gets no other signal that saves stopped reaching the static output.
|
|
196
|
+
if (!publishOnSaveEnabled(kestrelConfig, env)) {
|
|
197
|
+
add(
|
|
198
|
+
'info',
|
|
199
|
+
'saving a record no longer publishes it by default — only an explicit Publish action does (ADR-0008). ' +
|
|
200
|
+
'Set `output.publishOnSave: true` in kestrel.config.ts, or KESTREL_OUTPUT_PUBLISH_ON_SAVE=1, to restore the pre-2.0 behaviour.',
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
return found
|
|
183
205
|
}
|
|
@@ -8,5 +8,6 @@ defineBlock({ label: { en: 'Prose' }, icon: 'file-text' })
|
|
|
8
8
|
|
|
9
9
|
<template>
|
|
10
10
|
<!-- richtext is sanitized server-side on write -->
|
|
11
|
+
<!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in layers/fields/server/field-registry/index.ts -->
|
|
11
12
|
<div class="block-prose" v-html="body" />
|
|
12
13
|
</template>
|
|
@@ -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
|
})
|