@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.
- package/README.md +16 -8
- package/layers/admin/app/components/AdminAccount.vue +3 -3
- package/layers/admin/app/components/AdminNav.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +2 -2
- package/layers/admin/app/components/BlockPreview.vue +8 -8
- package/layers/admin/app/components/BlockTree.vue +10 -10
- package/layers/admin/app/components/BlocksBody.vue +4 -4
- package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
- package/layers/admin/app/components/CollectionEditor.vue +3 -3
- package/layers/admin/app/components/CollectionList.vue +91 -788
- package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
- package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
- package/layers/admin/app/components/CollectionListPager.vue +70 -0
- package/layers/admin/app/components/CollectionListTable.vue +308 -0
- package/layers/admin/app/components/EditorStatus.vue +4 -4
- package/layers/admin/app/components/FieldsBody.vue +1 -1
- package/layers/admin/app/components/LocaleBar.vue +4 -4
- package/layers/admin/app/components/PageFields.vue +17 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +52 -10
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/composables/useListBatchActions.ts +61 -0
- package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
- package/layers/admin/app/composables/useListRows.ts +70 -0
- package/layers/admin/app/composables/useListSelection.ts +28 -0
- package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
- package/layers/admin/app/layouts/admin.vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
- package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
- package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
- package/layers/admin/app/pages/admin/index.vue +3 -3
- package/layers/admin/app/pages/admin/login.vue +6 -6
- package/layers/admin/app/pages/admin/media.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +3 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/list-cell.ts +46 -0
- package/layers/admin/nuxt.config.ts +3 -0
- package/layers/core/modules/component-namespace/index.ts +20 -0
- package/layers/core/modules/component-namespace/shared.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +11 -0
- package/layers/core/nuxt.config.ts +1 -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/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -2
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/app/utils/field-factories.ts +1 -1
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaConflictDialog.vue +7 -7
- package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
- package/layers/media/app/components/MediaGrid.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +26 -24
- package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
- package/layers/media/app/components/MediaPathBar.vue +1 -1
- package/layers/media/app/components/MediaPicker.vue +3 -3
- package/layers/media/app/components/MediaRenameDialog.vue +8 -8
- package/layers/media/app/components/MediaTable.vue +2 -2
- package/layers/media/app/components/MediaToolbar.vue +4 -4
- package/layers/media/app/components/MediaUploadDialog.vue +9 -9
- package/layers/media/app/components/MediaViewer.vue +64 -15
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/nuxt.config.ts +5 -1
- 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 +32 -3
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/app/utils/json-ld.ts +139 -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/nuxt.config.ts +3 -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/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/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/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- 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/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/Slug.vue +2 -2
- package/layers/ui/app/components/ui/Button.vue +1 -1
- package/layers/ui/app/components/ui/EmptyState.vue +1 -1
- package/layers/ui/app/components/ui/TextInput.vue +1 -1
- package/layers/ui/app/components/ui/Toasts.vue +1 -1
- package/layers/ui/app/composables/useToast.ts +1 -1
- package/layers/ui/app/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/layers/ui/nuxt.config.ts +5 -1
- package/package.json +2 -1
- 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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
58
|
+
<KestrelAdminNav />
|
|
59
59
|
</div>
|
|
60
60
|
|
|
61
61
|
<div class="rail__foot">
|
|
62
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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,
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
<
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
<
|
|
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
|
-
<
|
|
127
|
-
<
|
|
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
|
-
<
|
|
131
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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,
|
|
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
|
-
<
|
|
30
|
+
<KestrelUiIcon name="triangle-alert" :size="18" />
|
|
31
31
|
<span>{{ t('refs.dashAlert', { n: brokenCount }) }}</span>
|
|
32
32
|
</NuxtLink>
|
|
33
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
33
|
-
<
|
|
32
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
33
|
+
<KestrelUiField :label="t('login.password')">
|
|
34
34
|
<template #default="f">
|
|
35
|
-
<
|
|
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
|
-
</
|
|
43
|
-
<
|
|
42
|
+
</KestrelUiField>
|
|
43
|
+
<KestrelUiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</KestrelUiButton>
|
|
44
44
|
</form>
|
|
45
45
|
</template>
|
|
46
46
|
|
|
@@ -26,14 +26,14 @@ try {
|
|
|
26
26
|
<template>
|
|
27
27
|
<section class="refs">
|
|
28
28
|
<NuxtLink to="/admin" class="refs__back">
|
|
29
|
-
<
|
|
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
|
-
<
|
|
36
|
-
<
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
}
|