@michaelthielemann/kestrel 1.2.1 → 1.3.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/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/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/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/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/package.json +6 -2
- package/scripts/copy-create-payload.mjs +52 -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
|
|
@@ -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,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
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
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, resolveTranslations } from '../../utils/crud'
|
|
8
|
+
import { requireCollection } from '../../utils/http'
|
|
9
|
+
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
+
import { desiredSchema } from '../../schema/desired'
|
|
11
|
+
import { diffSchema } from '../../schema/diff'
|
|
12
|
+
import { renderSqlite } from '../../schema/render-sqlite'
|
|
13
|
+
|
|
14
|
+
const pages = buildCollection(defineCollection({
|
|
15
|
+
name: 'pages', mode: 'multi', translatable: true, pageLike: true,
|
|
16
|
+
fields: { title: { type: 'text', required: true } },
|
|
17
|
+
}))
|
|
18
|
+
const notes = buildCollection(defineCollection({
|
|
19
|
+
name: 'notes', mode: 'multi', translatable: false,
|
|
20
|
+
fields: { title: { type: 'text', required: true } },
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
// `context.params` is where Nitro puts the route params the real `requireCollection` reads via h3.
|
|
24
|
+
interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string> } }
|
|
25
|
+
|
|
26
|
+
let db: ReturnType<typeof drizzle>
|
|
27
|
+
|
|
28
|
+
// The handler is a Nitro route: its auto-imported helpers are plain globals in a node test — bound to the
|
|
29
|
+
// REAL implementations so nothing here can pass against a stub the server does not have.
|
|
30
|
+
Object.assign(globalThis, {
|
|
31
|
+
defineEventHandler: (handler: unknown) => handler,
|
|
32
|
+
createError,
|
|
33
|
+
getQuery: (event: FakeEvent) => event.query,
|
|
34
|
+
useDb: () => db,
|
|
35
|
+
requireCollection,
|
|
36
|
+
resolveTranslations,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Calling the handler directly says nothing about the path ever reaching it: that the literal `translations`
|
|
40
|
+
// segment wins over the sibling `[id]` route is only provable on a real server → `test/e2e/api.test.ts`.
|
|
41
|
+
const handler = (await import('./translations.get')).default as unknown as (event: FakeEvent) => Record<string, number | null>
|
|
42
|
+
const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
const sqlite = new Database(':memory:')
|
|
46
|
+
for (const stmt of renderSqlite(diffSchema(desiredSchema([pages.table, notes.table]), {}))) sqlite.exec(stmt)
|
|
47
|
+
db = drizzle(sqlite)
|
|
48
|
+
clearRegistry()
|
|
49
|
+
registerCollection(pages)
|
|
50
|
+
registerCollection(notes)
|
|
51
|
+
})
|
|
52
|
+
afterEach(() => clearRegistry())
|
|
53
|
+
|
|
54
|
+
describe('GET /api/{collection}/translations?group=', () => {
|
|
55
|
+
it('resolves the group\'s locale → sibling id map without a record id', () => {
|
|
56
|
+
const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
|
|
57
|
+
const de = create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string }) as Record<string, unknown>
|
|
58
|
+
expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: de.id })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reports a locale with no sibling as null (what the "+ create" affordance keys off)', () => {
|
|
62
|
+
const en = create(db, pages, { title: 'Only EN' }) as Record<string, unknown>
|
|
63
|
+
expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: null })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns exactly the per-record map for the same group (one map builder, two entry points)', () => {
|
|
67
|
+
const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
|
|
68
|
+
create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string })
|
|
69
|
+
expect(get('pages', { group: en.translationGroup })).toEqual(resolveTranslations(db, pages, en.id as number))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('400s without a group rather than answering for an arbitrary one', () => {
|
|
73
|
+
expect(() => get('pages', {})).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
74
|
+
expect(() => get('pages', { group: ' ' })).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('404s for a group that has no rows', () => {
|
|
78
|
+
create(db, pages, { title: 'Home' })
|
|
79
|
+
expect(() => get('pages', { group: 'nope' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('400s (never 500s) for a collection without translations — it has no group column at all', () => {
|
|
83
|
+
create(db, notes, { title: 'Note' })
|
|
84
|
+
expect(() => get('notes', { group: 'g1' })).toThrowError(/Translations are not enabled/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('404s for an unknown collection', () => {
|
|
88
|
+
expect(() => get('nope', { group: 'g1' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
|
|
89
|
+
})
|
|
90
|
+
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michaelthielemann/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
|
|
@@ -21,18 +21,22 @@
|
|
|
21
21
|
],
|
|
22
22
|
"type": "module",
|
|
23
23
|
"main": "./nuxt.config.ts",
|
|
24
|
+
"bin": {
|
|
25
|
+
"kestrel": "./scripts/kestrel.mjs"
|
|
26
|
+
},
|
|
24
27
|
"packageManager": "pnpm@11.9.0",
|
|
25
28
|
"publishConfig": {
|
|
26
29
|
"access": "public",
|
|
27
30
|
"provenance": true
|
|
28
31
|
},
|
|
29
|
-
"//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import). `files` ships the entry + sub-layers + operator scripts; the `!**/*.test.ts` globs strip tests. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
|
|
32
|
+
"//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import); `bin` resolves by path from the package root, so it coexists with `main` untouched. `files` ships the entry + sub-layers + operator scripts + the `kestrel init` templates; the `!**/*.test.ts` globs strip tests — they are GLOBAL, so nothing under `templates/` may be named `*.test.ts` or it vanishes from the tarball. Template dotfiles are `_`-prefixed for the same class of reason: npm strips a literal `.gitignore` from a tarball and then applies it, taking its listed siblings with it. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
|
|
30
33
|
"files": [
|
|
31
34
|
"NOTICE",
|
|
32
35
|
"nuxt.config.ts",
|
|
33
36
|
"kestrel.config.ts",
|
|
34
37
|
"layers",
|
|
35
38
|
"scripts",
|
|
39
|
+
"templates",
|
|
36
40
|
"!**/*.test.ts",
|
|
37
41
|
"!**/*.dom.test.ts",
|
|
38
42
|
"!**/*.nuxt.test.ts"
|