@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.
Files changed (51) hide show
  1. package/README.md +33 -3
  2. package/layers/admin/app/components/PageFields.vue +25 -0
  3. package/layers/admin/app/composables/useEditForm.ts +5 -0
  4. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
  5. package/layers/auth/nuxt.config.ts +0 -0
  6. package/layers/auth/server/api/auth/session.get.ts +0 -0
  7. package/layers/auth/server/utils/password.ts +0 -0
  8. package/layers/auth/server/utils/session.ts +5 -2
  9. package/layers/collections/nuxt.config.ts +0 -0
  10. package/layers/core/app/composables/layouts.ts +5 -0
  11. package/layers/core/app/utils/layouts.ts +38 -0
  12. package/layers/core/modules/auto-discovery/index.ts +11 -1
  13. package/layers/core/modules/auto-discovery/virtual.d.ts +3 -0
  14. package/layers/core/modules/kestrel/app-shell.ts +55 -0
  15. package/layers/core/modules/kestrel/index.ts +17 -0
  16. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
  17. package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
  18. package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
  19. package/layers/core/server/utils/blocks.ts +0 -0
  20. package/layers/core/server/utils/seo.ts +0 -0
  21. package/layers/fields/nuxt.config.ts +0 -0
  22. package/layers/fields/server/utils/buildTable.ts +8 -2
  23. package/layers/media/server/api/media/[id].get.ts +0 -0
  24. package/layers/media/server/api/media/index.get.ts +0 -0
  25. package/layers/public/app/app.vue +0 -0
  26. package/layers/public/app/error.vue +0 -0
  27. package/layers/public/app/layouts/default.vue +0 -0
  28. package/layers/public/app/pages/[...slug].vue +31 -15
  29. package/layers/public/app/utils/page-layout.ts +18 -0
  30. package/layers/ui/app/assets/scss/_reset.scss +0 -0
  31. package/layers/ui/app/assets/scss/main.scss +0 -0
  32. package/layers/ui/app/components/ui/Alert.vue +0 -0
  33. package/layers/ui/app/i18n/de.ts +3 -0
  34. package/layers/ui/app/i18n/en.ts +3 -0
  35. package/package.json +6 -2
  36. package/scripts/copy-create-payload.mjs +49 -0
  37. package/scripts/hash-password.mjs +6 -15
  38. package/scripts/kestrel.mjs +216 -0
  39. package/scripts/lib/cli.mjs +114 -0
  40. package/scripts/lib/password.mjs +19 -0
  41. package/scripts/lib/scaffold.mjs +174 -0
  42. package/templates/starter/README.md +65 -0
  43. package/templates/starter/_env.example +17 -0
  44. package/templates/starter/_gitignore +26 -0
  45. package/templates/starter/_package.json +22 -0
  46. package/templates/starter/app/app.vue +7 -0
  47. package/templates/starter/app/blocks/Prose.vue +12 -0
  48. package/templates/starter/app/layouts/default.vue +6 -0
  49. package/templates/starter/nuxt.config.ts +20 -0
  50. package/templates/starter/pnpm-workspace.yaml +8 -0
  51. 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). Full guide:
92
- **[consuming-kestrel.md](docs/consuming-kestrel.md)**.
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
- if (isProd && !secureCookies) {
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,5 @@
1
+ import { kestrelLayouts } from '#build/kestrel-layouts.mjs'
2
+
3
+ export function useOfferableLayouts(): string[] {
4
+ return kestrelLayouts
5
+ }
@@ -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
@@ -10,3 +10,6 @@ declare module '#kestrel/schema-tables' {
10
10
  const tables: unknown[]
11
11
  export default tables
12
12
  }
13
+ declare module '#build/kestrel-layouts.mjs' {
14
+ export const kestrelLayouts: string[]
15
+ }
@@ -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,
@@ -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
+ })