create-prenta 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +964 -0
  2. package/LICENSE +21 -0
  3. package/assets/prisma/migrations/0001_init/migration.sql +941 -0
  4. package/assets/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
  5. package/assets/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
  6. package/assets/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
  7. package/assets/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
  8. package/assets/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
  9. package/assets/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
  10. package/assets/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
  11. package/assets/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
  12. package/assets/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
  13. package/assets/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
  14. package/assets/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
  15. package/assets/prisma/migrations/migration_lock.toml +3 -0
  16. package/assets/prisma/schema.prisma +989 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +182 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/options.d.ts +160 -0
  22. package/dist/options.d.ts.map +1 -0
  23. package/dist/options.js +206 -0
  24. package/dist/options.js.map +1 -0
  25. package/dist/scaffold.d.ts +45 -0
  26. package/dist/scaffold.d.ts.map +1 -0
  27. package/dist/scaffold.js +2163 -0
  28. package/dist/scaffold.js.map +1 -0
  29. package/dist/templates/admin-page.ts.template.d.ts +24 -0
  30. package/dist/templates/admin-page.ts.template.d.ts.map +1 -0
  31. package/dist/templates/admin-page.ts.template.js +145 -0
  32. package/dist/templates/admin-page.ts.template.js.map +1 -0
  33. package/dist/templates/astro-site.d.ts +36 -0
  34. package/dist/templates/astro-site.d.ts.map +1 -0
  35. package/dist/templates/astro-site.js +918 -0
  36. package/dist/templates/astro-site.js.map +1 -0
  37. package/dist/templates/login-page.ts.template.d.ts +14 -0
  38. package/dist/templates/login-page.ts.template.d.ts.map +1 -0
  39. package/dist/templates/login-page.ts.template.js +143 -0
  40. package/dist/templates/login-page.ts.template.js.map +1 -0
  41. package/dist/templates/marketing-custom.d.ts +56 -0
  42. package/dist/templates/marketing-custom.d.ts.map +1 -0
  43. package/dist/templates/marketing-custom.js +814 -0
  44. package/dist/templates/marketing-custom.js.map +1 -0
  45. package/dist/templates/prenta.config.ts.template.d.ts +3 -0
  46. package/dist/templates/prenta.config.ts.template.d.ts.map +1 -0
  47. package/dist/templates/prenta.config.ts.template.js +253 -0
  48. package/dist/templates/prenta.config.ts.template.js.map +1 -0
  49. package/package.json +58 -0
