@michaelthielemann/kestrel 2.0.0 → 3.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.
Files changed (117) hide show
  1. package/README.md +16 -8
  2. package/layers/admin/app/components/AdminAccount.vue +3 -3
  3. package/layers/admin/app/components/AdminNav.vue +1 -1
  4. package/layers/admin/app/components/BlockFields.vue +2 -2
  5. package/layers/admin/app/components/BlockPreview.vue +8 -8
  6. package/layers/admin/app/components/BlockTree.vue +10 -10
  7. package/layers/admin/app/components/BlocksBody.vue +4 -4
  8. package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
  9. package/layers/admin/app/components/CollectionEditor.vue +3 -3
  10. package/layers/admin/app/components/CollectionList.vue +91 -788
  11. package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
  12. package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
  14. package/layers/admin/app/components/CollectionListPager.vue +70 -0
  15. package/layers/admin/app/components/CollectionListTable.vue +308 -0
  16. package/layers/admin/app/components/EditorStatus.vue +4 -4
  17. package/layers/admin/app/components/FieldsBody.vue +1 -1
  18. package/layers/admin/app/components/LocaleBar.vue +4 -4
  19. package/layers/admin/app/components/PageFields.vue +17 -12
  20. package/layers/admin/app/components/PageFieldsPane.vue +2 -2
  21. package/layers/admin/app/components/SeoFields.vue +52 -10
  22. package/layers/admin/app/components/SingletonEditor.vue +7 -7
  23. package/layers/admin/app/composables/useEditForm.ts +9 -3
  24. package/layers/admin/app/composables/useListBatchActions.ts +61 -0
  25. package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
  26. package/layers/admin/app/composables/useListRows.ts +70 -0
  27. package/layers/admin/app/composables/useListSelection.ts +28 -0
  28. package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
  29. package/layers/admin/app/layouts/admin.vue +7 -7
  30. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
  31. package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
  32. package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
  33. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
  34. package/layers/admin/app/pages/admin/index.vue +3 -3
  35. package/layers/admin/app/pages/admin/login.vue +6 -6
  36. package/layers/admin/app/pages/admin/media.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +3 -3
  38. package/layers/admin/app/utils/edit-form.ts +9 -2
  39. package/layers/admin/app/utils/list-cell.ts +46 -0
  40. package/layers/admin/nuxt.config.ts +3 -0
  41. package/layers/core/modules/component-namespace/index.ts +20 -0
  42. package/layers/core/modules/component-namespace/shared.ts +24 -0
  43. package/layers/core/modules/kestrel/index.ts +11 -0
  44. package/layers/core/nuxt.config.ts +1 -0
  45. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  46. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  47. package/layers/core/server/utils/collection-types.ts +4 -3
  48. package/layers/core/server/utils/defineCollection.ts +8 -2
  49. package/layers/core/server/utils/kestrel-config.ts +35 -1
  50. package/layers/core/server/utils/seo.ts +18 -0
  51. package/layers/core/server/utils/serialize-collection.ts +0 -2
  52. package/layers/core/server/utils/write-effects.ts +40 -0
  53. package/layers/fields/app/utils/field-factories.ts +1 -1
  54. package/layers/fields/server/utils/buildCollection.ts +9 -7
  55. package/layers/media/app/components/KestrelImg.vue +29 -0
  56. package/layers/media/app/components/MediaConflictDialog.vue +7 -7
  57. package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
  58. package/layers/media/app/components/MediaGrid.vue +3 -3
  59. package/layers/media/app/components/MediaLibrary.vue +26 -24
  60. package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
  61. package/layers/media/app/components/MediaPathBar.vue +1 -1
  62. package/layers/media/app/components/MediaPicker.vue +3 -3
  63. package/layers/media/app/components/MediaRenameDialog.vue +8 -8
  64. package/layers/media/app/components/MediaTable.vue +2 -2
  65. package/layers/media/app/components/MediaToolbar.vue +4 -4
  66. package/layers/media/app/components/MediaUploadDialog.vue +9 -9
  67. package/layers/media/app/components/MediaViewer.vue +64 -15
  68. package/layers/media/app/components/field/Media.vue +9 -9
  69. package/layers/media/app/plugins/field-media.client.ts +3 -3
  70. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  71. package/layers/media/app/utils/library.ts +2 -0
  72. package/layers/media/nuxt.config.ts +5 -1
  73. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  74. package/layers/media/server/api/media/[id].patch.ts +25 -1
  75. package/layers/media/server/api/media/index.post.ts +19 -3
  76. package/layers/media/server/collections/media.ts +14 -0
  77. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  78. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  79. package/layers/media/server/utils/library.ts +2 -1
  80. package/layers/media/server/utils/record.ts +8 -0
  81. package/layers/media/server/utils/resolve.ts +12 -0
  82. package/layers/public/app/pages/[...slug].vue +32 -3
  83. package/layers/public/app/pages/__kestrel/preview.vue +1 -1
  84. package/layers/public/app/utils/json-ld.ts +139 -0
  85. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  86. package/layers/public/modules/prerender-routes/index.ts +5 -2
  87. package/layers/public/nuxt.config.ts +3 -0
  88. package/layers/public/server/api/route.get.ts +9 -1
  89. package/layers/public/server/collections/redirects.ts +75 -0
  90. package/layers/public/server/plugins/03.redirects.ts +37 -0
  91. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  92. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  93. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  94. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  95. package/layers/public/server/utils/llms-full.ts +125 -0
  96. package/layers/public/server/utils/llms.ts +13 -0
  97. package/layers/public/server/utils/page-resolve.ts +112 -4
  98. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  99. package/layers/public/server/utils/publish/publisher.ts +11 -5
  100. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  101. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  102. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  103. package/layers/public/server/utils/site-url.ts +8 -0
  104. package/layers/public/server/utils/sitemap.ts +5 -3
  105. package/layers/ui/app/components/field/Choice.vue +6 -1
  106. package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
  107. package/layers/ui/app/components/field/Slug.vue +2 -2
  108. package/layers/ui/app/components/ui/Button.vue +1 -1
  109. package/layers/ui/app/components/ui/EmptyState.vue +1 -1
  110. package/layers/ui/app/components/ui/TextInput.vue +1 -1
  111. package/layers/ui/app/components/ui/Toasts.vue +1 -1
  112. package/layers/ui/app/composables/useToast.ts +1 -1
  113. package/layers/ui/app/i18n/de.ts +13 -0
  114. package/layers/ui/app/i18n/en.ts +13 -0
  115. package/layers/ui/nuxt.config.ts +5 -1
  116. package/package.json +2 -1
  117. package/templates/starter/nuxt.config.ts +3 -0
