@michaelthielemann/kestrel 1.7.0 → 2.0.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 +8 -4
- 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 +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -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 +6 -3
- 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/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/kestrel-config.ts +7 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/public/app/pages/[...slug].vue +27 -6
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- 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/plugins/zz.publish.ts +12 -3
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +24 -0
- 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 +74 -9
- 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 +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +6 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
|
@@ -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.',
|
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.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michaelthielemann/kestrel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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": {
|
|
@@ -110,11 +112,14 @@
|
|
|
110
112
|
}
|
|
111
113
|
},
|
|
112
114
|
"devDependencies": {
|
|
115
|
+
"@nuxt/eslint": "^1.9.0",
|
|
113
116
|
"@nuxt/kit": "^4.5.2",
|
|
114
117
|
"@nuxt/test-utils": "^4.1.0",
|
|
115
118
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
116
119
|
"@vue/test-utils": "^2.4.11",
|
|
117
120
|
"drizzle-kit": "^0.31.10",
|
|
121
|
+
"eslint": "^9.39.1",
|
|
122
|
+
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
|
118
123
|
"h3": "^1.15.11",
|
|
119
124
|
"happy-dom": "^20.11.2",
|
|
120
125
|
"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>
|