@olonjs/cli 3.0.154 → 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.
- package/assets/src_tenant_alpha.sh +3 -3
- package/assets/templates/alpha/src_tenant.sh +3 -3
- package/assets/templates/next/manifest.json +5 -0
- package/assets/templates/next/src_tenant.sh +4174 -0
- package/package.json +2 -1
- package/src/index.js +78 -25
- package/src/selectWithArrows.js +71 -0
- package/src/templateChoice.js +29 -0
- package/src/templateChoice.test.js +38 -0
|
@@ -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
|