riverbankcms 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2213 @@
1
+ #!/usr/bin/env node
2
+ import { At as ok, kt as err, nt as parseSiteId } from "./src-BRAF1Dee.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ //#region src/scaffold/package-contract.ts
8
+ const LOCAL_PACKAGE_VENDOR_DIR = ".riverbank/local-packages";
9
+ function isRecord(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ function parseStableVersion(value) {
13
+ if (typeof value !== "string") return undefined;
14
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value);
15
+ if (!match) return undefined;
16
+ return {
17
+ major: Number(match[1]),
18
+ minor: Number(match[2]),
19
+ patch: Number(match[3])
20
+ };
21
+ }
22
+ /** Pure package-contract derivation from a parsed manifest. */
23
+ function deriveGeneratedPackageRanges(manifest) {
24
+ if (!isRecord(manifest)) return {
25
+ ok: false,
26
+ error: { tag: "invalid_manifest_shape" }
27
+ };
28
+ const version = parseStableVersion(manifest.version);
29
+ if (!version) return {
30
+ ok: false,
31
+ error: {
32
+ tag: "invalid_cli_version",
33
+ value: manifest.version
34
+ }
35
+ };
36
+ const peerDependencies = manifest.peerDependencies;
37
+ const sdkRange = isRecord(peerDependencies) ? peerDependencies["@riverbankcms/sdk"] : undefined;
38
+ if (typeof sdkRange !== "string" || sdkRange.trim().length === 0) {
39
+ return {
40
+ ok: false,
41
+ error: {
42
+ tag: "invalid_cli_peer_range",
43
+ value: sdkRange
44
+ }
45
+ };
46
+ }
47
+ return {
48
+ ok: true,
49
+ value: {
50
+ sdk: sdkRange,
51
+ cli: `^${version.major}.${version.minor}.0`
52
+ }
53
+ };
54
+ }
55
+ /**
56
+ * Find the nearest package root from a module or executable path. Walking
57
+ * upward works for both the source tree and a packed npm install; it does not
58
+ * rely on the repository's `scripts/` layout.
59
+ */
60
+ function findPackageManifestPath(startPath = fileURLToPath(import.meta.url), fileExists = existsSync) {
61
+ let directory = dirname(startPath);
62
+ while (true) {
63
+ const candidate = join(directory, "package.json");
64
+ if (fileExists(candidate)) return candidate;
65
+ const parent = dirname(directory);
66
+ if (parent === directory || directory === parse(directory).root) return undefined;
67
+ directory = parent;
68
+ }
69
+ }
70
+ /** Effectful package-boundary load; all range derivation remains pure above. */
71
+ function loadGeneratedPackageRanges(manifestPath = findPackageManifestPath()) {
72
+ if (!manifestPath) throw new Error("Cannot locate the riverbankcms package manifest.");
73
+ const parsed = deriveGeneratedPackageRanges(JSON.parse(readFileSync(manifestPath, "utf8")));
74
+ if (!parsed.ok) throw new Error(`Invalid riverbankcms package contract (${parsed.error.tag}).`);
75
+ return parsed.value;
76
+ }
77
+
78
+ //#endregion
79
+ //#region src/scaffold/local-manifest.ts
80
+ const DEFAULT_CONTENT_TYPES = {
81
+ withBlog: false,
82
+ withEvents: false,
83
+ withTestimonials: false,
84
+ withPortfolio: false
85
+ };
86
+ function parseProjectPath(value) {
87
+ if (typeof value !== "string" || value.length === 0 || value !== value.trim()) return undefined;
88
+ if (!isAbsolute(value) || value.includes("\0")) return undefined;
89
+ const normalized = resolve(value);
90
+ return normalized === parse(normalized).root ? undefined : normalized;
91
+ }
92
+ function parseFeatures(input) {
93
+ const preview = input.preview;
94
+ const portal = input.portal;
95
+ if (preview !== "enabled" && preview !== "disabled") return undefined;
96
+ if (portal !== "enabled" && portal !== "disabled") return undefined;
97
+ const contentTypesValue = input.contentTypes;
98
+ if (contentTypesValue === undefined) {
99
+ return {
100
+ preview,
101
+ portal,
102
+ contentTypes: DEFAULT_CONTENT_TYPES
103
+ };
104
+ }
105
+ if (typeof contentTypesValue !== "object" || contentTypesValue === null || Array.isArray(contentTypesValue)) return undefined;
106
+ const value = contentTypesValue;
107
+ const contentTypes = {
108
+ withBlog: value.withBlog === true,
109
+ withEvents: value.withEvents === true,
110
+ withTestimonials: value.withTestimonials === true,
111
+ withPortfolio: value.withPortfolio === true
112
+ };
113
+ const known = Object.keys(value);
114
+ if (known.some((key) => !Object.keys(DEFAULT_CONTENT_TYPES).includes(key) || typeof value[key] !== "boolean")) return undefined;
115
+ return {
116
+ preview,
117
+ portal,
118
+ contentTypes
119
+ };
120
+ }
121
+ /**
122
+ * Derive only local target state. This boundary can run before a remote site
123
+ * is created because it does not depend on site identity or dashboard data.
124
+ */
125
+ function deriveScaffoldLocalManifest(input) {
126
+ const projectPath = parseProjectPath(input.projectPath);
127
+ if (!projectPath) return err({
128
+ tag: "invalid_project_path",
129
+ value: input.projectPath
130
+ });
131
+ const features = parseFeatures(input.features);
132
+ if (!features) return err({
133
+ tag: "invalid_features",
134
+ field: "features"
135
+ });
136
+ return ok(Object.freeze({
137
+ projectPath,
138
+ features: Object.freeze({
139
+ ...features,
140
+ contentTypes: Object.freeze({ ...features.contentTypes })
141
+ })
142
+ }));
143
+ }
144
+
145
+ //#endregion
146
+ //#region src/scaffold/writer.ts
147
+ function errorMessage(error) {
148
+ return error instanceof Error ? error.message : String(error);
149
+ }
150
+ /**
151
+ * The single post-link completion authority used by fresh and resumed
152
+ * scaffolds. Keeping it here prevents init from growing a second docs/build
153
+ * implementation with subtly different ordering or error mapping.
154
+ */
155
+ function createScaffoldCompletion(projectPath, process) {
156
+ return async () => {
157
+ try {
158
+ await process.initializeDocs(projectPath);
159
+ } catch (error) {
160
+ return {
161
+ ok: false,
162
+ error: {
163
+ tag: "docs_initialization_failed",
164
+ message: errorMessage(error)
165
+ }
166
+ };
167
+ }
168
+ try {
169
+ await process.verifyBuild(projectPath);
170
+ } catch (error) {
171
+ return {
172
+ ok: false,
173
+ error: {
174
+ tag: "build_verification_failed",
175
+ message: errorMessage(error)
176
+ }
177
+ };
178
+ }
179
+ return { ok: true };
180
+ };
181
+ }
182
+ /**
183
+ * Read every target before the first write/process effect. A missing project
184
+ * root is valid when missing or empty; create-next-app is therefore the only
185
+ * process effect allowed to populate a clean target before canonical writes.
186
+ */
187
+ function preflightScaffoldPlan(plan, filesystem) {
188
+ return preflightScaffoldTargets(plan, filesystem);
189
+ }
190
+ /** Preflight the local project target before any site-creation/process effect. */
191
+ function preflightScaffoldTargets(manifest, filesystem) {
192
+ let rootState;
193
+ try {
194
+ rootState = filesystem.inspect(manifest.projectPath);
195
+ } catch (error) {
196
+ return {
197
+ tag: "path_inspection_failed",
198
+ path: manifest.projectPath,
199
+ message: errorMessage(error)
200
+ };
201
+ }
202
+ if (rootState !== "missing" && rootState !== "directory") {
203
+ return {
204
+ tag: "project_target_occupied",
205
+ path: manifest.projectPath,
206
+ state: rootState
207
+ };
208
+ }
209
+ if (rootState === "directory") {
210
+ let entries;
211
+ try {
212
+ entries = filesystem.listDirectory(manifest.projectPath);
213
+ } catch (error) {
214
+ return {
215
+ tag: "path_inspection_failed",
216
+ path: manifest.projectPath,
217
+ message: errorMessage(error)
218
+ };
219
+ }
220
+ if (entries.length > 0) return {
221
+ tag: "project_target_occupied",
222
+ path: manifest.projectPath,
223
+ state: "directory"
224
+ };
225
+ }
226
+ return undefined;
227
+ }
228
+ /** Write only a preflighted immutable manifest. */
229
+ function writePreflightedScaffoldFiles(plan, filesystem) {
230
+ try {
231
+ if (filesystem.inspect(plan.projectPath) === "missing") {
232
+ try {
233
+ filesystem.mkdir(plan.projectPath);
234
+ } catch (error) {
235
+ return {
236
+ ok: false,
237
+ error: {
238
+ tag: "directory_create_failed",
239
+ path: plan.projectPath,
240
+ message: errorMessage(error)
241
+ }
242
+ };
243
+ }
244
+ }
245
+ for (const file of plan.files) {
246
+ const target = join(plan.projectPath, file.path);
247
+ try {
248
+ filesystem.mkdir(dirname(target));
249
+ } catch (error) {
250
+ return {
251
+ ok: false,
252
+ error: {
253
+ tag: "directory_create_failed",
254
+ path: dirname(target),
255
+ message: errorMessage(error)
256
+ }
257
+ };
258
+ }
259
+ try {
260
+ filesystem.writeFile(target, file.content, Boolean(file.executable));
261
+ } catch (error) {
262
+ return {
263
+ ok: false,
264
+ error: {
265
+ tag: "file_write_failed",
266
+ path: target,
267
+ message: errorMessage(error)
268
+ }
269
+ };
270
+ }
271
+ }
272
+ return { ok: true };
273
+ } catch (error) {
274
+ return {
275
+ ok: false,
276
+ error: {
277
+ tag: "path_inspection_failed",
278
+ path: plan.projectPath,
279
+ message: errorMessage(error)
280
+ }
281
+ };
282
+ }
283
+ }
284
+ /** Validate all targets, then write the immutable manifest. */
285
+ function writeScaffoldFiles(plan, filesystem) {
286
+ const preflightError = preflightScaffoldPlan(plan, filesystem);
287
+ if (preflightError) return {
288
+ ok: false,
289
+ error: preflightError
290
+ };
291
+ return writePreflightedScaffoldFiles(plan, filesystem);
292
+ }
293
+ /**
294
+ * Prepare a two-stage effect shell so `init` can insert credential link
295
+ * between materialization and docs/build without duplicating the shell.
296
+ */
297
+ function prepareScaffoldExecution(plan, dependencies, packageSourceOverride) {
298
+ const preflightError = preflightScaffoldPlan(plan, dependencies.filesystem);
299
+ if (preflightError) return {
300
+ ok: false,
301
+ error: preflightError
302
+ };
303
+ return {
304
+ ok: true,
305
+ value: { materialize: () => {
306
+ try {
307
+ dependencies.process.createNextApp(plan.projectPath);
308
+ } catch (error) {
309
+ return {
310
+ ok: false,
311
+ error: {
312
+ tag: "create_next_app_failed",
313
+ message: errorMessage(error)
314
+ }
315
+ };
316
+ }
317
+ const written = writePreflightedScaffoldFiles(plan, dependencies.filesystem);
318
+ if (!written.ok) return written;
319
+ try {
320
+ dependencies.process.cleanupGeneratedDefaults(plan.projectPath);
321
+ } catch (error) {
322
+ return {
323
+ ok: false,
324
+ error: {
325
+ tag: "generated_defaults_cleanup_failed",
326
+ message: errorMessage(error)
327
+ }
328
+ };
329
+ }
330
+ try {
331
+ dependencies.process.configureProject(plan.projectPath, plan);
332
+ } catch (error) {
333
+ return {
334
+ ok: false,
335
+ error: {
336
+ tag: "file_write_failed",
337
+ path: join(plan.projectPath, "package.json"),
338
+ message: errorMessage(error)
339
+ }
340
+ };
341
+ }
342
+ try {
343
+ dependencies.process.installPackages(plan.projectPath, packageSourceOverride ?? plan.packageSource, plan.packageSource.ranges);
344
+ } catch (error) {
345
+ return {
346
+ ok: false,
347
+ error: {
348
+ tag: "package_install_failed",
349
+ message: errorMessage(error)
350
+ }
351
+ };
352
+ }
353
+ return {
354
+ ok: true,
355
+ value: { completeAfterLink: createScaffoldCompletion(plan.projectPath, dependencies.process) }
356
+ };
357
+ } }
358
+ };
359
+ }
360
+
361
+ //#endregion
362
+ //#region src/scaffold/templates/agents-md.ts
363
+ function agentsMdTemplate() {
364
+ return {
365
+ path: "AGENTS.md",
366
+ content: `# Code Guidelines
367
+
368
+ Guidelines for AI agents and developers working on this codebase.
369
+
370
+ ## Principles
371
+
372
+ - **DRY**: Don't repeat yourself. Extract shared logic into helpers.
373
+ - **Modularity**: Small, focused functions that do one thing well.
374
+ - **Functional style**: Prefer pure functions, avoid mutation.
375
+ - **Composition over inheritance**: Build complex behavior from simple pieces.
376
+ - **Simplicity**: The simplest solution that works is often the best.
377
+
378
+ ## SDK Development
379
+
380
+ This site uses \`@riverbankcms/sdk\` from Riverbank Digital. **We control the SDK repository.**
381
+
382
+ ### When you encounter SDK limitations:
383
+
384
+ 1. **Don't work around SDK shortcomings** in this codebase
385
+ 2. Document the limitation and desired behavior
386
+ 3. Create a plan/issue for SDK improvements
387
+ 4. Implement the fix in the SDK, then use it here
388
+
389
+ This approach keeps the site code clean and improves the SDK for all users.
390
+
391
+ ## File Organization
392
+
393
+ \`\`\`
394
+ src/
395
+ app/ # Next.js App Router pages
396
+ [[...slug]]/ # CMS-managed pages (catch-all route)
397
+ lib/ # Shared utilities and SDK client
398
+ components/ # React components (if needed)
399
+ __tests__/ # Test files
400
+ \`\`\`
401
+
402
+ ## Naming Conventions
403
+
404
+ | Type | Convention | Example |
405
+ |------|------------|---------|
406
+ | Files | kebab-case | \`riverbank-client.ts\` |
407
+ | Functions | camelCase | \`getRiverbankClient\` |
408
+ | Types/Interfaces | PascalCase | \`PageProps\` |
409
+ | Constants | SCREAMING_SNAKE_CASE | \`PREVIEW_MODE\` |
410
+ | Components | PascalCase | \`CustomHero.tsx\` |
411
+
412
+ ## Code Quality
413
+
414
+ ### Before committing:
415
+
416
+ \`\`\`bash
417
+ pnpm verify # Full check: lint + typecheck + tests
418
+ \`\`\`
419
+
420
+ ### Guidelines:
421
+
422
+ - Keep functions small and focused
423
+ - Prefer explicit over implicit
424
+ - Add types - avoid \`any\`
425
+ - Handle errors explicitly
426
+ - Write tests for business logic
427
+ `
428
+ };
429
+ }
430
+
431
+ //#endregion
432
+ //#region src/scaffold/templates/block-extensions.ts
433
+ function blockExtensionsTemplate() {
434
+ return {
435
+ path: "src/config/block-extensions.ts",
436
+ content: `import type { BlockFieldExtensionsMap, BlockFieldOptionsMap } from '@riverbankcms/sdk/config';
437
+
438
+ /**
439
+ * Block Field Extensions
440
+ *
441
+ * Add custom fields to built-in SDK blocks.
442
+ * These fields appear in the CMS editor alongside the block's default fields.
443
+ *
444
+ * Example:
445
+ * \`\`\`typescript
446
+ * 'block.hero': {
447
+ * fields: [
448
+ * {
449
+ * id: 'layout',
450
+ * type: 'select',
451
+ * label: 'Layout',
452
+ * required: false,
453
+ * multiple: false,
454
+ * options: [
455
+ * { value: 'default', label: 'Default' },
456
+ * { value: 'centered', label: 'Centered' },
457
+ * ],
458
+ * defaultValue: 'default',
459
+ * },
460
+ * ],
461
+ * }
462
+ * \`\`\`
463
+ */
464
+ export const blockFieldExtensions: BlockFieldExtensionsMap = {
465
+ // Add your block field extensions here
466
+ };
467
+
468
+ /**
469
+ * Block Field Options
470
+ *
471
+ * Customize the options available for existing fields on built-in blocks.
472
+ *
473
+ * Example:
474
+ * \`\`\`typescript
475
+ * 'block.embed': {
476
+ * layout: {
477
+ * options: [
478
+ * { value: 'grid', label: 'Grid Layout' },
479
+ * { value: 'list', label: 'List Layout' },
480
+ * ],
481
+ * },
482
+ * }
483
+ * \`\`\`
484
+ */
485
+ export const blockFieldOptions: BlockFieldOptionsMap = {
486
+ // Add your block field option overrides here
487
+ };
488
+ `
489
+ };
490
+ }
491
+
492
+ //#endregion
493
+ //#region src/scaffold/templates/block-overrides.ts
494
+ function blockOverridesTemplate() {
495
+ return {
496
+ path: "src/lib/block-overrides.tsx",
497
+ content: `/**
498
+ * Host block override registrations
499
+ *
500
+ * Fully headless sites own rendering for every block kind they use. The empty
501
+ * registry is intentional while the starter homepage has no blocks. Add a
502
+ * registration before adding a block to content; an unregistered kind raises
503
+ * MissingBlockOverrideError rather than using a built-in renderer.
504
+ *
505
+ * Example:
506
+ * \`\`\`typescript
507
+ * import {
508
+ * defineBlockOverrideRegistrations,
509
+ * defineLazyBlockOverride,
510
+ * type BlockOverrideProps,
511
+ * type BlockOverrideRegistrations,
512
+ * type HeroContent,
513
+ * } from '@riverbankcms/sdk/rendering';
514
+ * import { CustomHero } from '@/components/CustomHero';
515
+ *
516
+ * const TypedHero = (props: BlockOverrideProps<HeroContent>) => <CustomHero {...props} />;
517
+ *
518
+ * export const blockOverrides = defineBlockOverrideRegistrations({
519
+ * // Override the built-in hero block
520
+ * hero: TypedHero,
521
+ *
522
+ * // Lazy-load client-heavy or rarely used custom blocks
523
+ * 'custom.quiz': defineLazyBlockOverride(async () =>
524
+ * import('@/components/blocks/QuizBlock').then((module) => module.QuizBlock),
525
+ * ),
526
+ * }) satisfies BlockOverrideRegistrations;
527
+ * \`\`\`
528
+ */
529
+
530
+ import { defineBlockOverrideRegistrations } from '@riverbankcms/sdk/rendering';
531
+ import type { BlockOverrideRegistrations } from '@riverbankcms/sdk/rendering';
532
+
533
+ export const blockOverrides = defineBlockOverrideRegistrations({}) satisfies
534
+ BlockOverrideRegistrations;
535
+ `
536
+ };
537
+ }
538
+
539
+ //#endregion
540
+ //#region src/scaffold/templates/catch-all-page.ts
541
+ function catchAllPageTemplate(config) {
542
+ const previewImports = config.withPreviewMode ? `import { createPreviewModeBeforeRender } from '@riverbankcms/sdk/preview-next/before-render'\nimport { SdkPreviewModeClient } from '@/components/preview/SdkPreviewModeClient'\n` : "";
543
+ const previewBeforeRender = config.withPreviewMode ? `,
544
+ beforeRender: createPreviewModeBeforeRender({
545
+ getClient: getRiverbankClient,
546
+ siteId: config.siteId,
547
+ siteSlug: ${JSON.stringify(config.siteSlug)},
548
+ PreviewModeClient: SdkPreviewModeClient,
549
+ })` : "";
550
+ return {
551
+ path: "src/app/[[...slug]]/page.tsx",
552
+ content: `import {
553
+ createCatchAllPage,
554
+ createGenerateStaticParams,
555
+ } from '@riverbankcms/sdk/next';
556
+ import { getRiverbankClient } from '@/lib/riverbank-client';
557
+ import { SITE_RENDERING } from '@/lib/rendering';
558
+ import config from '../../../riverbank.config';
559
+ import { blockOverrides } from '@/lib/block-overrides';
560
+ import { dataLoaderOverrides } from '@/lib/data-loader-overrides.server';
561
+ ${previewImports}
562
+
563
+ // Next.js route config values must remain statically analyzable literals.
564
+ export const revalidate = 300;
565
+ export const dynamicParams = true;
566
+ // Route discovery falls back to the homepage when the CMS is unavailable so
567
+ // the scaffold verification build does not require a live API. Add
568
+ // failureMode: 'throw' when production CI should reject discovery failures.
569
+ export const generateStaticParams = createGenerateStaticParams({
570
+ getClient: getRiverbankClient,
571
+ siteId: config.siteId,
572
+ });
573
+
574
+ /**
575
+ * Create catch-all page using SDK helper
576
+ *
577
+ * The createCatchAllPage helper handles:
578
+ * - Loading page data from CMS
579
+ * - Rendering blocks with the Page component
580
+ * - Generating metadata for SEO
581
+ * - 404 handling for non-existent pages
582
+ *
583
+ * Add an override for every block kind used by the site's content. The empty
584
+ * starter registry is intentional; an unregistered kind raises the SDK's
585
+ * MissingBlockOverrideError instead of falling back to built-in rendering.
586
+ */
587
+ const { Page, generateMetadata } = createCatchAllPage({
588
+ getClient: getRiverbankClient,
589
+ config,
590
+ rendering: SITE_RENDERING,
591
+ searchParamsMode: 'ignore',
592
+ blockOverrides${previewBeforeRender},
593
+ dataLoaderOverrides,
594
+ });
595
+
596
+ export default Page;
597
+ export { generateMetadata };
598
+ `
599
+ };
600
+ }
601
+
602
+ //#endregion
603
+ //#region src/scaffold/templates/cms-push-staging.ts
604
+ function cmsPushStagingTemplate() {
605
+ return {
606
+ path: "scripts/cms-push-staging.sh",
607
+ executable: true,
608
+ content: `#!/usr/bin/env bash
609
+ set -euo pipefail
610
+
611
+ PROJECT_ROOT="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
612
+ ENV_FILE="\${PROJECT_ROOT}/.env.staging"
613
+
614
+ APPLY=0
615
+ if [[ "\${1:-}" == "--apply" ]]; then
616
+ APPLY=1
617
+ shift
618
+ fi
619
+
620
+ if [[ ! -f "\${ENV_FILE}" ]]; then
621
+ echo "Missing \${ENV_FILE}"
622
+ echo ""
623
+ echo "Create it by copying the template:"
624
+ echo " cp .env.staging.example .env.staging"
625
+ echo ""
626
+ echo "Then fill in:"
627
+ echo " RIVERBANK_STAGING_SITE_ID=..."
628
+ echo " RIVERBANK_STAGING_DASHBOARD_URL=https://..."
629
+ echo " RIVERBANK_STAGING_MGMT_API_KEY=bld_mgmt_sk_..."
630
+ exit 1
631
+ fi
632
+
633
+ cd "\${PROJECT_ROOT}"
634
+
635
+ set -a
636
+ # shellcheck disable=SC1090
637
+ source "\${ENV_FILE}"
638
+ set +a
639
+
640
+ : "\${RIVERBANK_STAGING_SITE_ID:?Missing RIVERBANK_STAGING_SITE_ID in .env.staging}"
641
+ : "\${RIVERBANK_STAGING_DASHBOARD_URL:?Missing RIVERBANK_STAGING_DASHBOARD_URL in .env.staging}"
642
+ : "\${RIVERBANK_STAGING_MGMT_API_KEY:?Missing RIVERBANK_STAGING_MGMT_API_KEY in .env.staging}"
643
+
644
+ dashboard_url="\${RIVERBANK_STAGING_DASHBOARD_URL}"
645
+ if [[ "\${dashboard_url}" != http://* && "\${dashboard_url}" != https://* ]]; then
646
+ dashboard_url="https://\${dashboard_url}"
647
+ fi
648
+
649
+ dashboard_host="$(DASHBOARD_URL="\${dashboard_url}" node -e "console.log(new URL(process.env.DASHBOARD_URL).host)")"
650
+
651
+ if [[ "\${dashboard_host}" == "www.riverbankcms.com" || "\${dashboard_host}" == "riverbankcms.com" ]]; then
652
+ echo "Refusing to run staging push against production dashboard host: \${dashboard_host}"
653
+ exit 1
654
+ fi
655
+
656
+ export RIVERBANK_REMOTE_SITE_ID="\${RIVERBANK_STAGING_SITE_ID}"
657
+ export RIVERBANK_REMOTE_DASHBOARD_URL="\${dashboard_url}"
658
+ export RIVERBANK_REMOTE_MGMT_API_KEY="\${RIVERBANK_STAGING_MGMT_API_KEY}"
659
+
660
+ cmd=(pnpm exec riverbankcms push --env=remote)
661
+ if [[ "\${APPLY}" == "1" ]]; then
662
+ cmd+=(--yes)
663
+ else
664
+ cmd+=(--dry-run)
665
+ fi
666
+ cmd+=("$@")
667
+
668
+ echo "Target: \${RIVERBANK_REMOTE_DASHBOARD_URL} (site: \${RIVERBANK_REMOTE_SITE_ID})"
669
+ echo "Running: \${cmd[*]}"
670
+ "\${cmd[@]}"
671
+ `
672
+ };
673
+ }
674
+
675
+ //#endregion
676
+ //#region src/scaffold/templates/content-types.ts
677
+ const BLOG_CONTENT_TYPE = ` // Blog posts
678
+ {
679
+ key: 'post',
680
+ name: 'Blog Post',
681
+ description: 'Blog articles and news',
682
+ hasPages: true,
683
+ routePattern: '/blog/{slug}',
684
+ fields: [
685
+ { id: 'excerpt', type: 'text', label: 'Excerpt', required: false, multiline: true, description: 'Short summary for listings' },
686
+ { id: 'image', type: 'media', label: 'Featured Image', required: false, mediaKinds: ['image'] },
687
+ { id: 'content', type: 'richText', label: 'Content', required: true },
688
+ { id: 'author', type: 'text', label: 'Author', required: false, multiline: false },
689
+ { id: 'publishedAt', type: 'datetime', label: 'Publish Date', required: false },
690
+ ],
691
+ }`;
692
+ const TESTIMONIALS_CONTENT_TYPE = ` // Testimonials
693
+ {
694
+ key: 'testimonial',
695
+ name: 'Testimonial',
696
+ description: 'Customer testimonials and reviews',
697
+ hasPages: false,
698
+ fields: [
699
+ { id: 'name', type: 'text', label: 'Name', required: true, multiline: false },
700
+ { id: 'jobTitle', type: 'text', label: 'Job Title', required: false, multiline: false },
701
+ { id: 'company', type: 'text', label: 'Company', required: false, multiline: false },
702
+ { id: 'body', type: 'richText', label: 'Quote', required: true },
703
+ ],
704
+ titleField: 'name',
705
+ }`;
706
+ const EVENTS_CONTENT_TYPE = ` // Events
707
+ {
708
+ key: 'event',
709
+ name: 'Event',
710
+ description: 'Events and workshops',
711
+ hasPages: true,
712
+ routePattern: '/events/{slug}',
713
+ fields: [
714
+ { id: 'excerpt', type: 'text', label: 'Summary', required: false, multiline: true },
715
+ { id: 'startsAt', type: 'datetime', label: 'Starts At', required: false },
716
+ { id: 'endsAt', type: 'datetime', label: 'Ends At', required: false },
717
+ { id: 'location', type: 'text', label: 'Location', required: false, multiline: false },
718
+ { id: 'content', type: 'richText', label: 'Details', required: true },
719
+ ],
720
+ }`;
721
+ const PORTFOLIO_CONTENT_TYPE = ` // Portfolio items
722
+ {
723
+ key: 'portfolio-item',
724
+ name: 'Portfolio Item',
725
+ description: 'Work portfolio entries with case study pages',
726
+ hasPages: true,
727
+ routePattern: '/work/{slug}',
728
+ fields: [
729
+ { id: 'short-description', type: 'text', label: 'Short Description', required: false, multiline: true, description: 'Brief summary shown in carousel/list views' },
730
+ { id: 'full-description', type: 'richText', label: 'Full Description', required: false, description: 'Detailed description for the case study page' },
731
+ { id: 'image', type: 'media', label: 'Featured Image', required: false, mediaKinds: ['image'], description: 'Screenshot or hero image' },
732
+ { id: 'content', type: 'richText', label: 'Case Study Content', required: false, description: 'Full case study content' },
733
+ { id: 'link', type: 'link', label: 'External Link', required: false, description: 'Link to the live project' },
734
+ ],
735
+ }`;
736
+ function contentTypesTemplate(options = {}) {
737
+ const contentTypes = [];
738
+ if (options.withBlog) {
739
+ contentTypes.push(BLOG_CONTENT_TYPE);
740
+ }
741
+ if (options.withTestimonials) {
742
+ contentTypes.push(TESTIMONIALS_CONTENT_TYPE);
743
+ }
744
+ if (options.withEvents) {
745
+ contentTypes.push(EVENTS_CONTENT_TYPE);
746
+ }
747
+ if (options.withPortfolio) {
748
+ contentTypes.push(PORTFOLIO_CONTENT_TYPE);
749
+ }
750
+ const contentTypesArray = contentTypes.length > 0 ? `[\n${contentTypes.join(",\n")},\n]` : `[
751
+ // Add your content types here
752
+ ]`;
753
+ return {
754
+ path: "src/config/content-types.ts",
755
+ content: `import type { ContentTypeConfig } from '@riverbankcms/sdk/config';
756
+ import { getContentEntryPrefixesFromContentTypes } from '@riverbankcms/sdk/routing';
757
+
758
+ /**
759
+ * Content Type Definitions
760
+ *
761
+ * Defines the schema for each content type (testimonials, services, blog posts, etc.)
762
+ * These are synced to the CMS dashboard via \`pnpm exec riverbankcms push-config\`.
763
+ *
764
+ * Example:
765
+ * \`\`\`typescript
766
+ * {
767
+ * key: 'testimonial',
768
+ * name: 'Testimonial',
769
+ * description: 'Customer testimonials and reviews',
770
+ * hasPages: false,
771
+ * fields: [
772
+ * { id: 'name', type: 'text', label: 'Name', required: true, multiline: false },
773
+ * { id: 'body', type: 'richText', label: 'Quote', required: true },
774
+ * ],
775
+ * titleField: 'name',
776
+ * }
777
+ * \`\`\`
778
+ */
779
+ export const contentTypes: ContentTypeConfig[] = ${contentTypesArray};
780
+
781
+ /**
782
+ * Route prefixes for content types with pages.
783
+ * Used by the catch-all route to determine whether a path is a content entry.
784
+ *
785
+ * Derived from routePattern values (e.g., '/blog/{slug}' → 'blog').
786
+ */
787
+ export const CONTENT_ENTRY_PREFIXES = getContentEntryPrefixesFromContentTypes(contentTypes);
788
+ `
789
+ };
790
+ }
791
+
792
+ //#endregion
793
+ //#region src/scaffold/templates/custom-blocks.ts
794
+ function customBlocksTemplate() {
795
+ return {
796
+ path: "src/config/custom-blocks.ts",
797
+ content: `import type { SdkCustomBlock } from '@riverbankcms/sdk/config';
798
+
799
+ /**
800
+ * Custom Block Definitions
801
+ *
802
+ * Site-specific blocks that extend the SDK's built-in block library.
803
+ * Each block needs a corresponding React component in src/components
804
+ * and an entry in blockOverrides.
805
+ *
806
+ * Example:
807
+ * \`\`\`typescript
808
+ * {
809
+ * id: 'custom.hero-banner',
810
+ * title: 'Hero Banner',
811
+ * description: 'Custom hero with animated background',
812
+ * category: 'hero',
813
+ * icon: 'Sparkles',
814
+ * tags: ['hero', 'banner', 'animated'],
815
+ * fields: [
816
+ * { id: 'title', type: 'text', label: 'Title', required: true, multiline: false },
817
+ * { id: 'subtitle', type: 'text', label: 'Subtitle', required: false, multiline: true },
818
+ * ],
819
+ * }
820
+ * \`\`\`
821
+ */
822
+ export const customBlocks: SdkCustomBlock[] = [
823
+ // Add your custom blocks here
824
+ ];
825
+ `
826
+ };
827
+ }
828
+
829
+ //#endregion
830
+ //#region src/scaffold/templates/data-loader-overrides.ts
831
+ function dataLoaderOverridesTemplate() {
832
+ return {
833
+ path: "src/lib/data-loader-overrides.server.ts",
834
+ content: `import 'server-only';
835
+
836
+ import type { DataLoaderOverrides } from '@riverbankcms/sdk/next';
837
+
838
+ /**
839
+ * Host data loaders run only while rendering Server Components. Keep database
840
+ * clients, private API credentials, and loader functions in this server module.
841
+ */
842
+ export const dataLoaderOverrides = {
843
+ // If a loader reads searchParams, set searchParamsMode: 'dynamic' in the catch-all page.
844
+ // 'custom.product-grid': {
845
+ // products: async ({ content, searchParams, previewStage, renderTarget }) => {
846
+ // const category =
847
+ // typeof content.category === 'string' ? content.category : undefined;
848
+ // const response = await fetch(
849
+ // \`https://host-api.example/products?category=\${encodeURIComponent(category ?? '')}\`,
850
+ // {
851
+ // headers: {
852
+ // // Use credentials only for the server request. Never return them.
853
+ // Authorization: \`Bearer \${process.env.HOST_API_TOKEN ?? ''}\`,
854
+ // },
855
+ // },
856
+ // );
857
+ // const payload: unknown = await response.json();
858
+ // return {
859
+ // products: parseProductPayload(payload),
860
+ // query: searchParams.query ?? null,
861
+ // previewStage,
862
+ // owner: describeRenderTarget(renderTarget),
863
+ // };
864
+ // },
865
+ // },
866
+ } satisfies DataLoaderOverrides;
867
+
868
+ // Import or implement an application parser before enabling the example.
869
+ // import type { DataLoaderRenderTarget } from '@riverbankcms/sdk/next';
870
+ // declare function parseProductPayload(value: unknown): readonly Product[];
871
+ //
872
+ // function describeRenderTarget(target: DataLoaderRenderTarget): string {
873
+ // switch (target.kind) {
874
+ // case 'page':
875
+ // return target.pageId;
876
+ // case 'entry-template':
877
+ // return target.entryId;
878
+ // case 'page-surface-template':
879
+ // return target.pageSurfaceId;
880
+ // default: {
881
+ // const exhaustive: never = target;
882
+ // return exhaustive;
883
+ // }
884
+ // }
885
+ // }
886
+ `
887
+ };
888
+ }
889
+
890
+ //#endregion
891
+ //#region src/scaffold/templates/env-config.ts
892
+ function envConfigTemplate(config) {
893
+ const previewConfig = config.withPreviewMode ? ` /** Whether preview mode is enabled */
894
+ previewMode: boolean;
895
+ /** Preview API key for draft content */
896
+ previewApiKey: string | undefined;
897
+ ` : "";
898
+ const previewReturn = config.withPreviewMode ? ` previewMode: process.env.RIVERBANK_PREVIEW_MODE === 'true',
899
+ previewApiKey: process.env[\`\${prefix}PREVIEW_API_KEY\`],
900
+ ` : "";
901
+ return {
902
+ path: "src/lib/env.ts",
903
+ content: `/**
904
+ * Multi-environment CMS configuration helper.
905
+ *
906
+ * Reads CMS config based on RIVERBANK_ENV (local | remote).
907
+ * All environment-specific variables use the pattern: RIVERBANK_{ENV}_{KEY}
908
+ */
909
+
910
+ type CmsEnv = 'local' | 'remote';
911
+
912
+ function getCmsEnv(): CmsEnv {
913
+ // Respect RIVERBANK_ENV if explicitly set (allows build with local CMS)
914
+ const envOverride = process.env.RIVERBANK_ENV;
915
+ if (envOverride === 'local' || envOverride === 'remote') {
916
+ return envOverride;
917
+ }
918
+
919
+ // In production runtime (not build), default to remote
920
+ // Deployed sites should use remote CMS unless explicitly overridden
921
+ if (process.env.NODE_ENV === 'production') {
922
+ return 'remote';
923
+ }
924
+
925
+ // In development, default to 'local'
926
+ return 'local';
927
+ }
928
+
929
+ export interface CmsConfig {
930
+ /** Current environment (local or remote) */
931
+ env: CmsEnv;
932
+ ${previewConfig}
933
+ /** Site UUID for this environment */
934
+ siteId: string | undefined;
935
+ /** Dashboard URL used for CMS API requests */
936
+ dashboardUrl: string | undefined;
937
+ /** Live API key for published content */
938
+ apiKey: string | undefined;
939
+ /** Management API key for SDK write operations */
940
+ mgmtApiKey: string | undefined;
941
+ }
942
+
943
+ /**
944
+ * Get CMS configuration for the current environment.
945
+ *
946
+ * Reads from environment variables with pattern: RIVERBANK_{ENV}_{KEY}
947
+ * where ENV is determined by RIVERBANK_ENV (defaults to 'local').
948
+ *
949
+ * @example
950
+ * \`\`\`typescript
951
+ * const { siteId, dashboardUrl, apiKey } = getCmsConfig();
952
+ * \`\`\`
953
+ */
954
+ export function getCmsConfig(): CmsConfig {
955
+ const env = getCmsEnv();
956
+ const prefix = \`RIVERBANK_\${env.toUpperCase()}_\`;
957
+
958
+ return {
959
+ env,
960
+ ${previewReturn}
961
+ siteId: process.env[\`\${prefix}SITE_ID\`],
962
+ dashboardUrl: process.env[\`\${prefix}DASHBOARD_URL\`],
963
+ apiKey: process.env[\`\${prefix}API_KEY\`],
964
+ mgmtApiKey: process.env[\`\${prefix}MGMT_API_KEY\`],
965
+ };
966
+ }
967
+
968
+ /**
969
+ * Get the current CMS environment name.
970
+ */
971
+ export function getCurrentEnv(): CmsEnv {
972
+ return getCmsEnv();
973
+ }
974
+ `
975
+ };
976
+ }
977
+
978
+ //#endregion
979
+ //#region src/scaffold/templates/env-example.ts
980
+ function envExampleTemplate(config) {
981
+ const previewBlock = config.withPreviewMode ? `# Preview mode: set to "true" to fetch draft content
982
+ RIVERBANK_PREVIEW_MODE=false
983
+ ` : "";
984
+ const previewKeys = config.withPreviewMode ? `RIVERBANK_LOCAL_PREVIEW_API_KEY=""
985
+ ` : "";
986
+ const remotePreviewKey = config.withPreviewMode ? `RIVERBANK_REMOTE_PREVIEW_API_KEY=""
987
+ ` : "";
988
+ return {
989
+ path: ".env.example",
990
+ content: `# Riverbank CMS Configuration
991
+ # Copy this to .env.local and fill in your values
992
+ # Switch environments by changing RIVERBANK_ENV
993
+
994
+ # Environment selector: local | remote
995
+ RIVERBANK_ENV=local
996
+
997
+ ${previewBlock}
998
+
999
+ # ============ LOCAL CMS ============
1000
+ # Your local development CMS instance
1001
+ RIVERBANK_LOCAL_SITE_ID=""
1002
+ RIVERBANK_LOCAL_DASHBOARD_URL=""
1003
+ RIVERBANK_LOCAL_API_KEY=""
1004
+ ${previewKeys}RIVERBANK_LOCAL_MGMT_API_KEY=""
1005
+
1006
+ # ============ REMOTE CMS ============
1007
+ # Your production/staging CMS instance
1008
+ RIVERBANK_REMOTE_SITE_ID=""
1009
+ RIVERBANK_REMOTE_DASHBOARD_URL=""
1010
+ RIVERBANK_REMOTE_API_KEY=""
1011
+ ${remotePreviewKey}RIVERBANK_REMOTE_MGMT_API_KEY=""
1012
+
1013
+ # ============ SHARED ============
1014
+ # Set after deployment with \`riverbankcms webhooks setup\`; init never registers a webhook.
1015
+ RIVERBANK_WEBHOOK_SECRET=""
1016
+
1017
+ `
1018
+ };
1019
+ }
1020
+
1021
+ //#endregion
1022
+ //#region src/scaffold/templates/env-staging-example.ts
1023
+ function envStagingExampleTemplate() {
1024
+ return {
1025
+ path: ".env.staging.example",
1026
+ content: `RIVERBANK_STAGING_SITE_ID=
1027
+ RIVERBANK_STAGING_DASHBOARD_URL=
1028
+ RIVERBANK_STAGING_MGMT_API_KEY=
1029
+ `
1030
+ };
1031
+ }
1032
+
1033
+ //#endregion
1034
+ //#region src/scaffold/templates/error.ts
1035
+ function errorTemplate() {
1036
+ return {
1037
+ path: "src/app/error.tsx",
1038
+ content: `'use client';
1039
+
1040
+ import { useEffect } from 'react';
1041
+ import Link from 'next/link';
1042
+
1043
+ export default function Error({
1044
+ error,
1045
+ reset,
1046
+ }: {
1047
+ error: Error & { digest?: string };
1048
+ reset: () => void;
1049
+ }) {
1050
+ useEffect(() => {
1051
+ // Log the error to an error reporting service
1052
+ console.error(error);
1053
+ }, [error]);
1054
+
1055
+ return (
1056
+ <div className="min-h-[60vh] flex items-center justify-center px-4">
1057
+ <div className="text-center max-w-md">
1058
+ {/* Error icon */}
1059
+ <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[var(--color-accent,#f59e0b)]/20 flex items-center justify-center">
1060
+ <svg
1061
+ className="w-10 h-10 text-[var(--color-accent,#f59e0b)]"
1062
+ fill="none"
1063
+ viewBox="0 0 24 24"
1064
+ stroke="currentColor"
1065
+ strokeWidth={1.5}
1066
+ >
1067
+ <path
1068
+ strokeLinecap="round"
1069
+ strokeLinejoin="round"
1070
+ d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
1071
+ />
1072
+ </svg>
1073
+ </div>
1074
+
1075
+ {/* Message */}
1076
+ <h1 className="text-2xl sm:text-3xl text-[var(--color-text,#1f2937)] font-bold">
1077
+ Something went wrong
1078
+ </h1>
1079
+ <p className="mt-4 text-lg text-[var(--color-text,#1f2937)]/70">
1080
+ We encountered an unexpected error. Please try again, or contact us if
1081
+ the problem persists.
1082
+ </p>
1083
+
1084
+ {/* Action buttons */}
1085
+ <div className="mt-8 flex flex-wrap justify-center gap-3">
1086
+ <button
1087
+ onClick={() => reset()}
1088
+ className="px-8 py-3 bg-[var(--color-primary,#6d28d9)] text-white rounded-full transition-colors hover:opacity-90 font-medium"
1089
+ >
1090
+ Try Again
1091
+ </button>
1092
+ <Link
1093
+ href="/"
1094
+ className="px-8 py-3 border border-[var(--color-primary,#6d28d9)] text-[var(--color-primary,#6d28d9)] rounded-full transition-colors hover:bg-[var(--color-primary,#6d28d9)]/10 font-medium"
1095
+ >
1096
+ Back to Home
1097
+ </Link>
1098
+ </div>
1099
+ </div>
1100
+ </div>
1101
+ );
1102
+ }
1103
+ `
1104
+ };
1105
+ }
1106
+
1107
+ //#endregion
1108
+ //#region src/scaffold/templates/example-test.ts
1109
+ function exampleTestTemplate() {
1110
+ return {
1111
+ path: "src/__tests__/example.test.mjs",
1112
+ content: `import assert from 'node:assert/strict';
1113
+ import { describe, it } from 'node:test';
1114
+
1115
+ describe('Example test suite', () => {
1116
+ it('should pass a basic test', () => {
1117
+ assert.equal(1 + 1, 2);
1118
+ });
1119
+
1120
+ it('should handle string operations', () => {
1121
+ const greeting = 'Hello, World!';
1122
+ assert.ok(greeting.includes('Hello'));
1123
+ assert.ok(greeting.length > 0);
1124
+ });
1125
+ });
1126
+ `
1127
+ };
1128
+ }
1129
+
1130
+ //#endregion
1131
+ //#region src/scaffold/templates/gitignore.ts
1132
+ const LOCAL_MEDIA_CACHE_GITIGNORE_SECTION = `# Riverbank local media cache and migration working files.
1133
+ # See .riverbank/docs/content-management.md for when to intentionally track fixtures.
1134
+ content/media/*
1135
+ !content/media/fixtures/
1136
+ !content/media/fixtures/**
1137
+ `;
1138
+ function gitignoreTemplate() {
1139
+ return {
1140
+ path: ".gitignore",
1141
+ content: `# Dependencies
1142
+ node_modules/
1143
+ .pnp
1144
+ .pnp.*
1145
+
1146
+ # Next.js
1147
+ .next/
1148
+ out/
1149
+
1150
+ # Production
1151
+ build/
1152
+ dist/
1153
+
1154
+ # Test output
1155
+ coverage/
1156
+
1157
+ # Environment files
1158
+ .env*
1159
+ !.env.example
1160
+ !.env.staging.example
1161
+
1162
+ ${LOCAL_MEDIA_CACHE_GITIGNORE_SECTION}
1163
+
1164
+ # Deployment
1165
+ .vercel/
1166
+
1167
+ # TypeScript
1168
+ *.tsbuildinfo
1169
+
1170
+ # Logs
1171
+ *.log
1172
+ npm-debug.log*
1173
+ yarn-debug.log*
1174
+ yarn-error.log*
1175
+ pnpm-debug.log*
1176
+
1177
+ # OS/editor files
1178
+ .DS_Store
1179
+ *.swp
1180
+ `
1181
+ };
1182
+ }
1183
+
1184
+ //#endregion
1185
+ //#region src/scaffold/templates/globals-css.ts
1186
+ function globalsCssTemplate(config) {
1187
+ const previewBlock = config.withPreviewMode ? `/* Preview editor chrome (Tailwind-managed).
1188
+ *
1189
+ * The preview chrome uses Tailwind utilities, but importing Tailwind from the SDK can
1190
+ * collide with site styling (preflight, layer order, @theme directives).
1191
+ *
1192
+ * Recommended: keep Tailwind owned by the site, and add Riverbank package @source globs
1193
+ * alongside your own Tailwind entrypoint.
1194
+ *
1195
+ * If your site does NOT use Tailwind, use the legacy single import instead:
1196
+ * @import "@riverbankcms/sdk/preview-next/styles.css";
1197
+ */
1198
+ @import "tailwindcss";
1199
+ @import "@riverbankcms/sdk/preview-next/tailwind-sources.css";
1200
+ @import "@riverbankcms/sdk/preview-next/chrome.css";
1201
+
1202
+ ` : "";
1203
+ return {
1204
+ path: "src/app/globals.css",
1205
+ content: `${previewBlock}/* Add host-owned styles below. */
1206
+ `
1207
+ };
1208
+ }
1209
+
1210
+ //#endregion
1211
+ //#region src/scaffold/templates/layout.ts
1212
+ function layoutTemplate(config) {
1213
+ const escapedSiteName = JSON.stringify(config.siteName);
1214
+ const chromeRoot = config.withPreviewMode ? `\n <div id="builder-editor-chrome-root" className="builder-editor-chrome builder-dashboard-chrome-theme" />` : "";
1215
+ return {
1216
+ path: "src/app/layout.tsx",
1217
+ content: `import type { Metadata } from 'next';
1218
+ import './globals.css';
1219
+
1220
+ export const metadata: Metadata = {
1221
+ title: ${escapedSiteName},
1222
+ description: 'Built with Riverbank CMS',
1223
+ };
1224
+
1225
+ export default function RootLayout({
1226
+ children,
1227
+ }: {
1228
+ children: React.ReactNode;
1229
+ }) {
1230
+ return (
1231
+ <html lang="en">
1232
+ <head />
1233
+ <body>
1234
+ ${chromeRoot}
1235
+ {children}
1236
+ </body>
1237
+ </html>
1238
+ );
1239
+ }
1240
+ `
1241
+ };
1242
+ }
1243
+
1244
+ //#endregion
1245
+ //#region src/scaffold/templates/next-config.ts
1246
+ function nextConfigTemplate() {
1247
+ return {
1248
+ path: "next.config.ts",
1249
+ content: `import type { NextConfig } from "next";
1250
+
1251
+ const nextConfig: NextConfig = {
1252
+ // Transpile workspace packages (needed for linked packages / local development).
1253
+ transpilePackages: ${JSON.stringify(["@riverbankcms/sdk"])},
1254
+
1255
+ // Allow imports from outside the project directory
1256
+ experimental: {
1257
+ externalDir: true,
1258
+ },
1259
+
1260
+ // Add remote origins required by your deployment. The SDK preserves storage
1261
+ // metadata in content responses, so this scaffold does not assume a provider.
1262
+ images: {
1263
+ remotePatterns: [],
1264
+ dangerouslyAllowSVG: true,
1265
+ contentDispositionType: 'inline',
1266
+ },
1267
+ };
1268
+
1269
+ export default nextConfig;
1270
+ `
1271
+ };
1272
+ }
1273
+
1274
+ //#endregion
1275
+ //#region src/scaffold/templates/not-found.ts
1276
+ function notFoundTemplate() {
1277
+ return {
1278
+ path: "src/app/not-found.tsx",
1279
+ content: `import Link from 'next/link';
1280
+
1281
+ export default function NotFound() {
1282
+ return (
1283
+ <div className="min-h-[60vh] flex items-center justify-center px-4">
1284
+ <div className="text-center max-w-md">
1285
+ {/* Large 404 with brand color */}
1286
+ <h1 className="text-[120px] sm:text-[160px] leading-none text-[var(--color-primary,#6d28d9)]/20 font-bold">
1287
+ 404
1288
+ </h1>
1289
+
1290
+ {/* Message */}
1291
+ <h2 className="text-2xl sm:text-3xl text-[var(--color-text,#1f2937)] -mt-4 font-bold">
1292
+ Page not found
1293
+ </h2>
1294
+ <p className="mt-4 text-lg text-[var(--color-text,#1f2937)]/70">
1295
+ Sorry, we couldn&apos;t find the page you&apos;re looking for. It may have been
1296
+ moved or no longer exists.
1297
+ </p>
1298
+
1299
+ {/* Back to home button */}
1300
+ <Link
1301
+ href="/"
1302
+ className="inline-block mt-8 px-8 py-3 bg-[var(--color-primary,#6d28d9)] text-white rounded-full transition-colors hover:opacity-90 font-medium"
1303
+ >
1304
+ Back to Home
1305
+ </Link>
1306
+ </div>
1307
+ </div>
1308
+ );
1309
+ }
1310
+ `
1311
+ };
1312
+ }
1313
+
1314
+ //#endregion
1315
+ //#region src/scaffold/templates/portal-callback-route.ts
1316
+ function portalCallbackRouteTemplate() {
1317
+ return {
1318
+ path: "src/app/api/portal/callback/route.ts",
1319
+ content: `/**
1320
+ * Portal callback route.
1321
+ *
1322
+ * Exchanges a short-lived portal code for a session token, stores the token in
1323
+ * an httpOnly cookie, and redirects back to the requested portal path.
1324
+ */
1325
+
1326
+ import { handlePortalCallback } from '@riverbankcms/sdk/portal';
1327
+
1328
+ export const GET = handlePortalCallback();
1329
+ `
1330
+ };
1331
+ }
1332
+
1333
+ //#endregion
1334
+ //#region src/scaffold/templates/portal-logout-route.ts
1335
+ function portalLogoutRouteTemplate() {
1336
+ return {
1337
+ path: "src/app/api/portal/logout/route.ts",
1338
+ content: `/**
1339
+ * Portal logout route.
1340
+ *
1341
+ * Invalidates the portal session upstream when possible and always clears the
1342
+ * local portal cookie before redirecting.
1343
+ */
1344
+
1345
+ import { handlePortalLogout } from '@riverbankcms/sdk/portal';
1346
+
1347
+ export const POST = handlePortalLogout();
1348
+ `
1349
+ };
1350
+ }
1351
+
1352
+ //#endregion
1353
+ //#region src/scaffold/templates/preview-keep-alive-route.ts
1354
+ function previewKeepAliveRouteTemplate() {
1355
+ return {
1356
+ path: "src/app/api/preview/keep-alive/route.ts",
1357
+ content: `import type { NextRequest } from 'next/server'
1358
+
1359
+ import { createPreviewKeepAliveRouteHandler } from '@riverbankcms/sdk/preview-next/keep-alive'
1360
+ import { getCmsConfig } from '@/lib/env'
1361
+
1362
+ const handler = createPreviewKeepAliveRouteHandler({
1363
+ dashboardUrl: getCmsConfig().dashboardUrl,
1364
+ })
1365
+
1366
+ export function GET(request: NextRequest) {
1367
+ return handler(request)
1368
+ }
1369
+
1370
+ export function POST(request: NextRequest) {
1371
+ return handler(request)
1372
+ }
1373
+ `
1374
+ };
1375
+ }
1376
+
1377
+ //#endregion
1378
+ //#region src/scaffold/templates/preview-mode-client.ts
1379
+ function previewModeClientTemplate() {
1380
+ return {
1381
+ path: "src/components/preview/SdkPreviewModeClient.tsx",
1382
+ content: `'use client'
1383
+
1384
+ import { PreviewQueryProvider, SdkPreviewMode, type SdkPreviewModePropsWithoutBlockOverrides } from '@riverbankcms/sdk/preview-next/client'
1385
+
1386
+ import { blockOverrides } from '@/lib/block-overrides'
1387
+ import { SITE_RENDERING } from '@/lib/rendering'
1388
+
1389
+ export function SdkPreviewModeClient(props: SdkPreviewModePropsWithoutBlockOverrides) {
1390
+ const { themeRuntime: _themeRuntime, ...previewProps } = props
1391
+
1392
+ return (
1393
+ <PreviewQueryProvider>
1394
+ <SdkPreviewMode
1395
+ {...previewProps}
1396
+ rendering={SITE_RENDERING}
1397
+ blockOverrides={blockOverrides}
1398
+ />
1399
+ </PreviewQueryProvider>
1400
+ )
1401
+ }
1402
+ `
1403
+ };
1404
+ }
1405
+
1406
+ //#endregion
1407
+ //#region src/scaffold/templates/proxy.ts
1408
+ function proxyTemplate(config) {
1409
+ const withPreviewMode = Boolean(config?.withPreviewMode);
1410
+ const previewImport = withPreviewMode ? `import { handlePreviewTokens } from '@riverbankcms/sdk/preview-next/middleware';
1411
+ import { handleRouteOutcomeRedirect } from '@riverbankcms/sdk/next/middleware';
1412
+
1413
+ import { getCmsConfig } from './lib/env';
1414
+ import { getRiverbankClient, getSiteId } from './lib/riverbank-client';
1415
+ ` : `import { handleRouteOutcomeRedirect } from '@riverbankcms/sdk/next/middleware';
1416
+
1417
+ import { getRiverbankClient, getSiteId } from './lib/riverbank-client';
1418
+ `;
1419
+ const previewBlock = withPreviewMode ? `
1420
+ if (process.env.RIVERBANK_PREVIEW_MODE === 'true') {
1421
+ const { dashboardUrl } = getCmsConfig();
1422
+ const response = await handlePreviewTokens(request, {
1423
+ dashboardUrl: dashboardUrl ?? null,
1424
+ onExchangeFailure: (failure) => {
1425
+ console.warn('[riverbank] preview token exchange failed', failure);
1426
+ },
1427
+ });
1428
+ if (response) return response;
1429
+ }
1430
+
1431
+ ` : "\n";
1432
+ return {
1433
+ path: "src/proxy.ts",
1434
+ content: `import { NextResponse } from 'next/server';
1435
+ import type { NextRequest } from 'next/server';
1436
+ ${previewImport}
1437
+
1438
+ /**
1439
+ * Proxy to handle system paths that shouldn't go through the CMS.
1440
+ *
1441
+ * Chrome DevTools and other tools request paths like:
1442
+ * - /.well-known/appspecific/com.chrome.devtools.json
1443
+ * - /apple-touch-icon-*.png
1444
+ *
1445
+ * Without this, these requests hit the catch-all route and cause
1446
+ * noisy error logs when the CMS can't find them.
1447
+ */
1448
+ export async function proxy(request: NextRequest) {
1449
+ const { pathname } = request.nextUrl;
1450
+
1451
+ ${previewBlock} // System paths that shouldn't go through the CMS
1452
+ const systemPaths = [
1453
+ /^\\/\\.well-known\\//,
1454
+ /^\\/apple-touch-icon/,
1455
+ /^\\/browserconfig\\.xml$/,
1456
+ ];
1457
+
1458
+ if (systemPaths.some(pattern => pattern.test(pathname))) {
1459
+ return new Response(null, { status: 404 });
1460
+ }
1461
+
1462
+ const redirectResponse = await handleRouteOutcomeRedirect(request, {
1463
+ client: getRiverbankClient(),
1464
+ siteId: getSiteId(),
1465
+ onFailure: (error, context) => {
1466
+ console.warn('[riverbank] route outcome redirect check failed', {
1467
+ ...context,
1468
+ error,
1469
+ });
1470
+ },
1471
+ });
1472
+ if (redirectResponse) return redirectResponse;
1473
+
1474
+ return NextResponse.next();
1475
+ }
1476
+
1477
+ export const config = {
1478
+ matcher: [
1479
+ // Only run on paths that might be system paths
1480
+ // Exclude _next, api, and common static files
1481
+ '/((?!_next/static|_next/image|api|favicon.ico|robots.txt|sitemap.xml).*)',
1482
+ ],
1483
+ };
1484
+ `
1485
+ };
1486
+ }
1487
+
1488
+ //#endregion
1489
+ //#region src/scaffold/templates/readme.ts
1490
+ function gettingStartedSection(config) {
1491
+ const { cli: cliRange } = config.packageRanges;
1492
+ const previewEnvironmentRows = config.withPreviewMode ? `| \`RIVERBANK_PREVIEW_MODE\` | Set to \`true\` to fetch draft content |
1493
+ | \`RIVERBANK_LOCAL_PREVIEW_API_KEY\` | Local preview key |
1494
+ ` : "";
1495
+ const previewSection = config.withPreviewMode ? `## Preview Mode
1496
+
1497
+ To preview unpublished content:
1498
+
1499
+ 1. Set \`RIVERBANK_PREVIEW_MODE=true\` in \`.env.local\`
1500
+ 2. Ensure \`riverbank.config.ts\` has \`dashboard.previewEditingEnabled: true\` and push config so dashboard preview links include Riverbank preview sessions
1501
+ 3. Restart the dev server (\`pnpm dev\`)
1502
+
1503
+ Preview mode:
1504
+ - Fetches draft content instead of published
1505
+ - Disables ISR caching for real-time updates
1506
+ - Uses the preview API key for authentication
1507
+ - Uses \`PreviewQueryProvider\` (React Query) for preview shell state
1508
+
1509
+ ` : "";
1510
+ return `# ${config.siteName}
1511
+
1512
+ A Next.js site powered by Riverbank CMS SDK with host-owned rendering.
1513
+
1514
+ ## Getting Started
1515
+
1516
+ \`\`\`bash
1517
+ # Install dependencies. The SDK and CLI are separate packages.
1518
+ pnpm install
1519
+
1520
+ # Start development server
1521
+ pnpm dev
1522
+ \`\`\`
1523
+
1524
+ Open [http://localhost:${config.devPort}](http://localhost:${config.devPort}) to view your site.
1525
+
1526
+ ## Content Workflow
1527
+
1528
+ \`riverbank.config.ts\` is for schema and configuration: content types, custom blocks, block field options, and preview settings.
1529
+
1530
+ Document content lives in \`content/**\`:
1531
+
1532
+ \`\`\`
1533
+ content/
1534
+ pages/home.json # CMS pages
1535
+ entries/<type>/<entry>.json # Content entries
1536
+ navigation.json # Navigation menus
1537
+ settings.json # Site settings such as homepage/SEO defaults
1538
+ media/ # Local media cache / migration working files
1539
+ \`\`\`
1540
+
1541
+ \`content/media/*\` is gitignored by default because it can hold large downloaded
1542
+ or migration-working files. Content JSON should reference media by identifier.
1543
+ If you intentionally want to version a few small fixtures, put them under
1544
+ \`content/media/fixtures/\`.
1545
+
1546
+ Use this flow after editing schema or starter content:
1547
+
1548
+ \`\`\`bash
1549
+ pnpm exec riverbankcms push-config --env=local
1550
+ pnpm exec riverbankcms push --env=local --with-config
1551
+ \`\`\`
1552
+
1553
+ \`--with-config\` syncs schema/config alongside the file-based document push. It does not push document content from \`riverbank.config.ts\`.
1554
+
1555
+ The generated project declares both packages explicitly: \`@riverbankcms/sdk\`
1556
+ provides the runtime and \`riverbankcms\` provides the command-line tools.
1557
+ The CLI is released independently; the generated \`${cliRange}\` range is derived
1558
+ from the published package contract. Run commands through \`pnpm exec riverbankcms\`
1559
+ to use the project-local binary.
1560
+
1561
+ ## Environment Variables
1562
+
1563
+ The \`.env.local\` file contains your CMS configuration:
1564
+
1565
+ | Variable | Description |
1566
+ |----------|-------------|
1567
+ | \`RIVERBANK_ENV\` | Environment selector (\`local\` or \`remote\`) |
1568
+ ${previewEnvironmentRows}
1569
+ | \`RIVERBANK_LOCAL_SITE_ID\` | Site ID for local dashboard |
1570
+ | \`RIVERBANK_LOCAL_DASHBOARD_URL\` | Local dashboard URL |
1571
+ | \`RIVERBANK_LOCAL_API_KEY\` | Local published-content key |
1572
+ | \`RIVERBANK_LOCAL_MGMT_API_KEY\` | Local management key (for push) |
1573
+ | \`RIVERBANK_WEBHOOK_SECRET\` | Signing secret, added by post-deployment webhook setup |
1574
+
1575
+ ${previewSection}
1576
+ `;
1577
+ }
1578
+ function renderingSection() {
1579
+ return `## Fully Headless Rendering
1580
+
1581
+ Generated sites use override-only rendering with no CMS theme or SDK runtime styling. The canonical choice lives in \`src/lib/rendering.ts\`; keep using \`SITE_RENDERING\` in any additional CMS route or preview entry point.
1582
+
1583
+ The starter homepage and override registry are intentionally empty. Add a typed registration in \`src/lib/block-overrides.tsx\` for every block kind that content uses. The SDK raises \`MissingBlockOverrideError\` for a missing registration instead of silently falling back to a built-in renderer.
1584
+
1585
+ Built-in semantic kinds are namespaced as \`block.*\` (for example \`block.hero\`, \`block.body-text\`, \`block.media-text\`, \`block.image-gallery\`, \`block.cta-full\`, and \`block.embed\`). The override registry accepts their short names too, such as \`hero\` or \`body-text\`, alongside your \`custom.*\` kinds.
1586
+
1587
+ Stateful kinds such as forms, appointment booking, event registration, and commerce need host-owned UI without reimplementing Riverbank-owned behavior. Where a public headless controller exists, compose it behind the server-safe override; the SDK currently exposes booking and event-registration controllers under \`@riverbankcms/sdk/bookings\`. Controller coverage varies by block, so check the public SDK surface rather than assuming every stateful kind is ready for a custom UI.
1588
+
1589
+ `;
1590
+ }
1591
+ function customizationSection() {
1592
+ return `## Customization
1593
+
1594
+ ### Block Overrides
1595
+
1596
+ Register host-owned block rendering. Keep small server-safe overrides as direct components, and use \`defineLazyBlockOverride()\` for client-heavy or rarely used custom blocks so the catch-all route does not eagerly import every block implementation:
1597
+
1598
+ \`\`\`tsx
1599
+ // In src/lib/block-overrides.tsx
1600
+ import {
1601
+ defineBlockOverrideRegistrations,
1602
+ defineLazyBlockOverride,
1603
+ type BlockOverrideRegistrations,
1604
+ } from '@riverbankcms/sdk/rendering';
1605
+ import { CustomHero } from '@/components/CustomHero';
1606
+
1607
+ export const blockOverrides = defineBlockOverrideRegistrations({
1608
+ hero: CustomHero,
1609
+ 'custom.quiz': defineLazyBlockOverride(async () =>
1610
+ import('@/components/blocks/QuizBlock').then((module) => module.QuizBlock),
1611
+ ),
1612
+ }) satisfies BlockOverrideRegistrations;
1613
+ \`\`\`
1614
+
1615
+ Keep the \`import()\` inside the lazy factory. Importing a heavy client block at the top of the file and then wrapping it is still eager from the bundler's point of view.
1616
+
1617
+ ### Server-only Block Data
1618
+
1619
+ Use \`src/lib/data-loader-overrides.server.ts\` when a custom block needs data from your own database or API. The generated catch-all already passes this registry to \`createCatchAllPage()\`, and the registry imports Next.js's \`server-only\` marker so accidental Client Component imports fail the build.
1620
+
1621
+ Loaders receive the resolved block content, URL search parameters, preview stage, and page/template identity. Return JSON-safe values only: strings, finite numbers, booleans, null, arrays, and plain objects. Functions, class instances, dates, symbols, \`undefined\`, and cyclic values are rejected at runtime while the page continues rendering.
1622
+
1623
+ \`generateMetadata()\` never runs these host loaders, including when you provide \`customMetadata\`. Keep secrets and database clients inside the marked registry.
1624
+
1625
+ ### Public Performance Checklist
1626
+
1627
+ - Avoid broad client imports such as \`@riverbankcms/sdk/rendering/client\` or \`@riverbankcms/blocks/client\` in route-level client code; prefer server-safe SDK imports or narrow block subpaths.
1628
+ - Do not mark decorative images as \`priority\` in \`next/image\`. Use CSS backgrounds or plain decorative images with empty alt text for non-content flourishes so Lighthouse does not treat them as the LCP candidate.
1629
+ - After adding a client-heavy block, run \`pnpm build\` and inspect the production bundle. The SDK keeps built-in islands lazy, but your site code can still accidentally import custom widgets eagerly.
1630
+
1631
+ ### Custom Pages
1632
+
1633
+ Add pages outside the CMS by creating files in \`src/app/\`:
1634
+
1635
+ \`\`\`
1636
+ src/app/
1637
+ [[...slug]]/page.tsx # CMS-managed pages (catch-all)
1638
+ custom/page.tsx # Custom page at /custom
1639
+ \`\`\`
1640
+
1641
+ ## Scripts
1642
+
1643
+ | Script | Description |
1644
+ |--------|-------------|
1645
+ | \`pnpm dev\` | Start development server |
1646
+ | \`pnpm build\` | Build for production |
1647
+ | \`pnpm start\` | Start production server |
1648
+ | \`pnpm dev:local\` | Run against local CMS config |
1649
+ | \`pnpm dev:remote\` | Run against remote CMS config |
1650
+ | \`pnpm verify\` | Run lint, typecheck, and tests |
1651
+ | \`pnpm test\` | Run tests |
1652
+ | \`pnpm typecheck\` | Run TypeScript type checking |
1653
+ | \`pnpm cms:push:staging\` | Dry-run push to staging (uses \`.env.staging\`) |
1654
+ | \`pnpm cms:push:staging:apply\` | Apply push to staging |
1655
+
1656
+ ## Staging Push
1657
+
1658
+ 1. Copy \`.env.staging.example\` to \`.env.staging\`
1659
+ 2. Fill in staging values
1660
+ 3. Run \`pnpm cms:push:staging\` first
1661
+ 4. Apply with \`pnpm cms:push:staging:apply\` once output looks correct
1662
+
1663
+ `;
1664
+ }
1665
+ function deploymentSection(config) {
1666
+ return `## Cache revalidation
1667
+
1668
+ The scaffold includes \`/api/revalidate\` but does not create a webhook or secret.
1669
+ After deploying to a public HTTPS origin, run:
1670
+
1671
+ \`\`\`bash
1672
+ riverbankcms webhooks setup --env=remote --url=https://your-site.com/api/revalidate
1673
+ \`\`\`
1674
+
1675
+ Until that explicit setup, the generated pages use the normal ISR interval.
1676
+
1677
+ ## Deployment
1678
+
1679
+ This is a standard Next.js application. Deploy to any platform that supports Next.js:
1680
+
1681
+ - [Vercel](https://vercel.com)
1682
+ - [Netlify](https://netlify.com)
1683
+ - [AWS Amplify](https://aws.amazon.com/amplify/)
1684
+
1685
+ Remember to set your environment variables in the deployment platform.
1686
+
1687
+ ## Learn More
1688
+
1689
+ - [Riverbank SDK Documentation](${config.dashboardUrl}/docs)
1690
+ - [Next.js Documentation](https://nextjs.org/docs)
1691
+ `;
1692
+ }
1693
+ function readmeTemplate(config) {
1694
+ return {
1695
+ path: "README.md",
1696
+ content: [
1697
+ gettingStartedSection(config),
1698
+ renderingSection(),
1699
+ customizationSection(),
1700
+ deploymentSection(config)
1701
+ ].join("\n")
1702
+ };
1703
+ }
1704
+
1705
+ //#endregion
1706
+ //#region src/scaffold/templates/revalidate-route.ts
1707
+ function revalidateRouteTemplate() {
1708
+ return {
1709
+ path: "src/app/api/revalidate/route.ts",
1710
+ content: `/**
1711
+ * Webhook endpoint for CMS cache revalidation
1712
+ *
1713
+ * This route receives webhook notifications from Riverbank CMS
1714
+ * and invalidates the appropriate Next.js cache entries.
1715
+ *
1716
+ * After deploying, register this endpoint explicitly with:
1717
+ * riverbankcms webhooks setup --env=remote --url=https://your-site.com/api/revalidate
1718
+ * Init does not register a webhook or create a signing secret.
1719
+ */
1720
+
1721
+ import { createRevalidateHandler } from '@riverbankcms/sdk/next/revalidate';
1722
+ import { getRiverbankClient } from '@/lib/riverbank-client';
1723
+
1724
+ /**
1725
+ * POST handler for webhook requests
1726
+ *
1727
+ * The handler:
1728
+ * 1. Verifies the webhook signature using HMAC-SHA256
1729
+ * 2. Calls revalidateTag() for each cache tag in the payload
1730
+ * 3. Calls revalidatePath() if a path is included
1731
+ * 4. Best-effort clears this process's SDK client response cache
1732
+ * 5. Returns a JSON response with revalidation results
1733
+ */
1734
+ export const POST = createRevalidateHandler({
1735
+ getClient: getRiverbankClient,
1736
+ // Optional: Add logging or analytics
1737
+ onRevalidate: (payload, items) => {
1738
+ console.log(\`[Revalidate] \${payload.event}: \${items.join(', ')}\`);
1739
+ },
1740
+ });
1741
+ `
1742
+ };
1743
+ }
1744
+
1745
+ //#endregion
1746
+ //#region src/scaffold/templates/riverbank-client.ts
1747
+ function riverbankClientTemplate(config) {
1748
+ const previewConfig = config.withPreviewMode ? ` const { dashboardUrl, apiKey, previewApiKey, env, previewMode } = getCmsConfig();
1749
+
1750
+ // Use preview API key when in preview mode, otherwise use live key
1751
+ const key = previewMode ? previewApiKey : apiKey;
1752
+
1753
+ if (!key) {
1754
+ const keyName = previewMode ? 'PREVIEW_API_KEY' : 'API_KEY';
1755
+ throw new Error(
1756
+ \`RIVERBANK_\${env.toUpperCase()}_\${keyName} is not set. Check your .env.local file.\`,
1757
+ );
1758
+ }
1759
+ ` : ` const { dashboardUrl, apiKey, env } = getCmsConfig();
1760
+ const key = apiKey;
1761
+
1762
+ if (!key) {
1763
+ throw new Error(
1764
+ \`RIVERBANK_\${env.toUpperCase()}_API_KEY is not set. Check your .env.local file.\`,
1765
+ );
1766
+ }
1767
+ `;
1768
+ const cacheConfig = config.withPreviewMode ? ` enabled: !previewMode, // Disable caching in preview mode
1769
+ ` : ` enabled: true,
1770
+ `;
1771
+ const previewExport = config.withPreviewMode ? `
1772
+ /** Check if preview mode is enabled. */
1773
+ export function isPreviewMode(): boolean {
1774
+ return getCmsConfig().previewMode;
1775
+ }
1776
+ ` : "";
1777
+ return {
1778
+ path: "src/lib/riverbank-client.ts",
1779
+ content: `import { createRiverbankClient, type RiverbankClient } from '@riverbankcms/sdk/server';
1780
+ import config from '../../riverbank.config';
1781
+ import { getCmsConfig } from './env';
1782
+
1783
+ /**
1784
+ * Singleton client instance.
1785
+ *
1786
+ * The client is created once per process and reused for all subsequent calls.
1787
+ * This provides shared caching across all SDK operations, improving performance
1788
+ * by avoiding redundant API calls within the cache TTL window.
1789
+ *
1790
+ * Note: In Next.js server components, each request typically runs in a fresh
1791
+ * context, so cache is effectively per-request unless using persistent processes.
1792
+ * The singleton pattern primarily benefits scenarios with multiple SDK calls
1793
+ * within a single request or when running in long-lived processes.
1794
+ */
1795
+ let clientInstance: RiverbankClient | null = null;
1796
+
1797
+ /**
1798
+ * Get the configured Riverbank SDK client (singleton).
1799
+ *
1800
+ * Returns a cached client instance, creating one if it doesn't exist.
1801
+ * The client handles:
1802
+ * - API authentication via environment-specific API key
1803
+ * - Request caching (300s TTL, 100 entries max, shared across all calls)
1804
+ * - Type-safe API responses
1805
+ *
1806
+ * Configuration is read based on RIVERBANK_ENV (local | remote).
1807
+ *
1808
+ * @returns The singleton RiverbankClient instance
1809
+ * @throws Error if required environment variables are not set
1810
+ */
1811
+ export function getRiverbankClient(): RiverbankClient {
1812
+ if (clientInstance) return clientInstance;
1813
+
1814
+ ${previewConfig}
1815
+
1816
+ if (!dashboardUrl) {
1817
+ throw new Error(
1818
+ \`RIVERBANK_\${env.toUpperCase()}_DASHBOARD_URL is not set. Check your .env.local file.\`
1819
+ );
1820
+ }
1821
+
1822
+ clientInstance = createRiverbankClient({
1823
+ apiKey: key,
1824
+ baseUrl: \`\${dashboardUrl}/api\`,
1825
+ cache: {
1826
+ ${cacheConfig}
1827
+ ttl: 300,
1828
+ maxSize: 100,
1829
+ },
1830
+ });
1831
+
1832
+ return clientInstance;
1833
+ }
1834
+
1835
+ /**
1836
+ * Get the site ID from riverbank.config.ts
1837
+ */
1838
+ export function getSiteId(): string {
1839
+ return config.siteId;
1840
+ }
1841
+
1842
+ /**
1843
+ * Check if preview mode is enabled
1844
+ */
1845
+ ${previewExport}
1846
+ `
1847
+ };
1848
+ }
1849
+
1850
+ //#endregion
1851
+ //#region src/scaffold/templates/riverbank-config.ts
1852
+ function riverbankConfigTemplate(config) {
1853
+ const previewDashboardConfig = config.withPreviewMode ? `
1854
+ // Dashboard preview links should pass short-lived auth tokens for editable preview mode.
1855
+ dashboard: {
1856
+ previewEditingEnabled: true,
1857
+ },
1858
+ ` : "";
1859
+ const portalConfig = config.withPortalAuth ? `
1860
+ // Enable portal session token plumbing for customer portal blocks.
1861
+ enablePortal: true,
1862
+ ` : "";
1863
+ return {
1864
+ path: "riverbank.config.ts",
1865
+ content: `import { defineConfig } from '@riverbankcms/sdk/config';
1866
+ import { getCmsConfig } from './src/lib/env';
1867
+ import { contentTypes } from './src/config/content-types';
1868
+ import { customBlocks } from './src/config/custom-blocks';
1869
+ import { blockFieldExtensions, blockFieldOptions } from './src/config/block-extensions';
1870
+
1871
+ const { siteId, env } = getCmsConfig();
1872
+
1873
+ if (!siteId) {
1874
+ throw new Error(
1875
+ \`RIVERBANK_\${env.toUpperCase()}_SITE_ID is not set. Check your .env.local file.\`
1876
+ );
1877
+ }
1878
+
1879
+ export default defineConfig({
1880
+ siteId,
1881
+ ${previewDashboardConfig}
1882
+ ${portalConfig}
1883
+
1884
+ // Schema/config surfaces sync with \`riverbankcms push-config\`.
1885
+ // Document content lives in content/** and syncs with \`riverbankcms push\`.
1886
+ content: {
1887
+ contentTypes,
1888
+ },
1889
+
1890
+ // Block field options (customize existing field options)
1891
+ blockFieldOptions,
1892
+
1893
+ // Block field extensions (add custom fields to built-in blocks)
1894
+ blockFieldExtensions,
1895
+
1896
+ // Custom blocks
1897
+ customBlocks,
1898
+
1899
+ // Sync configuration - allow updating existing entries
1900
+ sync: {
1901
+ existingEntries: 'update',
1902
+ },
1903
+
1904
+ });
1905
+ `
1906
+ };
1907
+ }
1908
+
1909
+ //#endregion
1910
+ //#region src/scaffold/templates/rendering.ts
1911
+ function renderingTemplate() {
1912
+ return {
1913
+ path: "src/lib/rendering.ts",
1914
+ content: `import type { SdkOverrideOnlyRendering } from '@riverbankcms/sdk/rendering';
1915
+
1916
+ /** The host owns all block rendering and the runtime styling wrapper. */
1917
+ export const SITE_RENDERING = {
1918
+ kind: 'overrides-only',
1919
+ runtime: { kind: 'none' },
1920
+ } satisfies SdkOverrideOnlyRendering;
1921
+ `
1922
+ };
1923
+ }
1924
+
1925
+ //#endregion
1926
+ //#region src/scaffold/templates/starter-content.ts
1927
+ function jsonTemplate(path$1, value) {
1928
+ return {
1929
+ path: path$1,
1930
+ content: `${JSON.stringify(value, null, 2)}\n`
1931
+ };
1932
+ }
1933
+ function richTextDoc(text) {
1934
+ return {
1935
+ type: "doc",
1936
+ content: [{
1937
+ type: "paragraph",
1938
+ content: [{
1939
+ type: "text",
1940
+ text
1941
+ }]
1942
+ }]
1943
+ };
1944
+ }
1945
+ function slugifySiteName(siteName) {
1946
+ const slug = siteName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1947
+ return slug || "starter-site";
1948
+ }
1949
+ function starterEntryTemplates(config) {
1950
+ const templates = [];
1951
+ const siteSlug = slugifySiteName(config.siteName);
1952
+ const contentTypes = config.contentTypes ?? {};
1953
+ if (contentTypes.withBlog) {
1954
+ templates.push(jsonTemplate("content/entries/post/welcome.json", {
1955
+ identifier: "welcome",
1956
+ slug: "welcome",
1957
+ status: "draft",
1958
+ data: {
1959
+ title: `Welcome to ${config.siteName}`,
1960
+ excerpt: "A short starter post you can edit or replace.",
1961
+ content: richTextDoc(`Use this first post to introduce ${config.siteName}.`),
1962
+ author: "Team"
1963
+ }
1964
+ }));
1965
+ }
1966
+ if (contentTypes.withTestimonials) {
1967
+ templates.push(jsonTemplate("content/entries/testimonial/example-testimonial.json", {
1968
+ identifier: "example-testimonial",
1969
+ status: "draft",
1970
+ data: {
1971
+ name: "Example Customer",
1972
+ jobTitle: "Founder",
1973
+ company: config.siteName,
1974
+ body: richTextDoc("Add a real testimonial here when you are ready.")
1975
+ }
1976
+ }));
1977
+ }
1978
+ if (contentTypes.withEvents) {
1979
+ templates.push(jsonTemplate("content/entries/event/starter-event.json", {
1980
+ identifier: "starter-event",
1981
+ slug: "starter-event",
1982
+ status: "draft",
1983
+ data: {
1984
+ title: "Starter event",
1985
+ excerpt: "A starter event entry you can edit or replace.",
1986
+ startsAt: "2026-01-01T10:00:00.000Z",
1987
+ endsAt: "2026-01-01T11:00:00.000Z",
1988
+ location: "Online",
1989
+ content: richTextDoc("Add the event details here.")
1990
+ }
1991
+ }));
1992
+ }
1993
+ if (contentTypes.withPortfolio) {
1994
+ templates.push(jsonTemplate("content/entries/portfolio-item/starter-project.json", {
1995
+ identifier: "starter-project",
1996
+ slug: "starter-project",
1997
+ status: "draft",
1998
+ data: {
1999
+ title: `${config.siteName} project`,
2000
+ "short-description": "A starter portfolio item you can edit or replace.",
2001
+ "full-description": richTextDoc(`Describe a ${siteSlug} project here.`),
2002
+ content: richTextDoc("Add the project story here.")
2003
+ }
2004
+ }));
2005
+ }
2006
+ return templates;
2007
+ }
2008
+ function starterContentTemplates(config) {
2009
+ return [
2010
+ jsonTemplate("content/pages/home.json", {
2011
+ identifier: "home",
2012
+ title: config.siteName,
2013
+ path: "/",
2014
+ status: "published",
2015
+ blocks: []
2016
+ }),
2017
+ jsonTemplate("content/navigation.json", { menus: [{
2018
+ identifier: "main",
2019
+ name: "Main",
2020
+ isPrimary: true,
2021
+ items: [{
2022
+ label: "Home",
2023
+ link: {
2024
+ kind: "page",
2025
+ identifier: "home"
2026
+ }
2027
+ }]
2028
+ }] }),
2029
+ jsonTemplate("content/settings.json", {
2030
+ homepageId: "home",
2031
+ seoDefaults: {
2032
+ title: config.siteName,
2033
+ description: `${config.siteName} website`,
2034
+ allowIndexingInProduction: true
2035
+ }
2036
+ }),
2037
+ ...starterEntryTemplates(config)
2038
+ ];
2039
+ }
2040
+
2041
+ //#endregion
2042
+ //#region src/scaffold/templates/index.ts
2043
+ function generateAllTemplates(config) {
2044
+ const preview = config.withPreviewMode ? [previewKeepAliveRouteTemplate(), previewModeClientTemplate()] : [];
2045
+ const portal = config.withPortalAuth ? [portalCallbackRouteTemplate(), portalLogoutRouteTemplate()] : [];
2046
+ return [
2047
+ gitignoreTemplate(),
2048
+ envConfigTemplate(config),
2049
+ envExampleTemplate(config),
2050
+ envStagingExampleTemplate(),
2051
+ riverbankConfigTemplate(config),
2052
+ riverbankClientTemplate(config),
2053
+ renderingTemplate(),
2054
+ nextConfigTemplate(),
2055
+ globalsCssTemplate(config),
2056
+ layoutTemplate(config),
2057
+ catchAllPageTemplate(config),
2058
+ errorTemplate(),
2059
+ notFoundTemplate(),
2060
+ proxyTemplate({ withPreviewMode: config.withPreviewMode }),
2061
+ ...preview,
2062
+ revalidateRouteTemplate(),
2063
+ ...portal,
2064
+ cmsPushStagingTemplate(),
2065
+ contentTypesTemplate(config.contentTypes ?? {}),
2066
+ customBlocksTemplate(),
2067
+ blockExtensionsTemplate(),
2068
+ blockOverridesTemplate(),
2069
+ dataLoaderOverridesTemplate(),
2070
+ ...starterContentTemplates(config),
2071
+ exampleTestTemplate(),
2072
+ readmeTemplate(config),
2073
+ agentsMdTemplate()
2074
+ ];
2075
+ }
2076
+
2077
+ //#endregion
2078
+ //#region src/scaffold/plan.ts
2079
+ const REVALIDATE_ROUTE = "src/app/api/revalidate/route.ts";
2080
+ const PREVIEW_PATHS = new Set(["src/app/api/preview/keep-alive/route.ts", "src/components/preview/SdkPreviewModeClient.tsx"]);
2081
+ function safeScaffoldTargetPath(path$1) {
2082
+ if (!path$1 || path$1.includes("\\") || path$1.includes("\0") || path$1.startsWith("/")) return false;
2083
+ const normalized = path$1.split("/").join(sep);
2084
+ const fromRoot = relative("", normalized);
2085
+ return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !normalized.split(sep).includes("");
2086
+ }
2087
+ function parseText(value, maxLength) {
2088
+ if (typeof value !== "string") return undefined;
2089
+ const normalized = value.trim();
2090
+ return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined;
2091
+ }
2092
+ function parseSlug(value) {
2093
+ const slug = parseText(value, 120);
2094
+ return slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) ? slug : undefined;
2095
+ }
2096
+ function parseDashboardUrl(value) {
2097
+ const text = parseText(value, 2048);
2098
+ if (!text) return undefined;
2099
+ try {
2100
+ const url = new URL(text);
2101
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
2102
+ if (url.username || url.password || url.search || url.hash) return undefined;
2103
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
2104
+ } catch {
2105
+ return undefined;
2106
+ }
2107
+ }
2108
+ function validPackageSource(input) {
2109
+ return input.tag === "npm" && input.ranges.sdk.trim().length > 0 && input.ranges.cli.trim().length > 0;
2110
+ }
2111
+ function validateManifest(files, features) {
2112
+ const seen = new Set();
2113
+ for (const file of files) {
2114
+ if (!safeScaffoldTargetPath(file.path)) return {
2115
+ tag: "unsafe_template_path",
2116
+ path: file.path
2117
+ };
2118
+ if (seen.has(file.path)) return {
2119
+ tag: "duplicate_template_path",
2120
+ path: file.path
2121
+ };
2122
+ seen.add(file.path);
2123
+ }
2124
+ if (!seen.has(REVALIDATE_ROUTE)) return { tag: "missing_revalidate_route" };
2125
+ const hasPreviewPaths = [...seen].some((path$1) => PREVIEW_PATHS.has(path$1));
2126
+ const hasPreviewImports = files.filter((file) => PREVIEW_PATHS.has(file.path)).some((file) => file.content.includes("@riverbankcms/sdk/preview-next"));
2127
+ if (features.preview === "enabled" && (!hasPreviewPaths || !hasPreviewImports)) return { tag: "preview_manifest_mismatch" };
2128
+ if (features.preview === "disabled" && (hasPreviewPaths || hasPreviewImports)) return { tag: "preview_manifest_mismatch" };
2129
+ return undefined;
2130
+ }
2131
+ function deriveScaffoldPlan(input) {
2132
+ const localManifest = deriveScaffoldLocalManifest({
2133
+ projectPath: input.projectPath,
2134
+ features: input.features
2135
+ });
2136
+ if (!localManifest.ok) return err(localManifest.error);
2137
+ const { projectPath, features } = localManifest.value;
2138
+ const parsedSiteId = parseSiteId(input.siteId);
2139
+ if (!parsedSiteId.ok) return err({
2140
+ tag: "invalid_site_id",
2141
+ value: input.siteId
2142
+ });
2143
+ const siteSlug = parseSlug(input.siteSlug);
2144
+ if (!siteSlug) return err({
2145
+ tag: "invalid_site_slug",
2146
+ value: input.siteSlug
2147
+ });
2148
+ const siteName = parseText(input.siteName, 160);
2149
+ if (!siteName) return err({
2150
+ tag: "invalid_site_name",
2151
+ value: input.siteName
2152
+ });
2153
+ const dashboardUrl = parseDashboardUrl(input.dashboardUrl);
2154
+ if (!dashboardUrl) return err({
2155
+ tag: "invalid_dashboard_url",
2156
+ value: input.dashboardUrl
2157
+ });
2158
+ if (typeof input.devPort !== "number" || !Number.isInteger(input.devPort) || input.devPort < 1 || input.devPort > 65535) {
2159
+ return err({
2160
+ tag: "invalid_dev_port",
2161
+ value: input.devPort
2162
+ });
2163
+ }
2164
+ if (!validPackageSource(input.packageSource)) {
2165
+ return err({
2166
+ tag: "invalid_package_source",
2167
+ field: input.packageSource.ranges.sdk.trim().length > 0 ? "cli" : "sdk"
2168
+ });
2169
+ }
2170
+ const templateConfig = {
2171
+ siteId: parsedSiteId.value,
2172
+ siteSlug,
2173
+ dashboardUrl,
2174
+ siteName,
2175
+ devPort: input.devPort,
2176
+ packageRanges: input.packageSource.ranges,
2177
+ withPreviewMode: features.preview === "enabled",
2178
+ withPortalAuth: features.portal === "enabled",
2179
+ contentTypes: features.contentTypes
2180
+ };
2181
+ const files = generateAllTemplates(templateConfig).map((file) => Object.freeze({ ...file }));
2182
+ const manifestError = validateManifest(files, features);
2183
+ if (manifestError) return err(manifestError);
2184
+ const credentialRequirements = features.preview === "enabled" ? {
2185
+ tag: "with_preview",
2186
+ live: true,
2187
+ management: true,
2188
+ preview: true
2189
+ } : {
2190
+ tag: "standard",
2191
+ live: true,
2192
+ management: true,
2193
+ preview: false
2194
+ };
2195
+ return ok(Object.freeze({
2196
+ projectPath,
2197
+ siteId: parsedSiteId.value,
2198
+ siteSlug,
2199
+ siteName,
2200
+ dashboardUrl,
2201
+ devPort: input.devPort,
2202
+ features,
2203
+ packageSource: Object.freeze({
2204
+ tag: "npm",
2205
+ ranges: Object.freeze({ ...input.packageSource.ranges })
2206
+ }),
2207
+ credentialRequirements,
2208
+ files: Object.freeze(files)
2209
+ }));
2210
+ }
2211
+
2212
+ //#endregion
2213
+ export { prepareScaffoldExecution as a, LOCAL_PACKAGE_VENDOR_DIR as c, loadGeneratedPackageRanges as d, preflightScaffoldTargets as i, deriveGeneratedPackageRanges as l, createScaffoldCompletion as n, writeScaffoldFiles as o, preflightScaffoldPlan as r, deriveScaffoldLocalManifest as s, deriveScaffoldPlan as t, findPackageManifestPath as u };