@olonjs/cli 3.0.165 → 3.0.166

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.
@@ -19,11 +19,10 @@ import {
19
19
  import { loadVisitorPage } from '@olonjs/next/server';
20
20
  import { EmptyTenantView } from '@/components/empty-tenant';
21
21
  import { WebMcpVisitorRuntime } from '@/components/webmcp/WebMcpVisitorRuntime';
22
- import { CollectionRegistry } from '@/lib/CollectionRegistry';
23
22
  import { buildVisitorWebPageJsonLd } from '@/lib/buildVisitorWebPageJsonLd';
24
- import { getFileCollections } from '@/lib/loaders/getFileCollections';
25
23
  import { getFilePages } from '@/lib/loaders/getFilePages';
26
24
  import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
25
+ import { loadVisitorPageBundle } from '@/lib/loaders/loadVisitorPageBundle';
27
26
  import { resolveVisitorShell } from '@/lib/resolveVisitorShell';
28
27
  import { VisitorSection } from '@/lib/VisitorSection';
29
28
 
@@ -89,7 +88,10 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
89
88
  notFound();
90
89
  }
91
90
 
92
- const collections = getFileCollections();
91
+ const { collections, collectionSchemas } = await loadVisitorPageBundle({
92
+ page: result.page,
93
+ params: result.params,
94
+ });
93
95
  const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle();
94
96
  const collectionContext = resolveCollectionContext(result.page, result.params, collections);
95
97
 
@@ -99,7 +101,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
99
101
  themeConfig,
100
102
  menuConfig,
101
103
  collections,
102
- collectionSchemas: CollectionRegistry as never,
104
+ collectionSchemas: collectionSchemas as never,
103
105
  collectionContext,
104
106
  refDocuments: {
105
107
  'menu.json': menuConfig,
@@ -213,27 +215,50 @@ mkdir -p "app/api/public-collection/[source]"
213
215
  mkdir -p "app/api/public-collection/[source]/[file]"
214
216
  echo "Creating app/api/public-collection/[source]/[file]/route.ts..."
215
217
  cat << 'END_OF_FILE_CONTENT' > "app/api/public-collection/[source]/[file]/route.ts"
216
- import { NextResponse } from 'next/server';
217
- import { resolvePublicCollectionDocument } from '@/lib/loaders/publishedContent';
218
- import { loadRuntimeSurfaceForSite } from '@/lib/webmcp/runtime/loadRuntimeSurface';
219
-
220
- export const dynamic = 'force-dynamic';
221
-
222
- /** GET /collections/{source}/{source}.json (rewrite) — collection document, runtime. */
223
- export async function GET(_request: Request, context: { params: Promise<{ source: string; file: string }> }) {
224
- try {
225
- const { source, file } = await context.params;
226
- const { bundle } = loadRuntimeSurfaceForSite();
227
- const doc = resolvePublicCollectionDocument(bundle, decodeURIComponent(source), decodeURIComponent(file));
228
- if (!doc) return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
229
- return NextResponse.json(doc);
230
- } catch (error) {
231
- return NextResponse.json(
232
- { error: error instanceof Error ? error.message : 'Collection JSON failed' },
233
- { status: 500 },
234
- );
235
- }
236
- }
218
+ import { NextResponse } from 'next/server';
219
+ import { resolveCollectionSources } from '@olonjs/sources';
220
+ import { resolvePublicCollectionDocument } from '@/lib/loaders/publishedContent';
221
+ import { collectionSources } from '@/sources';
222
+ import { loadRuntimeSurfaceForSite } from '@/lib/webmcp/runtime/loadRuntimeSurface';
223
+
224
+ export const dynamic = 'force-dynamic';
225
+
226
+ /**
227
+ * GET /collections/{source}/{source}.json (rewrite) — collection document, runtime.
228
+ *
229
+ * File-backed collections are read from the content bundle; EXTERNAL sources
230
+ * (ADR-0018) are projected on demand so the contract's `dataHref` resolves.
231
+ * Note: resolution here has no route params — parameterized sources should be
232
+ * resolved at the page level (see loadPublicPageBundleForRequest).
233
+ */
234
+ export async function GET(_request: Request, context: { params: Promise<{ source: string; file: string }> }) {
235
+ try {
236
+ const { source, file } = await context.params;
237
+ const sourceName = decodeURIComponent(source);
238
+
239
+ if (sourceName in collectionSources) {
240
+ const resolved = await resolveCollectionSources({
241
+ sources: collectionSources as never,
242
+ needed: [sourceName],
243
+ });
244
+ const projected = resolved.collections[sourceName];
245
+ if (!projected) {
246
+ return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
247
+ }
248
+ return NextResponse.json(projected);
249
+ }
250
+
251
+ const { bundle } = loadRuntimeSurfaceForSite();
252
+ const doc = resolvePublicCollectionDocument(bundle, sourceName, decodeURIComponent(file));
253
+ if (!doc) return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
254
+ return NextResponse.json(doc);
255
+ } catch (error) {
256
+ return NextResponse.json(
257
+ { error: error instanceof Error ? error.message : 'Collection JSON failed' },
258
+ { status: 500 },
259
+ );
260
+ }
261
+ }
237
262
 
238
263
  END_OF_FILE_CONTENT
239
264
  mkdir -p "app/api/public-config"
@@ -761,10 +786,10 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
761
786
  "dist:dna": "npm run dist"
762
787
  },
763
788
  "dependencies": {
764
- "@olonjs/core": "^2.0.1",
765
- "@olonjs/next": "^0.0.13",
766
- "@olonjs/react": "^0.2.1",
767
- "@olonjs/studio": "^0.2.1",
789
+ "@olonjs/core": "^2.0.2",
790
+ "@olonjs/next": "^0.0.14",
791
+ "@olonjs/react": "^0.2.2",
792
+ "@olonjs/studio": "^0.2.2",
768
793
  "clsx": "^2.1.1",
769
794
  "lucide-react": "^0.474.0",
770
795
  "next": "^15.5.0",
@@ -772,7 +797,8 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
772
797
  "react-dom": "^19.0.0",
773
798
  "react-router-dom": "^6.29.0",
774
799
  "tailwind-merge": "^3.0.1",
775
- "zod": "^4.6.0"
800
+ "zod": "^4.6.0",
801
+ "@olonjs/sources": "^0.1.0"
776
802
  },
