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.
- package/LICENSE +661 -0
- package/README.md +146 -0
- package/bin/create-kywi-app.mjs +200 -0
- package/lib/templates.mjs +1384 -0
- package/package.json +37 -0
|
@@ -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 }
|