@olonjs/cli 3.0.144 → 3.0.145

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.
@@ -1817,7 +1817,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^1.1.10",
1820
+ "@olonjs/core": "^1.1.11",
1821
1821
  "class-variance-authority": "^0.7.1",
1822
1822
  "clsx": "^2.1.1",
1823
1823
  "lucide-react": "^0.474.0",
@@ -2131,7 +2131,7 @@ import { build } from 'vite';
2131
2131
  import path from 'path';
2132
2132
  import { fileURLToPath, pathToFileURL } from 'url';
2133
2133
  import fs from 'fs/promises';
2134
- import { webmcp } from '@olonjs/core';
2134
+ import { resolvePageMatchFromRegistry, resolvePublicPageDocument, webmcp } from '@olonjs/core';
2135
2135
 
2136
2136
  const {
2137
2137
  buildPageContract,
@@ -2144,6 +2144,7 @@ const {
2144
2144
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
2145
2145
  const root = path.resolve(__dirname, '..');
2146
2146
  const pagesDir = path.resolve(root, 'src/data/pages');
2147
+ const collectionsDir = path.resolve(root, 'src/data/collections');
2147
2148
  const publicDir = path.resolve(root, 'public');
2148
2149
  const distDir = path.resolve(root, 'dist');
2149
2150
  const distSsrDir = path.resolve(root, 'dist-ssr');
@@ -2184,6 +2185,42 @@ function toCanonicalSlug(relativeJsonPath) {
2184
2185
  return slug;
2185
2186
  }
2186
2187
 
2188
+ async function readJsonFile(filePath) {
2189
+ const raw = await fs.readFile(filePath, 'utf-8');
2190
+ return JSON.parse(raw);
2191
+ }
2192
+
2193
+ async function expandCollectionTarget(slug, pageFilePath) {
2194
+ if (!/\[[^\]]+\]/.test(slug)) return [slug];
2195
+
2196
+ let pageConfig;
2197
+ try {
2198
+ pageConfig = await readJsonFile(pageFilePath);
2199
+ } catch {
2200
+ return [slug];
2201
+ }
2202
+
2203
+ const binding = pageConfig?.collection;
2204
+ if (!binding || typeof binding.source !== 'string' || typeof binding.paramKey !== 'string') {
2205
+ return [slug];
2206
+ }
2207
+
2208
+ const token = `[${binding.paramKey}]`;
2209
+ if (!slug.includes(token)) return [slug];
2210
+
2211
+ const collectionPath = path.resolve(collectionsDir, binding.source, `${binding.source}.json`);
2212
+ let collection;
2213
+ try {
2214
+ collection = await readJsonFile(collectionPath);
2215
+ } catch {
2216
+ return [slug];
2217
+ }
2218
+
2219
+ if (!collection || typeof collection !== 'object' || Array.isArray(collection)) return [slug];
2220
+ const itemIds = Object.keys(collection).sort((a, b) => a.localeCompare(b));
2221
+ return itemIds.length > 0 ? itemIds.map((itemId) => slug.replace(token, itemId)) : [slug];
2222
+ }
2223
+
2187
2224
  async function listJsonFilesRecursive(dir) {
2188
2225
  const items = await fs.readdir(dir, { withFileTypes: true });
2189
2226
  const files = [];
@@ -2206,7 +2243,14 @@ async function discoverTargets() {
2206
2243
  files = [];
2207
2244
  }
2208
2245
 
2209
- const rawSlugs = files.map((fullPath) => toCanonicalSlug(path.relative(pagesDir, fullPath)));
2246
+ const rawSlugs = (
2247
+ await Promise.all(
2248
+ files.map(async (fullPath) => {
2249
+ const slug = toCanonicalSlug(path.relative(pagesDir, fullPath));
2250
+ return expandCollectionTarget(slug, fullPath);
2251
+ })
2252
+ )
2253
+ ).flat();
2210
2254
  const slugs = Array.from(new Set(rawSlugs)).sort((a, b) => a.localeCompare(b));
2211
2255
 
2212
2256
  return slugs.map((slug) => {
@@ -2264,15 +2308,26 @@ const remoteStylesheetTags = getRemoteStylesheets()
2264
2308
  const webMcpBuildState = getWebMcpBuildState();
2265
2309
 
2266
2310
  for (const { slug } of targets) {
2267
- const pageConfig = webMcpBuildState.pages[slug];
2311
+ const pageConfig = resolvePageMatchFromRegistry(webMcpBuildState.pages, slug)?.page;
2268
2312
  if (!pageConfig) continue;
2313
+ const resolvedPageDocument = resolvePublicPageDocument({
2314
+ slug,
2315
+ pages: webMcpBuildState.pages,
2316
+ siteConfig: webMcpBuildState.siteConfig,
2317
+ themeConfig: webMcpBuildState.themeConfig,
2318
+ menuConfig: webMcpBuildState.menuConfig,
2319
+ collections: webMcpBuildState.collections,
2320
+ collectionSchemas: webMcpBuildState.collectionSchemas,
2321
+ refDocuments: webMcpBuildState.refDocuments,
2322
+ });
2323
+ const publicPageConfig = resolvedPageDocument?.page ?? pageConfig;
2269
2324
 
2270
- // Export the raw JSON data for the agentic web (so readResource works on SSG)
2271
- await writeJsonTargets(`pages/${slug}.json`, pageConfig);
2325
+ // Export the resolved public JSON data for the agentic web (so readResource matches runtime).
2326
+ await writeJsonTargets(`pages/${slug}.json`, publicPageConfig);
2272
2327
 
2273
2328
  const contract = buildPageContract({
2274
2329
  slug,
2275
- pageConfig,
2330
+ pageConfig: publicPageConfig,
2276
2331
  schemas: webMcpBuildState.schemas,
2277
2332
  submissionSchemas: webMcpBuildState.submissionSchemas,
2278
2333
  siteConfig: webMcpBuildState.siteConfig,
@@ -2280,7 +2335,7 @@ for (const { slug } of targets) {
2280
2335
  await writeSsrJson(`schemas/${slug}.schema.json`, contract);
2281
2336
  const pageManifest = buildPageManifest({
2282
2337
  slug,
2283
- pageConfig,
2338
+ pageConfig: publicPageConfig,
2284
2339
  schemas: webMcpBuildState.schemas,
2285
2340
  siteConfig: webMcpBuildState.siteConfig,
2286
2341
  });
@@ -2549,6 +2604,8 @@ const __dirname = path.dirname(__filename);
2549
2604
  const rootDir = path.resolve(__dirname, '..');
2550
2605
  const sourceDir = path.join(rootDir, 'src', 'data', 'pages');
2551
2606
  const targetDir = path.join(rootDir, 'public', 'pages');
2607
+ const sourceCollectionsDir = path.join(rootDir, 'src', 'data', 'collections');
2608
+ const targetCollectionsDir = path.join(rootDir, 'public', 'collections');
2552
2609
  const sourceSiteConfigPath = path.join(rootDir, 'src', 'data', 'config', 'site.json');
2553
2610
  const targetConfigDir = path.join(rootDir, 'public', 'config');
2554
2611
  const targetSiteConfigPath = path.join(targetConfigDir, 'site.json');
@@ -2562,12 +2619,18 @@ fs.rmSync(targetDir, { recursive: true, force: true });
2562
2619
  fs.mkdirSync(targetDir, { recursive: true });
2563
2620
  fs.cpSync(sourceDir, targetDir, { recursive: true });
2564
2621
 
2622
+ fs.rmSync(targetCollectionsDir, { recursive: true, force: true });
2623
+ if (fs.existsSync(sourceCollectionsDir)) {
2624
+ fs.mkdirSync(targetCollectionsDir, { recursive: true });
2625
+ fs.cpSync(sourceCollectionsDir, targetCollectionsDir, { recursive: true });
2626
+ }
2627
+
2565
2628
  if (fs.existsSync(sourceSiteConfigPath)) {
2566
2629
  fs.mkdirSync(targetConfigDir, { recursive: true });
2567
2630
  fs.cpSync(sourceSiteConfigPath, targetSiteConfigPath);
2568
2631
  }
2569
2632
 
2570
- console.log('[sync-pages-to-public] Synced pages to public/pages and site config to public/config/site.json');
2633
+ console.log('[sync-pages-to-public] Synced pages, collections, and site config to public/');
2571
2634
 
2572
2635
  END_OF_FILE_CONTENT
2573
2636
  echo "Creating scripts/webmcp-feature-check.mjs..."
@@ -2885,6 +2948,7 @@ import { JsonPagesEngine } from '@olonjs/core';
2885
2948
  import type { JsonPagesConfig, LibraryImageEntry, ProjectState } from '@olonjs/core';
2886
2949
  import { normalizeBasePath, withBasePath } from '@olonjs/core';
2887
2950
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
2951
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
2888
2952
  import { SECTION_SCHEMAS } from '@/lib/schemas';
2889
2953
  import { addSectionConfig } from '@/lib/addSectionConfig';
2890
2954
  import { getHydratedData } from '@/lib/draftStorage';
@@ -2895,6 +2959,7 @@ import { startCloudSaveStream } from '@olonjs/core';
2895
2959
  import siteData from '@/data/config/site.json';
2896
2960
  import themeData from '@/data/config/theme.json';
2897
2961
  import menuData from '@/data/config/menu.json';
2962
+ import libriData from '@/data/collections/libri/libri.json';
2898
2963
  import { getFilePages } from '@/lib/getFilePages';
2899
2964
  import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
2900
2965
  import { EmptyTenantView } from '@/components/empty-tenant';
@@ -2926,6 +2991,9 @@ const TENANT_ID = 'alpha';
2926
2991
 
2927
2992
  const filePages = getFilePages();
2928
2993
  const fileSiteConfig = siteData as unknown as SiteConfig;
2994
+ const collections = {
2995
+ libri: libriData as unknown as Record<string, unknown>,
2996
+ } satisfies NonNullable<JsonPagesConfig['collections']>;
2929
2997
  const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
2930
2998
  const ASSET_UPLOAD_MAX_RETRIES = 2;
2931
2999
  const ASSET_UPLOAD_TIMEOUT_MS = 20_000;
@@ -2963,6 +3031,7 @@ type CachedCloudContent = {
2963
3031
  savedAt: number;
2964
3032
  siteConfig: unknown | null;
2965
3033
  pages: Record<string, unknown>;
3034
+ collections?: JsonPagesConfig['collections'];
2966
3035
  };
2967
3036
 
2968
3037
  const CLOUD_CACHE_KEY = 'jp_cloud_content_cache_v1';
@@ -3008,7 +3077,7 @@ function asString(value: unknown, fallback: string): string {
3008
3077
  function normalizeRouteSlug(value: string): string {
3009
3078
  return value
3010
3079
  .toLowerCase()
3011
- .replace(/[^a-z0-9/_-]/g, '-')
3080
+ .replace(/[^a-z0-9/_[\]-]/g, '-')
3012
3081
  .replace(/^\/+|\/+$/g, '') || 'home';
3013
3082
  }
3014
3083
 
@@ -3034,6 +3103,7 @@ function coercePageConfig(slug: string, value: unknown): PageConfig | null {
3034
3103
  slug: normalizedSlug,
3035
3104
  meta: { title, description },
3036
3105
  sections: input.sections as PageConfig['sections'],
3106
+ ...(isObjectRecord(input.collection) ? { collection: input.collection as unknown as PageConfig['collection'] } : {}),
3037
3107
  ...(typeof input['global-header'] === 'boolean' ? { 'global-header': input['global-header'] } : {}),
3038
3108
  };
3039
3109
  }
@@ -3175,7 +3245,7 @@ function normalizeSlugForCache(slug: string): string {
3175
3245
  slug
3176
3246
  .trim()
3177
3247
  .toLowerCase()
3178
- .replace(/[^a-z0-9/_-]/g, '-')
3248
+ .replace(/[^a-z0-9/_[\]-]/g, '-')
3179
3249
  .replace(/^\/+|\/+$/g, '') || 'home'
3180
3250
  );
3181
3251
  }
@@ -3740,10 +3810,12 @@ function App() {
3740
3810
  basePath: APP_BASE_PATH,
3741
3811
  registry: ComponentRegistry as JsonPagesConfig['registry'],
3742
3812
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
3813
+ collectionSchemas: CollectionRegistry as unknown as JsonPagesConfig['collectionSchemas'],
3743
3814
  pages,
3744
3815
  siteConfig,
3745
3816
  themeConfig,
3746
3817
  menuConfig,
3818
+ collections,
3747
3819
  refDocuments,
3748
3820
  themeCss: { tenant: resolvedTenantCss },
3749
3821
  iconRegistry: iconMap,
@@ -3780,6 +3852,7 @@ function App() {
3780
3852
  slug,
3781
3853
  page: state.page,
3782
3854
  siteConfig: state.site,
3855
+ collections: state.collections,
3783
3856
  }),
3784
3857
  });
3785
3858
  const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
@@ -3793,6 +3866,7 @@ function App() {
3793
3866
  keyFingerprint,
3794
3867
  savedAt: Date.now(),
3795
3868
  siteConfig: state.site ?? null,
3869
+ collections: state.collections,
3796
3870
  pages: {
3797
3871
  ...(existing?.pages ?? {}),
3798
3872
  [normalizedSlug]: state.page,
@@ -4039,6 +4113,39 @@ export default App;
4039
4113
 
4040
4114
  END_OF_FILE_CONTENT
4041
4115
  # SKIP: src/App.tsx:Zone.Identifier is binary and cannot be embedded as text.
4116
+ mkdir -p "src/collections"
4117
+ mkdir -p "src/collections/libri"
4118
+ echo "Creating src/collections/libri/index.ts..."
4119
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/index.ts"
4120
+ export { LibroSchema, LibriCollectionSchema } from './schema';
4121
+ export type { Libro, LibriCollection } from './types';
4122
+
4123
+ END_OF_FILE_CONTENT
4124
+ echo "Creating src/collections/libri/schema.ts..."
4125
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/schema.ts"
4126
+ import { z } from 'zod';
4127
+ import { BaseCollectionItem } from '@olonjs/core';
4128
+
4129
+ export const LibroSchema = BaseCollectionItem.extend({
4130
+ title: z.string().describe('ui:text'),
4131
+ author: z.string().describe('ui:text'),
4132
+ year: z.number().describe('ui:number'),
4133
+ genre: z.string().describe('ui:text'),
4134
+ summary: z.string().describe('ui:textarea'),
4135
+ });
4136
+
4137
+ export const LibriCollectionSchema = z.record(z.string(), LibroSchema);
4138
+
4139
+ END_OF_FILE_CONTENT
4140
+ echo "Creating src/collections/libri/types.ts..."
4141
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/libri/types.ts"
4142
+ import { z } from 'zod';
4143
+ import { LibroSchema, LibriCollectionSchema } from './schema';
4144
+
4145
+ export type Libro = z.infer<typeof LibroSchema>;
4146
+ export type LibriCollection = z.infer<typeof LibriCollectionSchema>;
4147
+
4148
+ END_OF_FILE_CONTENT
4042
4149
  mkdir -p "src/components"
4043
4150
  echo "Creating src/components/ThemeProvider.tsx..."
4044
4151
  cat << 'END_OF_FILE_CONTENT' > "src/components/ThemeProvider.tsx"
@@ -4102,6 +4209,221 @@ export function useTheme() {
4102
4209
  return useContext(ThemeContext)
4103
4210
  }
4104
4211
 
4212
+ END_OF_FILE_CONTENT
4213
+ mkdir -p "src/components/book-detail"
4214
+ echo "Creating src/components/book-detail/View.tsx..."
4215
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/View.tsx"
4216
+ import type { BookDetailData } from './types';
4217
+
4218
+ type BookDetailViewProps = {
4219
+ data: BookDetailData;
4220
+ };
4221
+
4222
+ export function BookDetailView({ data }: BookDetailViewProps) {
4223
+ const book = data.item;
4224
+
4225
+ return (
4226
+ <main className="min-h-screen bg-background text-foreground px-6 py-16">
4227
+ <article
4228
+ data-jp-field="item"
4229
+ data-jp-item-id={book.id}
4230
+ data-jp-item-field="item"
4231
+ className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-card p-8 shadow-sm"
4232
+ >
4233
+ <a
4234
+ href="/libri"
4235
+ className="text-sm font-medium text-muted-foreground hover:text-foreground"
4236
+ >
4237
+ {data.backLabel}
4238
+ </a>
4239
+ <p className="mt-10 text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
4240
+ {book.genre} · {book.year}
4241
+ </p>
4242
+ <h1 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">
4243
+ {book.title}
4244
+ </h1>
4245
+ <p className="mt-4 text-lg text-muted-foreground">
4246
+ {book.author}
4247
+ </p>
4248
+ <p className="mt-8 text-base leading-8 text-muted-foreground">
4249
+ {book.summary}
4250
+ </p>
4251
+ </article>
4252
+ </main>
4253
+ );
4254
+ }
4255
+
4256
+ END_OF_FILE_CONTENT
4257
+ echo "Creating src/components/book-detail/index.ts..."
4258
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/index.ts"
4259
+ export * from './View';
4260
+ export * from './schema';
4261
+ export * from './types';
4262
+
4263
+ END_OF_FILE_CONTENT
4264
+ echo "Creating src/components/book-detail/schema.ts..."
4265
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/schema.ts"
4266
+ import { z } from 'zod';
4267
+ import { BaseSectionData } from '@olonjs/core';
4268
+ import { LibroSchema } from '@/collections/libri';
4269
+
4270
+ export const BookDetailSchema = BaseSectionData.extend({
4271
+ item: LibroSchema.describe('ui:collection-ref'),
4272
+ backLabel: z.string().default('Torna ai libri').describe('ui:text'),
4273
+ });
4274
+
4275
+ export const BookDetailSettingsSchema = z.object({});
4276
+
4277
+ END_OF_FILE_CONTENT
4278
+ echo "Creating src/components/book-detail/types.ts..."
4279
+ cat << 'END_OF_FILE_CONTENT' > "src/components/book-detail/types.ts"
4280
+ import { z } from 'zod';
4281
+ import { BookDetailSchema, BookDetailSettingsSchema } from './schema';
4282
+
4283
+ export type BookDetailData = z.infer<typeof BookDetailSchema>;
4284
+ export type BookDetailSettings = z.infer<typeof BookDetailSettingsSchema>;
4285
+
4286
+ END_OF_FILE_CONTENT
4287
+ mkdir -p "src/components/books-list"
4288
+ echo "Creating src/components/books-list/View.tsx..."
4289
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/View.tsx"
4290
+ import { useMemo, useState } from 'react';
4291
+ import type { Libro } from '@/collections/libri';
4292
+ import type { BooksListData } from './types';
4293
+
4294
+ type BooksListViewProps = {
4295
+ data: BooksListData;
4296
+ };
4297
+
4298
+ function toBooks(items: BooksListData['items']): Libro[] {
4299
+ return Object.values(items ?? {}).sort((a, b) => a.title.localeCompare(b.title));
4300
+ }
4301
+
4302
+ export function BooksListView({ data }: BooksListViewProps) {
4303
+ const books = useMemo(() => toBooks(data.items), [data.items]);
4304
+ const pageSize = Math.max(1, Math.floor(data.pageSize || 10));
4305
+ const totalPages = Math.max(1, Math.ceil(books.length / pageSize));
4306
+ const [page, setPage] = useState(1);
4307
+ const currentPage = Math.min(page, totalPages);
4308
+ const startIndex = (currentPage - 1) * pageSize;
4309
+ const visibleBooks = books.slice(startIndex, startIndex + pageSize);
4310
+
4311
+ return (
4312
+ <main className="min-h-screen bg-background text-foreground px-6 py-16">
4313
+ <section className="mx-auto flex w-full max-w-5xl flex-col gap-10">
4314
+ <div className="max-w-2xl">
4315
+ {data.eyebrow && (
4316
+ <p
4317
+ data-jp-field="eyebrow"
4318
+ className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground"
4319
+ >
4320
+ {data.eyebrow}
4321
+ </p>
4322
+ )}
4323
+ <h1
4324
+ data-jp-field="title"
4325
+ className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl"
4326
+ >
4327
+ {data.title}
4328
+ </h1>
4329
+ {data.description && (
4330
+ <p
4331
+ data-jp-field="description"
4332
+ className="mt-4 text-base leading-7 text-muted-foreground"
4333
+ >
4334
+ {data.description}
4335
+ </p>
4336
+ )}
4337
+ </div>
4338
+
4339
+ <div data-jp-field="items" className="grid gap-4">
4340
+ {visibleBooks.map((book) => (
4341
+ <article
4342
+ key={book.id}
4343
+ data-jp-item-id={book.id}
4344
+ data-jp-item-field="items"
4345
+ className="rounded-xl border border-border bg-card p-5 shadow-sm"
4346
+ >
4347
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
4348
+ <div>
4349
+ <h2 className="text-xl font-semibold">{book.title}</h2>
4350
+ <p className="mt-1 text-sm text-muted-foreground">
4351
+ {book.author} · {book.year} · {book.genre}
4352
+ </p>
4353
+ <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
4354
+ {book.summary}
4355
+ </p>
4356
+ </div>
4357
+ <a
4358
+ href={`/libri/${book.id}`}
4359
+ 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"
4360
+ >
4361
+ Apri scheda
4362
+ </a>
4363
+ </div>
4364
+ </article>
4365
+ ))}
4366
+ </div>
4367
+
4368
+ <nav className="flex items-center justify-between border-t border-border pt-6 text-sm">
4369
+ <button
4370
+ type="button"
4371
+ onClick={() => setPage((value) => Math.max(1, value - 1))}
4372
+ disabled={currentPage === 1}
4373
+ className="rounded-md border border-border px-3 py-2 font-medium disabled:cursor-not-allowed disabled:opacity-40 hover:bg-muted"
4374
+ >
4375
+ Precedente
4376
+ </button>
4377
+ <span className="text-muted-foreground">
4378
+ Pagina {currentPage} di {totalPages} · {books.length} libri
4379
+ </span>
4380
+ <button
4381
+ type="button"
4382
+ onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
4383
+ disabled={currentPage === totalPages}
4384
+ className="rounded-md border border-border px-3 py-2 font-medium disabled:cursor-not-allowed disabled:opacity-40 hover:bg-muted"
4385
+ >
4386
+ Successiva
4387
+ </button>
4388
+ </nav>
4389
+ </section>
4390
+ </main>
4391
+ );
4392
+ }
4393
+
4394
+ END_OF_FILE_CONTENT
4395
+ echo "Creating src/components/books-list/index.ts..."
4396
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/index.ts"
4397
+ export * from './View';
4398
+ export * from './schema';
4399
+ export * from './types';
4400
+
4401
+ END_OF_FILE_CONTENT
4402
+ echo "Creating src/components/books-list/schema.ts..."
4403
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/schema.ts"
4404
+ import { z } from 'zod';
4405
+ import { BaseSectionData } from '@olonjs/core';
4406
+ import { LibroSchema } from '@/collections/libri';
4407
+
4408
+ export const BooksListSchema = BaseSectionData.extend({
4409
+ eyebrow: z.string().optional().describe('ui:text'),
4410
+ title: z.string().describe('ui:text'),
4411
+ description: z.string().optional().describe('ui:textarea'),
4412
+ items: z.record(z.string(), LibroSchema).describe('ui:collection-ref'),
4413
+ pageSize: z.number().default(10).describe('ui:number'),
4414
+ });
4415
+
4416
+ export const BooksListSettingsSchema = z.object({});
4417
+
4418
+ END_OF_FILE_CONTENT
4419
+ echo "Creating src/components/books-list/types.ts..."
4420
+ cat << 'END_OF_FILE_CONTENT' > "src/components/books-list/types.ts"
4421
+ import { z } from 'zod';
4422
+ import { BooksListSchema, BooksListSettingsSchema } from './schema';
4423
+
4424
+ export type BooksListData = z.infer<typeof BooksListSchema>;
4425
+ export type BooksListSettings = z.infer<typeof BooksListSettingsSchema>;
4426
+
4105
4427
  END_OF_FILE_CONTENT
4106
4428
  mkdir -p "src/components/empty-tenant"
4107
4429
  echo "Creating src/components/empty-tenant/View.tsx..."
@@ -4155,6 +4477,123 @@ import { EmptyTenantSchema, EmptyTenantSettingsSchema } from './schema';
4155
4477
  export type EmptyTenantData = z.infer<typeof EmptyTenantSchema>;
4156
4478
  export type EmptyTenantSettings = z.infer<typeof EmptyTenantSettingsSchema>;
4157
4479
 
4480
+ END_OF_FILE_CONTENT
4481
+ mkdir -p "src/components/footer"
4482
+ echo "Creating src/components/footer/View.tsx..."
4483
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/View.tsx"
4484
+ import type React from 'react';
4485
+ import type { FooterData, FooterSettings } from './types';
4486
+
4487
+ type FooterViewProps = {
4488
+ data: FooterData;
4489
+ settings?: FooterSettings;
4490
+ };
4491
+
4492
+ export function FooterView({ data, settings }: FooterViewProps) {
4493
+ const links = data.links ?? [];
4494
+
4495
+ return (
4496
+ <footer
4497
+ style={{
4498
+ '--local-bg': 'var(--background)',
4499
+ '--local-text': 'var(--foreground)',
4500
+ '--local-muted': 'var(--muted-foreground)',
4501
+ '--local-border': 'var(--border)',
4502
+ } as React.CSSProperties}
4503
+ className="border-t border-[var(--local-border)] bg-[var(--local-bg)] px-6 py-10 text-[var(--local-text)]"
4504
+ >
4505
+ <div className="mx-auto flex w-full max-w-5xl flex-col gap-8 md:flex-row md:items-end md:justify-between">
4506
+ <div className="max-w-md">
4507
+ <div className="flex items-center gap-3">
4508
+ {settings?.showLogo !== false && (
4509
+ <span className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--local-border)] text-sm font-semibold">
4510
+ O
4511
+ </span>
4512
+ )}
4513
+ <p data-jp-field="brandText" className="text-lg font-semibold tracking-tight">
4514
+ {data.brandText}
4515
+ </p>
4516
+ </div>
4517
+ {data.description && (
4518
+ <p data-jp-field="description" className="mt-3 text-sm leading-6 text-[var(--local-muted)]">
4519
+ {data.description}
4520
+ </p>
4521
+ )}
4522
+ <p data-jp-field="copyright" className="mt-4 text-xs text-[var(--local-muted)]">
4523
+ {data.copyright}
4524
+ </p>
4525
+ </div>
4526
+
4527
+ <nav data-jp-field="links" className="flex flex-wrap gap-4 text-sm">
4528
+ {links.map((link) => (
4529
+ <a
4530
+ key={link.id}
4531
+ data-jp-item-id={link.id}
4532
+ data-jp-item-field="links"
4533
+ href={link.href}
4534
+ target={link.external ? '_blank' : undefined}
4535
+ rel={link.external ? 'noreferrer' : undefined}
4536
+ className="text-[var(--local-muted)] transition-colors hover:text-[var(--local-text)]"
4537
+ >
4538
+ {link.label}
4539
+ </a>
4540
+ ))}
4541
+ {data.designSystemHref && (
4542
+ <a
4543
+ data-jp-field="designSystemHref"
4544
+ href={data.designSystemHref}
4545
+ className="text-[var(--local-muted)] transition-colors hover:text-[var(--local-text)]"
4546
+ >
4547
+ Design system
4548
+ </a>
4549
+ )}
4550
+ </nav>
4551
+ </div>
4552
+ </footer>
4553
+ );
4554
+ }
4555
+
4556
+ END_OF_FILE_CONTENT
4557
+ echo "Creating src/components/footer/index.ts..."
4558
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/index.ts"
4559
+ export * from './View';
4560
+ export * from './schema';
4561
+ export * from './types';
4562
+
4563
+ END_OF_FILE_CONTENT
4564
+ echo "Creating src/components/footer/schema.ts..."
4565
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/schema.ts"
4566
+ import { z } from 'zod';
4567
+ import { BaseArrayItem, BaseSectionData } from '@olonjs/core';
4568
+
4569
+ export const FooterLinkSchema = BaseArrayItem.extend({
4570
+ id: z.string(),
4571
+ label: z.string().describe('ui:text'),
4572
+ href: z.string().describe('ui:text'),
4573
+ external: z.boolean().optional(),
4574
+ });
4575
+
4576
+ export const FooterSchema = BaseSectionData.extend({
4577
+ brandText: z.string().describe('ui:text'),
4578
+ description: z.string().optional().describe('ui:textarea'),
4579
+ copyright: z.string().describe('ui:text'),
4580
+ links: z.array(FooterLinkSchema).default([]).describe('ui:list'),
4581
+ designSystemHref: z.string().optional().describe('ui:text'),
4582
+ });
4583
+
4584
+ export const FooterSettingsSchema = z.object({
4585
+ showLogo: z.boolean().default(true),
4586
+ });
4587
+
4588
+ END_OF_FILE_CONTENT
4589
+ echo "Creating src/components/footer/types.ts..."
4590
+ cat << 'END_OF_FILE_CONTENT' > "src/components/footer/types.ts"
4591
+ import { z } from 'zod';
4592
+ import { FooterSchema, FooterSettingsSchema } from './schema';
4593
+
4594
+ export type FooterData = z.infer<typeof FooterSchema>;
4595
+ export type FooterSettings = z.infer<typeof FooterSettingsSchema>;
4596
+
4158
4597
  END_OF_FILE_CONTENT
4159
4598
  mkdir -p "src/components/form-demo"
4160
4599
  echo "Creating src/components/form-demo/Untitled..."
@@ -8567,6 +9006,253 @@ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
8567
9006
  END_OF_FILE_CONTENT
8568
9007
  # SKIP: src/components/ui/tooltip.tsx:Zone.Identifier is binary and cannot be embedded as text.
8569
9008
  mkdir -p "src/data"
9009
+ mkdir -p "src/data/collections"
9010
+ mkdir -p "src/data/collections/libri"
9011
+ echo "Creating src/data/collections/libri/libri.json..."
9012
+ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
9013
+ {
9014
+ "1984": {
9015
+ "id": "1984",
9016
+ "title": "1984",
9017
+ "author": "Guido Seiero",
9018
+ "year": 1949,
9019
+ "genre": "Distopia",
9020
+ "summary": "Un regime totalitario controlla linguaggio, memoria e pensiero."
9021
+ },
9022
+ "il-nome-della-rosa": {
9023
+ "id": "il-nome-della-rosa",
9024
+ "title": "Il nome della rosa",
9025
+ "author": "Umberto Eco",
9026
+ "year": 1980,
9027
+ "genre": "Romanzo storico",
9028
+ "summary": "Un'indagine in un'abbazia medievale diventa una riflessione su conoscenza, potere e interpretazione."
9029
+ },
9030
+ "se-questo-e-un-uomo": {
9031
+ "id": "se-questo-e-un-uomo",
9032
+ "title": "Se questo e un uomo",
9033
+ "author": "Primo Levi",
9034
+ "year": 1947,
9035
+ "genre": "Memoria",
9036
+ "summary": "La testimonianza essenziale di Levi sull'esperienza del lager e sulla dignita umana."
9037
+ },
9038
+ "le-citta-invisibili": {
9039
+ "id": "le-citta-invisibili",
9040
+ "title": "Le citta invisibili",
9041
+ "author": "Italo Calvino",
9042
+ "year": 1972,
9043
+ "genre": "Letteratura fantastica",
9044
+ "summary": "Marco Polo racconta a Kublai Khan citta immaginarie che parlano di memoria, desiderio e linguaggio."
9045
+ },
9046
+ "il-gattopardo": {
9047
+ "id": "il-gattopardo",
9048
+ "title": "Il Gattopardo",
9049
+ "author": "Giuseppe Tomasi di Lampedusa",
9050
+ "year": 1958,
9051
+ "genre": "Romanzo storico",
9052
+ "summary": "Il tramonto dell'aristocrazia siciliana durante l'unificazione italiana."
9053
+ },
9054
+ "la-coscienza-di-zeno": {
9055
+ "id": "la-coscienza-di-zeno",
9056
+ "title": "La coscienza di Zeno",
9057
+ "author": "Italo Svevo",
9058
+ "year": 1923,
9059
+ "genre": "Romanzo psicologico",
9060
+ "summary": "Un diario ironico e nevrotico attraversa memoria, terapia e autoinganno."
9061
+ },
9062
+ "i-promessi-sposi": {
9063
+ "id": "i-promessi-sposi",
9064
+ "title": "I promessi sposi",
9065
+ "author": "Alessandro Manzoni",
9066
+ "year": 1842,
9067
+ "genre": "Classico",
9068
+ "summary": "La vicenda di Renzo e Lucia dentro carestia, guerra, peste e provvidenza."
9069
+ },
9070
+ "il-barone-rampante": {
9071
+ "id": "il-barone-rampante",
9072
+ "title": "Il barone rampante",
9073
+ "author": "Italo Calvino",
9074
+ "year": 1957,
9075
+ "genre": "Romanzo filosofico",
9076
+ "summary": "Cosimo sceglie di vivere sugli alberi e trasforma la distanza in una forma di liberta."
9077
+ },
9078
+ "gomorra": {
9079
+ "id": "gomorra",
9080
+ "title": "Gomorra",
9081
+ "author": "Roberto Saviano",
9082
+ "year": 2006,
9083
+ "genre": "Inchiesta",
9084
+ "summary": "Un reportage narrativo sulle economie e le violenze del sistema camorristico."
9085
+ },
9086
+ "oceano-mare": {
9087
+ "id": "oceano-mare",
9088
+ "title": "Oceano mare",
9089
+ "author": "Alessandro Baricco",
9090
+ "year": 1993,
9091
+ "genre": "Romanzo letterario",
9092
+ "summary": "Storie diverse si incontrano in una locanda sul mare, tra cura, naufragio e mistero."
9093
+ },
9094
+ "lessico-famigliare": {
9095
+ "id": "lessico-famigliare",
9096
+ "title": "Lessico famigliare",
9097
+ "author": "Natalia Ginzburg",
9098
+ "year": 1963,
9099
+ "genre": "Memoria narrativa",
9100
+ "summary": "Una famiglia prende forma attraverso parole, tic linguistici e memoria civile."
9101
+ },
9102
+ "cent-anni-di-solitudine": {
9103
+ "id": "cent-anni-di-solitudine",
9104
+ "title": "Cent'anni di solitudine",
9105
+ "author": "Gabriel Garcia Marquez",
9106
+ "year": 1967,
9107
+ "genre": "Realismo magico",
9108
+ "summary": "La saga dei Buendia e di Macondo intreccia mito, storia e destino."
9109
+ },
9110
+ "fahrenheit-451": {
9111
+ "id": "fahrenheit-451",
9112
+ "title": "Fahrenheit 451",
9113
+ "author": "Ray Bradbury",
9114
+ "year": 1953,
9115
+ "genre": "Distopia",
9116
+ "summary": "In un futuro dove i libri bruciano, leggere diventa un atto di resistenza."
9117
+ },
9118
+ "dune": {
9119
+ "id": "dune",
9120
+ "title": "Dune",
9121
+ "author": "Frank Herbert",
9122
+ "year": 1965,
9123
+ "genre": "Fantascienza",
9124
+ "summary": "Politica, ecologia e messianismo si scontrano sul pianeta desertico Arrakis."
9125
+ },
9126
+ "neuromancer": {
9127
+ "id": "neuromancer",
9128
+ "title": "Neuromancer",
9129
+ "author": "William Gibson",
9130
+ "year": 1984,
9131
+ "genre": "Cyberpunk",
9132
+ "summary": "Un hacker decaduto viene trascinato in un colpo che attraversa cyberspazio e intelligenze artificiali."
9133
+ },
9134
+ "il-signore-degli-anelli": {
9135
+ "id": "il-signore-degli-anelli",
9136
+ "title": "Il Signore degli Anelli",
9137
+ "author": "J. R. R. Tolkien",
9138
+ "year": 1954,
9139
+ "genre": "Fantasy",
9140
+ "summary": "La Compagnia affronta il potere dell'Anello in una delle grandi epopee moderne."
9141
+ },
9142
+ "harry-potter-e-la-pietra-filosofale": {
9143
+ "id": "harry-potter-e-la-pietra-filosofale",
9144
+ "title": "Harry Potter e la pietra filosofale",
9145
+ "author": "J. K. Rowling",
9146
+ "year": 1997,
9147
+ "genre": "Fantasy",
9148
+ "summary": "Un ragazzo scopre il mondo magico e il proprio posto in una storia piu grande."
9149
+ },
9150
+ "orgoglio-e-pregiudizio": {
9151
+ "id": "orgoglio-e-pregiudizio",
9152
+ "title": "Orgoglio e pregiudizio",
9153
+ "author": "Jane Austen",
9154
+ "year": 1813,
9155
+ "genre": "Classico",
9156
+ "summary": "Elizabeth Bennet e Mr. Darcy si misurano con classe, carattere e giudizio sociale."
9157
+ },
9158
+ "moby-dick": {
9159
+ "id": "moby-dick",
9160
+ "title": "Moby Dick",
9161
+ "author": "Herman Melville",
9162
+ "year": 1851,
9163
+ "genre": "Avventura",
9164
+ "summary": "La caccia alla balena bianca diventa ossessione metafisica e viaggio nell'abisso."
9165
+ },
9166
+ "delitto-e-castigo": {
9167
+ "id": "delitto-e-castigo",
9168
+ "title": "Delitto e castigo",
9169
+ "author": "Fedor Dostoevskij",
9170
+ "year": 1866,
9171
+ "genre": "Romanzo psicologico",
9172
+ "summary": "Raskolnikov attraversa colpa, febbre morale e possibilita di redenzione."
9173
+ },
9174
+ "anna-karenina": {
9175
+ "id": "anna-karenina",
9176
+ "title": "Anna Karenina",
9177
+ "author": "Lev Tolstoj",
9178
+ "year": 1877,
9179
+ "genre": "Classico",
9180
+ "summary": "Una storia d'amore e rovina dentro la societa russa dell'Ottocento."
9181
+ },
9182
+ "il-maestro-e-margherita": {
9183
+ "id": "il-maestro-e-margherita",
9184
+ "title": "Il maestro e Margherita",
9185
+ "author": "Michail Bulgakov",
9186
+ "year": 1967,
9187
+ "genre": "Satira fantastica",
9188
+ "summary": "Il diavolo visita Mosca in un romanzo visionario su arte, censura e amore."
9189
+ },
9190
+ "la-strada": {
9191
+ "id": "la-strada",
9192
+ "title": "La strada",
9193
+ "author": "Cormac McCarthy",
9194
+ "year": 2006,
9195
+ "genre": "Post-apocalittico",
9196
+ "summary": "Padre e figlio attraversano un mondo bruciato portando con se una fragile idea di bene."
9197
+ },
9198
+ "cloud-atlas": {
9199
+ "id": "cloud-atlas",
9200
+ "title": "Cloud Atlas",
9201
+ "author": "David Mitchell",
9202
+ "year": 2004,
9203
+ "genre": "Romanzo corale",
9204
+ "summary": "Sei storie in epoche diverse compongono una meditazione su potere, memoria e reincorrenza."
9205
+ },
9206
+ "kafka-sulla-spiaggia": {
9207
+ "id": "kafka-sulla-spiaggia",
9208
+ "title": "Kafka sulla spiaggia",
9209
+ "author": "Haruki Murakami",
9210
+ "year": 2002,
9211
+ "genre": "Surrealismo",
9212
+ "summary": "Fuga, destino e sogno si intrecciano in un romanzo sospeso tra reale e mitico."
9213
+ },
9214
+ "americanah": {
9215
+ "id": "americanah",
9216
+ "title": "Americanah",
9217
+ "author": "Chimamanda Ngozi Adichie",
9218
+ "year": 2013,
9219
+ "genre": "Romanzo contemporaneo",
9220
+ "summary": "Migrazione, razza e identita raccontate attraverso una storia d'amore tra Nigeria e Stati Uniti."
9221
+ },
9222
+ "la-casa-degli-spiriti": {
9223
+ "id": "la-casa-degli-spiriti",
9224
+ "title": "La casa degli spiriti",
9225
+ "author": "Isabel Allende",
9226
+ "year": 1982,
9227
+ "genre": "Saga familiare",
9228
+ "summary": "La storia della famiglia Trueba intreccia politica, memoria e realismo magico."
9229
+ },
9230
+ "beloved": {
9231
+ "id": "beloved",
9232
+ "title": "Beloved",
9233
+ "author": "Toni Morrison",
9234
+ "year": 1987,
9235
+ "genre": "Romanzo storico",
9236
+ "summary": "Il trauma della schiavitu ritorna come presenza viva nella casa di Sethe."
9237
+ },
9238
+ "la-breve-favolosa-vita-di-oscar-wao": {
9239
+ "id": "la-breve-favolosa-vita-di-oscar-wao",
9240
+ "title": "La breve favolosa vita di Oscar Wao",
9241
+ "author": "Junot Diaz",
9242
+ "year": 2007,
9243
+ "genre": "Romanzo contemporaneo",
9244
+ "summary": "Famiglia, diaspora dominicana e cultura pop si intrecciano nella storia di Oscar."
9245
+ },
9246
+ "le-correzioni": {
9247
+ "id": "le-correzioni",
9248
+ "title": "Le correzioni",
9249
+ "author": "Jonathan Franzen",
9250
+ "year": 2001,
9251
+ "genre": "Romanzo familiare",
9252
+ "summary": "Una famiglia americana tenta di ritrovarsi mentre ciascuno affronta le proprie fratture."
9253
+ }
9254
+ }
9255
+ END_OF_FILE_CONTENT
8570
9256
  mkdir -p "src/data/config"
8571
9257
  echo "Creating src/data/config/menu.json..."
8572
9258
  cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
@@ -8592,6 +9278,24 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
8592
9278
  "label": "GitHub",
8593
9279
  "href": "https://github.com/olonjs/core"
8594
9280
  }
9281
+ ],
9282
+ "footer": [
9283
+ {
9284
+ "id": "footer-home",
9285
+ "label": "Home",
9286
+ "href": "/"
9287
+ },
9288
+ {
9289
+ "id": "footer-libri",
9290
+ "label": "Libri",
9291
+ "href": "/libri"
9292
+ },
9293
+ {
9294
+ "id": "footer-github",
9295
+ "label": "GitHub",
9296
+ "href": "https://github.com/olonjs/core",
9297
+ "external": true
9298
+ }
8595
9299
  ]
8596
9300
  }
8597
9301
  END_OF_FILE_CONTENT
@@ -8647,7 +9351,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
8647
9351
  "title": "OlonJS",
8648
9352
  "logoUrl": "/brand/mark/olon-mark-dark.svg"
8649
9353
  },
8650
-
8651
9354
  "header": {
8652
9355
  "id": "global-header",
8653
9356
  "type": "header",
@@ -8686,13 +9389,11 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
8686
9389
  "type": "footer",
8687
9390
  "data": {
8688
9391
  "brandText": "OlonJS",
9392
+ "description": "AI-native content infrastructure for deterministic, sovereign, git-backed sites.",
8689
9393
  "copyright": "© 2026 OlonJS · v1.5 · Guido Serio",
8690
- "links": [
8691
- {
8692
- "label": "GitHub",
8693
- "href": "https://github.com/olonjs/core"
8694
- }
8695
- ],
9394
+ "links": {
9395
+ "$ref": "../config/menu.json#/footer"
9396
+ },
8696
9397
  "designSystemHref": ""
8697
9398
  },
8698
9399
  "settings": {
@@ -8896,6 +9597,65 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
8896
9597
  "global-header": false
8897
9598
  }
8898
9599
  END_OF_FILE_CONTENT
9600
+ mkdir -p "src/data/pages/libri"
9601
+ echo "Creating src/data/pages/libri.json..."
9602
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri.json"
9603
+ {
9604
+ "id": "libri-page",
9605
+ "slug": "libri",
9606
+ "meta": {
9607
+ "title": "Libri",
9608
+ "description": "Catalogo libri dimostrativo alimentato da COP collections."
9609
+ },
9610
+ "sections": [
9611
+ {
9612
+ "id": "books-list-1",
9613
+ "type": "books-list",
9614
+ "data": {
9615
+ "anchorId": "catalogo-libri",
9616
+ "eyebrow": "Collection demo",
9617
+ "title": "Libri",
9618
+ "description": "Una pagina collection con 30 titoli, paginazione lato componente e link alle schede dinamiche.",
9619
+ "items": {
9620
+ "$ref": "../collections/libri/libri.json"
9621
+ },
9622
+ "pageSize": 10
9623
+ }
9624
+ }
9625
+ ],
9626
+ "global-header": false
9627
+ }
9628
+
9629
+ END_OF_FILE_CONTENT
9630
+ echo "Creating src/data/pages/libri/[slug].json..."
9631
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri/[slug].json"
9632
+ {
9633
+ "id": "libro-detail-page",
9634
+ "slug": "libri/[slug]",
9635
+ "meta": {
9636
+ "title": "Dettaglio libro",
9637
+ "description": "Pagina dinamica dettaglio libro alimentata dalla collection libri."
9638
+ },
9639
+ "sections": [
9640
+ {
9641
+ "id": "book-detail-1",
9642
+ "type": "book-detail",
9643
+ "data": {
9644
+ "anchorId": "dettaglio-libro",
9645
+ "item": {
9646
+ "$ref": "collection:current"
9647
+ },
9648
+ "backLabel": "← Torna ai libri"
9649
+ }
9650
+ }
9651
+ ],
9652
+ "collection": {
9653
+ "source": "libri",
9654
+ "paramKey": "slug"
9655
+ },
9656
+ "global-header": false
9657
+ }
9658
+ END_OF_FILE_CONTENT
8899
9659
  mkdir -p "src/emails"
8900
9660
  echo "Creating src/emails/LeadNotificationEmail.tsx..."
8901
9661
  cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadNotificationEmail.tsx"
@@ -9213,12 +9973,19 @@ echo "Creating src/entry-ssg.tsx..."
9213
9973
  cat << 'END_OF_FILE_CONTENT' > "src/entry-ssg.tsx"
9214
9974
  import { renderToString } from 'react-dom/server';
9215
9975
  import { StaticRouter } from 'react-router-dom/server';
9216
- import { ConfigProvider, PageRenderer, StudioProvider, resolveRuntimeConfig } from '@olonjs/core';
9976
+ import {
9977
+ ConfigProvider,
9978
+ PageRenderer,
9979
+ StudioProvider,
9980
+ contract,
9981
+ resolvePageMatchFromRegistry,
9982
+ resolveRuntimeConfig,
9983
+ } from '@olonjs/core';
9217
9984
  import type { JsonPagesConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
9218
9985
  import { ThemeProvider } from '@/components/ThemeProvider';
9219
9986
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
9220
9987
  import { SECTION_SCHEMAS } from '@/lib/schemas';
9221
- import { menuConfig, pages, refDocuments, siteConfig, themeConfig } from '@/runtime';
9988
+ import { collectionSchemas, collections, menuConfig, pages, refDocuments, siteConfig, themeConfig } from '@/runtime';
9222
9989
  import tenantCss from '@/index.css?inline';
9223
9990
 
9224
9991
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -9233,10 +10000,16 @@ function getSortedSlugs(): string[] {
9233
10000
  return Object.keys(pages).sort((a, b) => a.localeCompare(b));
9234
10001
  }
9235
10002
 
9236
- function resolvePage(slug: string): { slug: string; page: PageConfig } {
10003
+ function resolvePage(slug: string): { slug: string; registrySlug: string; page: PageConfig; params: Record<string, string> } {
9237
10004
  const normalized = normalizeSlug(slug);
9238
- if (normalized && pages[normalized]) {
9239
- return { slug: normalized, page: pages[normalized] };
10005
+ const pageMatch = resolvePageMatchFromRegistry(pages, normalized);
10006
+ if (pageMatch) {
10007
+ return {
10008
+ slug: normalized || pageMatch.registrySlug,
10009
+ registrySlug: pageMatch.registrySlug,
10010
+ page: pageMatch.page,
10011
+ params: pageMatch.params,
10012
+ };
9240
10013
  }
9241
10014
 
9242
10015
  const slugs = getSortedSlugs();
@@ -9246,7 +10019,7 @@ function resolvePage(slug: string): { slug: string; page: PageConfig } {
9246
10019
 
9247
10020
  const home = slugs.find((item) => item === 'home');
9248
10021
  const fallbackSlug = home ?? slugs[0];
9249
- return { slug: fallbackSlug, page: pages[fallbackSlug] };
10022
+ return { slug: fallbackSlug, registrySlug: fallbackSlug, page: pages[fallbackSlug], params: {} };
9250
10023
  }
9251
10024
 
9252
10025
  function flattenThemeTokens(
@@ -9334,14 +10107,18 @@ function resolveTenantId(): string {
9334
10107
  export function render(slug: string): string {
9335
10108
  const resolved = resolvePage(slug);
9336
10109
  const location = resolved.slug === 'home' ? '/' : `/${resolved.slug}`;
10110
+ const collectionContext = contract.resolveCollectionContext(resolved.page, resolved.params, collections);
9337
10111
  const resolvedRuntime = resolveRuntimeConfig({
9338
- pages,
10112
+ pages: { [resolved.registrySlug]: resolved.page },
9339
10113
  siteConfig,
9340
10114
  themeConfig,
9341
10115
  menuConfig,
10116
+ collections,
10117
+ collectionSchemas,
10118
+ collectionContext,
9342
10119
  refDocuments,
9343
10120
  });
9344
- const resolvedPage = resolvedRuntime.pages[resolved.slug] ?? resolved.page;
10121
+ const resolvedPage = resolvedRuntime.pages[resolved.registrySlug] ?? resolved.page;
9345
10122
 
9346
10123
  return renderToString(
9347
10124
  <StaticRouter location={location}>
@@ -9391,12 +10168,22 @@ export function getPageMeta(slug: string): { title: string; description: string
9391
10168
  export function getWebMcpBuildState(): {
9392
10169
  pages: Record<string, PageConfig>;
9393
10170
  schemas: JsonPagesConfig['schemas'];
10171
+ collections: JsonPagesConfig['collections'];
10172
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
9394
10173
  siteConfig: SiteConfig;
10174
+ themeConfig: ThemeConfig;
10175
+ menuConfig: JsonPagesConfig['menuConfig'];
10176
+ refDocuments: JsonPagesConfig['refDocuments'];
9395
10177
  } {
9396
10178
  return {
9397
10179
  pages,
9398
10180
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
10181
+ collections,
10182
+ collectionSchemas,
9399
10183
  siteConfig,
10184
+ themeConfig,
10185
+ menuConfig,
10186
+ refDocuments,
9400
10187
  };
9401
10188
  }
9402
10189
 
@@ -10344,17 +11131,34 @@ cat << 'END_OF_FILE_CONTENT' > "src/index.css"
10344
11131
 
10345
11132
  END_OF_FILE_CONTENT
10346
11133
  mkdir -p "src/lib"
11134
+ echo "Creating src/lib/CollectionRegistry.ts..."
11135
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/CollectionRegistry.ts"
11136
+ import { LibriCollectionSchema } from '@/collections/libri';
11137
+
11138
+ export const CollectionRegistry = {
11139
+ libri: LibriCollectionSchema,
11140
+ } as const;
11141
+
11142
+ export type CollectionType = keyof typeof CollectionRegistry;
11143
+
11144
+ END_OF_FILE_CONTENT
10347
11145
  echo "Creating src/lib/ComponentRegistry.tsx..."
10348
11146
  cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
10349
11147
  import type { SectionType } from '@/types';
10350
11148
  import type { SectionComponentPropsMap } from '@/types';
11149
+ import { BookDetailView } from '@/components/book-detail';
11150
+ import { BooksListView } from '@/components/books-list';
10351
11151
  import { EmptyTenantView } from '@/components/empty-tenant';
11152
+ import { FooterView } from '@/components/footer';
10352
11153
  import { FormDemoView } from '@/components/form-demo';
10353
11154
 
10354
11155
  export const ComponentRegistry: {
10355
11156
  [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
10356
11157
  } = {
11158
+ 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
11159
+ 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
10357
11160
  'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
11161
+ footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
10358
11162
  'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
10359
11163
  };
10360
11164
 
@@ -10423,20 +11227,44 @@ echo "Creating src/lib/addSectionConfig.ts..."
10423
11227
  cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
10424
11228
  import type { AddSectionConfig } from '@olonjs/core';
10425
11229
 
10426
- const addableSectionTypes = ['empty-tenant', 'form-demo'] as const;
11230
+ const addableSectionTypes = ['book-detail', 'books-list', 'empty-tenant', 'footer', 'form-demo'] as const;
10427
11231
 
10428
11232
  const sectionTypeLabels: Record<string, string> = {
11233
+ 'book-detail': 'Book Detail',
11234
+ 'books-list': 'Books List',
10429
11235
  'empty-tenant': 'Empty Tenant',
11236
+ footer: 'Footer',
10430
11237
  'form-demo': 'Form Demo',
10431
11238
  };
10432
11239
 
10433
11240
  function getDefaultSectionData(type: string): Record<string, unknown> {
10434
11241
  switch (type) {
11242
+ case 'book-detail':
11243
+ return {
11244
+ item: { $ref: 'collection:current' },
11245
+ backLabel: 'Torna ai libri',
11246
+ };
11247
+ case 'books-list':
11248
+ return {
11249
+ eyebrow: 'Collection demo',
11250
+ title: 'Libri',
11251
+ description: 'Catalogo dimostrativo alimentato dalla collection libri.',
11252
+ items: { $ref: '../collections/libri/libri.json' },
11253
+ pageSize: 10,
11254
+ };
10435
11255
  case 'empty-tenant':
10436
11256
  return {
10437
11257
  title: 'Your tenant is empty.',
10438
11258
  description: 'Create your first page to start building your site.',
10439
11259
  };
11260
+ case 'footer':
11261
+ return {
11262
+ brandText: 'OlonJS',
11263
+ description: 'AI-native content infrastructure for deterministic, git-backed sites.',
11264
+ copyright: '© 2026 OlonJS',
11265
+ links: [],
11266
+ designSystemHref: '',
11267
+ };
10440
11268
  case 'form-demo':
10441
11269
  return {
10442
11270
  title: 'Contattaci',
@@ -10535,11 +11363,17 @@ export function getFilePages(): Record<string, PageConfig> {
10535
11363
  END_OF_FILE_CONTENT
10536
11364
  echo "Creating src/lib/schemas.ts..."
10537
11365
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
11366
+ import { BookDetailSchema } from '@/components/book-detail';
11367
+ import { BooksListSchema } from '@/components/books-list';
10538
11368
  import { EmptyTenantSchema } from '@/components/empty-tenant';
11369
+ import { FooterSchema } from '@/components/footer';
10539
11370
  import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
10540
11371
 
10541
11372
  export const SECTION_SCHEMAS = {
11373
+ 'book-detail': BookDetailSchema,
11374
+ 'books-list': BooksListSchema,
10542
11375
  'empty-tenant': EmptyTenantSchema,
11376
+ footer: FooterSchema,
10543
11377
  'form-demo': FormDemoSchema,
10544
11378
  } as const;
10545
11379
 
@@ -10559,6 +11393,7 @@ export type SectionType = keyof typeof SECTION_SCHEMAS;
10559
11393
  export {
10560
11394
  BaseSectionData,
10561
11395
  BaseArrayItem,
11396
+ BaseCollectionItem,
10562
11397
  BaseSectionSettingsSchema,
10563
11398
  CtaSchema,
10564
11399
  ImageSelectionSchema,
@@ -10817,16 +11652,22 @@ END_OF_FILE_CONTENT
10817
11652
  echo "Creating src/runtime.ts..."
10818
11653
  cat << 'END_OF_FILE_CONTENT' > "src/runtime.ts"
10819
11654
  import type { JsonPagesConfig, MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
11655
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
10820
11656
  import { SECTION_SCHEMAS } from '@/lib/schemas';
10821
11657
  import { getFilePages } from '@/lib/getFilePages';
10822
11658
  import siteData from '@/data/config/site.json';
10823
11659
  import menuData from '@/data/config/menu.json';
10824
11660
  import themeData from '@/data/config/theme.json';
11661
+ import libriData from '@/data/collections/libri/libri.json';
10825
11662
 
10826
11663
  export const siteConfig = siteData as unknown as SiteConfig;
10827
11664
  export const themeConfig = themeData as unknown as ThemeConfig;
10828
11665
  export const menuConfig = menuData as unknown as MenuConfig;
10829
11666
  export const pages = getFilePages();
11667
+ export const collections = {
11668
+ libri: libriData as unknown as Record<string, unknown>,
11669
+ } satisfies NonNullable<JsonPagesConfig['collections']>;
11670
+ export const collectionSchemas = CollectionRegistry as unknown as JsonPagesConfig['collectionSchemas'];
10830
11671
  export const refDocuments = {
10831
11672
  'menu.json': menuConfig,
10832
11673
  'config/menu.json': menuConfig,
@@ -10836,12 +11677,22 @@ export const refDocuments = {
10836
11677
  export function getWebMcpBuildState(): {
10837
11678
  pages: Record<string, PageConfig>;
10838
11679
  schemas: JsonPagesConfig['schemas'];
11680
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
11681
+ collections: JsonPagesConfig['collections'];
10839
11682
  siteConfig: SiteConfig;
11683
+ themeConfig: ThemeConfig;
11684
+ menuConfig: MenuConfig;
11685
+ refDocuments: JsonPagesConfig['refDocuments'];
10840
11686
  } {
10841
11687
  return {
10842
11688
  pages,
10843
11689
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
11690
+ collectionSchemas,
11691
+ collections,
10844
11692
  siteConfig,
11693
+ themeConfig,
11694
+ menuConfig,
11695
+ refDocuments,
10845
11696
  };
10846
11697
  }
10847
11698
 
@@ -10849,23 +11700,39 @@ END_OF_FILE_CONTENT
10849
11700
  # SKIP: src/runtime.ts:Zone.Identifier is binary and cannot be embedded as text.
10850
11701
  echo "Creating src/types.ts..."
10851
11702
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
11703
+ import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
11704
+ import type { BooksListData, BooksListSettings } from '@/components/books-list';
10852
11705
  import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
11706
+ import type { FooterData, FooterSettings } from '@/components/footer';
10853
11707
  import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
11708
+ import type { Libro } from '@/collections/libri';
10854
11709
 
10855
11710
  export type SectionComponentPropsMap = {
11711
+ 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
11712
+ 'books-list': { data: BooksListData; settings?: BooksListSettings };
10856
11713
  'empty-tenant': { data: EmptyTenantData; settings?: EmptyTenantSettings };
11714
+ footer: { data: FooterData; settings?: FooterSettings };
10857
11715
  'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
10858
11716
  };
10859
11717
 
10860
11718
  declare module '@olonjs/core' {
10861
11719
  export interface SectionDataRegistry {
11720
+ 'book-detail': BookDetailData;
11721
+ 'books-list': BooksListData;
10862
11722
  'empty-tenant': EmptyTenantData;
11723
+ footer: FooterData;
10863
11724
  'form-demo': FormDemoData;
10864
11725
  }
10865
11726
  export interface SectionSettingsRegistry {
11727
+ 'book-detail': BookDetailSettings;
11728
+ 'books-list': BooksListSettings;
10866
11729
  'empty-tenant': EmptyTenantSettings;
11730
+ footer: FooterSettings;
10867
11731
  'form-demo': FormDemoSettings;
10868
11732
  }
11733
+ export interface CollectionItemRegistry {
11734
+ libri: Libro;
11735
+ }
10869
11736
  }
10870
11737
 
10871
11738
  export * from '@olonjs/core';
@@ -11027,6 +11894,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11027
11894
  const ASSETS_IMAGES_DIR = path.resolve(__dirname, 'public', 'assets', 'images');
11028
11895
  const DATA_CONFIG_DIR = path.resolve(__dirname, 'src', 'data', 'config');
11029
11896
  const DATA_PAGES_DIR = path.resolve(__dirname, 'src', 'data', 'pages');
11897
+ const DATA_COLLECTIONS_DIR = path.resolve(__dirname, 'src', 'data', 'collections');
11030
11898
  const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif']);
11031
11899
  const IMAGE_MIMES = new Set([
11032
11900
  'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif',
@@ -11063,6 +11931,20 @@ function sendJsonFile(res, filePath) {
11063
11931
  }
11064
11932
  }
11065
11933
 
11934
+ function safeDataSlugPath(rootDir, rawSlug, fallback) {
11935
+ const slug = String(rawSlug || fallback)
11936
+ .replace(/\\/g, '/')
11937
+ .replace(/^\/+|\/+$/g, '');
11938
+ const segments = slug
11939
+ .split('/')
11940
+ .map((segment) => segment.replace(/[^a-zA-Z0-9_[\]-]/g, '_'))
11941
+ .filter(Boolean);
11942
+ const candidate = path.resolve(rootDir, `${segments.join(path.sep) || fallback}.json`);
11943
+ const isInsideRoot = candidate.startsWith(`${rootDir}${path.sep}`) || candidate === rootDir;
11944
+ if (!isInsideRoot) throw new Error('Invalid data path');
11945
+ return candidate;
11946
+ }
11947
+
11066
11948
  function isTenantPageJsonRequest(req, pathname) {
11067
11949
  if (req.method !== 'GET' || !pathname.endsWith('.json')) return false;
11068
11950
  const viteOrStaticPrefixes = ['/api/', '/assets/', '/src/', '/node_modules/', '/public/', '/@'];
@@ -11120,15 +12002,23 @@ export default defineConfig({
11120
12002
  const pageManifestMatch = pathname.match(/^\/mcp-manifests\/(.+)\.json$/i);
11121
12003
  if (pageManifestMatch && req.method === 'GET') {
11122
12004
  const slug = normalizeManifestSlug(pageManifestMatch[1]);
11123
- const pageConfig = buildState.pages[slug];
11124
- if (!pageConfig) {
12005
+ const resolved = core.resolvePublicPageDocument({
12006
+ slug,
12007
+ pages: buildState.pages,
12008
+ siteConfig: buildState.siteConfig,
12009
+ themeConfig: buildState.themeConfig,
12010
+ menuConfig: buildState.menuConfig,
12011
+ collections: buildState.collections,
12012
+ refDocuments: buildState.refDocuments,
12013
+ });
12014
+ if (!resolved) {
11125
12015
  sendJson(res, 404, { error: 'Page manifest not found' });
11126
12016
  return true;
11127
12017
  }
11128
12018
 
11129
12019
  sendJson(res, 200, buildPageManifest({
11130
12020
  slug,
11131
- pageConfig,
12021
+ pageConfig: resolved.page,
11132
12022
  schemas: buildState.schemas,
11133
12023
  submissionSchemas: buildState.submissionSchemas,
11134
12024
  siteConfig: buildState.siteConfig,
@@ -11139,15 +12029,23 @@ export default defineConfig({
11139
12029
  const schemaMatch = pathname.match(/^\/schemas\/(.+)\.schema\.json$/i);
11140
12030
  if (schemaMatch && req.method === 'GET') {
11141
12031
  const slug = normalizeManifestSlug(schemaMatch[1]);
11142
- const pageConfig = buildState.pages[slug];
11143
- if (!pageConfig) {
12032
+ const resolved = core.resolvePublicPageDocument({
12033
+ slug,
12034
+ pages: buildState.pages,
12035
+ siteConfig: buildState.siteConfig,
12036
+ themeConfig: buildState.themeConfig,
12037
+ menuConfig: buildState.menuConfig,
12038
+ collections: buildState.collections,
12039
+ refDocuments: buildState.refDocuments,
12040
+ });
12041
+ if (!resolved) {
11144
12042
  sendJson(res, 404, { error: 'Schema contract not found' });
11145
12043
  return true;
11146
12044
  }
11147
12045
 
11148
12046
  sendJson(res, 200, buildPageContract({
11149
12047
  slug,
11150
- pageConfig,
12048
+ pageConfig: resolved.page,
11151
12049
  schemas: buildState.schemas,
11152
12050
  submissionSchemas: buildState.submissionSchemas,
11153
12051
  siteConfig: buildState.siteConfig,
@@ -11188,13 +12086,31 @@ export default defineConfig({
11188
12086
  .replace(/^pages\//i, '')
11189
12087
  .replace(/\.json$/i, '')
11190
12088
  .replace(/^\/+|\/+$/g, '');
11191
- const candidate = path.resolve(DATA_PAGES_DIR, `${slug}.json`);
11192
- const isInsidePagesDir = candidate.startsWith(`${DATA_PAGES_DIR}${path.sep}`) || candidate === DATA_PAGES_DIR;
11193
- if (!slug || !isInsidePagesDir || !fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
12089
+ if (!slug) {
11194
12090
  sendJson(res, 404, { error: 'Page JSON not found' });
11195
12091
  return;
11196
12092
  }
11197
- sendJsonFile(res, candidate);
12093
+ void (async () => {
12094
+ const core = await loadWebMcpBuilders();
12095
+ const runtime = await server.ssrLoadModule('/src/runtime.ts');
12096
+ const buildState = runtime.getWebMcpBuildState();
12097
+ const resolved = core.resolvePublicPageDocument({
12098
+ slug,
12099
+ pages: buildState.pages,
12100
+ siteConfig: buildState.siteConfig,
12101
+ themeConfig: buildState.themeConfig,
12102
+ menuConfig: buildState.menuConfig,
12103
+ collections: buildState.collections,
12104
+ refDocuments: buildState.refDocuments,
12105
+ });
12106
+ if (!resolved) {
12107
+ sendJson(res, 404, { error: 'Page JSON not found' });
12108
+ return;
12109
+ }
12110
+ sendJson(res, 200, resolved.page);
12111
+ })().catch((error) => {
12112
+ sendJson(res, 500, { error: error?.message || 'Page JSON resolution failed' });
12113
+ });
11198
12114
  return;
11199
12115
  }
11200
12116
  if (req.method === 'GET' && req.url === '/api/list-assets') {
@@ -11213,12 +12129,23 @@ export default defineConfig({
11213
12129
  if (!projectState || typeof slug !== 'string') { sendJson(res, 400, { error: 'Missing projectState or slug' }); return; }
11214
12130
  if (!fs.existsSync(DATA_CONFIG_DIR)) fs.mkdirSync(DATA_CONFIG_DIR, { recursive: true });
11215
12131
  if (!fs.existsSync(DATA_PAGES_DIR)) fs.mkdirSync(DATA_PAGES_DIR, { recursive: true });
12132
+ if (!fs.existsSync(DATA_COLLECTIONS_DIR)) fs.mkdirSync(DATA_COLLECTIONS_DIR, { recursive: true });
11216
12133
  if (projectState.site != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'site.json'), JSON.stringify(projectState.site, null, 2), 'utf8');
11217
12134
  if (projectState.menu != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'menu.json'), JSON.stringify(projectState.menu, null, 2), 'utf8');
11218
12135
  if (projectState.theme != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'theme.json'), JSON.stringify(projectState.theme, null, 2), 'utf8');
11219
12136
  if (projectState.page != null) {
11220
- const safeSlug = (slug.replace(/[^a-zA-Z0-9-_]/g, '_') || 'page');
11221
- fs.writeFileSync(path.join(DATA_PAGES_DIR, `${safeSlug}.json`), JSON.stringify(projectState.page, null, 2), 'utf8');
12137
+ const pagePath = safeDataSlugPath(DATA_PAGES_DIR, slug, 'page');
12138
+ fs.mkdirSync(path.dirname(pagePath), { recursive: true });
12139
+ fs.writeFileSync(pagePath, JSON.stringify(projectState.page, null, 2), 'utf8');
12140
+ }
12141
+ if (projectState.collections && typeof projectState.collections === 'object') {
12142
+ for (const [source, collection] of Object.entries(projectState.collections)) {
12143
+ const sourceSlug = String(source).replace(/[^a-zA-Z0-9-_]/g, '_');
12144
+ if (!sourceSlug) continue;
12145
+ const collectionDir = path.join(DATA_COLLECTIONS_DIR, sourceSlug);
12146
+ fs.mkdirSync(collectionDir, { recursive: true });
12147
+ fs.writeFileSync(path.join(collectionDir, `${sourceSlug}.json`), JSON.stringify(collection, null, 2), 'utf8');
12148
+ }
11222
12149
  }
11223
12150
  sendJson(res, 200, { ok: true });
11224
12151
  } catch (e) { sendJson(res, 500, { error: e?.message || 'Save to file failed' }); }