@olonjs/cli 3.0.153 → 3.0.155

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,4174 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ echo "Starting project reconstruction..."
5
+
6
+ mkdir -p "app"
7
+ mkdir -p "app/[[...slug]]"
8
+ echo "Creating app/[[...slug]]/page.tsx..."
9
+ cat << 'END_OF_FILE_CONTENT' > "app/[[...slug]]/page.tsx"
10
+ import type { Metadata } from 'next';
11
+ import { notFound } from 'next/navigation';
12
+ import {
13
+ resolveCollectionContext,
14
+ resolveRuntimeConfig,
15
+ type PageConfig,
16
+ } from '@olonjs/core';
17
+ import { loadVisitorPage } from '@olonjs/next/server';
18
+ import { EmptyTenantView } from '@/components/empty-tenant';
19
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
20
+ import { getFileCollections } from '@/lib/loaders/getFileCollections';
21
+ import { getFilePages } from '@/lib/loaders/getFilePages';
22
+ import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
23
+ import { resolveVisitorShell } from '@/lib/resolveVisitorShell';
24
+ import { VisitorSection } from '@/lib/VisitorSection';
25
+
26
+ export const dynamic = 'force-dynamic';
27
+
28
+ type PageProps = {
29
+ params: Promise<{ slug?: string[] }>;
30
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
31
+ };
32
+
33
+ function slugFromParams(segments: string[] | undefined): string {
34
+ if (!segments || segments.length === 0) return 'home';
35
+ return segments.map((s) => decodeURIComponent(s)).join('/');
36
+ }
37
+
38
+ function firstSearchValue(value: string | string[] | undefined): string | null {
39
+ if (Array.isArray(value)) return value[0] ?? null;
40
+ return value ?? null;
41
+ }
42
+
43
+ function resolveAuthorFilter(
44
+ params: Record<string, string>,
45
+ searchParams: Record<string, string | string[] | undefined>,
46
+ ): string | null {
47
+ if (params.authorId) return params.authorId;
48
+ return firstSearchValue(searchParams.author);
49
+ }
50
+
51
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
52
+ const { slug: segments } = await params;
53
+ const pages = getFilePages();
54
+ const result = loadVisitorPage({ pages, slug: slugFromParams(segments) });
55
+ if (result.kind === 'empty') {
56
+ return { title: 'Empty tenant' };
57
+ }
58
+ if (result.kind === 'not-found') {
59
+ return { title: 'Page not found' };
60
+ }
61
+ return {
62
+ title: result.page.meta?.title ?? 'OlonJS',
63
+ description: result.page.meta?.description,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Public visitor catch-all — RSC only.
69
+ * Must not import @olonjs/studio or JsonPagesEngine (ADR-0017).
70
+ */
71
+ export default async function VisitorCatchAllPage({ params, searchParams }: PageProps) {
72
+ const { slug: segments } = await params;
73
+ const query = await searchParams;
74
+ const requestSlug = slugFromParams(segments);
75
+ const pathname = requestSlug === 'home' ? '/' : `/${requestSlug}`;
76
+
77
+ const pages = getFilePages();
78
+ const result = loadVisitorPage({ pages, slug: requestSlug });
79
+
80
+ if (result.kind === 'empty') {
81
+ return <EmptyTenantView />;
82
+ }
83
+
84
+ if (result.kind === 'not-found') {
85
+ notFound();
86
+ }
87
+
88
+ const collections = getFileCollections();
89
+ const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle();
90
+ const collectionContext = resolveCollectionContext(result.page, result.params, collections);
91
+
92
+ const resolved = resolveRuntimeConfig({
93
+ pages: { [result.registrySlug]: result.page } as Record<string, PageConfig>,
94
+ siteConfig,
95
+ themeConfig,
96
+ menuConfig,
97
+ collections,
98
+ collectionSchemas: CollectionRegistry as never,
99
+ collectionContext,
100
+ refDocuments: {
101
+ 'menu.json': menuConfig,
102
+ 'config/menu.json': menuConfig,
103
+ 'src/data/config/menu.json': menuConfig,
104
+ },
105
+ });
106
+
107
+ const page = resolved.pages[result.registrySlug] ?? result.page;
108
+ const authorId = resolveAuthorFilter(result.params, query);
109
+ const pageNum = Number(firstSearchValue(query.page) ?? '1') || 1;
110
+ const shell = resolveVisitorShell(page, resolved.siteConfig ?? siteConfig);
111
+ const sectionExtras = { authorId, page: pageNum, pathname };
112
+
113
+ return (
114
+ <>
115
+ {shell.header ? (
116
+ <VisitorSection section={shell.header} extras={sectionExtras} />
117
+ ) : null}
118
+ <main>
119
+ {page.sections.map((section) => (
120
+ <VisitorSection
121
+ key={section.id}
122
+ section={section}
123
+ extras={sectionExtras}
124
+ />
125
+ ))}
126
+ </main>
127
+ {shell.footer ? (
128
+ <VisitorSection section={shell.footer} extras={sectionExtras} />
129
+ ) : null}
130
+ </>
131
+ );
132
+ }
133
+
134
+ END_OF_FILE_CONTENT
135
+ mkdir -p "app/admin"
136
+ mkdir -p "app/admin/[[...slug]]"
137
+ echo "Creating app/admin/[[...slug]]/page.tsx..."
138
+ cat << 'END_OF_FILE_CONTENT' > "app/admin/[[...slug]]/page.tsx"
139
+ import { AdminStudioDynamic } from '@/components/admin/AdminStudioDynamic';
140
+ import { getFileCollections } from '@/lib/loaders/getFileCollections';
141
+ import { getFilePages } from '@/lib/loaders/getFilePages';
142
+ import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
143
+
144
+ export const dynamic = 'force-dynamic';
145
+
146
+ /**
147
+ * Admin Studio entry — client island only (ADR-0017).
148
+ * Persistence: local save-to-file, or Save2Repo cold save when NEXT_PUBLIC_* cloud env is set.
149
+ * HotSave is out of scope for Next v1.
150
+ *
151
+ * Studio is loaded with next/dynamic ssr:false because JsonPagesEngine uses
152
+ * createBrowserRouter (needs `document`) and Next still SSRs `'use client'` once.
153
+ */
154
+ export default function AdminCatchAllPage() {
155
+ const pages = getFilePages();
156
+ const collections = getFileCollections();
157
+ const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle();
158
+
159
+ return (
160
+ <AdminStudioDynamic
161
+ initialPages={pages}
162
+ initialSiteConfig={siteConfig}
163
+ initialMenuConfig={menuConfig}
164
+ initialThemeConfig={themeConfig}
165
+ initialCollections={collections}
166
+ />
167
+ );
168
+ }
169
+
170
+ END_OF_FILE_CONTENT
171
+ mkdir -p "app/api"
172
+ mkdir -p "app/api/list-assets"
173
+ echo "Creating app/api/list-assets/route.ts..."
174
+ cat << 'END_OF_FILE_CONTENT' > "app/api/list-assets/route.ts"
175
+ import { NextResponse } from 'next/server';
176
+ import path from 'node:path';
177
+ import { listLocalImages, resolveLocalDataRoots } from '@olonjs/next/server';
178
+
179
+ export async function GET() {
180
+ try {
181
+ const roots = resolveLocalDataRoots(path.resolve(process.cwd()));
182
+ return NextResponse.json(listLocalImages(roots.assetsImagesDir));
183
+ } catch (error) {
184
+ return NextResponse.json(
185
+ { error: error instanceof Error ? error.message : 'List failed' },
186
+ { status: 500 },
187
+ );
188
+ }
189
+ }
190
+
191
+ END_OF_FILE_CONTENT
192
+ mkdir -p "app/api/public-page"
193
+ mkdir -p "app/api/public-page/[...slug]"
194
+ echo "Creating app/api/public-page/[...slug]/route.ts..."
195
+ cat << 'END_OF_FILE_CONTENT' > "app/api/public-page/[...slug]/route.ts"
196
+ import { NextResponse } from 'next/server';
197
+ import path from 'node:path';
198
+ import {
199
+ createPublicPageJsonHttpResult,
200
+ resolvePublicPageJson,
201
+ } from '@olonjs/next/server';
202
+ import { readServerCloudPolicy } from '@/lib/env/serverCloudPolicy';
203
+ import { loadPublicPageBundleForRequest } from '@/lib/loaders/loadPublicPageBundleForRequest';
204
+
205
+ /**
206
+ * Public page JSON API — Local / Static / Live via server cloud policy bootSource
207
+ * (Vite `GET /{slug}.json` parity).
208
+ */
209
+ export async function GET(
210
+ request: Request,
211
+ context: { params: Promise<{ slug?: string[] }> },
212
+ ) {
213
+ try {
214
+ const { slug: parts } = await context.params;
215
+ const slug = (parts ?? []).join('/');
216
+ const policy = readServerCloudPolicy();
217
+ const bundle = await loadPublicPageBundleForRequest({
218
+ bootSource: policy.bootSource,
219
+ slug,
220
+ requestUrl: request.url,
221
+ appRoot: path.resolve(process.cwd()),
222
+ apiUrl: policy.apiUrl,
223
+ apiKey: policy.apiKey,
224
+ });
225
+ const resolved = resolvePublicPageJson({ slug, bundle });
226
+ const http = createPublicPageJsonHttpResult(resolved);
227
+ return NextResponse.json(http.body, { status: http.status });
228
+ } catch (error) {
229
+ return NextResponse.json(
230
+ { error: error instanceof Error ? error.message : 'Page JSON resolution failed' },
231
+ { status: 500 },
232
+ );
233
+ }
234
+ }
235
+
236
+ END_OF_FILE_CONTENT
237
+ mkdir -p "app/api/save-to-file"
238
+ echo "Creating app/api/save-to-file/route.ts..."
239
+ cat << 'END_OF_FILE_CONTENT' > "app/api/save-to-file/route.ts"
240
+ import { NextResponse } from 'next/server';
241
+ import path from 'node:path';
242
+ import {
243
+ resolveLocalDataRoots,
244
+ saveProjectStateToDisk,
245
+ type ProjectStateLike,
246
+ } from '@olonjs/next/server';
247
+
248
+ export async function POST(request: Request) {
249
+ try {
250
+ const body = (await request.json()) as { projectState?: ProjectStateLike; slug?: string };
251
+ if (!body.projectState || typeof body.slug !== 'string') {
252
+ return NextResponse.json({ error: 'Missing projectState or slug' }, { status: 400 });
253
+ }
254
+ const roots = resolveLocalDataRoots(path.resolve(process.cwd()));
255
+ saveProjectStateToDisk(roots, body.projectState, body.slug);
256
+ return NextResponse.json({ ok: true });
257
+ } catch (error) {
258
+ return NextResponse.json(
259
+ { error: error instanceof Error ? error.message : 'Save to file failed' },
260
+ { status: 500 },
261
+ );
262
+ }
263
+ }
264
+
265
+ END_OF_FILE_CONTENT
266
+ mkdir -p "app/api/upload-asset"
267
+ echo "Creating app/api/upload-asset/route.ts..."
268
+ cat << 'END_OF_FILE_CONTENT' > "app/api/upload-asset/route.ts"
269
+ import { NextResponse } from 'next/server';
270
+ import path from 'node:path';
271
+ import { resolveLocalDataRoots, saveUploadedImage } from '@olonjs/next/server';
272
+
273
+ export async function POST(request: Request) {
274
+ try {
275
+ const body = (await request.json()) as {
276
+ filename?: string;
277
+ mimeType?: string;
278
+ data?: string;
279
+ };
280
+ if (!body.filename || typeof body.data !== 'string') {
281
+ return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
282
+ }
283
+ const roots = resolveLocalDataRoots(path.resolve(process.cwd()));
284
+ const result = saveUploadedImage({
285
+ assetsImagesDir: roots.assetsImagesDir,
286
+ filename: body.filename,
287
+ mimeType: body.mimeType,
288
+ base64Data: body.data,
289
+ });
290
+ return NextResponse.json(result);
291
+ } catch (error) {
292
+ const message = error instanceof Error ? error.message : 'Upload failed';
293
+ const status = message.includes('too large') ? 413 : message.includes('Invalid file') ? 400 : 500;
294
+ return NextResponse.json({ error: message }, { status });
295
+ }
296
+ }
297
+
298
+ END_OF_FILE_CONTENT
299
+ echo "Creating app/globals.css..."
300
+ cat << 'END_OF_FILE_CONTENT' > "app/globals.css"
301
+ @import "tailwindcss";
302
+
303
+ /*
304
+ TOKEN BRIDGE only — values come from theme.json via layout <style> (buildThemeVariableMap).
305
+ Do not hardcode --theme-* color literals here.
306
+ */
307
+ :root {
308
+ --background: var(--theme-colors-background);
309
+ --foreground: var(--theme-colors-foreground);
310
+ --muted-foreground: var(--theme-colors-muted-foreground);
311
+ --border: var(--theme-colors-border);
312
+ --card: var(--theme-colors-card);
313
+ --primary: var(--theme-colors-primary);
314
+ --primary-foreground: var(--theme-colors-primary-foreground);
315
+ --theme-font-primary: var(--theme-typography-font-family-primary);
316
+ }
317
+
318
+ @theme inline {
319
+ --color-background: var(--background);
320
+ --color-foreground: var(--foreground);
321
+ --color-muted-foreground: var(--muted-foreground);
322
+ --color-border: var(--border);
323
+ --color-card: var(--card);
324
+ --color-primary: var(--primary);
325
+ --color-primary-foreground: var(--primary-foreground);
326
+ }
327
+
328
+ body {
329
+ background: var(--background);
330
+ color: var(--foreground);
331
+ font-family: var(--theme-font-primary, system-ui, sans-serif);
332
+ }
333
+
334
+ END_OF_FILE_CONTENT
335
+ echo "Creating app/layout.tsx..."
336
+ cat << 'END_OF_FILE_CONTENT' > "app/layout.tsx"
337
+ import type { Metadata } from 'next';
338
+ import type { ReactNode } from 'react';
339
+ import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
340
+ import { serializeThemeRootCss } from '@/lib/css/serializeThemeRootCss';
341
+ import './globals.css';
342
+
343
+ export const metadata: Metadata = {
344
+ title: 'OlonJS Next Starter',
345
+ description: 'RSC visitors + admin client island (ADR-0017)',
346
+ };
347
+
348
+ export default function RootLayout({ children }: { children: ReactNode }) {
349
+ const { themeConfig } = getFileSiteBundle();
350
+ const themeCss = serializeThemeRootCss(themeConfig);
351
+
352
+ return (
353
+ <html lang="en">
354
+ <head>
355
+ {themeCss ? (
356
+ <style id="olon-theme-vars" dangerouslySetInnerHTML={{ __html: themeCss }} />
357
+ ) : null}
358
+ </head>
359
+ <body className="min-h-screen bg-background text-foreground antialiased">{children}</body>
360
+ </html>
361
+ );
362
+ }
363
+
364
+ END_OF_FILE_CONTENT
365
+ echo "Creating app/not-found.tsx..."
366
+ cat << 'END_OF_FILE_CONTENT' > "app/not-found.tsx"
367
+ export default function NotFound() {
368
+ return (
369
+ <main className="mx-auto flex min-h-[50vh] max-w-xl flex-col justify-center px-6 py-16 text-[var(--foreground)]">
370
+ <p className="text-sm opacity-60">404</p>
371
+ <h1 className="mt-2 text-2xl font-semibold tracking-tight">Page not found</h1>
372
+ <p className="mt-3 opacity-80">This route does not match any page in the tenant.</p>
373
+ <a href="/" className="mt-8 text-sm underline underline-offset-4 opacity-80 hover:opacity-100">
374
+ Back to home
375
+ </a>
376
+ </main>
377
+ );
378
+ }
379
+
380
+ END_OF_FILE_CONTENT
381
+ echo "Creating middleware.ts..."
382
+ cat << 'END_OF_FILE_CONTENT' > "middleware.ts"
383
+ import { NextResponse, type NextRequest } from 'next/server';
384
+ import {
385
+ authorizeAdminRequest,
386
+ buildAdminSessionCookie,
387
+ } from '@olonjs/next/admin-gate';
388
+
389
+ /**
390
+ * Admin protection (tenant-alpha Vite middleware parity, no SSO).
391
+ * Active only when VERCEL_ENV is set + ADMIN_PUBLIC_KEY present.
392
+ * Protects Studio HTML and local mutate APIs — not public page JSON.
393
+ */
394
+ export async function middleware(request: NextRequest) {
395
+ const result = await authorizeAdminRequest(request, {
396
+ VERCEL_ENV: process.env.VERCEL_ENV,
397
+ ADMIN_PUBLIC_KEY: process.env.ADMIN_PUBLIC_KEY,
398
+ });
399
+
400
+ if (result.kind === 'bypass' || result.kind === 'allow') {
401
+ return NextResponse.next();
402
+ }
403
+
404
+ if (result.kind === 'set-session') {
405
+ const response = NextResponse.redirect(result.location, 302);
406
+ response.headers.set('Set-Cookie', buildAdminSessionCookie(result.token));
407
+ return response;
408
+ }
409
+
410
+ console.error(`[admin-middleware] 401 reason: ${result.hint}`);
411
+ return new NextResponse('Unauthorized', { status: 401 });
412
+ }
413
+
414
+ export const config = {
415
+ matcher: [
416
+ '/admin',
417
+ '/admin/:path*',
418
+ '/api/save-to-file',
419
+ '/api/upload-asset',
420
+ '/api/list-assets',
421
+ ],
422
+ };
423
+
424
+ END_OF_FILE_CONTENT
425
+ echo "Creating next-env.d.ts..."
426
+ cat << 'END_OF_FILE_CONTENT' > "next-env.d.ts"
427
+ /// <reference types="next" />
428
+ /// <reference types="next/image-types/global" />
429
+ /// <reference path="./.next/types/routes.d.ts" />
430
+
431
+ // NOTE: This file should not be edited
432
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
433
+
434
+ END_OF_FILE_CONTENT
435
+ echo "Creating next.config.ts..."
436
+ cat << 'END_OF_FILE_CONTENT' > "next.config.ts"
437
+ import type { NextConfig } from 'next';
438
+
439
+ /**
440
+ * Keep rewrites inline — next.config is loaded via Node/CJS and cannot reliably
441
+ * import `@olonjs/next/server` (exports are ESM `import`-only).
442
+ * Contract guarded by `buildPublicPageJsonRewrites` unit tests in the package.
443
+ */
444
+ const publicPageJsonRewrites = [
445
+ {
446
+ source: '/pages/:path*.json',
447
+ destination: '/api/public-page/:path*',
448
+ },
449
+ {
450
+ source: '/:path*.json',
451
+ destination: '/api/public-page/:path*',
452
+ },
453
+ ];
454
+
455
+ const nextConfig: NextConfig = {
456
+ reactStrictMode: true,
457
+ transpilePackages: ['@olonjs/next', '@olonjs/core', '@olonjs/react', '@olonjs/studio'],
458
+ async rewrites() {
459
+ return publicPageJsonRewrites;
460
+ },
461
+ };
462
+
463
+ export default nextConfig;
464
+
465
+ END_OF_FILE_CONTENT
466
+ echo "Creating package.json..."
467
+ cat << 'END_OF_FILE_CONTENT' > "package.json"
468
+ {
469
+ "name": "tenant-next",
470
+ "version": "0.0.1",
471
+ "private": true,
472
+ "type": "module",
473
+ "scripts": {
474
+ "dev": "next dev",
475
+ "build": "next build",
476
+ "start": "next start",
477
+ "lint": "next lint",
478
+ "test": "vitest run",
479
+ "typecheck": "tsc --noEmit",
480
+ "dist": "bash ./src2Code.sh --template next app src middleware.ts next.config.ts postcss.config.mjs tsconfig.json package.json next-env.d.ts vitest.config.ts",
481
+ "dist:dna": "npm run dist"
482
+ },
483
+ "dependencies": {
484
+ "@olonjs/core": "^1.1.22",
485
+ "@olonjs/next": "^0.0.2",
486
+ "@olonjs/react": "^0.1.5",
487
+ "@olonjs/studio": "^0.1.5",
488
+ "clsx": "^2.1.1",
489
+ "lucide-react": "^0.474.0",
490
+ "next": "^15.5.0",
491
+ "react": "^19.0.0",
492
+ "react-dom": "^19.0.0",
493
+ "react-router-dom": "^6.29.0",
494
+ "tailwind-merge": "^3.0.1",
495
+ "zod": "^3.24.1"
496
+ },
497
+ "devDependencies": {
498
+ "@tailwindcss/postcss": "^4.0.0",
499
+ "@types/node": "^22.13.1",
500
+ "@types/react": "^19.0.0",
501
+ "@types/react-dom": "^19.0.0",
502
+ "tailwindcss": "^4.0.0",
503
+ "typescript": "^5.7.3",
504
+ "vitest": "^3.0.0"
505
+ }
506
+ }
507
+
508
+ END_OF_FILE_CONTENT
509
+ echo "Creating postcss.config.mjs..."
510
+ cat << 'END_OF_FILE_CONTENT' > "postcss.config.mjs"
511
+ /** @type {import('postcss-load-config').Config} */
512
+ const config = {
513
+ plugins: {
514
+ '@tailwindcss/postcss': {},
515
+ },
516
+ };
517
+
518
+ export default config;
519
+
520
+ END_OF_FILE_CONTENT
521
+ mkdir -p "src"
522
+ mkdir -p "src/collections"
523
+ mkdir -p "src/collections/autori"
524
+ echo "Creating src/collections/autori/index.ts..."
525
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/autori/index.ts"
526
+ export { AutoreSchema, AutoriCollectionSchema } from './schema';
527
+ export type { Autore, AutoriCollection } from './types';
528
+
529
+ END_OF_FILE_CONTENT
530
+ echo "Creating src/collections/autori/schema.ts..."
531
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/autori/schema.ts"
532
+ import { z } from 'zod';
533
+ import { BaseCollectionItem } from '@olonjs/core';
534
+
535
+ export const AutoreSchema = BaseCollectionItem.extend({
536
+ name: z.string().describe('ui:text'),
537
+ });
538
+
539
+ export const AutoriCollectionSchema = z.record(z.string(), AutoreSchema);
540
+
541
+ END_OF_FILE_CONTENT
542
+ echo "Creating src/collections/autori/types.ts..."
543
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/autori/types.ts"
544
+ import { z } from 'zod';
545
+ import { AutoreSchema, AutoriCollectionSchema } from './schema';
546
+
547
+ export type Autore = z.infer<typeof AutoreSchema>;
548
+ export type AutoriCollection = z.infer<typeof AutoriCollectionSchema>;
549
+
550
+ END_OF_FILE_CONTENT
551
+ mkdir -p "src/collections/libri"
552
+ echo "Creating src/collections/libri/index.ts..."
553
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/index.ts"
554
+ export { LibroSchema, LibriCollectionSchema } from './schema';
555
+ export type { Libro, LibriCollection } from './types';
556
+
557
+ END_OF_FILE_CONTENT
558
+ echo "Creating src/collections/libri/schema.ts..."
559
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/schema.ts"
560
+ import { z } from 'zod';
561
+ import { BaseCollectionItem } from '@olonjs/core';
562
+ import { AutoreSchema } from '@/collections/autori';
563
+
564
+ const CollectionRefSchema = z.object({
565
+ $ref: z.string(),
566
+ });
567
+
568
+ export const LibroSchema = BaseCollectionItem.extend({
569
+ title: z.string().describe('ui:text'),
570
+ author: z.union([AutoreSchema, CollectionRefSchema]).describe('ui:collection-ref:autori'),
571
+ year: z.number().describe('ui:number'),
572
+ genre: z.string().describe('ui:text'),
573
+ summary: z.string().describe('ui:textarea'),
574
+ });
575
+
576
+ export const LibriCollectionSchema = z.record(z.string(), LibroSchema);
577
+
578
+ END_OF_FILE_CONTENT
579
+ echo "Creating src/collections/libri/types.ts..."
580
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/types.ts"
581
+ import { z } from 'zod';
582
+ import { LibroSchema, LibriCollectionSchema } from './schema';
583
+
584
+ export type Libro = z.infer<typeof LibroSchema>;
585
+ export type LibriCollection = z.infer<typeof LibriCollectionSchema>;
586
+
587
+ END_OF_FILE_CONTENT
588
+ mkdir -p "src/components"
589
+ mkdir -p "src/components/admin"
590
+ echo "Creating src/components/admin/AdminStudioClient.tsx..."
591
+ cat << 'END_OF_FILE_CONTENT' > "src/components/admin/AdminStudioClient.tsx"
592
+ 'use client';
593
+
594
+ import { useCallback, useMemo, useState, type ReactNode } from 'react';
595
+ import type {
596
+ JsonPagesConfig,
597
+ MenuConfig,
598
+ PageConfig,
599
+ ProjectState,
600
+ SiteConfig,
601
+ ThemeConfig,
602
+ } from '@olonjs/core';
603
+ import { AdminIsland } from '@olonjs/next/client';
604
+ import { addSectionConfig } from '@/lib/addSectionConfig';
605
+ import { hydrateLocalProjectState } from '@/lib/admin/hydrateLocalProjectState';
606
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
607
+ import { ComponentRegistry } from '@/lib/ComponentRegistry';
608
+ import { iconMap } from '@/lib/IconResolver';
609
+ import { SECTION_SCHEMAS } from '@/lib/schemas';
610
+
611
+ export type AdminStudioClientProps = {
612
+ tenantId?: string;
613
+ initialPages: Record<string, PageConfig>;
614
+ initialSiteConfig: SiteConfig;
615
+ initialMenuConfig: MenuConfig;
616
+ initialThemeConfig: ThemeConfig;
617
+ initialCollections: NonNullable<JsonPagesConfig['collections']>;
618
+ /** When false (Save2Repo cloud), hide local disk save. Default true for T9. */
619
+ showLocalSave?: boolean;
620
+ /** Save2Repo cold save — wired in Task 10. */
621
+ showColdSave?: boolean;
622
+ coldSave?: (state: ProjectState, slug: string) => Promise<void>;
623
+ /** Optional drawer / overlays (cold-save UI). */
624
+ children?: ReactNode;
625
+ };
626
+
627
+ /**
628
+ * Tenant-wired admin island: protocol registries + local persistence.
629
+ * HotSave is intentionally omitted (out of scope for Next v1).
630
+ */
631
+ export function AdminStudioClient({
632
+ tenantId = 'next',
633
+ initialPages,
634
+ initialSiteConfig,
635
+ initialMenuConfig,
636
+ initialThemeConfig,
637
+ initialCollections,
638
+ showLocalSave = true,
639
+ showColdSave = false,
640
+ coldSave,
641
+ children,
642
+ }: AdminStudioClientProps) {
643
+ const [pages, setPages] = useState(initialPages);
644
+ const [siteConfig, setSiteConfig] = useState(initialSiteConfig);
645
+ const [menuConfig, setMenuConfig] = useState(initialMenuConfig);
646
+ const [themeConfig, setThemeConfig] = useState(initialThemeConfig);
647
+ const [collections, setCollections] = useState(initialCollections);
648
+
649
+ const saveToFile = useCallback(
650
+ async (state: ProjectState, slug: string): Promise<void> => {
651
+ const res = await fetch('/api/save-to-file', {
652
+ method: 'POST',
653
+ headers: { 'Content-Type': 'application/json' },
654
+ body: JSON.stringify({ projectState: state, slug }),
655
+ });
656
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
657
+ if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
658
+ hydrateLocalProjectState({
659
+ state,
660
+ slug,
661
+ setPages,
662
+ setSiteConfig,
663
+ setMenuConfig,
664
+ setThemeConfig,
665
+ setCollections,
666
+ });
667
+ },
668
+ [],
669
+ );
670
+
671
+ const refDocuments = useMemo(
672
+ () => ({
673
+ 'menu.json': menuConfig,
674
+ 'config/menu.json': menuConfig,
675
+ 'src/data/config/menu.json': menuConfig,
676
+ }),
677
+ [menuConfig],
678
+ );
679
+
680
+ const config: JsonPagesConfig = useMemo(
681
+ () => ({
682
+ tenantId,
683
+ basePath: '/',
684
+ registry: ComponentRegistry as JsonPagesConfig['registry'],
685
+ schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
686
+ collectionSchemas: CollectionRegistry as unknown as JsonPagesConfig['collectionSchemas'],
687
+ pages,
688
+ siteConfig,
689
+ themeConfig,
690
+ menuConfig,
691
+ collections,
692
+ refDocuments,
693
+ // Visitor theme lives in app/globals.css; engine still requires themeCss.
694
+ themeCss: { tenant: '' },
695
+ iconRegistry: iconMap,
696
+ addSection: addSectionConfig,
697
+ persistence: {
698
+ saveToFile,
699
+ ...(coldSave ? { coldSave } : {}),
700
+ showLocalSave,
701
+ showHotSave: false,
702
+ showColdSave,
703
+ },
704
+ }),
705
+ [
706
+ tenantId,
707
+ pages,
708
+ siteConfig,
709
+ themeConfig,
710
+ menuConfig,
711
+ collections,
712
+ refDocuments,
713
+ saveToFile,
714
+ coldSave,
715
+ showLocalSave,
716
+ showColdSave,
717
+ ],
718
+ );
719
+
720
+ return <AdminIsland config={config}>{children}</AdminIsland>;
721
+ }
722
+
723
+ END_OF_FILE_CONTENT
724
+ echo "Creating src/components/admin/AdminStudioDynamic.tsx..."
725
+ cat << 'END_OF_FILE_CONTENT' > "src/components/admin/AdminStudioDynamic.tsx"
726
+ 'use client';
727
+
728
+ import dynamic from 'next/dynamic';
729
+ import type { ComponentProps } from 'react';
730
+ import type { AdminStudioWithCloud } from '@/components/admin/AdminStudioWithCloud';
731
+
732
+ /**
733
+ * BrowserRouter / createBrowserRouter need `document`.
734
+ * Next still SSRs `'use client'` trees once — disable SSR for the Studio island.
735
+ */
736
+ export const AdminStudioDynamic = dynamic(
737
+ () =>
738
+ import('@/components/admin/AdminStudioWithCloud').then((m) => ({
739
+ default: m.AdminStudioWithCloud,
740
+ })),
741
+ {
742
+ ssr: false,
743
+ loading: () => (
744
+ <main className="flex min-h-screen items-center justify-center bg-background text-muted-foreground">
745
+ Loading Studio…
746
+ </main>
747
+ ),
748
+ },
749
+ );
750
+
751
+ export type AdminStudioDynamicProps = ComponentProps<typeof AdminStudioWithCloud>;
752
+
753
+ END_OF_FILE_CONTENT
754
+ echo "Creating src/components/admin/AdminStudioWithCloud.tsx..."
755
+ cat << 'END_OF_FILE_CONTENT' > "src/components/admin/AdminStudioWithCloud.tsx"
756
+ 'use client';
757
+
758
+ import { lazy, Suspense, useCallback } from 'react';
759
+ import type {
760
+ JsonPagesConfig,
761
+ MenuConfig,
762
+ PageConfig,
763
+ ProjectState,
764
+ SiteConfig,
765
+ ThemeConfig,
766
+ } from '@olonjs/core';
767
+ import { AdminStudioClient } from '@/components/admin/AdminStudioClient';
768
+ import { useCloudSave } from '@/lib/admin/useCloudSave';
769
+ import { cloudPolicy, TENANT_ID } from '@/lib/env/tenantEnv';
770
+
771
+ const ColdSaveDrawer = lazy(() =>
772
+ import('@/components/admin/ColdSaveDrawer').then((m) => ({ default: m.ColdSaveDrawer })),
773
+ );
774
+
775
+ export type AdminStudioWithCloudProps = {
776
+ initialPages: Record<string, PageConfig>;
777
+ initialSiteConfig: SiteConfig;
778
+ initialMenuConfig: MenuConfig;
779
+ initialThemeConfig: ThemeConfig;
780
+ initialCollections: NonNullable<JsonPagesConfig['collections']>;
781
+ };
782
+
783
+ /**
784
+ * Admin island + optional Save2Repo cold save (no HotSave).
785
+ * Local save when cloud credentials are absent; cold save when Save2Repo is enabled.
786
+ */
787
+ export function AdminStudioWithCloud(props: AdminStudioWithCloudProps) {
788
+ const { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave } = useCloudSave({
789
+ apiUrl: cloudPolicy.apiUrl,
790
+ apiKey: cloudPolicy.apiKey,
791
+ });
792
+
793
+ const coldSave = useCallback(
794
+ async (state: ProjectState, slug: string) => {
795
+ await runCloudSave({ state, slug }, true);
796
+ },
797
+ [runCloudSave],
798
+ );
799
+
800
+ const mountDrawer =
801
+ cloudPolicy.showColdSave && (cloudSaveUi.isOpen || cloudSaveUi.phase !== 'idle');
802
+
803
+ return (
804
+ <AdminStudioClient
805
+ tenantId={TENANT_ID}
806
+ {...props}
807
+ showLocalSave={cloudPolicy.showLocalSave}
808
+ showColdSave={cloudPolicy.showColdSave}
809
+ coldSave={cloudPolicy.showColdSave ? coldSave : undefined}
810
+ >
811
+ {mountDrawer ? (
812
+ <Suspense fallback={null}>
813
+ <ColdSaveDrawer
814
+ isOpen={cloudSaveUi.isOpen}
815
+ phase={cloudSaveUi.phase}
816
+ currentStepId={cloudSaveUi.currentStepId}
817
+ doneSteps={cloudSaveUi.doneSteps}
818
+ progress={cloudSaveUi.progress}
819
+ errorMessage={cloudSaveUi.errorMessage}
820
+ deployUrl={cloudSaveUi.deployUrl}
821
+ onClose={closeCloudDrawer}
822
+ onRetry={retryCloudSave}
823
+ />
824
+ </Suspense>
825
+ ) : null}
826
+ </AdminStudioClient>
827
+ );
828
+ }
829
+
830
+ END_OF_FILE_CONTENT
831
+ echo "Creating src/components/admin/ColdSaveDrawer.tsx..."
832
+ cat << 'END_OF_FILE_CONTENT' > "src/components/admin/ColdSaveDrawer.tsx"
833
+ 'use client';
834
+
835
+ import { useMemo, type CSSProperties } from 'react';
836
+ import { createPortal } from 'react-dom';
837
+ import { DEPLOY_STEPS, type StepId } from '@olonjs/core';
838
+ import type { CloudSaveUiState } from '@/lib/admin/useCloudSave';
839
+
840
+ export type ColdSaveDrawerProps = Pick<
841
+ CloudSaveUiState,
842
+ 'isOpen' | 'phase' | 'currentStepId' | 'doneSteps' | 'progress' | 'errorMessage' | 'deployUrl'
843
+ > & {
844
+ onClose: () => void;
845
+ onRetry: () => void;
846
+ };
847
+
848
+ /**
849
+ * Slim Save2Repo progress drawer for the Next admin island.
850
+ * Lazy-load from the admin client only — never import on visitor routes.
851
+ */
852
+ export function ColdSaveDrawer({
853
+ isOpen,
854
+ phase,
855
+ currentStepId,
856
+ doneSteps,
857
+ progress,
858
+ errorMessage,
859
+ deployUrl,
860
+ onClose,
861
+ onRetry,
862
+ }: ColdSaveDrawerProps) {
863
+ const currentStep = useMemo(
864
+ () => DEPLOY_STEPS.find((step) => step.id === currentStepId) ?? null,
865
+ [currentStepId],
866
+ );
867
+
868
+ if (typeof document === 'undefined' || !isOpen || phase === 'idle') return null;
869
+
870
+ const isRunning = phase === 'running';
871
+ const isDone = phase === 'done';
872
+ const isError = phase === 'error';
873
+
874
+ return createPortal(
875
+ <div
876
+ role="status"
877
+ aria-live="polite"
878
+ aria-label={isDone ? 'Deploy completed' : isError ? 'Deploy failed' : 'Deploying'}
879
+ style={{
880
+ position: 'fixed',
881
+ inset: 0,
882
+ zIndex: 2147483600,
883
+ display: 'flex',
884
+ alignItems: 'flex-end',
885
+ justifyContent: 'center',
886
+ padding: '1rem',
887
+ background: 'rgb(0 0 0 / 0.45)',
888
+ }}
889
+ onClick={isDone || isError ? onClose : undefined}
890
+ >
891
+ <div
892
+ style={{
893
+ width: '100%',
894
+ maxWidth: '28rem',
895
+ marginBottom: '1.5rem',
896
+ borderRadius: '0.75rem',
897
+ border: '1px solid rgb(255 255 255 / 0.08)',
898
+ background: 'hsl(222 18% 8%)',
899
+ color: 'hsl(210 20% 96%)',
900
+ padding: '1.25rem',
901
+ boxShadow: '0 20px 50px rgb(0 0 0 / 0.55)',
902
+ }}
903
+ onClick={(event) => event.stopPropagation()}
904
+ >
905
+ <p style={{ margin: 0, fontSize: '0.75rem', letterSpacing: '0.04em', textTransform: 'uppercase', opacity: 0.7 }}>
906
+ {isDone ? 'Live' : isError ? 'Build failed' : currentStep?.verb ?? 'Saving'}
907
+ </p>
908
+ <p style={{ margin: '0.5rem 0 0', fontSize: '1.05rem', fontWeight: 600 }}>
909
+ {isDone
910
+ ? 'Your content is live.'
911
+ : isError
912
+ ? 'Deploy failed.'
913
+ : currentStep
914
+ ? currentStep.poem[0]
915
+ : 'Starting Save2Repo…'}
916
+ </p>
917
+ <p style={{ margin: '0.35rem 0 0', fontSize: '0.85rem', opacity: 0.75 }}>
918
+ {isDone
919
+ ? 'Deployed to production successfully'
920
+ : isError
921
+ ? (errorMessage ?? 'Check your logs or retry below')
922
+ : currentStep
923
+ ? currentStep.poem[1]
924
+ : null}
925
+ </p>
926
+
927
+ <ol style={{ listStyle: 'none', margin: '1rem 0 0', padding: 0, display: '0.35rem' }}>
928
+ {DEPLOY_STEPS.map((step) => {
929
+ const done = doneSteps.includes(step.id as StepId);
930
+ const active = isRunning && currentStepId === step.id;
931
+ return (
932
+ <li
933
+ key={step.id}
934
+ style={{
935
+ display: 'flex',
936
+ alignItems: 'center',
937
+ gap: '0.5rem',
938
+ fontSize: '0.8rem',
939
+ opacity: done || active ? 1 : 0.45,
940
+ }}
941
+ >
942
+ <span
943
+ aria-hidden
944
+ style={{
945
+ width: '0.55rem',
946
+ height: '0.55rem',
947
+ borderRadius: '999px',
948
+ background: done ? step.color : active ? step.color : 'rgb(255 255 255 / 0.25)',
949
+ boxShadow: active ? `0 0 8px ${step.color}` : undefined,
950
+ }}
951
+ />
952
+ {step.verb}
953
+ </li>
954
+ );
955
+ })}
956
+ </ol>
957
+
958
+ <div
959
+ style={{
960
+ marginTop: '1rem',
961
+ height: '0.35rem',
962
+ borderRadius: '999px',
963
+ background: 'rgb(255 255 255 / 0.08)',
964
+ overflow: 'hidden',
965
+ }}
966
+ >
967
+ <div
968
+ style={{
969
+ height: '100%',
970
+ width: `${Math.max(0, Math.min(100, progress))}%`,
971
+ background: isError ? 'hsl(0 72% 51%)' : 'linear-gradient(90deg, #60a5fa, #34d399)',
972
+ transition: 'width 0.35s ease',
973
+ }}
974
+ />
975
+ </div>
976
+
977
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' }}>
978
+ {isDone ? (
979
+ <>
980
+ <button type="button" onClick={onClose} style={btnStyle('ghost')}>
981
+ Close
982
+ </button>
983
+ <button
984
+ type="button"
985
+ disabled={!deployUrl}
986
+ onClick={() => {
987
+ if (deployUrl) window.open(deployUrl, '_blank', 'noopener,noreferrer');
988
+ }}
989
+ style={btnStyle('primary')}
990
+ >
991
+ Open site
992
+ </button>
993
+ </>
994
+ ) : null}
995
+ {isError ? (
996
+ <>
997
+ <button type="button" onClick={onClose} style={btnStyle('ghost')}>
998
+ Cancel
999
+ </button>
1000
+ <button type="button" onClick={onRetry} style={btnStyle('danger')}>
1001
+ Retry
1002
+ </button>
1003
+ </>
1004
+ ) : null}
1005
+ {isRunning ? (
1006
+ <span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
1007
+ {doneSteps.length + 1} / {DEPLOY_STEPS.length}
1008
+ </span>
1009
+ ) : null}
1010
+ </div>
1011
+ </div>
1012
+ </div>,
1013
+ document.body,
1014
+ );
1015
+ }
1016
+
1017
+ function btnStyle(kind: 'ghost' | 'primary' | 'danger'): CSSProperties {
1018
+ const base: CSSProperties = {
1019
+ borderRadius: '0.5rem',
1020
+ border: '1px solid transparent',
1021
+ padding: '0.45rem 0.85rem',
1022
+ fontSize: '0.85rem',
1023
+ cursor: 'pointer',
1024
+ };
1025
+ if (kind === 'primary') {
1026
+ return { ...base, background: '#34d399', color: '#0a0f1a', fontWeight: 600 };
1027
+ }
1028
+ if (kind === 'danger') {
1029
+ return { ...base, background: 'hsl(0 72% 51%)', color: '#fff', fontWeight: 600 };
1030
+ }
1031
+ return { ...base, background: 'transparent', color: 'inherit', borderColor: 'rgb(255 255 255 / 0.15)' };
1032
+ }
1033
+
1034
+ END_OF_FILE_CONTENT
1035
+ mkdir -p "src/components/authors-list"
1036
+ echo "Creating src/components/authors-list/View.tsx..."
1037
+ cat << 'END_OF_FILE_CONTENT' > "src/components/authors-list/View.tsx"
1038
+ import type { Autore } from '@/collections/autori';
1039
+ import type { AuthorsListData, AuthorsListSettings } from './types';
1040
+
1041
+ type AuthorsListViewProps = {
1042
+ data: AuthorsListData;
1043
+ settings?: AuthorsListSettings;
1044
+ };
1045
+
1046
+ function toAuthors(items: AuthorsListData['items']): Autore[] {
1047
+ return Object.values(items ?? {}).sort((a, b) => a.name.localeCompare(b.name));
1048
+ }
1049
+
1050
+ /** RSC-safe authors directory — plain href links, no react-router. */
1051
+ export function AuthorsListView({ data }: AuthorsListViewProps) {
1052
+ const authors = toAuthors(data.items);
1053
+
1054
+ return (
1055
+ <main className="min-h-screen bg-background px-6 py-16 text-foreground">
1056
+ <section className="mx-auto flex w-full max-w-5xl flex-col gap-10">
1057
+ <div className="max-w-2xl">
1058
+ {data.eyebrow ? (
1059
+ <p
1060
+ data-jp-field="eyebrow"
1061
+ className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground"
1062
+ >
1063
+ {data.eyebrow}
1064
+ </p>
1065
+ ) : null}
1066
+ <h1
1067
+ data-jp-field="title"
1068
+ className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl"
1069
+ >
1070
+ {data.title}
1071
+ </h1>
1072
+ {data.description ? (
1073
+ <p
1074
+ data-jp-field="description"
1075
+ className="mt-4 text-base leading-7 text-muted-foreground"
1076
+ >
1077
+ {data.description}
1078
+ </p>
1079
+ ) : null}
1080
+ </div>
1081
+
1082
+ <div data-jp-field="items" className="grid gap-4 sm:grid-cols-2">
1083
+ {authors.map((author) => (
1084
+ <a
1085
+ key={author.id}
1086
+ href={`/authors/${encodeURIComponent(author.id)}/libri`}
1087
+ data-jp-item-id={author.id}
1088
+ data-jp-item-field="items"
1089
+ className="block rounded-xl border border-border bg-card p-5 shadow-sm transition-colors hover:bg-muted/40"
1090
+ >
1091
+ <h2 className="text-xl font-semibold">{author.name}</h2>
1092
+ <p className="mt-2 text-sm text-muted-foreground">Vedi libri di {author.name}</p>
1093
+ </a>
1094
+ ))}
1095
+ </div>
1096
+ </section>
1097
+ </main>
1098
+ );
1099
+ }
1100
+
1101
+ END_OF_FILE_CONTENT
1102
+ echo "Creating src/components/authors-list/index.ts..."
1103
+ cat << 'END_OF_FILE_CONTENT' > "src/components/authors-list/index.ts"
1104
+ export { AuthorsListView } from './View';
1105
+ export { AuthorsListSchema, AuthorsListSettingsSchema } from './schema';
1106
+ export type { AuthorsListData, AuthorsListSettings } from './types';
1107
+
1108
+ END_OF_FILE_CONTENT
1109
+ echo "Creating src/components/authors-list/schema.ts..."
1110
+ cat << 'END_OF_FILE_CONTENT' > "src/components/authors-list/schema.ts"
1111
+ import { z } from 'zod';
1112
+ import { BaseSectionData } from '@olonjs/core';
1113
+ import { AutoreSchema } from '@/collections/autori';
1114
+
1115
+ export const AuthorsListSchema = BaseSectionData.extend({
1116
+ eyebrow: z.string().optional().describe('ui:text'),
1117
+ title: z.string().describe('ui:text'),
1118
+ description: z.string().optional().describe('ui:textarea'),
1119
+ items: z.record(z.string(), AutoreSchema).describe('ui:collection-ref:autori'),
1120
+ });
1121
+
1122
+ export const AuthorsListSettingsSchema = z.object({});
1123
+
1124
+ END_OF_FILE_CONTENT
1125
+ echo "Creating src/components/authors-list/types.ts..."
1126
+ cat << 'END_OF_FILE_CONTENT' > "src/components/authors-list/types.ts"
1127
+ import { z } from 'zod';
1128
+ import { AuthorsListSchema, AuthorsListSettingsSchema } from './schema';
1129
+
1130
+ export type AuthorsListData = z.infer<typeof AuthorsListSchema>;
1131
+ export type AuthorsListSettings = z.infer<typeof AuthorsListSettingsSchema>;
1132
+
1133
+ END_OF_FILE_CONTENT
1134
+ mkdir -p "src/components/book-detail"
1135
+ echo "Creating src/components/book-detail/View.tsx..."
1136
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/View.tsx"
1137
+ import type { BookDetailData, BookDetailSettings } from './types';
1138
+
1139
+ type BookDetailViewProps = {
1140
+ data: BookDetailData;
1141
+ settings?: BookDetailSettings;
1142
+ };
1143
+
1144
+ function getAuthorName(author: BookDetailData['item']['author']): string {
1145
+ if (typeof author === 'object' && author !== null && 'name' in author) {
1146
+ return String(author.name);
1147
+ }
1148
+ return 'Autore';
1149
+ }
1150
+
1151
+ /** RSC-safe book detail — plain href back link, no react-router. */
1152
+ export function BookDetailView({ data }: BookDetailViewProps) {
1153
+ const book = data.item;
1154
+
1155
+ return (
1156
+ <main className="min-h-screen bg-background px-6 py-16 text-foreground">
1157
+ <article
1158
+ data-jp-field="item"
1159
+ data-jp-item-id={book.id}
1160
+ data-jp-item-field="item"
1161
+ className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-card p-8 shadow-sm"
1162
+ >
1163
+ <a href="/libri" className="text-sm font-medium text-muted-foreground hover:text-foreground">
1164
+ {data.backLabel}
1165
+ </a>
1166
+ <p className="mt-10 text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
1167
+ {book.genre} · {book.year}
1168
+ </p>
1169
+ <h1 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{book.title}</h1>
1170
+ <p className="mt-4 text-lg text-muted-foreground">{getAuthorName(book.author)}</p>
1171
+ <p className="mt-8 text-base leading-8 text-muted-foreground">{book.summary}</p>
1172
+ </article>
1173
+ </main>
1174
+ );
1175
+ }
1176
+
1177
+ END_OF_FILE_CONTENT
1178
+ echo "Creating src/components/book-detail/index.ts..."
1179
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/index.ts"
1180
+ export { BookDetailView } from './View';
1181
+ export { BookDetailSchema, BookDetailSettingsSchema } from './schema';
1182
+ export type { BookDetailData, BookDetailSettings } from './types';
1183
+
1184
+ END_OF_FILE_CONTENT
1185
+ echo "Creating src/components/book-detail/schema.ts..."
1186
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/schema.ts"
1187
+ import { z } from 'zod';
1188
+ import { BaseSectionData } from '@olonjs/core';
1189
+ import { LibroSchema } from '@/collections/libri';
1190
+
1191
+ export const BookDetailSchema = BaseSectionData.extend({
1192
+ item: LibroSchema.describe('ui:collection-ref'),
1193
+ backLabel: z.string().default('Torna ai libri').describe('ui:text'),
1194
+ });
1195
+
1196
+ export const BookDetailSettingsSchema = z.object({});
1197
+
1198
+ END_OF_FILE_CONTENT
1199
+ echo "Creating src/components/book-detail/types.ts..."
1200
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/types.ts"
1201
+ import { z } from 'zod';
1202
+ import { BookDetailSchema, BookDetailSettingsSchema } from './schema';
1203
+
1204
+ export type BookDetailData = z.infer<typeof BookDetailSchema>;
1205
+ export type BookDetailSettings = z.infer<typeof BookDetailSettingsSchema>;
1206
+
1207
+ END_OF_FILE_CONTENT
1208
+ mkdir -p "src/components/books-list"
1209
+ echo "Creating src/components/books-list/View.tsx..."
1210
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/View.tsx"
1211
+ import type { Libro } from '@/collections/libri';
1212
+ import type { BooksListData, BooksListSettings } from './types';
1213
+
1214
+ type BooksListViewProps = {
1215
+ data: BooksListData;
1216
+ settings?: BooksListSettings;
1217
+ /** From Next route params (`/authors/[authorId]/libri`) or `?author=`. */
1218
+ authorId?: string | null;
1219
+ /** 1-based page from `?page=` searchParams (RSC-safe pagination). */
1220
+ page?: number;
1221
+ /** Current pathname for pagination hrefs. */
1222
+ pathname?: string;
1223
+ };
1224
+
1225
+ function toBooks(items: BooksListData['items']): Libro[] {
1226
+ return Object.values(items ?? {}).sort((a, b) => a.title.localeCompare(b.title));
1227
+ }
1228
+
1229
+ function getAuthorName(author: Libro['author']): string {
1230
+ if (typeof author === 'object' && author !== null && 'name' in author) {
1231
+ return String(author.name);
1232
+ }
1233
+ return 'Autore';
1234
+ }
1235
+
1236
+ function getAuthorId(author: Libro['author']): string | null {
1237
+ if (typeof author === 'object' && author !== null && 'id' in author && typeof author.id === 'string') {
1238
+ return author.id;
1239
+ }
1240
+ if (typeof author === 'object' && author !== null && '$ref' in author && typeof author.$ref === 'string') {
1241
+ const pointer = author.$ref.split('#')[1]?.replace(/^\//, '') ?? '';
1242
+ return pointer.split('/')[0] || null;
1243
+ }
1244
+ return null;
1245
+ }
1246
+
1247
+ function pageHref(pathname: string, page: number, authorQuery?: string | null): string {
1248
+ const params = new URLSearchParams();
1249
+ if (page > 1) params.set('page', String(page));
1250
+ if (authorQuery) params.set('author', authorQuery);
1251
+ const qs = params.toString();
1252
+ return qs ? `${pathname}?${qs}` : pathname;
1253
+ }
1254
+
1255
+ /** RSC-safe books catalog — author filter via props, pagination via href + searchParams. */
1256
+ export function BooksListView({
1257
+ data,
1258
+ authorId = null,
1259
+ page = 1,
1260
+ pathname = '/',
1261
+ }: BooksListViewProps) {
1262
+ const books = toBooks(data.items);
1263
+ const filteredBooks = authorId
1264
+ ? books.filter((book) => getAuthorId(book.author) === authorId)
1265
+ : books;
1266
+ const pageSize = Math.max(1, Math.floor(data.pageSize || 10));
1267
+ const totalPages = Math.max(1, Math.ceil(filteredBooks.length / pageSize));
1268
+ const currentPage = Math.min(Math.max(1, Math.floor(page) || 1), totalPages);
1269
+ const startIndex = (currentPage - 1) * pageSize;
1270
+ const visibleBooks = filteredBooks.slice(startIndex, startIndex + pageSize);
1271
+ const authorInQuery = pathname.includes('/authors/') ? null : authorId;
1272
+
1273
+ return (
1274
+ <main className="min-h-screen bg-background px-6 py-16 text-foreground">
1275
+ <section className="mx-auto flex w-full max-w-5xl flex-col gap-10">
1276
+ <div className="max-w-2xl">
1277
+ {data.eyebrow ? (
1278
+ <p
1279
+ data-jp-field="eyebrow"
1280
+ className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground"
1281
+ >
1282
+ {data.eyebrow}
1283
+ </p>
1284
+ ) : null}
1285
+ <h1
1286
+ data-jp-field="title"
1287
+ className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl"
1288
+ >
1289
+ {data.title}
1290
+ </h1>
1291
+ {data.description ? (
1292
+ <p
1293
+ data-jp-field="description"
1294
+ className="mt-4 text-base leading-7 text-muted-foreground"
1295
+ >
1296
+ {data.description}
1297
+ </p>
1298
+ ) : null}
1299
+ </div>
1300
+
1301
+ <div data-jp-field="items" className="grid gap-4">
1302
+ {authorId ? (
1303
+ <p className="text-sm text-muted-foreground">
1304
+ Filtro autore: {authorId} · {filteredBooks.length} libri
1305
+ </p>
1306
+ ) : null}
1307
+ {visibleBooks.map((book) => (
1308
+ <article
1309
+ key={book.id}
1310
+ data-jp-item-id={book.id}
1311
+ data-jp-item-field="items"
1312
+ className="rounded-xl border border-border bg-card p-5 shadow-sm"
1313
+ >
1314
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
1315
+ <div>
1316
+ <h2 className="text-xl font-semibold">{book.title}</h2>
1317
+ <p className="mt-1 text-sm text-muted-foreground">
1318
+ {getAuthorName(book.author)} · {book.year} · {book.genre}
1319
+ </p>
1320
+ <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
1321
+ {book.summary}
1322
+ </p>
1323
+ </div>
1324
+ <a
1325
+ href={`/libri/${encodeURIComponent(book.id)}`}
1326
+ className="inline-flex shrink-0 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
1327
+ >
1328
+ Apri scheda
1329
+ </a>
1330
+ </div>
1331
+ </article>
1332
+ ))}
1333
+ </div>
1334
+
1335
+ <nav className="flex items-center justify-between border-t border-border pt-6 text-sm">
1336
+ {currentPage > 1 ? (
1337
+ <a
1338
+ href={pageHref(pathname, currentPage - 1, authorInQuery)}
1339
+ className="rounded-md border border-border px-3 py-2 font-medium hover:bg-muted"
1340
+ >
1341
+ Precedente
1342
+ </a>
1343
+ ) : (
1344
+ <span className="rounded-md border border-border px-3 py-2 font-medium opacity-40">
1345
+ Precedente
1346
+ </span>
1347
+ )}
1348
+ <span className="text-muted-foreground">
1349
+ Pagina {currentPage} di {totalPages} · {filteredBooks.length} libri
1350
+ </span>
1351
+ {currentPage < totalPages ? (
1352
+ <a
1353
+ href={pageHref(pathname, currentPage + 1, authorInQuery)}
1354
+ className="rounded-md border border-border px-3 py-2 font-medium hover:bg-muted"
1355
+ >
1356
+ Successiva
1357
+ </a>
1358
+ ) : (
1359
+ <span className="rounded-md border border-border px-3 py-2 font-medium opacity-40">
1360
+ Successiva
1361
+ </span>
1362
+ )}
1363
+ </nav>
1364
+ </section>
1365
+ </main>
1366
+ );
1367
+ }
1368
+
1369
+ END_OF_FILE_CONTENT
1370
+ echo "Creating src/components/books-list/index.ts..."
1371
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/index.ts"
1372
+ export { BooksListView } from './View';
1373
+ export { BooksListSchema, BooksListSettingsSchema } from './schema';
1374
+ export type { BooksListData, BooksListSettings } from './types';
1375
+
1376
+ END_OF_FILE_CONTENT
1377
+ echo "Creating src/components/books-list/schema.ts..."
1378
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/schema.ts"
1379
+ import { z } from 'zod';
1380
+ import { BaseSectionData } from '@olonjs/core';
1381
+ import { LibroSchema } from '@/collections/libri';
1382
+
1383
+ export const BooksListSchema = BaseSectionData.extend({
1384
+ eyebrow: z.string().optional().describe('ui:text'),
1385
+ title: z.string().describe('ui:text'),
1386
+ description: z.string().optional().describe('ui:textarea'),
1387
+ items: z.record(z.string(), LibroSchema).describe('ui:collection-ref:libri'),
1388
+ pageSize: z.number().default(10).describe('ui:number'),
1389
+ });
1390
+
1391
+ export const BooksListSettingsSchema = z.object({});
1392
+
1393
+ END_OF_FILE_CONTENT
1394
+ echo "Creating src/components/books-list/types.ts..."
1395
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/types.ts"
1396
+ import { z } from 'zod';
1397
+ import { BooksListSchema, BooksListSettingsSchema } from './schema';
1398
+
1399
+ export type BooksListData = z.infer<typeof BooksListSchema>;
1400
+ export type BooksListSettings = z.infer<typeof BooksListSettingsSchema>;
1401
+
1402
+ END_OF_FILE_CONTENT
1403
+ mkdir -p "src/components/empty-tenant"
1404
+ echo "Creating src/components/empty-tenant/View.tsx..."
1405
+ cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/View.tsx"
1406
+ import type { EmptyTenantData, EmptyTenantSettings } from './types';
1407
+
1408
+ type EmptyTenantViewProps = {
1409
+ data?: EmptyTenantData;
1410
+ settings?: EmptyTenantSettings;
1411
+ };
1412
+
1413
+ /** Server empty-tenant UI when the page registry has no pages. */
1414
+ export function EmptyTenantView({ data }: EmptyTenantViewProps) {
1415
+ const title = data?.title?.trim() || 'Your tenant is empty.';
1416
+ const description =
1417
+ data?.description?.trim() || 'Create your first page to start building your site.';
1418
+
1419
+ return (
1420
+ <main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
1421
+ <section className="w-full max-w-xl rounded-xl border border-border bg-card p-8 shadow-sm">
1422
+ <h1 data-jp-field="title" className="text-2xl font-semibold tracking-tight">
1423
+ {title}
1424
+ </h1>
1425
+ <p data-jp-field="description" className="mt-3 text-sm text-muted-foreground">
1426
+ {description}
1427
+ </p>
1428
+ </section>
1429
+ </main>
1430
+ );
1431
+ }
1432
+
1433
+ END_OF_FILE_CONTENT
1434
+ echo "Creating src/components/empty-tenant/index.ts..."
1435
+ cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/index.ts"
1436
+ export { EmptyTenantView } from './View';
1437
+ export { EmptyTenantSchema, EmptyTenantSettingsSchema } from './schema';
1438
+ export type { EmptyTenantData, EmptyTenantSettings } from './types';
1439
+
1440
+ END_OF_FILE_CONTENT
1441
+ echo "Creating src/components/empty-tenant/schema.ts..."
1442
+ cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/schema.ts"
1443
+ import { z } from 'zod';
1444
+ import { BaseSectionData } from '@olonjs/core';
1445
+
1446
+ export const EmptyTenantSchema = BaseSectionData.extend({
1447
+ title: z.string().optional().describe('ui:text'),
1448
+ description: z.string().optional().describe('ui:textarea'),
1449
+ });
1450
+
1451
+ export const EmptyTenantSettingsSchema = z.object({});
1452
+
1453
+ END_OF_FILE_CONTENT
1454
+ echo "Creating src/components/empty-tenant/types.ts..."
1455
+ cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/types.ts"
1456
+ import { z } from 'zod';
1457
+ import { EmptyTenantSchema, EmptyTenantSettingsSchema } from './schema';
1458
+
1459
+ export type EmptyTenantData = z.infer<typeof EmptyTenantSchema>;
1460
+ export type EmptyTenantSettings = z.infer<typeof EmptyTenantSettingsSchema>;
1461
+
1462
+ END_OF_FILE_CONTENT
1463
+ mkdir -p "src/components/footer"
1464
+ echo "Creating src/components/footer/View.tsx..."
1465
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/View.tsx"
1466
+ import type { CSSProperties } from 'react';
1467
+ import type { FooterData, FooterSettings } from './types';
1468
+
1469
+ export function FooterView({
1470
+ data,
1471
+ }: {
1472
+ data: FooterData;
1473
+ settings?: FooterSettings;
1474
+ }) {
1475
+ return (
1476
+ <footer
1477
+ style={
1478
+ {
1479
+ '--local-bg': 'var(--background)',
1480
+ '--local-text': 'var(--foreground)',
1481
+ } as CSSProperties
1482
+ }
1483
+ className="bg-[var(--local-bg)] px-6 py-8 text-[var(--local-text)]"
1484
+ >
1485
+ <p data-jp-field="brandText">{data.brandText}</p>
1486
+ </footer>
1487
+ );
1488
+ }
1489
+
1490
+ END_OF_FILE_CONTENT
1491
+ echo "Creating src/components/footer/index.ts..."
1492
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/index.ts"
1493
+ export { FooterView } from './View';
1494
+ export { FooterSchema, FooterSettingsSchema } from './schema';
1495
+ export type { FooterData, FooterSettings } from './types';
1496
+
1497
+ END_OF_FILE_CONTENT
1498
+ echo "Creating src/components/footer/schema.ts..."
1499
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/schema.ts"
1500
+ import { z } from 'zod';
1501
+ import { BaseArrayItem, BaseSectionData } from '@olonjs/core';
1502
+
1503
+ export const FooterLinkSchema = BaseArrayItem.extend({
1504
+ id: z.string(),
1505
+ label: z.string().describe('ui:text'),
1506
+ href: z.string().describe('ui:text'),
1507
+ external: z.boolean().optional(),
1508
+ });
1509
+
1510
+ export const FooterSchema = BaseSectionData.extend({
1511
+ brandText: z.string().describe('ui:text'),
1512
+ description: z.string().optional().describe('ui:textarea'),
1513
+ copyright: z.string().describe('ui:text'),
1514
+ links: z.array(FooterLinkSchema).default([]).describe('ui:list'),
1515
+ designSystemHref: z.string().optional().describe('ui:text'),
1516
+ });
1517
+
1518
+ export const FooterSettingsSchema = z.object({
1519
+ showLogo: z.boolean().default(true),
1520
+ });
1521
+
1522
+ END_OF_FILE_CONTENT
1523
+ echo "Creating src/components/footer/types.ts..."
1524
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/types.ts"
1525
+ import { z } from 'zod';
1526
+ import { FooterSchema, FooterSettingsSchema } from './schema';
1527
+
1528
+ export type FooterData = z.infer<typeof FooterSchema>;
1529
+ export type FooterSettings = z.infer<typeof FooterSettingsSchema>;
1530
+
1531
+ END_OF_FILE_CONTENT
1532
+ mkdir -p "src/components/form-demo"
1533
+ echo "Creating src/components/form-demo/FormDemoClient.tsx..."
1534
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/FormDemoClient.tsx"
1535
+ 'use client';
1536
+
1537
+ import { useCallback, useState, type FormEvent } from 'react';
1538
+ import { OlonFormsContext, useFormState, type FormState } from '@olonjs/react';
1539
+ import type { FormDemoData } from './types';
1540
+
1541
+ type FormDemoClientProps = {
1542
+ formId: string;
1543
+ data: FormDemoData;
1544
+ };
1545
+
1546
+ function FormDemoFields({
1547
+ formId,
1548
+ data,
1549
+ onSubmit,
1550
+ }: FormDemoClientProps & { onSubmit: (event: FormEvent<HTMLFormElement>) => void }) {
1551
+ const { status, message } = useFormState(formId);
1552
+
1553
+ return (
1554
+ <form
1555
+ id={formId}
1556
+ data-olon-recipient={data.recipientEmail ?? ''}
1557
+ className="space-y-4"
1558
+ onSubmit={onSubmit}
1559
+ >
1560
+ <div>
1561
+ <label className="mb-1 block text-xs font-medium text-muted-foreground">Nome</label>
1562
+ <input
1563
+ name="name"
1564
+ type="text"
1565
+ required
1566
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
1567
+ />
1568
+ </div>
1569
+
1570
+ <div>
1571
+ <label className="mb-1 block text-xs font-medium text-muted-foreground">Email</label>
1572
+ <input
1573
+ name="email"
1574
+ type="email"
1575
+ required
1576
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
1577
+ />
1578
+ </div>
1579
+
1580
+ <div>
1581
+ <label className="mb-1 block text-xs font-medium text-muted-foreground">Messaggio</label>
1582
+ <textarea
1583
+ name="message"
1584
+ required
1585
+ rows={4}
1586
+ className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
1587
+ />
1588
+ </div>
1589
+
1590
+ {status === 'error' ? <p className="text-xs text-red-500">{message}</p> : null}
1591
+ {status === 'success' ? <p className="text-xs text-green-600">{message}</p> : null}
1592
+
1593
+ <button
1594
+ type="submit"
1595
+ disabled={status === 'submitting'}
1596
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
1597
+ >
1598
+ {status === 'submitting' ? 'Invio...' : data.submitLabel || 'Invia'}
1599
+ </button>
1600
+ </form>
1601
+ );
1602
+ }
1603
+
1604
+ /**
1605
+ * Client leaf for form-demo — OlonFormsContext is scoped HERE only (not root layout).
1606
+ */
1607
+ export function FormDemoClient({ formId, data }: FormDemoClientProps) {
1608
+ const [states, setStates] = useState<Record<string, FormState>>({});
1609
+
1610
+ const onSubmit = useCallback(
1611
+ async (event: FormEvent<HTMLFormElement>) => {
1612
+ event.preventDefault();
1613
+ const form = event.currentTarget;
1614
+ setStates((prev) => ({
1615
+ ...prev,
1616
+ [formId]: { status: 'submitting', message: 'Invio in corso...' },
1617
+ }));
1618
+
1619
+ // Stub submit UX for local Next visitor (cloud submit can replace later).
1620
+ await new Promise((resolve) => setTimeout(resolve, 400));
1621
+ const fd = new FormData(form);
1622
+ const name = String(fd.get('name') ?? '').trim();
1623
+ const email = String(fd.get('email') ?? '').trim();
1624
+ const body = String(fd.get('message') ?? '').trim();
1625
+
1626
+ if (!name || !email || !body) {
1627
+ setStates((prev) => ({
1628
+ ...prev,
1629
+ [formId]: { status: 'error', message: 'Compila tutti i campi.' },
1630
+ }));
1631
+ return;
1632
+ }
1633
+
1634
+ setStates((prev) => ({
1635
+ ...prev,
1636
+ [formId]: {
1637
+ status: 'success',
1638
+ message: data.successMessage || 'Richiesta inviata con successo.',
1639
+ },
1640
+ }));
1641
+ form.reset();
1642
+ },
1643
+ [data.successMessage, formId],
1644
+ );
1645
+
1646
+ return (
1647
+ <OlonFormsContext.Provider value={states}>
1648
+ <FormDemoFields formId={formId} data={data} onSubmit={onSubmit} />
1649
+ </OlonFormsContext.Provider>
1650
+ );
1651
+ }
1652
+
1653
+ END_OF_FILE_CONTENT
1654
+ echo "Creating src/components/form-demo/View.tsx..."
1655
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
1656
+ import { Icon } from '@/lib/IconResolver';
1657
+ import type { FormDemoData, FormDemoSettings } from './types';
1658
+ import { FormDemoClient } from './FormDemoClient';
1659
+
1660
+ type FormDemoViewProps = {
1661
+ data: FormDemoData;
1662
+ settings?: FormDemoSettings;
1663
+ };
1664
+
1665
+ /**
1666
+ * RSC shell for form-demo — chrome + IDAC attrs; interactivity lives in FormDemoClient.
1667
+ */
1668
+ export function FormDemoView({ data }: FormDemoViewProps) {
1669
+ const formId = data.anchorId?.trim() || 'form-demo';
1670
+
1671
+ return (
1672
+ <main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
1673
+ <section className="w-full max-w-xl space-y-6 rounded-xl border border-border bg-card p-8 shadow-sm">
1674
+ {data.icon ? (
1675
+ <div data-jp-field="icon" className="mb-2">
1676
+ <Icon name={data.icon} size={24} />
1677
+ </div>
1678
+ ) : null}
1679
+ {data.title ? (
1680
+ <div>
1681
+ <h1 data-jp-field="title" className="text-2xl font-semibold tracking-tight">
1682
+ {data.title}
1683
+ </h1>
1684
+ {data.description ? (
1685
+ <p data-jp-field="description" className="mt-3 text-sm text-muted-foreground">
1686
+ {data.description}
1687
+ </p>
1688
+ ) : null}
1689
+ </div>
1690
+ ) : null}
1691
+
1692
+ <FormDemoClient formId={formId} data={data} />
1693
+ </section>
1694
+ </main>
1695
+ );
1696
+ }
1697
+
1698
+ END_OF_FILE_CONTENT
1699
+ echo "Creating src/components/form-demo/index.ts..."
1700
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/index.ts"
1701
+ export { FormDemoView } from './View';
1702
+ export { FormDemoClient } from './FormDemoClient';
1703
+ export { FormDemoSchema, FormDemoSettingsSchema, FormDemoSubmissionSchema } from './schema';
1704
+ export type { FormDemoData, FormDemoSettings } from './types';
1705
+
1706
+ END_OF_FILE_CONTENT
1707
+ echo "Creating src/components/form-demo/schema.ts..."
1708
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/schema.ts"
1709
+ import { z } from 'zod';
1710
+ import { BaseSectionData, WithFormRecipient } from '@olonjs/core';
1711
+
1712
+ export const FormDemoSchema = BaseSectionData.merge(WithFormRecipient).extend({
1713
+ icon: z.string().optional().describe('ui:icon-picker'),
1714
+ title: z.string().optional().describe('ui:text'),
1715
+ description: z.string().optional().describe('ui:textarea'),
1716
+ submitLabel: z.string().default('Invia').describe('ui:text'),
1717
+ successMessage: z.string().default('Richiesta inviata con successo.').describe('ui:text'),
1718
+ });
1719
+
1720
+ export const FormDemoSettingsSchema = z.object({});
1721
+
1722
+ /**
1723
+ * Submission payload schema for the `form-demo` section.
1724
+ */
1725
+ export const FormDemoSubmissionSchema = z.object({
1726
+ name: z.string().min(1).describe('Full name of the person submitting the form'),
1727
+ email: z.string().email().describe('Contact email address where we will reply'),
1728
+ message: z.string().min(1).describe('Free-form message body'),
1729
+ });
1730
+
1731
+ END_OF_FILE_CONTENT
1732
+ echo "Creating src/components/form-demo/types.ts..."
1733
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/types.ts"
1734
+ import { z } from 'zod';
1735
+ import { FormDemoSchema, FormDemoSettingsSchema } from './schema';
1736
+
1737
+ export type FormDemoData = z.infer<typeof FormDemoSchema>;
1738
+ export type FormDemoSettings = z.infer<typeof FormDemoSettingsSchema>;
1739
+
1740
+ END_OF_FILE_CONTENT
1741
+ mkdir -p "src/components/header"
1742
+ echo "Creating src/components/header/View.tsx..."
1743
+ cat << 'END_OF_FILE_CONTENT' > "src/components/header/View.tsx"
1744
+ import type { CSSProperties } from 'react';
1745
+ import type { HeaderData, HeaderSettings } from './types';
1746
+
1747
+ export function HeaderView({
1748
+ data,
1749
+ }: {
1750
+ data: HeaderData;
1751
+ settings?: HeaderSettings;
1752
+ }) {
1753
+ return (
1754
+ <header
1755
+ style={
1756
+ {
1757
+ '--local-bg': 'var(--background)',
1758
+ '--local-text': 'var(--foreground)',
1759
+ '--local-primary': 'var(--primary)',
1760
+ } as CSSProperties
1761
+ }
1762
+ className="border-b border-black/10 bg-[var(--local-bg)] px-6 py-4 text-[var(--local-text)]"
1763
+ >
1764
+ <div className="mx-auto flex max-w-5xl items-center justify-between gap-6">
1765
+ <a href="/" className="flex items-baseline gap-1 font-semibold tracking-tight">
1766
+ <span data-jp-field="logoText">{data.logoText}</span>
1767
+ {data.badge ? (
1768
+ <span data-jp-field="badge" className="text-[var(--local-primary)]">
1769
+ {data.badge}
1770
+ </span>
1771
+ ) : null}
1772
+ </a>
1773
+ <nav className="flex flex-wrap items-center gap-4 text-sm">
1774
+ {(data.links ?? []).map((link, index) => (
1775
+ <a
1776
+ key={`${link.href}-${index}`}
1777
+ href={link.href}
1778
+ data-jp-item-id={link.id ?? String(index)}
1779
+ data-jp-item-field="links"
1780
+ className="opacity-80 hover:opacity-100"
1781
+ >
1782
+ {link.label}
1783
+ </a>
1784
+ ))}
1785
+ </nav>
1786
+ </div>
1787
+ </header>
1788
+ );
1789
+ }
1790
+
1791
+ END_OF_FILE_CONTENT
1792
+ echo "Creating src/components/header/index.ts..."
1793
+ cat << 'END_OF_FILE_CONTENT' > "src/components/header/index.ts"
1794
+ export { HeaderView } from './View';
1795
+ export { HeaderSchema, HeaderSettingsSchema } from './schema';
1796
+ export type { HeaderData, HeaderSettings } from './types';
1797
+
1798
+ END_OF_FILE_CONTENT
1799
+ echo "Creating src/components/header/schema.ts..."
1800
+ cat << 'END_OF_FILE_CONTENT' > "src/components/header/schema.ts"
1801
+ import { z } from 'zod';
1802
+ import { BaseArrayItem, BaseSectionData } from '@olonjs/core';
1803
+
1804
+ export const HeaderLinkSchema = BaseArrayItem.extend({
1805
+ label: z.string().describe('ui:text'),
1806
+ href: z.string().describe('ui:text'),
1807
+ });
1808
+
1809
+ /** Matches authored `site.json` header.data shape for the Next demo tenant. */
1810
+ export const HeaderSchema = BaseSectionData.extend({
1811
+ logoText: z.string().describe('ui:text'),
1812
+ badge: z.string().optional().describe('ui:text'),
1813
+ links: z.array(HeaderLinkSchema).default([]).describe('ui:list'),
1814
+ ctaLabel: z.string().optional().describe('ui:text'),
1815
+ ctaHref: z.string().optional().describe('ui:text'),
1816
+ signinHref: z.string().optional().describe('ui:text'),
1817
+ });
1818
+
1819
+ export const HeaderSettingsSchema = z.object({});
1820
+
1821
+ END_OF_FILE_CONTENT
1822
+ echo "Creating src/components/header/types.ts..."
1823
+ cat << 'END_OF_FILE_CONTENT' > "src/components/header/types.ts"
1824
+ import { z } from 'zod';
1825
+ import { HeaderSchema, HeaderSettingsSchema } from './schema';
1826
+
1827
+ export type HeaderData = z.infer<typeof HeaderSchema>;
1828
+ export type HeaderSettings = z.infer<typeof HeaderSettingsSchema>;
1829
+
1830
+ END_OF_FILE_CONTENT
1831
+ mkdir -p "src/data"
1832
+ mkdir -p "src/data/collections"
1833
+ mkdir -p "src/data/collections/autori"
1834
+ echo "Creating src/data/collections/autori/autori.json..."
1835
+ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/autori/autori.json"
1836
+ {
1837
+ "george-orwell": {
1838
+ "id": "george-orwell",
1839
+ "name": "George Orwell"
1840
+ },
1841
+ "umberto-eco": {
1842
+ "id": "umberto-eco",
1843
+ "name": "Umberto Eco"
1844
+ },
1845
+ "primo-levi": {
1846
+ "id": "primo-levi",
1847
+ "name": "Primo Levi"
1848
+ },
1849
+ "italo-calvino": {
1850
+ "id": "italo-calvino",
1851
+ "name": "Italo Calvino"
1852
+ },
1853
+ "giuseppe-tomasi-di-lampedusa": {
1854
+ "id": "giuseppe-tomasi-di-lampedusa",
1855
+ "name": "Giuseppe Tomasi di Lampedusa"
1856
+ },
1857
+ "italo-svevo": {
1858
+ "id": "italo-svevo",
1859
+ "name": "Italo Svevo"
1860
+ },
1861
+ "alessandro-manzoni": {
1862
+ "id": "alessandro-manzoni",
1863
+ "name": "Alessandro Manzoni"
1864
+ },
1865
+ "roberto-saviano": {
1866
+ "id": "roberto-saviano",
1867
+ "name": "Roberto Saviano"
1868
+ },
1869
+ "alessandro-baricco": {
1870
+ "id": "alessandro-baricco",
1871
+ "name": "Alessandro Baricco"
1872
+ },
1873
+ "natalia-ginzburg": {
1874
+ "id": "natalia-ginzburg",
1875
+ "name": "Natalia Ginzburg"
1876
+ },
1877
+ "gabriel-garcia-marquez": {
1878
+ "id": "gabriel-garcia-marquez",
1879
+ "name": "Gabriel Garcia Marquez"
1880
+ },
1881
+ "ray-bradbury": {
1882
+ "id": "ray-bradbury",
1883
+ "name": "Ray Bradbury"
1884
+ },
1885
+ "frank-herbert": {
1886
+ "id": "frank-herbert",
1887
+ "name": "Frank Herbert"
1888
+ },
1889
+ "william-gibson": {
1890
+ "id": "william-gibson",
1891
+ "name": "William Gibson"
1892
+ },
1893
+ "j-r-r-tolkien": {
1894
+ "id": "j-r-r-tolkien",
1895
+ "name": "J. R. R. Tolkien"
1896
+ },
1897
+ "j-k-rowling": {
1898
+ "id": "j-k-rowling",
1899
+ "name": "J. K. Rowling"
1900
+ },
1901
+ "jane-austen": {
1902
+ "id": "jane-austen",
1903
+ "name": "Jane Austen"
1904
+ },
1905
+ "herman-melville": {
1906
+ "id": "herman-melville",
1907
+ "name": "Herman Melville"
1908
+ },
1909
+ "fedor-dostoevskij": {
1910
+ "id": "fedor-dostoevskij",
1911
+ "name": "Fedor Dostoevskij"
1912
+ },
1913
+ "lev-tolstoj": {
1914
+ "id": "lev-tolstoj",
1915
+ "name": "Lev Tolstoj"
1916
+ },
1917
+ "michail-bulgakov": {
1918
+ "id": "michail-bulgakov",
1919
+ "name": "Michail Bulgakov"
1920
+ },
1921
+ "cormac-mccarthy": {
1922
+ "id": "cormac-mccarthy",
1923
+ "name": "Cormac McCarthy"
1924
+ },
1925
+ "david-mitchell": {
1926
+ "id": "david-mitchell",
1927
+ "name": "David Mitchell"
1928
+ },
1929
+ "haruki-murakami": {
1930
+ "id": "haruki-murakami",
1931
+ "name": "Haruki Murakami"
1932
+ },
1933
+ "chimamanda-ngozi-adichie": {
1934
+ "id": "chimamanda-ngozi-adichie",
1935
+ "name": "Chimamanda Ngozi Adichie"
1936
+ },
1937
+ "isabel-allende": {
1938
+ "id": "isabel-allende",
1939
+ "name": "Isabel Allende"
1940
+ },
1941
+ "toni-morrison": {
1942
+ "id": "toni-morrison",
1943
+ "name": "Toni Morrison"
1944
+ },
1945
+ "junot-diaz": {
1946
+ "id": "junot-diaz",
1947
+ "name": "Junot Diaz"
1948
+ },
1949
+ "jonathan-franzen": {
1950
+ "id": "jonathan-franzen",
1951
+ "name": "Jonathan Franzen"
1952
+ }
1953
+ }
1954
+ END_OF_FILE_CONTENT
1955
+ mkdir -p "src/data/collections/libri"
1956
+ echo "Creating src/data/collections/libri/libri.json..."
1957
+ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
1958
+ {
1959
+ "1984": {
1960
+ "id": "1984",
1961
+ "title": "1984",
1962
+ "author": {
1963
+ "$ref": "../autori/autori.json#/george-orwell"
1964
+ },
1965
+ "year": 1949,
1966
+ "genre": "Distopia",
1967
+ "summary": "Un regime totalitario controlla linguaggio, memoria e pensiero."
1968
+ },
1969
+ "il-nome-della-rosa": {
1970
+ "id": "il-nome-della-rosa",
1971
+ "title": "Il nome della rosa",
1972
+ "author": {
1973
+ "$ref": "../autori/autori.json#/umberto-eco"
1974
+ },
1975
+ "year": 1980,
1976
+ "genre": "Romanzo storico",
1977
+ "summary": "Un'indagine in un'abbazia medievale diventa una riflessione su conoscenza, potere e interpretazione."
1978
+ },
1979
+ "se-questo-e-un-uomo": {
1980
+ "id": "se-questo-e-un-uomo",
1981
+ "title": "Se questo e un uomo",
1982
+ "author": {
1983
+ "$ref": "../autori/autori.json#/primo-levi"
1984
+ },
1985
+ "year": 1947,
1986
+ "genre": "Memoria",
1987
+ "summary": "La testimonianza essenziale di Levi sull'esperienza del lager e sulla dignita umana."
1988
+ },
1989
+ "le-citta-invisibili": {
1990
+ "id": "le-citta-invisibili",
1991
+ "title": "Le citta invisibili",
1992
+ "author": {
1993
+ "$ref": "../autori/autori.json#/italo-calvino"
1994
+ },
1995
+ "year": 1972,
1996
+ "genre": "Letteratura fantastica",
1997
+ "summary": "Marco Polo racconta a Kublai Khan citta immaginarie che parlano di memoria, desiderio e linguaggio."
1998
+ },
1999
+ "il-gattopardo": {
2000
+ "id": "il-gattopardo",
2001
+ "title": "Il Gattopardo",
2002
+ "author": {
2003
+ "$ref": "../autori/autori.json#/giuseppe-tomasi-di-lampedusa"
2004
+ },
2005
+ "year": 1958,
2006
+ "genre": "Romanzo storico",
2007
+ "summary": "Il tramonto dell'aristocrazia siciliana durante l'unificazione italiana."
2008
+ },
2009
+ "la-coscienza-di-zeno": {
2010
+ "id": "la-coscienza-di-zeno",
2011
+ "title": "La coscienza di Zeno",
2012
+ "author": {
2013
+ "$ref": "../autori/autori.json#/italo-svevo"
2014
+ },
2015
+ "year": 1923,
2016
+ "genre": "Romanzo psicologico",
2017
+ "summary": "Un diario ironico e nevrotico attraversa memoria, terapia e autoinganno."
2018
+ },
2019
+ "i-promessi-sposi": {
2020
+ "id": "i-promessi-sposi",
2021
+ "title": "I promessi sposi",
2022
+ "author": {
2023
+ "$ref": "../autori/autori.json#/alessandro-manzoni"
2024
+ },
2025
+ "year": 1842,
2026
+ "genre": "Classico",
2027
+ "summary": "La vicenda di Renzo e Lucia dentro carestia, guerra, peste e provvidenza."
2028
+ },
2029
+ "il-barone-rampante": {
2030
+ "id": "il-barone-rampante",
2031
+ "title": "Il barone rampante",
2032
+ "author": {
2033
+ "$ref": "../autori/autori.json#/italo-calvino"
2034
+ },
2035
+ "year": 1957,
2036
+ "genre": "Romanzo filosofico",
2037
+ "summary": "Cosimo sceglie di vivere sugli alberi e trasforma la distanza in una forma di liberta."
2038
+ },
2039
+ "gomorra": {
2040
+ "id": "gomorra",
2041
+ "title": "Gomorra",
2042
+ "author": {
2043
+ "$ref": "../autori/autori.json#/roberto-saviano"
2044
+ },
2045
+ "year": 2006,
2046
+ "genre": "Inchiesta",
2047
+ "summary": "Un reportage narrativo sulle economie e le violenze del sistema camorristico."
2048
+ },
2049
+ "oceano-mare": {
2050
+ "id": "oceano-mare",
2051
+ "title": "Oceano mare",
2052
+ "author": {
2053
+ "$ref": "../autori/autori.json#/alessandro-baricco"
2054
+ },
2055
+ "year": 1993,
2056
+ "genre": "Romanzo letterario",
2057
+ "summary": "Storie diverse si incontrano in una locanda sul mare, tra cura, naufragio e mistero."
2058
+ },
2059
+ "lessico-famigliare": {
2060
+ "id": "lessico-famigliare",
2061
+ "title": "Lessico famigliare",
2062
+ "author": {
2063
+ "$ref": "../autori/autori.json#/natalia-ginzburg"
2064
+ },
2065
+ "year": 1963,
2066
+ "genre": "Memoria narrativa",
2067
+ "summary": "Una famiglia prende forma attraverso parole, tic linguistici e memoria civile."
2068
+ },
2069
+ "cent-anni-di-solitudine": {
2070
+ "id": "cent-anni-di-solitudine",
2071
+ "title": "Cent'anni di solitudine",
2072
+ "author": {
2073
+ "$ref": "../autori/autori.json#/gabriel-garcia-marquez"
2074
+ },
2075
+ "year": 1967,
2076
+ "genre": "Realismo magico",
2077
+ "summary": "La saga dei Buendia e di Macondo intreccia mito, storia e destino."
2078
+ },
2079
+ "fahrenheit-451": {
2080
+ "id": "fahrenheit-451",
2081
+ "title": "Fahrenheit 451",
2082
+ "author": {
2083
+ "$ref": "../autori/autori.json#/ray-bradbury"
2084
+ },
2085
+ "year": 1953,
2086
+ "genre": "Distopia",
2087
+ "summary": "In un futuro dove i libri bruciano, leggere diventa un atto di resistenza."
2088
+ },
2089
+ "dune": {
2090
+ "id": "dune",
2091
+ "title": "Dune",
2092
+ "author": {
2093
+ "$ref": "../autori/autori.json#/frank-herbert"
2094
+ },
2095
+ "year": 1965,
2096
+ "genre": "Fantascienza",
2097
+ "summary": "Politica, ecologia e messianismo si scontrano sul pianeta desertico Arrakis."
2098
+ },
2099
+ "neuromancer": {
2100
+ "id": "neuromancer",
2101
+ "title": "Neuromancer",
2102
+ "author": {
2103
+ "$ref": "../autori/autori.json#/william-gibson"
2104
+ },
2105
+ "year": 1984,
2106
+ "genre": "Cyberpunk",
2107
+ "summary": "Un hacker decaduto viene trascinato in un colpo che attraversa cyberspazio e intelligenze artificiali."
2108
+ },
2109
+ "il-signore-degli-anelli": {
2110
+ "id": "il-signore-degli-anelli",
2111
+ "title": "Il Signore degli Anelli",
2112
+ "author": {
2113
+ "$ref": "../autori/autori.json#/j-r-r-tolkien"
2114
+ },
2115
+ "year": 1954,
2116
+ "genre": "Fantasy",
2117
+ "summary": "La Compagnia affronta il potere dell'Anello in una delle grandi epopee moderne."
2118
+ },
2119
+ "harry-potter-e-la-pietra-filosofale": {
2120
+ "id": "harry-potter-e-la-pietra-filosofale",
2121
+ "title": "Harry Potter e la pietra filosofale",
2122
+ "author": {
2123
+ "$ref": "../autori/autori.json#/j-k-rowling"
2124
+ },
2125
+ "year": 1997,
2126
+ "genre": "Fantasy",
2127
+ "summary": "Un ragazzo scopre il mondo magico e il proprio posto in una storia piu grande."
2128
+ },
2129
+ "orgoglio-e-pregiudizio": {
2130
+ "id": "orgoglio-e-pregiudizio",
2131
+ "title": "Orgoglio e pregiudizio",
2132
+ "author": {
2133
+ "$ref": "../autori/autori.json#/jane-austen"
2134
+ },
2135
+ "year": 1813,
2136
+ "genre": "Classico",
2137
+ "summary": "Elizabeth Bennet e Mr. Darcy si misurano con classe, carattere e giudizio sociale."
2138
+ },
2139
+ "moby-dick": {
2140
+ "id": "moby-dick",
2141
+ "title": "Moby Dick",
2142
+ "author": {
2143
+ "$ref": "../autori/autori.json#/herman-melville"
2144
+ },
2145
+ "year": 1851,
2146
+ "genre": "Avventura",
2147
+ "summary": "La caccia alla balena bianca diventa ossessione metafisica e viaggio nell'abisso."
2148
+ },
2149
+ "delitto-e-castigo": {
2150
+ "id": "delitto-e-castigo",
2151
+ "title": "Delitto e castigo",
2152
+ "author": {
2153
+ "$ref": "../autori/autori.json#/fedor-dostoevskij"
2154
+ },
2155
+ "year": 1866,
2156
+ "genre": "Romanzo psicologico",
2157
+ "summary": "Raskolnikov attraversa colpa, febbre morale e possibilita di redenzione."
2158
+ },
2159
+ "anna-karenina": {
2160
+ "id": "anna-karenina",
2161
+ "title": "Anna Karenina",
2162
+ "author": {
2163
+ "$ref": "../autori/autori.json#/lev-tolstoj"
2164
+ },
2165
+ "year": 1877,
2166
+ "genre": "Classico",
2167
+ "summary": "Una storia d'amore e rovina dentro la societa russa dell'Ottocento."
2168
+ },
2169
+ "il-maestro-e-margherita": {
2170
+ "id": "il-maestro-e-margherita",
2171
+ "title": "Il maestro e Margherita",
2172
+ "author": {
2173
+ "$ref": "../autori/autori.json#/michail-bulgakov"
2174
+ },
2175
+ "year": 1967,
2176
+ "genre": "Satira fantastica",
2177
+ "summary": "Il diavolo visita Mosca in un romanzo visionario su arte, censura e amore."
2178
+ },
2179
+ "la-strada": {
2180
+ "id": "la-strada",
2181
+ "title": "La strada",
2182
+ "author": {
2183
+ "$ref": "../autori/autori.json#/cormac-mccarthy"
2184
+ },
2185
+ "year": 2006,
2186
+ "genre": "Post-apocalittico",
2187
+ "summary": "Padre e figlio attraversano un mondo bruciato portando con se una fragile idea di bene."
2188
+ },
2189
+ "cloud-atlas": {
2190
+ "id": "cloud-atlas",
2191
+ "title": "Cloud Atlas",
2192
+ "author": {
2193
+ "$ref": "../autori/autori.json#/david-mitchell"
2194
+ },
2195
+ "year": 2004,
2196
+ "genre": "Romanzo corale",
2197
+ "summary": "Sei storie in epoche diverse compongono una meditazione su potere, memoria e reincorrenza."
2198
+ },
2199
+ "kafka-sulla-spiaggia": {
2200
+ "id": "kafka-sulla-spiaggia",
2201
+ "title": "Kafka sulla spiaggia",
2202
+ "author": {
2203
+ "$ref": "../autori/autori.json#/haruki-murakami"
2204
+ },
2205
+ "year": 2002,
2206
+ "genre": "Surrealismo",
2207
+ "summary": "Fuga, destino e sogno si intrecciano in un romanzo sospeso tra reale e mitico."
2208
+ },
2209
+ "americanah": {
2210
+ "id": "americanah",
2211
+ "title": "Americanah",
2212
+ "author": {
2213
+ "$ref": "../autori/autori.json#/chimamanda-ngozi-adichie"
2214
+ },
2215
+ "year": 2013,
2216
+ "genre": "Romanzo contemporaneo",
2217
+ "summary": "Migrazione, razza e identita raccontate attraverso una storia d'amore tra Nigeria e Stati Uniti."
2218
+ },
2219
+ "la-casa-degli-spiriti": {
2220
+ "id": "la-casa-degli-spiriti",
2221
+ "title": "La casa degli spiriti",
2222
+ "author": {
2223
+ "$ref": "../autori/autori.json#/isabel-allende"
2224
+ },
2225
+ "year": 1982,
2226
+ "genre": "Saga familiare",
2227
+ "summary": "La storia della famiglia Trueba intreccia politica, memoria e realismo magico."
2228
+ },
2229
+ "beloved": {
2230
+ "id": "beloved",
2231
+ "title": "Beloved",
2232
+ "author": {
2233
+ "$ref": "../autori/autori.json#/toni-morrison"
2234
+ },
2235
+ "year": 1987,
2236
+ "genre": "Romanzo storico",
2237
+ "summary": "Il trauma della schiavitu ritorna come presenza viva nella casa di Sethe."
2238
+ },
2239
+ "la-breve-favolosa-vita-di-oscar-wao": {
2240
+ "id": "la-breve-favolosa-vita-di-oscar-wao",
2241
+ "title": "La breve favolosa vita di Oscar Wao",
2242
+ "author": {
2243
+ "$ref": "../autori/autori.json#/junot-diaz"
2244
+ },
2245
+ "year": 2007,
2246
+ "genre": "Romanzo contemporaneo",
2247
+ "summary": "Famiglia, diaspora dominicana e cultura pop si intrecciano nella storia di Oscar."
2248
+ },
2249
+ "le-correzioni": {
2250
+ "id": "le-correzioni",
2251
+ "title": "Le correzioni",
2252
+ "author": {
2253
+ "$ref": "../autori/autori.json#/jonathan-franzen"
2254
+ },
2255
+ "year": 2001,
2256
+ "genre": "Romanzo familiare",
2257
+ "summary": "Una famiglia americana tenta di ritrovarsi mentre ciascuno affronta le proprie fratture."
2258
+ }
2259
+ }
2260
+ END_OF_FILE_CONTENT
2261
+ mkdir -p "src/data/config"
2262
+ echo "Creating src/data/config/menu.json..."
2263
+ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
2264
+ {
2265
+ "main": [
2266
+ {
2267
+ "label": "Why",
2268
+ "href": "#Why"
2269
+ },
2270
+ {
2271
+ "label": "Architecture",
2272
+ "href": "#Architecture"
2273
+ },
2274
+ {
2275
+ "label": "Example",
2276
+ "href": "#Example"
2277
+ },
2278
+ {
2279
+ "label": "Get started",
2280
+ "href": "#Getstarted"
2281
+ },
2282
+ {
2283
+ "label": "GitHub",
2284
+ "href": "https://github.com/olonjs/core"
2285
+ }
2286
+ ],
2287
+ "footer": [
2288
+ {
2289
+ "id": "footer-home",
2290
+ "label": "Home",
2291
+ "href": "/"
2292
+ },
2293
+ {
2294
+ "id": "footer-libri",
2295
+ "label": "Libri",
2296
+ "href": "/libri"
2297
+ },
2298
+ {
2299
+ "id": "footer-github",
2300
+ "label": "GitHub",
2301
+ "href": "https://github.com/olonjs/core",
2302
+ "external": true
2303
+ }
2304
+ ]
2305
+ }
2306
+ END_OF_FILE_CONTENT
2307
+ echo "Creating src/data/config/site.json..."
2308
+ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
2309
+ {
2310
+ "identity": {
2311
+ "title": "OlonJS",
2312
+ "logoUrl": "/brand/mark/olon-mark-dark.svg"
2313
+ },
2314
+ "header": {
2315
+ "id": "global-header",
2316
+ "type": "header",
2317
+ "data": {
2318
+ "logoText": "Olon",
2319
+ "badge": "JS",
2320
+ "links": [
2321
+ {
2322
+ "label": "Why",
2323
+ "href": "#Why"
2324
+ },
2325
+ {
2326
+ "label": "Architecture",
2327
+ "href": "#Architecture"
2328
+ },
2329
+ {
2330
+ "label": "Example",
2331
+ "href": "#Example"
2332
+ },
2333
+ {
2334
+ "label": "Get started",
2335
+ "href": "#Getstarted"
2336
+ },
2337
+ {
2338
+ "label": "GitHub",
2339
+ "href": "https://github.com/olonjs/core"
2340
+ }
2341
+ ],
2342
+ "ctaLabel": "",
2343
+ "ctaHref": "",
2344
+ "signinHref": ""
2345
+ }
2346
+ },
2347
+ "footer": {
2348
+ "id": "global-footer",
2349
+ "type": "footer",
2350
+ "data": {
2351
+ "brandText": "OlonJS",
2352
+ "description": "AI-native content infrastructure for deterministic, sovereign, git-backed sites.",
2353
+ "copyright": "© 2026 OlonJS · v1.5 · Guido Serio",
2354
+ "links": {
2355
+ "$ref": "../config/menu.json#/footer"
2356
+ },
2357
+ "designSystemHref": ""
2358
+ },
2359
+ "settings": {
2360
+ "showLogo": true
2361
+ }
2362
+ }
2363
+ }
2364
+ END_OF_FILE_CONTENT
2365
+ echo "Creating src/data/config/theme.json..."
2366
+ cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
2367
+ {
2368
+ "name": "Olon",
2369
+ "tokens": {
2370
+ "colors": {
2371
+ "background": "hsl(215 28% 7%)",
2372
+ "card": "hsl(218 44% 9%)",
2373
+ "elevated": "#141B24",
2374
+ "overlay": "#1C2433",
2375
+ "popover": "hsl(218 44% 9%)",
2376
+ "popover-foreground": "hsl(214 33% 84%)",
2377
+ "foreground": "hsl(214 33% 84%)",
2378
+ "card-foreground": "hsl(214 33% 84%)",
2379
+ "muted-foreground": "hsl(215 23% 57%)",
2380
+ "placeholder": "#4A5C78",
2381
+ "primary": "hsl(222 100% 54%)",
2382
+ "primary-foreground": "hsl(0 0% 100%)",
2383
+ "primary-light": "#84ABFF",
2384
+ "primary-dark": "#0F52E0",
2385
+ "primary-50": "#EEF3FF",
2386
+ "primary-100": "#D6E4FF",
2387
+ "primary-200": "#ADC8FF",
2388
+ "primary-300": "#84ABFF",
2389
+ "primary-400": "#5B8EFF",
2390
+ "primary-500": "#1763FF",
2391
+ "primary-600": "#0F52E0",
2392
+ "primary-700": "#0940B8",
2393
+ "primary-800": "#063090",
2394
+ "primary-900": "#031E68",
2395
+ "accent": "hsl(216 28% 15%)",
2396
+ "accent-foreground": "hsl(214 33% 84%)",
2397
+ "secondary": "hsl(217 30% 11%)",
2398
+ "secondary-foreground": "hsl(214 33% 84%)",
2399
+ "muted": "hsl(217 30% 11%)",
2400
+ "border": "hsl(216 27% 21%)",
2401
+ "border-strong": "#2F3D55",
2402
+ "input": "hsl(216 27% 21%)",
2403
+ "ring": "hsl(222 100% 54%)",
2404
+ "destructive": "hsl(0 40% 46%)",
2405
+ "destructive-foreground": "hsl(210 58% 93%)",
2406
+ "destructive-border": "#7F2626",
2407
+ "destructive-ring": "#E06060",
2408
+ "success": "hsl(152 83% 26%)",
2409
+ "success-foreground": "hsl(210 58% 93%)",
2410
+ "success-border": "#1DB87A",
2411
+ "success-indicator": "#1DB87A",
2412
+ "warning": "hsl(46 100% 21%)",
2413
+ "warning-foreground": "hsl(210 58% 93%)",
2414
+ "warning-border": "#C49A00",
2415
+ "info": "hsl(214 100% 40%)",
2416
+ "info-foreground": "hsl(210 58% 93%)",
2417
+ "info-border": "#4D9FE0"
2418
+ },
2419
+ "modes": {
2420
+ "light": {
2421
+ "colors": {
2422
+ "background": "hsl(0 0% 96%)",
2423
+ "card": "hsl(0 0% 100%)",
2424
+ "elevated": "#F4F3EF",
2425
+ "overlay": "#E5E3DC",
2426
+ "popover": "hsl(0 0% 100%)",
2427
+ "popover-foreground": "hsl(0 0% 3%)",
2428
+ "foreground": "hsl(0 0% 3%)",
2429
+ "card-foreground": "hsl(0 0% 3%)",
2430
+ "muted-foreground": "hsl(0 0% 42%)",
2431
+ "placeholder": "#B4B2AD",
2432
+ "primary": "hsl(222 100% 54%)",
2433
+ "primary-foreground": "hsl(0 0% 100%)",
2434
+ "primary-light": "#5B8EFF",
2435
+ "primary-dark": "#0F52E0",
2436
+ "primary-50": "#EEF3FF",
2437
+ "primary-100": "#D6E4FF",
2438
+ "primary-200": "#ADC8FF",
2439
+ "primary-300": "#84ABFF",
2440
+ "primary-400": "#5B8EFF",
2441
+ "primary-500": "#1763FF",
2442
+ "primary-600": "#0F52E0",
2443
+ "primary-700": "#0940B8",
2444
+ "primary-800": "#063090",
2445
+ "primary-900": "#031E68",
2446
+ "accent": "hsl(222 100% 92%)",
2447
+ "accent-foreground": "hsl(222 100% 54%)",
2448
+ "secondary": "hsl(0 0% 92%)",
2449
+ "secondary-foreground": "hsl(0 0% 3%)",
2450
+ "muted": "hsl(0 0% 92%)",
2451
+ "border": "hsl(0 0% 84%)",
2452
+ "border-strong": "#B4B2AD",
2453
+ "input": "hsl(0 0% 84%)",
2454
+ "ring": "hsl(222 100% 54%)",
2455
+ "destructive": "hsl(0 72% 51%)",
2456
+ "destructive-foreground": "hsl(0 0% 100%)",
2457
+ "destructive-border": "#FECACA",
2458
+ "destructive-ring": "#EF4444",
2459
+ "success": "hsl(160 84% 39%)",
2460
+ "success-foreground": "hsl(0 0% 100%)",
2461
+ "success-border": "#D4F0E2",
2462
+ "success-indicator": "#0A7C4E",
2463
+ "warning": "hsl(38 92% 50%)",
2464
+ "warning-foreground": "hsl(0 0% 3%)",
2465
+ "warning-border": "#F5EAD4",
2466
+ "info": "hsl(222 100% 54%)",
2467
+ "info-foreground": "hsl(0 0% 100%)",
2468
+ "info-border": "#D4E5F5"
2469
+ }
2470
+ }
2471
+ },
2472
+ "typography": {
2473
+ "fontFamily": {
2474
+ "primary": "\"Instrument Sans\", Helvetica, Arial, sans-serif",
2475
+ "mono": "\"JetBrains Mono\", \"Fira Code\", monospace",
2476
+ "display": "\"Instrument Sans\", Helvetica, Arial, sans-serif"
2477
+ },
2478
+ "wordmark": {
2479
+ "fontFamily": "\"Instrument Sans\", Helvetica, Arial, sans-serif",
2480
+ "weight": "700",
2481
+ "tracking": "-0.05em"
2482
+ },
2483
+ "scale": {
2484
+ "xs": "0.75rem",
2485
+ "sm": "0.875rem",
2486
+ "base": "1rem",
2487
+ "lg": "1.125rem",
2488
+ "xl": "1.25rem",
2489
+ "2xl": "1.5rem",
2490
+ "3xl": "1.875rem",
2491
+ "4xl": "2.25rem",
2492
+ "5xl": "3rem",
2493
+ "6xl": "3rem",
2494
+ "7xl": "4.5rem"
2495
+ },
2496
+ "tracking": {
2497
+ "tight": "-0.04em",
2498
+ "normal": "0em",
2499
+ "wide": "0.04em",
2500
+ "widest": "0.14em"
2501
+ },
2502
+ "leading": {
2503
+ "tight": "1.2",
2504
+ "normal": "1.5",
2505
+ "relaxed": "1.7"
2506
+ }
2507
+ },
2508
+ "borderRadius": {
2509
+ "xl": "1rem",
2510
+ "lg": "0.75rem",
2511
+ "md": "0.5rem",
2512
+ "sm": "0.25rem",
2513
+ "full": "9999px"
2514
+ },
2515
+ "spacing": {
2516
+ "container-max": "72rem",
2517
+ "section-y": "4rem",
2518
+ "header-h": "4rem"
2519
+ },
2520
+ "zIndex": {
2521
+ "base": "0",
2522
+ "elevated": "10",
2523
+ "dropdown": "20",
2524
+ "sticky": "40",
2525
+ "overlay": "50",
2526
+ "modal": "60",
2527
+ "toast": "100"
2528
+ }
2529
+ }
2530
+ }
2531
+ END_OF_FILE_CONTENT
2532
+ mkdir -p "src/data/pages"
2533
+ mkdir -p "src/data/pages/authors"
2534
+ echo "Creating src/data/pages/authors.json..."
2535
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/authors.json"
2536
+ {
2537
+ "id": "authors-page",
2538
+ "slug": "authors",
2539
+ "meta": {
2540
+ "title": "Authors",
2541
+ "description": "Author directory powered by the autori collection."
2542
+ },
2543
+ "sections": [
2544
+ {
2545
+ "id": "authors-list-1",
2546
+ "type": "authors-list",
2547
+ "data": {
2548
+ "anchorId": "authors",
2549
+ "eyebrow": "Collection demo",
2550
+ "title": "Authors",
2551
+ "description": "A collection-backed directory of authors referenced by books.",
2552
+ "items": {
2553
+ "$ref": "../collections/autori/autori.json"
2554
+ }
2555
+ }
2556
+ }
2557
+ ],
2558
+ "global-header": false
2559
+ }
2560
+
2561
+ END_OF_FILE_CONTENT
2562
+ mkdir -p "src/data/pages/authors/[authorId]"
2563
+ echo "Creating src/data/pages/authors/[authorId]/libri.json..."
2564
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/authors/[authorId]/libri.json"
2565
+ {
2566
+ "id": "author-books-page",
2567
+ "slug": "authors/[authorId]/libri",
2568
+ "meta": {
2569
+ "title": "Libri per autore",
2570
+ "description": "Catalogo libri filtrato per autore."
2571
+ },
2572
+ "sections": [
2573
+ {
2574
+ "id": "books-list-author-filter",
2575
+ "type": "books-list",
2576
+ "data": {
2577
+ "anchorId": "libri-autore",
2578
+ "eyebrow": "Author books",
2579
+ "title": "Libri",
2580
+ "description": "Libri filtrati per autore dalla collection autori.",
2581
+ "items": {
2582
+ "$ref": "../../collections/libri/libri.json"
2583
+ },
2584
+ "pageSize": 10
2585
+ }
2586
+ }
2587
+ ],
2588
+ "global-header": false
2589
+ }
2590
+
2591
+ END_OF_FILE_CONTENT
2592
+ echo "Creating src/data/pages/form.json..."
2593
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/form.json"
2594
+ {
2595
+ "id": "form-page",
2596
+ "slug": "form",
2597
+ "meta": {
2598
+ "title": "Home",
2599
+ "description": "OlonJS tenant alpha — form smoke test"
2600
+ },
2601
+ "sections": [
2602
+ {
2603
+ "id": "form-demo-1",
2604
+ "type": "form-demo",
2605
+ "data": {
2606
+ "anchorId": "form-demo",
2607
+ "recipientEmail": "test@olonjs.io",
2608
+ "icon": "mail",
2609
+ "title": "contact us",
2610
+ "description": "Compila il modulo e ti risponderemo al più presto.",
2611
+ "submitLabel": "Invia",
2612
+ "successMessage": "Richiesta inviata con successo."
2613
+ }
2614
+ }
2615
+ ],
2616
+ "global-header": false
2617
+ }
2618
+
2619
+ END_OF_FILE_CONTENT
2620
+ echo "Creating src/data/pages/home.json..."
2621
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
2622
+ {
2623
+ "id": "libri-page",
2624
+ "slug": "home",
2625
+ "meta": {
2626
+ "title": "Libri",
2627
+ "description": "Catalogo libri dimostrativo alimentato da COP collections."
2628
+ },
2629
+ "sections": [
2630
+ {
2631
+ "id": "books-list-1",
2632
+ "type": "books-list",
2633
+ "data": {
2634
+ "anchorId": "catalogo-libri",
2635
+ "eyebrow": "Collection demo",
2636
+ "title": "Collections",
2637
+ "description": "Una pagina collection con 30 titoli, paginazione lato componente e link alle schede dinamiche.",
2638
+ "items": {
2639
+ "$ref": "../collections/libri/libri.json"
2640
+ },
2641
+ "pageSize": 10
2642
+ }
2643
+ }
2644
+ ],
2645
+ "global-header": false
2646
+ }
2647
+ END_OF_FILE_CONTENT
2648
+ mkdir -p "src/data/pages/libri"
2649
+ echo "Creating src/data/pages/libri.json..."
2650
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri.json"
2651
+ {
2652
+ "id": "libri-page",
2653
+ "slug": "libri",
2654
+ "meta": {
2655
+ "title": "Libri",
2656
+ "description": "Catalogo libri dimostrativo alimentato da COP collections."
2657
+ },
2658
+ "sections": [
2659
+ {
2660
+ "id": "books-list-1",
2661
+ "type": "books-list",
2662
+ "data": {
2663
+ "anchorId": "catalogo-libri",
2664
+ "eyebrow": "Collection demo",
2665
+ "title": "Libri",
2666
+ "description": "Una pagina collection con titoli, paginazione lato componente e filtro autore.",
2667
+ "items": {
2668
+ "$ref": "../collections/libri/libri.json"
2669
+ },
2670
+ "pageSize": 10
2671
+ }
2672
+ }
2673
+ ],
2674
+ "global-header": false
2675
+ }
2676
+
2677
+ END_OF_FILE_CONTENT
2678
+ echo "Creating src/data/pages/libri/[slug].json..."
2679
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri/[slug].json"
2680
+ {
2681
+ "id": "libro-detail-page",
2682
+ "slug": "libri/[slug]",
2683
+ "meta": {
2684
+ "title": "Dettaglio libro",
2685
+ "description": "Pagina dinamica dettaglio libro alimentata dalla collection libri."
2686
+ },
2687
+ "sections": [
2688
+ {
2689
+ "id": "book-detail-1",
2690
+ "type": "book-detail",
2691
+ "data": {
2692
+ "anchorId": "dettaglio-libro",
2693
+ "item": {
2694
+ "$ref": "collection:current"
2695
+ },
2696
+ "backLabel": "← Torna ai libri"
2697
+ }
2698
+ }
2699
+ ],
2700
+ "collection": {
2701
+ "source": "libri",
2702
+ "paramKey": "slug"
2703
+ },
2704
+ "global-header": false
2705
+ }
2706
+
2707
+ END_OF_FILE_CONTENT
2708
+ mkdir -p "src/lib"
2709
+ echo "Creating src/lib/CollectionRegistry.ts..."
2710
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/CollectionRegistry.ts"
2711
+ import { AutoriCollectionSchema } from '@/collections/autori';
2712
+ import { LibriCollectionSchema } from '@/collections/libri';
2713
+
2714
+ export const CollectionRegistry = {
2715
+ autori: AutoriCollectionSchema,
2716
+ libri: LibriCollectionSchema,
2717
+ } as const;
2718
+
2719
+ export type CollectionType = keyof typeof CollectionRegistry;
2720
+
2721
+ END_OF_FILE_CONTENT
2722
+ echo "Creating src/lib/ComponentRegistry.tsx..."
2723
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
2724
+ import type React from 'react';
2725
+ import type { SectionType } from '@/types';
2726
+ import type { SectionComponentPropsMap } from '@/types';
2727
+ import { AuthorsListView } from '@/components/authors-list';
2728
+ import { BookDetailView } from '@/components/book-detail';
2729
+ import { BooksListView } from '@/components/books-list';
2730
+ import { EmptyTenantView } from '@/components/empty-tenant';
2731
+ import { FooterView } from '@/components/footer';
2732
+ import { FormDemoView } from '@/components/form-demo';
2733
+ import { HeaderView } from '@/components/header';
2734
+
2735
+ export const ComponentRegistry: {
2736
+ [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
2737
+ } = {
2738
+ 'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
2739
+ 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
2740
+ 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
2741
+ 'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
2742
+ footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
2743
+ 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
2744
+ header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
2745
+ };
2746
+
2747
+ END_OF_FILE_CONTENT
2748
+ echo "Creating src/lib/IconResolver.tsx..."
2749
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/IconResolver.tsx"
2750
+ import React from 'react';
2751
+ import {
2752
+ Layers,
2753
+ Github,
2754
+ ArrowRight,
2755
+ Box,
2756
+ Terminal,
2757
+ ChevronRight,
2758
+ Menu,
2759
+ X,
2760
+ Sparkles,
2761
+ Zap,
2762
+ Mail,
2763
+ type LucideIcon,
2764
+ } from 'lucide-react';
2765
+
2766
+ export const iconMap = {
2767
+ layers: Layers,
2768
+ github: Github,
2769
+ 'arrow-right': ArrowRight,
2770
+ box: Box,
2771
+ terminal: Terminal,
2772
+ 'chevron-right': ChevronRight,
2773
+ menu: Menu,
2774
+ x: X,
2775
+ sparkles: Sparkles,
2776
+ zap: Zap,
2777
+ mail: Mail,
2778
+ } as const satisfies Record<string, LucideIcon>;
2779
+
2780
+ export type IconName = keyof typeof iconMap;
2781
+
2782
+ export function isIconName(s: string): s is IconName {
2783
+ return s in iconMap;
2784
+ }
2785
+
2786
+ interface IconProps {
2787
+ name: string;
2788
+ size?: number;
2789
+ className?: string;
2790
+ }
2791
+
2792
+ export const Icon: React.FC<IconProps> = ({ name, size = 20, className }) => {
2793
+ const IconComponent = isIconName(name) ? iconMap[name] : undefined;
2794
+
2795
+ if (!IconComponent) {
2796
+ if (process.env.NODE_ENV === 'development') {
2797
+ console.warn(`[IconResolver] Unknown icon: "${name}". Add it to iconMap.`);
2798
+ }
2799
+ return null;
2800
+ }
2801
+
2802
+ return <IconComponent size={size} className={className} />;
2803
+ };
2804
+
2805
+ END_OF_FILE_CONTENT
2806
+ echo "Creating src/lib/VisitorSection.tsx..."
2807
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/VisitorSection.tsx"
2808
+ import type { FC } from 'react';
2809
+ import type { Section, SectionType } from '@/types';
2810
+ import type { BooksListData } from '@/components/books-list';
2811
+ import { BooksListView } from '@/components/books-list';
2812
+ import { ComponentRegistry } from '@/lib/ComponentRegistry';
2813
+
2814
+ export type VisitorSectionExtras = {
2815
+ authorId?: string | null;
2816
+ page?: number;
2817
+ pathname?: string;
2818
+ };
2819
+
2820
+ /** Render a resolved page section via the tenant ComponentRegistry (RSC path). */
2821
+ export function VisitorSection({
2822
+ section,
2823
+ extras,
2824
+ }: {
2825
+ section: Section;
2826
+ extras?: VisitorSectionExtras;
2827
+ }) {
2828
+ if (section.type === 'books-list') {
2829
+ return (
2830
+ <BooksListView
2831
+ data={section.data as BooksListData}
2832
+ authorId={extras?.authorId}
2833
+ page={extras?.page}
2834
+ pathname={extras?.pathname}
2835
+ />
2836
+ );
2837
+ }
2838
+
2839
+ const type = section.type as SectionType;
2840
+ const Comp = ComponentRegistry[type];
2841
+ if (!Comp) {
2842
+ return (
2843
+ <section className="px-6 py-8 text-muted-foreground">
2844
+ Unknown section type: {String(section.type)}
2845
+ </section>
2846
+ );
2847
+ }
2848
+
2849
+ // Registry Views accept { data, settings? }; cast keeps MTRP map flexible for RSC host.
2850
+ const View = Comp as FC<{ data: unknown; settings?: unknown }>;
2851
+ return <View data={section.data} settings={section.settings} />;
2852
+ }
2853
+
2854
+ END_OF_FILE_CONTENT
2855
+ echo "Creating src/lib/addSectionConfig.ts..."
2856
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
2857
+ import type { AddSectionConfig } from '@olonjs/core';
2858
+
2859
+ const addableSectionTypes = ['authors-list', 'book-detail', 'books-list', 'empty-tenant', 'footer', 'form-demo'] as const;
2860
+
2861
+ const sectionTypeLabels: Record<string, string> = {
2862
+ 'authors-list': 'Authors List',
2863
+ 'book-detail': 'Book Detail',
2864
+ 'books-list': 'Books List',
2865
+ 'empty-tenant': 'Empty Tenant',
2866
+ footer: 'Footer',
2867
+ 'form-demo': 'Form Demo',
2868
+ };
2869
+
2870
+ function getDefaultSectionData(type: string): Record<string, unknown> {
2871
+ switch (type) {
2872
+ case 'authors-list':
2873
+ return {
2874
+ eyebrow: 'Collection demo',
2875
+ title: 'Authors',
2876
+ description: 'Authors loaded from the autori collection.',
2877
+ items: { $ref: '../collections/autori/autori.json' },
2878
+ };
2879
+ case 'book-detail':
2880
+ return {
2881
+ item: { $ref: 'collection:current' },
2882
+ backLabel: 'Torna ai libri',
2883
+ };
2884
+ case 'books-list':
2885
+ return {
2886
+ eyebrow: 'Collection demo',
2887
+ title: 'Libri',
2888
+ description: 'Catalogo dimostrativo alimentato dalla collection libri.',
2889
+ items: { $ref: '../collections/libri/libri.json' },
2890
+ pageSize: 10,
2891
+ };
2892
+ case 'empty-tenant':
2893
+ return {
2894
+ title: 'Your tenant is empty.',
2895
+ description: 'Create your first page to start building your site.',
2896
+ };
2897
+ case 'footer':
2898
+ return {
2899
+ brandText: 'OlonJS',
2900
+ description: 'AI-native content infrastructure for deterministic, git-backed sites.',
2901
+ copyright: '© 2026 OlonJS',
2902
+ links: [],
2903
+ designSystemHref: '',
2904
+ };
2905
+ case 'form-demo':
2906
+ return {
2907
+ title: 'Contattaci',
2908
+ description: 'Compila il modulo e ti risponderemo al più presto.',
2909
+ recipientEmail: '',
2910
+ submitLabel: 'Invia',
2911
+ successMessage: 'Richiesta inviata con successo.',
2912
+ };
2913
+ default:
2914
+ return {};
2915
+ }
2916
+ }
2917
+
2918
+ export const addSectionConfig: AddSectionConfig = {
2919
+ addableSectionTypes: [...addableSectionTypes],
2920
+ sectionTypeLabels,
2921
+ getDefaultSectionData,
2922
+ };
2923
+
2924
+ END_OF_FILE_CONTENT
2925
+ mkdir -p "src/lib/admin"
2926
+ echo "Creating src/lib/admin/hydrateLocalProjectState.ts..."
2927
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/admin/hydrateLocalProjectState.ts"
2928
+ import type { JsonPagesConfig, MenuConfig, PageConfig, ProjectState, SiteConfig, ThemeConfig } from '@olonjs/core';
2929
+
2930
+ type HydrateLocalProjectStateArgs = {
2931
+ state: ProjectState;
2932
+ slug: string;
2933
+ setPages: (updater: (prev: Record<string, PageConfig>) => Record<string, PageConfig>) => void;
2934
+ setSiteConfig: (site: SiteConfig) => void;
2935
+ setMenuConfig: (menu: MenuConfig) => void;
2936
+ setThemeConfig: (theme: ThemeConfig) => void;
2937
+ setCollections: (collections: NonNullable<JsonPagesConfig['collections']>) => void;
2938
+ };
2939
+
2940
+ /**
2941
+ * Write-through hydrate after local `/api/save-to-file`.
2942
+ * Pushes only the slices present in the saved ProjectState into island state.
2943
+ */
2944
+ export function hydrateLocalProjectState({
2945
+ state,
2946
+ slug,
2947
+ setPages,
2948
+ setSiteConfig,
2949
+ setMenuConfig,
2950
+ setThemeConfig,
2951
+ setCollections,
2952
+ }: HydrateLocalProjectStateArgs): void {
2953
+ if (state.menu != null) setMenuConfig(state.menu);
2954
+ if (state.site != null) setSiteConfig(state.site);
2955
+ if (state.theme != null) setThemeConfig(state.theme);
2956
+ if (state.page != null) {
2957
+ setPages((prev) => ({ ...prev, [slug]: state.page }));
2958
+ }
2959
+ if (state.collections != null) {
2960
+ setCollections(state.collections as NonNullable<JsonPagesConfig['collections']>);
2961
+ }
2962
+ }
2963
+
2964
+ END_OF_FILE_CONTENT
2965
+ echo "Creating src/lib/admin/useCloudSave.ts..."
2966
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/admin/useCloudSave.ts"
2967
+ 'use client';
2968
+
2969
+ import { useCallback, useEffect, useRef, useState } from 'react';
2970
+ import type { DeployPhase, ProjectState, StepId } from '@olonjs/core';
2971
+ import { DEPLOY_STEPS, startCloudSaveStream } from '@olonjs/core';
2972
+
2973
+ export interface CloudSaveUiState {
2974
+ isOpen: boolean;
2975
+ phase: DeployPhase;
2976
+ currentStepId: StepId | null;
2977
+ doneSteps: StepId[];
2978
+ progress: number;
2979
+ errorMessage?: string;
2980
+ deployUrl?: string;
2981
+ }
2982
+
2983
+ function getInitialCloudSaveUiState(): CloudSaveUiState {
2984
+ return {
2985
+ isOpen: false,
2986
+ phase: 'idle',
2987
+ currentStepId: null,
2988
+ doneSteps: [],
2989
+ progress: 0,
2990
+ };
2991
+ }
2992
+
2993
+ function stepProgress(doneSteps: StepId[]): number {
2994
+ return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
2995
+ }
2996
+
2997
+ function isRecord(value: unknown): value is Record<string, unknown> {
2998
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
2999
+ }
3000
+
3001
+ function cloneJson<T>(value: T): T {
3002
+ return JSON.parse(JSON.stringify(value)) as T;
3003
+ }
3004
+
3005
+ function normalizePathSegments(value: string): string {
3006
+ return value
3007
+ .split('/')
3008
+ .map((segment) => segment.trim())
3009
+ .filter(Boolean)
3010
+ .join('/');
3011
+ }
3012
+
3013
+ function resolveAdminContentSlug(basePath = '/'): string | null {
3014
+ if (typeof window === 'undefined') return null;
3015
+
3016
+ const normalizedBase = basePath.replace(/\/+$/, '');
3017
+ let path = window.location.pathname;
3018
+ if (normalizedBase && normalizedBase !== '/' && path.startsWith(normalizedBase)) {
3019
+ path = path.slice(normalizedBase.length) || '/';
3020
+ }
3021
+
3022
+ const slug = normalizePathSegments(path.replace(/^\/admin\/?/, ''));
3023
+ return slug || null;
3024
+ }
3025
+
3026
+ function resolveTemplateParamValue(templateSlug: string, concreteSlug: string, paramKey: string): string | null {
3027
+ const templateSegments = normalizePathSegments(templateSlug).split('/').filter(Boolean);
3028
+ const concreteSegments = normalizePathSegments(concreteSlug).split('/').filter(Boolean);
3029
+ if (templateSegments.length !== concreteSegments.length) return null;
3030
+
3031
+ for (let index = 0; index < templateSegments.length; index += 1) {
3032
+ const templateSegment = templateSegments[index];
3033
+ const concreteSegment = concreteSegments[index];
3034
+ const paramMatch = templateSegment.match(/^\[([A-Za-z0-9_-]+)\]$/);
3035
+ if (paramMatch?.[1] === paramKey) return concreteSegment;
3036
+ if (!paramMatch && templateSegment !== concreteSegment) return null;
3037
+ }
3038
+
3039
+ return null;
3040
+ }
3041
+
3042
+ function hasCollectionCurrentRef(value: unknown): boolean {
3043
+ if (Array.isArray(value)) return value.some(hasCollectionCurrentRef);
3044
+ if (!isRecord(value)) return false;
3045
+ if (value.$ref === 'collection:current') return true;
3046
+ return Object.values(value).some(hasCollectionCurrentRef);
3047
+ }
3048
+
3049
+ function replaceCollectionCurrentRefs(value: unknown, currentItem: unknown): unknown {
3050
+ if (Array.isArray(value)) return value.map((item) => replaceCollectionCurrentRefs(item, currentItem));
3051
+ if (!isRecord(value)) return value;
3052
+ if (value.$ref === 'collection:current') return cloneJson(currentItem);
3053
+ return Object.fromEntries(
3054
+ Object.entries(value).map(([key, entryValue]) => [key, replaceCollectionCurrentRefs(entryValue, currentItem)]),
3055
+ );
3056
+ }
3057
+
3058
+ function buildSaveStreamPagePayload(state: ProjectState, fallbackSlug: string): { slug: string; page: ProjectState['page'] } {
3059
+ const page = state.page;
3060
+ const collection = page.collection;
3061
+ if (!collection || !hasCollectionCurrentRef(page)) {
3062
+ return { slug: fallbackSlug, page };
3063
+ }
3064
+
3065
+ const concreteSlug = resolveAdminContentSlug();
3066
+ if (!concreteSlug) {
3067
+ throw new Error('Cannot resolve concrete admin route for collection page save.');
3068
+ }
3069
+
3070
+ const paramValue = resolveTemplateParamValue(page.slug, concreteSlug, collection.paramKey);
3071
+ if (!paramValue) {
3072
+ throw new Error(`Cannot resolve collection param "${collection.paramKey}" from route "${concreteSlug}".`);
3073
+ }
3074
+
3075
+ const collectionDocument = state.collections?.[collection.source];
3076
+ const currentItem = isRecord(collectionDocument) ? collectionDocument[paramValue] : undefined;
3077
+ if (!currentItem) {
3078
+ throw new Error(`Cannot resolve collection item "${collection.source}/${paramValue}" for save.`);
3079
+ }
3080
+
3081
+ const resolvedPage = replaceCollectionCurrentRefs(page, currentItem) as ProjectState['page'];
3082
+ return {
3083
+ slug: concreteSlug,
3084
+ page: {
3085
+ ...resolvedPage,
3086
+ slug: concreteSlug,
3087
+ },
3088
+ };
3089
+ }
3090
+
3091
+ export type UseCloudSaveOptions = {
3092
+ apiUrl: string;
3093
+ apiKey: string;
3094
+ };
3095
+
3096
+ /**
3097
+ * Slim Save2Repo cold-save hook (alpha useCloudSave pattern).
3098
+ * HotSave is intentionally not included.
3099
+ */
3100
+ export function useCloudSave({ apiUrl, apiKey }: UseCloudSaveOptions) {
3101
+ const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
3102
+ const activeCloudSaveController = useRef<AbortController | null>(null);
3103
+ const pendingCloudSave = useRef<{ state: ProjectState; slug: string } | null>(null);
3104
+
3105
+ useEffect(() => {
3106
+ return () => {
3107
+ activeCloudSaveController.current?.abort();
3108
+ };
3109
+ }, []);
3110
+
3111
+ const runCloudSave = useCallback(
3112
+ async (payload: { state: ProjectState; slug: string }, rejectOnError: boolean): Promise<void> => {
3113
+ if (!apiUrl || !apiKey) {
3114
+ const noCloudError = new Error('Cloud mode is not configured.');
3115
+ if (rejectOnError) throw noCloudError;
3116
+ return;
3117
+ }
3118
+
3119
+ pendingCloudSave.current = payload;
3120
+ activeCloudSaveController.current?.abort();
3121
+ const controller = new AbortController();
3122
+ activeCloudSaveController.current = controller;
3123
+
3124
+ setCloudSaveUi({
3125
+ isOpen: true,
3126
+ phase: 'running',
3127
+ currentStepId: null,
3128
+ doneSteps: [],
3129
+ progress: 0,
3130
+ });
3131
+
3132
+ try {
3133
+ const savePage = buildSaveStreamPagePayload(payload.state, payload.slug);
3134
+ await startCloudSaveStream({
3135
+ apiBaseUrl: apiUrl,
3136
+ apiKey,
3137
+ path: `src/data/pages/${savePage.slug}.json`,
3138
+ content: savePage.page,
3139
+ additionalFiles: [
3140
+ { path: 'src/data/config/site.json', content: payload.state.site },
3141
+ { path: 'src/data/config/menu.json', content: payload.state.menu },
3142
+ ],
3143
+ changedScopes: ['page', 'site', 'menu'],
3144
+ message: `Content update for ${savePage.slug} via Visual Editor`,
3145
+ signal: controller.signal,
3146
+ onStep: (event) => {
3147
+ setCloudSaveUi((prev) => {
3148
+ if (event.status === 'running') {
3149
+ return {
3150
+ ...prev,
3151
+ isOpen: true,
3152
+ phase: 'running',
3153
+ currentStepId: event.id,
3154
+ errorMessage: undefined,
3155
+ };
3156
+ }
3157
+
3158
+ if (prev.doneSteps.includes(event.id)) {
3159
+ return prev;
3160
+ }
3161
+
3162
+ const nextDone = [...prev.doneSteps, event.id];
3163
+ return {
3164
+ ...prev,
3165
+ isOpen: true,
3166
+ phase: 'running',
3167
+ currentStepId: event.id,
3168
+ doneSteps: nextDone,
3169
+ progress: stepProgress(nextDone),
3170
+ };
3171
+ });
3172
+ },
3173
+ onDone: (event) => {
3174
+ const completed = DEPLOY_STEPS.map((step) => step.id);
3175
+ setCloudSaveUi({
3176
+ isOpen: true,
3177
+ phase: 'done',
3178
+ currentStepId: 'live',
3179
+ doneSteps: completed,
3180
+ progress: 100,
3181
+ deployUrl: event.deployUrl,
3182
+ });
3183
+ },
3184
+ });
3185
+ } catch (error: unknown) {
3186
+ const message = error instanceof Error ? error.message : 'Cloud save failed.';
3187
+ setCloudSaveUi((prev) => ({
3188
+ ...prev,
3189
+ isOpen: true,
3190
+ phase: 'error',
3191
+ errorMessage: message,
3192
+ }));
3193
+ if (rejectOnError) throw new Error(message);
3194
+ } finally {
3195
+ if (activeCloudSaveController.current === controller) {
3196
+ activeCloudSaveController.current = null;
3197
+ }
3198
+ }
3199
+ },
3200
+ [apiUrl, apiKey],
3201
+ );
3202
+
3203
+ const closeCloudDrawer = useCallback(() => {
3204
+ setCloudSaveUi(getInitialCloudSaveUiState());
3205
+ }, []);
3206
+
3207
+ const retryCloudSave = useCallback(() => {
3208
+ if (!pendingCloudSave.current) return;
3209
+ void runCloudSave(pendingCloudSave.current, false);
3210
+ }, [runCloudSave]);
3211
+
3212
+ return { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave };
3213
+ }
3214
+
3215
+ END_OF_FILE_CONTENT
3216
+ mkdir -p "src/lib/css"
3217
+ echo "Creating src/lib/css/serializeThemeRootCss.test.ts..."
3218
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/css/serializeThemeRootCss.test.ts"
3219
+ import { describe, expect, it } from 'vitest';
3220
+ import type { ThemeConfig } from '@olonjs/core';
3221
+ import { serializeThemeRootCss } from './serializeThemeRootCss';
3222
+
3223
+ describe('serializeThemeRootCss', () => {
3224
+ it('emits :root --theme-* from theme.json (no CSS color literals as source)', () => {
3225
+ const theme = {
3226
+ name: 'test',
3227
+ tokens: {
3228
+ colors: { background: 'hsl(10 20% 30%)', foreground: 'hsl(0 0% 100%)' },
3229
+ typography: { fontFamily: { primary: 'Inter, sans-serif' } },
3230
+ borderRadius: {},
3231
+ },
3232
+ } as ThemeConfig;
3233
+
3234
+ const css = serializeThemeRootCss(theme);
3235
+ expect(css).toContain('--theme-colors-background:hsl(10 20% 30%)');
3236
+ expect(css).toContain('--theme-colors-foreground:hsl(0 0% 100%)');
3237
+ expect(css).not.toContain('hsl(215 28% 7%)');
3238
+ });
3239
+ });
3240
+
3241
+ END_OF_FILE_CONTENT
3242
+ echo "Creating src/lib/css/serializeThemeRootCss.ts..."
3243
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/css/serializeThemeRootCss.ts"
3244
+ import { buildThemeVariableMap, type ThemeConfig } from '@olonjs/core';
3245
+
3246
+ /** Publish theme.json as `:root{--theme-*}` (CIP / alpha entry-ssg parity). */
3247
+ export function serializeThemeRootCss(theme: ThemeConfig): string {
3248
+ const mappings = buildThemeVariableMap(theme);
3249
+ const entries = Object.entries(mappings);
3250
+ if (entries.length === 0) return '';
3251
+ return `:root{${entries.map(([name, value]) => `${name}:${value}`).join(';')}}`;
3252
+ }
3253
+
3254
+ END_OF_FILE_CONTENT
3255
+ echo "Creating src/lib/dnaDistWiring.test.ts..."
3256
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/dnaDistWiring.test.ts"
3257
+ import fs from 'node:fs';
3258
+ import path from 'node:path';
3259
+ import { describe, expect, it } from 'vitest';
3260
+ import { fileURLToPath } from 'node:url';
3261
+
3262
+ const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
3263
+
3264
+ describe('tenant-next DNA dist wiring', () => {
3265
+ it('has src2Code.sh and a dist script targeting template next', () => {
3266
+ const encoder = path.join(appRoot, 'src2Code.sh');
3267
+ expect(fs.existsSync(encoder)).toBe(true);
3268
+
3269
+ const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8')) as {
3270
+ scripts?: Record<string, string>;
3271
+ };
3272
+ const dist = pkg.scripts?.dist ?? '';
3273
+ expect(dist).toContain('src2Code.sh');
3274
+ expect(dist).toContain('--template next');
3275
+ expect(dist).toMatch(/\bapp\b/);
3276
+ expect(dist).toMatch(/\bsrc\b/);
3277
+ expect(pkg.scripts?.['dist:dna']).toBe('npm run dist');
3278
+ });
3279
+ });
3280
+
3281
+ END_OF_FILE_CONTENT
3282
+ echo "Creating src/lib/dnaTemplateNextAssets.test.ts..."
3283
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/dnaTemplateNextAssets.test.ts"
3284
+ import fs from 'node:fs';
3285
+ import path from 'node:path';
3286
+ import { describe, expect, it } from 'vitest';
3287
+ import { fileURLToPath } from 'node:url';
3288
+
3289
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
3290
+ const templateDir = path.join(repoRoot, 'packages/cli/assets/templates/next');
3291
+
3292
+ describe('CLI template next DNA assets', () => {
3293
+ it('has src_tenant.sh + manifest with expected markers', () => {
3294
+ const dna = path.join(templateDir, 'src_tenant.sh');
3295
+ const manifestPath = path.join(templateDir, 'manifest.json');
3296
+ expect(fs.existsSync(dna)).toBe(true);
3297
+ expect(fs.existsSync(manifestPath)).toBe(true);
3298
+
3299
+ const content = fs.readFileSync(dna, 'utf8');
3300
+ expect(content).toContain('set -e');
3301
+ expect(content).toContain('package.json');
3302
+ expect(content).toContain('middleware.ts');
3303
+ expect(content).toMatch(/app\//);
3304
+
3305
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
3306
+ name?: string;
3307
+ dnaScript?: string;
3308
+ };
3309
+ expect(manifest.name).toBe('next');
3310
+ expect(manifest.dnaScript).toBe('src_tenant.sh');
3311
+ });
3312
+ });
3313
+
3314
+ END_OF_FILE_CONTENT
3315
+ mkdir -p "src/lib/env"
3316
+ echo "Creating src/lib/env/serverCloudPolicy.test.ts..."
3317
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/env/serverCloudPolicy.test.ts"
3318
+ import { describe, expect, it } from 'vitest';
3319
+ import { buildServerApiCandidates, readServerCloudPolicy } from './serverCloudPolicy';
3320
+
3321
+ describe('readServerCloudPolicy', () => {
3322
+ it('defaults to local without credentials', () => {
3323
+ expect(readServerCloudPolicy({}).bootSource).toBe('local');
3324
+ });
3325
+
3326
+ it('selects live with credentials and Save2Repo off', () => {
3327
+ expect(
3328
+ readServerCloudPolicy({
3329
+ NEXT_PUBLIC_OLONJS_CLOUD_URL: 'https://api.example',
3330
+ NEXT_PUBLIC_OLONJS_API_KEY: 'k',
3331
+ }).bootSource,
3332
+ ).toBe('live');
3333
+ });
3334
+
3335
+ it('selects static with credentials and Save2Repo on', () => {
3336
+ expect(
3337
+ readServerCloudPolicy({
3338
+ NEXT_PUBLIC_OLONJS_CLOUD_URL: 'https://api.example',
3339
+ NEXT_PUBLIC_OLONJS_API_KEY: 'k',
3340
+ NEXT_PUBLIC_SAVE2REPO: 'true',
3341
+ }).bootSource,
3342
+ ).toBe('static');
3343
+ });
3344
+ });
3345
+
3346
+ describe('buildServerApiCandidates', () => {
3347
+ it('prefers /api/v1 and keeps raw base', () => {
3348
+ expect(buildServerApiCandidates('https://api.example')).toEqual([
3349
+ 'https://api.example/api/v1',
3350
+ 'https://api.example',
3351
+ ]);
3352
+ });
3353
+ });
3354
+
3355
+ END_OF_FILE_CONTENT
3356
+ echo "Creating src/lib/env/serverCloudPolicy.ts..."
3357
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/env/serverCloudPolicy.ts"
3358
+ export type ServerCloudBootSource = 'local' | 'static' | 'live';
3359
+
3360
+ export type ServerCloudPolicy = {
3361
+ bootSource: ServerCloudBootSource;
3362
+ apiUrl: string;
3363
+ apiKey: string;
3364
+ save2RepoEnabled: boolean;
3365
+ };
3366
+
3367
+ /**
3368
+ * Server-safe cloud policy (mirrors `@olonjs/react` resolveCloudPolicy).
3369
+ * Do not import `@olonjs/react` from Route Handlers — its package entry is client-bound.
3370
+ */
3371
+ export function readServerCloudPolicy(
3372
+ env: Record<string, string | undefined> = process.env,
3373
+ ): ServerCloudPolicy {
3374
+ const apiUrl =
3375
+ (env.NEXT_PUBLIC_OLONJS_CLOUD_URL ?? env.NEXT_PUBLIC_JSONPAGES_CLOUD_URL ?? '').trim();
3376
+ const apiKey =
3377
+ (env.NEXT_PUBLIC_OLONJS_API_KEY ?? env.NEXT_PUBLIC_JSONPAGES_API_KEY ?? '').trim();
3378
+ const save2RepoRaw =
3379
+ env.NEXT_PUBLIC_OLONJS_SAVE2REPO ?? env.NEXT_PUBLIC_SAVE2REPO ?? '';
3380
+ const save2RepoFlag = save2RepoRaw === 'true';
3381
+ const isCloudMode = Boolean(apiUrl && apiKey);
3382
+ const save2RepoEnabled = isCloudMode && save2RepoFlag;
3383
+
3384
+ let bootSource: ServerCloudBootSource = 'local';
3385
+ if (isCloudMode) {
3386
+ bootSource = save2RepoEnabled ? 'static' : 'live';
3387
+ }
3388
+
3389
+ return { bootSource, apiUrl, apiKey, save2RepoEnabled };
3390
+ }
3391
+
3392
+ /** Prefer …/api/v1, keep raw base as fallback (mirrors `@olonjs/react` buildApiCandidates). */
3393
+ export function buildServerApiCandidates(raw: string): string[] {
3394
+ const base = raw.trim().replace(/\/+$/, '');
3395
+ if (!base) return [];
3396
+ const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
3397
+ return Array.from(new Set([withApi, base]));
3398
+ }
3399
+
3400
+ END_OF_FILE_CONTENT
3401
+ echo "Creating src/lib/env/tenantEnv.ts..."
3402
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/env/tenantEnv.ts"
3403
+ import { resolveCloudPolicy, type CloudEnvInput, type CloudPolicy } from '@olonjs/react';
3404
+
3405
+ /**
3406
+ * Read Next.js public env → CloudEnvInput.
3407
+ * Prefer NEXT_PUBLIC_OLONJS_* ; also accept NEXT_PUBLIC_JSONPAGES_* and
3408
+ * NEXT_PUBLIC_SAVE2REPO (alpha-style flag name under the Next public prefix).
3409
+ *
3410
+ * Note: `import.meta.env` / VITE_* are not available in the Next client bundle
3411
+ * unless mirrored via next.config — do not call readCloudEnvFromVite here.
3412
+ */
3413
+ export function readCloudEnvFromNext(
3414
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
3415
+ ): CloudEnvInput {
3416
+ const apiUrl =
3417
+ (env.NEXT_PUBLIC_OLONJS_CLOUD_URL ?? env.NEXT_PUBLIC_JSONPAGES_CLOUD_URL ?? '').trim();
3418
+ const apiKey =
3419
+ (env.NEXT_PUBLIC_OLONJS_API_KEY ?? env.NEXT_PUBLIC_JSONPAGES_API_KEY ?? '').trim();
3420
+ const save2RepoRaw =
3421
+ env.NEXT_PUBLIC_OLONJS_SAVE2REPO ?? env.NEXT_PUBLIC_SAVE2REPO ?? '';
3422
+ return {
3423
+ apiUrl,
3424
+ apiKey,
3425
+ save2RepoFlag: save2RepoRaw === 'true',
3426
+ };
3427
+ }
3428
+
3429
+ /** Single policy path for the Next admin island. */
3430
+ export const cloudPolicy: CloudPolicy = resolveCloudPolicy(readCloudEnvFromNext());
3431
+
3432
+ export const CLOUD_API_URL = cloudPolicy.apiUrl;
3433
+ export const CLOUD_API_KEY = cloudPolicy.apiKey;
3434
+ export const TENANT_ID = 'next';
3435
+
3436
+ END_OF_FILE_CONTENT
3437
+ mkdir -p "src/lib/loaders"
3438
+ echo "Creating src/lib/loaders/getFileCollections.ts..."
3439
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/getFileCollections.ts"
3440
+ import fs from 'node:fs';
3441
+ import path from 'node:path';
3442
+ import type { JsonPagesConfig } from '@olonjs/core';
3443
+ import { resolveLocalDataRoots } from '@olonjs/next/server';
3444
+
3445
+ type CollectionDocuments = NonNullable<JsonPagesConfig['collections']>;
3446
+
3447
+ /** Collection documents from src/data/collections/<source>/<source>.json. */
3448
+ export function getFileCollections(appRoot = process.cwd()): CollectionDocuments {
3449
+ const { collectionsDir } = resolveLocalDataRoots(appRoot);
3450
+ const collections: CollectionDocuments = {};
3451
+ if (!fs.existsSync(collectionsDir)) return collections;
3452
+
3453
+ for (const source of fs.readdirSync(collectionsDir, { withFileTypes: true })) {
3454
+ if (!source.isDirectory()) continue;
3455
+ const sourceName = source.name.trim();
3456
+ if (!sourceName) continue;
3457
+ const filePath = path.join(collectionsDir, sourceName, `${sourceName}.json`);
3458
+ if (!fs.existsSync(filePath)) continue;
3459
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
3460
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) continue;
3461
+ collections[sourceName] = raw as CollectionDocuments[string];
3462
+ }
3463
+ return collections;
3464
+ }
3465
+
3466
+ END_OF_FILE_CONTENT
3467
+ echo "Creating src/lib/loaders/getFilePages.ts..."
3468
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/getFilePages.ts"
3469
+ import fs from 'node:fs';
3470
+ import path from 'node:path';
3471
+ import type { PageConfig } from '@olonjs/core';
3472
+ import { resolveLocalDataRoots } from '@olonjs/next/server';
3473
+
3474
+ function slugFromRelative(relPath: string): string {
3475
+ const withoutExt = relPath.replace(/\.json$/i, '');
3476
+ const canonical = withoutExt
3477
+ .split(/[/\\]/)
3478
+ .map((segment) => segment.trim())
3479
+ .filter(Boolean)
3480
+ .join('/');
3481
+ return canonical || 'home';
3482
+ }
3483
+
3484
+ function walkJsonFiles(dir: string, baseDir: string, out: string[]): void {
3485
+ if (!fs.existsSync(dir)) return;
3486
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3487
+ const abs = path.join(dir, entry.name);
3488
+ if (entry.isDirectory()) {
3489
+ walkJsonFiles(abs, baseDir, out);
3490
+ continue;
3491
+ }
3492
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
3493
+ out.push(path.relative(baseDir, abs));
3494
+ }
3495
+ }
3496
+ }
3497
+
3498
+ /** Page registry from nested JSON under src/data/pages (JSP). */
3499
+ export function getFilePages(appRoot = process.cwd()): Record<string, PageConfig> {
3500
+ const { pagesDir } = resolveLocalDataRoots(appRoot);
3501
+ const relFiles: string[] = [];
3502
+ walkJsonFiles(pagesDir, pagesDir, relFiles);
3503
+ relFiles.sort((a, b) => a.localeCompare(b));
3504
+
3505
+ const bySlug = new Map<string, PageConfig>();
3506
+ for (const rel of relFiles) {
3507
+ const abs = path.join(pagesDir, rel);
3508
+ const raw = JSON.parse(fs.readFileSync(abs, 'utf8')) as unknown;
3509
+ if (raw == null || typeof raw !== 'object') continue;
3510
+ const slug = slugFromRelative(rel);
3511
+ bySlug.set(slug, raw as PageConfig);
3512
+ }
3513
+
3514
+ const slugs = Array.from(bySlug.keys()).sort((a, b) =>
3515
+ a === 'home' ? -1 : b === 'home' ? 1 : a.localeCompare(b),
3516
+ );
3517
+ const record: Record<string, PageConfig> = {};
3518
+ for (const slug of slugs) {
3519
+ const config = bySlug.get(slug);
3520
+ if (config) record[slug] = config;
3521
+ }
3522
+ return record;
3523
+ }
3524
+
3525
+ END_OF_FILE_CONTENT
3526
+ echo "Creating src/lib/loaders/getFileSiteConfig.ts..."
3527
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/getFileSiteConfig.ts"
3528
+ import fs from 'node:fs';
3529
+ import path from 'node:path';
3530
+ import type { MenuConfig, SiteConfig, ThemeConfig } from '@olonjs/core';
3531
+ import { resolveLocalDataRoots } from '@olonjs/next/server';
3532
+
3533
+ function readJson<T>(filePath: string, fallback: T): T {
3534
+ if (!fs.existsSync(filePath)) return fallback;
3535
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
3536
+ }
3537
+
3538
+ export function getFileSiteBundle(appRoot = process.cwd()): {
3539
+ siteConfig: SiteConfig;
3540
+ menuConfig: MenuConfig;
3541
+ themeConfig: ThemeConfig;
3542
+ } {
3543
+ const { configDir } = resolveLocalDataRoots(appRoot);
3544
+ return {
3545
+ siteConfig: readJson(
3546
+ path.join(configDir, 'site.json'),
3547
+ {
3548
+ identity: { title: 'OlonJS' },
3549
+ footer: { id: 'footer', type: 'footer', data: { brandText: 'OlonJS' } },
3550
+ } as unknown as SiteConfig,
3551
+ ),
3552
+ menuConfig: readJson<MenuConfig>(path.join(configDir, 'menu.json'), {}),
3553
+ themeConfig: readJson(path.join(configDir, 'theme.json'), {
3554
+ name: 'default',
3555
+ tokens: { colors: {}, typography: { fontFamily: {} }, borderRadius: {} },
3556
+ } as ThemeConfig),
3557
+ };
3558
+ }
3559
+
3560
+ END_OF_FILE_CONTENT
3561
+ echo "Creating src/lib/loaders/loadLivePublicPageBundle.test.ts..."
3562
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadLivePublicPageBundle.test.ts"
3563
+ import path from 'node:path';
3564
+ import { describe, expect, it, vi } from 'vitest';
3565
+ import { resolvePublicPageJson } from '@olonjs/next/server';
3566
+ import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
3567
+
3568
+ describe('loadLivePublicPageBundle', () => {
3569
+ const appRoot = path.resolve(__dirname, '../../..');
3570
+
3571
+ it('resolves home.json from a mocked live render payload', async () => {
3572
+ const fetchImpl = vi.fn(async () =>
3573
+ new Response(
3574
+ JSON.stringify({
3575
+ ok: true,
3576
+ route: { path: '/', template: 'home', params: {} },
3577
+ context: {
3578
+ siteConfig: {
3579
+ identity: { title: 'Live' },
3580
+ footer: { id: 'footer', type: 'footer', data: { brandText: 'L' } },
3581
+ },
3582
+ menuConfig: {},
3583
+ },
3584
+ page: {
3585
+ id: 'home-page',
3586
+ slug: 'home',
3587
+ meta: { title: 'Live Home' },
3588
+ sections: [],
3589
+ },
3590
+ }),
3591
+ { status: 200, headers: { 'content-type': 'application/json' } },
3592
+ ),
3593
+ );
3594
+
3595
+ const bundle = await loadLivePublicPageBundle({
3596
+ slug: 'home.json',
3597
+ appRoot,
3598
+ apiBases: ['https://api.example/api/v1'],
3599
+ apiKey: 'k',
3600
+ fetchImpl: fetchImpl as typeof fetch,
3601
+ });
3602
+
3603
+ const resolved = resolvePublicPageJson({ slug: 'home.json', bundle });
3604
+ expect(resolved?.page.meta?.title).toBe('Live Home');
3605
+ });
3606
+ });
3607
+
3608
+ END_OF_FILE_CONTENT
3609
+ echo "Creating src/lib/loaders/loadLivePublicPageBundle.ts..."
3610
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadLivePublicPageBundle.ts"
3611
+ import type { PublicPageContentBundle } from '@olonjs/next/server';
3612
+ import { loadLivePublicPageContent } from '@olonjs/next/server';
3613
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
3614
+ import { getFileSiteBundle } from './getFileSiteConfig';
3615
+
3616
+ /**
3617
+ * Live public-page bundle: SPP render for one slug + local theme/schemas.
3618
+ */
3619
+ export async function loadLivePublicPageBundle(input: {
3620
+ slug: string;
3621
+ apiBases: string[];
3622
+ apiKey: string;
3623
+ appRoot?: string;
3624
+ fetchImpl?: typeof fetch;
3625
+ }): Promise<PublicPageContentBundle> {
3626
+ const appRoot = input.appRoot ?? process.cwd();
3627
+ const { themeConfig } = getFileSiteBundle(appRoot);
3628
+ const live = await loadLivePublicPageContent({
3629
+ slug: input.slug,
3630
+ apiBases: input.apiBases,
3631
+ apiKey: input.apiKey,
3632
+ fetchImpl: input.fetchImpl,
3633
+ });
3634
+ return {
3635
+ pages: live.pages,
3636
+ siteConfig: live.siteConfig,
3637
+ menuConfig: live.menuConfig,
3638
+ themeConfig,
3639
+ collectionSchemas: CollectionRegistry as PublicPageContentBundle['collectionSchemas'],
3640
+ refDocuments: {
3641
+ 'menu.json': live.menuConfig,
3642
+ 'config/menu.json': live.menuConfig,
3643
+ 'src/data/config/menu.json': live.menuConfig,
3644
+ },
3645
+ };
3646
+ }
3647
+
3648
+ END_OF_FILE_CONTENT
3649
+ echo "Creating src/lib/loaders/loadLocalPublicPageBundle.test.ts..."
3650
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadLocalPublicPageBundle.test.ts"
3651
+ import path from 'node:path';
3652
+ import { describe, expect, it } from 'vitest';
3653
+ import { createPublicPageJsonHttpResult, resolvePublicPageJson } from '@olonjs/next/server';
3654
+ import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
3655
+
3656
+ describe('loadLocalPublicPageBundle', () => {
3657
+ const appRoot = path.resolve(__dirname, '../../..');
3658
+
3659
+ it('loads local pages and site config into a resolve bundle', () => {
3660
+ const bundle = loadLocalPublicPageBundle(appRoot);
3661
+ expect(bundle.pages.home).toBeDefined();
3662
+ expect(bundle.siteConfig).toBeDefined();
3663
+ expect(bundle.menuConfig).toBeDefined();
3664
+ expect(bundle.themeConfig).toBeDefined();
3665
+ });
3666
+
3667
+ it('resolves home.json to 200 and unknown slug to 404', () => {
3668
+ const bundle = loadLocalPublicPageBundle(appRoot);
3669
+ const home = createPublicPageJsonHttpResult(
3670
+ resolvePublicPageJson({ slug: 'home.json', bundle }),
3671
+ );
3672
+ expect(home.status).toBe(200);
3673
+ expect((home.body as { slug?: string }).slug).toBe('home');
3674
+
3675
+ const missing = createPublicPageJsonHttpResult(
3676
+ resolvePublicPageJson({ slug: 'does-not-exist.json', bundle }),
3677
+ );
3678
+ expect(missing.status).toBe(404);
3679
+ expect(missing.body).toEqual({ error: 'Page JSON not found' });
3680
+ });
3681
+ });
3682
+
3683
+ END_OF_FILE_CONTENT
3684
+ echo "Creating src/lib/loaders/loadLocalPublicPageBundle.ts..."
3685
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadLocalPublicPageBundle.ts"
3686
+ import type { PublicPageContentBundle } from '@olonjs/next/server';
3687
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
3688
+ import { getFileCollections } from './getFileCollections';
3689
+ import { getFilePages } from './getFilePages';
3690
+ import { getFileSiteBundle } from './getFileSiteConfig';
3691
+
3692
+ /** Local filesystem content bundle for public page JSON (Vite local parity). */
3693
+ export function loadLocalPublicPageBundle(appRoot = process.cwd()): PublicPageContentBundle {
3694
+ const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle(appRoot);
3695
+ return {
3696
+ pages: getFilePages(appRoot),
3697
+ siteConfig,
3698
+ themeConfig,
3699
+ menuConfig,
3700
+ collections: getFileCollections(appRoot),
3701
+ collectionSchemas: CollectionRegistry as PublicPageContentBundle['collectionSchemas'],
3702
+ refDocuments: {
3703
+ 'menu.json': menuConfig,
3704
+ 'config/menu.json': menuConfig,
3705
+ 'src/data/config/menu.json': menuConfig,
3706
+ },
3707
+ };
3708
+ }
3709
+
3710
+
3711
+ END_OF_FILE_CONTENT
3712
+ echo "Creating src/lib/loaders/loadPublicPageBundleForRequest.test.ts..."
3713
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadPublicPageBundleForRequest.test.ts"
3714
+ import path from 'node:path';
3715
+ import { describe, expect, it, vi } from 'vitest';
3716
+
3717
+ vi.mock('./loadLocalPublicPageBundle', () => ({
3718
+ loadLocalPublicPageBundle: vi.fn(() => ({
3719
+ pages: { home: { id: 'local', slug: 'home', meta: { title: 'Local' }, sections: [] } },
3720
+ siteConfig: { identity: { title: 'L' } },
3721
+ themeConfig: {},
3722
+ menuConfig: {},
3723
+ })),
3724
+ }));
3725
+
3726
+ vi.mock('./loadStaticPublicPageBundle', () => ({
3727
+ loadStaticPublicPageBundle: vi.fn(async () => ({
3728
+ pages: { home: { id: 'static', slug: 'home', meta: { title: 'Static' }, sections: [] } },
3729
+ siteConfig: { identity: { title: 'S' } },
3730
+ themeConfig: {},
3731
+ menuConfig: {},
3732
+ })),
3733
+ }));
3734
+
3735
+ vi.mock('./loadLivePublicPageBundle', () => ({
3736
+ loadLivePublicPageBundle: vi.fn(async () => ({
3737
+ pages: { home: { id: 'live', slug: 'home', meta: { title: 'Live' }, sections: [] } },
3738
+ siteConfig: { identity: { title: 'V' } },
3739
+ themeConfig: {},
3740
+ menuConfig: {},
3741
+ })),
3742
+ }));
3743
+
3744
+ import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
3745
+ import { loadStaticPublicPageBundle } from './loadStaticPublicPageBundle';
3746
+ import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
3747
+ import { loadPublicPageBundleForRequest } from './loadPublicPageBundleForRequest';
3748
+
3749
+ describe('loadPublicPageBundleForRequest', () => {
3750
+ const appRoot = path.resolve(__dirname, '../../..');
3751
+
3752
+ it('selects Local when bootSource is local', async () => {
3753
+ const bundle = await loadPublicPageBundleForRequest({
3754
+ bootSource: 'local',
3755
+ slug: 'home',
3756
+ requestUrl: 'http://localhost:3000/home.json',
3757
+ appRoot,
3758
+ });
3759
+ expect(bundle.pages.home?.meta?.title).toBe('Local');
3760
+ expect(loadLocalPublicPageBundle).toHaveBeenCalled();
3761
+ expect(loadStaticPublicPageBundle).not.toHaveBeenCalled();
3762
+ expect(loadLivePublicPageBundle).not.toHaveBeenCalled();
3763
+ });
3764
+
3765
+ it('selects Static when bootSource is static', async () => {
3766
+ const bundle = await loadPublicPageBundleForRequest({
3767
+ bootSource: 'static',
3768
+ slug: 'home',
3769
+ requestUrl: 'http://localhost:3000/home.json',
3770
+ appRoot,
3771
+ fetchImpl: vi.fn() as unknown as typeof fetch,
3772
+ });
3773
+ expect(bundle.pages.home?.meta?.title).toBe('Static');
3774
+ expect(loadStaticPublicPageBundle).toHaveBeenCalled();
3775
+ });
3776
+
3777
+ it('selects Live when bootSource is live', async () => {
3778
+ const bundle = await loadPublicPageBundleForRequest({
3779
+ bootSource: 'live',
3780
+ slug: 'home',
3781
+ requestUrl: 'http://localhost:3000/home.json',
3782
+ appRoot,
3783
+ apiUrl: 'https://api.example',
3784
+ apiKey: 'k',
3785
+ fetchImpl: vi.fn() as unknown as typeof fetch,
3786
+ });
3787
+ expect(bundle.pages.home?.meta?.title).toBe('Live');
3788
+ expect(loadLivePublicPageBundle).toHaveBeenCalled();
3789
+ });
3790
+ });
3791
+
3792
+ END_OF_FILE_CONTENT
3793
+ echo "Creating src/lib/loaders/loadPublicPageBundleForRequest.ts..."
3794
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadPublicPageBundleForRequest.ts"
3795
+ import type { PublicPageContentBundle } from '@olonjs/next/server';
3796
+ import {
3797
+ buildServerApiCandidates,
3798
+ type ServerCloudBootSource,
3799
+ } from '@/lib/env/serverCloudPolicy';
3800
+ import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
3801
+ import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
3802
+ import { loadStaticPublicPageBundle } from './loadStaticPublicPageBundle';
3803
+
3804
+ export type LoadPublicPageBundleForRequestInput = {
3805
+ bootSource: ServerCloudBootSource;
3806
+ slug: string;
3807
+ /** Absolute request URL (used for Static same-origin base). */
3808
+ requestUrl: string;
3809
+ appRoot?: string;
3810
+ apiUrl?: string;
3811
+ apiKey?: string;
3812
+ fetchImpl?: typeof fetch;
3813
+ };
3814
+
3815
+ /**
3816
+ * Select Local / Static / Live content bundle from server cloud policy bootSource.
3817
+ */
3818
+ export async function loadPublicPageBundleForRequest(
3819
+ input: LoadPublicPageBundleForRequestInput,
3820
+ ): Promise<PublicPageContentBundle> {
3821
+ const appRoot = input.appRoot ?? process.cwd();
3822
+
3823
+ if (input.bootSource === 'static') {
3824
+ const origin = new URL(input.requestUrl).origin;
3825
+ return loadStaticPublicPageBundle({
3826
+ baseUrl: `${origin}/`,
3827
+ appRoot,
3828
+ fetchImpl: input.fetchImpl,
3829
+ });
3830
+ }
3831
+
3832
+ if (input.bootSource === 'live') {
3833
+ const apiUrl = (input.apiUrl ?? '').trim();
3834
+ const apiKey = (input.apiKey ?? '').trim();
3835
+ if (!apiUrl || !apiKey) {
3836
+ throw new Error('Live public page JSON requires cloud API URL and key');
3837
+ }
3838
+ return loadLivePublicPageBundle({
3839
+ slug: input.slug,
3840
+ apiBases: buildServerApiCandidates(apiUrl),
3841
+ apiKey,
3842
+ appRoot,
3843
+ fetchImpl: input.fetchImpl,
3844
+ });
3845
+ }
3846
+
3847
+ return loadLocalPublicPageBundle(appRoot);
3848
+ }
3849
+
3850
+ END_OF_FILE_CONTENT
3851
+ echo "Creating src/lib/loaders/loadStaticPublicPageBundle.test.ts..."
3852
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadStaticPublicPageBundle.test.ts"
3853
+ import path from 'node:path';
3854
+ import { describe, expect, it, vi } from 'vitest';
3855
+ import { resolvePublicPageJson } from '@olonjs/next/server';
3856
+ import { loadStaticPublicPageBundle } from './loadStaticPublicPageBundle';
3857
+
3858
+ describe('loadStaticPublicPageBundle', () => {
3859
+ const appRoot = path.resolve(__dirname, '../../..');
3860
+
3861
+ it('builds a resolveable bundle from mocked published static content', async () => {
3862
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
3863
+ const url = String(input);
3864
+ if (url.endsWith('/config/site.json')) {
3865
+ return new Response(
3866
+ JSON.stringify({
3867
+ identity: { title: 'Static Site' },
3868
+ footer: { id: 'footer', type: 'footer', data: { brandText: 'S' } },
3869
+ }),
3870
+ { status: 200 },
3871
+ );
3872
+ }
3873
+ if (url.includes('/pages/') && url.endsWith('.json')) {
3874
+ return new Response(
3875
+ JSON.stringify({
3876
+ id: 'home-page',
3877
+ slug: 'home',
3878
+ meta: { title: 'Static Home' },
3879
+ sections: [],
3880
+ }),
3881
+ { status: 200 },
3882
+ );
3883
+ }
3884
+ return new Response('no', { status: 404 });
3885
+ });
3886
+
3887
+ const bundle = await loadStaticPublicPageBundle({
3888
+ appRoot,
3889
+ baseUrl: 'https://static.example/',
3890
+ fetchImpl: fetchImpl as typeof fetch,
3891
+ });
3892
+
3893
+ expect(bundle.siteConfig).toMatchObject({ identity: { title: 'Static Site' } });
3894
+ const resolved = resolvePublicPageJson({ slug: 'home.json', bundle });
3895
+ expect(resolved?.page.meta?.title).toBe('Static Home');
3896
+ });
3897
+ });
3898
+
3899
+ END_OF_FILE_CONTENT
3900
+ echo "Creating src/lib/loaders/loadStaticPublicPageBundle.ts..."
3901
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadStaticPublicPageBundle.ts"
3902
+ import type { PublicPageContentBundle } from '@olonjs/next/server';
3903
+ import { loadPublishedStaticContent } from '@olonjs/next/server';
3904
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
3905
+ import { getFileCollections } from './getFileCollections';
3906
+ import { getFilePages } from './getFilePages';
3907
+ import { getFileSiteBundle } from './getFileSiteConfig';
3908
+
3909
+ /**
3910
+ * Static (Save2Repo) public-page bundle: published pages + site from baseUrl,
3911
+ * menu/theme/collections from local seeds (alpha bootStatic parity).
3912
+ */
3913
+ export async function loadStaticPublicPageBundle(input: {
3914
+ baseUrl: string;
3915
+ appRoot?: string;
3916
+ fetchImpl?: typeof fetch;
3917
+ }): Promise<PublicPageContentBundle> {
3918
+ const appRoot = input.appRoot ?? process.cwd();
3919
+ const knownSlugs = Object.keys(getFilePages(appRoot));
3920
+ const { pages, siteConfig } = await loadPublishedStaticContent({
3921
+ knownSlugs,
3922
+ baseUrl: input.baseUrl,
3923
+ fetchImpl: input.fetchImpl,
3924
+ });
3925
+ const { menuConfig, themeConfig } = getFileSiteBundle(appRoot);
3926
+ return {
3927
+ pages,
3928
+ siteConfig,
3929
+ themeConfig,
3930
+ menuConfig,
3931
+ collections: getFileCollections(appRoot),
3932
+ collectionSchemas: CollectionRegistry as PublicPageContentBundle['collectionSchemas'],
3933
+ refDocuments: {
3934
+ 'menu.json': menuConfig,
3935
+ 'config/menu.json': menuConfig,
3936
+ 'src/data/config/menu.json': menuConfig,
3937
+ },
3938
+ };
3939
+ }
3940
+
3941
+ END_OF_FILE_CONTENT
3942
+ echo "Creating src/lib/resolveVisitorShell.test.ts..."
3943
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/resolveVisitorShell.test.ts"
3944
+ import { describe, expect, it } from 'vitest';
3945
+ import type { PageConfig, SiteConfig } from '@olonjs/core';
3946
+ import { resolveVisitorShell } from './resolveVisitorShell';
3947
+
3948
+ const site = {
3949
+ identity: { title: 'T' },
3950
+ header: { id: 'h', type: 'header', data: { logoText: 'Olon' } },
3951
+ footer: { id: 'f', type: 'footer', data: { brandText: 'OlonJS' } },
3952
+ } as unknown as SiteConfig;
3953
+
3954
+ describe('resolveVisitorShell', () => {
3955
+ it('always returns footer when present on site', () => {
3956
+ const page = { id: 'p', slug: 'home', sections: [], 'global-header': false } as PageConfig;
3957
+ const shell = resolveVisitorShell(page, site);
3958
+ expect(shell.footer?.id).toBe('f');
3959
+ expect(shell.header).toBeNull();
3960
+ });
3961
+
3962
+ it('shows header when global-header is absent (default true)', () => {
3963
+ const page = { id: 'p', slug: 'home', sections: [] } as PageConfig;
3964
+ const shell = resolveVisitorShell(page, site);
3965
+ expect(shell.header?.id).toBe('h');
3966
+ expect(shell.footer?.id).toBe('f');
3967
+ });
3968
+
3969
+ it('shows header when global-header is true', () => {
3970
+ const page = { id: 'p', slug: 'home', sections: [], 'global-header': true } as PageConfig;
3971
+ expect(resolveVisitorShell(page, site).header?.id).toBe('h');
3972
+ });
3973
+ });
3974
+
3975
+ END_OF_FILE_CONTENT
3976
+ echo "Creating src/lib/resolveVisitorShell.ts..."
3977
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/resolveVisitorShell.ts"
3978
+ import {
3979
+ shouldRenderSiteGlobalHeader,
3980
+ type PageConfig,
3981
+ type Section,
3982
+ type SiteConfig,
3983
+ } from '@olonjs/core';
3984
+
3985
+ export type VisitorShellSections = {
3986
+ header: Section | null;
3987
+ footer: Section | null;
3988
+ };
3989
+
3990
+ /**
3991
+ * Visitor chrome: footer always (when site has one); header gated by page `global-header`
3992
+ * (absent ⇒ true; false ⇒ hide). Uses core `shouldRenderSiteGlobalHeader`.
3993
+ */
3994
+ export function resolveVisitorShell(page: PageConfig, site: SiteConfig): VisitorShellSections {
3995
+ const header =
3996
+ shouldRenderSiteGlobalHeader(page, site) && site.header != null ? (site.header as Section) : null;
3997
+ const footer = site.footer != null ? (site.footer as Section) : null;
3998
+ return { header, footer };
3999
+ }
4000
+
4001
+ END_OF_FILE_CONTENT
4002
+ echo "Creating src/lib/schemas.ts..."
4003
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
4004
+ import { AuthorsListSchema } from '@/components/authors-list';
4005
+ import { BookDetailSchema } from '@/components/book-detail';
4006
+ import { BooksListSchema } from '@/components/books-list';
4007
+ import { EmptyTenantSchema } from '@/components/empty-tenant';
4008
+ import { FooterSchema } from '@/components/footer';
4009
+ import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
4010
+ import { HeaderSchema } from '@/components/header';
4011
+
4012
+ export const SECTION_SCHEMAS = {
4013
+ 'authors-list': AuthorsListSchema,
4014
+ 'book-detail': BookDetailSchema,
4015
+ 'books-list': BooksListSchema,
4016
+ 'empty-tenant': EmptyTenantSchema,
4017
+ footer: FooterSchema,
4018
+ 'form-demo': FormDemoSchema,
4019
+ header: HeaderSchema,
4020
+ } as const;
4021
+
4022
+ /**
4023
+ * Registry of per-section-type submission schemas. Keys MUST match a key of
4024
+ * SECTION_SCHEMAS. A section type appearing here is declaring itself as
4025
+ * MCP-submittable: the Zod schema describes the payload accepted by the form.
4026
+ *
4027
+ * See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
4028
+ */
4029
+ export const SECTION_SUBMISSION_SCHEMAS = {
4030
+ 'form-demo': FormDemoSubmissionSchema,
4031
+ } as const;
4032
+
4033
+ export type SectionType = keyof typeof SECTION_SCHEMAS;
4034
+
4035
+ export {
4036
+ BaseSectionData,
4037
+ BaseArrayItem,
4038
+ BaseCollectionItem,
4039
+ BaseSectionSettingsSchema,
4040
+ CtaSchema,
4041
+ ImageSelectionSchema,
4042
+ } from '@olonjs/core';
4043
+
4044
+ END_OF_FILE_CONTENT
4045
+ echo "Creating src/lib/utils.ts..."
4046
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
4047
+ import { clsx, type ClassValue } from 'clsx';
4048
+ import { twMerge } from 'tailwind-merge';
4049
+
4050
+ export function cn(...inputs: ClassValue[]) {
4051
+ return twMerge(clsx(inputs));
4052
+ }
4053
+
4054
+ END_OF_FILE_CONTENT
4055
+ echo "Creating src/lib/visitorSurface.test.ts..."
4056
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/visitorSurface.test.ts"
4057
+ import { describe, expect, it } from 'vitest';
4058
+ import { VISITOR_SURFACE } from './visitorSurface';
4059
+
4060
+ describe('visitorSurface', () => {
4061
+ it('marks the public path as RSC (not a Studio client island)', () => {
4062
+ expect(VISITOR_SURFACE.mode).toBe('rsc');
4063
+ expect(VISITOR_SURFACE.loadsStudio).toBe(false);
4064
+ });
4065
+ });
4066
+
4067
+ END_OF_FILE_CONTENT
4068
+ echo "Creating src/lib/visitorSurface.ts..."
4069
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/visitorSurface.ts"
4070
+ export const VISITOR_SURFACE = {
4071
+ mode: 'rsc' as const,
4072
+ loadsStudio: false,
4073
+ };
4074
+
4075
+ END_OF_FILE_CONTENT
4076
+ echo "Creating src/types.ts..."
4077
+ cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
4078
+ import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
4079
+ import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
4080
+ import type { BooksListData, BooksListSettings } from '@/components/books-list';
4081
+ import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
4082
+ import type { FooterData, FooterSettings } from '@/components/footer';
4083
+ import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
4084
+ import type { HeaderData, HeaderSettings } from '@/components/header';
4085
+ import type { Autore } from '@/collections/autori';
4086
+ import type { Libro } from '@/collections/libri';
4087
+
4088
+ export type SectionComponentPropsMap = {
4089
+ 'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
4090
+ 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
4091
+ 'books-list': { data: BooksListData; settings?: BooksListSettings };
4092
+ 'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
4093
+ footer: { data: FooterData; settings?: FooterSettings };
4094
+ header: { data: HeaderData; settings?: HeaderSettings };
4095
+ 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
4096
+ };
4097
+
4098
+ declare module '@olonjs/core' {
4099
+ export interface SectionDataRegistry {
4100
+ 'authors-list': AuthorsListData;
4101
+ 'book-detail': BookDetailData;
4102
+ 'books-list': BooksListData;
4103
+ 'empty-tenant': EmptyTenantData;
4104
+ footer: FooterData;
4105
+ header: HeaderData;
4106
+ 'form-demo': FormDemoData;
4107
+ }
4108
+ export interface SectionSettingsRegistry {
4109
+ 'authors-list': AuthorsListSettings;
4110
+ 'book-detail': BookDetailSettings;
4111
+ 'books-list': BooksListSettings;
4112
+ 'empty-tenant': EmptyTenantSettings;
4113
+ footer: FooterSettings;
4114
+ header: HeaderSettings;
4115
+ 'form-demo': FormDemoSettings;
4116
+ }
4117
+ export interface CollectionItemRegistry {
4118
+ autori: Autore;
4119
+ libri: Libro;
4120
+ }
4121
+ }
4122
+
4123
+ export * from '@olonjs/core';
4124
+
4125
+ END_OF_FILE_CONTENT
4126
+ echo "Creating tsconfig.json..."
4127
+ cat << 'END_OF_FILE_CONTENT' > "tsconfig.json"
4128
+ {
4129
+ "compilerOptions": {
4130
+ "target": "ES2020",
4131
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
4132
+ "allowJs": false,
4133
+ "skipLibCheck": true,
4134
+ "strict": true,
4135
+ "noEmit": true,
4136
+ "esModuleInterop": true,
4137
+ "module": "ESNext",
4138
+ "moduleResolution": "bundler",
4139
+ "resolveJsonModule": true,
4140
+ "isolatedModules": true,
4141
+ "jsx": "preserve",
4142
+ "incremental": true,
4143
+ "plugins": [{ "name": "next" }],
4144
+ "paths": {
4145
+ "@/*": ["./src/*"]
4146
+ },
4147
+ "baseUrl": "."
4148
+ },
4149
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
4150
+ "exclude": ["node_modules", "src/**/*.test.ts"]
4151
+ }
4152
+
4153
+ END_OF_FILE_CONTENT
4154
+ echo "Creating vitest.config.ts..."
4155
+ cat << 'END_OF_FILE_CONTENT' > "vitest.config.ts"
4156
+ import { defineConfig } from 'vitest/config';
4157
+ import path from 'path';
4158
+ import { fileURLToPath } from 'url';
4159
+
4160
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
4161
+
4162
+ export default defineConfig({
4163
+ test: {
4164
+ environment: 'node',
4165
+ include: ['src/**/*.test.ts'],
4166
+ },
4167
+ resolve: {
4168
+ alias: {
4169
+ '@': path.resolve(__dirname, './src'),
4170
+ },
4171
+ },
4172
+ });
4173
+
4174
+ END_OF_FILE_CONTENT