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.
- package/CHANGELOG.md +964 -0
- package/LICENSE +21 -0
- package/assets/prisma/migrations/0001_init/migration.sql +941 -0
- package/assets/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
- package/assets/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
- package/assets/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
- package/assets/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
- package/assets/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
- package/assets/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
- package/assets/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
- package/assets/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
- package/assets/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
- package/assets/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
- package/assets/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
- package/assets/prisma/migrations/migration_lock.toml +3 -0
- package/assets/prisma/schema.prisma +989 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +160 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +206 -0
- package/dist/options.js.map +1 -0
- package/dist/scaffold.d.ts +45 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +2163 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/templates/admin-page.ts.template.d.ts +24 -0
- package/dist/templates/admin-page.ts.template.d.ts.map +1 -0
- package/dist/templates/admin-page.ts.template.js +145 -0
- package/dist/templates/admin-page.ts.template.js.map +1 -0
- package/dist/templates/astro-site.d.ts +36 -0
- package/dist/templates/astro-site.d.ts.map +1 -0
- package/dist/templates/astro-site.js +918 -0
- package/dist/templates/astro-site.js.map +1 -0
- package/dist/templates/login-page.ts.template.d.ts +14 -0
- package/dist/templates/login-page.ts.template.d.ts.map +1 -0
- package/dist/templates/login-page.ts.template.js +143 -0
- package/dist/templates/login-page.ts.template.js.map +1 -0
- package/dist/templates/marketing-custom.d.ts +56 -0
- package/dist/templates/marketing-custom.d.ts.map +1 -0
- package/dist/templates/marketing-custom.js +814 -0
- package/dist/templates/marketing-custom.js.map +1 -0
- package/dist/templates/prenta.config.ts.template.d.ts +3 -0
- package/dist/templates/prenta.config.ts.template.d.ts.map +1 -0
- package/dist/templates/prenta.config.ts.template.js +253 -0
- package/dist/templates/prenta.config.ts.template.js.map +1 -0
- package/package.json +58 -0
package/dist/scaffold.js
ADDED
|
@@ -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>© {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 "home" 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
|