777
803
  "devDependencies": {
778
804
  "@tailwindcss/postcss": "^4.0.0",
@@ -1011,10 +1037,16 @@ const SCRIPT = path.join(__dirname, 'webmcp-feature-check.mjs');
1011
1037
  const PKG = path.join(ROOT, 'package.json');
1012
1038
 
1013
1039
  describe('next verify:webmcp script', () => {
1014
- it('probes document.modelContextProtocol (the surface ensureWebMcpRuntime assigns)', () => {
1040
+ it('probes the native document.modelContext surface with polyfill fallback', () => {
1015
1041
  assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
1016
1042
  const src = fs.readFileSync(SCRIPT, 'utf8');
1043
+ // Native Chrome surface: document.modelContext.getTools() / executeTool(toolObject, argsObject).
1044
+ assert.match(src, /document\.modelContext/);
1045
+ assert.match(src, /await native\.getTools\(\)/);
1046
+ assert.match(src, /native\.executeTool\(tool,/);
1047
+ // Polyfill fallback (browsers without native WebMCP): listTools / executeTool(name, json).
1017
1048
  assert.match(src, /document\.modelContextProtocol/);
1049
+ assert.match(src, /protocol\.executeTool\(/);
1018
1050
  // modelContextTesting is never assigned by the runtime — probing it is a false negative.
1019
1051
  assert.doesNotMatch(src, /modelContextTesting/);
1020
1052
  assert.doesNotMatch(src, /navigator\.modelContext/);
@@ -1031,302 +1063,351 @@ describe('next verify:webmcp script', () => {
1031
1063
  END_OF_FILE_CONTENT
1032
1064
  echo "Creating scripts/webmcp-feature-check.mjs..."
1033
1065
  cat << 'END_OF_FILE_CONTENT' > "scripts/webmcp-feature-check.mjs"
1034
- import fs from 'fs/promises';
1035
- import path from 'path';
1036
- import { fileURLToPath } from 'url';
1037
- import { createRequire } from 'module';
1038
-
1039
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
1040
- const rootDir = path.resolve(__dirname, '..');
1041
- const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
1042
- const UPDATE_TOOL_NAME = 'update-section';
1043
- const SAVE_TOOL_NAME = 'save';
1044
-
1045
- function pageFilePathFromSlug(slug) {
1046
- return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1047
- }
1048
-
1049
- function adminUrlFromSlug(slug) {
1050
- return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1051
- }
1052
-
1053
- function isStringSchema(schema) {
1054
- if (!schema || typeof schema !== 'object') return false;
1055
- if (schema.type === 'string') return true;
1056
- if (Array.isArray(schema.anyOf)) {
1057
- return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1058
- }
1059
- return false;
1060
- }
1061
-
1062
- function findTopLevelStringField(sectionSchema) {
1063
- const properties = sectionSchema?.properties;
1064
- if (!properties || typeof properties !== 'object') return null;
1065
- const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1066
- for (const key of preferred) {
1067
- if (isStringSchema(properties[key])) return key;
1068
- }
1069
- for (const [key, value] of Object.entries(properties)) {
1070
- if (isStringSchema(value)) return key;
1071
- }
1072
- return null;
1073
- }
1074
-
1075
- async function loadPlaywright() {
1076
- const require = createRequire(import.meta.url);
1077
- try {
1078
- return require('playwright');
1079
- } catch (error) {
1080
- const message = error instanceof Error ? error.message : String(error);
1081
- throw new Error(
1082
- `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1083
- );
1084
- }
1085
- }
1086
-
1087
- async function readPageJson(slug) {
1088
- const pageFilePath = pageFilePathFromSlug(slug);
1089
- const raw = await fs.readFile(pageFilePath, 'utf8');
1090
- return { raw, json: JSON.parse(raw), pageFilePath };
1091
- }
1092
-
1093
- async function waitFor(predicate, timeoutMs, label) {
1094
- const startedAt = Date.now();
1095
- for (;;) {
1096
- const result = await predicate();
1097
- if (result) return result;
1098
- if (Date.now() - startedAt > timeoutMs) {
1099
- throw new Error(`Timed out while waiting for ${label}.`);
1100
- }
1101
- await new Promise((resolve) => setTimeout(resolve, 150));
1102
- }
1103
- }
1104
-
1105
- async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1106
- await waitFor(async () => {
1107
- const { json } = await readPageJson(slug);
1108
- const section = Array.isArray(json.sections)
1109
- ? json.sections.find((item) => item?.id === sectionId)
1110
- : null;
1111
- return section?.data?.[fieldKey] === expectedValue;
1112
- }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1113
- }
1114
-
1115
- async function ensureResponseOk(response, label) {
1116
- if (!response.ok) {
1117
- const text = await response.text();
1118
- throw new Error(`${label} failed with ${response.status}: ${text}`);
1119
- }
1120
- return response;
1121
- }
1122
-
1123
- async function fetchJson(relativePath, label) {
1124
- const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1125
- return response.json();
1126
- }
1127
-
1128
- async function selectTarget() {
1129
- const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1130
- const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1131
- ? process.env.WEBMCP_TARGET_SLUG.trim()
1132
- : null;
1133
-
1134
- const candidatePages = requestedSlug
1135
- ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1136
- : (siteIndex.pages ?? []);
1137
-
1138
- for (const pageEntry of candidatePages) {
1139
- if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1140
- const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1141
- const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1142
- const localInstances = Array.isArray(pageContract.sectionInstances)
1143
- ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1144
- : [];
1145
- const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1146
- // Manifest tools are page-level (`update-section`, `save`); targets come from the
1147
- // contract's local section instances + their schemas.
1148
- const updateTool = tools.find((tool) => tool?.name === UPDATE_TOOL_NAME);
1149
- const saveTool = tools.find((tool) => tool?.name === SAVE_TOOL_NAME);
1150
- if (!updateTool || !saveTool) continue;
1151
-
1152
- for (const targetInstance of localInstances) {
1153
- const sectionType = targetInstance?.type;
1154
- if (!targetInstance?.id || typeof sectionType !== 'string') continue;
1155
- const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1156
- if (!targetFieldKey) continue;
1157
- const pageState = await readPageJson(pageEntry.slug);
1158
- const section = Array.isArray(pageState.json.sections)
1159
- ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1160
- : null;
1161
- const originalValue = section?.data?.[targetFieldKey];
1162
- if (typeof originalValue !== 'string') continue;
1163
-
1164
- return {
1165
- slug: pageEntry.slug,
1166
- manifestHref: pageEntry.manifestHref,
1167
- contractHref: pageEntry.contractHref,
1168
- toolName: UPDATE_TOOL_NAME,
1169
- saveToolName: SAVE_TOOL_NAME,
1170
- sectionId: targetInstance.id,
1171
- sectionType,
1172
- fieldKey: targetFieldKey,
1173
- originalValue,
1174
- originalState: pageState,
1175
- };
1176
- }
1177
- }
1178
-
1179
- throw new Error(
1180
- requestedSlug
1181
- ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1182
- : 'No valid WebMCP verification target found in manifest index.'
1183
- );
1184
- }
1185
-
1186
- async function main() {
1187
- const { chromium } = await loadPlaywright();
1188
- const target = await selectTarget();
1189
- const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1190
- const browser = await chromium.launch({ headless: true });
1191
- const page = await browser.newPage();
1192
- const consoleEvents = [];
1193
- let mutationApplied = false;
1194
-
1195
- page.on('console', (message) => {
1196
- if (message.type() === 'error' || message.type() === 'warning') {
1197
- consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1198
- }
1199
- });
1200
- page.on('pageerror', (error) => {
1201
- consoleEvents.push(`[pageerror] ${error.message}`);
1202
- });
1203
-
1204
- /** update-section mutates the Studio draft only; save persists. Returns raw update result. */
1205
- const runUpdateThenSave = async (value) => {
1206
- const rawUpdate = await page.evaluate(
1207
- async ({ toolName, slug, sectionId, sectionType, fieldKey, value: nextValue }) => {
1208
- const runtime = document.modelContextProtocol;
1209
- if (!runtime?.executeTool) {
1210
- throw new Error('document.modelContextProtocol.executeTool is unavailable.');
1211
- }
1212
- return runtime.executeTool(
1213
- toolName,
1214
- JSON.stringify({ slug, sectionId, sectionType, fieldKey, value: nextValue })
1215
- );
1216
- },
1217
- {
1218
- toolName: target.toolName,
1219
- slug: target.slug,
1220
- sectionId: target.sectionId,
1221
- sectionType: target.sectionType,
1222
- fieldKey: target.fieldKey,
1223
- value,
1224
- }
1225
- );
1226
- const parsedUpdate = JSON.parse(rawUpdate);
1227
- if (parsedUpdate?.isError) {
1228
- throw new Error(`WebMCP ${target.toolName} returned an error: ${rawUpdate}`);
1229
- }
1230
- const rawSave = await page.evaluate(
1231
- async ({ saveToolName }) => document.modelContextProtocol.executeTool(saveToolName, '{}'),
1232
- { saveToolName: target.saveToolName }
1233
- );
1234
- const parsedSave = JSON.parse(rawSave);
1235
- if (parsedSave?.isError) {
1236
- throw new Error(`WebMCP ${target.saveToolName} returned an error: ${rawSave}`);
1237
- }
1238
- return rawUpdate;
1239
- };
1240
-
1241
- const restoreOriginal = async () => {
1242
- try {
1243
- await runUpdateThenSave(target.originalValue);
1244
- await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1245
- } catch {
1246
- await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1247
- }
1248
- };
1249
-
1250
- try {
1251
- const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1252
- if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1253
- throw new Error(`Manifest does not expose ${target.toolName}.`);
1254
- }
1255
-
1256
- const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
1257
- if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1258
- throw new Error(`Page contract does not expose ${target.toolName}.`);
1259
- }
1260
-
1261
- await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1262
-
1263
- try {
1264
- await page.waitForFunction(
1265
- ({ manifestHref, contractHref }) => {
1266
- const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1267
- const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1268
- return manifestLink?.getAttribute('href') === manifestHref
1269
- && contractLink?.getAttribute('href') === contractHref;
1270
- },
1271
- { manifestHref: target.manifestHref, contractHref: target.contractHref },
1272
- { timeout: 10_000 }
1273
- );
1274
- } catch (error) {
1275
- const diagnostics = await page.evaluate(() => ({
1276
- head: document.head.innerHTML,
1277
- bodyText: document.body.innerText,
1278
- }));
1279
- throw new Error(
1280
- [
1281
- error instanceof Error ? error.message : String(error),
1282
- `head=${diagnostics.head}`,
1283
- `body=${diagnostics.bodyText}`,
1284
- ...consoleEvents,
1285
- ].join('\n')
1286
- );
1287
- }
1288
-
1289
- const toolNames = await page.evaluate(() => {
1290
- const runtime = document.modelContextProtocol;
1291
- return runtime?.listTools?.().map((tool) => tool.name) ?? [];
1292
- });
1293
- for (const required of [target.toolName, target.saveToolName]) {
1294
- if (!toolNames.includes(required)) {
1295
- throw new Error(`Runtime did not register ${required}. Found: ${toolNames.join(', ')}`);
1296
- }
1297
- }
1298
-
1299
- mutationApplied = true;
1300
- await runUpdateThenSave(nextValue);
1301
- await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1302
- await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1303
-
1304
- console.log(
1305
- JSON.stringify({
1306
- ok: true,
1307
- slug: target.slug,
1308
- manifestHref: target.manifestHref,
1309
- contractHref: target.contractHref,
1310
- toolName: target.toolName,
1311
- saveToolName: target.saveToolName,
1312
- sectionId: target.sectionId,
1313
- sectionType: target.sectionType,
1314
- fieldKey: target.fieldKey,
1315
- toolNames,
1316
- })
1317
- );
1318
- } finally {
1319
- if (mutationApplied) {
1320
- await restoreOriginal();
1321
- }
1322
- await browser.close();
1323
- }
1324
- }
1325
-
1326
- main().catch((error) => {
1327
- console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1328
- process.exit(1);
1329
- });
1066
+ import fs from 'fs/promises';
1067
+ import path from 'path';
1068
+ import { fileURLToPath } from 'url';
1069
+ import { createRequire } from 'module';
1070
+
1071
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1072
+ const rootDir = path.resolve(__dirname, '..');
1073
+ const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
1074
+ const UPDATE_TOOL_NAME = 'update-section';
1075
+ const SAVE_TOOL_NAME = 'save';
1076
+
1077
+ function pageFilePathFromSlug(slug) {
1078
+ return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1079
+ }
1080
+
1081
+ function adminUrlFromSlug(slug) {
1082
+ return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1083
+ }
1084
+
1085
+ function isStringSchema(schema) {
1086
+ if (!schema || typeof schema !== 'object') return false;
1087
+ if (schema.type === 'string') return true;
1088
+ if (Array.isArray(schema.anyOf)) {
1089
+ return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1090
+ }
1091
+ return false;
1092
+ }
1093
+
1094
+ function findTopLevelStringField(sectionSchema) {
1095
+ const properties = sectionSchema?.properties;
1096
+ if (!properties || typeof properties !== 'object') return null;
1097
+ const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1098
+ for (const key of preferred) {
1099
+ if (isStringSchema(properties[key])) return key;
1100
+ }
1101
+ for (const [key, value] of Object.entries(properties)) {
1102
+ if (isStringSchema(value)) return key;
1103
+ }
1104
+ return null;
1105
+ }
1106
+
1107
+ async function loadPlaywright() {
1108
+ const require = createRequire(import.meta.url);
1109
+ try {
1110
+ return require('playwright');
1111
+ } catch (error) {
1112
+ const message = error instanceof Error ? error.message : String(error);
1113
+ throw new Error(
1114
+ `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1115
+ );
1116
+ }
1117
+ }
1118
+
1119
+ async function readPageJson(slug) {
1120
+ const pageFilePath = pageFilePathFromSlug(slug);
1121
+ const raw = await fs.readFile(pageFilePath, 'utf8');
1122
+ return { raw, json: JSON.parse(raw), pageFilePath };
1123
+ }
1124
+
1125
+ async function waitFor(predicate, timeoutMs, label) {
1126
+ const startedAt = Date.now();
1127
+ for (;;) {
1128
+ const result = await predicate();
1129
+ if (result) return result;
1130
+ if (Date.now() - startedAt > timeoutMs) {
1131
+ throw new Error(`Timed out while waiting for ${label}.`);
1132
+ }
1133
+ await new Promise((resolve) => setTimeout(resolve, 150));
1134
+ }
1135
+ }
1136
+
1137
+ async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1138
+ await waitFor(async () => {
1139
+ const { json } = await readPageJson(slug);
1140
+ const section = Array.isArray(json.sections)
1141
+ ? json.sections.find((item) => item?.id === sectionId)
1142
+ : null;
1143
+ return section?.data?.[fieldKey] === expectedValue;
1144
+ }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1145
+ }
1146
+
1147
+ async function ensureResponseOk(response, label) {
1148
+ if (!response.ok) {
1149
+ const text = await response.text();
1150
+ throw new Error(`${label} failed with ${response.status}: ${text}`);
1151
+ }
1152
+ return response;
1153
+ }
1154
+
1155
+ async function fetchJson(relativePath, label) {
1156
+ const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1157
+ return response.json();
1158
+ }
1159
+
1160
+ async function selectTarget() {
1161
+ const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1162
+ const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1163
+ ? process.env.WEBMCP_TARGET_SLUG.trim()
1164
+ : null;
1165
+
1166
+ const candidatePages = requestedSlug
1167
+ ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1168
+ : (siteIndex.pages ?? []);
1169
+
1170
+ for (const pageEntry of candidatePages) {
1171
+ if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1172
+ const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1173
+ const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1174
+ const localInstances = Array.isArray(pageContract.sectionInstances)
1175
+ ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1176
+ : [];
1177
+ const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1178
+ // Manifest tools are page-level (`update-section`, `save`); targets come from the
1179
+ // contract's local section instances + their schemas.
1180
+ const updateTool = tools.find((tool) => tool?.name === UPDATE_TOOL_NAME);
1181
+ const saveTool = tools.find((tool) => tool?.name === SAVE_TOOL_NAME);
1182
+ if (!updateTool || !saveTool) continue;
1183
+
1184
+ for (const targetInstance of localInstances) {
1185
+ const sectionType = targetInstance?.type;
1186
+ if (!targetInstance?.id || typeof sectionType !== 'string') continue;
1187
+ const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1188
+ if (!targetFieldKey) continue;
1189
+ const pageState = await readPageJson(pageEntry.slug);
1190
+ const section = Array.isArray(pageState.json.sections)
1191
+ ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1192
+ : null;
1193
+ const originalValue = section?.data?.[targetFieldKey];
1194
+ if (typeof originalValue !== 'string') continue;
1195
+
1196
+ return {
1197
+ slug: pageEntry.slug,
1198
+ manifestHref: pageEntry.manifestHref,
1199
+ contractHref: pageEntry.contractHref,
1200
+ toolName: UPDATE_TOOL_NAME,
1201
+ saveToolName: SAVE_TOOL_NAME,
1202
+ sectionId: targetInstance.id,
1203
+ sectionType,
1204
+ fieldKey: targetFieldKey,
1205
+ originalValue,
1206
+ originalState: pageState,
1207
+ };
1208
+ }
1209
+ }
1210
+
1211
+ throw new Error(
1212
+ requestedSlug
1213
+ ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1214
+ : 'No valid WebMCP verification target found in manifest index.'
1215
+ );
1216
+ }
1217
+
1218
+ async function main() {
1219
+ const { chromium } = await loadPlaywright();
1220
+ const target = await selectTarget();
1221
+ const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1222
+ const browser = await chromium.launch({ headless: true });
1223
+ const page = await browser.newPage();
1224
+ const consoleEvents = [];
1225
+ let mutationApplied = false;
1226
+
1227
+ page.on('console', (message) => {
1228
+ if (message.type() === 'error' || message.type() === 'warning') {
1229
+ consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1230
+ }
1231
+ });
1232
+ page.on('pageerror', (error) => {
1233
+ consoleEvents.push(`[pageerror] ${error.message}`);
1234
+ });
1235
+
1236
+ /** update-section mutates the Studio draft only; save persists. Returns raw update result. */
1237
+ const runUpdateThenSave = async (value) => {
1238
+ const rawUpdate = await page.evaluate(
1239
+ async ({ toolName, slug, sectionId, sectionType, fieldKey, value: nextValue }) => {
1240
+ const args = { slug, sectionId, sectionType, fieldKey, value: nextValue };
1241
+ const native = document.modelContext;
1242
+ const protocol = document.modelContextProtocol;
1243
+ // Native Chrome WebMCP: executeTool(toolObject, args) — the tool object
1244
+ // comes from getTools(). Args format: plain object since Chrome 155;
1245
+ // before that the native expects a JSON string (verified on Chrome 153).
1246
+ if (native && typeof native.executeTool === 'function') {
1247
+ const tools = await native.getTools();
1248
+ const tool = tools.find((entry) => entry.name === toolName);
1249
+ if (!tool) {
1250
+ throw new Error(`Tool "${toolName}" not found in native getTools().`);
1251
+ }
1252
+ const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
1253
+ const nativeArgs = chromeMajor >= 155 ? args : JSON.stringify(args);
1254
+ const result = await native.executeTool(tool, nativeArgs);
1255
+ if (result === null) {
1256
+ throw new Error(`Tool "${toolName}" returned null (navigation).`);
1257
+ }
1258
+ return typeof result === 'string' ? result : JSON.stringify(result);
1259
+ }
1260
+ if (protocol && typeof protocol.executeTool === 'function') {
1261
+ return protocol.executeTool(toolName, JSON.stringify(args));
1262
+ }
1263
+ throw new Error(
1264
+ 'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
1265
+ );
1266
+ },
1267
+ {
1268
+ toolName: target.toolName,
1269
+ slug: target.slug,
1270
+ sectionId: target.sectionId,
1271
+ sectionType: target.sectionType,
1272
+ fieldKey: target.fieldKey,
1273
+ value,
1274
+ }
1275
+ );
1276
+ const parsedUpdate = JSON.parse(rawUpdate);
1277
+ if (parsedUpdate?.isError) {
1278
+ throw new Error(`WebMCP ${target.toolName} returned an error: ${rawUpdate}`);
1279
+ }
1280
+ const rawSave = await page.evaluate(
1281
+ async ({ saveToolName }) => {
1282
+ const native = document.modelContext;
1283
+ const protocol = document.modelContextProtocol;
1284
+ if (native && typeof native.executeTool === 'function') {
1285
+ const tools = await native.getTools();
1286
+ const tool = tools.find((entry) => entry.name === saveToolName);
1287
+ if (!tool) {
1288
+ throw new Error(`Tool "${saveToolName}" not found in native getTools().`);
1289
+ }
1290
+ const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
1291
+ const nativeArgs = chromeMajor >= 155 ? {} : '{}';
1292
+ const result = await native.executeTool(tool, nativeArgs);
1293
+ if (result === null) {
1294
+ throw new Error(`Tool "${saveToolName}" returned null (navigation).`);
1295
+ }
1296
+ return typeof result === 'string' ? result : JSON.stringify(result);
1297
+ }
1298
+ if (protocol && typeof protocol.executeTool === 'function') {
1299
+ return protocol.executeTool(saveToolName, '{}');
1300
+ }
1301
+ throw new Error(
1302
+ 'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
1303
+ );
1304
+ },
1305
+ { saveToolName: target.saveToolName }
1306
+ );
1307
+ const parsedSave = JSON.parse(rawSave);
1308
+ if (parsedSave?.isError) {
1309
+ throw new Error(`WebMCP ${target.saveToolName} returned an error: ${rawSave}`);
1310
+ }
1311
+ return rawUpdate;
1312
+ };
1313
+
1314
+ const restoreOriginal = async () => {
1315
+ try {
1316
+ await runUpdateThenSave(target.originalValue);
1317
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1318
+ } catch {
1319
+ await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1320
+ }
1321
+ };
1322
+
1323
+ try {
1324
+ const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1325
+ if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1326
+ throw new Error(`Manifest does not expose ${target.toolName}.`);
1327
+ }
1328
+
1329
+ const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
1330
+ if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1331
+ throw new Error(`Page contract does not expose ${target.toolName}.`);
1332
+ }
1333
+
1334
+ await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1335
+
1336
+ try {
1337
+ await page.waitForFunction(
1338
+ ({ manifestHref, contractHref }) => {
1339
+ const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1340
+ const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1341
+ return manifestLink?.getAttribute('href') === manifestHref
1342
+ && contractLink?.getAttribute('href') === contractHref;
1343
+ },
1344
+ { manifestHref: target.manifestHref, contractHref: target.contractHref },
1345
+ { timeout: 10_000 }
1346
+ );
1347
+ } catch (error) {
1348
+ const diagnostics = await page.evaluate(() => ({
1349
+ head: document.head.innerHTML,
1350
+ bodyText: document.body.innerText,
1351
+ }));
1352
+ throw new Error(
1353
+ [
1354
+ error instanceof Error ? error.message : String(error),
1355
+ `head=${diagnostics.head}`,
1356
+ `body=${diagnostics.bodyText}`,
1357
+ ...consoleEvents,
1358
+ ].join('\n')
1359
+ );
1360
+ }
1361
+
1362
+ const toolNames = await page.evaluate(async () => {
1363
+ const native = document.modelContext;
1364
+ const protocol = document.modelContextProtocol;
1365
+ if (native && typeof native.getTools === 'function') {
1366
+ const tools = await native.getTools();
1367
+ return tools.map((tool) => tool.name);
1368
+ }
1369
+ if (protocol && typeof protocol.listTools === 'function') {
1370
+ return protocol.listTools().map((tool) => tool.name);
1371
+ }
1372
+ return [];
1373
+ });
1374
+ for (const required of [target.toolName, target.saveToolName]) {
1375
+ if (!toolNames.includes(required)) {
1376
+ throw new Error(`Runtime did not register ${required}. Found: ${toolNames.join(', ')}`);
1377
+ }
1378
+ }
1379
+
1380
+ mutationApplied = true;
1381
+ await runUpdateThenSave(nextValue);
1382
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1383
+ await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1384
+
1385
+ console.log(
1386
+ JSON.stringify({
1387
+ ok: true,
1388
+ slug: target.slug,
1389
+ manifestHref: target.manifestHref,
1390
+ contractHref: target.contractHref,
1391
+ toolName: target.toolName,
1392
+ saveToolName: target.saveToolName,
1393
+ sectionId: target.sectionId,
1394
+ sectionType: target.sectionType,
1395
+ fieldKey: target.fieldKey,
1396
+ toolNames,
1397
+ })
1398
+ );
1399
+ } finally {
1400
+ if (mutationApplied) {
1401
+ await restoreOriginal();
1402
+ }
1403
+ await browser.close();
1404
+ }
1405
+ }
1406
+
1407
+ main().catch((error) => {
1408
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1409
+ process.exit(1);
1410
+ });
1330
1411
 
1331
1412
  END_OF_FILE_CONTENT
1332
1413
  mkdir -p "src"
@@ -1396,6 +1477,43 @@ import { LibroSchema, LibriCollectionSchema } from './schema';
1396
1477
  export type Libro = z.infer<typeof LibroSchema>;
1397
1478
  export type LibriCollection = z.infer<typeof LibriCollectionSchema>;
1398
1479
 
1480
+ END_OF_FILE_CONTENT
1481
+ mkdir -p "src/collections/prodotti"
1482
+ echo "Creating src/collections/prodotti/index.ts..."
1483
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/index.ts"
1484
+ export { ProdottoSchema, ProdottiCollectionSchema } from './schema';
1485
+ export type { Prodotto, ProdottiCollection } from './types';
1486
+
1487
+ END_OF_FILE_CONTENT
1488
+ echo "Creating src/collections/prodotti/schema.ts..."
1489
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/schema.ts"
1490
+ import { z } from 'zod';
1491
+ import { BaseCollectionItem } from '@olonjs/core';
1492
+
1493
+ /**
1494
+ * Demo collection whose source of truth is EXTERNAL (see src/sources/catalogo.ts).
1495
+ * Same Zod item shape feeds the human list rendering (ui:* descriptors) and the
1496
+ * agent contracts (JSON Schema emitted from this schema).
1497
+ */
1498
+ export const ProdottoSchema = BaseCollectionItem.extend({
1499
+ title: z.string().describe('ui:text'),
1500
+ price: z.number().describe('ui:number'),
1501
+ currency: z.string().default('EUR').describe('ui:text'),
1502
+ description: z.string().optional().describe('ui:textarea'),
1503
+ image: z.string().optional().describe('ui:text'),
1504
+ });
1505
+
1506
+ export const ProdottiCollectionSchema = z.record(z.string(), ProdottoSchema);
1507
+
1508
+ END_OF_FILE_CONTENT
1509
+ echo "Creating src/collections/prodotti/types.ts..."
1510
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/types.ts"
1511
+ import { z } from 'zod';
1512
+ import { ProdottoSchema, ProdottiCollectionSchema } from './schema';
1513
+
1514
+ export type Prodotto = z.infer<typeof ProdottoSchema>;
1515
+ export type ProdottiCollection = z.infer<typeof ProdottiCollectionSchema>;
1516
+
1399
1517
  END_OF_FILE_CONTENT
1400
1518
  mkdir -p "src/components"
1401
1519
  mkdir -p "src/components/admin"
@@ -2661,6 +2779,183 @@ import { HeaderSchema, HeaderSettingsSchema } from './schema';
2661
2779
  export type HeaderData = z.infer<typeof HeaderSchema>;
2662
2780
  export type HeaderSettings = z.infer<typeof HeaderSettingsSchema>;
2663
2781
 
2782
+ END_OF_FILE_CONTENT
2783
+ mkdir -p "src/components/prodotti-list"
2784
+ echo "Creating src/components/prodotti-list/View.tsx..."
2785
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/View.tsx"
2786
+ import type { Prodotto } from '@/collections/prodotti';
2787
+ import type { ProdottiListData, ProdottiListSettings } from './types';
2788
+
2789
+ type ProdottiListViewProps = {
2790
+ data: ProdottiListData;
2791
+ settings?: ProdottiListSettings;
2792
+ };
2793
+
2794
+ function formatPrice(prodotto: Prodotto): string {
2795
+ const amount = (prodotto.price / 100).toFixed(2);
2796
+ return `${amount} ${prodotto.currency ?? 'EUR'}`;
2797
+ }
2798
+
2799
+ function toProdotti(items: ProdottiListData['items']): Prodotto[] {
2800
+ return Object.values(items ?? {}).sort((a, b) => a.title.localeCompare(b.title));
2801
+ }
2802
+
2803
+ /**
2804
+ * RSC-safe catalog list — data comes from a PROJECTED external source
2805
+ * (src/sources/catalogo.ts). The View itself never fetches (CIP).
2806
+ */
2807
+ export function ProdottiListView({ data }: ProdottiListViewProps) {
2808
+ const prodotti = toProdotti(data.items);
2809
+
2810
+ return (
2811
+ <section className="bg-background px-6 py-16 text-foreground">
2812
+ <div className="mx-auto w-full max-w-5xl">
2813
+ {data.eyebrow ? (
2814
+ <p className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
2815
+ {data.eyebrow}
2816
+ </p>
2817
+ ) : null}
2818
+ <h1 data-jp-field="title" className="mt-2 text-3xl font-semibold">
2819
+ {data.title}
2820
+ </h1>
2821
+ {data.description ? (
2822
+ <p data-jp-field="description" className="mt-3 max-w-2xl text-muted-foreground">
2823
+ {data.description}
2824
+ </p>
2825
+ ) : null}
2826
+
2827
+ <ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
2828
+ {prodotti.map((prodotto) => (
2829
+ <li
2830
+ key={prodotto.id}
2831
+ data-jp-item-id={prodotto.id}
2832
+ data-jp-item-field="items"
2833
+ className="rounded-2xl border border-border bg-card p-6 shadow-sm"
2834
+ >
2835
+ <a
2836
+ href={`/prodotti/${prodotto.id}`}
2837
+ className="text-lg font-medium hover:underline"
2838
+ >
2839
+ {prodotto.title}
2840
+ </a>
2841
+ <p className="mt-2 text-sm text-muted-foreground">{formatPrice(prodotto)}</p>
2842
+ {prodotto.description ? (
2843
+ <p className="mt-3 text-sm text-muted-foreground">{prodotto.description}</p>
2844
+ ) : null}
2845
+ </li>
2846
+ ))}
2847
+ </ul>
2848
+
2849
+ {prodotti.length === 0 ? (
2850
+ <p className="mt-10 text-muted-foreground">No products available.</p>
2851
+ ) : null}
2852
+ </div>
2853
+ </section>
2854
+ );
2855
+ }
2856
+
2857
+ END_OF_FILE_CONTENT
2858
+ echo "Creating src/components/prodotti-list/index.ts..."
2859
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/index.ts"
2860
+ export { ProdottiListView } from './View';
2861
+ export { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
2862
+ export type { ProdottiListData, ProdottiListSettings } from './types';
2863
+
2864
+ END_OF_FILE_CONTENT
2865
+ echo "Creating src/components/prodotti-list/schema.ts..."
2866
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/schema.ts"
2867
+ import { z } from 'zod';
2868
+ import { BaseSectionData } from '@olonjs/core';
2869
+ import { ProdottoSchema } from '@/collections/prodotti';
2870
+
2871
+ export const ProdottiListSchema = BaseSectionData.extend({
2872
+ eyebrow: z.string().optional().describe('ui:text'),
2873
+ title: z.string().describe('ui:text'),
2874
+ description: z.string().optional().describe('ui:textarea'),
2875
+ items: z.record(z.string(), ProdottoSchema).describe('ui:collection-ref:catalogo'),
2876
+ });
2877
+
2878
+ export const ProdottiListSettingsSchema = z.object({});
2879
+
2880
+ END_OF_FILE_CONTENT
2881
+ echo "Creating src/components/prodotti-list/types.ts..."
2882
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/types.ts"
2883
+ import type { z } from 'zod';
2884
+ import type { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
2885
+
2886
+ export type ProdottiListData = z.infer<typeof ProdottiListSchema>;
2887
+ export type ProdottiListSettings = z.infer<typeof ProdottiListSettingsSchema>;
2888
+
2889
+ END_OF_FILE_CONTENT
2890
+ mkdir -p "src/components/prodotto-detail"
2891
+ echo "Creating src/components/prodotto-detail/View.tsx..."
2892
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/View.tsx"
2893
+ import type { ProdottoDetailData, ProdottoDetailSettings } from './types';
2894
+
2895
+ type ProdottoDetailViewProps = {
2896
+ data: ProdottoDetailData;
2897
+ settings?: ProdottoDetailSettings;
2898
+ };
2899
+
2900
+ function formatPrice(price: number, currency?: string): string {
2901
+ return `${(price / 100).toFixed(2)} ${currency ?? 'EUR'}`;
2902
+ }
2903
+
2904
+ /** RSC-safe product detail — resolved from `collection:current` (projected source). */
2905
+ export function ProdottoDetailView({ data }: ProdottoDetailViewProps) {
2906
+ const prodotto = data.item;
2907
+
2908
+ return (
2909
+ <main className="min-h-screen bg-background px-6 py-16 text-foreground">
2910
+ <article
2911
+ data-jp-field="item"
2912
+ data-jp-item-id={prodotto.id}
2913
+ data-jp-item-field="item"
2914
+ className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-card p-8 shadow-sm"
2915
+ >
2916
+ <a href="/prodotti" className="text-sm font-medium text-muted-foreground hover:text-foreground">
2917
+ {data.backLabel}
2918
+ </a>
2919
+ <h1 className="mt-10 text-3xl font-semibold">{prodotto.title}</h1>
2920
+ <p className="mt-3 text-lg">{formatPrice(prodotto.price, prodotto.currency)}</p>
2921
+ {prodotto.description ? (
2922
+ <p className="mt-6 text-muted-foreground">{prodotto.description}</p>
2923
+ ) : null}
2924
+ </article>
2925
+ </main>
2926
+ );
2927
+ }
2928
+
2929
+ END_OF_FILE_CONTENT
2930
+ echo "Creating src/components/prodotto-detail/index.ts..."
2931
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/index.ts"
2932
+ export { ProdottoDetailView } from './View';
2933
+ export { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
2934
+ export type { ProdottoDetailData, ProdottoDetailSettings } from './types';
2935
+
2936
+ END_OF_FILE_CONTENT
2937
+ echo "Creating src/components/prodotto-detail/schema.ts..."
2938
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/schema.ts"
2939
+ import { z } from 'zod';
2940
+ import { BaseSectionData } from '@olonjs/core';
2941
+ import { ProdottoSchema } from '@/collections/prodotti';
2942
+
2943
+ export const ProdottoDetailSchema = BaseSectionData.extend({
2944
+ item: ProdottoSchema.describe('ui:collection-ref'),
2945
+ backLabel: z.string().default('Torna al catalogo').describe('ui:text'),
2946
+ });
2947
+
2948
+ export const ProdottoDetailSettingsSchema = z.object({});
2949
+
2950
+ END_OF_FILE_CONTENT
2951
+ echo "Creating src/components/prodotto-detail/types.ts..."
2952
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/types.ts"
2953
+ import type { z } from 'zod';
2954
+ import type { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
2955
+
2956
+ export type ProdottoDetailData = z.infer<typeof ProdottoDetailSchema>;
2957
+ export type ProdottoDetailSettings = z.infer<typeof ProdottoDetailSettingsSchema>;
2958
+
2664
2959
  END_OF_FILE_CONTENT
2665
2960
  mkdir -p "src/components/webmcp"
2666
2961
  echo "Creating src/components/webmcp/WebMcpVisitorRuntime.tsx..."
@@ -3062,8 +3357,8 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
3062
3357
  "summary": "Fuga, destino e sogno si intrecciano in un romanzo sospeso tra reale e mitico."
3063
3358
  },
3064
3359
  "americanah": {
3065
- "id": "americanah",
3066
- "title": "Americanah",
3360
+ "id": "americana",
3361
+ "title": "Americana",
3067
3362
  "author": {
3068
3363
  "$ref": "../autori/autori.json#/chimamanda-ngozi-adichie"
3069
3364
  },
@@ -3134,6 +3429,10 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
3134
3429
  "label": "Get started",
3135
3430
  "href": "#Getstarted"
3136
3431
  },
3432
+ {
3433
+ "label": "Prodotti",
3434
+ "href": "/prodotti"
3435
+ },
3137
3436
  {
3138
3437
  "label": "GitHub",
3139
3438
  "href": "https://github.com/olonjs/core"
@@ -3388,31 +3687,30 @@ mkdir -p "src/data/pages"
3388
3687
  mkdir -p "src/data/pages/authors"
3389
3688
  echo "Creating src/data/pages/authors.json..."
3390
3689
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/authors.json"
3391
- {
3392
- "id": "authors-page",
3393
- "slug": "authors",
3394
- "meta": {
3395
- "title": "Authors",
3396
- "description": "Author directory powered by the autori collection."
3397
- },
3398
- "sections": [
3399
- {
3400
- "id": "authors-list-1",
3401
- "type": "authors-list",
3402
- "data": {
3403
- "anchorId": "authors",
3404
- "eyebrow": "Collection demo",
3405
- "title": "Authors",
3406
- "description": "A collection-backed directory of authors referenced by books.",
3407
- "items": {
3408
- "$ref": "../collections/autori/autori.json"
3409
- }
3410
- }
3411
- }
3412
- ],
3413
- "global-header": false
3414
- }
3415
-
3690
+ {
3691
+ "id": "authors-page",
3692
+ "slug": "authors",
3693
+ "meta": {
3694
+ "title": "Authors",
3695
+ "description": "Author directory powered by the autori collection."
3696
+ },
3697
+ "sections": [
3698
+ {
3699
+ "id": "authors-list-1",
3700
+ "type": "authors-list",
3701
+ "data": {
3702
+ "anchorId": "authors",
3703
+ "eyebrow": "Collection demo",
3704
+ "title": "Authors",
3705
+ "description": "A collection-backed directory of authors referenced by books.",
3706
+ "items": {
3707
+ "$ref": "../collections/autori/autori.json"
3708
+ }
3709
+ }
3710
+ }
3711
+ ],
3712
+ "global-header": false
3713
+ }
3416
3714
  END_OF_FILE_CONTENT
3417
3715
  mkdir -p "src/data/pages/authors/[authorId]"
3418
3716
  echo "Creating src/data/pages/authors/[authorId]/libri.json..."
@@ -3559,46 +3857,111 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri/[slug].json"
3559
3857
  "global-header": false
3560
3858
  }
3561
3859
 
3860
+ END_OF_FILE_CONTENT
3861
+ mkdir -p "src/data/pages/prodotti"
3862
+ echo "Creating src/data/pages/prodotti.json..."
3863
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti.json"
3864
+ {
3865
+ "id": "prodotti-page",
3866
+ "slug": "prodotti",
3867
+ "meta": {
3868
+ "title": "Prodotti",
3869
+ "description": "Catalogo proiettato da una sorgente esterna (demo @olonjs/sources)."
3870
+ },
3871
+ "sections": [
3872
+ {
3873
+ "id": "prodotti-list-1",
3874
+ "type": "prodotti-list",
3875
+ "data": {
3876
+ "anchorId": "catalogo-prodotti",
3877
+ "eyebrow": "Projected source demo",
3878
+ "title": "Prodotti",
3879
+ "description": "Catalogo letto da una sorgente esterna, validato con la sua Zod shape e proiettato nel contratto OlonJS.",
3880
+ "items": {
3881
+ "$ref": "../collections/catalogo/catalogo.json"
3882
+ }
3883
+ }
3884
+ }
3885
+ ],
3886
+ "global-header": false
3887
+ }
3888
+
3889
+ END_OF_FILE_CONTENT
3890
+ echo "Creating src/data/pages/prodotti/[slug].json..."
3891
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti/[slug].json"
3892
+ {
3893
+ "id": "prodotto-detail-page",
3894
+ "slug": "prodotti/[slug]",
3895
+ "meta": {
3896
+ "title": "Dettaglio prodotto",
3897
+ "description": "Pagina dinamica alimentata dalla sorgente esterna catalogo."
3898
+ },
3899
+ "sections": [
3900
+ {
3901
+ "id": "prodotto-detail-1",
3902
+ "type": "prodotto-detail",
3903
+ "data": {
3904
+ "anchorId": "dettaglio-prodotto",
3905
+ "item": {
3906
+ "$ref": "collection:current"
3907
+ },
3908
+ "backLabel": "← Torna al catalogo"
3909
+ }
3910
+ }
3911
+ ],
3912
+ "collection": {
3913
+ "source": "catalogo",
3914
+ "paramKey": "slug"
3915
+ },
3916
+ "global-header": false
3917
+ }
3918
+
3562
3919
  END_OF_FILE_CONTENT
3563
3920
  # SKIP: src/index.css is binary and cannot be embedded as text.
3564
3921
  mkdir -p "src/lib"
3565
3922
  echo "Creating src/lib/CollectionRegistry.ts..."
3566
3923
  cat << 'END_OF_FILE_CONTENT' > "src/lib/CollectionRegistry.ts"
3567
- import { AutoriCollectionSchema } from '@/collections/autori';
3568
- import { LibriCollectionSchema } from '@/collections/libri';
3569
-
3570
- export const CollectionRegistry = {
3571
- autori: AutoriCollectionSchema,
3572
- libri: LibriCollectionSchema,
3573
- } as const;
3574
-
3575
- export type CollectionType = keyof typeof CollectionRegistry;
3924
+ import { AutoriCollectionSchema } from '@/collections/autori';
3925
+ import { LibriCollectionSchema } from '@/collections/libri';
3926
+ import { ProdottiCollectionSchema } from '@/collections/prodotti';
3927
+
3928
+ export const CollectionRegistry = {
3929
+ autori: AutoriCollectionSchema,
3930
+ libri: LibriCollectionSchema,
3931
+ catalogo: ProdottiCollectionSchema,
3932
+ } as const;
3933
+
3934
+ export type CollectionType = keyof typeof CollectionRegistry;
3576
3935
 
3577
3936
  END_OF_FILE_CONTENT
3578
3937
  echo "Creating src/lib/ComponentRegistry.tsx..."
3579
3938
  cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
3580
- import type React from 'react';
3581
- import type { SectionType } from '@/types';
3582
- import type { SectionComponentPropsMap } from '@/types';
3583
- import { AuthorsListView } from '@/components/authors-list';
3584
- import { BookDetailView } from '@/components/book-detail';
3585
- import { BooksListView } from '@/components/books-list';
3586
- import { EmptyTenantView } from '@/components/empty-tenant';
3587
- import { FooterView } from '@/components/footer';
3588
- import { FormDemoView } from '@/components/form-demo';
3589
- import { HeaderView } from '@/components/header';
3590
-
3591
- export const ComponentRegistry: {
3592
- [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
3593
- } = {
3594
- 'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
3595
- 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
3596
- 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
3597
- 'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
3598
- footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
3599
- 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
3600
- header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
3601
- };
3939
+ import type React from 'react';
3940
+ import type { SectionType } from '@/types';
3941
+ import type { SectionComponentPropsMap } from '@/types';
3942
+ import { AuthorsListView } from '@/components/authors-list';
3943
+ import { BookDetailView } from '@/components/book-detail';
3944
+ import { BooksListView } from '@/components/books-list';
3945
+ import { EmptyTenantView } from '@/components/empty-tenant';
3946
+ import { FooterView } from '@/components/footer';
3947
+ import { FormDemoView } from '@/components/form-demo';
3948
+ import { HeaderView } from '@/components/header';
3949
+ import { ProdottoDetailView } from '@/components/prodotto-detail';
3950
+ import { ProdottiListView } from '@/components/prodotti-list';
3951
+
3952
+ export const ComponentRegistry: {
3953
+ [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
3954
+ } = {
3955
+ 'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
3956
+ 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
3957
+ 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
3958
+ 'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
3959
+ footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
3960
+ 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
3961
+ header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
3962
+ 'prodotti-list': ProdottiListView as React.FC<SectionComponentPropsMap['prodotti-list']>,
3963
+ 'prodotto-detail': ProdottoDetailView as React.FC<SectionComponentPropsMap['prodotto-detail']>,
3964
+ };
3602
3965
 
3603
3966
  END_OF_FILE_CONTENT
3604
3967
  echo "Creating src/lib/IconResolver.tsx..."
@@ -5152,13 +5515,57 @@ END_OF_FILE_CONTENT
5152
5515
  echo "Creating src/lib/loaders/loadPublicPageBundleForRequest.ts..."
5153
5516
  cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadPublicPageBundleForRequest.ts"
5154
5517
  import type { PublicPageContentBundle } from '@olonjs/next/server';
5518
+ import { resolvePageMatchFromRegistry } from '@olonjs/core';
5519
+ import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
5155
5520
  import {
5156
5521
  buildServerApiCandidates,
5157
5522
  type ServerCloudBootSource,
5158
5523
  } from '@/lib/env/serverCloudPolicy';
5524
+ import { collectionSources } from '@/sources';
5159
5525
  import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
5160
5526
  import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
5161
5527
 
5528
+ /**
5529
+ * Project external collection sources referenced by the requested slug's page
5530
+ * (ADR-0018). Without this, page JSON endpoints would expose unresolved $refs.
5531
+ */
5532
+ export async function withProjectedSources(
5533
+ bundle: PublicPageContentBundle,
5534
+ slug: string,
5535
+ appRoot?: string,
5536
+ ): Promise<PublicPageContentBundle> {
5537
+ const match = resolvePageMatchFromRegistry(
5538
+ bundle.pages as never,
5539
+ slug
5540
+ );
5541
+ if (!match) return bundle;
5542
+
5543
+ const needed = collectCollectionSources(match.page).filter(
5544
+ (name) => name in collectionSources
5545
+ );
5546
+ if (needed.length === 0) return bundle;
5547
+
5548
+ const resolved = await resolveCollectionSources({
5549
+ sources: collectionSources as never,
5550
+ needed,
5551
+ params: match.params,
5552
+ });
5553
+
5554
+ return {
5555
+ ...bundle,
5556
+ collections: {
5557
+ ...(bundle.collections ?? {}),
5558
+ ...(resolved.collections as NonNullable<PublicPageContentBundle['collections']>),
5559
+ },
5560
+ collectionSchemas: {
5561
+ ...(bundle.collectionSchemas ?? {}),
5562
+ ...(resolved.collectionSchemas as NonNullable<
5563
+ PublicPageContentBundle['collectionSchemas']
5564
+ >),
5565
+ },
5566
+ };
5567
+ }
5568
+
5162
5569
  export type LoadPublicPageBundleForRequestInput = {
5163
5570
  bootSource: ServerCloudBootSource;
5164
5571
  slug: string;
@@ -5190,18 +5597,76 @@ export async function loadPublicPageBundleForRequest(
5190
5597
  if (!apiUrl || !apiKey) {
5191
5598
  throw new Error('Live public page JSON requires cloud API URL and key');
5192
5599
  }
5193
- return loadLivePublicPageBundle({
5600
+ const liveBundle = await loadLivePublicPageBundle({
5194
5601
  slug: input.slug,
5195
5602
  apiBases: buildServerApiCandidates(apiUrl),
5196
5603
  apiKey,
5197
5604
  appRoot,
5198
5605
  fetchImpl: input.fetchImpl,
5199
5606
  });
5607
+ return withProjectedSources(liveBundle, input.slug, appRoot);
5200
5608
  }
5201
5609
 
5202
- return loadLocalPublicPageBundle(appRoot);
5610
+ const localBundle = await loadLocalPublicPageBundle(appRoot);
5611
+ return withProjectedSources(localBundle, input.slug, appRoot);
5203
5612
  }
5204
5613
 
5614
+ END_OF_FILE_CONTENT
5615
+ echo "Creating src/lib/loaders/loadVisitorPageBundle.ts..."
5616
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadVisitorPageBundle.ts"
5617
+ import type { JsonPagesConfig, PageConfig } from '@olonjs/core';
5618
+ import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
5619
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
5620
+ import { collectionSources } from '@/sources';
5621
+ import { getFileCollections } from './getFileCollections';
5622
+
5623
+ export interface VisitorPageBundle {
5624
+ collections: NonNullable<JsonPagesConfig['collections']>;
5625
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
5626
+ /** Provenance of the projected sources resolved for this route. */
5627
+ sourceProvenance: Record<string, { fetchedAt: string; readOnly: boolean }>;
5628
+ }
5629
+
5630
+ /**
5631
+ * Load the per-route content bundle: file-backed collections plus the external
5632
+ * sources this page needs (projected, validated, read-only).
5633
+ *
5634
+ * Sources are resolved ONLY when referenced by the page (ADR-0018 / spec
5635
+ * external-collection-sources). Source names not registered as external
5636
+ * sources are file-backed and skipped here.
5637
+ */
5638
+ export async function loadVisitorPageBundle(input: {
5639
+ page: PageConfig;
5640
+ params: Record<string, string>;
5641
+ appRoot?: string;
5642
+ }): Promise<VisitorPageBundle> {
5643
+ const fileCollections = getFileCollections(input.appRoot) as NonNullable<
5644
+ JsonPagesConfig['collections']
5645
+ >;
5646
+
5647
+ const needed = collectCollectionSources(input.page).filter(
5648
+ (name) => name in collectionSources
5649
+ );
5650
+
5651
+ const resolved = await resolveCollectionSources({
5652
+ sources: collectionSources as never,
5653
+ needed,
5654
+ params: input.params,
5655
+ });
5656
+
5657
+ return {
5658
+ collections: {
5659
+ ...fileCollections,
5660
+ ...(resolved.collections as NonNullable<JsonPagesConfig['collections']>),
5661
+ },
5662
+ collectionSchemas: {
5663
+ ...(CollectionRegistry as JsonPagesConfig['collectionSchemas']),
5664
+ ...(resolved.collectionSchemas as JsonPagesConfig['collectionSchemas']),
5665
+ },
5666
+ sourceProvenance: resolved.provenance,
5667
+ };
5668
+ }
5669
+
5205
5670
  END_OF_FILE_CONTENT
5206
5671
  echo "Creating src/lib/loaders/publishedContent.test.ts..."
5207
5672
  cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/publishedContent.test.ts"
@@ -5347,45 +5812,49 @@ export function resolveVisitorShell(page: PageConfig, site: SiteConfig): Visitor
5347
5812
  END_OF_FILE_CONTENT
5348
5813
  echo "Creating src/lib/schemas.ts..."
5349
5814
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
5350
- import { AuthorsListSchema } from '@/components/authors-list';
5351
- import { BookDetailSchema } from '@/components/book-detail';
5352
- import { BooksListSchema } from '@/components/books-list';
5353
- import { EmptyTenantSchema } from '@/components/empty-tenant';
5354
- import { FooterSchema } from '@/components/footer';
5355
- import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
5356
- import { HeaderSchema } from '@/components/header';
5357
-
5358
- export const SECTION_SCHEMAS = {
5359
- 'authors-list': AuthorsListSchema,
5360
- 'book-detail': BookDetailSchema,
5361
- 'books-list': BooksListSchema,
5362
- 'empty-tenant': EmptyTenantSchema,
5363
- footer: FooterSchema,
5364
- 'form-demo': FormDemoSchema,
5365
- header: HeaderSchema,
5366
- } as const;
5367
-
5368
- /**
5369
- * Registry of per-section-type submission schemas. Keys MUST match a key of
5370
- * SECTION_SCHEMAS. A section type appearing here is declaring itself as
5371
- * MCP-submittable: the Zod schema describes the payload accepted by the form.
5372
- *
5373
- * See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
5374
- */
5375
- export const SECTION_SUBMISSION_SCHEMAS = {
5376
- 'form-demo': FormDemoSubmissionSchema,
5377
- } as const;
5378
-
5379
- export type SectionType = keyof typeof SECTION_SCHEMAS;
5380
-
5381
- export {
5382
- BaseSectionData,
5383
- BaseArrayItem,
5384
- BaseCollectionItem,
5385
- BaseSectionSettingsSchema,
5386
- CtaSchema,
5387
- ImageSelectionSchema,
5388
- } from '@olonjs/core';
5815
+ import { AuthorsListSchema } from '@/components/authors-list';
5816
+ import { BookDetailSchema } from '@/components/book-detail';
5817
+ import { BooksListSchema } from '@/components/books-list';
5818
+ import { EmptyTenantSchema } from '@/components/empty-tenant';
5819
+ import { FooterSchema } from '@/components/footer';
5820
+ import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
5821
+ import { HeaderSchema } from '@/components/header';
5822
+ import { ProdottoDetailSchema } from '@/components/prodotto-detail';
5823
+ import { ProdottiListSchema } from '@/components/prodotti-list';
5824
+
5825
+ export const SECTION_SCHEMAS = {
5826
+ 'authors-list': AuthorsListSchema,
5827
+ 'book-detail': BookDetailSchema,
5828
+ 'books-list': BooksListSchema,
5829
+ 'empty-tenant': EmptyTenantSchema,
5830
+ footer: FooterSchema,
5831
+ 'form-demo': FormDemoSchema,
5832
+ header: HeaderSchema,
5833
+ 'prodotti-list': ProdottiListSchema,
5834
+ 'prodotto-detail': ProdottoDetailSchema,
5835
+ } as const;
5836
+
5837
+ /**
5838
+ * Registry of per-section-type submission schemas. Keys MUST match a key of
5839
+ * SECTION_SCHEMAS. A section type appearing here is declaring itself as
5840
+ * MCP-submittable: the Zod schema describes the payload accepted by the form.
5841
+ *
5842
+ * See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
5843
+ */
5844
+ export const SECTION_SUBMISSION_SCHEMAS = {
5845
+ 'form-demo': FormDemoSubmissionSchema,
5846
+ } as const;
5847
+
5848
+ export type SectionType = keyof typeof SECTION_SCHEMAS;
5849
+
5850
+ export {
5851
+ BaseSectionData,
5852
+ BaseArrayItem,
5853
+ BaseCollectionItem,
5854
+ BaseSectionSettingsSchema,
5855
+ CtaSchema,
5856
+ ImageSelectionSchema,
5857
+ } from '@olonjs/core';
5389
5858
 
5390
5859
  END_OF_FILE_CONTENT
5391
5860
  echo "Creating src/lib/utils.ts..."
@@ -6037,55 +6506,194 @@ describe('visitor page WebMCP wiring', () => {
6037
6506
  });
6038
6507
  });
6039
6508
 
6509
+ END_OF_FILE_CONTENT
6510
+ mkdir -p "src/sources"
6511
+ echo "Creating src/sources/catalogo.test.ts..."
6512
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.test.ts"
6513
+ import { describe, expect, it } from 'vitest';
6514
+ import { runSourceConformance } from '@olonjs/sources';
6515
+ import { catalogoSource, fetchCatalogoFromExternalService } from './catalogo';
6516
+
6517
+ describe('catalogo (demo external source)', () => {
6518
+ it('passes the provider conformance suite', async () => {
6519
+ await expect(runSourceConformance(catalogoSource)).resolves.toBeUndefined();
6520
+ });
6521
+
6522
+ it('projects a deterministic document keyed by product id', async () => {
6523
+ const document = await fetchCatalogoFromExternalService();
6524
+
6525
+ expect(Object.keys(document).sort()).toEqual([
6526
+ '1984',
6527
+ 'dune',
6528
+ 'il-nome-della-rosa',
6529
+ 'neuromancer',
6530
+ ]);
6531
+ for (const [key, item] of Object.entries(document)) {
6532
+ expect(item.id).toBe(key);
6533
+ }
6534
+ });
6535
+
6536
+ it('surfaces failures for fallback/breaker policy to consume', async () => {
6537
+ await expect(fetchCatalogoFromExternalService({ fail: true })).rejects.toThrow(
6538
+ /unavailable/
6539
+ );
6540
+ });
6541
+ });
6542
+
6543
+ END_OF_FILE_CONTENT
6544
+ echo "Creating src/sources/catalogo.ts..."
6545
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.ts"
6546
+ import { z } from 'zod';
6547
+ import { defineCollectionSource, withCache } from '@olonjs/sources';
6548
+ import { ProdottoSchema, type Prodotto } from '@/collections/prodotti';
6549
+
6550
+ /**
6551
+ * Demo provider: a DETERMINISTIC, local stand-in for an external catalog
6552
+ * service (no network — keeps dev, tests and CI hermetic). The real-world twin
6553
+ * would be `src/sources/shopify.ts` implementing the same contract.
6554
+ *
6555
+ * The `shape` is the single declaration: it validates the projected document
6556
+ * and feeds the agent contracts (`/schemas/collections/catalogo.schema.json`).
6557
+ */
6558
+
6559
+ const CATALOGO_FIXTURE: Record<string, Prodotto> = {
6560
+ dune: {
6561
+ id: 'dune',
6562
+ title: 'Dune — Edizione illustrata',
6563
+ price: 2490,
6564
+ currency: 'EUR',
6565
+ description: 'Arrakis, spezia e politica: il classico di Frank Herbert.',
6566
+ },
6567
+ '1984': {
6568
+ id: '1984',
6569
+ title: '1984',
6570
+ price: 1490,
6571
+ currency: 'EUR',
6572
+ description: 'Il Grande Fratello e il controllo del linguaggio.',
6573
+ },
6574
+ neuromancer: {
6575
+ id: 'neuromancer',
6576
+ title: 'Neuromancer',
6577
+ price: 1890,
6578
+ currency: 'EUR',
6579
+ description: 'Il romanzo che ha definito il cyberpunk.',
6580
+ },
6581
+ 'il-nome-della-rosa': {
6582
+ id: 'il-nome-della-rosa',
6583
+ title: 'Il nome della rosa',
6584
+ price: 2100,
6585
+ currency: 'EUR',
6586
+ description: 'Un giallo medievale tra biblioteche e potere.',
6587
+ },
6588
+ };
6589
+
6590
+ export interface ExternalCatalogServiceOptions {
6591
+ /** Simulated latency in ms (default 0 — deterministic tests). */
6592
+ latencyMs?: number;
6593
+ /** Force a failure, to exercise fallback/breaker policy. */
6594
+ fail?: boolean;
6595
+ signal?: AbortSignal;
6596
+ }
6597
+
6598
+ /** Stand-in for the external system of record (Shopify/PIM/…). */
6599
+ export async function fetchCatalogoFromExternalService(
6600
+ options: ExternalCatalogServiceOptions = {}
6601
+ ): Promise<Record<string, Prodotto>> {
6602
+ const { latencyMs = 0, fail = false, signal } = options;
6603
+
6604
+ if (latencyMs > 0) {
6605
+ await new Promise((resolve) => setTimeout(resolve, latencyMs));
6606
+ }
6607
+ if (signal?.aborted) throw new Error('The catalog request was aborted');
6608
+ if (fail) throw new Error('External catalog service unavailable');
6609
+
6610
+ return JSON.parse(JSON.stringify(CATALOGO_FIXTURE)) as Record<string, Prodotto>;
6611
+ }
6612
+
6613
+ export const catalogoSource = withCache(
6614
+ defineCollectionSource({
6615
+ shape: z.record(z.string(), ProdottoSchema),
6616
+ resolve: async ({ signal }) => fetchCatalogoFromExternalService({ signal }),
6617
+ readOnly: true,
6618
+ }),
6619
+ { ttlMs: 60_000, swrMs: 300_000 }
6620
+ );
6621
+
6622
+ END_OF_FILE_CONTENT
6623
+ echo "Creating src/sources/index.ts..."
6624
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/index.ts"
6625
+ import { catalogoSource } from './catalogo';
6626
+
6627
+ /**
6628
+ * Tenant source registry. Adding a provider is a one-line change here — no
6629
+ * changes to @olonjs/core (ADR-0018). Sources not listed here are file-backed
6630
+ * collections loaded from src/data/collections.
6631
+ */
6632
+ export const collectionSources = {
6633
+ catalogo: catalogoSource,
6634
+ } as const;
6635
+
6636
+ export type CollectionSourceName = keyof typeof collectionSources;
6637
+
6040
6638
  END_OF_FILE_CONTENT
6041
6639
  echo "Creating src/types.ts..."
6042
6640
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
6043
- import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
6044
- import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
6045
- import type { BooksListData, BooksListSettings } from '@/components/books-list';
6046
- import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
6047
- import type { FooterData, FooterSettings } from '@/components/footer';
6048
- import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
6049
- import type { HeaderData, HeaderSettings } from '@/components/header';
6050
- import type { Autore } from '@/collections/autori';
6051
- import type { Libro } from '@/collections/libri';
6052
-
6053
- export type SectionComponentPropsMap = {
6054
- 'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
6055
- 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
6056
- 'books-list': { data: BooksListData; settings?: BooksListSettings };
6057
- 'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
6058
- footer: { data: FooterData; settings?: FooterSettings };
6059
- header: { data: HeaderData; settings?: HeaderSettings };
6060
- 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
6061
- };
6062
-
6063
- declare module '@olonjs/core' {
6064
- export interface SectionDataRegistry {
6065
- 'authors-list': AuthorsListData;
6066
- 'book-detail': BookDetailData;
6067
- 'books-list': BooksListData;
6068
- 'empty-tenant': EmptyTenantData;
6069
- footer: FooterData;
6070
- header: HeaderData;
6071
- 'form-demo': FormDemoData;
6072
- }
6073
- export interface SectionSettingsRegistry {
6074
- 'authors-list': AuthorsListSettings;
6075
- 'book-detail': BookDetailSettings;
6076
- 'books-list': BooksListSettings;
6077
- 'empty-tenant': EmptyTenantSettings;
6078
- footer: FooterSettings;
6079
- header: HeaderSettings;
6080
- 'form-demo': FormDemoSettings;
6081
- }
6082
- export interface CollectionItemRegistry {
6083
- autori: Autore;
6084
- libri: Libro;
6085
- }
6086
- }
6087
-
6088
- export * from '@olonjs/core';
6641
+ import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
6642
+ import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
6643
+ import type { BooksListData, BooksListSettings } from '@/components/books-list';
6644
+ import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
6645
+ import type { FooterData, FooterSettings } from '@/components/footer';
6646
+ import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
6647
+ import type { HeaderData, HeaderSettings } from '@/components/header';
6648
+ import type { ProdottoDetailData, ProdottoDetailSettings } from '@/components/prodotto-detail';
6649
+ import type { ProdottiListData, ProdottiListSettings } from '@/components/prodotti-list';
6650
+ import type { Autore } from '@/collections/autori';
6651
+ import type { Libro } from '@/collections/libri';
6652
+ import type { Prodotto } from '@/collections/prodotti';
6653
+
6654
+ export type SectionComponentPropsMap = {
6655
+ 'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
6656
+ 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
6657
+ 'books-list': { data: BooksListData; settings?: BooksListSettings };
6658
+ 'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
6659
+ footer: { data: FooterData; settings?: FooterSettings };
6660
+ header: { data: HeaderData; settings?: HeaderSettings };
6661
+ 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
6662
+ 'prodotti-list': { data: ProdottiListData; settings?: ProdottiListSettings };
6663
+ 'prodotto-detail': { data: ProdottoDetailData; settings?: ProdottoDetailSettings };
6664
+ };
6665
+
6666
+ declare module '@olonjs/core' {
6667
+ export interface SectionDataRegistry {
6668
+ 'authors-list': AuthorsListData;
6669
+ 'book-detail': BookDetailData;
6670
+ 'books-list': BooksListData;
6671
+ 'empty-tenant': EmptyTenantData;
6672
+ footer: FooterData;
6673
+ header: HeaderData;
6674
+ 'form-demo': FormDemoData;
6675
+ 'prodotti-list': ProdottiListData;
6676
+ 'prodotto-detail': ProdottoDetailData;
6677
+ }
6678
+ export interface SectionSettingsRegistry {
6679
+ 'authors-list': AuthorsListSettings;
6680
+ 'book-detail': BookDetailSettings;
6681
+ 'books-list': BooksListSettings;
6682
+ 'empty-tenant': EmptyTenantSettings;
6683
+ footer: FooterSettings;
6684
+ header: HeaderSettings;
6685
+ 'form-demo': FormDemoSettings;
6686
+ 'prodotti-list': ProdottiListSettings;
6687
+ 'prodotto-detail': ProdottoDetailSettings;
6688
+ }
6689
+ export interface CollectionItemRegistry {
6690
+ autori: Autore;
6691
+ libri: Libro;
6692
+ prodotti: Prodotto;
6693
+ }
6694
+ }
6695
+
6696
+ export * from '@olonjs/core';
6089
6697
 
6090
6698
  END_OF_FILE_CONTENT
6091
6699
  mkdir -p "templates"
@@ -7394,7 +8002,7 @@ export const ContactFormSchema = BaseSectionData.merge(WithFormRecipient).extend
7394
8002
  });
7395
8003
  export const ContactFormSubmissionSchema = z.object({
7396
8004
  name: z.string().min(1).describe('Full name'),
7397
- email: z.string().email().describe('Reply email'),
8005
+ email: z.email().describe('Reply email'),
7398
8006
  message: z.string().min(1).describe('Message body'),
7399
8007
  });
7400
8008
  EOF
@@ -11368,6 +11976,8 @@ import type { TagPostsData, TagPostsSettings } from '@/components/tag-posts';
11368
11976
  import type { ContentBlockData, ContentBlockSettings } from '@/components/content-block';
11369
11977
  import type { StatsBandData, StatsBandSettings } from '@/components/stats-band';
11370
11978
  import type { CtaBannerData, CtaBannerSettings } from '@/components/cta-banner';
11979
+ import type { Post } from '@/collections/posts';
11980
+ import type { Tag } from '@/collections/tags';
11371
11981
 
11372
11982
  export type SectionComponentPropsMap = {
11373
11983
  'header': { data: HeaderData; settings: HeaderSettings };
@@ -11416,6 +12026,10 @@ declare module '@olonjs/core' {
11416
12026
  'stats-band': StatsBandSettings;
11417
12027
  'cta-banner': CtaBannerSettings;
11418
12028
  }
12029
+ export interface CollectionItemRegistry {
12030
+ posts: Post;
12031
+ tags: Tag;
12032
+ }
11419
12033
  }
11420
12034
 
11421
12035
  export * from '@olonjs/core';