@@ -0,0 +1,2163 @@
1
+ import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { generatePrentaConfig } from './templates/prenta.config.ts.template.js';
7
+ import { generateAdminPage } from './templates/admin-page.ts.template.js';
8
+ import { generateLoginPage } from './templates/login-page.ts.template.js';
9
+ import { MARKETING_CUSTOM_GITIGNORE, MARKETING_CUSTOM_SCRIPTS, isMarketingCustom, marketingCustomFiles, workspaceSectionKitFiles, } from './templates/marketing-custom.js';
10
+ import { astroSiteFiles, isAstroSiteOnly, isAstroSplit } from './templates/astro-site.js';
11
+ /**
12
+ * Canonical Prisma schema + migrations bundled with this package, synced from
13
+ * `@prenta/core` via `scripts/sync-prisma-assets.ts`. Resolves the
14
+ * same whether running from `src/` (tests/tsx) or `dist/` (published) since both
15
+ * sit one level below the package root next to `assets/`.
16
+ */
17
+ const PRISMA_ASSETS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'prisma');
18
+ const PRENTA_PACKAGES = [
19
+ '@prenta/core',
20
+ '@prenta/admin',
21
+ '@prenta/component-blocks',
22
+ '@prenta/sections-react',
23
+ '@prenta/cli',
24
+ '@prenta/platform-vercel',
25
+ '@prenta/platform-aws',
26
+ '@prenta/plugin-seo',
27
+ '@prenta/plugin-blocks',
28
+ '@prenta/plugin-forms',
29
+ '@prenta/plugin-email',
30
+ '@prenta/plugin-media',
31
+ '@prenta/plugin-redirects',
32
+ '@prenta/plugin-navigation',
33
+ '@prenta/plugin-commerce',
34
+ '@prenta/plugin-ai',
35
+ ];
36
+ async function fetchLatestVersion(pkg) {
37
+ try {
38
+ const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, {
39
+ signal: AbortSignal.timeout(5000),
40
+ });
41
+ if (!res.ok)
42
+ return null;
43
+ const data = (await res.json());
44
+ return data.version ?? null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function resolvePackageVersions() {
51
+ const results = await Promise.allSettled(PRENTA_PACKAGES.map(async (pkg) => {
52
+ const version = await fetchLatestVersion(pkg);
53
+ return [pkg, version];
54
+ }));
55
+ const versions = {};
56
+ for (const result of results) {
57
+ if (result.status === 'fulfilled') {
58
+ const [pkg, version] = result.value;
59
+ if (version) {
60
+ versions[pkg] = `^${version}`;
61
+ }
62
+ }
63
+ }
64
+ return versions;
65
+ }
66
+ function versionFor(versions, pkg) {
67
+ return versions[pkg] ?? '^0.1.0';
68
+ }
69
+ function generateGlobalsCss() {
70
+ return `@import "tailwindcss";
71
+
72
+ /*
73
+ * The public section renderer (@prenta/sections-react) ships its own
74
+ * Tailwind utility classes outside this app's default content roots. Scan the
75
+ * built package so those classes are generated. See ADR 0001 / WS-A4.
76
+ */
77
+ @source '../../node_modules/@prenta/sections-react/dist';
78
+
79
+ :root {
80
+ --font-sans: system-ui, -apple-system, sans-serif;
81
+ }
82
+
83
+ /*
84
+ * Custom fonts — uncomment after adding licensed woff2 files to public/fonts/.
85
+ *
86
+ * @font-face {
87
+ * font-family: 'Site Display';
88
+ * src: url('/fonts/display.woff2') format('woff2');
89
+ * font-weight: 600 700;
90
+ * font-display: swap;
91
+ * }
92
+ * @font-face {
93
+ * font-family: 'Site Body';
94
+ * src: url('/fonts/body.woff2') format('woff2');
95
+ * font-weight: 400 500;
96
+ * font-display: swap;
97
+ * }
98
+ * :root {
99
+ * --font-sans: 'Site Body', system-ui, sans-serif;
100
+ * }
101
+ */
102
+
103
+ body {
104
+ font-family: var(--font-sans);
105
+ -webkit-font-smoothing: antialiased;
106
+ -moz-osx-font-smoothing: grayscale;
107
+ }
108
+ `;
109
+ }
110
+ /**
111
+ * `.cursor/mcp.json` — wires the Prenta MCP server into Cursor / Claude
112
+ * Desktop so an AI agent can author content right after setup. The API key is
113
+ * a clearly-fake placeholder the developer swaps for a real `act_sk_...` key
114
+ * minted in Admin → API Keys. The file is git-ignored (see `buildGitignore`)
115
+ * so a real key pasted here never gets committed. See ADR 0001 / WS-C C5.
116
+ */
117
+ function generateCursorMcpJson() {
118
+ const config = {
119
+ mcpServers: {
120
+ prenta: {
121
+ command: 'npx',
122
+ args: ['-y', '@prenta/mcp-server'],
123
+ env: {
124
+ PRENTA_BASE_URL: 'http://localhost:3000',
125
+ PRENTA_API_KEY: 'act_sk_REPLACE_WITH_YOUR_KEY',
126
+ },
127
+ },
128
+ },
129
+ };
130
+ return JSON.stringify(config, null, 2) + '\n';
131
+ }
132
+ /**
133
+ * VS Code uses a different MCP config shape than Cursor: the top-level key is
134
+ * `servers` (not `mcpServers`) and each entry declares `type: "stdio"`. Same
135
+ * placeholder key as the Cursor config; the file is git-ignored so a real key
136
+ * pasted here is never committed.
137
+ */
138
+ function generateVscodeMcpJson() {
139
+ const config = {
140
+ servers: {
141
+ prenta: {
142
+ type: 'stdio',
143
+ command: 'npx',
144
+ args: ['-y', '@prenta/mcp-server'],
145
+ env: {
146
+ PRENTA_BASE_URL: 'http://localhost:3000',
147
+ PRENTA_API_KEY: 'act_sk_REPLACE_WITH_YOUR_KEY',
148
+ },
149
+ },
150
+ },
151
+ };
152
+ return JSON.stringify(config, null, 2) + '\n';
153
+ }
154
+ /**
155
+ * GitHub Actions CI: a migration gate that runs on every pull request. The
156
+ * bundled Prenta migrations (plus any the consumer adds) must apply cleanly to
157
+ * a fresh Postgres, so a broken/out-of-order migration fails the PR in review
158
+ * instead of breaking the production Vercel build (whose build command runs
159
+ * `prisma migrate deploy`).
160
+ *
161
+ * Provider-agnostic by design: it stands up a throwaway Postgres service
162
+ * container rather than branching a specific host, so it works the same whether
163
+ * production runs on Prisma Postgres, Neon, Supabase, or self-hosted — and needs
164
+ * no secrets. `migrate deploy` reads `DIRECT_DATABASE_URL` (see prisma.config.ts).
165
+ *
166
+ * Note: `\${{ ... }}` below are GitHub Actions expressions, escaped so they
167
+ * survive this template literal verbatim.
168
+ *
169
+ * astro-split scaffolds are pnpm workspaces: the workflow installs with pnpm,
170
+ * targets `apps/cms` via a filter for the migration gate, and (marketing-custom)
171
+ * runs `verify:sections` so schema/renderer drift in the shared section kit
172
+ * fails the PR.
173
+ */
174
+ function generateCiWorkflow(options) {
175
+ const header = `name: CI
176
+
177
+ on:
178
+ pull_request:
179
+ branches: [main]
180
+
181
+ concurrency:
182
+ group: ci-\${{ github.head_ref || github.ref }}
183
+ cancel-in-progress: true
184
+
185
+ jobs:
186
+ migrations:
187
+ name: Prisma migrate (ephemeral Postgres)
188
+ runs-on: ubuntu-latest
189
+
190
+ services:
191
+ postgres:
192
+ image: postgres:16
193
+ env:
194
+ POSTGRES_USER: prenta
195
+ POSTGRES_PASSWORD: prenta
196
+ POSTGRES_DB: prenta_ci
197
+ ports:
198
+ - 5432:5432
199
+ options: >-
200
+ --health-cmd "pg_isready -U prenta"
201
+ --health-interval 10s
202
+ --health-timeout 5s
203
+ --health-retries 5
204
+
205
+ env:
206
+ # prisma migrate deploy reads DIRECT_DATABASE_URL (see prisma.config.ts).
207
+ # Both point at the throwaway CI database — never a real environment.
208
+ DATABASE_URL: postgresql://prenta:prenta@localhost:5432/prenta_ci
209
+ DIRECT_DATABASE_URL: postgresql://prenta:prenta@localhost:5432/prenta_ci
210
+
211
+ steps:
212
+ - uses: actions/checkout@v4
213
+ `;
214
+ if (isAstroSplit(options)) {
215
+ const verifyStep = isMarketingCustom(options)
216
+ ? ` # Section contract gate: every schema id in packages/sections has a
217
+ # renderer and vice versa (drift breaks the editor canvas or the site).
218
+ - name: Verify section contract
219
+ run: pnpm run verify:sections
220
+ # Canvas ↔ public fidelity: same sectionRenderers → same normalized tree.
221
+ - name: Section render parity
222
+ run: pnpm run test:parity
223
+ `
224
+ : '';
225
+ return `${header} - uses: pnpm/action-setup@v4
226
+ - uses: actions/setup-node@v4
227
+ with:
228
+ node-version: 22
229
+ cache: pnpm
230
+ - name: Install dependencies
231
+ # Switch to --frozen-lockfile once pnpm-lock.yaml is committed.
232
+ run: pnpm install --no-frozen-lockfile
233
+ - name: Generate Prisma client
234
+ run: pnpm --filter cms exec prisma generate
235
+ # The gate: migrations must apply cleanly to a fresh database. A broken or
236
+ # out-of-order migration fails here, not at deploy time.
237
+ - name: Apply migrations
238
+ run: pnpm --filter cms exec prisma migrate deploy
239
+ ${verifyStep}`;
240
+ }
241
+ return `${header} - uses: actions/setup-node@v4
242
+ with:
243
+ node-version: 20
244
+ cache: npm
245
+ - name: Install dependencies
246
+ run: npm ci
247
+ - name: Generate Prisma client
248
+ run: npx prisma generate
249
+ # The gate: migrations must apply cleanly to a fresh database. A broken or
250
+ # out-of-order migration fails here, not at deploy time.
251
+ - name: Apply migrations
252
+ run: npx prisma migrate deploy
253
+ `;
254
+ }
255
+ /**
256
+ * Renovate preset (ADR 0003, code plane). Groups every `@prenta/*`
257
+ * upgrade into one PR, schedules it, and — crucially — only auto-merges
258
+ * non-major bumps, leaning on the CI migration gate (`.github/workflows/ci.yml`)
259
+ * to prove the new schema before merge. Major bumps (which can carry breaking
260
+ * schema/API changes) are never auto-merged and get labelled for review.
261
+ *
262
+ * "Patch the fleet" then = merge N Renovate PRs, each independently CI-gated —
263
+ * no bespoke orchestrator. Auto-merge of minor/patch only actually waits for CI
264
+ * when the repo has branch protection requiring the `CI` check (documented in
265
+ * DEPLOYMENT.md).
266
+ */
267
+ function generateRenovateJson() {
268
+ const config = {
269
+ $schema: 'https://docs.renovatebot.com/renovate-schema.json',
270
+ extends: ['config:recommended'],
271
+ labels: ['dependencies'],
272
+ schedule: ['before 6am on monday'],
273
+ prConcurrentLimit: 5,
274
+ packageRules: [
275
+ {
276
+ description: 'Group all Prenta CMS packages so cms-core, plugins, admin, and the CLI bump together in one CI-gated PR.',
277
+ matchPackageNames: ['@prenta/*'],
278
+ groupName: 'Prenta CMS',
279
+ },
280
+ {
281
+ description: 'Auto-merge non-major Prenta updates once CI passes (incl. the prisma migrate deploy gate). Requires branch protection that requires the CI check.',
282
+ matchPackageNames: ['@prenta/*'],
283
+ matchUpdateTypes: ['minor', 'patch'],
284
+ automerge: true,
285
+ },
286
+ {
287
+ description: 'Major Prenta bumps can ship breaking schema/API changes — never auto-merge; flag for manual review.',
288
+ matchPackageNames: ['@prenta/*'],
289
+ matchUpdateTypes: ['major'],
290
+ automerge: false,
291
+ addLabels: ['breaking-change'],
292
+ },
293
+ ],
294
+ };
295
+ return JSON.stringify(config, null, 2) + '\n';
296
+ }
297
+ function generatePostcssConfig() {
298
+ return `const config = {
299
+ plugins: {
300
+ "@tailwindcss/postcss": {},
301
+ },
302
+ };
303
+ export default config;
304
+ `;
305
+ }
306
+ function generateAdminLayoutCss() {
307
+ return `/* Pre-compiled Prenta CMS admin styles — includes all Tailwind utilities and theme. */
308
+ @import "@prenta/admin/styles/precompiled.css";
309
+ `;
310
+ }
311
+ function generateAdminLayout() {
312
+ return `import type { Metadata } from 'next';
313
+ import './admin.css';
314
+
315
+ // Resolve the browser-tab favicon from CMS Brand Settings (Settings →
316
+ // Appearance). Wrapped in try/catch so builds without a DB connection still
317
+ // emit valid metadata. The admin SPA also keeps the favicon live on save (no
318
+ // reload needed). Importing the light \`appearance\` subpath avoids dragging DB
319
+ // codegen into the layout module graph.
320
+ export async function generateMetadata(): Promise<Metadata> {
321
+ const base: Metadata = { robots: 'noindex, nofollow' };
322
+ try {
323
+ const [{ resolveBrand }, { getPrisma }] = await Promise.all([
324
+ import('@prenta/core/appearance'),
325
+ import('@/lib/prisma'),
326
+ ]);
327
+ const { favicon } = await resolveBrand(getPrisma(), null);
328
+ if (favicon.icon) {
329
+ base.icons = {
330
+ icon: [{ url: favicon.icon, type: favicon.type ?? undefined }],
331
+ shortcut: [{ url: favicon.icon }],
332
+ apple: favicon.apple ? [{ url: favicon.apple }] : undefined,
333
+ };
334
+ }
335
+ } catch {
336
+ // Database unavailable — fall back to base metadata.
337
+ }
338
+ return base;
339
+ }
340
+
341
+ export default function AdminLayout({
342
+ children,
343
+ }: {
344
+ children: React.ReactNode;
345
+ }) {
346
+ return (
347
+ <html lang="en" suppressHydrationWarning>
348
+ <body>
349
+ <div className="prenta-admin">
350
+ {children}
351
+ </div>
352
+ </body>
353
+ </html>
354
+ );
355
+ }
356
+ `;
357
+ }
358
+ function generateSiteLayout(options) {
359
+ const { projectName, plugins } = options;
360
+ const registerCustomSections = isMarketingCustom(options);
361
+ const hasConsent = plugins.includes('consent');
362
+ // The marketing-custom template registers its public section renderers via a
363
+ // side-effect import so <Sections> can render them on the server.
364
+ const registerImport = registerCustomSections ? `import '@/src/sections/register';\n` : '';
365
+ const consentImport = hasConsent
366
+ ? `import { ConsentRoot } from '@prenta/plugin-consent';\nimport { consentAnalyticsConfig, consentLinks } from '@/src/consent/config';\n`
367
+ : '';
368
+ const consentNode = hasConsent
369
+ ? `\n <ConsentRoot analytics={consentAnalyticsConfig} {...consentLinks} />`
370
+ : '';
371
+ return `import type { Metadata } from 'next';
372
+ import { headers } from 'next/headers';
373
+ import { unstable_cache } from 'next/cache';
374
+ import { SiteNav } from '@prenta/sections-react';
375
+ import { ScriptTagGroup } from '@prenta/core/next/script-tags';
376
+ import {
377
+ resolveScriptTagsForPath,
378
+ SCRIPT_TAGS_CACHE_TAG,
379
+ } from '@prenta/core/script-tags';
380
+ import { getNavigation } from '@/lib/cms';
381
+ import { getPrisma } from '@/lib/prisma';
382
+ ${registerImport}${consentImport}import './globals.css';
383
+
384
+ // Optional custom fonts — after adding woff2 files under public/fonts/, uncomment
385
+ // the preload links below and the @font-face rules in globals.css.
386
+ // See public/fonts/README.md for licensing and naming guidance.
387
+ /*
388
+ <link rel="preload" href="/fonts/display.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
389
+ <link rel="preload" href="/fonts/body.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
390
+ */
391
+
392
+ // Resolve the browser-tab favicon from CMS Brand Settings (Settings →
393
+ // Appearance) so uploading a favicon updates the public site's <head> icons.
394
+ // Wrapped in try/catch with a static fallback so builds without a DB still emit
395
+ // valid base metadata. The light \`appearance\` subpath keeps DB codegen out of
396
+ // the layout module graph.
397
+ export async function generateMetadata(): Promise<Metadata> {
398
+ const base: Metadata = {
399
+ metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
400
+ title: '${projectName}',
401
+ description: 'Powered by Prenta CMS',
402
+ };
403
+ try {
404
+ const [{ resolveBrand }, { getPrisma }] = await Promise.all([
405
+ import('@prenta/core/appearance'),
406
+ import('@/lib/prisma'),
407
+ ]);
408
+ const { favicon } = await resolveBrand(getPrisma(), null);
409
+ if (favicon.icon) {
410
+ base.icons = {
411
+ icon: [{ url: favicon.icon, type: favicon.type ?? undefined }],
412
+ shortcut: [{ url: favicon.icon }],
413
+ apple: favicon.apple ? [{ url: favicon.apple }] : undefined,
414
+ };
415
+ }
416
+ } catch {
417
+ // Database unavailable — fall back to base metadata.
418
+ }
419
+ return base;
420
+ }
421
+
422
+ interface ScriptTags {
423
+ head: string[];
424
+ body_open: string[];
425
+ body_close: string[];
426
+ }
427
+
428
+ async function getScriptTags(): Promise<ScriptTags> {
429
+ const headerStore = await headers();
430
+ const pathname =
431
+ headerStore.get('x-prenta-pathname') ??
432
+ headerStore.get('x-invoke-path') ??
433
+ '/';
434
+ const preview = headerStore.get('x-prenta-preview') === '1';
435
+ try {
436
+ // Preview requests bypass the cache: they're rare, and tags flagged
437
+ // "disable in preview" must be filtered per-request, not cached.
438
+ if (preview) {
439
+ return await resolveScriptTagsForPath(getPrisma(), pathname, { preview: true });
440
+ }
441
+ const resolve = unstable_cache(
442
+ async (path: string) => resolveScriptTagsForPath(getPrisma(), path),
443
+ ['script-tags'],
444
+ { revalidate: 60, tags: ['prenta', SCRIPT_TAGS_CACHE_TAG] },
445
+ );
446
+ return await resolve(pathname);
447
+ } catch {
448
+ return { head: [], body_open: [], body_close: [] };
449
+ }
450
+ }
451
+
452
+ // Load a navigation menu by slug. Returns null when the navigation plugin
453
+ // isn't installed or no menu with that slug exists yet, so the header/footer
454
+ // degrade gracefully.
455
+ async function getNav(slug: string) {
456
+ try {
457
+ return await getNavigation(slug);
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ export default async function SiteLayout({
464
+ children,
465
+ }: {
466
+ children: React.ReactNode;
467
+ }) {
468
+ const [tags, headerNav, footerNav] = await Promise.all([
469
+ getScriptTags(),
470
+ getNav('main'),
471
+ getNav('footer'),
472
+ ]);
473
+
474
+ return (
475
+ <html lang="en">
476
+ <head>
477
+ <ScriptTagGroup codes={tags.head} idPrefix="head" />
478
+ </head>
479
+ <body className="flex min-h-screen flex-col">
480
+ <ScriptTagGroup codes={tags.body_open} idPrefix="body-open" />
481
+ <header className="border-b border-zinc-200">
482
+ <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
483
+ <a href="/" className="text-lg font-medium text-zinc-900">
484
+ ${projectName}
485
+ </a>
486
+ {headerNav && headerNav.items.length > 0 && <SiteNav items={headerNav.items} />}
487
+ </div>
488
+ </header>
489
+ <main className="flex-1">{children}</main>
490
+ {footerNav && footerNav.items.length > 0 && (
491
+ <footer className="border-t border-zinc-200">
492
+ <div className="mx-auto flex max-w-5xl flex-col gap-2 px-4 py-6 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
493
+ <span>&copy; {new Date().getFullYear()} ${projectName}</span>
494
+ <SiteNav items={footerNav.items} ariaLabel="Footer" />
495
+ </div>
496
+ </footer>
497
+ )}
498
+ <ScriptTagGroup codes={tags.body_close} idPrefix="body-close" />${consentNode}
499
+ </body>
500
+ </html>
501
+ );
502
+ }
503
+ `;
504
+ }
505
+ function generateConsentConfig() {
506
+ return `import type { ConsentAnalyticsConfig } from '@prenta/plugin-consent';
507
+
508
+ /** Analytics IDs — injected only after the visitor accepts all cookies. */
509
+ export const consentAnalyticsConfig: ConsentAnalyticsConfig = {
510
+ ga4MeasurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID,
511
+ googleAdsId: process.env.NEXT_PUBLIC_GOOGLE_ADS_ID,
512
+ metaPixelId: process.env.NEXT_PUBLIC_META_PIXEL_ID,
513
+ };
514
+
515
+ export const consentLinks = {
516
+ privacyUrl: '/privacy-policy',
517
+ termsUrl: '/terms',
518
+ };
519
+ `;
520
+ }
521
+ /**
522
+ * Split-login mode: the auth screens get their own mount, distinct from the
523
+ * admin app. When the two paths are equal the classic inline login applies.
524
+ */
525
+ export function isSplitLogin(options) {
526
+ return options.loginPath !== options.adminPath;
527
+ }
528
+ /** Package names for CLI plugin slugs — exported for scaffold / CLI consistency tests. */
529
+ export const PLUGIN_PACKAGES = {
530
+ seo: '@prenta/plugin-seo',
531
+ blocks: '@prenta/plugin-blocks',
532
+ forms: '@prenta/plugin-forms',
533
+ email: '@prenta/plugin-email',
534
+ media: '@prenta/plugin-media',
535
+ redirects: '@prenta/plugin-redirects',
536
+ navigation: '@prenta/plugin-navigation',
537
+ commerce: '@prenta/plugin-commerce',
538
+ ai: '@prenta/plugin-ai',
539
+ consent: '@prenta/plugin-consent',
540
+ };
541
+ /** Package names for CLI platform slugs — exported for scaffold / CLI consistency tests. */
542
+ export const PLATFORM_PACKAGES = {
543
+ vercel: '@prenta/platform-vercel',
544
+ aws: '@prenta/platform-aws',
545
+ };
546
+ /**
547
+ * npm-safe workspace scope derived from the project name (folder basename when
548
+ * scaffolding into `.`). Used for the shared section-kit package name
549
+ * (`@<scope>/sections`) and the workspace-root package name.
550
+ */
551
+ function workspaceScopeName(options, root) {
552
+ const base = options.projectName === '.' ? path.basename(root) : options.projectName;
553
+ const cleaned = base
554
+ .toLowerCase()
555
+ .replace(/[^a-z0-9_-]/g, '-')
556
+ .replace(/^[-_.]+|[-_.]+$/g, '');
557
+ return cleaned || 'site';
558
+ }
559
+ /** `pnpm-workspace.yaml` for astro-split scaffolds. */
560
+ function buildPnpmWorkspaceYaml() {
561
+ return `packages:
562
+ - apps/*
563
+ - packages/*
564
+ `;
565
+ }
566
+ /**
567
+ * Workspace-root `package.json` for astro-split scaffolds. Scripts delegate to
568
+ * the member packages via pnpm filters so day-to-day commands run from the
569
+ * repo root (`pnpm dev`, `pnpm db:migrate`, `pnpm verify:sections`, …).
570
+ */
571
+ function buildWorkspaceRootPackageJson(options, root, sectionsPackage) {
572
+ const scripts = {
573
+ // Both apps in one terminal; use dev:cms / dev:site for separate terminals.
574
+ dev: 'pnpm --parallel -r run dev',
575
+ 'dev:cms': 'pnpm --filter cms dev',
576
+ 'dev:site': 'pnpm --filter site dev',
577
+ build: 'pnpm -r run build',
578
+ 'db:migrate': 'pnpm --filter cms db:migrate',
579
+ 'db:seed': 'pnpm --filter cms db:seed',
580
+ populate: 'pnpm --filter cms populate',
581
+ doctor: 'pnpm --filter cms doctor',
582
+ 'deploy:check': 'pnpm --filter cms deploy:check',
583
+ verify: 'pnpm --filter cms verify',
584
+ };
585
+ if (sectionsPackage) {
586
+ scripts['verify:sections'] = `pnpm --filter ${sectionsPackage} verify:sections`;
587
+ scripts['blocks:extract'] = `pnpm --filter ${sectionsPackage} blocks:extract`;
588
+ scripts['blocks:diff'] = `pnpm --filter ${sectionsPackage} blocks:diff`;
589
+ scripts['test:parity'] = `pnpm --filter ${sectionsPackage} test:parity`;
590
+ }
591
+ const pkg = {
592
+ name: workspaceScopeName(options, root),
593
+ version: '0.1.0',
594
+ private: true,
595
+ // pnpm/action-setup and corepack read the toolchain from here.
596
+ packageManager: 'pnpm@9.15.0',
597
+ scripts,
598
+ engines: {
599
+ // Astro 7 (apps/site) requires Node 22.12+; hold the workspace to it.
600
+ node: '>=22.12.0',
601
+ },
602
+ };
603
+ return JSON.stringify(pkg, null, 2) + '\n';
604
+ }
605
+ function buildPackageJson(options, versions) {
606
+ const deps = {
607
+ '@prenta/core': versionFor(versions, '@prenta/core'),
608
+ '@prenta/admin': versionFor(versions, '@prenta/admin'),
609
+ // Block primitives the admin page-builder relies on (optional peer of cms-admin).
610
+ '@prenta/component-blocks': versionFor(versions, '@prenta/component-blocks'),
611
+ // Public renderer for the canonical PageSection[] content contract (ADR 0001).
612
+ '@prenta/sections-react': versionFor(versions, '@prenta/sections-react'),
613
+ '@prisma/adapter-pg': '^7.8.0',
614
+ '@prisma/client': '^7.8.0',
615
+ next: '^16.2.4',
616
+ pg: '^8.20.0',
617
+ react: '^19.2.0',
618
+ 'react-dom': '^19.2.0',
619
+ // The /api/cms/graphql endpoint is served by cms-core, which lazily imports
620
+ // graphql + graphql-yoga (their optional peer dependencies). Ship them so the
621
+ // GraphQL API works out of the box; remove both if you only use the REST API.
622
+ graphql: '^16.13.2',
623
+ 'graphql-yoga': '^5.19.0',
624
+ // cms-core optimizes uploaded images with sharp and marks it a Next
625
+ // serverExternalPackage (a native module can't be bundled). It's dynamically
626
+ // imported, so Next never traces it — the app must depend on it directly or
627
+ // Vercel won't include it in the function bundle and media uploads 500 with
628
+ // "Cannot find package 'sharp'".
629
+ sharp: '^0.35.0',
630
+ };
631
+ if (options.platform in PLATFORM_PACKAGES) {
632
+ const pkg = PLATFORM_PACKAGES[options.platform];
633
+ deps[pkg] = versionFor(versions, pkg);
634
+ }
635
+ // The generated prenta.config uses the `storage: { provider: 'vercel-blob' }`
636
+ // shorthand, which Prenta auto-wires to platform-vercel's storage adapter
637
+ // server-side. That adapter dynamically imports `@vercel/blob` (an *optional*
638
+ // peer it never installs), so without this the first media upload throws
639
+ // "Cannot find module '@vercel/blob'". Ship it as a direct runtime dependency
640
+ // so uploads work out of the box.
641
+ if (options.platform === 'vercel') {
642
+ deps['@vercel/blob'] = '^2.4.0';
643
+ deps['@vercel/functions'] = '^3.6.0';
644
+ }
645
+ for (const plugin of options.plugins) {
646
+ const pkg = PLUGIN_PACKAGES[plugin];
647
+ if (pkg)
648
+ deps[pkg] = versionFor(versions, pkg);
649
+ }
650
+ const scripts = {
651
+ dev: 'next dev',
652
+ build: 'prisma generate && node scripts/deploy-migrate.mjs && next build',
653
+ start: 'next start',
654
+ postinstall: 'prisma generate',
655
+ // No `lint` script: Next 16 removed the `next lint` subcommand, and we ship
656
+ // no ESLint config or eslint-config-next dependency — so `next lint` would
657
+ // just error in a fresh project. Consumers can add ESLint themselves.
658
+ generate: 'prenta generate',
659
+ 'db:migrate': 'prisma migrate dev',
660
+ 'db:push': 'prisma db push',
661
+ 'db:studio': 'prisma studio',
662
+ 'db:seed': 'prenta seed',
663
+ populate: 'prenta populate',
664
+ doctor: 'prenta doctor',
665
+ 'deploy:check': 'prenta deploy:check',
666
+ verify: 'prenta verify --full',
667
+ // The marketing-custom template adds blocks:extract / blocks:diff /
668
+ // verify:sections for the custom-section workflow. In the astro-split
669
+ // workspace those live on packages/sections; the CMS app keeps a
670
+ // verify:sections alias so the contract check runs from either app.
671
+ ...(isMarketingCustom(options)
672
+ ? isAstroSplit(options)
673
+ ? { 'verify:sections': 'node ../../packages/sections/scripts/verify-sections.mjs' }
674
+ : MARKETING_CUSTOM_SCRIPTS
675
+ : {}),
676
+ };
677
+ const packageJson = {
678
+ // In the astro-split workspace this package is `apps/cms` (the workspace
679
+ // root carries the project name); pnpm filters target it as `cms`.
680
+ name: isAstroSplit(options) ? 'cms' : options.projectName,
681
+ version: '0.1.0',
682
+ private: true,
683
+ scripts,
684
+ dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
685
+ devDependencies: {
686
+ '@prenta/cli': versionFor(versions, '@prenta/cli'),
687
+ '@tailwindcss/postcss': '^4.0.0',
688
+ '@types/node': '^22.0.0',
689
+ '@types/pg': '^8.20.0',
690
+ '@types/react': '^19.0.0',
691
+ '@types/react-dom': '^19.0.0',
692
+ prisma: '^7.8.0',
693
+ typescript: '^5.7.0',
694
+ },
695
+ };
696
+ return JSON.stringify(packageJson, null, 2) + '\n';
697
+ }
698
+ function buildEnvExample(options) {
699
+ const lines = ['# ── Database (Required) ──────────────────────────────────'];
700
+ if (options.database === 'prisma-postgres') {
701
+ lines.push('# Pooled connection (runtime queries — provisioned by the Vercel Marketplace)');
702
+ lines.push('DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=YOUR_API_KEY"');
703
+ lines.push('# Direct TCP connection (migrations - non-pooled)');
704
+ lines.push('DIRECT_DATABASE_URL="postgresql://user:password@db.prisma.io:5432/postgres?sslmode=require"');
705
+ }
706
+ else if (options.database === 'neon') {
707
+ lines.push('# Pooled connection (runtime queries)');
708
+ lines.push('DATABASE_URL="postgresql://user:password@ep-xyz.us-east-2.aws.neon.tech/prenta?sslmode=require"');
709
+ lines.push('# Direct connection (migrations — non-pooled)');
710
+ lines.push('DIRECT_DATABASE_URL="postgresql://user:password@ep-xyz.us-east-2.aws.neon.tech/prenta?sslmode=require"');
711
+ }
712
+ else if (options.database === 'supabase') {
713
+ lines.push('# Pooled connection (runtime queries)');
714
+ lines.push('DATABASE_URL="postgresql://postgres:password@db.xyzproject.supabase.co:6543/postgres"');
715
+ lines.push('# Direct connection (migrations — non-pooled)');
716
+ lines.push('DIRECT_DATABASE_URL="postgresql://postgres:password@db.xyzproject.supabase.co:5432/postgres"');
717
+ }
718
+ else {
719
+ lines.push('DATABASE_URL="postgresql://user:password@localhost:5432/prenta"');
720
+ lines.push('DIRECT_DATABASE_URL="postgresql://user:password@localhost:5432/prenta"');
721
+ }
722
+ lines.push('');
723
+ lines.push('# ── Security (Required) ──────────────────────────────────');
724
+ lines.push('# Min 32 chars — used for JWT signing and session encryption');
725
+ lines.push('CMS_SECRET=""');
726
+ lines.push('# 32-byte hex key for AES-256-GCM field encryption');
727
+ lines.push("# Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"");
728
+ lines.push('CMS_ENCRYPTION_KEY=""');
729
+ lines.push('# Protects /api/cms/cron/* (Vercel sends Authorization: Bearer <CRON_SECRET>)');
730
+ lines.push("# Generate with: node -e \"console.log(require('crypto').randomBytes(24).toString('hex'))\"");
731
+ lines.push('CRON_SECRET=""');
732
+ lines.push('');
733
+ lines.push('# ── Public ───────────────────────────────────────────────');
734
+ if (options.framework === 'astro-split') {
735
+ // Public apex is Astro (default :4321). Admin/API stay on the Next CMS host
736
+ // (:3000). Pointing NEXT_PUBLIC_SITE_URL at the CMS host makes View live open
737
+ // the wrong site — Admin → Settings → General can override without redeploy.
738
+ lines.push('NEXT_PUBLIC_SITE_URL="http://localhost:4321"');
739
+ lines.push('# PUBLIC apex (Astro site/), NOT the CMS host. Production: your marketing domain.');
740
+ lines.push('# Mirrored as prenta.config.ts `seo.siteUrl`. Admin → Settings → General overrides.');
741
+ }
742
+ else {
743
+ lines.push('NEXT_PUBLIC_SITE_URL="http://localhost:3000"');
744
+ lines.push('# Canonical origin for sitemap, OG tags, and JSON-LD. Must match your');
745
+ lines.push('# production domain (no trailing slash). Also mirrored in prenta.config.ts');
746
+ lines.push('# `seo.siteUrl` — Admin → Settings → General can override without redeploying.');
747
+ }
748
+ if (options.plugins.includes('consent')) {
749
+ lines.push('');
750
+ lines.push('# ── Consent & analytics (plugin-consent) ─────────────────');
751
+ lines.push('# Loaded only after the visitor clicks Accept all on the cookie banner.');
752
+ lines.push('NEXT_PUBLIC_GA4_MEASUREMENT_ID=""');
753
+ lines.push('NEXT_PUBLIC_GOOGLE_ADS_ID=""');
754
+ lines.push('NEXT_PUBLIC_META_PIXEL_ID=""');
755
+ }
756
+ lines.push('');
757
+ lines.push('# ── First-Run Admin (Optional) ───────────────────────────');
758
+ lines.push('# If set, auto-creates the initial admin user on first boot.');
759
+ lines.push('# If not set, visit /admin to create via the setup wizard.');
760
+ lines.push('# CMS_ADMIN_EMAIL=""');
761
+ lines.push('# CMS_ADMIN_PASSWORD=""');
762
+ // OAuth env names must match what cms-core reads at runtime:
763
+ // `OAUTH_<PROVIDER>_CLIENT_ID` / `OAUTH_<PROVIDER>_CLIENT_SECRET`
764
+ // (see handlers.ts `/auth/oauth/:provider`). Bare GOOGLE_*/GITHUB_* names
765
+ // are ignored by the server.
766
+ if (options.authProviders.includes('google')) {
767
+ lines.push('', '# ── Google OAuth ─────────────────────────────────────────');
768
+ lines.push('OAUTH_GOOGLE_CLIENT_ID=""');
769
+ lines.push('OAUTH_GOOGLE_CLIENT_SECRET=""');
770
+ }
771
+ if (options.authProviders.includes('github')) {
772
+ lines.push('', '# ── GitHub OAuth ─────────────────────────────────────────');
773
+ lines.push('OAUTH_GITHUB_CLIENT_ID=""');
774
+ lines.push('OAUTH_GITHUB_CLIENT_SECRET=""');
775
+ }
776
+ if (options.authProviders.includes('microsoft')) {
777
+ lines.push('', '# ── Microsoft OAuth ──────────────────────────────────────');
778
+ lines.push('OAUTH_MICROSOFT_CLIENT_ID=""');
779
+ lines.push('OAUTH_MICROSOFT_CLIENT_SECRET=""');
780
+ }
781
+ if (options.platform === 'vercel') {
782
+ lines.push('', '# ── Rate Limiting (Optional, recommended for production) ──');
783
+ lines.push('# Adding Upstash from the Vercel Marketplace auto-provisions');
784
+ lines.push('# KV_REST_API_URL / KV_REST_API_TOKEN — the rate limiter accepts');
785
+ lines.push('# those automatically, so you usually do not set anything here.');
786
+ lines.push('# Set these only when pointing at an Upstash store directly:');
787
+ lines.push('# UPSTASH_REDIS_REST_URL=""');
788
+ lines.push('# UPSTASH_REDIS_REST_TOKEN=""');
789
+ }
790
+ if (options.plugins.includes('forms')) {
791
+ lines.push('', '# ── Forms & CRM (plugin-forms) ───────────────────────────');
792
+ lines.push('# After `npm run populate`, open Admin → Forms → [form] → Integrations');
793
+ lines.push('# and paste your Zapier Catch Hook URL (Webhooks by Zapier → Catch Hook).');
794
+ lines.push('# Email notifications use RESEND_API_KEY / SMTP when the email plugin is enabled.');
795
+ }
796
+ if (options.plugins.includes('email')) {
797
+ lines.push('', '# ── Email (Optional) ─────────────────────────────────────');
798
+ lines.push('# Choose a provider with EMAIL_PROVIDER: resend | ses | smtpcom | smtp.');
799
+ lines.push('# When omitted it is inferred: SMTP_HOST → smtp, else RESEND_API_KEY → resend.');
800
+ lines.push('# EMAIL_PROVIDER=""');
801
+ lines.push('EMAIL_FROM="noreply@example.com"');
802
+ lines.push('#');
803
+ lines.push('# Resend (HTTP API):');
804
+ lines.push('RESEND_API_KEY=""');
805
+ lines.push('#');
806
+ lines.push('# Generic SMTP (any mail server; run `npm i nodemailer`):');
807
+ lines.push('# EMAIL_PROVIDER="smtp"');
808
+ lines.push('# SMTP_HOST=""');
809
+ lines.push('# SMTP_PORT="587" # 587 = STARTTLS, 465 = implicit TLS');
810
+ lines.push('# SMTP_SECURE="false" # "true" forces implicit TLS');
811
+ lines.push('# SMTP_USER=""');
812
+ lines.push('# SMTP_PASS=""');
813
+ }
814
+ if (options.plugins.includes('commerce')) {
815
+ lines.push('', '# ── Stripe ───────────────────────────────────────────────');
816
+ lines.push('STRIPE_SECRET_KEY=""');
817
+ lines.push('STRIPE_WEBHOOK_SECRET=""');
818
+ }
819
+ if (options.plugins.includes('ai')) {
820
+ lines.push('', '# ── AI Provider ──────────────────────────────────────────');
821
+ lines.push('ANTHROPIC_API_KEY=""');
822
+ lines.push('# OPENAI_API_KEY=""');
823
+ }
824
+ if (options.plugins.includes('media')) {
825
+ lines.push('', '# ── Media Storage ────────────────────────────────────────');
826
+ if (options.platform === 'vercel') {
827
+ lines.push('BLOB_READ_WRITE_TOKEN=""');
828
+ }
829
+ else {
830
+ // Auto-wired via `storage: { provider: 's3' }` → @prenta/platform-aws.
831
+ // In production, prefer an IAM role (ECS task role / EC2 instance profile) and
832
+ // leave the key/secret unset — the AWS SDK resolves credentials automatically.
833
+ lines.push('AWS_S3_BUCKET=""');
834
+ lines.push('AWS_REGION=""');
835
+ lines.push('# Static keys for local dev only; omit when running under an IAM role:');
836
+ lines.push('# AWS_ACCESS_KEY_ID=""');
837
+ lines.push('# AWS_SECRET_ACCESS_KEY=""');
838
+ lines.push('# Optional CloudFront/CDN base for public object URLs:');
839
+ lines.push('# AWS_S3_PUBLIC_URL=""');
840
+ }
841
+ }
842
+ lines.push('', '# ── CAPTCHA (Optional) ───────────────────────────────────');
843
+ lines.push('# Google reCAPTCHA v3 — get keys at https://www.google.com/recaptcha/admin');
844
+ lines.push('# RECAPTCHA_SITE_KEY=""');
845
+ lines.push('# RECAPTCHA_SECRET_KEY=""');
846
+ lines.push('# RECAPTCHA_SCORE_THRESHOLD="0.5"');
847
+ lines.push('#');
848
+ lines.push('# OR Cloudflare Turnstile — get keys at https://dash.cloudflare.com/turnstile');
849
+ lines.push('# TURNSTILE_SITE_KEY=""');
850
+ lines.push('# TURNSTILE_SECRET_KEY=""');
851
+ lines.push('');
852
+ return lines.join('\n');
853
+ }
854
+ function buildVercelJson(options) {
855
+ const config = {
856
+ buildCommand: 'prisma generate && node scripts/deploy-migrate.mjs && next build',
857
+ framework: 'nextjs',
858
+ functions: {
859
+ 'app/api/cms/**/*.ts': {
860
+ maxDuration: 30,
861
+ memory: 1024,
862
+ },
863
+ },
864
+ };
865
+ const crons = [];
866
+ crons.push({
867
+ path: '/api/cms/cron/publish',
868
+ schedule: '*/5 * * * *',
869
+ });
870
+ crons.push({
871
+ path: '/api/cms/cron/cleanup',
872
+ schedule: '0 3 * * *',
873
+ });
874
+ if (options.plugins.includes('seo')) {
875
+ crons.push({
876
+ path: '/api/cms/cron/seo-scan',
877
+ schedule: '0 4 * * 1',
878
+ });
879
+ }
880
+ // Nightly content-health scan — populates the dashboard "Content
881
+ // Health" tile (missing meta descriptions, alt text, outdated
882
+ // content, broken internal links). Scheduled at 02:30 UTC so it
883
+ // doesn't collide with the 03:00 cleanup or 04:00 SEO scan.
884
+ crons.push({
885
+ path: '/api/cms/cron/content-health',
886
+ schedule: '30 2 * * *',
887
+ });
888
+ config.crons = crons;
889
+ return JSON.stringify(config, null, 2) + '\n';
890
+ }
891
+ function databaseLabel(database) {
892
+ if (database === 'prisma-postgres')
893
+ return 'Prisma Postgres';
894
+ if (database === 'neon')
895
+ return 'Neon';
896
+ if (database === 'supabase')
897
+ return 'Supabase';
898
+ return 'PostgreSQL';
899
+ }
900
+ function marketplaceDatabaseLabel(database) {
901
+ if (database === 'neon')
902
+ return 'Neon Postgres';
903
+ // Prisma Postgres is the default Marketplace recommendation; Supabase and
904
+ // custom URLs are provisioned outside the Marketplace, so still point the
905
+ // reader at the recommended integration.
906
+ return 'Prisma Postgres';
907
+ }
908
+ function buildDeploymentGuide(options) {
909
+ const lines = [
910
+ '# Deployment Guide',
911
+ '',
912
+ '## Prerequisites',
913
+ '',
914
+ // Astro 7 (apps/site) requires Node 22.12+, so the workspace holds to it.
915
+ isAstroSplit(options) ? '- Node.js >= 22.12' : '- Node.js >= 20',
916
+ `- A ${databaseLabel(options.database)} database`,
917
+ options.platform === 'vercel'
918
+ ? '- A Vercel account'
919
+ : '- An AWS account with appropriate IAM permissions',
920
+ '',
921
+ ];
922
+ if (isAstroSplit(options)) {
923
+ lines.push('## Monorepo layout (astro-split)', '', 'This repo is a **pnpm workspace**: `apps/cms` (Next CMS host) and', '`apps/site` (Astro public site)' +
924
+ (isMarketingCustom(options) ? ', plus `packages/sections` (the shared section kit).' : '.'), '', 'The steps below apply to the **CMS host** — create that Vercel project', 'with **Root Directory = `apps/cms`**. Deploy `apps/site` as a second', 'project (Root Directory = `apps/site`); see `ASTRO.md` for its routing,', 'env vars, and the purge-on-publish webhook.', '', 'Run the `npm run …` commands below from `apps/cms`, or use the pnpm', 'aliases at the workspace root (`pnpm db:seed`, `pnpm doctor`, …).', '');
925
+ }
926
+ if (options.platform === 'vercel') {
927
+ lines.push('## Vercel Deployment', '', '### 1. Link your project', '', '```bash', 'npx vercel link', '```', '', '### 2. Provision infrastructure', '', 'Via the Vercel dashboard or CLI:', '', `- **Database**: Add ${marketplaceDatabaseLabel(options.database)} from the Vercel Marketplace`, ' - This auto-provisions `DATABASE_URL` and `DIRECT_DATABASE_URL`', '- **Blob Storage**: Add Vercel Blob from the dashboard', ' - This auto-provisions `BLOB_READ_WRITE_TOKEN`', '- **Rate Limiting**: Add Upstash Redis from the Vercel Marketplace', ' - This auto-provisions `KV_REST_API_URL` / `KV_REST_API_TOKEN`, which the', ' rate limiter reads automatically (the native `UPSTASH_REDIS_REST_URL` /', ' `UPSTASH_REDIS_REST_TOKEN` names also work if you set them directly)', '', '### 3. Pull auto-provisioned env vars', '', '```bash', 'npx vercel env pull .env.local', '```', '', '### 4. Set remaining environment variables', '', 'In the Vercel dashboard (Settings > Environment Variables), add:', '', '```', 'CMS_SECRET=<min-32-char-random-string>', 'CMS_ENCRYPTION_KEY=<64-char-hex-string>', 'NEXT_PUBLIC_SITE_URL=https://your-domain.com', '```', '', 'Generate secrets with:', '', '```bash', "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", '```', '', '### 5. Deploy', '', '```bash', 'git push # Vercel auto-deploys on push', '```', '', 'The build command runs migrations automatically before building, but only on', '**production** deploys (`scripts/deploy-migrate.mjs` gates `prisma migrate deploy`', 'on `VERCEL_ENV=production`). Preview deploys skip migrations so an unmerged PR', "can't mutate your production database — set `ALLOW_MIGRATE_DEPLOY=true` in a", 'preview environment only if it has its own isolated database.', '', '### 6. Verify deployment readiness', '', '```bash', 'npm run doctor', 'npm run deploy:check', '```', '', 'These commands check required env vars, Prisma models, and deployment metadata', 'before you rely on the admin UI.', '', '### 7. Configure SEO & verify public routes', '', '1. Set **Admin → Settings → General** site URL to match `NEXT_PUBLIC_SITE_URL`.', '2. Open **Admin → SEO** and confirm:', ' - Default OG image (upload 1200×630 to Media or set `public/og-default.png`)', ' - Organization name, logo URL, and social profiles (`sameAs`)', ' - AI crawler policy under Technical → Robots (GPTBot, ClaudeBot, etc.)', '3. Hit these URLs on your deployed domain (scaffold proxies cms-core):', ' - `/sitemap.xml`', ' - `/robots.txt`', ' - `/llms.txt`', '4. Work through the **Prototype → Production** checklist in the Prenta docs', ' before go-live (forms, consent, performance, accessibility, launch QA).', '', '### 8. Seed first-run content', '', '```bash', 'CMS_ADMIN_EMAIL=admin@example.com CMS_ADMIN_PASSWORD=change-me npm run db:seed -- --demo', '```', '', ...(isMarketingCustom(options)
928
+ ? [
929
+ 'This project includes a pre-filled `prenta.seed.json` ("Marketing homepage"',
930
+ 'with hero, stats, pricing, and CTA sections). Load it with:',
931
+ '',
932
+ '```bash',
933
+ 'npm run populate',
934
+ '```',
935
+ '',
936
+ ]
937
+ : []), 'For real projects, create `prenta.seed.json` or `prenta.seed.ts` with pages,', 'globals, forms, SEO fields, media references, and saved sections, then run', '`npm run populate`. Script tags are **not** part of the seed envelope —', 'bootstrap them in Admin → Script Tags or a custom insert-if-missing seed script', '(see Prenta docs: Script Tags / populate guidance).', '', '### 9. Create your admin account', '', `Visit \`https://your-domain.com${options.loginPath}\` — the setup wizard will`, 'guide you through creating the initial admin account.', '', ...(isSplitLogin(options)
938
+ ? [
939
+ `Sign-in happens at \`${options.loginPath}\`; the admin app itself lives at`,
940
+ `\`${options.adminPath}\`. Signed-out visits to \`${options.adminPath}\` show a 404 so the`,
941
+ 'admin is not advertised at a well-known path.',
942
+ '',
943
+ ]
944
+ : []), 'Alternatively, set `CMS_ADMIN_EMAIL` and `CMS_ADMIN_PASSWORD` env vars', 'before deploying to auto-create the admin account. If you did this (or ran', 'the seed in step 7 with those vars set), the admin already exists and the', 'setup wizard will not appear — sign in with those credentials instead.', '', '### 10. Verify after deploy', '', '```bash', 'npm run verify', '```', '', '---', '', '## Continuous integration (migration gate)', '', 'This project ships a `.github/workflows/ci.yml` that runs on every pull', 'request. It stands up a throwaway Postgres container and runs', '`prisma migrate deploy` against it, so a broken or out-of-order migration', 'fails the PR in review instead of breaking your production build (whose', 'build applies migrations only on production deploys — see above).', '', 'It is **provider-agnostic** — the container is independent of where', 'production runs (Prisma Postgres, Neon, Supabase, or self-hosted) and needs', 'no secrets. The workflow points `DATABASE_URL` / `DIRECT_DATABASE_URL` at the', 'CI container only; it never touches a real environment.', '', '### Dependency updates (Renovate)', '', 'This project ships a `renovate.json` preset that groups every', '`@prenta/*` upgrade into a single PR. Enable Renovate (the GitHub', 'app, or self-hosted) on the repo and it will open a grouped "Prenta CMS"', 'PR on a weekly schedule.', '', '- **Minor/patch** Prenta bumps **auto-merge once CI passes** — including the', ' migration gate above — so routine security/patch updates land hands-free.', '- **Major** bumps are never auto-merged (they can carry breaking schema/API', ' changes); they get a `breaking-change` label for manual review.', '', 'For auto-merge to actually wait on CI, add a **branch protection rule** on', '`main` that requires the `CI` status check. Without it, Renovate may merge a', 'green-but-unchecked PR.', '', 'Running several Prenta sites? "Patch the fleet" is just merging each', "repo's grouped Renovate PR — every one independently CI-gated. See ADR 0003", 'in the Prenta repo for the full multi-site model.', '', '---', '', '## Preview Deployments', '', 'Every PR creates a preview deployment with its own URL. Preview deployments', 'share the production database by default. To use a separate preview database:', '', '1. Provision a second database (e.g. a separate Prisma Postgres database)', '2. Set `DATABASE_URL` and `DIRECT_DATABASE_URL` for the Preview environment', ' in Vercel dashboard > Settings > Environment Variables', '', '---', '', '## Cron Jobs', '', 'The following cron jobs are configured in `vercel.json`:', '', '| Schedule | Endpoint | Purpose |', '|----------|----------|---------|', '| Every 5 min | `/api/cms/cron/publish` | Process scheduled publish/unpublish |', '| Daily 3 AM | `/api/cms/cron/cleanup` | Clean expired locks, sessions, soft-deleted content |');
945
+ if (options.plugins.includes('seo')) {
946
+ lines.push('| Weekly Mon 4 AM | `/api/cms/cron/seo-scan` | Run automated SEO audit |');
947
+ }
948
+ lines.push('| Nightly 2:30 AM | `/api/cms/cron/content-health` | Scan content for missing meta, alt text, broken links, outdated docs |');
949
+ lines.push('', 'These require Vercel Pro or Enterprise plan for cron intervals < 1 day.', '');
950
+ }
951
+ else {
952
+ lines.push('## AWS Deployment', '', 'Deploy the Next.js standalone output on ECS/Fargate (recommended) or Lambda', '(OpenNext/SST), backed by RDS PostgreSQL and S3 + CloudFront for media. See the', '`@prenta/platform-aws` README for the full service mapping, IAM policy,', 'and CloudFront/OAC setup.', '', "Media storage is auto-wired from `storage: { provider: 's3' }` in", '`prenta.config.ts`. Set `AWS_S3_BUCKET` + `AWS_REGION` (see `.env.example`) and', 'grant the task/instance IAM role `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`', 'on the bucket — no static keys needed in production.', '', 'Scheduled jobs run via EventBridge Scheduler, one schedule per endpoint', '(targeting API Gateway/Lambda or the ALB). `generateEventBridgeConfig()` from', '`@prenta/platform-aws` emits the `cron(...)` expressions:', '', '| Schedule | Endpoint | Purpose |', '|----------|----------|---------|', '| Every 5 min | `/api/cms/cron/publish` | Process scheduled publish/unpublish |', '| Daily 3 AM | `/api/cms/cron/cleanup` | Clean expired locks, sessions, soft-deleted content |');
953
+ if (options.plugins.includes('seo')) {
954
+ lines.push('| Weekly Mon 4 AM | `/api/cms/cron/seo-scan` | Run automated SEO audit |');
955
+ }
956
+ lines.push('| Nightly 2:30 AM | `/api/cms/cron/content-health` | Scan content for missing meta, alt text, broken links, outdated docs |', '');
957
+ }
958
+ lines.push('## Updating Prenta CMS', '', '1. Check for updates in the admin panel (Settings > Updates)', '2. Or run: `npm outdated @prenta/core`', '3. Upgrade (pre-1.0 minors require an explicit bump — `npm update` alone is not enough):', ' `npx prenta upgrade --dry-run` then `npx prenta upgrade && npm install`', '4. Sync schema + migrations: `npx prenta db:sync && npx prisma migrate deploy`', '5. Deploy: `git push`', '', '## Troubleshooting', '', '### Build fails with "migration failed"', '', 'Ensure `DIRECT_DATABASE_URL` is set and points to a non-pooled connection.', 'Pooled connections (PgBouncer/Neon pooler) cannot run DDL statements.', '', '### "No users found" after deploy', '', `Visit \`${options.loginPath}\` to trigger the setup wizard, or set`, '`CMS_ADMIN_EMAIL` and `CMS_ADMIN_PASSWORD` environment variables.', '');
959
+ return lines.join('\n');
960
+ }
961
+ function buildNextConfig(options, sectionsPackage) {
962
+ // Allow next/image to optimize media served from the project's storage
963
+ // backend (CMS images carry absolute URLs from the upload provider).
964
+ const remoteHost = options.platform === 'aws' ? '*.amazonaws.com' : '*.public.blob.vercel-storage.com';
965
+ const transpileBlock = sectionsPackage
966
+ ? `
967
+ // The shared section kit ships raw TypeScript source (one map for the admin
968
+ // canvas here and the Astro public site) — have Next compile it.
969
+ transpilePackages: ['${sectionsPackage}'],`
970
+ : '';
971
+ return `import type { NextConfig } from 'next';
972
+ import { withPrentaCMS } from '@prenta/core/next';
973
+ import config from './prenta.config';
974
+
975
+ const nextConfig: NextConfig = {${transpileBlock}
976
+ images: {
977
+ // Serve AVIF first (≈50% smaller than JPEG), then WebP, for the best LCP.
978
+ // next/image negotiates the format per request based on Accept headers.
979
+ formats: ['image/avif', 'image/webp'],
980
+ // Storage backend for CMS media. Add your own CDN/host here as needed.
981
+ remotePatterns: [{ protocol: 'https', hostname: '${remoteHost}' }],
982
+ },
983
+ };
984
+
985
+ export default withPrentaCMS(config, nextConfig);
986
+ `;
987
+ }
988
+ function buildPrismaConfig() {
989
+ return `import path from 'node:path';
990
+ import { defineConfig } from 'prisma/config';
991
+
992
+ // Migrations (DDL) should run over a NON-pooled connection. Neon/Supabase
993
+ // poolers frequently break schema changes, producing opaque "migration failed"
994
+ // errors. Prefer DIRECT_DATABASE_URL; fall back to DATABASE_URL but warn loudly
995
+ // so the failure mode isn't silent.
996
+ const migrationUrl = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? '';
997
+ if (process.env.DATABASE_URL && !process.env.DIRECT_DATABASE_URL) {
998
+ console.warn(
999
+ '[prenta] DIRECT_DATABASE_URL is not set — running migrations against DATABASE_URL. ' +
1000
+ 'If that is a pooled connection (e.g. a Neon/Supabase pooler), migrations may fail; ' +
1001
+ 'set DIRECT_DATABASE_URL to the non-pooled connection string.',
1002
+ );
1003
+ }
1004
+
1005
+ export default defineConfig({
1006
+ schema: path.join(__dirname, 'prisma', 'schema.prisma'),
1007
+ migrations: {
1008
+ path: path.join(__dirname, 'prisma', 'migrations'),
1009
+ },
1010
+ datasource: {
1011
+ url: migrationUrl,
1012
+ },
1013
+ });
1014
+ `;
1015
+ }
1016
+ function generateCmsHelper() {
1017
+ return `import { createNextSiteHelpers } from '@prenta/core/next/site';
1018
+
1019
+ const site = createNextSiteHelpers({
1020
+ baseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
1021
+ revalidate: 300,
1022
+ });
1023
+
1024
+ export const {
1025
+ getGlobal,
1026
+ resolveDocument,
1027
+ getNavigation,
1028
+ getPublicForm,
1029
+ getCachedPublishedDocument,
1030
+ getCachedPageMetadata,
1031
+ } = site;
1032
+ `;
1033
+ }
1034
+ function generatePrismaHelper() {
1035
+ return `import { PrismaPg } from '@prisma/adapter-pg';
1036
+ import pg from 'pg';
1037
+ import { PrismaClient } from '../generated/prisma/client';
1038
+
1039
+ const globalForPrisma = globalThis as unknown as { prisma?: InstanceType<typeof PrismaClient> };
1040
+
1041
+ function makePrisma() {
1042
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
1043
+ const adapter = new PrismaPg(pool);
1044
+ return new PrismaClient({ adapter } as any);
1045
+ }
1046
+
1047
+ export function getPrisma() {
1048
+ if (!globalForPrisma.prisma) {
1049
+ globalForPrisma.prisma = makePrisma();
1050
+ }
1051
+ return globalForPrisma.prisma;
1052
+ }
1053
+ `;
1054
+ }
1055
+ function generateSiteSettingsHelper() {
1056
+ return `import { getGlobal } from './cms';
1057
+
1058
+ export interface SiteSettings {
1059
+ siteName?: string;
1060
+ description?: string;
1061
+ phone?: string;
1062
+ email?: string;
1063
+ [key: string]: unknown;
1064
+ }
1065
+
1066
+ export async function getSiteSettings(): Promise<SiteSettings | null> {
1067
+ try {
1068
+ return await getGlobal<SiteSettings>('site-settings');
1069
+ } catch {
1070
+ return null;
1071
+ }
1072
+ }
1073
+ `;
1074
+ }
1075
+ function generateResolveMediaHelper() {
1076
+ return `export {
1077
+ normalizeMedia,
1078
+ resolveMedia,
1079
+ } from '@prenta/core/site';
1080
+
1081
+ export type {
1082
+ MediaInput,
1083
+ ResolvedMedia,
1084
+ ResolveMediaOptions,
1085
+ } from '@prenta/core/site';
1086
+ `;
1087
+ }
1088
+ function buildTsConfig() {
1089
+ return (JSON.stringify({
1090
+ compilerOptions: {
1091
+ target: 'ES2022',
1092
+ lib: ['dom', 'dom.iterable', 'esnext'],
1093
+ allowJs: true,
1094
+ skipLibCheck: true,
1095
+ strict: true,
1096
+ noEmit: true,
1097
+ esModuleInterop: true,
1098
+ module: 'esnext',
1099
+ moduleResolution: 'bundler',
1100
+ resolveJsonModule: true,
1101
+ isolatedModules: true,
1102
+ jsx: 'preserve',
1103
+ incremental: true,
1104
+ plugins: [{ name: 'next' }],
1105
+ paths: { '@/*': ['./*'] },
1106
+ },
1107
+ include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
1108
+ exclude: ['node_modules'],
1109
+ }, null, 2) + '\n');
1110
+ }
1111
+ function buildNpmrc() {
1112
+ // pnpm's default isolated node_modules layout does not flatten the transitive
1113
+ // dependencies that some published @prenta/* packages (and their
1114
+ // Radix/TipTap deps) rely on being resolvable from their own package
1115
+ // directory. Under Next.js + Turbopack that surfaces as "Module not found"
1116
+ // for packages like @radix-ui/react-use-controllable-state during `next build`.
1117
+ // A hoisted linker gives a flat, npm-like layout so those resolve cleanly.
1118
+ return `# Flatten node_modules so bundlers can resolve the published packages'
1119
+ # transitive deps (Radix/TipTap internals) during \`next build\`.
1120
+ node-linker=hoisted
1121
+ `;
1122
+ }
1123
+ function generateDeployMigrateScript() {
1124
+ // Emitted to scripts/deploy-migrate.mjs and invoked from the build command.
1125
+ // No template placeholders / backticks inside so it survives this template.
1126
+ return `// Applies pending Prisma migrations, but ONLY on Vercel *production* builds.
1127
+ //
1128
+ // Vercel preview deployments (from PRs) share the production database by
1129
+ // default, so running \`prisma migrate deploy\` on every build would apply an
1130
+ // unmerged branch's migrations to production before review. This guard runs
1131
+ // migrations only when VERCEL_ENV=production, or when you explicitly opt in
1132
+ // with ALLOW_MIGRATE_DEPLOY=true (e.g. against an isolated preview database).
1133
+ // Locally (no VERCEL_ENV) it skips — use \`pnpm db:migrate\` for local changes.
1134
+ import { execSync } from 'node:child_process'
1135
+
1136
+ const vercelEnv = process.env.VERCEL_ENV || 'unset'
1137
+ const force = process.env.ALLOW_MIGRATE_DEPLOY === 'true'
1138
+
1139
+ if (vercelEnv === 'production' || force) {
1140
+ console.log('[deploy-migrate] applying migrations (VERCEL_ENV=' + vercelEnv + (force ? ', forced' : '') + ')')
1141
+ execSync('prisma migrate deploy', { stdio: 'inherit' })
1142
+ } else {
1143
+ console.log(
1144
+ '[deploy-migrate] skipping prisma migrate deploy (VERCEL_ENV=' + vercelEnv + '). ' +
1145
+ 'Set ALLOW_MIGRATE_DEPLOY=true to run it against an isolated preview database.',
1146
+ )
1147
+ }
1148
+ `;
1149
+ }
1150
+ function buildGitignore(options) {
1151
+ const base = `node_modules/
1152
+ .next/
1153
+ dist/
1154
+ .env
1155
+ .env.local
1156
+ .env.production.local
1157
+ .turbo
1158
+ .vercel
1159
+ *.tsbuildinfo
1160
+
1161
+ # Local MCP config — contains a real API key once you fill it in.
1162
+ .cursor/mcp.json
1163
+ .vscode/mcp.json
1164
+ `;
1165
+ const extras = [];
1166
+ if (isAstroSplit(options)) {
1167
+ // Astro build artifacts in apps/site (node_modules/ and dist/ are already
1168
+ // covered at any depth by the base patterns above).
1169
+ extras.push('.astro/');
1170
+ }
1171
+ if (isMarketingCustom(options) && MARKETING_CUSTOM_GITIGNORE.length > 0) {
1172
+ extras.push('# Throwaway manifest written by `npm run blocks:diff`.');
1173
+ extras.push(...MARKETING_CUSTOM_GITIGNORE);
1174
+ }
1175
+ if (extras.length === 0)
1176
+ return base;
1177
+ return `${base}\n${extras.join('\n')}\n`;
1178
+ }
1179
+ async function mkdirSafe(dirPath) {
1180
+ await mkdir(dirPath, { recursive: true });
1181
+ }
1182
+ /**
1183
+ * Scaffold only the Astro public site (no Next CMS host). Used by
1184
+ * `--framework astro-site` and `create-prenta-astro` when attaching to an
1185
+ * existing Prenta CMS deployment.
1186
+ */
1187
+ async function scaffoldAstroSiteOnly(options, root) {
1188
+ const spinner = ora('Creating Astro public site...').start();
1189
+ try {
1190
+ await mkdirSafe(root);
1191
+ const siteFiles = astroSiteFiles({
1192
+ cmsUrlExample: 'https://cms.example.com',
1193
+ });
1194
+ await Promise.all(Object.entries(siteFiles).map(async ([relPath, contents]) => {
1195
+ const dest = path.join(root, ...relPath.split('/'));
1196
+ await mkdirSafe(path.dirname(dest));
1197
+ await writeFile(dest, contents);
1198
+ }));
1199
+ // Prefer a project-named package.json when scaffolding into a named folder.
1200
+ if (options.projectName !== '.') {
1201
+ try {
1202
+ const pkgPath = path.join(root, 'package.json');
1203
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
1204
+ pkg.name = options.projectName;
1205
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
1206
+ }
1207
+ catch {
1208
+ // package.json is always written by astroSiteFiles; ignore rename failures.
1209
+ }
1210
+ }
1211
+ spinner.succeed('Astro public site created!');
1212
+ console.log();
1213
+ console.log(chalk.green(' Prenta Astro site is ready.'));
1214
+ console.log();
1215
+ console.log(' Next steps:');
1216
+ console.log();
1217
+ if (options.projectName !== '.') {
1218
+ console.log(chalk.cyan(` cd ${options.projectName}`));
1219
+ }
1220
+ console.log(chalk.cyan(' cp .env.example .env'));
1221
+ console.log(chalk.dim(' # Set PRENTA_CMS_URL to your existing CMS host origin'));
1222
+ console.log(chalk.cyan(' npm install && npm run dev'));
1223
+ console.log();
1224
+ console.log(chalk.dim(' Preview cookies need same-origin /api/cms — see README.'));
1225
+ console.log(chalk.dim(' Docs: https://github.com/actuate-media/prenta/blob/main/apps/docs/content/astro-public-site.md'));
1226
+ console.log();
1227
+ }
1228
+ catch (err) {
1229
+ spinner.fail('Failed to create Astro site');
1230
+ throw err;
1231
+ }
1232
+ }
1233
+ export async function scaffoldProject(rawOptions) {
1234
+ // Older programmatic callers may omit `loginPath`; treat that as the classic
1235
+ // inline login (auth screens on the admin mount).
1236
+ const options = {
1237
+ ...rawOptions,
1238
+ loginPath: rawOptions.loginPath ?? rawOptions.adminPath,
1239
+ };
1240
+ const root = path.resolve(process.cwd(), options.projectName);
1241
+ if (isAstroSiteOnly(options)) {
1242
+ await scaffoldAstroSiteOnly(options, root);
1243
+ return;
1244
+ }
1245
+ const spinner = ora('Resolving latest package versions...').start();
1246
+ try {
1247
+ const versions = await resolvePackageVersions();
1248
+ const resolvedCount = Object.keys(versions).length;
1249
+ if (resolvedCount > 0) {
1250
+ spinner.text = `Resolved ${resolvedCount} package versions from npm`;
1251
+ }
1252
+ else {
1253
+ spinner.text = 'Using default versions (npm registry unreachable)';
1254
+ }
1255
+ const adminDir = options.adminPath.slice(1);
1256
+ const splitLogin = isSplitLogin(options);
1257
+ const loginDir = options.loginPath.slice(1);
1258
+ spinner.text = 'Creating project structure...';
1259
+ const astroSplit = isAstroSplit(options);
1260
+ // astro-split scaffolds are pnpm workspaces: the Next CMS host lives in
1261
+ // apps/cms, the Astro public site in apps/site, and (marketing-custom) the
1262
+ // shared section kit in packages/sections. Flat scaffolds keep the Next
1263
+ // app at the project root (cmsRoot === root).
1264
+ const cmsRoot = astroSplit ? path.join(root, 'apps', 'cms') : root;
1265
+ const sectionsPackage = astroSplit && isMarketingCustom(options)
1266
+ ? `@${workspaceScopeName(options, root)}/sections`
1267
+ : undefined;
1268
+ await mkdirSafe(root);
1269
+ await mkdirSafe(path.join(cmsRoot, 'app', '(admin)', adminDir, '[[...path]]'));
1270
+ if (splitLogin) {
1271
+ await mkdirSafe(path.join(cmsRoot, 'app', '(admin)', loginDir, '[[...path]]'));
1272
+ }
1273
+ await mkdirSafe(path.join(cmsRoot, 'app', 'api', 'cms', '[[...cms]]'));
1274
+ if (!astroSplit) {
1275
+ await mkdirSafe(path.join(cmsRoot, 'app', '(site)', '[[...path]]'));
1276
+ await mkdirSafe(path.join(cmsRoot, 'app', 'sitemap.xml'));
1277
+ await mkdirSafe(path.join(cmsRoot, 'app', 'robots.txt'));
1278
+ await mkdirSafe(path.join(cmsRoot, 'app', 'feed.xml'));
1279
+ await mkdirSafe(path.join(cmsRoot, 'app', 'feed.json'));
1280
+ await mkdirSafe(path.join(cmsRoot, 'app', 'llms.txt'));
1281
+ await mkdirSafe(path.join(cmsRoot, 'app', 'llms-full.txt'));
1282
+ }
1283
+ await mkdirSafe(path.join(cmsRoot, 'prisma'));
1284
+ await mkdirSafe(path.join(cmsRoot, 'public'));
1285
+ await mkdirSafe(path.join(cmsRoot, 'public', 'fonts'));
1286
+ await mkdirSafe(path.join(cmsRoot, 'lib'));
1287
+ await mkdirSafe(path.join(cmsRoot, 'scripts'));
1288
+ await mkdirSafe(path.join(root, '.cursor'));
1289
+ await mkdirSafe(path.join(root, '.vscode'));
1290
+ await mkdirSafe(path.join(root, '.github', 'workflows'));
1291
+ if (astroSplit) {
1292
+ await mkdirSafe(path.join(root, 'apps', 'site'));
1293
+ }
1294
+ spinner.text = 'Writing configuration files...';
1295
+ // Read the canonical schema once up front so the parallel write batch below
1296
+ // stays synchronous; migrations are copied as a tree after the batch.
1297
+ const prismaSchema = await readFile(path.join(PRISMA_ASSETS_DIR, 'schema.prisma'), 'utf-8');
1298
+ const writes = [
1299
+ // CMS app manifest (apps/cms in a workspace; project root otherwise).
1300
+ writeFile(path.join(cmsRoot, 'package.json'), buildPackageJson(options, versions)),
1301
+ // Workspace plumbing lives at the repo root.
1302
+ ...(astroSplit
1303
+ ? [
1304
+ writeFile(path.join(root, 'package.json'), buildWorkspaceRootPackageJson(options, root, sectionsPackage)),
1305
+ writeFile(path.join(root, 'pnpm-workspace.yaml'), buildPnpmWorkspaceYaml()),
1306
+ ]
1307
+ : []),
1308
+ writeFile(path.join(root, '.npmrc'), buildNpmrc()),
1309
+ writeFile(path.join(cmsRoot, 'scripts', 'deploy-migrate.mjs'), generateDeployMigrateScript()),
1310
+ writeFile(path.join(cmsRoot, '.env.example'), buildEnvExample(options)),
1311
+ writeFile(path.join(cmsRoot, 'next.config.ts'), buildNextConfig(options, sectionsPackage)),
1312
+ writeFile(path.join(cmsRoot, 'prisma.config.ts'), buildPrismaConfig()),
1313
+ writeFile(path.join(cmsRoot, 'tsconfig.json'), buildTsConfig()),
1314
+ writeFile(path.join(root, '.gitignore'), buildGitignore(options)),
1315
+ writeFile(path.join(root, '.github', 'workflows', 'ci.yml'), generateCiWorkflow(options)),
1316
+ writeFile(path.join(root, 'renovate.json'), generateRenovateJson()),
1317
+ writeFile(path.join(root, 'DEPLOYMENT.md'), buildDeploymentGuide(options)),
1318
+ writeFile(path.join(cmsRoot, 'public', 'fonts', 'README.md'), generateFontsReadme()),
1319
+ writeFile(path.join(cmsRoot, 'prisma', 'schema.prisma'), prismaSchema),
1320
+ writeFile(path.join(cmsRoot, 'prenta.config.ts'), generatePrentaConfig(options)),
1321
+ writeFile(path.join(cmsRoot, 'lib', 'cms.ts'), generateCmsHelper()),
1322
+ writeFile(path.join(cmsRoot, 'lib', 'prisma.ts'), generatePrismaHelper()),
1323
+ writeFile(path.join(cmsRoot, 'lib', 'site-settings.ts'), generateSiteSettingsHelper()),
1324
+ writeFile(path.join(cmsRoot, 'lib', 'resolve-media.ts'), generateResolveMediaHelper()),
1325
+ writeFile(path.join(cmsRoot, 'app', '(admin)', adminDir, '[[...path]]', 'page.tsx'), generateAdminPage({
1326
+ sectionRenderers: isMarketingCustom(options),
1327
+ // Workspace: import the shared kit by package name (compiled via
1328
+ // next.config transpilePackages); flat: the local src/sections path.
1329
+ sectionRenderersImport: sectionsPackage ? `${sectionsPackage}/register` : undefined,
1330
+ loginPath: splitLogin ? options.loginPath : undefined,
1331
+ })),
1332
+ writeFile(path.join(cmsRoot, 'app', 'api', 'cms', '[[...cms]]', 'route.ts'), generateApiRoute(options)),
1333
+ // Monolith: root SEO proxies + public catch-all. Astro-split: public SEO
1334
+ // and pages live under apps/site; CMS host only serves admin + API.
1335
+ ...(astroSplit
1336
+ ? [
1337
+ writeFile(path.join(cmsRoot, 'app', 'page.tsx'), generateCmsHostRootPage(options)),
1338
+ writeFile(path.join(root, 'ASTRO.md'), generateAstroSplitReadme(options, sectionsPackage)),
1339
+ ]
1340
+ : [
1341
+ writeFile(path.join(cmsRoot, 'app', 'sitemap.xml', 'route.ts'), generateSitemapRoute()),
1342
+ writeFile(path.join(cmsRoot, 'app', 'robots.txt', 'route.ts'), generateRobotsRoute()),
1343
+ writeFile(path.join(cmsRoot, 'app', 'feed.xml', 'route.ts'), generateFeedRoute('xml')),
1344
+ writeFile(path.join(cmsRoot, 'app', 'feed.json', 'route.ts'), generateFeedRoute('json')),
1345
+ writeFile(path.join(cmsRoot, 'app', 'llms.txt', 'route.ts'), generateLlmsRoute('llms.txt')),
1346
+ writeFile(path.join(cmsRoot, 'app', 'llms-full.txt', 'route.ts'), generateLlmsRoute('llms-full.txt')),
1347
+ writeFile(path.join(cmsRoot, 'app', '(site)', 'layout.tsx'), generateSiteLayout(options)),
1348
+ writeFile(path.join(cmsRoot, 'app', '(site)', '[[...path]]', 'page.tsx'), generateCatchAllPage()),
1349
+ writeFile(path.join(cmsRoot, 'app', '(site)', 'globals.css'), generateGlobalsCss()),
1350
+ ]),
1351
+ writeFile(path.join(cmsRoot, 'proxy.ts'), generateProxy(options.adminPath, splitLogin ? options.loginPath : undefined)),
1352
+ writeFile(path.join(cmsRoot, 'app', 'layout.tsx'), generateRootLayout()),
1353
+ writeFile(path.join(cmsRoot, 'postcss.config.mjs'), generatePostcssConfig()),
1354
+ writeFile(path.join(cmsRoot, 'app', '(admin)', adminDir, 'admin.css'), generateAdminLayoutCss()),
1355
+ writeFile(path.join(cmsRoot, 'app', '(admin)', adminDir, 'layout.tsx'), generateAdminLayout()),
1356
+ ...(splitLogin
1357
+ ? [
1358
+ writeFile(path.join(cmsRoot, 'app', '(admin)', loginDir, '[[...path]]', 'page.tsx'), generateLoginPage({ loginPath: options.loginPath, adminPath: options.adminPath })),
1359
+ writeFile(path.join(cmsRoot, 'app', '(admin)', loginDir, 'admin.css'), generateAdminLayoutCss()),
1360
+ writeFile(path.join(cmsRoot, 'app', '(admin)', loginDir, 'layout.tsx'), generateAdminLayout()),
1361
+ ]
1362
+ : []),
1363
+ writeFile(path.join(root, '.cursor', 'mcp.json'), generateCursorMcpJson()),
1364
+ writeFile(path.join(root, '.vscode', 'mcp.json'), generateVscodeMcpJson()),
1365
+ ...(options.plugins.includes('consent')
1366
+ ? [
1367
+ (async () => {
1368
+ const consentDir = path.join(cmsRoot, 'src', 'consent');
1369
+ await mkdirSafe(consentDir);
1370
+ await writeFile(path.join(consentDir, 'config.ts'), generateConsentConfig());
1371
+ })(),
1372
+ ]
1373
+ : []),
1374
+ ];
1375
+ if (options.platform === 'vercel') {
1376
+ writes.push(writeFile(path.join(cmsRoot, 'vercel.json'), buildVercelJson(options)));
1377
+ }
1378
+ await Promise.all(writes);
1379
+ // Ship the canonical migration history so `prisma migrate deploy` (run in
1380
+ // the build) actually creates the `prenta_*` tables. Without this the
1381
+ // build applies nothing and the client gets an empty database.
1382
+ await cp(path.join(PRISMA_ASSETS_DIR, 'migrations'), path.join(cmsRoot, 'prisma', 'migrations'), { recursive: true });
1383
+ // Marketing-custom template: emit the section schema/renderer/blocks/verify
1384
+ // files. Workspace scaffolds put the kit in packages/sections (keys are
1385
+ // project-root-relative in both shapes); create parent dirs, then write.
1386
+ if (isMarketingCustom(options)) {
1387
+ spinner.text = 'Writing marketing-custom section files...';
1388
+ const extraFiles = sectionsPackage
1389
+ ? workspaceSectionKitFiles({ packageName: sectionsPackage, versions })
1390
+ : marketingCustomFiles();
1391
+ await Promise.all(Object.entries(extraFiles).map(async ([relPath, contents]) => {
1392
+ const dest = path.join(root, ...relPath.split('/'));
1393
+ await mkdirSafe(path.dirname(dest));
1394
+ await writeFile(dest, contents);
1395
+ }));
1396
+ }
1397
+ if (astroSplit) {
1398
+ spinner.text = 'Writing Astro public site (apps/site)...';
1399
+ const siteFiles = astroSiteFiles({
1400
+ cmsUrlExample: 'http://localhost:3000',
1401
+ packageName: 'site',
1402
+ sectionsPackage,
1403
+ adminPath: options.adminPath,
1404
+ loginPath: options.loginPath,
1405
+ });
1406
+ await Promise.all(Object.entries(siteFiles).map(async ([relPath, contents]) => {
1407
+ const dest = path.join(root, 'apps', 'site', ...relPath.split('/'));
1408
+ await mkdirSafe(path.dirname(dest));
1409
+ await writeFile(dest, contents);
1410
+ }));
1411
+ }
1412
+ spinner.succeed('Project created successfully!');
1413
+ console.log();
1414
+ console.log(chalk.green(' Your Prenta CMS project is ready!'));
1415
+ console.log();
1416
+ console.log(' Next steps:');
1417
+ console.log();
1418
+ console.log(chalk.cyan(` cd ${options.projectName}`));
1419
+ if (astroSplit) {
1420
+ console.log(chalk.cyan(' pnpm install'));
1421
+ console.log(chalk.cyan(' cp apps/cms/.env.example apps/cms/.env'));
1422
+ console.log(chalk.dim(' # Edit with your database URL and secrets'));
1423
+ console.log(chalk.cyan(' cp apps/site/.env.example apps/site/.env'));
1424
+ console.log(chalk.dim(' # PRENTA_CMS_URL=http://localhost:3000'));
1425
+ console.log(chalk.cyan(' pnpm --filter cms exec prisma migrate deploy'));
1426
+ console.log(chalk.dim(' # applies the bundled Prenta CMS migrations to your database'));
1427
+ console.log(chalk.cyan(' pnpm dev'));
1428
+ console.log(chalk.dim(' # both apps (or pnpm dev:cms / pnpm dev:site)'));
1429
+ console.log();
1430
+ console.log(chalk.dim(' See ASTRO.md for routing, the section kit, and purge-on-publish.'));
1431
+ }
1432
+ else {
1433
+ console.log(chalk.cyan(' cp .env.example .env'));
1434
+ console.log(chalk.dim(' # Edit .env with your database URL and secrets'));
1435
+ console.log(chalk.cyan(' npm install'));
1436
+ console.log(chalk.cyan(' npx prisma migrate deploy'));
1437
+ console.log(chalk.dim(' # applies the bundled Prenta CMS migrations to your database'));
1438
+ console.log(chalk.cyan(' npm run dev'));
1439
+ }
1440
+ console.log();
1441
+ console.log(chalk.dim(' Then visit'), chalk.cyan(`http://localhost:3000${options.loginPath}`));
1442
+ console.log(chalk.dim(' to create your admin account via the setup wizard.'));
1443
+ if (splitLogin) {
1444
+ console.log(chalk.dim(' Sign-in lives at'), chalk.cyan(options.loginPath), chalk.dim('— the admin app at'), chalk.cyan(options.adminPath), chalk.dim('shows a 404 when signed out.'));
1445
+ }
1446
+ console.log();
1447
+ console.log(' Connect an AI agent (optional):');
1448
+ console.log();
1449
+ console.log(chalk.dim(' 1.'), `Create an API key in Admin → API Keys (Content preset).`);
1450
+ console.log(chalk.dim(' 2.'), `Paste it into`, chalk.cyan('.cursor/mcp.json'), chalk.dim('(Cursor / Claude Desktop) or'), chalk.cyan('.vscode/mcp.json'), chalk.dim('(VS Code) as PRENTA_API_KEY.'));
1451
+ console.log(chalk.dim(' 3.'), `Restart your editor — Prenta MCP tools + the authoring guide appear automatically.`);
1452
+ console.log(chalk.dim(' Both files are git-ignored so your key is never committed.'));
1453
+ console.log();
1454
+ console.log(chalk.dim(' For deployment instructions, see'), chalk.cyan('DEPLOYMENT.md'));
1455
+ console.log();
1456
+ console.log(chalk.dim(' Admin path:'), options.adminPath);
1457
+ console.log(chalk.dim(' Login path:'), options.loginPath);
1458
+ console.log(chalk.dim(' Plugins:'), options.plugins.join(', ') || 'none');
1459
+ console.log(chalk.dim(' Platform:'), options.platform);
1460
+ console.log(chalk.dim(' Database:'), options.database);
1461
+ console.log(chalk.dim(' Auth:'), options.authProviders.join(', '));
1462
+ console.log();
1463
+ }
1464
+ catch (err) {
1465
+ spinner.fail('Failed to create project');
1466
+ throw err;
1467
+ }
1468
+ }
1469
+ function generateFontsReadme() {
1470
+ return `# Custom fonts
1471
+
1472
+ Place licensed \`.woff2\` files here, then:
1473
+
1474
+ 1. Uncomment the \`@font-face\` rules in \`app/(site)/globals.css\`.
1475
+ 2. Uncomment the \`<link rel="preload">\` hints in \`app/(site)/layout.tsx\`.
1476
+ 3. Keep \`font-display: swap\` and preload only the weights you use above the fold.
1477
+
1478
+ Recommended filenames:
1479
+
1480
+ - \`display.woff2\` — headings (600–700)
1481
+ - \`body.woff2\` — body copy (400–500)
1482
+
1483
+ Self-hosted fonts avoid third-party latency and keep LCP predictable.
1484
+ `;
1485
+ }
1486
+ function generateApiRoute(options) {
1487
+ const cacheBlock = options.platform === 'vercel'
1488
+ ? `import { getCache, invalidateByTag } from '@vercel/functions';
1489
+ import { revalidateTag } from 'next/cache';
1490
+ import { getPrisma } from '../../../../lib/prisma';
1491
+ import config from '../../../../prenta.config';
1492
+ import { handlePrentaAPI } from '@prenta/core/api';
1493
+ import { assertServingOriginConfigured } from '@prenta/core';
1494
+
1495
+ // Launch Gate E — fail loud when NEXT_PUBLIC_SITE_URL is unset in production
1496
+ // (siteUrlFromRequest would otherwise fall back to the request host).
1497
+ assertServingOriginConfigured({
1498
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? config.seo?.siteUrl,
1499
+ strict:
1500
+ process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production',
1501
+ });
1502
+
1503
+ /**
1504
+ * Tag-based caching for public CMS reads + on-demand invalidation after writes.
1505
+ * See https://prentacms.dev/docs/caching — mirrors apps/dev production wiring.
1506
+ */
1507
+ const handler = handlePrentaAPI({
1508
+ prismaClient: getPrisma(),
1509
+ config: {
1510
+ ...config,
1511
+ cache: {
1512
+ publicReads: {
1513
+ enabled: true,
1514
+ maxAge: 60,
1515
+ staleWhileRevalidate: 300,
1516
+ },
1517
+ runtimeCache: {
1518
+ adapter: getCache(),
1519
+ ttl: 60,
1520
+ },
1521
+ onInvalidate: async (tags: string[]) => {
1522
+ for (const tag of tags) revalidateTag(tag, 'default');
1523
+ try {
1524
+ await invalidateByTag(tags);
1525
+ } catch (err) {
1526
+ console.warn('[prenta][cache] invalidateByTag unavailable:', err);
1527
+ }
1528
+ },
1529
+ },
1530
+ },
1531
+ });`
1532
+ : `import { revalidateTag } from 'next/cache';
1533
+ import { getPrisma } from '../../../../lib/prisma';
1534
+ import config from '../../../../prenta.config';
1535
+ import { handlePrentaAPI } from '@prenta/core/api';
1536
+ import { assertServingOriginConfigured } from '@prenta/core';
1537
+
1538
+ assertServingOriginConfigured({
1539
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? config.seo?.siteUrl,
1540
+ strict:
1541
+ process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production',
1542
+ });
1543
+
1544
+ /** CDN/runtime cache wiring is Vercel-specific; still drain Next ISR on writes. */
1545
+ const handler = handlePrentaAPI({
1546
+ prismaClient: getPrisma(),
1547
+ config: {
1548
+ ...config,
1549
+ cache: {
1550
+ publicReads: { enabled: true, maxAge: 60, staleWhileRevalidate: 300 },
1551
+ onInvalidate: async (tags: string[]) => {
1552
+ for (const tag of tags) revalidateTag(tag, 'default');
1553
+ },
1554
+ },
1555
+ },
1556
+ });`;
1557
+ return `${cacheBlock}
1558
+
1559
+ export const GET = handler;
1560
+ export const POST = handler;
1561
+ export const PUT = handler;
1562
+ export const PATCH = handler;
1563
+ export const DELETE = handler;
1564
+ `;
1565
+ }
1566
+ function generateSitemapRoute() {
1567
+ // Root /sitemap.xml proxies the cms-core sitemap index so the public domain
1568
+ // serves a canonical sitemap URL (cms-core's robots.txt advertises this
1569
+ // path). cms-core (+ plugin-seo) is the single source of truth; this route
1570
+ // only forwards and preserves caching headers.
1571
+ return `import { NextResponse } from 'next/server';
1572
+
1573
+ export const dynamic = 'force-dynamic';
1574
+
1575
+ export async function GET(request: Request): Promise<NextResponse> {
1576
+ const origin = new URL(request.url).origin;
1577
+ try {
1578
+ const res = await fetch(\`\${origin}/api/cms/sitemap.xml\`, {
1579
+ headers: { accept: 'application/xml' },
1580
+ cache: 'no-store',
1581
+ });
1582
+ if (!res.ok) {
1583
+ return new NextResponse('Sitemap unavailable', { status: res.status });
1584
+ }
1585
+ const xml = await res.text();
1586
+ return new NextResponse(xml, {
1587
+ status: 200,
1588
+ headers: {
1589
+ 'Content-Type': 'application/xml; charset=utf-8',
1590
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
1591
+ },
1592
+ });
1593
+ } catch {
1594
+ return new NextResponse('Sitemap unavailable', { status: 502 });
1595
+ }
1596
+ }
1597
+ `;
1598
+ }
1599
+ function generateFeedRoute(kind) {
1600
+ // Root /feed.xml + /feed.json proxy the cms-core content feeds. cms-core is
1601
+ // the single source of truth; this route only forwards and preserves caching.
1602
+ const contentType = kind === 'xml' ? 'application/rss+xml; charset=utf-8' : 'application/feed+json; charset=utf-8';
1603
+ const accept = kind === 'xml' ? 'application/rss+xml' : 'application/feed+json';
1604
+ return `import { NextResponse } from 'next/server';
1605
+
1606
+ export const dynamic = 'force-dynamic';
1607
+
1608
+ export async function GET(request: Request): Promise<NextResponse> {
1609
+ const origin = new URL(request.url).origin;
1610
+ try {
1611
+ const res = await fetch(\`\${origin}/api/cms/feed.${kind}\`, {
1612
+ headers: { accept: '${accept}' },
1613
+ cache: 'no-store',
1614
+ });
1615
+ if (!res.ok) {
1616
+ return new NextResponse('Feed unavailable', { status: res.status });
1617
+ }
1618
+ const body = await res.text();
1619
+ return new NextResponse(body, {
1620
+ status: 200,
1621
+ headers: {
1622
+ 'Content-Type': '${contentType}',
1623
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
1624
+ },
1625
+ });
1626
+ } catch {
1627
+ return new NextResponse('Feed unavailable', { status: 502 });
1628
+ }
1629
+ }
1630
+ `;
1631
+ }
1632
+ function generateRobotsRoute() {
1633
+ // Root /robots.txt proxies the cms-core robots route (which honours the
1634
+ // admin-edited custom robots.txt body / generated defaults). Single source of
1635
+ // truth lives in cms-core; this route only forwards.
1636
+ return `import { NextResponse } from 'next/server';
1637
+
1638
+ export const dynamic = 'force-dynamic';
1639
+
1640
+ export async function GET(request: Request): Promise<NextResponse> {
1641
+ const origin = new URL(request.url).origin;
1642
+ try {
1643
+ const res = await fetch(\`\${origin}/api/cms/robots.txt\`, {
1644
+ headers: { accept: 'text/plain' },
1645
+ cache: 'no-store',
1646
+ });
1647
+ if (!res.ok) {
1648
+ return new NextResponse('robots.txt unavailable', { status: res.status });
1649
+ }
1650
+ const body = await res.text();
1651
+ return new NextResponse(body, {
1652
+ status: 200,
1653
+ headers: {
1654
+ 'Content-Type': 'text/plain; charset=utf-8',
1655
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
1656
+ },
1657
+ });
1658
+ } catch {
1659
+ return new NextResponse('robots.txt unavailable', { status: 502 });
1660
+ }
1661
+ }
1662
+ `;
1663
+ }
1664
+ function generateLlmsRoute(file) {
1665
+ // Root /llms.txt (+ optional /llms-full.txt) proxy the cms-core routes.
1666
+ // cms-core is the single source of truth (curation, eligibility, sanitization,
1667
+ // noindex header); this route only forwards and preserves the noindex header
1668
+ // so the file itself is never indexed.
1669
+ return `import { NextResponse } from 'next/server';
1670
+
1671
+ export const dynamic = 'force-dynamic';
1672
+
1673
+ export async function GET(request: Request): Promise<NextResponse> {
1674
+ const origin = new URL(request.url).origin;
1675
+ try {
1676
+ const res = await fetch(\`\${origin}/api/cms/${file}\`, {
1677
+ headers: { accept: 'text/plain' },
1678
+ cache: 'no-store',
1679
+ });
1680
+ if (!res.ok) {
1681
+ return new NextResponse('${file} unavailable', { status: res.status });
1682
+ }
1683
+ const body = await res.text();
1684
+ return new NextResponse(body, {
1685
+ status: 200,
1686
+ headers: {
1687
+ 'Content-Type': 'text/plain; charset=utf-8',
1688
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
1689
+ 'X-Robots-Tag': 'noindex',
1690
+ },
1691
+ });
1692
+ } catch {
1693
+ return new NextResponse('${file} unavailable', { status: 502 });
1694
+ }
1695
+ }
1696
+ `;
1697
+ }
1698
+ /** Minimal CMS-host landing when the public site is Astro (`astro-split`). */
1699
+ function generateCmsHostRootPage(options) {
1700
+ const admin = options.adminPath;
1701
+ const login = options.loginPath;
1702
+ return `export default function CmsHostHome() {
1703
+ return (
1704
+ <main style={{ fontFamily: 'system-ui', maxWidth: 40 + 'rem', margin: '3rem auto', padding: '0 1rem' }}>
1705
+ <h1>Prenta CMS host</h1>
1706
+ <p>
1707
+ This deploy serves the admin app and <code>/api/cms</code>. The public site is the Astro
1708
+ app in <code>apps/site</code>.
1709
+ </p>
1710
+ <ul>
1711
+ <li>
1712
+ <a href="${login}">Sign in / setup</a> ({login})
1713
+ </li>
1714
+ <li>
1715
+ <a href="${admin}">Admin</a> ({admin})
1716
+ </li>
1717
+ </ul>
1718
+ <p>
1719
+ See <code>ASTRO.md</code> for split-deploy routing.
1720
+ </p>
1721
+ </main>
1722
+ );
1723
+ }
1724
+ `;
1725
+ }
1726
+ function generateAstroSplitReadme(options, sectionsPackage) {
1727
+ const kitRow = sectionsPackage
1728
+ ? `\n| \`packages/sections\` | Shared section kit (\`${sectionsPackage}\`) — schema + ONE renderer map for both apps |`
1729
+ : '';
1730
+ const kitSection = sectionsPackage
1731
+ ? `
1732
+ ## Section kit (three-layer contract)
1733
+
1734
+ Custom sections live in **one** workspace package so the schema, the admin
1735
+ editor canvas, and the public site can never drift:
1736
+
1737
+ 1. **Schema** — \`packages/sections/src/types.ts\`, imported by
1738
+ \`apps/cms/prenta.config.ts\` (\`sections.types\`) so editors can author the
1739
+ sections.
1740
+ 2. **Renderers** — \`packages/sections/src/register.tsx\` exports ONE
1741
+ \`sectionRenderers\` map. \`apps/cms\` passes it to
1742
+ \`<AdminRoot sectionRenderers>\` (designed previews in the editor canvas);
1743
+ \`apps/site\` imports \`${sectionsPackage}/register\` as a side effect in
1744
+ \`PageSections.tsx\` (public rendering via \`<Sections>\`).
1745
+ 3. **Verification** — \`pnpm verify:sections\` (schema ↔ renderer ids) and
1746
+ \`pnpm test:parity\` (canvas ↔ public render trees); both run in CI
1747
+ fails when a schema id has no renderer or vice versa.
1748
+
1749
+ See \`SECTIONS.md\` for the add-a-section workflow (and the bundled
1750
+ \`.cursor/skills/prenta-add-section\` agent skill).
1751
+ `
1752
+ : '';
1753
+ const loginRewrite = options.loginPath !== options.adminPath ? `, \`${options.loginPath}/*\`` : '';
1754
+ return `# Astro split deploy (pnpm workspace)
1755
+
1756
+ This project was scaffolded with \`--framework astro-split\`:
1757
+
1758
+ | Path | Role |
1759
+ |------|------|
1760
+ | \`apps/cms\` | Next CMS host — admin (${options.adminPath}), API (\`/api/cms\`), cron |
1761
+ | \`apps/site\` | Astro public site — pages, SEO proxies, section rendering |${kitRow}
1762
+
1763
+ ## Local development
1764
+
1765
+ \`\`\`bash
1766
+ pnpm install # once, at the workspace root
1767
+
1768
+ cp apps/cms/.env.example apps/cms/.env # DATABASE_URL, CMS_SECRET, …
1769
+ # NEXT_PUBLIC_SITE_URL must be the Astro apex (default http://localhost:4321), not :3000
1770
+ cp apps/site/.env.example apps/site/.env
1771
+ # PRENTA_CMS_URL=http://localhost:3000 ← CMS API host
1772
+ # SITE=http://localhost:4321 ← public apex (matches NEXT_PUBLIC_SITE_URL)
1773
+
1774
+ pnpm --filter cms exec prisma migrate deploy # apply the bundled CMS migrations
1775
+ pnpm dev # both apps (or: pnpm dev:cms / pnpm dev:site)
1776
+ \`\`\`
1777
+
1778
+ ## Two URLs (do not conflate)
1779
+
1780
+ | Variable | Where | Meaning |
1781
+ |----------|-------|---------|
1782
+ | \`NEXT_PUBLIC_SITE_URL\` / \`seo.siteUrl\` | \`apps/cms\` | **Public** apex (Astro) — View live, sitemap, OG |
1783
+ | \`PRENTA_CMS_URL\` | \`apps/site\` | CMS API origin (admin + \`/api/cms\`) |
1784
+
1785
+ If View live opens the Next CMS host, fix **Admin → Settings → General → Site URL** (or \`NEXT_PUBLIC_SITE_URL\`) to the Astro origin.
1786
+ ${kitSection}
1787
+ ## Publish freshness (purge-on-publish)
1788
+
1789
+ Published pages serve **cache-static** from the CDN edge
1790
+ (\`Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400\`) and are
1791
+ evicted the moment an editor publishes:
1792
+
1793
+ 1. Deploy \`apps/site\`, then in the CMS admin go to **Settings → Webhooks** and
1794
+ add an endpoint for \`https://<public apex>/api/prenta/purge\` (it subscribes
1795
+ to the publish lifecycle by default).
1796
+ 2. Copy the endpoint's signing secret into \`PRENTA_WEBHOOK_SECRET\` on the
1797
+ Astro deploy.
1798
+ 3. Optional: add CDN purge credentials (Cloudflare zone purge or a Vercel
1799
+ deploy hook) and/or Upstash Redis as a layer-2 runtime cache — see
1800
+ \`apps/site/.env.example\`. Without CDN credentials the endpoint still drains
1801
+ the runtime cache, so staleness is bounded by the CDN TTL.
1802
+
1803
+ ## Production routing (same-apex default)
1804
+
1805
+ \`apps/site/vercel.json\` ships the same-apex rewrites as the **default**:
1806
+
1807
+ - \`/api/cms/*\`, \`${options.adminPath}/*\`${loginRewrite} → CMS host
1808
+ - everything else → Astro
1809
+
1810
+ **Before going live**, replace \`CMS_HOST_ORIGIN\` in \`apps/site/vercel.json\`
1811
+ with the deployed CMS host origin (or configure the same rules at your CDN),
1812
+ and set \`PRENTA_CMS_URL\` on the Astro project to the **public apex** so draft
1813
+ preview cookies stay same-origin.
1814
+
1815
+ ## Deploy (Vercel)
1816
+
1817
+ Create **two projects** from this repository:
1818
+
1819
+ | Project | Root Directory | Notes |
1820
+ |---------|----------------|-------|
1821
+ | CMS host | \`apps/cms\` | database, cron, \`NEXT_PUBLIC_SITE_URL\` = public apex |
1822
+ | Public site | \`apps/site\` | \`PRENTA_CMS_URL\`, \`PRENTA_WEBHOOK_SECRET\`, apex domain |
1823
+
1824
+ Then finish the purge webhook setup above. See \`DEPLOYMENT.md\` for the full
1825
+ CMS-host checklist (env vars, migrations, seeding, SEO).
1826
+ `;
1827
+ }
1828
+ function generateRootLayout() {
1829
+ return `export default function RootLayout({
1830
+ children,
1831
+ }: {
1832
+ children: React.ReactNode;
1833
+ }) {
1834
+ return children;
1835
+ }
1836
+ `;
1837
+ }
1838
+ function generateCatchAllPage() {
1839
+ // Canonical content contract (ADR 0001): the public site renders the ordered
1840
+ // PageSection[] stored at `data.sections` with <Sections> from
1841
+ // @prenta/sections-react. Author-supplied rich text is sanitized
1842
+ // through cms-core's allow-list (`sanitizeHtml`) before it reaches the DOM.
1843
+ return `import { notFound } from 'next/navigation';
1844
+ import type { Metadata } from 'next';
1845
+ import Image from 'next/image';
1846
+ import { headers } from 'next/headers';
1847
+ import { sanitizeHtml } from '@prenta/core';
1848
+ import { recordPublic404 } from '@prenta/core/site';
1849
+ import { collectFormSlugsFromSections, prefetchPublicForms } from '@prenta/core';
1850
+ import { coercePostHeader } from '@prenta/core/page-sections';
1851
+ import {
1852
+ PostHeader,
1853
+ Sections,
1854
+ createLcpController,
1855
+ type PostContext,
1856
+ type PublicSection,
1857
+ type RenderImage,
1858
+ } from '@prenta/sections-react';
1859
+ import { getAdminPath } from '@prenta/core/next';
1860
+ import {
1861
+ getCachedPageMetadata,
1862
+ getCachedPublishedDocument,
1863
+ getPublicForm,
1864
+ resolveDocument,
1865
+ } from '@/lib/cms';
1866
+ import { getSiteSettings } from '@/lib/site-settings';
1867
+
1868
+ export const revalidate = 300;
1869
+
1870
+ interface PageProps {
1871
+ params: Promise<{ path?: string[] }>;
1872
+ searchParams: Promise<{ preview?: string }>;
1873
+ }
1874
+
1875
+ const ADMIN_PATH = getAdminPath();
1876
+
1877
+ // The site catch-all lives in the (site) route group; the admin SPA lives in
1878
+ // (admin). Next route precedence usually keeps them separate, but guard here so
1879
+ // a request under the admin base is never resolved as CMS content.
1880
+ function isAdminPath(p: string): boolean {
1881
+ const base = ADMIN_PATH.endsWith('/') ? ADMIN_PATH.slice(0, -1) : ADMIN_PATH;
1882
+ return p === base || p.startsWith(\`\${base}/\`);
1883
+ }
1884
+
1885
+ // Render CMS images through next/image for responsive srcset + optimization.
1886
+ // The /resolve endpoint enriches section images with intrinsic width/height
1887
+ // from the media library; when present we hand them to next/image, otherwise
1888
+ // we fall back to a fluid lazy <img> (next/image requires dimensions).
1889
+ const renderCmsImage: RenderImage = ({ src, alt, width, height, className, priority, sizes }) => {
1890
+ if (!src) return null;
1891
+ if (typeof width === 'number' && typeof height === 'number') {
1892
+ return (
1893
+ <Image
1894
+ src={src}
1895
+ alt={alt}
1896
+ width={width}
1897
+ height={height}
1898
+ className={className}
1899
+ priority={priority}
1900
+ sizes={sizes ?? '100vw'}
1901
+ />
1902
+ );
1903
+ }
1904
+ // eslint-disable-next-line @next/next/no-img-element
1905
+ return (
1906
+ <img
1907
+ src={src}
1908
+ alt={alt}
1909
+ loading={priority ? 'eager' : 'lazy'}
1910
+ fetchPriority={priority ? 'high' : undefined}
1911
+ decoding="async"
1912
+ sizes={sizes}
1913
+ className={className}
1914
+ />
1915
+ );
1916
+ };
1917
+
1918
+ // sanitizeHtml + renderImage are stable; the LCP controller is created
1919
+ // per-request inside the page so each render gets exactly one priority image.
1920
+ const renderOptions = { sanitizeHtml, renderImage: renderCmsImage };
1921
+
1922
+ function asUrl(value: unknown): string {
1923
+ if (typeof value === 'string') return value;
1924
+ if (value && typeof value === 'object' && 'url' in value) {
1925
+ const u = (value as { url?: unknown }).url;
1926
+ return typeof u === 'string' ? u : '';
1927
+ }
1928
+ return '';
1929
+ }
1930
+
1931
+ function asSections(value: unknown): PublicSection[] {
1932
+ return Array.isArray(value) ? (value as PublicSection[]) : [];
1933
+ }
1934
+
1935
+ async function reportPublic404(urlPath: string): Promise<void> {
1936
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim();
1937
+ let apiBase: string | null = null;
1938
+ if (siteUrl) {
1939
+ try {
1940
+ apiBase = \`\${new URL(siteUrl).origin}/api/cms\`;
1941
+ } catch {
1942
+ apiBase = null;
1943
+ }
1944
+ }
1945
+ if (!apiBase) {
1946
+ const h = await headers();
1947
+ const host = h.get('host');
1948
+ if (!host) return;
1949
+ const proto = h.get('x-forwarded-proto') ?? 'https';
1950
+ apiBase = \`\${proto}://\${host}/api/cms\`;
1951
+ }
1952
+ recordPublic404(apiBase, urlPath);
1953
+ }
1954
+
1955
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
1956
+ const { path } = await params;
1957
+ const urlPath = path && path.length ? \`/\${path.join('/')}\` : '/';
1958
+ if (isAdminPath(urlPath)) return {};
1959
+ const result = await getCachedPageMetadata(urlPath);
1960
+ // cms-core composes a Next.js-compatible metadata object server-side, so we
1961
+ // can spread it directly instead of re-deriving OG/Twitter/robots tags.
1962
+ return result;
1963
+ }
1964
+
1965
+ export default async function CatchAllPage({ params, searchParams }: PageProps) {
1966
+ const { path } = await params;
1967
+ // Editors arrive with \`?preview=<signed token>\` (admin "View on site" /
1968
+ // inline site preview). Forwarding it to /resolve renders the draft at its
1969
+ // real URL; without a valid token only PUBLISHED content resolves.
1970
+ const { preview } = await searchParams;
1971
+ const urlPath = path && path.length ? \`/\${path.join('/')}\` : '/';
1972
+ if (isAdminPath(urlPath)) notFound();
1973
+ const [result, siteSettings] = await Promise.all([
1974
+ preview ? resolveDocument(urlPath, preview) : getCachedPublishedDocument(urlPath),
1975
+ getSiteSettings(),
1976
+ ]);
1977
+
1978
+ if (!result?.data) {
1979
+ if (urlPath === '/') {
1980
+ return (
1981
+ <main className="flex min-h-screen flex-col items-center justify-center p-8 text-center">
1982
+ <h1 className="mb-4 text-4xl font-bold">{siteSettings?.siteName ?? 'Welcome'}</h1>
1983
+ <p className="mb-8 text-lg text-gray-600">
1984
+ Your site is running. Create a page with slug &quot;home&quot; in the CMS to replace this message.
1985
+ </p>
1986
+ </main>
1987
+ );
1988
+ }
1989
+ if (!preview) void reportPublic404(urlPath);
1990
+ notFound();
1991
+ }
1992
+
1993
+ const doc = result.data;
1994
+ const data = (doc.data ?? {}) as Record<string, unknown>;
1995
+ const isPage = doc.collection === 'pages';
1996
+
1997
+ const sections = asSections(data.sections);
1998
+ const str = (key: string) => (typeof data[key] === 'string' ? (data[key] as string) : '');
1999
+
2000
+ const formSlugs = collectFormSlugsFromSections(sections);
2001
+ const prefetchedForms =
2002
+ formSlugs.length > 0 ? await prefetchPublicForms(getPublicForm, formSlugs) : {};
2003
+
2004
+ const ctx: PostContext = {
2005
+ title: str('title'),
2006
+ excerpt: str('excerpt'),
2007
+ body: str('body'),
2008
+ // Prefer the resolver's featured image (carries intrinsic width/height from
2009
+ // the media library) so the post header renders a CLS-free, dimensioned LCP
2010
+ // image; fall back to the raw field URL.
2011
+ featuredImageUrl: result.post?.featuredImage?.url ?? asUrl(data.featuredImage),
2012
+ featuredImageWidth: result.post?.featuredImage?.width,
2013
+ featuredImageHeight: result.post?.featuredImage?.height,
2014
+ publishDate: str('publishDate') || null,
2015
+ category: str('category'),
2016
+ author: result.post?.author ?? undefined,
2017
+ relatedPosts: result.post?.relatedPosts ?? [],
2018
+ };
2019
+
2020
+ // One LCP budget shared by the post header + body sections so only the first
2021
+ // image on the page is eager/high-priority; everything else lazy-loads.
2022
+ const lcp = createLcpController();
2023
+ const pageOptions = { ...renderOptions, lcp, prefetchedForms };
2024
+
2025
+ return (
2026
+ <main className="min-h-screen bg-white">
2027
+ {result.jsonLd ? (
2028
+ <script
2029
+ type="application/ld+json"
2030
+ dangerouslySetInnerHTML={{
2031
+ __html: JSON.stringify(result.jsonLd).replace(/</g, '\\u003c'),
2032
+ }}
2033
+ />
2034
+ ) : null}
2035
+ {result.preview?.active && (
2036
+ <div className="bg-amber-100 px-4 py-2 text-center text-sm text-amber-900">
2037
+ Preview mode — you are viewing a draft. This page is not publicly visible.
2038
+ </div>
2039
+ )}
2040
+ {/* Posts render a design-driven header from their post-type template;
2041
+ pages render their own hero section instead. */}
2042
+ {!isPage && (
2043
+ <PostHeader
2044
+ config={coercePostHeader(result.template?.header)}
2045
+ ctx={ctx}
2046
+ options={pageOptions}
2047
+ />
2048
+ )}
2049
+
2050
+ {sections.length ? (
2051
+ <Sections sections={sections} ctx={ctx} options={pageOptions} />
2052
+ ) : (
2053
+ <div className="mx-auto max-w-2xl px-6 py-24 text-center text-gray-400">
2054
+ This document has no sections yet.
2055
+ </div>
2056
+ )}
2057
+ </main>
2058
+ );
2059
+ }
2060
+ `;
2061
+ }
2062
+ function generateProxy(adminPath, loginPath) {
2063
+ // Skip the public-route redirect engine for both CMS mounts. The auth/IP
2064
+ // guard itself comes from `createCMSMiddleware(cmsConfig)`, which reads
2065
+ // `admin.path` and `admin.loginPath` from the config.
2066
+ const cmsMountCheck = loginPath
2067
+ ? `pathname.startsWith('${adminPath}') || pathname.startsWith('${loginPath}')`
2068
+ : `pathname.startsWith('${adminPath}')`;
2069
+ const adminPathsLiteral = loginPath ? `['${adminPath}', '${loginPath}']` : `['${adminPath}']`;
2070
+ return `import { createCMSMiddleware } from '@prenta/core/middleware';
2071
+ import { applyPublicRedirect } from '@prenta/core/site';
2072
+ import { NextResponse, type NextRequest } from 'next/server';
2073
+ import cmsConfig from './prenta.config';
2074
+
2075
+ const cmsMiddleware = createCMSMiddleware(cmsConfig);
2076
+
2077
+ function toNextRedirect(response: Response): Response {
2078
+ if (response.status === 410) return new NextResponse('Gone', { status: 410 });
2079
+ const location = response.headers.get('Location');
2080
+ if (!location) return response;
2081
+ const status = [301, 302, 307, 308].includes(response.status) ? response.status : 301;
2082
+ return NextResponse.redirect(location, status as 301 | 302 | 307 | 308);
2083
+ }
2084
+
2085
+ export async function proxy(request: NextRequest) {
2086
+ const { pathname, origin } = request.nextUrl;
2087
+
2088
+ // Admin + API guard (auth, IP allowlist).
2089
+ const result = await cmsMiddleware(request);
2090
+ if (result) return result;
2091
+
2092
+ const isAdmin = ${cmsMountCheck};
2093
+ const isApi = pathname.startsWith('/api');
2094
+
2095
+ // Yoast-style public sitemap URLs at site root (post-sitemap.xml,
2096
+ // post-sitemap2.xml, …) and browser XSLT stylesheets. Rewrites to the
2097
+ // cms-core handler; /sitemap.xml is handled by app/sitemap.xml/route.ts.
2098
+ if (
2099
+ !isAdmin &&
2100
+ !isApi &&
2101
+ (request.method === 'GET' || request.method === 'HEAD') &&
2102
+ new RegExp('^/(?:[a-z0-9-]+-sitemap\\\\d*\\\\.xml|sitemap-index\\\\.xsl|sitemap\\\\.xsl)$', 'i').test(pathname)
2103
+ ) {
2104
+ const target = pathname.endsWith('.xsl')
2105
+ ? new URL(\`/api/cms\${pathname}\`, origin)
2106
+ : new URL(\`/api/cms/sitemaps\${pathname}\`, origin);
2107
+ return NextResponse.rewrite(target);
2108
+ }
2109
+
2110
+ // Per-page Markdown: rewrite /{path}.md -> the cms-core md route so agents can
2111
+ // fetch clean Markdown of any published page. The route serves noindex and the
2112
+ // URLs are never in the sitemap, so the HTML page stays canonical.
2113
+ if (
2114
+ !isAdmin &&
2115
+ !isApi &&
2116
+ pathname.endsWith('.md') &&
2117
+ (request.method === 'GET' || request.method === 'HEAD')
2118
+ ) {
2119
+ const docPath = pathname.slice(0, -3) || '/';
2120
+ const rewriteUrl = new URL('/api/cms/md', origin);
2121
+ rewriteUrl.searchParams.set('path', docPath);
2122
+ return NextResponse.rewrite(rewriteUrl);
2123
+ }
2124
+
2125
+ // SEO redirects — shared engine (chain collapse, preserveQuery, record-hit).
2126
+ // Same helper Astro split-deploy sites use via createAstroRedirectMiddleware.
2127
+ if (!isAdmin && !isApi) {
2128
+ const redirect = await applyPublicRedirect(request, {
2129
+ baseUrl: origin,
2130
+ adminPaths: ${adminPathsLiteral},
2131
+ });
2132
+ if (redirect) return toNextRedirect(redirect);
2133
+ }
2134
+
2135
+ const requestHeaders = new Headers(request.headers);
2136
+ requestHeaders.set('x-prenta-pathname', pathname);
2137
+ // Editors preview drafts via \`?preview=<token>\`. Flag those requests so the
2138
+ // layout can skip script tags marked "disable in preview" (analytics/pixels
2139
+ // shouldn't fire while editing).
2140
+ if (request.nextUrl.searchParams.has('preview')) {
2141
+ requestHeaders.set('x-prenta-preview', '1');
2142
+ }
2143
+ return NextResponse.next({ request: { headers: requestHeaders } });
2144
+ }
2145
+
2146
+ export const config = {
2147
+ // Admin + API for the auth guard, plus all public routes for redirects
2148
+ // (excluding Next internals and static files). The \`(?!md$)\` exception lets
2149
+ // \`/{path}.md\` requests through; a dedicated matcher re-admits legacy
2150
+ // content-page extensions (.html/.php/.aspx/.jsp/.cfm) so migrated URLs like
2151
+ // /products.html can still be redirected. Additional matchers expose
2152
+ // Yoast-style \`*-sitemap*.xml\` and XSL stylesheets at the site root.
2153
+ matcher: [
2154
+ '/((?!_next/|.*\\\\.(?!md$)[a-zA-Z0-9]+$).*)',
2155
+ '/((?!_next/|api/).*\\\\.(?:html?|php|aspx?|jsp|cfm)$)',
2156
+ '/((?:[a-z0-9-]+-sitemap\\\\d*)\\\\.xml)',
2157
+ '/sitemap.xsl',
2158
+ '/sitemap-index.xsl',
2159
+ ],
2160
+ };
2161
+ `;
2162
+ }
2163
+ //# sourceMappingURL=scaffold.js.map