create-krantho 0.0.0-stage → 1.0.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,539 @@
1
+ import { KRANTHO_RANGE } from '../version.js';
2
+ export function generateNextjsFiles(options) {
3
+ const files = new Map();
4
+ files.set('package.json', packageJson(options));
5
+ files.set('tsconfig.json', tsconfig());
6
+ files.set('next.config.ts', nextConfig());
7
+ files.set('krantho.config.ts', kranthoConfig(options));
8
+ files.set('krantho.collections.ts', kranthoCollections(options));
9
+ files.set('lib/krantho.ts', kranthoInit());
10
+ files.set('app/api/[...path]/route.ts', apiRoute());
11
+ files.set('app/studio/[[...slug]]/route.ts', studioRoute());
12
+ files.set('app/layout.tsx', rootLayout(options));
13
+ files.set('app/(site)/layout.tsx', siteLayout(options));
14
+ files.set('app/(site)/[[...slug]]/page.tsx', page(options));
15
+ files.set('app/globals.css', globalsCss(options));
16
+ if (options.ui === 'tailwind') {
17
+ files.set('postcss.config.mjs', postcssConfig());
18
+ }
19
+ return files;
20
+ }
21
+ function packageJson(options) {
22
+ // `@krantho/next` is the one-install umbrella — it bundles server-next,
23
+ // ui-react, studio, media-local, and seo so a single dependency provisions the
24
+ // full stack. The packages the generated source imports *by name*
25
+ // (config, server-next, ui-react, schema) are also listed
26
+ // explicitly so the project resolves under a strict, non-hoisted
27
+ // node_modules layout where transitive deps are not directly importable.
28
+ const deps = {
29
+ '@krantho/config': KRANTHO_RANGE,
30
+ '@krantho/next': KRANTHO_RANGE,
31
+ '@krantho/server-next': KRANTHO_RANGE,
32
+ '@krantho/ui-react': KRANTHO_RANGE,
33
+ '@krantho/schema': KRANTHO_RANGE,
34
+ next: '^16.2.1',
35
+ react: '^19.2.4',
36
+ 'react-dom': '^19.2.4',
37
+ zod: '^4.4.3',
38
+ };
39
+ if (options.ui === 'tailwind') {
40
+ deps['tailwindcss'] = '^4.2.2';
41
+ deps['@tailwindcss/postcss'] = '^4.2.2';
42
+ }
43
+ if (options.ui === 'mui') {
44
+ deps['@mui/material'] = '^7.3.0';
45
+ deps['@emotion/react'] = '^11.14.0';
46
+ deps['@emotion/styled'] = '^11.14.0';
47
+ }
48
+ const devDeps = {
49
+ '@types/node': '^25.5.0',
50
+ '@types/react': '^19.2.14',
51
+ typescript: '^6.0.2',
52
+ };
53
+ const pkg = {
54
+ name: options.name,
55
+ version: '0.1.0',
56
+ private: true,
57
+ type: 'module',
58
+ scripts: {
59
+ dev: `next dev --port ${options.port}`,
60
+ build: 'next build',
61
+ start: 'next start',
62
+ },
63
+ dependencies: sortObject(deps),
64
+ devDependencies: sortObject(devDeps),
65
+ };
66
+ return JSON.stringify(pkg, null, 2) + '\n';
67
+ }
68
+ function tsconfig() {
69
+ return (JSON.stringify({
70
+ compilerOptions: {
71
+ target: 'ES2022',
72
+ lib: ['dom', 'dom.iterable', 'esnext'],
73
+ allowJs: true,
74
+ skipLibCheck: true,
75
+ strict: true,
76
+ noEmit: true,
77
+ esModuleInterop: true,
78
+ module: 'esnext',
79
+ moduleResolution: 'bundler',
80
+ resolveJsonModule: true,
81
+ isolatedModules: true,
82
+ jsx: 'preserve',
83
+ incremental: true,
84
+ plugins: [{ name: 'next' }],
85
+ paths: { '@/*': ['./*'] },
86
+ },
87
+ include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
88
+ exclude: ['node_modules'],
89
+ }, null, 2) + '\n');
90
+ }
91
+ function nextConfig() {
92
+ return `import type { NextConfig } from 'next'
93
+
94
+ const nextConfig: NextConfig = {
95
+ serverExternalPackages: [
96
+ 'better-sqlite3',
97
+ '@krantho/server',
98
+ '@krantho/server-next',
99
+ ],
100
+ // Search engines look for /robots.txt and /sitemap.xml at the site root; the
101
+ // krantho API builds both from the published content and site.baseUrl.
102
+ async rewrites() {
103
+ return [
104
+ { source: '/robots.txt', destination: '/api/seo/robots.txt' },
105
+ { source: '/sitemap.xml', destination: '/api/seo/sitemap' },
106
+ ]
107
+ },
108
+ }
109
+
110
+ export default nextConfig
111
+ `;
112
+ }
113
+ function kranthoInit() {
114
+ return `import { notFound } from 'next/navigation'
115
+ import { createContentLoader } from '@krantho/server-next'
116
+ import { getCMSContent, initServerLoader, NetworkError } from '@krantho/ui-react/server'
117
+ import type { CMSContent } from '@krantho/ui-react/server'
118
+ import config from '../krantho.config'
119
+ import { collections } from '../krantho.collections'
120
+
121
+ // Server-rendered pages read the database the API route opens: both resolve this
122
+ // app's configuration and open persistence through the same factory.
123
+ initServerLoader(createContentLoader({ config, collections }))
124
+
125
+ /** A page's published content; a document with no published version answers 404 */
126
+ export async function requirePublished(collection: string, slug: string): Promise<CMSContent> {
127
+ try {
128
+ return await getCMSContent(collection, slug)
129
+ } catch (error) {
130
+ if (error instanceof NetworkError && error.status === 404) notFound()
131
+ throw error
132
+ }
133
+ }
134
+
135
+ /** A global's published fields; none when it is not published */
136
+ export async function globalFields(slug: string): Promise<Record<string, unknown>> {
137
+ try {
138
+ return (await getCMSContent('globals', slug)).fields
139
+ } catch {
140
+ return {}
141
+ }
142
+ }
143
+ `;
144
+ }
145
+ /** With SEO chosen, a page's own structured data is a group of its metadata, edited in Data mode */
146
+ function kranthoCollections(options) {
147
+ const seoSlot = options.seo ? ', seo: { archetype: structuredDataMetadata }' : '';
148
+ const imports = options.seo
149
+ ? `import {
150
+ defineCollection,
151
+ defineDocumentArchetype,
152
+ sharingMetadata,
153
+ structuredDataMetadata,
154
+ } from '@krantho/schema'`
155
+ : "import { defineCollection, defineDocumentArchetype, sharingMetadata } from '@krantho/schema'";
156
+ return `import { join } from 'path'
157
+ ${imports}
158
+ import { z } from 'zod'
159
+
160
+ // The collections this site serves — shared by the API route and the page loader,
161
+ // so whichever a fresh deployment serves first imports the same content.
162
+
163
+ const pages = defineCollection({
164
+ name: 'pages',
165
+ source: 'filesystem',
166
+ directory: join(process.cwd(), 'content', 'pages'),
167
+ archetype: defineDocumentArchetype({
168
+ id: 'site/pages', label: 'Page',
169
+ fields: z.object({
170
+ title: z.string(),
171
+ description: z.string(),
172
+
173
+ }),
174
+ metadata: { sharing: { archetype: sharingMetadata }${seoSlot} },
175
+ }),
176
+ })
177
+
178
+ const globals = defineCollection({
179
+ name: 'globals',
180
+ source: 'filesystem',
181
+ directory: join(process.cwd(), 'content', 'globals'),
182
+ archetype: defineDocumentArchetype({
183
+ id: 'site/globals', label: 'Shared content',
184
+ fields: z.object({
185
+ 'logo-name': z.string().optional(),
186
+ 'logo-subtitle': z.string().optional(),
187
+ 'nav-home': z.string().optional(),
188
+ 'nav-about': z.string().optional(),
189
+ 'footer-title': z.string().optional(),
190
+ 'footer-description': z.string().optional(),
191
+ 'footer-copyright-text': z.string().optional(),
192
+ }),
193
+ metadata: { sharing: { archetype: sharingMetadata } },
194
+ }),
195
+ })
196
+
197
+ export const collections = { pages, globals }
198
+ `;
199
+ }
200
+ function apiRoute() {
201
+ return `import { createApiRouteHandlers } from '@krantho/server-next'
202
+ import config from '../../../krantho.config'
203
+ import { collections } from '../../../krantho.collections'
204
+
205
+ export const { GET, POST, PUT, DELETE } = createApiRouteHandlers({
206
+ config,
207
+ collections,
208
+ })
209
+ `;
210
+ }
211
+ /** Studio, which the project tells its owner to open at /studio */
212
+ function studioRoute() {
213
+ return `import { createStudioRouteHandler } from '@krantho/server-next'
214
+ import config from '../../../krantho.config'
215
+
216
+ export const { GET } = createStudioRouteHandler({ config })
217
+ `;
218
+ }
219
+ function kranthoConfig(options) {
220
+ const authLines = options.auth
221
+ ? `
222
+ // Token mode — the secret comes from KRANTHO_API_TOKEN in the environment.
223
+ auth: { mode: 'token' },`
224
+ : '';
225
+ return `import { defineKranthoConfig } from '@krantho/config'
226
+
227
+ /**
228
+ * Site configuration — data only. Every value here can be overridden from the
229
+ * environment; \`KRANTHO_SITE_BASE_URL\` overrides \`site.baseUrl\`, and so on.
230
+ * Collections live in krantho.collections.ts; plugins are wiring in the route
231
+ * file. The media file store is chosen by \`media.provider\`.
232
+ */
233
+ export default defineKranthoConfig({
234
+ site: {
235
+ domain: 'localhost',
236
+ name: '${options.name}',
237
+ language: 'en',
238
+ baseUrl: 'http://localhost:${options.port}',
239
+ supportedLocales: ['en'],
240
+ defaultLocale: 'en',
241
+ },
242
+ // Pages stay cached until a content change invalidates them
243
+ cache: { pages: 'next-isr' },
244
+ // Uploads go to public/uploads, which Next.js serves at /uploads
245
+ media: { provider: 'local' },${authLines}
246
+ })
247
+ `;
248
+ }
249
+ function rootLayout(options) {
250
+ return `import './globals.css'
251
+
252
+ export const metadata = {
253
+ title: '${options.name}',
254
+ }
255
+
256
+ // The root layout reads no content: the build prerenders the 404 page through
257
+ // it, and the build cannot reach the database.
258
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
259
+ return (
260
+ <html lang="en">
261
+ <body>{children}</body>
262
+ </html>
263
+ )
264
+ }
265
+ `;
266
+ }
267
+ function siteLayout(options) {
268
+ return `import { showPreviewBanner } from '@krantho/server-next'
269
+ import { globalFields } from '@/lib/krantho'
270
+
271
+ export default async function SiteLayout({ children }: { children: React.ReactNode }) {
272
+ const navFm = await globalFields('navigation')
273
+ const footerFm = await globalFields('footer')
274
+ // An editor previewing drafts ("Preview on site" in Studio)
275
+ const previewing = await showPreviewBanner()
276
+
277
+ return (
278
+ <>
279
+ {previewing && (
280
+ <p className="preview-banner">
281
+ Previewing drafts — <a href="/api/preview/exit">Exit preview</a>
282
+ </p>
283
+ )}
284
+ <header>
285
+ <nav>
286
+ <a href="/">
287
+ <strong>{String(navFm['logo-name'] ?? '${options.name}')}</strong>
288
+ {Boolean(navFm['logo-subtitle']) && <span> &mdash; {String(navFm['logo-subtitle'])}</span>}
289
+ </a>
290
+ <div>
291
+ <a href="/">{String(navFm['nav-home'] ?? 'Home')}</a>
292
+ <a href="/about">{String(navFm['nav-about'] ?? 'About')}</a>
293
+ </div>
294
+ </nav>
295
+ </header>
296
+ <main>{children}</main>
297
+ <footer>
298
+ <p>{String(footerFm['footer-title'] ?? '${options.name}')}</p>
299
+ {Boolean(footerFm['footer-description']) && <p>{String(footerFm['footer-description'])}</p>}
300
+ <p>{String(footerFm['footer-copyright-text'] ?? '')}</p>
301
+ </footer>
302
+ </>
303
+ )
304
+ }
305
+ `;
306
+ }
307
+ function page(options) {
308
+ const seoImport = options.seo ? ', SeoJsonLd' : '';
309
+ const seoComponent = options.seo ? `\n <SeoJsonLd collection="pages" slug={slug} />` : '';
310
+ return `import { notFound } from 'next/navigation'
311
+ import { CMSMarkdownServer${seoImport} } from '@krantho/ui-react/server'
312
+ import { requirePublished } from '@/lib/krantho'
313
+
314
+ // Every page of the \`pages\` collection. None is rendered at build — the build
315
+ // never reads the database. Each renders on its first request and stays cached
316
+ // until Studio changes what it shows (\`cache.pages: 'next-isr'\`).
317
+ export const revalidate = false
318
+
319
+ export function generateStaticParams(): { slug: string[] }[] {
320
+ return []
321
+ }
322
+
323
+ interface PageProps {
324
+ params: Promise<{ slug?: string[] | undefined }>
325
+ }
326
+
327
+ /** \`/\` is the home page; any other page is one path segment */
328
+ function pageSlug(path: string[] | undefined): string {
329
+ if (!path) return 'home'
330
+ const [name] = path
331
+ if (path.length !== 1 || !name || name === 'home') notFound()
332
+ return name
333
+ }
334
+
335
+ export default async function Page({ params }: PageProps) {
336
+ const slug = pageSlug((await params).slug)
337
+ const page = await requirePublished('pages', slug)
338
+
339
+ return (
340
+ <article>${seoComponent}
341
+ <h1>{String(page.fields['title'] ?? slug)}</h1>
342
+ <CMSMarkdownServer collection="pages" slug={slug} />
343
+ </article>
344
+ )
345
+ }
346
+ `;
347
+ }
348
+ function globalsCss(options) {
349
+ if (options.ui === 'tailwind') {
350
+ return `@import 'tailwindcss';
351
+
352
+ :root {
353
+ --font-sans: system-ui, -apple-system, sans-serif;
354
+ --color-primary: #2563eb;
355
+ --color-text: #1a1a2e;
356
+ --color-muted: #6b7280;
357
+ --color-bg: #ffffff;
358
+ --color-surface: #f9fafb;
359
+ --color-border: #e5e7eb;
360
+ }
361
+
362
+ body {
363
+ font-family: var(--font-sans);
364
+ color: var(--color-text);
365
+ background: var(--color-bg);
366
+ margin: 0;
367
+ line-height: 1.6;
368
+ }
369
+
370
+ header {
371
+ @apply border-b border-gray-200 px-6 py-4;
372
+ }
373
+
374
+ header nav {
375
+ @apply max-w-5xl mx-auto flex items-center justify-between;
376
+ }
377
+
378
+ header nav a {
379
+ @apply text-inherit no-underline;
380
+ }
381
+
382
+ header nav div {
383
+ @apply flex gap-4;
384
+ }
385
+
386
+ header nav div a:hover {
387
+ @apply text-blue-600;
388
+ }
389
+
390
+ main {
391
+ @apply max-w-3xl mx-auto px-6 py-12;
392
+ }
393
+
394
+ article h1 {
395
+ @apply text-3xl font-bold mb-4;
396
+ }
397
+
398
+ article h2 {
399
+ @apply text-xl font-semibold mt-8 mb-3;
400
+ }
401
+
402
+ article .intro {
403
+ @apply text-lg text-gray-600 mb-8;
404
+ }
405
+
406
+ article ul {
407
+ @apply list-disc pl-6 mb-4;
408
+ }
409
+
410
+ article li {
411
+ @apply mb-1;
412
+ }
413
+
414
+ article a {
415
+ @apply text-blue-600 underline;
416
+ }
417
+
418
+ footer {
419
+ @apply border-t border-gray-200 px-6 py-8 text-center text-sm text-gray-500;
420
+ }
421
+ `;
422
+ }
423
+ // Plain CSS or MUI — minimal base styles
424
+ return `:root {
425
+ --font-sans: system-ui, -apple-system, sans-serif;
426
+ --color-primary: #2563eb;
427
+ --color-text: #1a1a2e;
428
+ --color-muted: #6b7280;
429
+ --color-bg: #ffffff;
430
+ --color-surface: #f9fafb;
431
+ --color-border: #e5e7eb;
432
+ --max-width: 48rem;
433
+ }
434
+
435
+ * { box-sizing: border-box; margin: 0; padding: 0; }
436
+
437
+ body {
438
+ font-family: var(--font-sans);
439
+ color: var(--color-text);
440
+ background: var(--color-bg);
441
+ line-height: 1.6;
442
+ }
443
+
444
+ header {
445
+ border-bottom: 1px solid var(--color-border);
446
+ padding: 1rem 1.5rem;
447
+ }
448
+
449
+ header nav {
450
+ max-width: var(--max-width);
451
+ margin: 0 auto;
452
+ display: flex;
453
+ align-items: center;
454
+ justify-content: space-between;
455
+ }
456
+
457
+ header nav a {
458
+ color: inherit;
459
+ text-decoration: none;
460
+ }
461
+
462
+ header nav div {
463
+ display: flex;
464
+ gap: 1rem;
465
+ }
466
+
467
+ header nav div a:hover {
468
+ color: var(--color-primary);
469
+ }
470
+
471
+ main {
472
+ max-width: var(--max-width);
473
+ margin: 0 auto;
474
+ padding: 3rem 1.5rem;
475
+ }
476
+
477
+ article h1 {
478
+ font-size: 1.875rem;
479
+ font-weight: 700;
480
+ margin-bottom: 1rem;
481
+ }
482
+
483
+ article h2 {
484
+ font-size: 1.25rem;
485
+ font-weight: 600;
486
+ margin-top: 2rem;
487
+ margin-bottom: 0.75rem;
488
+ }
489
+
490
+ article .intro {
491
+ font-size: 1.125rem;
492
+ color: var(--color-muted);
493
+ margin-bottom: 2rem;
494
+ }
495
+
496
+ article ul {
497
+ list-style: disc;
498
+ padding-left: 1.5rem;
499
+ margin-bottom: 1rem;
500
+ }
501
+
502
+ article li { margin-bottom: 0.25rem; }
503
+
504
+ article a {
505
+ color: var(--color-primary);
506
+ text-decoration: underline;
507
+ }
508
+
509
+ article strong { font-weight: 600; }
510
+
511
+ footer {
512
+ border-top: 1px solid var(--color-border);
513
+ padding: 2rem 1.5rem;
514
+ text-align: center;
515
+ font-size: 0.875rem;
516
+ color: var(--color-muted);
517
+ }
518
+ `;
519
+ }
520
+ function postcssConfig() {
521
+ return `export default {
522
+ plugins: {
523
+ '@tailwindcss/postcss': {},
524
+ },
525
+ }
526
+ `;
527
+ }
528
+ function sortObject(obj) {
529
+ const sorted = {};
530
+ for (const key of Object.keys(obj).sort()) {
531
+ const value = obj[key];
532
+ // `key` came from `Object.keys(obj)` so it is guaranteed to be present —
533
+ // but noUncheckedIndexedAccess treats the lookup as `string | undefined`.
534
+ // Explicit guard instead of a non-null assertion keeps the check visible.
535
+ if (value !== undefined)
536
+ sorted[key] = value;
537
+ }
538
+ return sorted;
539
+ }
@@ -0,0 +1,2 @@
1
+ import type { ProjectOptions } from '../prompts.js';
2
+ export declare function generateSveltekitFiles(options: ProjectOptions): Map<string, string>;