@michaelthielemann/kestrel 1.2.1 → 1.4.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 +33 -3
- package/layers/admin/app/components/PageFields.vue +25 -0
- package/layers/admin/app/composables/useEditForm.ts +5 -0
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
- package/layers/auth/nuxt.config.ts +0 -0
- package/layers/auth/server/api/auth/session.get.ts +0 -0
- package/layers/auth/server/utils/password.ts +0 -0
- package/layers/auth/server/utils/session.ts +5 -2
- package/layers/collections/nuxt.config.ts +0 -0
- package/layers/core/app/composables/layouts.ts +5 -0
- package/layers/core/app/utils/layouts.ts +38 -0
- package/layers/core/modules/auto-discovery/index.ts +11 -1
- package/layers/core/modules/auto-discovery/virtual.d.ts +3 -0
- package/layers/core/modules/kestrel/app-shell.ts +55 -0
- package/layers/core/modules/kestrel/index.ts +17 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
- package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
- package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
- package/layers/core/server/utils/blocks.ts +0 -0
- package/layers/core/server/utils/seo.ts +0 -0
- package/layers/fields/nuxt.config.ts +0 -0
- package/layers/fields/server/utils/buildTable.ts +8 -2
- package/layers/media/server/api/media/[id].get.ts +0 -0
- package/layers/media/server/api/media/index.get.ts +0 -0
- package/layers/public/app/app.vue +0 -0
- package/layers/public/app/error.vue +0 -0
- package/layers/public/app/layouts/default.vue +0 -0
- package/layers/public/app/pages/[...slug].vue +31 -15
- package/layers/public/app/utils/page-layout.ts +18 -0
- package/layers/ui/app/assets/scss/_reset.scss +0 -0
- package/layers/ui/app/assets/scss/main.scss +0 -0
- package/layers/ui/app/components/ui/Alert.vue +0 -0
- package/layers/ui/app/i18n/de.ts +3 -0
- package/layers/ui/app/i18n/en.ts +3 -0
- package/package.json +6 -2
- package/scripts/copy-create-payload.mjs +49 -0
- package/scripts/hash-password.mjs +6 -15
- package/scripts/kestrel.mjs +216 -0
- package/scripts/lib/cli.mjs +114 -0
- package/scripts/lib/password.mjs +19 -0
- package/scripts/lib/scaffold.mjs +174 -0
- package/templates/starter/README.md +65 -0
- package/templates/starter/_env.example +17 -0
- package/templates/starter/_gitignore +26 -0
- package/templates/starter/_package.json +22 -0
- package/templates/starter/app/app.vue +7 -0
- package/templates/starter/app/blocks/Prose.vue +12 -0
- package/templates/starter/app/layouts/default.vue +6 -0
- package/templates/starter/nuxt.config.ts +20 -0
- package/templates/starter/pnpm-workspace.yaml +8 -0
- package/templates/starter/tsconfig.json +3 -0
package/README.md
CHANGED
|
@@ -35,6 +35,9 @@ static host. It is deliberately **not**:
|
|
|
35
35
|
|
|
36
36
|
## Features
|
|
37
37
|
|
|
38
|
+
- **Runnable in one command** — `pnpm create kestrel` scaffolds a project that boots with a working
|
|
39
|
+
`/admin`, prompting for the admin password and writing its hash; `kestrel init` does the same to an
|
|
40
|
+
existing project without clobbering it, and `kestrel doctor` names whatever is still missing.
|
|
38
41
|
- **Collection-driven** — declare collections + fields in TypeScript; Kestrel derives the SQLite tables, a
|
|
39
42
|
typed CRUD REST API, and the full admin UI. The schema **migrates itself** (additive in dev; explicit
|
|
40
43
|
`db:migrate` in prod).
|
|
@@ -58,10 +61,26 @@ static host. It is deliberately **not**:
|
|
|
58
61
|
|
|
59
62
|
## Quickstart (consumer)
|
|
60
63
|
|
|
64
|
+
```bash
|
|
65
|
+
pnpm create kestrel my-site
|
|
66
|
+
cd my-site && pnpm install && pnpm dev
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It asks for an admin password and writes a project that runs as-is: `nuxt.config.ts` extending the
|
|
70
|
+
meta-layer, an `app.vue` that renders, a `.env` holding a fresh session secret and the scrypt hash of
|
|
71
|
+
your password, and one example block. Sign in at <http://localhost:3000/admin>.
|
|
72
|
+
|
|
73
|
+
Already have a project? Run it in place — existing files are kept, `package.json` and `.env` are merged:
|
|
74
|
+
|
|
61
75
|
```bash
|
|
62
76
|
pnpm add @michaelthielemann/kestrel
|
|
77
|
+
pnpm kestrel init # completes the project
|
|
78
|
+
pnpm kestrel doctor # or just diagnose one that misbehaves
|
|
63
79
|
```
|
|
64
80
|
|
|
81
|
+
Installing the package **alone does nothing**: Nuxt only loads Kestrel once a config extends it. If you
|
|
82
|
+
would rather wire it up by hand, that is two files:
|
|
83
|
+
|
|
65
84
|
```ts
|
|
66
85
|
// nuxt.config.ts
|
|
67
86
|
export default defineNuxtConfig({
|
|
@@ -88,8 +107,13 @@ export default defineCollection({
|
|
|
88
107
|
|
|
89
108
|
Set the auth env (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`), start the app, and manage
|
|
90
109
|
content at `/admin`. You bring your own **public layout** and **block SFCs**
|
|
91
|
-
(`app/blocks/Hero.vue` — one file for schema + display — is the `hero` block).
|
|
92
|
-
|
|
110
|
+
(`app/blocks/Hero.vue` — one file for schema + display — is the `hero` block).
|
|
111
|
+
|
|
112
|
+
> **Do not add an `app/app.vue` that omits `<NuxtPage />`.** A project-owned one shadows the layer's, and
|
|
113
|
+
> the file `nuxi init` writes renders `<NuxtWelcome />` instead of your routes — the admin then appears to
|
|
114
|
+
> be missing rather than blank. Kestrel reports this at build time; `kestrel doctor` catches it earlier.
|
|
115
|
+
|
|
116
|
+
Full guide: **[consuming-kestrel.md](docs/consuming-kestrel.md)**.
|
|
93
117
|
|
|
94
118
|
## Documentation
|
|
95
119
|
|
|
@@ -125,7 +149,10 @@ The CMS is split into Nuxt layers under `layers/`:
|
|
|
125
149
|
- **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
|
|
126
150
|
- **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, sitemap / robots / llms.txt, deploy.
|
|
127
151
|
|
|
128
|
-
`playground/` is a small consuming example.
|
|
152
|
+
`playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
|
|
153
|
+
`scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
|
|
154
|
+
`pnpm create kestrel` front end, which copies the same templates in at pack time rather than keeping
|
|
155
|
+
its own.
|
|
129
156
|
|
|
130
157
|
## Development
|
|
131
158
|
|
|
@@ -144,6 +171,9 @@ pnpm test:e2e # end-to-end tests (real dev server)
|
|
|
144
171
|
pnpm db:generate # drizzle-kit: generate a migration
|
|
145
172
|
pnpm db:migrate # drizzle-kit: apply migrations
|
|
146
173
|
pnpm hash-password # produce a KESTREL_ADMIN_PASSWORD_HASH
|
|
174
|
+
|
|
175
|
+
node scripts/kestrel.mjs init <dir> # the consumer scaffolder, from a checkout
|
|
176
|
+
node scripts/kestrel.mjs doctor <dir> # diagnose a consumer project
|
|
147
177
|
```
|
|
148
178
|
|
|
149
179
|
In dev, the schema auto-syncs from the collection definitions (additive changes only); production applies
|
|
@@ -25,6 +25,11 @@ const props = defineProps<{
|
|
|
25
25
|
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
|
26
26
|
const { t } = useT()
|
|
27
27
|
|
|
28
|
+
// A project with a single layout has nothing to choose, so the control stays out of the pane entirely
|
|
29
|
+
// rather than offering one dead option.
|
|
30
|
+
const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
|
|
31
|
+
const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
|
|
32
|
+
|
|
28
33
|
// Live preview of the slug the server will auto-generate from the title while the field is left blank
|
|
29
34
|
// (the server slugifies the title on save). Falls back to '/' when there is no title yet.
|
|
30
35
|
const slugPlaceholder = computed(() => {
|
|
@@ -88,6 +93,26 @@ const slugPlaceholder = computed(() => {
|
|
|
88
93
|
</template>
|
|
89
94
|
</UiField>
|
|
90
95
|
|
|
96
|
+
<!-- Which layout renders this page (the `layout` system column). Empty = the `default` layout, so an
|
|
97
|
+
unset value keeps rendering exactly as a project without the column. -->
|
|
98
|
+
<UiField
|
|
99
|
+
v-if="showLayout"
|
|
100
|
+
class="page-settings__layout"
|
|
101
|
+
:label="t('pageSettings.layoutLabel')"
|
|
102
|
+
:hint="t('pageSettings.layoutHint')"
|
|
103
|
+
:error="errors.layout || null"
|
|
104
|
+
>
|
|
105
|
+
<template #default="f">
|
|
106
|
+
<UiSelect
|
|
107
|
+
:model-value="(values.layout as string) ?? ''"
|
|
108
|
+
:options="layoutOptions"
|
|
109
|
+
:disabled="disabled"
|
|
110
|
+
v-bind="f"
|
|
111
|
+
@update:model-value="(v) => emit('update', 'layout', v)"
|
|
112
|
+
/>
|
|
113
|
+
</template>
|
|
114
|
+
</UiField>
|
|
115
|
+
|
|
91
116
|
<!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
|
|
92
117
|
<SeoFields
|
|
93
118
|
v-if="seo"
|
|
@@ -135,6 +135,8 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
135
135
|
if (blocksEnabled.value) next.content = (source?.content as unknown[]) ?? []
|
|
136
136
|
// `path` (the page slug) is a pageLike system column, likewise round-tripped explicitly.
|
|
137
137
|
if (pageLike.value) next.path = (source?.path as string | null | undefined) ?? ''
|
|
138
|
+
// '' is the "no override" form the select binds to.
|
|
139
|
+
if (pageLike.value) next.layout = (source?.layout as string | null | undefined) ?? ''
|
|
138
140
|
// `seo` is a JSON system column; default to an empty object so the editor can fill it in.
|
|
139
141
|
if (hasSeo.value) next.seo = (source?.seo as Record<string, unknown> | undefined) ?? {}
|
|
140
142
|
// `status` is a system column; a new record defaults to 'draft' (unpublished) — matches the DB default.
|
|
@@ -261,6 +263,9 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
261
263
|
if (blocksEnabled.value) body.content = values.content
|
|
262
264
|
// Send the slug as the routable path; a blank slug clears the route (stored as null, not "").
|
|
263
265
|
if (pageLike.value) body.path = (values.path as string) ? values.path : null
|
|
266
|
+
// An unset layout is stored as NULL, never '': the render coalesces NULL to `default`, and a stored ''
|
|
267
|
+
// would be indistinguishable from a name that failed to save.
|
|
268
|
+
if (pageLike.value) body.layout = (values.layout as string) || null
|
|
264
269
|
if (hasSeo.value) body.seo = values.seo ?? {}
|
|
265
270
|
if (hasStatus.value) body.status = (values.status as string) ?? 'draft'
|
|
266
271
|
// A new translatable multi record carries its locale, and links to a translation group when it is
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useState } from '#imports'
|
|
3
|
+
import { flushPromises } from '@vue/test-utils'
|
|
4
|
+
import { readBody } from 'h3'
|
|
5
|
+
import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
|
|
6
|
+
import RecordPage from './[id].vue'
|
|
7
|
+
|
|
8
|
+
const thingsSchema = {
|
|
9
|
+
name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
10
|
+
blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
|
|
11
|
+
fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
|
|
12
|
+
}
|
|
13
|
+
// Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
|
|
14
|
+
const articlesSchema = {
|
|
15
|
+
name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
16
|
+
blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
|
|
17
|
+
fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
|
|
18
|
+
}
|
|
19
|
+
registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
|
|
20
|
+
registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
|
|
21
|
+
|
|
22
|
+
let patched: Record<string, unknown> | null = null
|
|
23
|
+
let posted: Record<string, unknown> | null = null
|
|
24
|
+
registerEndpoint('/api/things/1', async (event) => {
|
|
25
|
+
if (event.method === 'PATCH') { patched = await readBody(event); return { id: 1, ...patched } }
|
|
26
|
+
return { id: 1, title: 'Existing' }
|
|
27
|
+
})
|
|
28
|
+
// A record whose title field is blank — the heading must fall back to the generic "#id" phrase.
|
|
29
|
+
registerEndpoint('/api/things/2', () => ({ id: 2, title: ' ' }))
|
|
30
|
+
registerEndpoint('/api/things', async (event) => {
|
|
31
|
+
if (event.method === 'POST') { posted = await readBody(event); return { id: 7, ...posted } }
|
|
32
|
+
return { data: [], total: 0, page: 1, perPage: 25 }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// The editor's Delete flows through the shared batch endpoint + the referrer-aggregate preview.
|
|
36
|
+
let bulkBody: Record<string, unknown> | null = null
|
|
37
|
+
registerEndpoint('/api/things/bulk', async (event) => {
|
|
38
|
+
bulkBody = await readBody(event)
|
|
39
|
+
return { action: bulkBody!.action, count: 1, ids: bulkBody!.ids }
|
|
40
|
+
})
|
|
41
|
+
registerEndpoint('/api/references/referrers', () => ({ counts: {} }))
|
|
42
|
+
|
|
43
|
+
// Route + navigation are mocked so the page reads collection/id from `h.params` and navigations are captured.
|
|
44
|
+
const h = vi.hoisted(() => ({ params: { collection: 'things', id: '1' }, nav: [] as unknown[] }))
|
|
45
|
+
mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
|
|
46
|
+
mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
h.params = { collection: 'things', id: '1' }
|
|
50
|
+
h.nav.length = 0
|
|
51
|
+
patched = null
|
|
52
|
+
posted = null
|
|
53
|
+
bulkBody = null
|
|
54
|
+
useState('kestrel-collections').value = null
|
|
55
|
+
useState('kestrel-blocks').value = null
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const settle = async () => {
|
|
59
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
60
|
+
await flushPromises()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('record editor page header', () => {
|
|
64
|
+
it('merges Save, Cancel and Delete into the record head, each with an icon', async () => {
|
|
65
|
+
const w = await mountSuspended(RecordPage)
|
|
66
|
+
await flushPromises()
|
|
67
|
+
|
|
68
|
+
const head = w.find('.record__head')
|
|
69
|
+
expect(head.exists()).toBe(true)
|
|
70
|
+
const buttons = head.findAll('.ui-button')
|
|
71
|
+
expect(buttons.map((b) => b.text())).toEqual(expect.arrayContaining(['Save', 'Cancel', 'Delete']))
|
|
72
|
+
for (const b of buttons) expect(b.find('.ui-icon').exists()).toBe(true)
|
|
73
|
+
expect(w.find('.editor__actions').exists()).toBe(false)
|
|
74
|
+
|
|
75
|
+
// Cancel carries real button chrome — it sits beside the solid Delete/Save, not as bare text…
|
|
76
|
+
expect(buttons.find((b) => b.text() === 'Cancel')!.classes()).toContain('ui-button--secondary')
|
|
77
|
+
// …while the icon-only tools (undo/redo/open) stay quiet ghosts.
|
|
78
|
+
const iconOnly = buttons.filter((b) => b.text() === '')
|
|
79
|
+
expect(iconOnly.length).toBeGreaterThan(0)
|
|
80
|
+
for (const b of iconOnly) expect(b.classes()).toContain('ui-button--ghost')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('titles the header with the singular label, not the raw (plural) route param', async () => {
|
|
84
|
+
h.params = { collection: 'things', id: 'new' }
|
|
85
|
+
const wNew = await mountSuspended(RecordPage)
|
|
86
|
+
await flushPromises()
|
|
87
|
+
expect(wNew.find('.record__title').text()).toBe('New Thing')
|
|
88
|
+
expect(wNew.find('.record__back').text()).toContain('Things')
|
|
89
|
+
// The generic phrase is title-cased; a real record title would not be.
|
|
90
|
+
expect(wNew.find('.record__title').classes()).toContain('record__title--generic')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps the back link on the heading row (one line, so the panes keep the vertical space)', async () => {
|
|
94
|
+
const w = await mountSuspended(RecordPage)
|
|
95
|
+
await flushPromises()
|
|
96
|
+
expect(w.find('.record__head .record__back').exists()).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('titles a saved record with its own title — an id means nothing to an editor', async () => {
|
|
100
|
+
h.params = { collection: 'things', id: '1' }
|
|
101
|
+
const wEdit = await mountSuspended(RecordPage)
|
|
102
|
+
await settle()
|
|
103
|
+
expect(wEdit.find('.record__title').text()).toBe('Existing')
|
|
104
|
+
expect(wEdit.find('.record__title').classes()).not.toContain('record__title--generic')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to "Edit {collection} #{id}" when the record has no usable title', async () => {
|
|
108
|
+
h.params = { collection: 'things', id: '2' }
|
|
109
|
+
const wEdit = await mountSuspended(RecordPage)
|
|
110
|
+
await settle()
|
|
111
|
+
expect(wEdit.find('.record__title').text()).toBe('Edit Thing #2')
|
|
112
|
+
expect(wEdit.find('.record__title').classes()).toContain('record__title--generic')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('uses the collection\'s explicit label.new for the create heading (no generic "New X")', async () => {
|
|
116
|
+
h.params = { collection: 'articles', id: 'new' }
|
|
117
|
+
const w = await mountSuspended(RecordPage)
|
|
118
|
+
await flushPromises()
|
|
119
|
+
expect(w.find('.record__title').text()).toBe('Compose article')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('wires the header Save button to submit the editor form', async () => {
|
|
123
|
+
const w = await mountSuspended(RecordPage)
|
|
124
|
+
await flushPromises()
|
|
125
|
+
const save = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Save')!
|
|
126
|
+
expect(save.attributes('type')).toBe('submit')
|
|
127
|
+
expect(save.attributes('form')).toBe('record-editor')
|
|
128
|
+
expect(w.find('form.editor').attributes('id')).toBe('record-editor')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('shows the back link with a centered SVG arrow (no text arrow glyph)', async () => {
|
|
132
|
+
const w = await mountSuspended(RecordPage)
|
|
133
|
+
await flushPromises()
|
|
134
|
+
const back = w.find('.record__back')
|
|
135
|
+
expect(back.exists()).toBe(true)
|
|
136
|
+
expect(back.find('.ui-icon').exists()).toBe(true)
|
|
137
|
+
expect(back.text()).not.toContain('←')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('saving an existing record stays on the page — Save only saves', async () => {
|
|
141
|
+
const w = await mountSuspended(RecordPage)
|
|
142
|
+
await flushPromises()
|
|
143
|
+
await w.find('form.editor').trigger('submit')
|
|
144
|
+
await settle()
|
|
145
|
+
expect(patched).toMatchObject({ title: 'Existing' })
|
|
146
|
+
expect(h.nav).toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('saving a NEW record moves to its own editor, not back to the list', async () => {
|
|
150
|
+
h.params = { collection: 'things', id: 'new' }
|
|
151
|
+
const w = await mountSuspended(RecordPage)
|
|
152
|
+
await flushPromises()
|
|
153
|
+
await w.findAll('input')[0]!.setValue('Fresh')
|
|
154
|
+
await w.find('form.editor').trigger('submit')
|
|
155
|
+
await settle()
|
|
156
|
+
expect(posted).toMatchObject({ title: 'Fresh' })
|
|
157
|
+
// navigates to the created record's editor (id 7), never to the list path
|
|
158
|
+
expect(h.nav).toContain('/admin/things/7')
|
|
159
|
+
expect(h.nav).not.toContain('/admin/things')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('deletes through the confirm dialog (shared batch op) and navigates back to the list', async () => {
|
|
163
|
+
const w = await mountSuspended(RecordPage)
|
|
164
|
+
await settle()
|
|
165
|
+
// the header Delete opens the dialog rather than window.confirm
|
|
166
|
+
const del = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Delete')!
|
|
167
|
+
await del.trigger('click')
|
|
168
|
+
await settle()
|
|
169
|
+
expect(w.find('.ui-dialog__content').exists()).toBe(true)
|
|
170
|
+
// confirming posts the bulk delete for this one id and then navigates to the list
|
|
171
|
+
const confirm = w.findAll('.ui-dialog__content .ui-button').find((b) => /^delete$/i.test(b.text().trim()))!
|
|
172
|
+
await confirm.trigger('click')
|
|
173
|
+
await settle()
|
|
174
|
+
expect(bulkBody).toEqual({ action: 'delete', ids: [1] })
|
|
175
|
+
expect(h.nav).toContain('/admin/things')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('a native beforeunload (tab close / reload) is blocked only while there are unsaved changes', async () => {
|
|
179
|
+
const w = await mountSuspended(RecordPage)
|
|
180
|
+
await flushPromises()
|
|
181
|
+
|
|
182
|
+
// pristine → unload is NOT blocked
|
|
183
|
+
const clean = new Event('beforeunload', { cancelable: true })
|
|
184
|
+
window.dispatchEvent(clean)
|
|
185
|
+
expect(clean.defaultPrevented).toBe(false)
|
|
186
|
+
|
|
187
|
+
// edit a field → dirty → unload IS blocked (browser shows its native prompt)
|
|
188
|
+
await w.findAll('input')[0]!.setValue('Changed')
|
|
189
|
+
await settle()
|
|
190
|
+
const dirty = new Event('beforeunload', { cancelable: true })
|
|
191
|
+
window.dispatchEvent(dirty)
|
|
192
|
+
expect(dirty.defaultPrevented).toBe(true)
|
|
193
|
+
|
|
194
|
+
// unmounting removes the listener (no leak across pages)
|
|
195
|
+
w.unmount()
|
|
196
|
+
const afterUnmount = new Event('beforeunload', { cancelable: true })
|
|
197
|
+
window.dispatchEvent(afterUnmount)
|
|
198
|
+
expect(afterUnmount.defaultPrevented).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -52,7 +52,7 @@ export interface SessionSettings {
|
|
|
52
52
|
cookieName: string
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function sessionSettings(): SessionSettings {
|
|
55
|
+
export function sessionSettings({ prerender = import.meta.prerender === true } = {}): SessionSettings {
|
|
56
56
|
// Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
|
|
57
57
|
// that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
|
|
58
58
|
// not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
|
|
@@ -60,7 +60,10 @@ export function sessionSettings(): SessionSettings {
|
|
|
60
60
|
const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
|
|
61
61
|
const isProd = !explicitDev
|
|
62
62
|
const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
|
|
63
|
-
|
|
63
|
+
// `nuxt generate` prerenders every page through `/api/route`, which passes the access guard and lands
|
|
64
|
+
// here with NODE_ENV=production. A prerender request never issues a cookie, so enforcing the flag there
|
|
65
|
+
// only means a dev `.env` silently drops pages from the static output.
|
|
66
|
+
if (isProd && !secureCookies && !prerender) {
|
|
64
67
|
throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
|
|
65
68
|
}
|
|
66
69
|
const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The admin shell. Never offerable for a public record — a page rendered inside it would carry the
|
|
2
|
+
* admin chrome and its own `useHead`. */
|
|
3
|
+
export const ADMIN_LAYOUT = 'admin'
|
|
4
|
+
|
|
5
|
+
/** Nuxt's resolved `app.layouts` entry (`nuxt.options.app.layouts`, filled before the `app:resolve` hook). */
|
|
6
|
+
export interface ResolvedLayout { name: string, file: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The layout names a page may be assigned, from Nuxt's own resolved layout map. Nuxt has already done the
|
|
10
|
+
* layer-ordered, name-first dedup (a consumer's `default.vue` shadows the engine's), so this only filters
|
|
11
|
+
* and sorts — no directory scan of our own.
|
|
12
|
+
*/
|
|
13
|
+
export function offerableLayouts(layouts: Record<string, ResolvedLayout | undefined>): string[] {
|
|
14
|
+
return Object.values(layouts)
|
|
15
|
+
.filter((l): l is ResolvedLayout => !!l && typeof l.file === 'string' && l.file.endsWith('.vue'))
|
|
16
|
+
.map((l) => l.name)
|
|
17
|
+
.filter((name) => name !== ADMIN_LAYOUT)
|
|
18
|
+
.sort()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderLayoutRegistry(names: string[]): string {
|
|
22
|
+
return `export const kestrelLayouts = ${JSON.stringify(names)}\n`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for the page-layout select. The fallback is one entry with an EMPTY value — an unset column
|
|
27
|
+
* already renders `default`, so offering `default` as its own value would give the editor two controls for
|
|
28
|
+
* one outcome and pin the row to a name the consumer may later rename.
|
|
29
|
+
*/
|
|
30
|
+
export function layoutSelectOptions(names: string[], fallbackLabel: string): { label: string, value: string }[] {
|
|
31
|
+
return [
|
|
32
|
+
{ label: fallbackLabel, value: '' },
|
|
33
|
+
...names.filter((n) => n !== DEFAULT_LAYOUT_NAME).map((n) => ({ label: n, value: n })),
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mirrors `DEFAULT_LAYOUT` in the public layer; kept local so this util stays dependency-free. */
|
|
38
|
+
const DEFAULT_LAYOUT_NAME = 'default'
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { addComponentsDir, addTypeTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
3
|
+
import { addComponentsDir, addTemplate, addTypeTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
4
4
|
import { collectBlockSfcs, collectDefinitions, renderRegistry } from './scan'
|
|
5
5
|
import { renderBlockRegistry } from './extract-block'
|
|
6
|
+
import { offerableLayouts, renderLayoutRegistry } from '../../app/utils/layouts'
|
|
6
7
|
|
|
7
8
|
export default defineNuxtModule({
|
|
8
9
|
meta: { name: 'kestrel-auto-discovery' },
|
|
@@ -22,6 +23,15 @@ export default defineNuxtModule({
|
|
|
22
23
|
if (existsSync(dir)) addComponentsDir({ path: dir, prefix: 'Blocks', global: true, pathPrefix: false })
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// Layouts need no scan of our own: Nuxt already resolves `app/layouts/*.vue` across the layers with the
|
|
27
|
+
// same name-first, consumer-wins dedup, and fills `app.layouts` just before `app:resolve` — which runs
|
|
28
|
+
// inside `generateApp`, ahead of the templates being written, so the closure below is filled in time.
|
|
29
|
+
let layoutNames: string[] = []
|
|
30
|
+
nuxt.hook('app:resolve', (app) => { layoutNames = offerableLayouts(app.layouts ?? {}) })
|
|
31
|
+
// `write` so the resolved list is inspectable in `.nuxt/` — a virtual-only template makes "which layouts
|
|
32
|
+
// did the build actually find" unanswerable without a debugger.
|
|
33
|
+
addTemplate({ filename: 'kestrel-layouts.mjs', write: true, getContents: () => renderLayoutRegistry(layoutNames) })
|
|
34
|
+
|
|
25
35
|
nuxt.hook('nitro:config', (nitro) => {
|
|
26
36
|
nitro.virtual ||= {}
|
|
27
37
|
// Consumer field types register as a side effect on import, and the schema engine builds a table the
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type AppShellDiagnostic = { level: 'error' | 'warn'; message: string }
|
|
2
|
+
|
|
3
|
+
export type AppShellInput = {
|
|
4
|
+
mainComponent: string | null | undefined
|
|
5
|
+
pagesEnabled: boolean
|
|
6
|
+
read: (file: string) => string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const withoutComments = (src: string) => src.replace(/<!--[\s\S]*?-->/g, '')
|
|
10
|
+
|
|
11
|
+
// `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
|
|
12
|
+
const kebab = (tag: string) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
|
|
13
|
+
const uses = (src: string, tag: string) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
|
|
14
|
+
|
|
15
|
+
const FIX = `
|
|
16
|
+
<template>
|
|
17
|
+
<NuxtLayout>
|
|
18
|
+
<NuxtPage />
|
|
19
|
+
</NuxtLayout>
|
|
20
|
+
</template>`
|
|
21
|
+
|
|
22
|
+
/** Reports only — assigning `mainComponent` would defeat a legitimate override. See ADR-0005. */
|
|
23
|
+
export function diagnoseAppShell({ mainComponent, pagesEnabled, read }: AppShellInput): AppShellDiagnostic[] {
|
|
24
|
+
const found: AppShellDiagnostic[] = []
|
|
25
|
+
|
|
26
|
+
if (!pagesEnabled) {
|
|
27
|
+
found.push({
|
|
28
|
+
level: 'error',
|
|
29
|
+
message:
|
|
30
|
+
'the pages feature is disabled (`pages: false`), so no route is registered at all — the admin is a set of pages under `app/pages/admin/`. Remove the override to reach /admin.',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (mainComponent) {
|
|
35
|
+
let src: string | undefined
|
|
36
|
+
try {
|
|
37
|
+
src = withoutComments(read(mainComponent))
|
|
38
|
+
} catch {
|
|
39
|
+
// An unreadable app root is Nuxt's to report; guessing here would produce a phantom error.
|
|
40
|
+
}
|
|
41
|
+
if (src !== undefined && !uses(src, 'NuxtPage')) {
|
|
42
|
+
found.push({
|
|
43
|
+
level: 'error',
|
|
44
|
+
message: `${mainComponent} renders no <NuxtPage />, so NO route renders — including /admin. It shadows Kestrel's own app.vue because the consumer layer wins. Either delete it (Kestrel ships a working one) or make it:${FIX}`,
|
|
45
|
+
})
|
|
46
|
+
} else if (src !== undefined && !uses(src, 'NuxtLayout')) {
|
|
47
|
+
found.push({
|
|
48
|
+
level: 'warn',
|
|
49
|
+
message: `${mainComponent} renders no <NuxtLayout />, so page-level \`layout\` is ignored and the admin loses its navigation shell. Wrap <NuxtPage /> in <NuxtLayout>.`,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return found
|
|
55
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
3
|
import { resolveKestrel, type KestrelConfig } from '../../server/utils/kestrel-config'
|
|
4
|
+
import { diagnoseAppShell } from './app-shell'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The `kestrel` config namespace (`kestrel: { … }` in nuxt.config, sourced from `kestrel.config.ts`).
|
|
@@ -13,6 +15,21 @@ export default defineNuxtModule<KestrelConfig>({
|
|
|
13
15
|
setup(options, nuxt) {
|
|
14
16
|
const c = resolveKestrel(options, process.env, nuxt.options.rootDir)
|
|
15
17
|
|
|
18
|
+
// `app:resolve` is the first hook where `mainComponent` is settled across all layers. In dev it fires
|
|
19
|
+
// on every watched change, so an unfixed problem would repaint the whole message on each keystroke.
|
|
20
|
+
const reported = new Set<string>()
|
|
21
|
+
nuxt.hook('app:resolve', (app) => {
|
|
22
|
+
for (const d of diagnoseAppShell({
|
|
23
|
+
mainComponent: app.mainComponent,
|
|
24
|
+
pagesEnabled: nuxt.options.pages !== false,
|
|
25
|
+
read: (file) => readFileSync(file, 'utf8'),
|
|
26
|
+
})) {
|
|
27
|
+
if (reported.has(d.message)) continue
|
|
28
|
+
reported.add(d.message)
|
|
29
|
+
console[d.level === 'error' ? 'error' : 'warn'](`[kestrel] ${d.message}`)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
16
33
|
const rc = nuxt.options.runtimeConfig
|
|
17
34
|
rc.media = {
|
|
18
35
|
driver: c.media.driver,
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } 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 { create } from '../../utils/crud'
|
|
8
|
+
import { requireCollection, parseIdList } from '../../utils/http'
|
|
9
|
+
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
+
import { pickerOptions } from '../../utils/picker'
|
|
11
|
+
import { desiredSchema } from '../../schema/desired'
|
|
12
|
+
import { diffSchema } from '../../schema/diff'
|
|
13
|
+
import { renderSqlite } from '../../schema/render-sqlite'
|
|
14
|
+
|
|
15
|
+
const posts = buildCollection(defineCollection({
|
|
16
|
+
name: 'posts', mode: 'multi', translatable: false,
|
|
17
|
+
fields: { title: { type: 'text', required: true } },
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string>; readScope?: string } }
|
|
21
|
+
|
|
22
|
+
let db: ReturnType<typeof drizzle>
|
|
23
|
+
|
|
24
|
+
// Same rationale as translations.get.test.ts: bind the handler's auto-imported helpers to the REAL
|
|
25
|
+
// implementations so this proves the actual wiring, not a stub the server does not have.
|
|
26
|
+
Object.assign(globalThis, {
|
|
27
|
+
defineEventHandler: (handler: unknown) => handler,
|
|
28
|
+
createError,
|
|
29
|
+
getQuery: (event: FakeEvent) => event.query,
|
|
30
|
+
useDb: () => db,
|
|
31
|
+
requireCollection,
|
|
32
|
+
parseIdList,
|
|
33
|
+
pickerOptions,
|
|
34
|
+
publishedOnlyForScope: () => false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const handler = (await import('./options.get')).default as unknown as (event: FakeEvent) => ReturnType<typeof pickerOptions>
|
|
38
|
+
const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
const sqlite = new Database(':memory:')
|
|
42
|
+
for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table]), {}))) sqlite.exec(stmt)
|
|
43
|
+
db = drizzle(sqlite)
|
|
44
|
+
clearRegistry()
|
|
45
|
+
registerCollection(posts)
|
|
46
|
+
})
|
|
47
|
+
afterEach(() => clearRegistry())
|
|
48
|
+
|
|
49
|
+
describe('GET /api/{collection}/options?ids=', () => {
|
|
50
|
+
it('resolves more than 100 ids in one request instead of truncating', () => {
|
|
51
|
+
const ids = Array.from({ length: 150 }, (_, i) => (create(db, posts, { title: `T${i}` }) as { id: number }).id)
|
|
52
|
+
const r = get('posts', { ids: ids.join(',') })
|
|
53
|
+
expect(r.data.length).toBe(150)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('400s (never silently truncates) an ids list over the shared bulk cap', () => {
|
|
57
|
+
const ids = Array.from({ length: 501 }, (_, i) => i + 1)
|
|
58
|
+
expect(() => get('posts', { ids: ids.join(',') })).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
59
|
+
})
|
|
60
|
+
})
|