@@ -32,7 +32,7 @@ useHead(() => ({
32
32
  <aside v-if="authenticated" class="admin__rail">
33
33
  <div class="rail__head">
34
34
  <NuxtLink to="/admin" class="rail__brand" aria-label="Kestrel">
35
- <UiBrand />
35
+ <KestrelUiBrand />
36
36
  <span class="rail__brand-word rail__label">kestrel</span>
37
37
  </NuxtLink>
38
38
  <button
@@ -41,7 +41,7 @@ useHead(() => ({
41
41
  :aria-label="collapsed ? t('a11y.expandSidebar') : t('a11y.collapseSidebar')"
42
42
  @click="toggleRail"
43
43
  >
44
- <UiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
44
+ <KestrelUiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
45
45
  </button>
46
46
  </div>
47
47
 
@@ -51,15 +51,15 @@ useHead(() => ({
51
51
  class="rail__item rail__item--dashboard"
52
52
  :title="collapsed ? t('nav.dashboard') : undefined"
53
53
  >
54
- <UiIcon name="home" class="rail__icon" size="1.25rem" />
54
+ <KestrelUiIcon name="home" class="rail__icon" size="1.25rem" />
55
55
  <span class="rail__label">{{ t('nav.dashboard') }}</span>
56
56
  </NuxtLink>
57
57
 
58
- <AdminNav />
58
+ <KestrelAdminNav />
59
59
  </div>
60
60
 
61
61
  <div class="rail__foot">
62
- <AdminAccount />
62
+ <KestrelAdminAccount />
63
63
  <button
64
64
  type="button"
65
65
  class="rail__item"
@@ -67,14 +67,14 @@ useHead(() => ({
67
67
  :aria-label="theme === 'dark' ? t('a11y.toLight') : t('a11y.toDark')"
68
68
  @click="toggleTheme"
69
69
  >
70
- <UiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
70
+ <KestrelUiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
71
71
  <span class="rail__label">{{ theme === 'dark' ? t('theme.light') : t('theme.dark') }}</span>
72
72
  </button>
73
73
  </div>
74
74
  </aside>
75
75
 
76
76
  <main class="admin__main"><slot /></main>
77
- <UiToasts />
77
+ <KestrelUiToasts />
78
78
  </div>
79
79
  </template>
80
80
 
@@ -8,13 +8,13 @@ import RecordPage from './[id].vue'
8
8
  const thingsSchema = {
9
9
  name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
10
10
  blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
11
- fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
11
+ fields: { title: { type: 'text', required: true, unique: false } },
12
12
  }
13
13
  // Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
14
14
  const articlesSchema = {
15
15
  name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
16
16
  blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
17
- fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
17
+ fields: { title: { type: 'text', required: true, unique: false } },
18
18
  }
19
19
  registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
20
20
  registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
@@ -111,24 +111,24 @@ async function confirmDelete() {
111
111
  <section class="record">
112
112
  <div class="record__head">
113
113
  <NuxtLink :to="listPath" class="record__back">
114
- <UiIcon name="arrow-left" :size="16" />
114
+ <KestrelUiIcon name="arrow-left" :size="16" />
115
115
  <span>{{ t('editor.back', { collection: plural }) }}</span>
116
116
  </NuxtLink>
117
117
  <h1 class="record__title" :class="{ 'record__title--generic': !hasRecordTitle }">{{ heading }}</h1>
118
118
  <div class="record__actions">
119
- <UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
120
- <UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
121
- <UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
122
- <UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
123
- <UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
124
- <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
119
+ <KestrelUiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
120
+ <KestrelUiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
121
+ <KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
122
+ <KestrelUiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</KestrelUiButton>
123
+ <KestrelUiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</KestrelUiButton>
124
+ <KestrelUiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
125
125
  <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
126
- <UiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</UiButton>
127
- <EditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
126
+ <KestrelUiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
127
+ <KestrelEditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
128
128
  </div>
129
129
  </div>
130
- <CollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
131
- <CollectionDeleteDialog
130
+ <KestrelCollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
131
+ <KestrelCollectionDeleteDialog
132
132
  :open="deleteOpen"
133
133
  :report="deleteReport"
134
134
  :busy="deleting"
@@ -29,12 +29,12 @@ const listLocale = computed(() => (def?.translatable ? (localeParam.value || pri
29
29
  <p v-if="!def" class="collection__missing">{{ t('collection.unknown', { name: collection }) }}</p>
30
30
 
31
31
  <template v-else-if="def.mode === 'single'">
32
- <SingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
32
+ <KestrelSingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
33
33
  </template>
34
34
 
35
35
  <template v-else>
36
36
  <h1 class="collection__title">{{ resolveLocalized(def.label?.plural, lang) ?? collection }}</h1>
37
- <CollectionList :schema="def" :locale="listLocale" />
37
+ <KestrelCollectionList :schema="def" :locale="listLocale" />
38
38
  </template>
39
39
  </section>
40
40
  </template>
@@ -9,7 +9,7 @@ import RecordPage from './[id].vue'
9
9
  const pagesSchema = {
10
10
  name: 'pages', mode: 'multi', translatable: false, pageLike: true, seo: false, status: true,
11
11
  blocks: { enabled: false }, label: { singular: 'Page', plural: 'Pages' },
12
- fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
12
+ fields: { title: { type: 'text', required: true, unique: false } },
13
13
  }
14
14
  registerEndpoint('/api/collections', () => ({ data: [pagesSchema] }))
15
15
 
@@ -27,10 +27,10 @@ $fetch<unknown[]>('/api/references/broken')
27
27
  <h1 class="dash__title">{{ t('dash.title') }}</h1>
28
28
  <p class="dash__lede">{{ t('dash.lede') }}</p>
29
29
  <NuxtLink v-if="brokenCount" to="/admin/references" class="dash__alert">
30
- <UiIcon name="triangle-alert" :size="18" />
30
+ <KestrelUiIcon name="triangle-alert" :size="18" />
31
31
  <span>{{ t('refs.dashAlert', { n: brokenCount }) }}</span>
32
32
  </NuxtLink>
33
- <UiEmptyState
33
+ <KestrelUiEmptyState
34
34
  v-if="!collections.length"
35
35
  icon="file-text"
36
36
  :title="t('dash.empty.title')"
@@ -39,7 +39,7 @@ $fetch<unknown[]>('/api/references/broken')
39
39
  <ul v-else class="dash__grid">
40
40
  <li v-for="c in collections" :key="c.name">
41
41
  <NuxtLink :to="`/admin/${c.name}`" class="dash__card">
42
- <UiIcon :name="c.icon ?? 'file-text'" class="dash__icon" size="1.5rem" />
42
+ <KestrelUiIcon :name="c.icon ?? 'file-text'" class="dash__icon" size="1.5rem" />
43
43
  <span class="dash__name">{{ resolveLocalized(c.label?.plural, lang) ?? c.name }}</span>
44
44
  <span class="dash__mode">{{ c.mode === 'single' ? t('dash.mode.singleton') : t('dash.mode.collection') }}</span>
45
45
  </NuxtLink>
@@ -25,22 +25,22 @@ async function submit() {
25
25
  <template>
26
26
  <form class="login" @submit.prevent="submit">
27
27
  <div class="login__brand">
28
- <UiBrand />
28
+ <KestrelUiBrand />
29
29
  <span class="login__brand-word">kestrel</span>
30
30
  </div>
31
31
  <h1 class="login__title">{{ t('login.signIn') }}</h1>
32
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
33
- <UiField :label="t('login.password')">
32
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
33
+ <KestrelUiField :label="t('login.password')">
34
34
  <template #default="f">
35
- <UiTextInput
35
+ <KestrelUiTextInput
36
36
  v-model="password"
37
37
  type="password"
38
38
  autocomplete="current-password"
39
39
  v-bind="f"
40
40
  />
41
41
  </template>
42
- </UiField>
43
- <UiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</UiButton>
42
+ </KestrelUiField>
43
+ <KestrelUiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</KestrelUiButton>
44
44
  </form>
45
45
  </template>
46
46
 
@@ -6,7 +6,7 @@ const { t } = useT()
6
6
  <template>
7
7
  <section class="media-page">
8
8
  <h1 class="media-page__title">{{ t('media.title') }}</h1>
9
- <MediaLibrary />
9
+ <KestrelMediaLibrary />
10
10
  </section>
11
11
  </template>
12
12
 
@@ -26,14 +26,14 @@ try {
26
26
  <template>
27
27
  <section class="refs">
28
28
  <NuxtLink to="/admin" class="refs__back">
29
- <UiIcon name="arrow-left" :size="16" />
29
+ <KestrelUiIcon name="arrow-left" :size="16" />
30
30
  <span>{{ t('nav.dashboard') }}</span>
31
31
  </NuxtLink>
32
32
  <h1 class="refs__title">{{ t('refs.title') }}</h1>
33
33
  <p class="refs__lede">{{ t('refs.lede') }}</p>
34
34
 
35
- <UiAlert v-if="loadError" variant="error" class="refs__error">{{ t('refs.loadError') }}</UiAlert>
36
- <UiEmptyState
35
+ <KestrelUiAlert v-if="loadError" variant="error" class="refs__error">{{ t('refs.loadError') }}</KestrelUiAlert>
36
+ <KestrelUiEmptyState
37
37
  v-else-if="!broken.length"
38
38
  icon="check"
39
39
  :title="t('refs.empty.title')"
@@ -17,6 +17,8 @@ export interface FetchErrorInfo {
17
17
  statusCode?: number
18
18
  statusMessage?: string
19
19
  issues: ServerIssue[]
20
+ /** The error body when it is NOT a Zod-issue array — e.g. `{ savedUpdatedAt }` from a failed write effect. */
21
+ data?: Record<string, unknown>
20
22
  }
21
23
 
22
24
  // The wire `SerializedField` is structurally what every Field* widget and `validateField` read
@@ -26,13 +28,18 @@ export function asFieldDef(field: SerializedField): FieldDef {
26
28
  }
27
29
 
28
30
  // Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
31
+ // The BODY's statusMessage wins over the response's: ofetch maps `statusMessage` from `response.statusText`,
32
+ // and HTTP/2 has no reason phrase — behind any h2 proxy that arrives as '' and the real message is only in
33
+ // the JSON. Anything else under `data` (not an issue array) is handed back for the caller to act on.
29
34
  export function readFetchError(e: unknown): FetchErrorInfo {
30
35
  const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
31
- const raw = err.data?.data ?? err.data
36
+ const body = Array.isArray(err.data) ? undefined : err.data
37
+ const raw = body?.data ?? err.data
32
38
  return {
33
39
  statusCode: err.statusCode,
34
- statusMessage: err.statusMessage,
40
+ statusMessage: (body?.statusMessage as string | undefined) || err.statusMessage,
35
41
  issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
42
+ ...(body && !Array.isArray(body.data) ? { data: body } : {}),
36
43
  }
37
44
  }
38
45
 
@@ -0,0 +1,46 @@
1
+ // Cell rendering for the collection list: how a raw row value becomes the string in a table cell.
2
+ import type { ListColumn } from './list-columns'
3
+ import { humanizeFieldName } from '../../../ui/app/utils/humanize'
4
+
5
+ const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
6
+ // A date-only value ('YYYY-MM-DD', from a datetime field with precision 'date') parses as UTC midnight;
7
+ // formatting it in the local zone would shift the calendar day, so pin it to UTC. Full ISO timestamps
8
+ // (createdAt/updatedAt carry a 'Z') and local datetimes keep the local-zone formatter.
9
+ const dateOnlyFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })
10
+
11
+ export function isDateColumn(col: ListColumn): boolean {
12
+ return col.key === 'createdAt' || col.key === 'updatedAt' || col.field?.type === 'datetime'
13
+ }
14
+
15
+ export function cellText(value: unknown): string {
16
+ if (value == null) return ''
17
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, '').slice(0, 80)
18
+ if (typeof value === 'object') return JSON.stringify(value).slice(0, 80)
19
+ return String(value)
20
+ }
21
+
22
+ export function cellDisplay(col: ListColumn, row: Record<string, unknown>): string {
23
+ const value = row[col.key]
24
+ if (isDateColumn(col) && value != null) {
25
+ const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
26
+ const d = new Date(value as string)
27
+ if (!Number.isNaN(d.getTime())) return (dateOnly ? dateOnlyFmt : dateFmt).format(d)
28
+ }
29
+ return cellText(value)
30
+ }
31
+
32
+ /** A column header's text: a field column humanizes its own name, chrome columns translate a key. */
33
+ export function columnLabel(col: ListColumn, t: (key: string) => string): string {
34
+ return col.type === 'field' ? humanizeFieldName(col.name ?? col.key) : t(col.labelKey ?? col.key)
35
+ }
36
+
37
+ /** A row's human label: the first visible non-sidecar cell that has a value, else the bare id — used for
38
+ * the per-row checkbox / action aria-labels so each control names its row. */
39
+ export function rowLabel(columns: ListColumn[], row: Record<string, unknown>): string {
40
+ for (const c of columns) {
41
+ if (c.type === 'translations' || c.type === 'deadRefs') continue
42
+ const d = cellDisplay(c, row)
43
+ if (d) return d
44
+ }
45
+ return `#${row.id}`
46
+ }
@@ -1,4 +1,7 @@
1
+ import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
+
1
3
  export default defineNuxtConfig({
4
+ components: [kestrelComponents(import.meta.url)],
2
5
  routeRules: {
3
6
  // The admin is a client-rendered SPA.
4
7
  '/admin/**': { ssr: false },
@@ -0,0 +1,20 @@
1
+ import { addComponentsDir, defineNuxtModule } from '@nuxt/kit'
2
+ import { resolve } from 'node:path'
3
+ import { KESTREL_COMPONENT_PREFIX, KESTREL_OVERRIDE_PRIORITY } from './shared'
4
+
5
+ /** Where a consumer places a component that replaces a shipped one, relative to their `srcDir`. */
6
+ export const KESTREL_OVERRIDE_DIR = 'Kestrel/components'
7
+
8
+ export default defineNuxtModule({
9
+ meta: { name: 'kestrel-component-namespace' },
10
+ setup(_options, nuxt) {
11
+ // The trailing `components` segment is load-bearing: Nuxt reports a missing registered directory
12
+ // (NUXT_B3001) unless the path matches its default-components pattern, which requires that suffix.
13
+ // Without it every consumer who never uses the seam would see a build warning.
14
+ addComponentsDir({
15
+ path: resolve(nuxt.options.srcDir, KESTREL_OVERRIDE_DIR),
16
+ prefix: KESTREL_COMPONENT_PREFIX,
17
+ priority: KESTREL_OVERRIDE_PRIORITY,
18
+ })
19
+ },
20
+ })
@@ -0,0 +1,24 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ export const KESTREL_COMPONENT_PREFIX = 'Kestrel'
4
+
5
+ /**
6
+ * Above every layer-derived priority (Nuxt assigns `layerCount - i`, so the consumer's app dir — index 0
7
+ * — otherwise always wins). A consumer file that lands on a `Kestrel*` name anywhere but the override
8
+ * directory therefore loses instead of silently replacing the admin component.
9
+ */
10
+ export const KESTREL_LAYER_PRIORITY = 100
11
+
12
+ /** The one supported override seam, above the layers. */
13
+ export const KESTREL_OVERRIDE_PRIORITY = 200
14
+
15
+ /**
16
+ * The components directory of a Kestrel layer, namespaced. Nuxt drops a prefix the filename already
17
+ * carries, so `KestrelImg.vue` stays `KestrelImg` while `ui/Button.vue` becomes `KestrelUiButton`.
18
+ * Pass the layer's own `import.meta.url`.
19
+ */
20
+ export const kestrelComponents = (layerUrl: string) => ({
21
+ path: fileURLToPath(new URL('./app/components', layerUrl)),
22
+ prefix: KESTREL_COMPONENT_PREFIX,
23
+ priority: KESTREL_LAYER_PRIORITY,
24
+ })
@@ -56,6 +56,13 @@ export default defineNuxtModule<KestrelConfig>({
56
56
  pub.siteName = c.siteName ?? ''
57
57
  // Reference desktop width (px) for the admin page-builder preset; the editor's scale-to-fit shrinks it.
58
58
  pub.previewDesktopWidth = c.preview.desktopWidth
59
+ // Whether the admin shows the EU AI Act disclosure controls on a media asset; the data itself is
60
+ // always resolved server-side, so flipping this off only hides the editor.
61
+ pub.aiDisclosureEnabled = c.aiDisclosure.enabled
62
+ // Article metadata is an APP-side concern on both ends — the admin SEO panel decides whether to offer
63
+ // the fields, the public page decides whether to publish them as JSON-LD — so the flag has to be
64
+ // client-visible. It gates a disclosure, not a secret.
65
+ pub.seoArticleMeta = c.seo.articleMeta
59
66
 
60
67
  // Server-only resolved settings, so server utils read the CONSUMER's `kestrel: {}` (via this module)
61
68
  // rather than importing Kestrel's own `kestrel.config.ts` file. Essential when Kestrel is consumed as
@@ -67,6 +74,10 @@ export default defineNuxtModule<KestrelConfig>({
67
74
  srv.siteDescription = c.siteDescription
68
75
  // Built-in collection toggles (pages/media) — read by the register plugin to gate built-ins.
69
76
  srv.collections = c.collections
77
+ // Read by the upload route to decide whether to run the AI-signal scan at all.
78
+ srv.aiDisclosure = c.aiDisclosure
79
+ // Answer-engine toggles; `llmsFull` is server-only (it gates a Nitro route and what the publisher writes).
80
+ srv.seo = c.seo
70
81
  // Static-publish target for the runtime publisher (server-only). S3 creds are env-only; prefer the
71
82
  // output-specific creds, fall back to the shared media creds so a single S3 account "just works".
72
83
  srv.output = {
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'
2
2
 
3
3
  export default defineNuxtConfig({
4
4
  modules: [
5
+ fileURLToPath(new URL('./modules/component-namespace/index.ts', import.meta.url)),
5
6
  fileURLToPath(new URL('./modules/auto-discovery/index.ts', import.meta.url)),
6
7
  fileURLToPath(new URL('./modules/kestrel/index.ts', import.meta.url)),
7
8
  ],
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { putSingleton } from '../../utils/crud'
8
+ import { requireCollection } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { clearWriteListeners } from '../../utils/write-events'
11
+ import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
12
+ import { desiredSchema } from '../../schema/desired'
13
+ import { diffSchema } from '../../schema/diff'
14
+ import { renderSqlite } from '../../schema/render-sqlite'
15
+
16
+ const settings = buildCollection(defineCollection({
17
+ name: 'settings', mode: 'single',
18
+ fields: { title: { type: 'text', required: true } },
19
+ }))
20
+
21
+ interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
22
+
23
+ let db: ReturnType<typeof drizzle>
24
+
25
+ // Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
26
+ // than a stub (same rationale as options.get.test.ts).
27
+ Object.assign(globalThis, {
28
+ defineEventHandler: (handler: unknown) => handler,
29
+ createError,
30
+ getQuery: (event: FakeEvent) => event.query,
31
+ readBody: (event: FakeEvent) => Promise.resolve(event.body),
32
+ useDb: () => db,
33
+ requireAdmin: () => {},
34
+ requireCollection,
35
+ readIfUnmodifiedSince: () => undefined,
36
+ putSingleton,
37
+ runWriteEffects,
38
+ })
39
+
40
+ const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
41
+ const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
42
+
43
+ beforeEach(() => {
44
+ const sqlite = new Database(':memory:')
45
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
46
+ db = drizzle(sqlite)
47
+ clearRegistry()
48
+ registerCollection(settings)
49
+ clearWriteListeners()
50
+ clearWriteEffects()
51
+ })
52
+
53
+ describe('PUT /api/[collection] — singleton save', () => {
54
+ it('writes the row and returns it', async () => {
55
+ await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
56
+ })
57
+
58
+ it('runs post-write effects with the saved row', async () => {
59
+ const seen: unknown[] = []
60
+ registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
61
+ await put('settings', { title: 'Hello' })
62
+ expect(seen).toEqual([['settings', 'Hello']])
63
+ })
64
+
65
+ it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
66
+ registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
67
+ await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
68
+ })
69
+
70
+ it('does not run effects when validation rejects the body', async () => {
71
+ let ran = false
72
+ registerWriteEffect(() => { ran = true })
73
+ await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
74
+ expect(ran).toBe(false)
75
+ })
76
+ })
@@ -3,5 +3,22 @@ export default defineEventHandler(async (event) => {
3
3
  const collection = requireCollection(event)
4
4
  const query = getQuery(event)
5
5
  const body = await readBody(event)
6
- return putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
6
+ const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
7
+ // Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
8
+ // (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
9
+ // write-event bus swallows throws by design. See write-effects.ts.
10
+ try {
11
+ await runWriteEffects(collection.def, saved)
12
+ } catch (error) {
13
+ // The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
14
+ // hand the new baseline back with the error, or the retry these failures ask for would 409 on a
15
+ // precondition that is stale by construction.
16
+ const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
17
+ throw createError({
18
+ statusCode: err.statusCode ?? 500,
19
+ statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
20
+ data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
21
+ })
22
+ }
23
+ return saved
7
24
  })
@@ -20,8 +20,9 @@ export interface BuiltCollection {
20
20
  insert: CollectionSchema
21
21
  update: CollectionSchema
22
22
  select: CollectionSchema
23
- /** Re-enforce `required` for conditional fields whose condition is met against the whole record
24
- * (the per-field schema can't see siblings). Returns Zod-shaped issues keyed by the field's def
25
- * name. Present only when the collection has conditional required fields. */
23
+ /** Every pre-write check the per-field schema can't do because it sees one field at a time: `required`
24
+ * re-enforced for conditional fields whose condition is met against the whole record, plus the
25
+ * collection's own `def.validate`. Returns Zod-shaped issues keyed by the field's def name. Present
26
+ * only when the collection has conditional required fields or a `validate`. */
26
27
  applyConditions?: (record: Record<string, unknown>) => { issues: ConditionIssue[] }
27
28
  }
@@ -15,7 +15,6 @@ export type Localized = string | Record<string, string>
15
15
  interface BaseFieldDef {
16
16
  required?: boolean
17
17
  default?: unknown
18
- translatable?: boolean
19
18
  unique?: boolean
20
19
  /** Add a non-unique DB index on this field's column — for a field frequently FILTERED or SORTED by but
21
20
  * not unique (e.g. `media.folder`). `unique` already implies an index, so this is only the non-unique case. */
@@ -97,7 +96,7 @@ export type FieldDef =
97
96
  | (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
98
97
  | (BaseFieldDef & { type: 'boolean' })
99
98
  | (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
100
- | (BaseFieldDef & { type: 'choice'; options: { choices: { label: string; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
99
+ | (BaseFieldDef & { type: 'choice'; options: { choices: { label: Localized; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
101
100
  | (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
102
101
  | (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
103
102
  | (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
@@ -158,6 +157,13 @@ export interface CollectionDef {
158
157
  * so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
159
158
  * nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
160
159
  fieldLayout?: FieldLayoutDSL
160
+ /** Whole-record validation the per-field Zod schema cannot express, because a field validator only ever
161
+ * sees its own value — e.g. a rule set whose rows have to compile as a unit. Runs server-side BEFORE
162
+ * the write, next to the conditional-required check, and its issues become the same field-scoped 400
163
+ * the editor already renders. Server-only: a function, never serialized.
164
+ * Note the asymmetry: `record` is keyed by COLUMN name (`authorId` for a single relation/media field —
165
+ * see `resolveColumnName`), while an issue's `path[0]` is the FIELD key the editor renders against. */
166
+ validate?: (record: Record<string, unknown>) => Array<{ path: (string | number)[]; message: string }>
161
167
  /** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
162
168
  * `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
163
169
  * `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
@@ -138,10 +138,32 @@ export interface KestrelConfig {
138
138
  /** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
139
139
  * `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
140
140
  collections?: { pages?: boolean; media?: boolean }
141
+ /** Search / answer-engine surface. Both flags default to **false** and stay off until a consumer turns
142
+ * them on: each one publishes something the site did not publish before, and an upgrade must never
143
+ * start disclosing it. Everything else on this surface (canonical, OG, hreflang, sitemap, robots,
144
+ * llms.txt, the WebSite/WebPage JSON-LD graph) is unconditional — it only restates what the page head
145
+ * already says. */
146
+ seo?: {
147
+ /** Offer editors `author` / `publishedDate` / `keywords` on the SEO panel and publish them as
148
+ * schema.org `author` / `datePublished` / `keywords` (which also promotes the page's JSON-LD node
149
+ * from `WebPage` to `Article`). Some consumers must not attribute content at all — internal
150
+ * authorship, confidentiality — so nothing is emitted and the fields are not even shown unless this
151
+ * is on. Env `KESTREL_SEO_ARTICLE_META`. */
152
+ articleMeta?: boolean
153
+ /** Serve and publish `/llms-full.txt`: the full Markdown body of every published, indexable page in
154
+ * one file (the llmstxt.org long form). Off by default — it aggregates the whole site into a single
155
+ * scrapeable artifact, and it reads every page's block content on every publish, which the link-only
156
+ * `llms.txt` deliberately avoids. Env `KESTREL_SEO_LLMS_FULL`. */
157
+ llmsFull?: boolean
158
+ }
141
159
  /** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
142
160
  * preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
143
161
  * `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
144
162
  preview?: { desktopWidth?: number }
163
+ /** EU AI Act Art. 50 disclosure fields on media assets — OFF by default. Kestrel only stores/manages
164
+ * this metadata; it never burns a label/watermark into an image and never auto-emits it into public
165
+ * output — see docs/media-uploads.md. Env: `KESTREL_AI_DISCLOSURE`. */
166
+ aiDisclosure?: { enabled?: boolean }
145
167
  }
146
168
 
147
169
  export interface ResolvedKestrel {
@@ -170,8 +192,13 @@ export interface ResolvedKestrel {
170
192
  }
171
193
  /** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
172
194
  collections: { pages: boolean; media: boolean }
195
+ /** Resolved answer-engine toggles, both default-off (see `KestrelConfig.seo`). */
196
+ seo: { articleMeta: boolean; llmsFull: boolean }
173
197
  /** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
174
198
  preview: { desktopWidth: number }
199
+ /** Gates the media-disclosure UI in the admin ONLY. `ResolvedMedia.aiDisclosure` is always resolved from
200
+ * the columns, so turning this off hides the editor controls without touching existing data. */
201
+ aiDisclosure: { enabled: boolean }
175
202
  }
176
203
 
177
204
  type Env = Record<string, string | undefined>
@@ -411,7 +438,14 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
411
438
  media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
412
439
  }
413
440
 
441
+ const seo = {
442
+ articleMeta: envBool(env.KESTREL_SEO_ARTICLE_META, c.seo?.articleMeta ?? false),
443
+ llmsFull: envBool(env.KESTREL_SEO_LLMS_FULL, c.seo?.llmsFull ?? false),
444
+ }
445
+
414
446
  const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
415
447
 
416
- return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, preview }
448
+ const aiDisclosure = { enabled: envBool(env.KESTREL_AI_DISCLOSURE, c.aiDisclosure?.enabled ?? false) }
449
+
450
+ return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, seo, preview, aiDisclosure }
417
451
  }