create-kywi-app 0.1.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.
@@ -0,0 +1,1384 @@
1
+ /**
2
+ * File templates for a scaffolded Kywi app.
3
+ *
4
+ * Each factory takes the resolved answers and returns file content. Kept as a
5
+ * plain data module (no deps) so the generator stays dependency-free and easy to
6
+ * unit test — buildFileSet() returns the exact map of path → content that the
7
+ * CLI writes, which is what the tests assert against.
8
+ *
9
+ * A generated project boots and works as documented with ZERO manual edits:
10
+ * `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives a working admin
11
+ * at /admin (log in with the seeded credentials, create + publish a page) and,
12
+ * in coupled mode, a public site that renders published pages at their slug.
13
+ *
14
+ * The three deployment modes differ only in the public surface:
15
+ * - coupled: renders the public site (app/(site)) AND serves the admin + API.
16
+ * - headless: admin + API only — GET / returns 404, no public rendering.
17
+ * - decoupled: admin + API only — a SEPARATE frontend consumes the API via
18
+ * @kywi-software/sdk (see the generated README).
19
+ * The admin (/admin) and API (/api/v1) are identical across all three modes.
20
+ *
21
+ * The security-critical session plumbing (JWT verify, httpOnly cookie contract,
22
+ * cookie→bearer bridge) is NOT copied into every project: it lives once in
23
+ * @kywi-software/core/host (+ /host-client) and the generated middleware.ts and
24
+ * API route are thin framework wiring over those primitives, so a `kywi` upgrade
25
+ * ships session fixes without the app hand-maintaining crypto.
26
+ */
27
+
28
+ /** @typedef {{ projectName: string, dbProvider: 'postgresql'|'mysql', authProviders: string[], mode: 'coupled'|'headless'|'decoupled', kywiVersion: string }} Answers */
29
+
30
+ const CORE_RANGE = (v) => `^${v}`
31
+
32
+ // ── package.json ──────────────────────────────────────────────────────────────
33
+
34
+ /** @param {Answers} a */
35
+ function packageJson(a) {
36
+ return JSON.stringify(
37
+ {
38
+ name: a.projectName,
39
+ version: '0.1.0',
40
+ private: true,
41
+ type: 'module',
42
+ scripts: {
43
+ dev: 'next dev',
44
+ build: 'next build',
45
+ start: 'next start',
46
+ migrate: 'kywi migrate --push',
47
+ seed: 'kywi seed',
48
+ },
49
+ dependencies: {
50
+ '@kywi-software/core': CORE_RANGE(a.kywiVersion),
51
+ '@kywi-software/cli': CORE_RANGE(a.kywiVersion),
52
+ next: '^15.0.0',
53
+ react: '^19.0.0',
54
+ 'react-dom': '^19.0.0',
55
+ // Server-side image processing used by core's media pipeline. Listed in
56
+ // next.config's serverExternalPackages, so it must be installed.
57
+ sharp: '^0.34.0',
58
+ },
59
+ devDependencies: {
60
+ '@types/node': '^20.0.0',
61
+ '@types/react': '^19.0.0',
62
+ '@types/react-dom': '^19.0.0',
63
+ // `kywi migrate` shells out to drizzle-kit against the schema generated
64
+ // into kywi-generated/. They must be present in THIS project's
65
+ // node_modules for `node_modules/.bin/drizzle-kit` to resolve.
66
+ 'drizzle-kit': '^0.28.0',
67
+ 'drizzle-orm': '^0.38.0',
68
+ // The kywi CLI loads your TypeScript kywi.config.ts via the tsx loader.
69
+ tsx: '^4.19.0',
70
+ typescript: '^5.5.0',
71
+ },
72
+ // Let pnpm run the native build steps these packages need without an
73
+ // interactive approval prompt (no-op for npm/yarn).
74
+ pnpm: {
75
+ onlyBuiltDependencies: ['esbuild', 'sharp'],
76
+ },
77
+ },
78
+ null,
79
+ 2,
80
+ ) + '\n'
81
+ }
82
+
83
+ // ── kywi.config.ts ────────────────────────────────────────────────────────────
84
+
85
+ /** @param {Answers} a */
86
+ function kywiConfig(a) {
87
+ const providers = a.authProviders.map((p) => `'${p}'`).join(', ')
88
+ return `import { defineKywiConfig, defineSite, defineTheme } from '@kywi-software/core/config'
89
+ import { assertProductionAuthSecret } from '@kywi-software/core/host'
90
+
91
+ // Refuse to build/start in production with a missing or dev-fallback AUTH_SECRET
92
+ // (tokens signed with a publicly-known default are forgeable). No-op in
93
+ // development, so \`pnpm dev\` stays frictionless. Runs whenever this config is
94
+ // imported — by the app at build/boot and by the CLI (migrate/seed).
95
+ assertProductionAuthSecret(process.env.AUTH_SECRET)
96
+
97
+ /**
98
+ * Kywi configuration — the single source of truth for this project.
99
+ * Docs: https://kywi.dev/docs/config
100
+ */
101
+ export default defineKywiConfig({
102
+ // Deployment mode:
103
+ // 'coupled' — this app renders the public site AND serves the API + admin.
104
+ // 'headless' — API + admin only; no public rendering (GET / is 404).
105
+ // 'decoupled' — API + admin here; a separate frontend consumes @kywi-software/sdk.
106
+ mode: '${a.mode}',
107
+
108
+ db: {
109
+ provider: '${a.dbProvider}',
110
+ url: process.env.DATABASE_URL ?? 'postgres://localhost:5432/${slugify(a.projectName)}',
111
+ },
112
+
113
+ auth: {
114
+ // NEVER ship the fallback secret to production — set AUTH_SECRET in the env.
115
+ secret: process.env.AUTH_SECRET ?? 'dev-secret-change-in-production',
116
+ providers: [${providers}],
117
+ },
118
+
119
+ sites: [
120
+ defineSite({
121
+ id: 'default',
122
+ name: '${a.projectName}',
123
+ domain: 'localhost',
124
+ defaultLocale: 'en',
125
+ theme: 'default',
126
+ }),
127
+ ],
128
+
129
+ themes: [
130
+ defineTheme({
131
+ name: 'default',
132
+ regions: [
133
+ { name: 'header', label: 'Header' },
134
+ { name: 'main', label: 'Main Content' },
135
+ { name: 'footer', label: 'Footer' },
136
+ ],
137
+ }),
138
+ ],
139
+
140
+ // Content types you can author in the admin. "Page" ships by default so a
141
+ // fresh site can create and publish pages immediately; add your own here
142
+ // (blog posts, events, …) and re-run \`pnpm migrate\`.
143
+ contentTypes: [
144
+ {
145
+ name: 'page',
146
+ label: 'Page',
147
+ baseType: 'content',
148
+ fields: [],
149
+ },
150
+ ],
151
+ })
152
+ `
153
+ }
154
+
155
+ // ── tsconfig.json ─────────────────────────────────────────────────────────────
156
+
157
+ function tsconfig() {
158
+ return JSON.stringify(
159
+ {
160
+ compilerOptions: {
161
+ target: 'ES2022',
162
+ lib: ['dom', 'dom.iterable', 'ES2022'],
163
+ module: 'ESNext',
164
+ moduleResolution: 'Bundler',
165
+ strict: true,
166
+ noEmit: true,
167
+ esModuleInterop: true,
168
+ jsx: 'preserve',
169
+ incremental: true,
170
+ skipLibCheck: true,
171
+ plugins: [{ name: 'next' }],
172
+ paths: { '@/*': ['./*'] },
173
+ },
174
+ include: ['**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
175
+ exclude: ['node_modules'],
176
+ },
177
+ null,
178
+ 2,
179
+ ) + '\n'
180
+ }
181
+
182
+ // ── next.config.mjs ───────────────────────────────────────────────────────────
183
+
184
+ function nextConfig() {
185
+ return `import { createRequire } from 'node:module'
186
+
187
+ // require() is needed for require.resolve() of the local dompurify stub below.
188
+ const require = createRequire(import.meta.url)
189
+
190
+ /**
191
+ * Next.js config for a Kywi app. Every setting here is required to consume
192
+ * @kywi-software/core; removing any of them breaks the build:
193
+ *
194
+ * - transpilePackages: core ships TypeScript/TSX source, transpiled in-app.
195
+ * - extensionAlias: core imports use the TS-ESM \`.js\` convention; webpack
196
+ * must resolve those specifiers to the real \`.ts\`/\`.tsx\`.
197
+ * - serverExternalPackages: core's server deps (sharp, drizzle, postgres, …)
198
+ * stay server-side and out of the client bundle.
199
+ * - isomorphic-dompurify stub: jsdom fails when webpack bundles it on the
200
+ * server, and its CJS/ESM interop breaks client transpile;
201
+ * the stub passes content through (sanitisation is
202
+ * defense-in-depth — CMS-authored content is trusted).
203
+ */
204
+ /** @type {import('next').NextConfig} */
205
+ const nextConfig = {
206
+ transpilePackages: ['@kywi-software/core'],
207
+ serverExternalPackages: [
208
+ 'sharp',
209
+ 'drizzle-orm',
210
+ 'postgres',
211
+ 'nodemailer',
212
+ 'bcryptjs',
213
+ '@aws-sdk/client-s3',
214
+ 'jsdom',
215
+ 'isomorphic-dompurify',
216
+ ],
217
+ webpack(config) {
218
+ config.resolve.extensionAlias = {
219
+ ...config.resolve.extensionAlias,
220
+ '.js': ['.ts', '.tsx', '.js'],
221
+ '.jsx': ['.tsx', '.jsx'],
222
+ }
223
+ config.resolve.alias = {
224
+ ...config.resolve.alias,
225
+ 'isomorphic-dompurify': require.resolve('./lib/dompurify-stub.js'),
226
+ }
227
+ return config
228
+ },
229
+ }
230
+
231
+ export default nextConfig
232
+ `
233
+ }
234
+
235
+ function dompurifyStub() {
236
+ return `/**
237
+ * Stub for isomorphic-dompurify used in both server and client webpack bundles.
238
+ * Server: isomorphic-dompurify uses jsdom, which fails when webpack bundles it.
239
+ * Client: its CJS/ESM interop breaks when @kywi-software/core is transpiled.
240
+ * This stub passes content through unchanged — DOMPurify sanitisation is
241
+ * defense-in-depth; CMS-authored content is trusted.
242
+ */
243
+ const DOMPurify = {
244
+ sanitize: (dirty) => dirty,
245
+ addHook: () => {},
246
+ removeHook: () => {},
247
+ removeHooks: () => {},
248
+ removeAllHooks: () => {},
249
+ isValidAttribute: () => true,
250
+ setConfig: () => {},
251
+ clearConfig: () => {},
252
+ }
253
+
254
+ export default DOMPurify
255
+ export const sanitize = DOMPurify.sanitize
256
+ `
257
+ }
258
+
259
+ // ── env / gitignore ───────────────────────────────────────────────────────────
260
+
261
+ function envExample() {
262
+ return `# Copy to .env and fill in. .env is gitignored.
263
+
264
+ # PostgreSQL connection string. Create the database first: createdb my_db
265
+ # Postgres 14 or newer works.
266
+ DATABASE_URL=postgres://localhost:5432/CHANGE_ME
267
+
268
+ # 32+ char random string. Generate: openssl rand -base64 32
269
+ # Required in production — the app refuses to start without it.
270
+ AUTH_SECRET=CHANGE_ME
271
+ `
272
+ }
273
+
274
+ function gitignore() {
275
+ return `node_modules
276
+ .next
277
+ .env
278
+ .env.local
279
+ *.log
280
+ `
281
+ }
282
+
283
+ // ── server runtime (lib/kywi.ts) ──────────────────────────────────────────────
284
+
285
+ function libKywi() {
286
+ return `import {
287
+ createDb,
288
+ createKywiApiHandler,
289
+ createStorageProvider,
290
+ resolveDatabaseUrl,
291
+ resolveAuthSecret,
292
+ } from '@kywi-software/core/server'
293
+ import type { KywiApiHandler, KywiDb } from '@kywi-software/core/server'
294
+ import { createKywiScope } from '@kywi-software/core/scope'
295
+ import type { KywiScope } from '@kywi-software/core/scope'
296
+ import config from '../kywi.config'
297
+
298
+ /**
299
+ * Server-side Kywi singletons — config, DB, API handler and the DB-backed scope,
300
+ * all sharing one connection pool. Memoised so it initialises once per process.
301
+ */
302
+ export interface KywiRuntime {
303
+ handler: KywiApiHandler
304
+ scope: KywiScope
305
+ db: KywiDb
306
+ siteId: string
307
+ config: typeof config
308
+ }
309
+
310
+ let _runtime: KywiRuntime | null = null
311
+ let _init: Promise<KywiRuntime> | null = null
312
+
313
+ async function init(): Promise<KywiRuntime> {
314
+ const { url: dbUrl } = resolveDatabaseUrl(config.db.url)
315
+ const db = createDb(dbUrl, config.db.provider)
316
+ const authSecret = resolveAuthSecret(config.auth.secret)
317
+ const storage = createStorageProvider(config.media ?? { provider: 'local', localPath: './uploads' })
318
+ const handler = await createKywiApiHandler({ config, db, authSecret, storage })
319
+ const scope = createKywiScope(config, db, undefined, { mediaBaseUrl: '/api/v1' })
320
+
321
+ // Resolve the DB UUID for the first configured site (its \`id\` is the slug).
322
+ const sites = await scope.site.list()
323
+ const match = sites.find((s) => (s as { slug?: string }).slug === config.sites[0]?.id)
324
+ const siteId = (match?.['id'] as string | undefined) ?? config.sites[0]?.id ?? 'default'
325
+
326
+ return { handler, scope, db, siteId, config }
327
+ }
328
+
329
+ export async function getKywi(): Promise<KywiRuntime> {
330
+ if (_runtime) return _runtime
331
+ if (!_init) _init = init().then((r) => (_runtime = r))
332
+ return _init
333
+ }
334
+
335
+ export async function getKywiHandler(): Promise<KywiApiHandler> {
336
+ return (await getKywi()).handler
337
+ }
338
+ `
339
+ }
340
+
341
+ function libConfig() {
342
+ return `/** Re-export the Kywi config for use throughout the app (single import path). */
343
+ import config from '../kywi.config'
344
+ export default config
345
+ `
346
+ }
347
+
348
+ function libAdminAuth() {
349
+ return `/**
350
+ * Client-side admin helpers. Re-exported from @kywi-software/core/host-client so
351
+ * every admin surface imports them from one local path — swap the source here if
352
+ * you ever want to customise them.
353
+ */
354
+ export {
355
+ adminFetch,
356
+ getAdminUser,
357
+ setAuth,
358
+ clearAuth,
359
+ signOut,
360
+ getActiveSiteId,
361
+ setActiveSiteId,
362
+ clearActiveSiteId,
363
+ type AdminUser,
364
+ } from '@kywi-software/core/host-client'
365
+ `
366
+ }
367
+
368
+ // ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
369
+
370
+ function middleware() {
371
+ return `import { NextResponse, type NextRequest } from 'next/server'
372
+ import {
373
+ ACCESS_COOKIE,
374
+ REFRESH_COOKIE,
375
+ assertProductionAuthSecret,
376
+ classifyAccessToken,
377
+ setAccessCookie,
378
+ clearSessionCookies,
379
+ } from '@kywi-software/core/host'
380
+
381
+ /**
382
+ * Server-side auth enforcement + transparent session refresh for /admin and the
383
+ * versioned API. The security-critical logic (JWT verify, cookie contract) lives
384
+ * in @kywi-software/core/host; this file is only the Next.js wiring:
385
+ *
386
+ * 1. Refresh a stale access cookie from the 7-day refresh cookie, so an admin
387
+ * is never bounced to /admin/login mid-session.
388
+ * 2. Bridge the httpOnly access cookie onto \`Authorization: Bearer\` for
389
+ * /api/v1/* — core reads credentials only from that header, and the browser
390
+ * cannot attach it (the token is httpOnly).
391
+ * 3. Gate /admin/* (except /admin/login): no usable session → redirect to login.
392
+ *
393
+ * Must resolve the SAME secret handed to createKywiApiHandler (config.auth.secret
394
+ * === process.env.AUTH_SECRET ?? default), or it would reject tokens the API accepts.
395
+ */
396
+
397
+ // Refuse to run in production with a missing or dev-fallback AUTH_SECRET — this
398
+ // middleware verifies session JWTs, so a publicly-known default would make them
399
+ // forgeable. Runs at module load (edge bundle init), so a bad prod deploy fails
400
+ // loudly instead of silently accepting forged tokens. No-op in development.
401
+ assertProductionAuthSecret(process.env.AUTH_SECRET)
402
+
403
+ const AUTH_SECRET = process.env.AUTH_SECRET ?? 'dev-secret-change-in-production'
404
+
405
+ // Token/session endpoints are pass-through: never guarded, refreshed, or bridged
406
+ // (they establish a session rather than require one; the refresh call below is
407
+ // itself a POST to /auth/refresh and must not recurse).
408
+ function isAuthEndpoint(pathname: string): boolean {
409
+ return pathname.startsWith('/api/v1/auth/') &&
410
+ !pathname.startsWith('/api/v1/auth/oauth-clients') &&
411
+ !pathname.startsWith('/api/v1/auth/api-keys')
412
+ }
413
+
414
+ async function fetchFreshAccessToken(origin: string, refreshToken: string): Promise<string | undefined> {
415
+ try {
416
+ const res = await fetch(origin + '/api/v1/auth/refresh', {
417
+ method: 'POST',
418
+ headers: { 'content-type': 'application/json' },
419
+ body: JSON.stringify({ refreshToken }),
420
+ })
421
+ if (!res.ok) return undefined
422
+ const body = (await res.json().catch(() => null)) as { data?: { accessToken?: unknown } } | null
423
+ const token = body?.data?.accessToken
424
+ return typeof token === 'string' && token.length > 0 ? token : undefined
425
+ } catch {
426
+ return undefined
427
+ }
428
+ }
429
+
430
+ export async function middleware(req: NextRequest): Promise<NextResponse> {
431
+ const { pathname } = req.nextUrl
432
+ if (isAuthEndpoint(pathname)) return NextResponse.next()
433
+
434
+ const accessToken = req.cookies.get(ACCESS_COOKIE)?.value
435
+ const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value
436
+ const status = await classifyAccessToken(accessToken, AUTH_SECRET)
437
+
438
+ let freshAccess: string | undefined
439
+ if (status !== 'valid' && refreshToken) {
440
+ freshAccess = await fetchFreshAccessToken(req.nextUrl.origin, refreshToken)
441
+ }
442
+ const usableToken = status === 'valid' ? accessToken : freshAccess
443
+
444
+ // /api/v1/*: bridge cookie → Authorization header.
445
+ if (pathname.startsWith('/api/v1/')) {
446
+ if (usableToken && !req.headers.get('authorization')) {
447
+ const headers = new Headers(req.headers)
448
+ headers.set('authorization', 'Bearer ' + usableToken)
449
+ const res = NextResponse.next({ request: { headers } })
450
+ if (freshAccess) setAccessCookie(res, freshAccess)
451
+ return res
452
+ }
453
+ const res = NextResponse.next()
454
+ if (status !== 'valid' && refreshToken && !freshAccess) clearSessionCookies(res)
455
+ return res
456
+ }
457
+
458
+ // /admin/*: server-side gate. Login must stay reachable while anonymous.
459
+ if (pathname === '/admin/login') return NextResponse.next()
460
+ if (status === 'valid') return NextResponse.next()
461
+ if (freshAccess) {
462
+ const res = NextResponse.next()
463
+ setAccessCookie(res, freshAccess)
464
+ return res
465
+ }
466
+
467
+ const loginUrl = req.nextUrl.clone()
468
+ loginUrl.pathname = '/admin/login'
469
+ loginUrl.search = ''
470
+ loginUrl.searchParams.set('next', pathname + req.nextUrl.search)
471
+ const res = NextResponse.redirect(loginUrl)
472
+ clearSessionCookies(res)
473
+ return res
474
+ }
475
+
476
+ export const config = {
477
+ matcher: ['/admin/:path*', '/api/v1/:path*'],
478
+ }
479
+ `
480
+ }
481
+
482
+ // ── root layout + public site ────────────────────────────────────────────────
483
+
484
+ function rootLayout() {
485
+ return `import React from 'react'
486
+
487
+ export const metadata = {
488
+ title: 'Kywi CMS',
489
+ description: 'Built with Kywi',
490
+ }
491
+
492
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
493
+ return (
494
+ <html lang="en">
495
+ <body style={{ margin: 0, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{children}</body>
496
+ </html>
497
+ )
498
+ }
499
+ `
500
+ }
501
+
502
+ /** @param {Answers} a — coupled public site shell. */
503
+ function siteLayout(a) {
504
+ return `import React from 'react'
505
+
506
+ /**
507
+ * Public site shell. The header carries this project's name; edit freely — this
508
+ * is your app's own layer over the DB-backed content that ${'app/(site)/[[...slug]]'} renders.
509
+ */
510
+ export default function SiteLayout({ children }: { children: React.ReactNode }) {
511
+ return (
512
+ <div>
513
+ <header style={{ borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
514
+ <div style={{ maxWidth: 960, margin: '0 auto', padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
515
+ <a href="/" style={{ fontWeight: 700, fontSize: '1.125rem', color: '#0f172a', textDecoration: 'none' }}>${escapeJsxText(a.projectName)}</a>
516
+ <a href="/admin" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.9375rem' }}>Admin →</a>
517
+ </div>
518
+ </header>
519
+ <main style={{ maxWidth: 720, margin: '0 auto', padding: '2.5rem 1rem' }}>{children}</main>
520
+ <footer style={{ borderTop: '1px solid #e2e8f0', marginTop: '3rem' }}>
521
+ <div style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem', color: '#64748b', fontSize: '0.875rem' }}>
522
+ Powered by <a href="https://kywi.dev" style={{ color: '#64748b' }}>Kywi CMS</a>
523
+ </div>
524
+ </footer>
525
+ </div>
526
+ )
527
+ }
528
+ `
529
+ }
530
+
531
+ /** Coupled catch-all: renders home ("/") and any published page at its slug. */
532
+ function siteSlugPage() {
533
+ return `import React from 'react'
534
+ import { notFound } from 'next/navigation'
535
+ import { KywiBody } from '@kywi-software/core/scope-client'
536
+ import { getKywi } from '../../../lib/kywi'
537
+
538
+ // Every page comes from the database, so this route is always dynamic.
539
+ export const dynamic = 'force-dynamic'
540
+
541
+ type Params = { params: Promise<{ slug?: string[] }> }
542
+
543
+ // "/" resolves the seeded Home node; any other path resolves the published node
544
+ // whose slug is the last URL segment. Draft / missing content 404s.
545
+ export default async function PublicPage({ params }: Params) {
546
+ const { slug } = await params
547
+ const { scope, siteId } = await getKywi()
548
+ const target = slug && slug.length > 0 ? slug[slug.length - 1]! : 'home'
549
+
550
+ const node = (await scope.content.getBySlug(target, siteId)) as Record<string, unknown> | null
551
+ if (!node || node['status'] !== 'published') notFound()
552
+
553
+ const title = String(node['title'] ?? 'Untitled')
554
+ const body = (node['body'] as string) || ''
555
+
556
+ return (
557
+ <article>
558
+ <h1 style={{ fontSize: '2rem', marginBottom: '1rem', color: '#0f172a' }}>{title}</h1>
559
+ {body ? <KywiBody content={body} /> : <p style={{ color: '#64748b' }}>This page has no content yet.</p>}
560
+ </article>
561
+ )
562
+ }
563
+ `
564
+ }
565
+
566
+ /** @param {Answers} a — headless/decoupled root page (no public rendering). */
567
+ function headlessHomePage(a) {
568
+ const note =
569
+ a.mode === 'decoupled'
570
+ ? 'This is a DECOUPLED deployment — build your frontend separately with @kywi-software/sdk and point it at /api/v1. See README.md.'
571
+ : 'This is a HEADLESS deployment — there is no public frontend. Use /admin and /api/v1.'
572
+ return `import { notFound } from 'next/navigation'
573
+
574
+ // ${a.mode === 'decoupled' ? 'Decoupled' : 'Headless'} mode: no public rendering.
575
+ // ${note}
576
+ export default function HomePage() {
577
+ notFound()
578
+ }
579
+ `
580
+ }
581
+
582
+ // ── API route (thin proxy over @kywi-software/core/host) ──────────────────────
583
+
584
+ function apiRoute() {
585
+ return `import { NextRequest, NextResponse } from 'next/server'
586
+ import { getKywiHandler } from '../../../../lib/kywi'
587
+ import {
588
+ TOKEN_ISSUING_PATHS,
589
+ parseSessionTokens,
590
+ setAccessCookie,
591
+ setRefreshCookie,
592
+ clearSessionCookies,
593
+ } from '@kywi-software/core/host'
594
+
595
+ /**
596
+ * The versioned Kywi API. Delegates every verb to core's DB-backed handler, with
597
+ * a thin session-cookie bridge on top: this route ESTABLISHES the session by
598
+ * lifting the tokens a login/refresh RESPONSE returns into httpOnly cookies (so
599
+ * an XSS payload can never read them), and clears them on logout. The read/
600
+ * refresh side lives in middleware.ts. The cookie contract is shared via
601
+ * @kywi-software/core/host — core itself is untouched.
602
+ */
603
+ async function handleRequest(
604
+ req: NextRequest,
605
+ ctx: { params: Promise<{ kywi: string[] }> },
606
+ ): Promise<Response> {
607
+ const segments = (await ctx.params).kywi
608
+ const path = segments.join('/')
609
+ const handler = await getKywiHandler()
610
+ const res = await handler.handle(req, segments)
611
+
612
+ if (path === 'auth/logout') {
613
+ const bodyText = await res.text()
614
+ const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
615
+ clearSessionCookies(out)
616
+ return out
617
+ }
618
+
619
+ if (TOKEN_ISSUING_PATHS.has(path) && res.ok) {
620
+ const bodyText = await res.text()
621
+ const { accessToken, refreshToken } = parseSessionTokens(bodyText)
622
+ const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
623
+ if (accessToken) setAccessCookie(out, accessToken)
624
+ if (refreshToken) setRefreshCookie(out, refreshToken)
625
+ return out
626
+ }
627
+
628
+ return res
629
+ }
630
+
631
+ export const GET = handleRequest
632
+ export const POST = handleRequest
633
+ export const PUT = handleRequest
634
+ export const PATCH = handleRequest
635
+ export const DELETE = handleRequest
636
+
637
+ export const dynamic = 'force-dynamic'
638
+ `
639
+ }
640
+
641
+ // ── admin ─────────────────────────────────────────────────────────────────────
642
+
643
+ function adminLayout() {
644
+ return `import React from 'react'
645
+
646
+ // Pass-through: the root layout provides <html>/<body>; admin pages bring their
647
+ // own chrome (AdminShell for authed pages, nothing extra for the login page).
648
+ export default function AdminLayout({ children }: { children: React.ReactNode }) {
649
+ return <>{children}</>
650
+ }
651
+ `
652
+ }
653
+
654
+ function adminIndexPage() {
655
+ return `import { redirect } from 'next/navigation'
656
+
657
+ export default function AdminIndexPage() {
658
+ redirect('/admin/content')
659
+ }
660
+ `
661
+ }
662
+
663
+ function adminShellComponent() {
664
+ return `'use client'
665
+
666
+ import React, { useEffect, useState } from 'react'
667
+ import { usePathname } from 'next/navigation'
668
+ import '@kywi-software/core/admin/styles.css'
669
+ import { getAdminUser, signOut, type AdminUser } from '../lib/admin-auth'
670
+
671
+ /**
672
+ * Minimal admin chrome: a sidebar + top bar around the authed admin pages. The
673
+ * server middleware is the real access gate (a valid session cookie was already
674
+ * verified to reach here); this only loads the user descriptor for display and
675
+ * bounces to /admin/login if it's gone (e.g. signed out in another tab).
676
+ *
677
+ * Extend this as you grow the admin — it's your app's own layer over core's
678
+ * admin components (ContentEditForm etc.).
679
+ */
680
+ const NAV = [
681
+ { href: '/admin/content', label: 'Content' },
682
+ ]
683
+
684
+ export function AdminShell({ children }: { children: React.ReactNode }) {
685
+ const pathname = usePathname()
686
+ const [user, setUser] = useState<AdminUser | null>(null)
687
+ const [checked, setChecked] = useState(false)
688
+
689
+ useEffect(() => {
690
+ const u = getAdminUser()
691
+ if (!u) {
692
+ window.location.href = '/admin/login'
693
+ return
694
+ }
695
+ setUser(u)
696
+ setChecked(true)
697
+ }, [])
698
+
699
+ async function handleSignOut() {
700
+ await signOut()
701
+ window.location.href = '/admin/login'
702
+ }
703
+
704
+ if (!checked) {
705
+ return (
706
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
707
+ Loading…
708
+ </div>
709
+ )
710
+ }
711
+
712
+ return (
713
+ <div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
714
+ <aside style={{ width: 220, background: '#0f172a', color: '#e2e8f0', padding: '1.25rem 0', flexShrink: 0 }}>
715
+ <div style={{ padding: '0 1.25rem 1rem', fontWeight: 700, fontSize: '1.0625rem' }}>Kywi Admin</div>
716
+ <nav>
717
+ {NAV.map((item) => {
718
+ const active = pathname === item.href || pathname.startsWith(item.href + '/')
719
+ return (
720
+ <a
721
+ key={item.href}
722
+ href={item.href}
723
+ style={{
724
+ display: 'block',
725
+ padding: '0.5rem 1.25rem',
726
+ color: active ? '#fff' : '#cbd5e1',
727
+ background: active ? '#1e293b' : 'transparent',
728
+ textDecoration: 'none',
729
+ fontSize: '0.9375rem',
730
+ }}
731
+ >
732
+ {item.label}
733
+ </a>
734
+ )
735
+ })}
736
+ </nav>
737
+ </aside>
738
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
739
+ <header
740
+ style={{
741
+ display: 'flex',
742
+ justifyContent: 'flex-end',
743
+ alignItems: 'center',
744
+ gap: '1rem',
745
+ padding: '0.75rem 1.5rem',
746
+ borderBottom: '1px solid #e2e8f0',
747
+ background: '#fff',
748
+ }}
749
+ >
750
+ <a href="/" style={{ color: '#64748b', fontSize: '0.875rem', textDecoration: 'none' }}>View site ↗</a>
751
+ <span style={{ color: '#64748b', fontSize: '0.875rem' }}>{user?.role}</span>
752
+ <button type="button" onClick={handleSignOut} className="kywi-btn kywi-btn-secondary kywi-btn-sm">
753
+ Sign out
754
+ </button>
755
+ </header>
756
+ <main style={{ padding: '1.5rem', flex: 1 }}>{children}</main>
757
+ </div>
758
+ </div>
759
+ )
760
+ }
761
+ `
762
+ }
763
+
764
+ function adminLoginPage() {
765
+ return `'use client'
766
+
767
+ import React, { useState } from 'react'
768
+ import { setAuth } from '../../../lib/admin-auth'
769
+ import '@kywi-software/core/admin/styles.css'
770
+
771
+ /** Honor a safe internal ?next= (set by middleware.ts), else the content list. */
772
+ function safeNext(): string {
773
+ const fallback = '/admin/content'
774
+ if (typeof window === 'undefined') return fallback
775
+ const next = new URLSearchParams(window.location.search).get('next')
776
+ if (next && next.startsWith('/admin/') && !next.startsWith('//') && next !== '/admin/login') {
777
+ return next
778
+ }
779
+ return fallback
780
+ }
781
+
782
+ export default function AdminLoginPage() {
783
+ const [email, setEmail] = useState('')
784
+ const [password, setPassword] = useState('')
785
+ const [error, setError] = useState('')
786
+ const [loading, setLoading] = useState(false)
787
+
788
+ async function handleSubmit(e: React.FormEvent) {
789
+ e.preventDefault()
790
+ setError('')
791
+ setLoading(true)
792
+ try {
793
+ const res = await fetch('/api/v1/auth/login', {
794
+ method: 'POST',
795
+ headers: { 'Content-Type': 'application/json' },
796
+ body: JSON.stringify({ email, password }),
797
+ })
798
+ const body = await res.json()
799
+ if (!res.ok) {
800
+ setError(body.error?.message ?? 'Login failed')
801
+ return
802
+ }
803
+ if (!body.data?.user || !body.data?.accessToken) {
804
+ setError('This account requires an unsupported login step.')
805
+ return
806
+ }
807
+ // The token is set by the server as an httpOnly cookie; we keep only the
808
+ // (non-sensitive) user descriptor for rendering the chrome.
809
+ setAuth(body.data.user)
810
+ window.location.href = safeNext()
811
+ } catch {
812
+ setError('Network error — is the server running?')
813
+ } finally {
814
+ setLoading(false)
815
+ }
816
+ }
817
+
818
+ return (
819
+ <div className="kywi-admin-shell kywi-admin-login">
820
+ <form onSubmit={handleSubmit} className="kywi-admin-login-card">
821
+ <div className="kywi-admin-login-brand">
822
+ <span aria-hidden="true" className="kywi-admin-login-dot" />
823
+ <h1>Kywi Admin</h1>
824
+ </div>
825
+ <p className="kywi-admin-login-subtitle">Sign in to your workspace</p>
826
+ {error && (
827
+ <div className="kywi-admin-callout kywi-admin-callout-danger" role="alert">
828
+ {error}
829
+ </div>
830
+ )}
831
+ <label className="kywi-admin-login-field">
832
+ <span className="kywi-admin-login-label">Email</span>
833
+ <input type="email" className="kywi-input" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
834
+ </label>
835
+ <label className="kywi-admin-login-field">
836
+ <span className="kywi-admin-login-label">Password</span>
837
+ <input type="password" className="kywi-input" value={password} onChange={(e) => setPassword(e.target.value)} required />
838
+ </label>
839
+ <button type="submit" disabled={loading} className="kywi-btn kywi-btn-primary kywi-admin-login-submit">
840
+ {loading ? 'Signing in…' : 'Sign in'}
841
+ </button>
842
+ </form>
843
+ </div>
844
+ )
845
+ }
846
+ `
847
+ }
848
+
849
+ /** Shared client helper (bundled into pages that import it): resolve a content
850
+ * type by name from config + the built-ins. Emitted as a lib file. */
851
+ function libContentTypes() {
852
+ return `import type { ContentTypeConfig } from '@kywi-software/core/config'
853
+ import config from './config'
854
+
855
+ /** Built-in types always available in the editor even if not in kywi.config.ts. */
856
+ export const BUILT_IN_CONTENT_TYPES: ContentTypeConfig[] = [
857
+ { name: 'page', label: 'Page', baseType: 'content', fields: [] },
858
+ { name: 'folder', label: 'Folder', baseType: 'folder', fields: [] },
859
+ { name: 'link', label: 'Link', baseType: 'link', fields: [] },
860
+ ]
861
+
862
+ /** All content types the admin can author: config-defined win over built-ins by name. */
863
+ export function allContentTypes(): ContentTypeConfig[] {
864
+ const byName = new Map<string, ContentTypeConfig>()
865
+ for (const ct of BUILT_IN_CONTENT_TYPES) byName.set(ct.name, ct)
866
+ for (const ct of config.contentTypes) byName.set(ct.name, ct)
867
+ return [...byName.values()]
868
+ }
869
+
870
+ export function resolveContentType(name: string): ContentTypeConfig | null {
871
+ return allContentTypes().find((ct) => ct.name === name) ?? null
872
+ }
873
+ `
874
+ }
875
+
876
+ function adminContentLandingPage() {
877
+ return `'use client'
878
+
879
+ import React from 'react'
880
+ import { useRouter } from 'next/navigation'
881
+ import { AdminShell } from '../../../components/admin-shell'
882
+ import { allContentTypes } from '../../../lib/content-types'
883
+
884
+ // Pick a content type to browse/author. Types come from kywi.config.ts plus the
885
+ // always-available built-ins (page/folder/link).
886
+ export default function ContentLandingPage() {
887
+ const router = useRouter()
888
+ const types = allContentTypes()
889
+
890
+ return (
891
+ <AdminShell>
892
+ <h1 style={{ margin: '0 0 1.5rem', fontSize: '1.25rem', fontWeight: 600 }}>Content</h1>
893
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}>
894
+ {types.map((ct) => (
895
+ <button
896
+ key={ct.name}
897
+ onClick={() => router.push('/admin/content/' + ct.name)}
898
+ style={{
899
+ display: 'flex',
900
+ flexDirection: 'column',
901
+ alignItems: 'flex-start',
902
+ padding: '1.25rem',
903
+ background: '#fff',
904
+ border: '1px solid #e2e8f0',
905
+ borderRadius: 8,
906
+ cursor: 'pointer',
907
+ textAlign: 'left',
908
+ }}
909
+ >
910
+ <span style={{ fontSize: '1rem', fontWeight: 600, color: '#1e293b' }}>{ct.label}</span>
911
+ <span style={{ fontSize: '0.8125rem', color: '#64748b', marginTop: '0.25rem' }}>
912
+ {ct.fields.length} custom field{ct.fields.length !== 1 ? 's' : ''}
913
+ </span>
914
+ </button>
915
+ ))}
916
+ </div>
917
+ </AdminShell>
918
+ )
919
+ }
920
+ `
921
+ }
922
+
923
+ function adminContentListPage() {
924
+ return `'use client'
925
+
926
+ import React, { useEffect, useState } from 'react'
927
+ import { useParams, useRouter } from 'next/navigation'
928
+ import { AdminShell } from '../../../../components/admin-shell'
929
+ import { adminFetch } from '../../../../lib/admin-auth'
930
+ import { resolveContentType } from '../../../../lib/content-types'
931
+
932
+ interface Row {
933
+ id: string
934
+ title?: string
935
+ slug?: string
936
+ path?: string
937
+ status?: string
938
+ }
939
+
940
+ export default function ContentListPage() {
941
+ const params = useParams<{ type: string }>()
942
+ const router = useRouter()
943
+ const typeName = params.type
944
+ const contentType = resolveContentType(typeName)
945
+ const [rows, setRows] = useState<Row[] | null>(null)
946
+
947
+ useEffect(() => {
948
+ let cancelled = false
949
+ adminFetch('/api/v1/content/' + typeName)
950
+ .then((res) => (res.ok ? res.json() : { data: [] }))
951
+ .then((body) => {
952
+ if (!cancelled) setRows((body?.data ?? []) as Row[])
953
+ })
954
+ .catch(() => {
955
+ if (!cancelled) setRows([])
956
+ })
957
+ return () => {
958
+ cancelled = true
959
+ }
960
+ }, [typeName])
961
+
962
+ return (
963
+ <AdminShell>
964
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
965
+ <h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{contentType?.label ?? typeName}</h1>
966
+ <button className="kywi-btn kywi-btn-primary" onClick={() => router.push('/admin/content/' + typeName + '/new')}>
967
+ + New {contentType?.label ?? typeName}
968
+ </button>
969
+ </div>
970
+ {rows === null ? (
971
+ <p style={{ color: '#64748b' }}>Loading…</p>
972
+ ) : rows.length === 0 ? (
973
+ <p style={{ color: '#64748b' }}>Nothing here yet. Create your first {contentType?.label ?? typeName}.</p>
974
+ ) : (
975
+ <div className="kywi-table-scroll" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8 }}>
976
+ <table className="kywi-table" style={{ width: '100%' }}>
977
+ <thead>
978
+ <tr>
979
+ <th>Title</th>
980
+ <th>Slug</th>
981
+ <th>Status</th>
982
+ <th />
983
+ </tr>
984
+ </thead>
985
+ <tbody>
986
+ {rows.map((r) => (
987
+ <tr key={r.id}>
988
+ <td>
989
+ <a href={'/admin/content/' + typeName + '/' + r.id}>{r.title || '(untitled)'}</a>
990
+ </td>
991
+ <td>{r.slug}</td>
992
+ <td>
993
+ {r.status && <span className={'kywi-status-badge kywi-status-' + r.status}>{r.status}</span>}
994
+ </td>
995
+ <td style={{ textAlign: 'right' }}>
996
+ {r.status === 'published' && r.path && (
997
+ <a href={r.path} target="_blank" rel="noreferrer" style={{ fontSize: '0.875rem' }}>
998
+ View ↗
999
+ </a>
1000
+ )}
1001
+ </td>
1002
+ </tr>
1003
+ ))}
1004
+ </tbody>
1005
+ </table>
1006
+ </div>
1007
+ )}
1008
+ </AdminShell>
1009
+ )
1010
+ }
1011
+ `
1012
+ }
1013
+
1014
+ function adminContentNewPage() {
1015
+ return `'use client'
1016
+
1017
+ import React, { useState } from 'react'
1018
+ import { useParams, useRouter } from 'next/navigation'
1019
+ import { AdminShell } from '../../../../../components/admin-shell'
1020
+ import { ContentEditForm, buildWritablePayload } from '@kywi-software/core/admin'
1021
+ import { adminFetch } from '../../../../../lib/admin-auth'
1022
+ import { resolveContentType } from '../../../../../lib/content-types'
1023
+
1024
+ export default function ContentNewPage() {
1025
+ const params = useParams<{ type: string }>()
1026
+ const router = useRouter()
1027
+ const typeName = params.type
1028
+ const contentType = resolveContentType(typeName)
1029
+ const [isSubmitting, setIsSubmitting] = useState(false)
1030
+ const [error, setError] = useState<string | null>(null)
1031
+
1032
+ if (!contentType) {
1033
+ return (
1034
+ <AdminShell>
1035
+ <h1>Content type "{typeName}" not found</h1>
1036
+ <p>It is not defined in kywi.config.ts.</p>
1037
+ </AdminShell>
1038
+ )
1039
+ }
1040
+
1041
+ const fieldNames = contentType.fields.map((f) => f.name)
1042
+
1043
+ async function handleSubmit(data: Record<string, unknown>) {
1044
+ setIsSubmitting(true)
1045
+ setError(null)
1046
+ try {
1047
+ const payload = buildWritablePayload(data, fieldNames)
1048
+ const res = await adminFetch('/api/v1/content/' + typeName, {
1049
+ method: 'POST',
1050
+ body: JSON.stringify(payload),
1051
+ })
1052
+ if (!res.ok) {
1053
+ const body = await res.json().catch(() => ({}))
1054
+ throw new Error(body.error?.message ?? 'HTTP ' + res.status)
1055
+ }
1056
+ const created = await res.json()
1057
+ const id = created.data?.id ?? created.id
1058
+ router.push(id ? '/admin/content/' + typeName + '/' + id : '/admin/content/' + typeName)
1059
+ } catch (err) {
1060
+ setError(err instanceof Error ? err.message : 'Failed to create content')
1061
+ setIsSubmitting(false)
1062
+ }
1063
+ }
1064
+
1065
+ return (
1066
+ <AdminShell>
1067
+ <div style={{ marginBottom: '1rem' }}>
1068
+ <button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
1069
+ ← {contentType.label}
1070
+ </button>
1071
+ </div>
1072
+ {error && (
1073
+ <div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
1074
+ {error}
1075
+ </div>
1076
+ )}
1077
+ <ContentEditForm
1078
+ contentType={contentType}
1079
+ mode="create"
1080
+ isSubmitting={isSubmitting}
1081
+ onSubmit={handleSubmit}
1082
+ onCancel={() => router.push('/admin/content/' + typeName)}
1083
+ />
1084
+ </AdminShell>
1085
+ )
1086
+ }
1087
+ `
1088
+ }
1089
+
1090
+ function adminContentEditPage() {
1091
+ return `'use client'
1092
+
1093
+ import React, { useEffect, useState } from 'react'
1094
+ import { useParams, useRouter } from 'next/navigation'
1095
+ import { AdminShell } from '../../../../../components/admin-shell'
1096
+ import { ContentEditForm, buildWritablePayload, mapContentToInitialValues } from '@kywi-software/core/admin'
1097
+ import { adminFetch } from '../../../../../lib/admin-auth'
1098
+ import { resolveContentType } from '../../../../../lib/content-types'
1099
+
1100
+ export default function ContentEditPage() {
1101
+ const params = useParams<{ type: string; id: string }>()
1102
+ const router = useRouter()
1103
+ const typeName = params.type
1104
+ const id = params.id
1105
+ const contentType = resolveContentType(typeName)
1106
+ const [node, setNode] = useState<Record<string, unknown> | null>(null)
1107
+ const [loaded, setLoaded] = useState(false)
1108
+ const [isSubmitting, setIsSubmitting] = useState(false)
1109
+ const [error, setError] = useState<string | null>(null)
1110
+ const [notice, setNotice] = useState<string | null>(null)
1111
+
1112
+ useEffect(() => {
1113
+ let cancelled = false
1114
+ adminFetch('/api/v1/content/' + typeName + '/' + id)
1115
+ .then((res) => (res.ok ? res.json() : null))
1116
+ .then((body) => {
1117
+ if (cancelled) return
1118
+ setNode((body?.data ?? null) as Record<string, unknown> | null)
1119
+ setLoaded(true)
1120
+ })
1121
+ .catch(() => {
1122
+ if (!cancelled) setLoaded(true)
1123
+ })
1124
+ return () => {
1125
+ cancelled = true
1126
+ }
1127
+ }, [typeName, id])
1128
+
1129
+ if (!contentType) {
1130
+ return (
1131
+ <AdminShell>
1132
+ <h1>Content type "{typeName}" not found</h1>
1133
+ </AdminShell>
1134
+ )
1135
+ }
1136
+
1137
+ if (!loaded) {
1138
+ return (
1139
+ <AdminShell>
1140
+ <p style={{ color: '#64748b' }}>Loading…</p>
1141
+ </AdminShell>
1142
+ )
1143
+ }
1144
+
1145
+ if (!node) {
1146
+ return (
1147
+ <AdminShell>
1148
+ <h1>Not found</h1>
1149
+ <p>This content no longer exists.</p>
1150
+ </AdminShell>
1151
+ )
1152
+ }
1153
+
1154
+ const fieldNames = contentType.fields.map((f) => f.name)
1155
+ const status = String(node['status'] ?? 'draft')
1156
+ const path = node['path'] as string | undefined
1157
+
1158
+ async function patch(payload: Record<string, unknown>): Promise<Record<string, unknown> | null> {
1159
+ const res = await adminFetch('/api/v1/content/' + typeName + '/' + id, {
1160
+ method: 'PATCH',
1161
+ body: JSON.stringify(payload),
1162
+ })
1163
+ if (!res.ok) {
1164
+ const body = await res.json().catch(() => ({}))
1165
+ throw new Error(body.error?.message ?? 'HTTP ' + res.status)
1166
+ }
1167
+ const body = await res.json().catch(() => ({}))
1168
+ return (body?.data ?? null) as Record<string, unknown> | null
1169
+ }
1170
+
1171
+ async function handleSubmit(data: Record<string, unknown>) {
1172
+ setIsSubmitting(true)
1173
+ setError(null)
1174
+ setNotice(null)
1175
+ try {
1176
+ const updated = await patch(buildWritablePayload(data, fieldNames))
1177
+ if (updated) setNode(updated)
1178
+ setNotice('Saved.')
1179
+ } catch (err) {
1180
+ setError(err instanceof Error ? err.message : 'Failed to save')
1181
+ } finally {
1182
+ setIsSubmitting(false)
1183
+ }
1184
+ }
1185
+
1186
+ // Publish/unpublish directly, so it works regardless of how you fill the form.
1187
+ async function togglePublish() {
1188
+ setError(null)
1189
+ setNotice(null)
1190
+ try {
1191
+ const next = status === 'published' ? 'draft' : 'published'
1192
+ const updated = await patch({ status: next })
1193
+ if (updated) setNode(updated)
1194
+ setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
1195
+ } catch (err) {
1196
+ setError(err instanceof Error ? err.message : 'Failed to change status')
1197
+ }
1198
+ }
1199
+
1200
+ return (
1201
+ <AdminShell>
1202
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '1rem' }}>
1203
+ <button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
1204
+ ← {contentType.label}
1205
+ </button>
1206
+ <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
1207
+ <span className={'kywi-status-badge kywi-status-' + status}>{status}</span>
1208
+ {status === 'published' && path && (
1209
+ <a href={path} target="_blank" rel="noreferrer" className="kywi-btn kywi-btn-secondary kywi-btn-sm">
1210
+ View ↗
1211
+ </a>
1212
+ )}
1213
+ <button className="kywi-btn kywi-btn-primary kywi-btn-sm" onClick={togglePublish}>
1214
+ {status === 'published' ? 'Unpublish' : 'Publish'}
1215
+ </button>
1216
+ </div>
1217
+ </div>
1218
+ {error && (
1219
+ <div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
1220
+ {error}
1221
+ </div>
1222
+ )}
1223
+ {notice && (
1224
+ <div className="kywi-admin-callout" style={{ marginBottom: '1rem' }}>
1225
+ {notice}
1226
+ </div>
1227
+ )}
1228
+ <ContentEditForm
1229
+ contentType={contentType}
1230
+ mode="edit"
1231
+ initialValues={mapContentToInitialValues(node)}
1232
+ isSubmitting={isSubmitting}
1233
+ onSubmit={handleSubmit}
1234
+ onCancel={() => router.push('/admin/content/' + typeName)}
1235
+ />
1236
+ </AdminShell>
1237
+ )
1238
+ }
1239
+ `
1240
+ }
1241
+
1242
+ // ── README ────────────────────────────────────────────────────────────────────
1243
+
1244
+ /** @param {Answers} a */
1245
+ function readme(a) {
1246
+ const modeLine =
1247
+ a.mode === 'coupled'
1248
+ ? 'This app renders the public site and serves the API + admin.'
1249
+ : a.mode === 'headless'
1250
+ ? 'This is a headless deployment: API + admin only (GET / returns 404).'
1251
+ : 'This is a decoupled deployment: the API + admin serve here; build your frontend separately with @kywi-software/sdk against /api/v1.'
1252
+ const publicLine =
1253
+ a.mode === 'coupled'
1254
+ ? '- `http://localhost:3000/` — your public site (published pages render at their slug)'
1255
+ : '- `http://localhost:3000/api/v1/content/page` — the content API'
1256
+ const decoupledBlock =
1257
+ a.mode === 'decoupled'
1258
+ ? `\n### Separate frontend\n\nInstall \`@kywi-software/sdk\` in your frontend project and point it at this API:\n\n\`\`\`ts\nimport { createKywiSdk } from '@kywi-software/sdk'\nconst kywi = createKywiSdk({ baseUrl: 'http://localhost:3000/api/v1' })\n\`\`\`\n`
1259
+ : ''
1260
+ return `# ${a.projectName}
1261
+
1262
+ A [Kywi CMS](https://kywi.dev) project (\`${a.mode}\` mode). ${modeLine}
1263
+
1264
+ ## Getting started
1265
+
1266
+ \`\`\`bash
1267
+ # 1. Install dependencies
1268
+ pnpm install # (or npm install / yarn)
1269
+
1270
+ # 2. Create the database and configure the environment
1271
+ createdb ${slugify(a.projectName)} # PostgreSQL 14+ works
1272
+ cp .env.example .env
1273
+ # → set DATABASE_URL to your connection string
1274
+ # → set AUTH_SECRET to a 32+ char random value (openssl rand -base64 32)
1275
+
1276
+ # 3. Run migrations and seed the default site + admin user
1277
+ pnpm migrate # applies the schema (see the note on NOTICEs below)
1278
+ pnpm seed # prints the generated superadmin credentials
1279
+
1280
+ # 4. Start the dev server
1281
+ pnpm dev # http://localhost:3000
1282
+ \`\`\`
1283
+
1284
+ Then open:
1285
+ - \`http://localhost:3000/admin\` — the admin UI. Sign in with the credentials
1286
+ \`pnpm seed\` printed (default \`admin@kywi.dev\` / \`admin123\` unless you set
1287
+ \`KYWI_ADMIN_EMAIL\` / \`KYWI_ADMIN_PASSWORD\`).
1288
+ ${publicLine}
1289
+ ${decoupledBlock}
1290
+ ### Create your first page
1291
+
1292
+ In the admin: **Content → Page → + New Page**, fill in a title and slug, save,
1293
+ then **Publish**.${a.mode === 'coupled' ? ' Open its slug (e.g. `/about`) to see it live.' : ' Fetch it from `/api/v1/content/page`.'}
1294
+
1295
+ ## Notes
1296
+
1297
+ - **Migration NOTICEs are expected.** \`pnpm migrate\` prints PostgreSQL
1298
+ \`NOTICE: identifier "..." will be truncated\` lines for a few long foreign-key
1299
+ names. They are informational — the migration still applies cleanly.
1300
+ - **Config lives in \`kywi.config.ts\`** — sites, themes, content types, auth
1301
+ providers and the deployment mode. Edit it (add content types, etc.) and
1302
+ re-run \`pnpm migrate\`, then restart the dev server.
1303
+
1304
+ ## Project layout
1305
+
1306
+ \`\`\`
1307
+ kywi.config.ts your config: sites, themes, content types, auth, mode
1308
+ middleware.ts auth gate + session refresh + cookie→bearer bridge
1309
+ next.config.mjs required Next config to consume @kywi-software/core
1310
+ lib/kywi.ts server runtime (DB, API handler, content scope)
1311
+ lib/admin-auth.ts client admin helpers (re-exported from core)
1312
+ app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
1313
+ app/admin/… the admin UI (login + content authoring)
1314
+ components/admin-shell.tsx your admin chrome${a.mode === 'coupled' ? '\napp/(site)/… your public site (renders published pages)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
1315
+ \`\`\`
1316
+ `
1317
+ }
1318
+
1319
+ // ── helpers ───────────────────────────────────────────────────────────────────
1320
+
1321
+ function slugify(name) {
1322
+ return String(name)
1323
+ .toLowerCase()
1324
+ .replace(/[^a-z0-9]+/g, '_')
1325
+ .replace(/^_+|_+$/g, '') || 'kywi_app'
1326
+ }
1327
+
1328
+ /** Escape a value interpolated as JSX text so it can't break out of the element. */
1329
+ function escapeJsxText(value) {
1330
+ return String(value).replace(/[{}<>]/g, (ch) => `{'${ch}'}`)
1331
+ }
1332
+
1333
+ /**
1334
+ * Build the complete map of relative-path → file-content for a project.
1335
+ * @param {Answers} answers
1336
+ * @returns {Record<string, string>}
1337
+ */
1338
+ export function buildFileSet(answers) {
1339
+ /** @type {Record<string, string>} */
1340
+ const files = {
1341
+ // config + build
1342
+ 'package.json': packageJson(answers),
1343
+ 'kywi.config.ts': kywiConfig(answers),
1344
+ 'tsconfig.json': tsconfig(),
1345
+ 'next.config.mjs': nextConfig(),
1346
+ '.env.example': envExample(),
1347
+ '.gitignore': gitignore(),
1348
+ 'README.md': readme(answers),
1349
+ 'lib/dompurify-stub.js': dompurifyStub(),
1350
+ // server runtime + client helpers
1351
+ 'lib/kywi.ts': libKywi(),
1352
+ 'lib/config.ts': libConfig(),
1353
+ 'lib/admin-auth.ts': libAdminAuth(),
1354
+ 'lib/content-types.ts': libContentTypes(),
1355
+ // host wiring (thin, over @kywi-software/core/host)
1356
+ 'middleware.ts': middleware(),
1357
+ 'app/api/v1/[...kywi]/route.ts': apiRoute(),
1358
+ // root
1359
+ 'app/layout.tsx': rootLayout(),
1360
+ // admin (all modes)
1361
+ 'components/admin-shell.tsx': adminShellComponent(),
1362
+ 'app/admin/layout.tsx': adminLayout(),
1363
+ 'app/admin/page.tsx': adminIndexPage(),
1364
+ 'app/admin/login/page.tsx': adminLoginPage(),
1365
+ 'app/admin/content/page.tsx': adminContentLandingPage(),
1366
+ 'app/admin/content/[type]/page.tsx': adminContentListPage(),
1367
+ 'app/admin/content/[type]/new/page.tsx': adminContentNewPage(),
1368
+ 'app/admin/content/[type]/[id]/page.tsx': adminContentEditPage(),
1369
+ }
1370
+
1371
+ if (answers.mode === 'coupled') {
1372
+ // Public site: an optional catch-all renders "/" (home) and every published
1373
+ // page at its slug. More-specific /admin and /api routes take precedence.
1374
+ files['app/(site)/layout.tsx'] = siteLayout(answers)
1375
+ files['app/(site)/[[...slug]]/page.tsx'] = siteSlugPage()
1376
+ } else {
1377
+ // headless + decoupled: no public rendering.
1378
+ files['app/page.tsx'] = headlessHomePage(answers)
1379
+ }
1380
+
1381
+ return files
1382
+ }
1383
+
1384
+ export { slugify }