@olonjs/cli 3.0.143 → 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.7",
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,8 +2144,10 @@ 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');
2150
+ const distSsrDir = path.resolve(root, 'dist-ssr');
2149
2151
 
2150
2152
  async function writeTargets(relativePath, content) {
2151
2153
  const targets = [
@@ -2163,6 +2165,15 @@ async function writeJsonTargets(relativePath, value) {
2163
2165
  await writeTargets(relativePath, `${JSON.stringify(value, null, 2)}\n`);
2164
2166
  }
2165
2167
 
2168
+ // Schemas go to dist-ssr ONLY: committed (dist-ssr is not gitignored) so provisioning can read
2169
+ // them from the repo, but NOT served as static files — so /schemas/* falls through to the
2170
+ // blob rewrite, and provision/hotSave own the served contract.
2171
+ async function writeSsrJson(relativePath, value) {
2172
+ const target = path.resolve(distSsrDir, relativePath);
2173
+ await fs.mkdir(path.dirname(target), { recursive: true });
2174
+ await fs.writeFile(target, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
2175
+ }
2176
+
2166
2177
  function escapeHtmlAttribute(value) {
2167
2178
  return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
2168
2179
  }
@@ -2174,6 +2185,42 @@ function toCanonicalSlug(relativeJsonPath) {
2174
2185
  return slug;
2175
2186
  }
2176
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
+
2177
2224
  async function listJsonFilesRecursive(dir) {
2178
2225
  const items = await fs.readdir(dir, { withFileTypes: true });
2179
2226
  const files = [];
@@ -2196,7 +2243,14 @@ async function discoverTargets() {
2196
2243
  files = [];
2197
2244
  }
2198
2245
 
2199
- 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();
2200
2254
  const slugs = Array.from(new Set(rawSlugs)).sort((a, b) => a.localeCompare(b));
2201
2255
 
2202
2256
  return slugs.map((slug) => {
@@ -2254,22 +2308,34 @@ const remoteStylesheetTags = getRemoteStylesheets()
2254
2308
  const webMcpBuildState = getWebMcpBuildState();
2255
2309
 
2256
2310
  for (const { slug } of targets) {
2257
- const pageConfig = webMcpBuildState.pages[slug];
2311
+ const pageConfig = resolvePageMatchFromRegistry(webMcpBuildState.pages, slug)?.page;
2258
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;
2259
2324
 
2260
- // Export the raw JSON data for the agentic web (so readResource works on SSG)
2261
- 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);
2262
2327
 
2263
2328
  const contract = buildPageContract({
2264
2329
  slug,
2265
- pageConfig,
2330
+ pageConfig: publicPageConfig,
2266
2331
  schemas: webMcpBuildState.schemas,
2332
+ submissionSchemas: webMcpBuildState.submissionSchemas,
2267
2333
  siteConfig: webMcpBuildState.siteConfig,
2268
2334
  });
2269
- await writeJsonTargets(`schemas/${slug}.schema.json`, contract);
2335
+ await writeSsrJson(`schemas/${slug}.schema.json`, contract);
2270
2336
  const pageManifest = buildPageManifest({
2271
2337
  slug,
2272
- pageConfig,
2338
+ pageConfig: publicPageConfig,
2273
2339
  schemas: webMcpBuildState.schemas,
2274
2340
  siteConfig: webMcpBuildState.siteConfig,
2275
2341
  });
@@ -2342,6 +2408,7 @@ for (const { slug, out, depth } of targets) {
2342
2408
  console.log('\n[bake] All pages baked. OK\n');
2343
2409
 
2344
2410
  END_OF_FILE_CONTENT
2411
+ # SKIP: scripts/bake.mjs:Zone.Identifier is binary and cannot be embedded as text.
2345
2412
  echo "Creating scripts/generate-llms-txt.mjs..."
2346
2413
  cat << 'END_OF_FILE_CONTENT' > "scripts/generate-llms-txt.mjs"
2347
2414
  import fs from 'fs';
@@ -2537,6 +2604,8 @@ const __dirname = path.dirname(__filename);
2537
2604
  const rootDir = path.resolve(__dirname, '..');
2538
2605
  const sourceDir = path.join(rootDir, 'src', 'data', 'pages');
2539
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');
2540
2609
  const sourceSiteConfigPath = path.join(rootDir, 'src', 'data', 'config', 'site.json');
2541
2610
  const targetConfigDir = path.join(rootDir, 'public', 'config');
2542
2611
  const targetSiteConfigPath = path.join(targetConfigDir, 'site.json');
@@ -2550,12 +2619,18 @@ fs.rmSync(targetDir, { recursive: true, force: true });
2550
2619
  fs.mkdirSync(targetDir, { recursive: true });
2551
2620
  fs.cpSync(sourceDir, targetDir, { recursive: true });
2552
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
+
2553
2628
  if (fs.existsSync(sourceSiteConfigPath)) {
2554
2629
  fs.mkdirSync(targetConfigDir, { recursive: true });
2555
2630
  fs.cpSync(sourceSiteConfigPath, targetSiteConfigPath);
2556
2631
  }
2557
2632
 
2558
- 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/');
2559
2634
 
2560
2635
  END_OF_FILE_CONTENT
2561
2636
  echo "Creating scripts/webmcp-feature-check.mjs..."
@@ -2873,7 +2948,8 @@ import { JsonPagesEngine } from '@olonjs/core';
2873
2948
  import type { JsonPagesConfig, LibraryImageEntry, ProjectState } from '@olonjs/core';
2874
2949
  import { normalizeBasePath, withBasePath } from '@olonjs/core';
2875
2950
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
2876
- import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '@/lib/schemas';
2951
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
2952
+ import { SECTION_SCHEMAS } from '@/lib/schemas';
2877
2953
  import { addSectionConfig } from '@/lib/addSectionConfig';
2878
2954
  import { getHydratedData } from '@/lib/draftStorage';
2879
2955
  import type { SiteConfig, ThemeConfig, MenuConfig, PageConfig } from '@/types';
@@ -2883,6 +2959,7 @@ import { startCloudSaveStream } from '@olonjs/core';
2883
2959
  import siteData from '@/data/config/site.json';
2884
2960
  import themeData from '@/data/config/theme.json';
2885
2961
  import menuData from '@/data/config/menu.json';
2962
+ import libriData from '@/data/collections/libri/libri.json';
2886
2963
  import { getFilePages } from '@/lib/getFilePages';
2887
2964
  import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
2888
2965
  import { EmptyTenantView } from '@/components/empty-tenant';
@@ -2914,6 +2991,9 @@ const TENANT_ID = 'alpha';
2914
2991
 
2915
2992
  const filePages = getFilePages();
2916
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']>;
2917
2997
  const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
2918
2998
  const ASSET_UPLOAD_MAX_RETRIES = 2;
2919
2999
  const ASSET_UPLOAD_TIMEOUT_MS = 20_000;
@@ -2951,6 +3031,7 @@ type CachedCloudContent = {
2951
3031
  savedAt: number;
2952
3032
  siteConfig: unknown | null;
2953
3033
  pages: Record<string, unknown>;
3034
+ collections?: JsonPagesConfig['collections'];
2954
3035
  };
2955
3036
 
2956
3037
  const CLOUD_CACHE_KEY = 'jp_cloud_content_cache_v1';
@@ -2996,7 +3077,7 @@ function asString(value: unknown, fallback: string): string {
2996
3077
  function normalizeRouteSlug(value: string): string {
2997
3078
  return value
2998
3079
  .toLowerCase()
2999
- .replace(/[^a-z0-9/_-]/g, '-')
3080
+ .replace(/[^a-z0-9/_[\]-]/g, '-')
3000
3081
  .replace(/^\/+|\/+$/g, '') || 'home';
3001
3082
  }
3002
3083
 
@@ -3022,6 +3103,7 @@ function coercePageConfig(slug: string, value: unknown): PageConfig | null {
3022
3103
  slug: normalizedSlug,
3023
3104
  meta: { title, description },
3024
3105
  sections: input.sections as PageConfig['sections'],
3106
+ ...(isObjectRecord(input.collection) ? { collection: input.collection as unknown as PageConfig['collection'] } : {}),
3025
3107
  ...(typeof input['global-header'] === 'boolean' ? { 'global-header': input['global-header'] } : {}),
3026
3108
  };
3027
3109
  }
@@ -3037,6 +3119,7 @@ function coerceSiteConfig(value: unknown): SiteConfig | null {
3037
3119
  }
3038
3120
  if (!isObjectRecord(input)) return null;
3039
3121
  if (!isObjectRecord(input.identity)) return null;
3122
+ if (!Array.isArray(input.pages)) return null;
3040
3123
 
3041
3124
  return input as unknown as SiteConfig;
3042
3125
  }
@@ -3162,7 +3245,7 @@ function normalizeSlugForCache(slug: string): string {
3162
3245
  slug
3163
3246
  .trim()
3164
3247
  .toLowerCase()
3165
- .replace(/[^a-z0-9/_-]/g, '-')
3248
+ .replace(/[^a-z0-9/_[\]-]/g, '-')
3166
3249
  .replace(/^\/+|\/+$/g, '') || 'home'
3167
3250
  );
3168
3251
  }
@@ -3727,14 +3810,15 @@ function App() {
3727
3810
  basePath: APP_BASE_PATH,
3728
3811
  registry: ComponentRegistry as JsonPagesConfig['registry'],
3729
3812
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
3730
- submissionSchemas: SECTION_SUBMISSION_SCHEMAS as unknown as JsonPagesConfig['submissionSchemas'],
3813
+ collectionSchemas: CollectionRegistry as unknown as JsonPagesConfig['collectionSchemas'],
3731
3814
  pages,
3732
3815
  siteConfig,
3733
3816
  themeConfig,
3734
3817
  menuConfig,
3818
+ collections,
3735
3819
  refDocuments,
3736
- iconRegistry: iconMap,
3737
3820
  themeCss: { tenant: resolvedTenantCss },
3821
+ iconRegistry: iconMap,
3738
3822
  addSection: addSectionConfig,
3739
3823
  webmcp: {
3740
3824
  enabled: true,
@@ -3768,6 +3852,7 @@ function App() {
3768
3852
  slug,
3769
3853
  page: state.page,
3770
3854
  siteConfig: state.site,
3855
+ collections: state.collections,
3771
3856
  }),
3772
3857
  });
3773
3858
  const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
@@ -3781,6 +3866,7 @@ function App() {
3781
3866
  keyFingerprint,
3782
3867
  savedAt: Date.now(),
3783
3868
  siteConfig: state.site ?? null,
3869
+ collections: state.collections,
3784
3870
  pages: {
3785
3871
  ...(existing?.pages ?? {}),
3786
3872
  [normalizedSlug]: state.page,
@@ -3950,13 +4036,7 @@ function App() {
3950
4036
  </div>
3951
4037
  </div>
3952
4038
  ) : null}
3953
- {shouldRenderEngine ? (
3954
- isTenantEmpty ? (
3955
- <EmptyTenantView />
3956
- ) : (
3957
- <JsonPagesEngine config={config} />
3958
- )
3959
- ) : null}
4039
+ {shouldRenderEngine ? (isTenantEmpty ? <EmptyTenantView /> : <JsonPagesEngine config={config} />) : null}
3960
4040
  {isCloudMode && (contentMode === 'error' || contentFallback?.reasonCode === 'CLOUD_REFRESH_FAILED') ? (
3961
4041
  <div
3962
4042
  role="status"
@@ -4031,6 +4111,40 @@ function App() {
4031
4111
 
4032
4112
  export default App;
4033
4113
 
4114
+ END_OF_FILE_CONTENT
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
+
4034
4148
  END_OF_FILE_CONTENT
4035
4149
  mkdir -p "src/components"
4036
4150
  echo "Creating src/components/ThemeProvider.tsx..."
@@ -4095,6 +4209,221 @@ export function useTheme() {
4095
4209
  return useContext(ThemeContext)
4096
4210
  }
4097
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
+
4098
4427
  END_OF_FILE_CONTENT
4099
4428
  mkdir -p "src/components/empty-tenant"
4100
4429
  echo "Creating src/components/empty-tenant/View.tsx..."
@@ -4148,6 +4477,123 @@ import { EmptyTenantSchema, EmptyTenantSettingsSchema } from './schema';
4148
4477
  export type EmptyTenantData = z.infer<typeof EmptyTenantSchema>;
4149
4478
  export type EmptyTenantSettings = z.infer<typeof EmptyTenantSettingsSchema>;
4150
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
+
4151
4597
  END_OF_FILE_CONTENT
4152
4598
  mkdir -p "src/components/form-demo"
4153
4599
  echo "Creating src/components/form-demo/Untitled..."
@@ -8560,6 +9006,253 @@ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
8560
9006
  END_OF_FILE_CONTENT
8561
9007
  # SKIP: src/components/ui/tooltip.tsx:Zone.Identifier is binary and cannot be embedded as text.
8562
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
8563
9256
  mkdir -p "src/data/config"
8564
9257
  echo "Creating src/data/config/menu.json..."
8565
9258
  cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
@@ -8585,6 +9278,24 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
8585
9278
  "label": "GitHub",
8586
9279
  "href": "https://github.com/olonjs/core"
8587
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
+ }
8588
9299
  ]
8589
9300
  }
8590
9301
  END_OF_FILE_CONTENT
@@ -8640,7 +9351,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
8640
9351
  "title": "OlonJS",
8641
9352
  "logoUrl": "/brand/mark/olon-mark-dark.svg"
8642
9353
  },
8643
-
8644
9354
  "header": {
8645
9355
  "id": "global-header",
8646
9356
  "type": "header",
@@ -8679,13 +9389,11 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
8679
9389
  "type": "footer",
8680
9390
  "data": {
8681
9391
  "brandText": "OlonJS",
9392
+ "description": "AI-native content infrastructure for deterministic, sovereign, git-backed sites.",
8682
9393
  "copyright": "© 2026 OlonJS · v1.5 · Guido Serio",
8683
- "links": [
8684
- {
8685
- "label": "GitHub",
8686
- "href": "https://github.com/olonjs/core"
8687
- }
8688
- ],
9394
+ "links": {
9395
+ "$ref": "../config/menu.json#/footer"
9396
+ },
8689
9397
  "designSystemHref": ""
8690
9398
  },
8691
9399
  "settings": {
@@ -8889,6 +9597,65 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
8889
9597
  "global-header": false
8890
9598
  }
8891
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
8892
9659
  mkdir -p "src/emails"
8893
9660
  echo "Creating src/emails/LeadNotificationEmail.tsx..."
8894
9661
  cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadNotificationEmail.tsx"
@@ -9206,12 +9973,19 @@ echo "Creating src/entry-ssg.tsx..."
9206
9973
  cat << 'END_OF_FILE_CONTENT' > "src/entry-ssg.tsx"
9207
9974
  import { renderToString } from 'react-dom/server';
9208
9975
  import { StaticRouter } from 'react-router-dom/server';
9209
- 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';
9210
9984
  import type { JsonPagesConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
9211
9985
  import { ThemeProvider } from '@/components/ThemeProvider';
9212
9986
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
9213
9987
  import { SECTION_SCHEMAS } from '@/lib/schemas';
9214
- import { menuConfig, pages, refDocuments, siteConfig, themeConfig } from '@/runtime';
9988
+ import { collectionSchemas, collections, menuConfig, pages, refDocuments, siteConfig, themeConfig } from '@/runtime';
9215
9989
  import tenantCss from '@/index.css?inline';
9216
9990
 
9217
9991
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -9226,10 +10000,16 @@ function getSortedSlugs(): string[] {
9226
10000
  return Object.keys(pages).sort((a, b) => a.localeCompare(b));
9227
10001
  }
9228
10002
 
9229
- function resolvePage(slug: string): { slug: string; page: PageConfig } {
10003
+ function resolvePage(slug: string): { slug: string; registrySlug: string; page: PageConfig; params: Record<string, string> } {
9230
10004
  const normalized = normalizeSlug(slug);
9231
- if (normalized && pages[normalized]) {
9232
- 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
+ };
9233
10013
  }
9234
10014
 
9235
10015
  const slugs = getSortedSlugs();
@@ -9239,7 +10019,7 @@ function resolvePage(slug: string): { slug: string; page: PageConfig } {
9239
10019
 
9240
10020
  const home = slugs.find((item) => item === 'home');
9241
10021
  const fallbackSlug = home ?? slugs[0];
9242
- return { slug: fallbackSlug, page: pages[fallbackSlug] };
10022
+ return { slug: fallbackSlug, registrySlug: fallbackSlug, page: pages[fallbackSlug], params: {} };
9243
10023
  }
9244
10024
 
9245
10025
  function flattenThemeTokens(
@@ -9327,14 +10107,18 @@ function resolveTenantId(): string {
9327
10107
  export function render(slug: string): string {
9328
10108
  const resolved = resolvePage(slug);
9329
10109
  const location = resolved.slug === 'home' ? '/' : `/${resolved.slug}`;
10110
+ const collectionContext = contract.resolveCollectionContext(resolved.page, resolved.params, collections);
9330
10111
  const resolvedRuntime = resolveRuntimeConfig({
9331
- pages,
10112
+ pages: { [resolved.registrySlug]: resolved.page },
9332
10113
  siteConfig,
9333
10114
  themeConfig,
9334
10115
  menuConfig,
10116
+ collections,
10117
+ collectionSchemas,
10118
+ collectionContext,
9335
10119
  refDocuments,
9336
10120
  });
9337
- const resolvedPage = resolvedRuntime.pages[resolved.slug] ?? resolved.page;
10121
+ const resolvedPage = resolvedRuntime.pages[resolved.registrySlug] ?? resolved.page;
9338
10122
 
9339
10123
  return renderToString(
9340
10124
  <StaticRouter location={location}>
@@ -9384,12 +10168,22 @@ export function getPageMeta(slug: string): { title: string; description: string
9384
10168
  export function getWebMcpBuildState(): {
9385
10169
  pages: Record<string, PageConfig>;
9386
10170
  schemas: JsonPagesConfig['schemas'];
10171
+ collections: JsonPagesConfig['collections'];
10172
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
9387
10173
  siteConfig: SiteConfig;
10174
+ themeConfig: ThemeConfig;
10175
+ menuConfig: JsonPagesConfig['menuConfig'];
10176
+ refDocuments: JsonPagesConfig['refDocuments'];
9388
10177
  } {
9389
10178
  return {
9390
10179
  pages,
9391
10180
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
10181
+ collections,
10182
+ collectionSchemas,
9392
10183
  siteConfig,
10184
+ themeConfig,
10185
+ menuConfig,
10186
+ refDocuments,
9393
10187
  };
9394
10188
  }
9395
10189
 
@@ -10337,17 +11131,34 @@ cat << 'END_OF_FILE_CONTENT' > "src/index.css"
10337
11131
 
10338
11132
  END_OF_FILE_CONTENT
10339
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
10340
11145
  echo "Creating src/lib/ComponentRegistry.tsx..."
10341
11146
  cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
10342
11147
  import type { SectionType } from '@/types';
10343
11148
  import type { SectionComponentPropsMap } from '@/types';
11149
+ import { BookDetailView } from '@/components/book-detail';
11150
+ import { BooksListView } from '@/components/books-list';
10344
11151
  import { EmptyTenantView } from '@/components/empty-tenant';
11152
+ import { FooterView } from '@/components/footer';
10345
11153
  import { FormDemoView } from '@/components/form-demo';
10346
11154
 
10347
11155
  export const ComponentRegistry: {
10348
11156
  [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
10349
11157
  } = {
11158
+ 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
11159
+ 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
10350
11160
  'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
11161
+ footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
10351
11162
  'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
10352
11163
  };
10353
11164
 
@@ -10416,20 +11227,44 @@ echo "Creating src/lib/addSectionConfig.ts..."
10416
11227
  cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
10417
11228
  import type { AddSectionConfig } from '@olonjs/core';
10418
11229
 
10419
- const addableSectionTypes = ['empty-tenant', 'form-demo'] as const;
11230
+ const addableSectionTypes = ['book-detail', 'books-list', 'empty-tenant', 'footer', 'form-demo'] as const;
10420
11231
 
10421
11232
  const sectionTypeLabels: Record<string, string> = {
11233
+ 'book-detail': 'Book Detail',
11234
+ 'books-list': 'Books List',
10422
11235
  'empty-tenant': 'Empty Tenant',
11236
+ footer: 'Footer',
10423
11237
  'form-demo': 'Form Demo',
10424
11238
  };
10425
11239
 
10426
11240
  function getDefaultSectionData(type: string): Record<string, unknown> {
10427
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
+ };
10428
11255
  case 'empty-tenant':
10429
11256
  return {
10430
11257
  title: 'Your tenant is empty.',
10431
11258
  description: 'Create your first page to start building your site.',
10432
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
+ };
10433
11268
  case 'form-demo':
10434
11269
  return {
10435
11270
  title: 'Contattaci',
@@ -10528,11 +11363,17 @@ export function getFilePages(): Record<string, PageConfig> {
10528
11363
  END_OF_FILE_CONTENT
10529
11364
  echo "Creating src/lib/schemas.ts..."
10530
11365
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
11366
+ import { BookDetailSchema } from '@/components/book-detail';
11367
+ import { BooksListSchema } from '@/components/books-list';
10531
11368
  import { EmptyTenantSchema } from '@/components/empty-tenant';
11369
+ import { FooterSchema } from '@/components/footer';
10532
11370
  import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
10533
11371
 
10534
11372
  export const SECTION_SCHEMAS = {
11373
+ 'book-detail': BookDetailSchema,
11374
+ 'books-list': BooksListSchema,
10535
11375
  'empty-tenant': EmptyTenantSchema,
11376
+ footer: FooterSchema,
10536
11377
  'form-demo': FormDemoSchema,
10537
11378
  } as const;
10538
11379
 
@@ -10552,6 +11393,7 @@ export type SectionType = keyof typeof SECTION_SCHEMAS;
10552
11393
  export {
10553
11394
  BaseSectionData,
10554
11395
  BaseArrayItem,
11396
+ BaseCollectionItem,
10555
11397
  BaseSectionSettingsSchema,
10556
11398
  CtaSchema,
10557
11399
  ImageSelectionSchema,
@@ -10810,16 +11652,22 @@ END_OF_FILE_CONTENT
10810
11652
  echo "Creating src/runtime.ts..."
10811
11653
  cat << 'END_OF_FILE_CONTENT' > "src/runtime.ts"
10812
11654
  import type { JsonPagesConfig, MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
10813
- import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '@/lib/schemas';
11655
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
11656
+ import { SECTION_SCHEMAS } from '@/lib/schemas';
10814
11657
  import { getFilePages } from '@/lib/getFilePages';
10815
11658
  import siteData from '@/data/config/site.json';
10816
11659
  import menuData from '@/data/config/menu.json';
10817
11660
  import themeData from '@/data/config/theme.json';
11661
+ import libriData from '@/data/collections/libri/libri.json';
10818
11662
 
10819
11663
  export const siteConfig = siteData as unknown as SiteConfig;
10820
11664
  export const themeConfig = themeData as unknown as ThemeConfig;
10821
11665
  export const menuConfig = menuData as unknown as MenuConfig;
10822
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'];
10823
11671
  export const refDocuments = {
10824
11672
  'menu.json': menuConfig,
10825
11673
  'config/menu.json': menuConfig,
@@ -10829,42 +11677,122 @@ export const refDocuments = {
10829
11677
  export function getWebMcpBuildState(): {
10830
11678
  pages: Record<string, PageConfig>;
10831
11679
  schemas: JsonPagesConfig['schemas'];
10832
- submissionSchemas: JsonPagesConfig['submissionSchemas'];
11680
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
11681
+ collections: JsonPagesConfig['collections'];
10833
11682
  siteConfig: SiteConfig;
11683
+ themeConfig: ThemeConfig;
11684
+ menuConfig: MenuConfig;
11685
+ refDocuments: JsonPagesConfig['refDocuments'];
10834
11686
  } {
10835
11687
  return {
10836
11688
  pages,
10837
11689
  schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
10838
- submissionSchemas: SECTION_SUBMISSION_SCHEMAS as unknown as JsonPagesConfig['submissionSchemas'],
11690
+ collectionSchemas,
11691
+ collections,
10839
11692
  siteConfig,
11693
+ themeConfig,
11694
+ menuConfig,
11695
+ refDocuments,
10840
11696
  };
10841
11697
  }
10842
11698
 
10843
11699
  END_OF_FILE_CONTENT
11700
+ # SKIP: src/runtime.ts:Zone.Identifier is binary and cannot be embedded as text.
10844
11701
  echo "Creating src/types.ts..."
10845
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';
10846
11705
  import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
11706
+ import type { FooterData, FooterSettings } from '@/components/footer';
10847
11707
  import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
11708
+ import type { Libro } from '@/collections/libri';
10848
11709
 
10849
11710
  export type SectionComponentPropsMap = {
11711
+ 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
11712
+ 'books-list': { data: BooksListData; settings?: BooksListSettings };
10850
11713
  'empty-tenant': { data: EmptyTenantData; settings?: EmptyTenantSettings };
11714
+ footer: { data: FooterData; settings?: FooterSettings };
10851
11715
  'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
10852
11716
  };
10853
11717
 
10854
11718
  declare module '@olonjs/core' {
10855
11719
  export interface SectionDataRegistry {
11720
+ 'book-detail': BookDetailData;
11721
+ 'books-list': BooksListData;
10856
11722
  'empty-tenant': EmptyTenantData;
11723
+ footer: FooterData;
10857
11724
  'form-demo': FormDemoData;
10858
11725
  }
10859
11726
  export interface SectionSettingsRegistry {
11727
+ 'book-detail': BookDetailSettings;
11728
+ 'books-list': BooksListSettings;
10860
11729
  'empty-tenant': EmptyTenantSettings;
11730
+ footer: FooterSettings;
10861
11731
  'form-demo': FormDemoSettings;
10862
11732
  }
11733
+ export interface CollectionItemRegistry {
11734
+ libri: Libro;
11735
+ }
10863
11736
  }
10864
11737
 
10865
11738
  export * from '@olonjs/core';
10866
11739
 
10867
11740
  END_OF_FILE_CONTENT
11741
+ echo "Creating src/vercel.json..."
11742
+ cat << 'END_OF_FILE_CONTENT' > "src/vercel.json"
11743
+ {
11744
+ "rewrites": [
11745
+ {
11746
+ "source": "/robots.txt",
11747
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/robots.txt"
11748
+ },
11749
+ {
11750
+ "source": "/sitemap.xml",
11751
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/sitemap.xml"
11752
+ },
11753
+ {
11754
+ "source": "/llms.txt",
11755
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/llms.txt"
11756
+ },
11757
+ {
11758
+ "source": "/.well-known/agent-card.json",
11759
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/.well-known/agent-card.json"
11760
+ },
11761
+ {
11762
+ "source": "/mcp-manifest.json",
11763
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/mcp-manifest.json"
11764
+ },
11765
+ {
11766
+ "source": "/mcp-manifests/:path*.json",
11767
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/mcp-manifests/:path*.json"
11768
+ },
11769
+ {
11770
+ "source": "/schemas/:path*.json",
11771
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/schemas/:path*.json"
11772
+ },
11773
+ {
11774
+ "source": "/:path*.json",
11775
+ "destination": "https://bat5elmxofxdroan.public.blob.vercel-storage.com/tenants/santa1/pages/:path*.json"
11776
+ },
11777
+ {
11778
+ "source": "/(.*)",
11779
+ "destination": "/index.html"
11780
+ }
11781
+ ],
11782
+ "headers": [
11783
+ {
11784
+ "source": "/assets/(.*)",
11785
+ "headers": [
11786
+ {
11787
+ "key": "Cache-Control",
11788
+ "value": "public, max-age=31536000, immutable"
11789
+ }
11790
+ ]
11791
+ }
11792
+ ]
11793
+ }
11794
+ END_OF_FILE_CONTENT
11795
+ # SKIP: src/vercel.json:Zone.Identifier is binary and cannot be embedded as text.
10868
11796
  echo "Creating src/vite-env.d.ts..."
10869
11797
  cat << 'END_OF_FILE_CONTENT' > "src/vite-env.d.ts"
10870
11798
  /// <reference types="vite/client" />
@@ -10966,6 +11894,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10966
11894
  const ASSETS_IMAGES_DIR = path.resolve(__dirname, 'public', 'assets', 'images');
10967
11895
  const DATA_CONFIG_DIR = path.resolve(__dirname, 'src', 'data', 'config');
10968
11896
  const DATA_PAGES_DIR = path.resolve(__dirname, 'src', 'data', 'pages');
11897
+ const DATA_COLLECTIONS_DIR = path.resolve(__dirname, 'src', 'data', 'collections');
10969
11898
  const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif']);
10970
11899
  const IMAGE_MIMES = new Set([
10971
11900
  'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif',
@@ -11002,6 +11931,20 @@ function sendJsonFile(res, filePath) {
11002
11931
  }
11003
11932
  }
11004
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
+
11005
11948
  function isTenantPageJsonRequest(req, pathname) {
11006
11949
  if (req.method !== 'GET' || !pathname.endsWith('.json')) return false;
11007
11950
  const viteOrStaticPrefixes = ['/api/', '/assets/', '/src/', '/node_modules/', '/public/', '/@'];
@@ -11059,15 +12002,23 @@ export default defineConfig({
11059
12002
  const pageManifestMatch = pathname.match(/^\/mcp-manifests\/(.+)\.json$/i);
11060
12003
  if (pageManifestMatch && req.method === 'GET') {
11061
12004
  const slug = normalizeManifestSlug(pageManifestMatch[1]);
11062
- const pageConfig = buildState.pages[slug];
11063
- 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) {
11064
12015
  sendJson(res, 404, { error: 'Page manifest not found' });
11065
12016
  return true;
11066
12017
  }
11067
12018
 
11068
12019
  sendJson(res, 200, buildPageManifest({
11069
12020
  slug,
11070
- pageConfig,
12021
+ pageConfig: resolved.page,
11071
12022
  schemas: buildState.schemas,
11072
12023
  submissionSchemas: buildState.submissionSchemas,
11073
12024
  siteConfig: buildState.siteConfig,
@@ -11078,15 +12029,23 @@ export default defineConfig({
11078
12029
  const schemaMatch = pathname.match(/^\/schemas\/(.+)\.schema\.json$/i);
11079
12030
  if (schemaMatch && req.method === 'GET') {
11080
12031
  const slug = normalizeManifestSlug(schemaMatch[1]);
11081
- const pageConfig = buildState.pages[slug];
11082
- 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) {
11083
12042
  sendJson(res, 404, { error: 'Schema contract not found' });
11084
12043
  return true;
11085
12044
  }
11086
12045
 
11087
12046
  sendJson(res, 200, buildPageContract({
11088
12047
  slug,
11089
- pageConfig,
12048
+ pageConfig: resolved.page,
11090
12049
  schemas: buildState.schemas,
11091
12050
  submissionSchemas: buildState.submissionSchemas,
11092
12051
  siteConfig: buildState.siteConfig,
@@ -11127,13 +12086,31 @@ export default defineConfig({
11127
12086
  .replace(/^pages\//i, '')
11128
12087
  .replace(/\.json$/i, '')
11129
12088
  .replace(/^\/+|\/+$/g, '');
11130
- const candidate = path.resolve(DATA_PAGES_DIR, `${slug}.json`);
11131
- const isInsidePagesDir = candidate.startsWith(`${DATA_PAGES_DIR}${path.sep}`) || candidate === DATA_PAGES_DIR;
11132
- if (!slug || !isInsidePagesDir || !fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
12089
+ if (!slug) {
11133
12090
  sendJson(res, 404, { error: 'Page JSON not found' });
11134
12091
  return;
11135
12092
  }
11136
- 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
+ });
11137
12114
  return;
11138
12115
  }
11139
12116
  if (req.method === 'GET' && req.url === '/api/list-assets') {
@@ -11152,12 +12129,23 @@ export default defineConfig({
11152
12129
  if (!projectState || typeof slug !== 'string') { sendJson(res, 400, { error: 'Missing projectState or slug' }); return; }
11153
12130
  if (!fs.existsSync(DATA_CONFIG_DIR)) fs.mkdirSync(DATA_CONFIG_DIR, { recursive: true });
11154
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 });
11155
12133
  if (projectState.site != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'site.json'), JSON.stringify(projectState.site, null, 2), 'utf8');
11156
12134
  if (projectState.menu != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'menu.json'), JSON.stringify(projectState.menu, null, 2), 'utf8');
11157
12135
  if (projectState.theme != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'theme.json'), JSON.stringify(projectState.theme, null, 2), 'utf8');
11158
12136
  if (projectState.page != null) {
11159
- const safeSlug = (slug.replace(/[^a-zA-Z0-9-_]/g, '_') || 'page');
11160
- 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
+ }
11161
12149
  }
11162
12150
  sendJson(res, 200, { ok: true });
11163
12151
  } catch (e) { sendJson(res, 500, { error: e?.message || 'Save to file failed' }); }