@olonjs/cli 3.0.165 → 3.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/src_tenant_alpha.sh +62 -13
- package/assets/templates/alpha/src_tenant.sh +62 -13
- package/assets/templates/next/src_tenant.sh +1664 -480
- package/package.json +1 -1
|
@@ -19,12 +19,13 @@ 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';
|
|
27
|
+
import { resolvePublicToolNames } from '@/lib/webmcp/resolvePublicToolNames';
|
|
28
|
+
import { sectionPublicTools } from '@/lib/webmcp/toolRegistry';
|
|
28
29
|
import { VisitorSection } from '@/lib/VisitorSection';
|
|
29
30
|
|
|
30
31
|
export const dynamic = 'force-dynamic';
|
|
@@ -89,7 +90,10 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
|
|
|
89
90
|
notFound();
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
const collections =
|
|
93
|
+
const { collections, collectionSchemas } = await loadVisitorPageBundle({
|
|
94
|
+
page: result.page,
|
|
95
|
+
params: result.params,
|
|
96
|
+
});
|
|
93
97
|
const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle();
|
|
94
98
|
const collectionContext = resolveCollectionContext(result.page, result.params, collections);
|
|
95
99
|
|
|
@@ -99,7 +103,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
|
|
|
99
103
|
themeConfig,
|
|
100
104
|
menuConfig,
|
|
101
105
|
collections,
|
|
102
|
-
collectionSchemas:
|
|
106
|
+
collectionSchemas: collectionSchemas as never,
|
|
103
107
|
collectionContext,
|
|
104
108
|
refDocuments: {
|
|
105
109
|
'menu.json': menuConfig,
|
|
@@ -113,6 +117,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
|
|
|
113
117
|
const pageNum = Number(firstSearchValue(query.page) ?? '1') || 1;
|
|
114
118
|
const shell = resolveVisitorShell(page, resolved.siteConfig ?? siteConfig);
|
|
115
119
|
const sectionExtras = { authorId, page: pageNum, pathname };
|
|
120
|
+
const publicToolNames = resolvePublicToolNames(page.sections, sectionPublicTools as Record<string, readonly string[]>);
|
|
116
121
|
const title = page.meta?.title ?? result.registrySlug;
|
|
117
122
|
const description = typeof page.meta?.description === 'string' ? page.meta.description : '';
|
|
118
123
|
const jsonLd = buildVisitorWebPageJsonLd({
|
|
@@ -123,7 +128,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
|
|
|
123
128
|
|
|
124
129
|
return (
|
|
125
130
|
<>
|
|
126
|
-
<WebMcpVisitorRuntime />
|
|
131
|
+
<WebMcpVisitorRuntime toolNames={publicToolNames} />
|
|
127
132
|
<link rel="mcp-manifest" href={buildPageManifestHref(requestSlug)} />
|
|
128
133
|
<link rel="olon-contract" href={buildPageContractHref(requestSlug)} />
|
|
129
134
|
<script
|
|
@@ -213,27 +218,50 @@ mkdir -p "app/api/public-collection/[source]"
|
|
|
213
218
|
mkdir -p "app/api/public-collection/[source]/[file]"
|
|
214
219
|
echo "Creating app/api/public-collection/[source]/[file]/route.ts..."
|
|
215
220
|
cat << 'END_OF_FILE_CONTENT' > "app/api/public-collection/[source]/[file]/route.ts"
|
|
216
|
-
import { NextResponse } from 'next/server';
|
|
217
|
-
import {
|
|
218
|
-
import {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
import { NextResponse } from 'next/server';
|
|
222
|
+
import { resolveCollectionSources } from '@olonjs/sources';
|
|
223
|
+
import { resolvePublicCollectionDocument } from '@/lib/loaders/publishedContent';
|
|
224
|
+
import { collectionSources } from '@/sources';
|
|
225
|
+
import { loadRuntimeSurfaceForSite } from '@/lib/webmcp/runtime/loadRuntimeSurface';
|
|
226
|
+
|
|
227
|
+
export const dynamic = 'force-dynamic';
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* GET /collections/{source}/{source}.json (rewrite) — collection document, runtime.
|
|
231
|
+
*
|
|
232
|
+
* File-backed collections are read from the content bundle; EXTERNAL sources
|
|
233
|
+
* (ADR-0018) are projected on demand so the contract's `dataHref` resolves.
|
|
234
|
+
* Note: resolution here has no route params — parameterized sources should be
|
|
235
|
+
* resolved at the page level (see loadPublicPageBundleForRequest).
|
|
236
|
+
*/
|
|
237
|
+
export async function GET(_request: Request, context: { params: Promise<{ source: string; file: string }> }) {
|
|
238
|
+
try {
|
|
239
|
+
const { source, file } = await context.params;
|
|
240
|
+
const sourceName = decodeURIComponent(source);
|
|
241
|
+
|
|
242
|
+
if (sourceName in collectionSources) {
|
|
243
|
+
const resolved = await resolveCollectionSources({
|
|
244
|
+
sources: collectionSources as never,
|
|
245
|
+
needed: [sourceName],
|
|
246
|
+
});
|
|
247
|
+
const projected = resolved.collections[sourceName];
|
|
248
|
+
if (!projected) {
|
|
249
|
+
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
|
|
250
|
+
}
|
|
251
|
+
return NextResponse.json(projected);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const { bundle } = loadRuntimeSurfaceForSite();
|
|
255
|
+
const doc = resolvePublicCollectionDocument(bundle, sourceName, decodeURIComponent(file));
|
|
256
|
+
if (!doc) return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
|
|
257
|
+
return NextResponse.json(doc);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return NextResponse.json(
|
|
260
|
+
{ error: error instanceof Error ? error.message : 'Collection JSON failed' },
|
|
261
|
+
{ status: 500 },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
237
265
|
|
|
238
266
|
END_OF_FILE_CONTENT
|
|
239
267
|
mkdir -p "app/api/public-config"
|
|
@@ -761,10 +789,11 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
761
789
|
"dist:dna": "npm run dist"
|
|
762
790
|
},
|
|
763
791
|
"dependencies": {
|
|
764
|
-
"@olonjs/core": "^2.0.
|
|
765
|
-
"@olonjs/next": "^0.0.
|
|
766
|
-
"@olonjs/react": "^0.2.
|
|
767
|
-
"@olonjs/
|
|
792
|
+
"@olonjs/core": "^2.0.3",
|
|
793
|
+
"@olonjs/next": "^0.0.15",
|
|
794
|
+
"@olonjs/react": "^0.2.3",
|
|
795
|
+
"@olonjs/sources": "^0.1.0",
|
|
796
|
+
"@olonjs/studio": "^0.2.3",
|
|
768
797
|
"clsx": "^2.1.1",
|
|
769
798
|
"lucide-react": "^0.474.0",
|
|
770
799
|
"next": "^15.5.0",
|
|
@@ -779,6 +808,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
779
808
|
"@types/node": "^22.13.1",
|
|
780
809
|
"@types/react": "^19.0.0",
|
|
781
810
|
"@types/react-dom": "^19.0.0",
|
|
811
|
+
"jsdom": "^26.1.0",
|
|
782
812
|
"tailwindcss": "^4.0.0",
|
|
783
813
|
"typescript": "^5.7.3",
|
|
784
814
|
"vitest": "^3.0.0"
|
|
@@ -1011,10 +1041,16 @@ const SCRIPT = path.join(__dirname, 'webmcp-feature-check.mjs');
|
|
|
1011
1041
|
const PKG = path.join(ROOT, 'package.json');
|
|
1012
1042
|
|
|
1013
1043
|
describe('next verify:webmcp script', () => {
|
|
1014
|
-
it('probes document.
|
|
1044
|
+
it('probes the native document.modelContext surface with polyfill fallback', () => {
|
|
1015
1045
|
assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
|
|
1016
1046
|
const src = fs.readFileSync(SCRIPT, 'utf8');
|
|
1047
|
+
// Native Chrome surface: document.modelContext.getTools() / executeTool(toolObject, argsObject).
|
|
1048
|
+
assert.match(src, /document\.modelContext/);
|
|
1049
|
+
assert.match(src, /await native\.getTools\(\)/);
|
|
1050
|
+
assert.match(src, /native\.executeTool\(tool,/);
|
|
1051
|
+
// Polyfill fallback (browsers without native WebMCP): listTools / executeTool(name, json).
|
|
1017
1052
|
assert.match(src, /document\.modelContextProtocol/);
|
|
1053
|
+
assert.match(src, /protocol\.executeTool\(/);
|
|
1018
1054
|
// modelContextTesting is never assigned by the runtime — probing it is a false negative.
|
|
1019
1055
|
assert.doesNotMatch(src, /modelContextTesting/);
|
|
1020
1056
|
assert.doesNotMatch(src, /navigator\.modelContext/);
|
|
@@ -1031,302 +1067,351 @@ describe('next verify:webmcp script', () => {
|
|
|
1031
1067
|
END_OF_FILE_CONTENT
|
|
1032
1068
|
echo "Creating scripts/webmcp-feature-check.mjs..."
|
|
1033
1069
|
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
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
});
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
await page.
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1070
|
+
import fs from 'fs/promises';
|
|
1071
|
+
import path from 'path';
|
|
1072
|
+
import { fileURLToPath } from 'url';
|
|
1073
|
+
import { createRequire } from 'module';
|
|
1074
|
+
|
|
1075
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1076
|
+
const rootDir = path.resolve(__dirname, '..');
|
|
1077
|
+
const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
|
|
1078
|
+
const UPDATE_TOOL_NAME = 'update-section';
|
|
1079
|
+
const SAVE_TOOL_NAME = 'save';
|
|
1080
|
+
|
|
1081
|
+
function pageFilePathFromSlug(slug) {
|
|
1082
|
+
return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function adminUrlFromSlug(slug) {
|
|
1086
|
+
return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function isStringSchema(schema) {
|
|
1090
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
1091
|
+
if (schema.type === 'string') return true;
|
|
1092
|
+
if (Array.isArray(schema.anyOf)) {
|
|
1093
|
+
return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
|
|
1094
|
+
}
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function findTopLevelStringField(sectionSchema) {
|
|
1099
|
+
const properties = sectionSchema?.properties;
|
|
1100
|
+
if (!properties || typeof properties !== 'object') return null;
|
|
1101
|
+
const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
|
|
1102
|
+
for (const key of preferred) {
|
|
1103
|
+
if (isStringSchema(properties[key])) return key;
|
|
1104
|
+
}
|
|
1105
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
1106
|
+
if (isStringSchema(value)) return key;
|
|
1107
|
+
}
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function loadPlaywright() {
|
|
1112
|
+
const require = createRequire(import.meta.url);
|
|
1113
|
+
try {
|
|
1114
|
+
return require('playwright');
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
`Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function readPageJson(slug) {
|
|
1124
|
+
const pageFilePath = pageFilePathFromSlug(slug);
|
|
1125
|
+
const raw = await fs.readFile(pageFilePath, 'utf8');
|
|
1126
|
+
return { raw, json: JSON.parse(raw), pageFilePath };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function waitFor(predicate, timeoutMs, label) {
|
|
1130
|
+
const startedAt = Date.now();
|
|
1131
|
+
for (;;) {
|
|
1132
|
+
const result = await predicate();
|
|
1133
|
+
if (result) return result;
|
|
1134
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
1135
|
+
throw new Error(`Timed out while waiting for ${label}.`);
|
|
1136
|
+
}
|
|
1137
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
|
|
1142
|
+
await waitFor(async () => {
|
|
1143
|
+
const { json } = await readPageJson(slug);
|
|
1144
|
+
const section = Array.isArray(json.sections)
|
|
1145
|
+
? json.sections.find((item) => item?.id === sectionId)
|
|
1146
|
+
: null;
|
|
1147
|
+
return section?.data?.[fieldKey] === expectedValue;
|
|
1148
|
+
}, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function ensureResponseOk(response, label) {
|
|
1152
|
+
if (!response.ok) {
|
|
1153
|
+
const text = await response.text();
|
|
1154
|
+
throw new Error(`${label} failed with ${response.status}: ${text}`);
|
|
1155
|
+
}
|
|
1156
|
+
return response;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function fetchJson(relativePath, label) {
|
|
1160
|
+
const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
|
|
1161
|
+
return response.json();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function selectTarget() {
|
|
1165
|
+
const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
|
|
1166
|
+
const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
|
|
1167
|
+
? process.env.WEBMCP_TARGET_SLUG.trim()
|
|
1168
|
+
: null;
|
|
1169
|
+
|
|
1170
|
+
const candidatePages = requestedSlug
|
|
1171
|
+
? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
|
|
1172
|
+
: (siteIndex.pages ?? []);
|
|
1173
|
+
|
|
1174
|
+
for (const pageEntry of candidatePages) {
|
|
1175
|
+
if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
|
|
1176
|
+
const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
|
|
1177
|
+
const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
|
|
1178
|
+
const localInstances = Array.isArray(pageContract.sectionInstances)
|
|
1179
|
+
? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
|
|
1180
|
+
: [];
|
|
1181
|
+
const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
|
|
1182
|
+
// Manifest tools are page-level (`update-section`, `save`); targets come from the
|
|
1183
|
+
// contract's local section instances + their schemas.
|
|
1184
|
+
const updateTool = tools.find((tool) => tool?.name === UPDATE_TOOL_NAME);
|
|
1185
|
+
const saveTool = tools.find((tool) => tool?.name === SAVE_TOOL_NAME);
|
|
1186
|
+
if (!updateTool || !saveTool) continue;
|
|
1187
|
+
|
|
1188
|
+
for (const targetInstance of localInstances) {
|
|
1189
|
+
const sectionType = targetInstance?.type;
|
|
1190
|
+
if (!targetInstance?.id || typeof sectionType !== 'string') continue;
|
|
1191
|
+
const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
|
|
1192
|
+
if (!targetFieldKey) continue;
|
|
1193
|
+
const pageState = await readPageJson(pageEntry.slug);
|
|
1194
|
+
const section = Array.isArray(pageState.json.sections)
|
|
1195
|
+
? pageState.json.sections.find((item) => item?.id === targetInstance.id)
|
|
1196
|
+
: null;
|
|
1197
|
+
const originalValue = section?.data?.[targetFieldKey];
|
|
1198
|
+
if (typeof originalValue !== 'string') continue;
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
slug: pageEntry.slug,
|
|
1202
|
+
manifestHref: pageEntry.manifestHref,
|
|
1203
|
+
contractHref: pageEntry.contractHref,
|
|
1204
|
+
toolName: UPDATE_TOOL_NAME,
|
|
1205
|
+
saveToolName: SAVE_TOOL_NAME,
|
|
1206
|
+
sectionId: targetInstance.id,
|
|
1207
|
+
sectionType,
|
|
1208
|
+
fieldKey: targetFieldKey,
|
|
1209
|
+
originalValue,
|
|
1210
|
+
originalState: pageState,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
requestedSlug
|
|
1217
|
+
? `No valid WebMCP verification target found for page "${requestedSlug}".`
|
|
1218
|
+
: 'No valid WebMCP verification target found in manifest index.'
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async function main() {
|
|
1223
|
+
const { chromium } = await loadPlaywright();
|
|
1224
|
+
const target = await selectTarget();
|
|
1225
|
+
const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
|
|
1226
|
+
const browser = await chromium.launch({ headless: true });
|
|
1227
|
+
const page = await browser.newPage();
|
|
1228
|
+
const consoleEvents = [];
|
|
1229
|
+
let mutationApplied = false;
|
|
1230
|
+
|
|
1231
|
+
page.on('console', (message) => {
|
|
1232
|
+
if (message.type() === 'error' || message.type() === 'warning') {
|
|
1233
|
+
consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
page.on('pageerror', (error) => {
|
|
1237
|
+
consoleEvents.push(`[pageerror] ${error.message}`);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
/** update-section mutates the Studio draft only; save persists. Returns raw update result. */
|
|
1241
|
+
const runUpdateThenSave = async (value) => {
|
|
1242
|
+
const rawUpdate = await page.evaluate(
|
|
1243
|
+
async ({ toolName, slug, sectionId, sectionType, fieldKey, value: nextValue }) => {
|
|
1244
|
+
const args = { slug, sectionId, sectionType, fieldKey, value: nextValue };
|
|
1245
|
+
const native = document.modelContext;
|
|
1246
|
+
const protocol = document.modelContextProtocol;
|
|
1247
|
+
// Native Chrome WebMCP: executeTool(toolObject, args) — the tool object
|
|
1248
|
+
// comes from getTools(). Args format: plain object since Chrome 155;
|
|
1249
|
+
// before that the native expects a JSON string (verified on Chrome 153).
|
|
1250
|
+
if (native && typeof native.executeTool === 'function') {
|
|
1251
|
+
const tools = await native.getTools();
|
|
1252
|
+
const tool = tools.find((entry) => entry.name === toolName);
|
|
1253
|
+
if (!tool) {
|
|
1254
|
+
throw new Error(`Tool "${toolName}" not found in native getTools().`);
|
|
1255
|
+
}
|
|
1256
|
+
const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
|
|
1257
|
+
const nativeArgs = chromeMajor >= 155 ? args : JSON.stringify(args);
|
|
1258
|
+
const result = await native.executeTool(tool, nativeArgs);
|
|
1259
|
+
if (result === null) {
|
|
1260
|
+
throw new Error(`Tool "${toolName}" returned null (navigation).`);
|
|
1261
|
+
}
|
|
1262
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
1263
|
+
}
|
|
1264
|
+
if (protocol && typeof protocol.executeTool === 'function') {
|
|
1265
|
+
return protocol.executeTool(toolName, JSON.stringify(args));
|
|
1266
|
+
}
|
|
1267
|
+
throw new Error(
|
|
1268
|
+
'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
|
|
1269
|
+
);
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
toolName: target.toolName,
|
|
1273
|
+
slug: target.slug,
|
|
1274
|
+
sectionId: target.sectionId,
|
|
1275
|
+
sectionType: target.sectionType,
|
|
1276
|
+
fieldKey: target.fieldKey,
|
|
1277
|
+
value,
|
|
1278
|
+
}
|
|
1279
|
+
);
|
|
1280
|
+
const parsedUpdate = JSON.parse(rawUpdate);
|
|
1281
|
+
if (parsedUpdate?.isError) {
|
|
1282
|
+
throw new Error(`WebMCP ${target.toolName} returned an error: ${rawUpdate}`);
|
|
1283
|
+
}
|
|
1284
|
+
const rawSave = await page.evaluate(
|
|
1285
|
+
async ({ saveToolName }) => {
|
|
1286
|
+
const native = document.modelContext;
|
|
1287
|
+
const protocol = document.modelContextProtocol;
|
|
1288
|
+
if (native && typeof native.executeTool === 'function') {
|
|
1289
|
+
const tools = await native.getTools();
|
|
1290
|
+
const tool = tools.find((entry) => entry.name === saveToolName);
|
|
1291
|
+
if (!tool) {
|
|
1292
|
+
throw new Error(`Tool "${saveToolName}" not found in native getTools().`);
|
|
1293
|
+
}
|
|
1294
|
+
const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
|
|
1295
|
+
const nativeArgs = chromeMajor >= 155 ? {} : '{}';
|
|
1296
|
+
const result = await native.executeTool(tool, nativeArgs);
|
|
1297
|
+
if (result === null) {
|
|
1298
|
+
throw new Error(`Tool "${saveToolName}" returned null (navigation).`);
|
|
1299
|
+
}
|
|
1300
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
1301
|
+
}
|
|
1302
|
+
if (protocol && typeof protocol.executeTool === 'function') {
|
|
1303
|
+
return protocol.executeTool(saveToolName, '{}');
|
|
1304
|
+
}
|
|
1305
|
+
throw new Error(
|
|
1306
|
+
'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
|
|
1307
|
+
);
|
|
1308
|
+
},
|
|
1309
|
+
{ saveToolName: target.saveToolName }
|
|
1310
|
+
);
|
|
1311
|
+
const parsedSave = JSON.parse(rawSave);
|
|
1312
|
+
if (parsedSave?.isError) {
|
|
1313
|
+
throw new Error(`WebMCP ${target.saveToolName} returned an error: ${rawSave}`);
|
|
1314
|
+
}
|
|
1315
|
+
return rawUpdate;
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
const restoreOriginal = async () => {
|
|
1319
|
+
try {
|
|
1320
|
+
await runUpdateThenSave(target.originalValue);
|
|
1321
|
+
await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
|
|
1322
|
+
} catch {
|
|
1323
|
+
await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
|
|
1329
|
+
if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
|
|
1330
|
+
throw new Error(`Manifest does not expose ${target.toolName}.`);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
|
|
1334
|
+
if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
|
|
1335
|
+
throw new Error(`Page contract does not expose ${target.toolName}.`);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
|
|
1339
|
+
|
|
1340
|
+
try {
|
|
1341
|
+
await page.waitForFunction(
|
|
1342
|
+
({ manifestHref, contractHref }) => {
|
|
1343
|
+
const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
|
|
1344
|
+
const contractLink = document.head.querySelector('link[rel="olon-contract"]');
|
|
1345
|
+
return manifestLink?.getAttribute('href') === manifestHref
|
|
1346
|
+
&& contractLink?.getAttribute('href') === contractHref;
|
|
1347
|
+
},
|
|
1348
|
+
{ manifestHref: target.manifestHref, contractHref: target.contractHref },
|
|
1349
|
+
{ timeout: 10_000 }
|
|
1350
|
+
);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
const diagnostics = await page.evaluate(() => ({
|
|
1353
|
+
head: document.head.innerHTML,
|
|
1354
|
+
bodyText: document.body.innerText,
|
|
1355
|
+
}));
|
|
1356
|
+
throw new Error(
|
|
1357
|
+
[
|
|
1358
|
+
error instanceof Error ? error.message : String(error),
|
|
1359
|
+
`head=${diagnostics.head}`,
|
|
1360
|
+
`body=${diagnostics.bodyText}`,
|
|
1361
|
+
...consoleEvents,
|
|
1362
|
+
].join('\n')
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const toolNames = await page.evaluate(async () => {
|
|
1367
|
+
const native = document.modelContext;
|
|
1368
|
+
const protocol = document.modelContextProtocol;
|
|
1369
|
+
if (native && typeof native.getTools === 'function') {
|
|
1370
|
+
const tools = await native.getTools();
|
|
1371
|
+
return tools.map((tool) => tool.name);
|
|
1372
|
+
}
|
|
1373
|
+
if (protocol && typeof protocol.listTools === 'function') {
|
|
1374
|
+
return protocol.listTools().map((tool) => tool.name);
|
|
1375
|
+
}
|
|
1376
|
+
return [];
|
|
1377
|
+
});
|
|
1378
|
+
for (const required of [target.toolName, target.saveToolName]) {
|
|
1379
|
+
if (!toolNames.includes(required)) {
|
|
1380
|
+
throw new Error(`Runtime did not register ${required}. Found: ${toolNames.join(', ')}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
mutationApplied = true;
|
|
1385
|
+
await runUpdateThenSave(nextValue);
|
|
1386
|
+
await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
|
|
1387
|
+
await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
|
|
1388
|
+
|
|
1389
|
+
console.log(
|
|
1390
|
+
JSON.stringify({
|
|
1391
|
+
ok: true,
|
|
1392
|
+
slug: target.slug,
|
|
1393
|
+
manifestHref: target.manifestHref,
|
|
1394
|
+
contractHref: target.contractHref,
|
|
1395
|
+
toolName: target.toolName,
|
|
1396
|
+
saveToolName: target.saveToolName,
|
|
1397
|
+
sectionId: target.sectionId,
|
|
1398
|
+
sectionType: target.sectionType,
|
|
1399
|
+
fieldKey: target.fieldKey,
|
|
1400
|
+
toolNames,
|
|
1401
|
+
})
|
|
1402
|
+
);
|
|
1403
|
+
} finally {
|
|
1404
|
+
if (mutationApplied) {
|
|
1405
|
+
await restoreOriginal();
|
|
1406
|
+
}
|
|
1407
|
+
await browser.close();
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
main().catch((error) => {
|
|
1412
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
1413
|
+
process.exit(1);
|
|
1414
|
+
});
|
|
1330
1415
|
|
|
1331
1416
|
END_OF_FILE_CONTENT
|
|
1332
1417
|
mkdir -p "src"
|
|
@@ -1396,6 +1481,43 @@ import { LibroSchema, LibriCollectionSchema } from './schema';
|
|
|
1396
1481
|
export type Libro = z.infer<typeof LibroSchema>;
|
|
1397
1482
|
export type LibriCollection = z.infer<typeof LibriCollectionSchema>;
|
|
1398
1483
|
|
|
1484
|
+
END_OF_FILE_CONTENT
|
|
1485
|
+
mkdir -p "src/collections/prodotti"
|
|
1486
|
+
echo "Creating src/collections/prodotti/index.ts..."
|
|
1487
|
+
cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/index.ts"
|
|
1488
|
+
export { ProdottoSchema, ProdottiCollectionSchema } from './schema';
|
|
1489
|
+
export type { Prodotto, ProdottiCollection } from './types';
|
|
1490
|
+
|
|
1491
|
+
END_OF_FILE_CONTENT
|
|
1492
|
+
echo "Creating src/collections/prodotti/schema.ts..."
|
|
1493
|
+
cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/schema.ts"
|
|
1494
|
+
import { z } from 'zod';
|
|
1495
|
+
import { BaseCollectionItem } from '@olonjs/core';
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Demo collection whose source of truth is EXTERNAL (see src/sources/catalogo.ts).
|
|
1499
|
+
* Same Zod item shape feeds the human list rendering (ui:* descriptors) and the
|
|
1500
|
+
* agent contracts (JSON Schema emitted from this schema).
|
|
1501
|
+
*/
|
|
1502
|
+
export const ProdottoSchema = BaseCollectionItem.extend({
|
|
1503
|
+
title: z.string().describe('ui:text'),
|
|
1504
|
+
price: z.number().describe('ui:number'),
|
|
1505
|
+
currency: z.string().default('EUR').describe('ui:text'),
|
|
1506
|
+
description: z.string().optional().describe('ui:textarea'),
|
|
1507
|
+
image: z.string().optional().describe('ui:text'),
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
export const ProdottiCollectionSchema = z.record(z.string(), ProdottoSchema);
|
|
1511
|
+
|
|
1512
|
+
END_OF_FILE_CONTENT
|
|
1513
|
+
echo "Creating src/collections/prodotti/types.ts..."
|
|
1514
|
+
cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/types.ts"
|
|
1515
|
+
import { z } from 'zod';
|
|
1516
|
+
import { ProdottoSchema, ProdottiCollectionSchema } from './schema';
|
|
1517
|
+
|
|
1518
|
+
export type Prodotto = z.infer<typeof ProdottoSchema>;
|
|
1519
|
+
export type ProdottiCollection = z.infer<typeof ProdottiCollectionSchema>;
|
|
1520
|
+
|
|
1399
1521
|
END_OF_FILE_CONTENT
|
|
1400
1522
|
mkdir -p "src/components"
|
|
1401
1523
|
mkdir -p "src/components/admin"
|
|
@@ -1420,6 +1542,7 @@ import { ComponentRegistry } from '@/lib/ComponentRegistry';
|
|
|
1420
1542
|
import { iconMap } from '@/lib/IconResolver';
|
|
1421
1543
|
import { SECTION_SCHEMAS } from '@/lib/schemas';
|
|
1422
1544
|
import { buildEnabledWebMcpConfig } from '@/lib/webmcp/buildEnabledWebMcpConfig';
|
|
1545
|
+
import { adminToolExecutors, declaredAdminTools } from '@/lib/webmcp/toolRegistry';
|
|
1423
1546
|
|
|
1424
1547
|
export type AdminStudioClientProps = {
|
|
1425
1548
|
tenantId?: string;
|
|
@@ -1507,7 +1630,11 @@ export function AdminStudioClient({
|
|
|
1507
1630
|
themeCss: { tenant: '' },
|
|
1508
1631
|
iconRegistry: iconMap,
|
|
1509
1632
|
addSection: addSectionConfig,
|
|
1510
|
-
webmcp:
|
|
1633
|
+
webmcp: {
|
|
1634
|
+
...buildEnabledWebMcpConfig(),
|
|
1635
|
+
tools: declaredAdminTools,
|
|
1636
|
+
toolExecutors: adminToolExecutors,
|
|
1637
|
+
},
|
|
1511
1638
|
persistence: {
|
|
1512
1639
|
saveToFile,
|
|
1513
1640
|
...(coldSave ? { coldSave } : {}),
|
|
@@ -2535,6 +2662,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/index.ts"
|
|
|
2535
2662
|
export { FormDemoView } from './View';
|
|
2536
2663
|
export { FormDemoClient } from './FormDemoClient';
|
|
2537
2664
|
export { FormDemoSchema, FormDemoSettingsSchema, FormDemoSubmissionSchema } from './schema';
|
|
2665
|
+
export { webmcpTools } from './webmcpTools';
|
|
2538
2666
|
export type { FormDemoData, FormDemoSettings } from './types';
|
|
2539
2667
|
|
|
2540
2668
|
END_OF_FILE_CONTENT
|
|
@@ -2562,6 +2690,173 @@ export const FormDemoSubmissionSchema = z.object({
|
|
|
2562
2690
|
message: z.string().min(1).describe('Free-form message body'),
|
|
2563
2691
|
});
|
|
2564
2692
|
|
|
2693
|
+
END_OF_FILE_CONTENT
|
|
2694
|
+
echo "Creating src/components/form-demo/submitFormLead.test.ts..."
|
|
2695
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/submitFormLead.test.ts"
|
|
2696
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2697
|
+
|
|
2698
|
+
const CLOUD_URL = 'https://cloud.example.com';
|
|
2699
|
+
const API_KEY = 'secret-key';
|
|
2700
|
+
|
|
2701
|
+
async function loadSubmitFormLead() {
|
|
2702
|
+
vi.resetModules();
|
|
2703
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', CLOUD_URL);
|
|
2704
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', API_KEY);
|
|
2705
|
+
return await import('./submitFormLead');
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
beforeEach(() => {
|
|
2709
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2712
|
+
afterEach(() => {
|
|
2713
|
+
vi.unstubAllEnvs();
|
|
2714
|
+
vi.unstubAllGlobals();
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
function mockFetchOk() {
|
|
2718
|
+
const fetchMock = vi.mocked(fetch);
|
|
2719
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
2720
|
+
return fetchMock;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
describe('submitFormLead', () => {
|
|
2724
|
+
it('POSTs to {cloudUrl}/form/send with parity headers and full payload', async () => {
|
|
2725
|
+
const { submitFormLead } = await loadSubmitFormLead();
|
|
2726
|
+
const fetchMock = mockFetchOk();
|
|
2727
|
+
|
|
2728
|
+
const ctx = {
|
|
2729
|
+
recipientEmail: 'lead@example.com',
|
|
2730
|
+
sectionId: 'form-demo-1',
|
|
2731
|
+
pageSlug: 'contatti',
|
|
2732
|
+
};
|
|
2733
|
+
const args = { name: 'Ada', email: 'ada@example.com', message: 'Ciao' };
|
|
2734
|
+
|
|
2735
|
+
await expect(submitFormLead(args, ctx)).resolves.toEqual({ ok: true });
|
|
2736
|
+
|
|
2737
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
2738
|
+
expect(url).toBe(`${CLOUD_URL}/form/send`);
|
|
2739
|
+
const headers = init.headers as Record<string, string>;
|
|
2740
|
+
expect(headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
|
2741
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
2742
|
+
expect(headers['Idempotency-Key']).toMatch(/^form-form-demo-1-\d+$/);
|
|
2743
|
+
const payload = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
2744
|
+
expect(payload).toMatchObject({
|
|
2745
|
+
name: 'Ada',
|
|
2746
|
+
email: 'ada@example.com',
|
|
2747
|
+
message: 'Ciao',
|
|
2748
|
+
recipientEmail: 'lead@example.com',
|
|
2749
|
+
page: 'contatti',
|
|
2750
|
+
section: 'form-demo-1',
|
|
2751
|
+
source: 'olon-webmcp',
|
|
2752
|
+
});
|
|
2753
|
+
expect(typeof payload.submittedAt).toBe('string');
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
it('uses the dev-declared path, normalizing slashes', async () => {
|
|
2757
|
+
const { submitFormLead } = await loadSubmitFormLead();
|
|
2758
|
+
const fetchMock = mockFetchOk();
|
|
2759
|
+
|
|
2760
|
+
await submitFormLead(
|
|
2761
|
+
{ name: 'A', email: 'a@b.c', message: 'm' },
|
|
2762
|
+
{ recipientEmail: '', sectionId: 's1', pageSlug: 'x', path: '/lead/submit/' },
|
|
2763
|
+
);
|
|
2764
|
+
|
|
2765
|
+
expect((fetchMock.mock.calls[0] as [string])[0]).toBe(`${CLOUD_URL}/lead/submit`);
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
it('throws a readable error on a non-ok response', async () => {
|
|
2769
|
+
const { submitFormLead } = await loadSubmitFormLead();
|
|
2770
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
2771
|
+
new Response(JSON.stringify({ error: 'bad gateway' }), { status: 502 }),
|
|
2772
|
+
);
|
|
2773
|
+
|
|
2774
|
+
await expect(
|
|
2775
|
+
submitFormLead(
|
|
2776
|
+
{ name: 'A', email: 'a@b.c', message: 'm' },
|
|
2777
|
+
{ recipientEmail: '', sectionId: 's1', pageSlug: 'x' },
|
|
2778
|
+
),
|
|
2779
|
+
).rejects.toThrow(/bad gateway/);
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
it('throws an explicit error when the cloud env is not configured', async () => {
|
|
2783
|
+
vi.resetModules();
|
|
2784
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', '');
|
|
2785
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', '');
|
|
2786
|
+
const { submitFormLead } = await import('./submitFormLead');
|
|
2787
|
+
|
|
2788
|
+
await expect(
|
|
2789
|
+
submitFormLead(
|
|
2790
|
+
{ name: 'A', email: 'a@b.c', message: 'm' },
|
|
2791
|
+
{ recipientEmail: '', sectionId: 's1', pageSlug: 'x' },
|
|
2792
|
+
),
|
|
2793
|
+
).rejects.toThrow(/Cloud env not configured/);
|
|
2794
|
+
});
|
|
2795
|
+
});
|
|
2796
|
+
|
|
2797
|
+
END_OF_FILE_CONTENT
|
|
2798
|
+
echo "Creating src/components/form-demo/submitFormLead.ts..."
|
|
2799
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/submitFormLead.ts"
|
|
2800
|
+
import { cloudPolicy } from '@/lib/env/tenantEnv';
|
|
2801
|
+
|
|
2802
|
+
export interface SubmitFormArgs {
|
|
2803
|
+
name: string;
|
|
2804
|
+
email: string;
|
|
2805
|
+
message: string;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
export interface SubmitFormContext {
|
|
2809
|
+
recipientEmail: string;
|
|
2810
|
+
sectionId: string;
|
|
2811
|
+
pageSlug: string;
|
|
2812
|
+
/** The dev-declared endpoint path (the variable piece); default 'form/send'. */
|
|
2813
|
+
path?: string;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* Shared submit executor for form sections (spec 03-webmcp-tool-submit-plugin).
|
|
2818
|
+
*
|
|
2819
|
+
* Base URL always comes from the cloud env chain (`tenantEnv`/`cloudPolicy`);
|
|
2820
|
+
* the dev declares only the variable path. Header parity with tenant-alpha's
|
|
2821
|
+
* `useOlonForms` (Bearer + Idempotency-Key).
|
|
2822
|
+
*/
|
|
2823
|
+
export async function submitFormLead(
|
|
2824
|
+
args: SubmitFormArgs,
|
|
2825
|
+
ctx: SubmitFormContext,
|
|
2826
|
+
): Promise<{ ok: true }> {
|
|
2827
|
+
const base = (cloudPolicy.apiUrl ?? '').replace(/\/$/, '');
|
|
2828
|
+
if (!base || !cloudPolicy.apiKey) {
|
|
2829
|
+
throw new Error('Cloud env not configured (NEXT_PUBLIC_OLONJS_*).');
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
const path = (ctx.path ?? 'form/send').replace(/^\/+|\/+$/g, '');
|
|
2833
|
+
const endpoint = `${base}/${path}`;
|
|
2834
|
+
|
|
2835
|
+
const response = await fetch(endpoint, {
|
|
2836
|
+
method: 'POST',
|
|
2837
|
+
headers: {
|
|
2838
|
+
Authorization: `Bearer ${cloudPolicy.apiKey}`,
|
|
2839
|
+
'Content-Type': 'application/json',
|
|
2840
|
+
'Idempotency-Key': `form-${ctx.sectionId}-${Date.now()}`,
|
|
2841
|
+
},
|
|
2842
|
+
body: JSON.stringify({
|
|
2843
|
+
...args,
|
|
2844
|
+
recipientEmail: ctx.recipientEmail,
|
|
2845
|
+
page: ctx.pageSlug,
|
|
2846
|
+
section: ctx.sectionId,
|
|
2847
|
+
source: 'olon-webmcp',
|
|
2848
|
+
submittedAt: new Date().toISOString(),
|
|
2849
|
+
}),
|
|
2850
|
+
});
|
|
2851
|
+
|
|
2852
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
2853
|
+
if (!response.ok) {
|
|
2854
|
+
throw new Error(body.error ?? `Submit failed (${response.status})`);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
return { ok: true };
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2565
2860
|
END_OF_FILE_CONTENT
|
|
2566
2861
|
echo "Creating src/components/form-demo/types.ts..."
|
|
2567
2862
|
cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/types.ts"
|
|
@@ -2571,6 +2866,27 @@ import { FormDemoSchema, FormDemoSettingsSchema } from './schema';
|
|
|
2571
2866
|
export type FormDemoData = z.infer<typeof FormDemoSchema>;
|
|
2572
2867
|
export type FormDemoSettings = z.infer<typeof FormDemoSettingsSchema>;
|
|
2573
2868
|
|
|
2869
|
+
END_OF_FILE_CONTENT
|
|
2870
|
+
echo "Creating src/components/form-demo/webmcpTools.ts..."
|
|
2871
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/webmcpTools.ts"
|
|
2872
|
+
import type { WebMcpToolDef } from '@olonjs/core';
|
|
2873
|
+
import { FormDemoSubmissionSchema } from './schema';
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* Public WebMCP tools declared by the form-demo capsule (spec 03).
|
|
2877
|
+
* Serializable defs only — the executor is registered client-side in
|
|
2878
|
+
* `src/lib/webmcp/submitFormTool.ts` (functions cannot cross the RSC boundary).
|
|
2879
|
+
*/
|
|
2880
|
+
export const webmcpTools: readonly WebMcpToolDef[] = [
|
|
2881
|
+
{
|
|
2882
|
+
name: 'submit-form',
|
|
2883
|
+
description: 'Submit the contact form on this page (name, email, message).',
|
|
2884
|
+
surface: 'public',
|
|
2885
|
+
inputSchema: FormDemoSubmissionSchema.toJSONSchema() as Record<string, unknown>,
|
|
2886
|
+
annotations: { consequentialHint: true },
|
|
2887
|
+
},
|
|
2888
|
+
];
|
|
2889
|
+
|
|
2574
2890
|
END_OF_FILE_CONTENT
|
|
2575
2891
|
mkdir -p "src/components/header"
|
|
2576
2892
|
echo "Creating src/components/header/View.tsx..."
|
|
@@ -2661,6 +2977,183 @@ import { HeaderSchema, HeaderSettingsSchema } from './schema';
|
|
|
2661
2977
|
export type HeaderData = z.infer<typeof HeaderSchema>;
|
|
2662
2978
|
export type HeaderSettings = z.infer<typeof HeaderSettingsSchema>;
|
|
2663
2979
|
|
|
2980
|
+
END_OF_FILE_CONTENT
|
|
2981
|
+
mkdir -p "src/components/prodotti-list"
|
|
2982
|
+
echo "Creating src/components/prodotti-list/View.tsx..."
|
|
2983
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/View.tsx"
|
|
2984
|
+
import type { Prodotto } from '@/collections/prodotti';
|
|
2985
|
+
import type { ProdottiListData, ProdottiListSettings } from './types';
|
|
2986
|
+
|
|
2987
|
+
type ProdottiListViewProps = {
|
|
2988
|
+
data: ProdottiListData;
|
|
2989
|
+
settings?: ProdottiListSettings;
|
|
2990
|
+
};
|
|
2991
|
+
|
|
2992
|
+
function formatPrice(prodotto: Prodotto): string {
|
|
2993
|
+
const amount = (prodotto.price / 100).toFixed(2);
|
|
2994
|
+
return `${amount} ${prodotto.currency ?? 'EUR'}`;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
function toProdotti(items: ProdottiListData['items']): Prodotto[] {
|
|
2998
|
+
return Object.values(items ?? {}).sort((a, b) => a.title.localeCompare(b.title));
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* RSC-safe catalog list — data comes from a PROJECTED external source
|
|
3003
|
+
* (src/sources/catalogo.ts). The View itself never fetches (CIP).
|
|
3004
|
+
*/
|
|
3005
|
+
export function ProdottiListView({ data }: ProdottiListViewProps) {
|
|
3006
|
+
const prodotti = toProdotti(data.items);
|
|
3007
|
+
|
|
3008
|
+
return (
|
|
3009
|
+
<section className="bg-background px-6 py-16 text-foreground">
|
|
3010
|
+
<div className="mx-auto w-full max-w-5xl">
|
|
3011
|
+
{data.eyebrow ? (
|
|
3012
|
+
<p className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
3013
|
+
{data.eyebrow}
|
|
3014
|
+
</p>
|
|
3015
|
+
) : null}
|
|
3016
|
+
<h1 data-jp-field="title" className="mt-2 text-3xl font-semibold">
|
|
3017
|
+
{data.title}
|
|
3018
|
+
</h1>
|
|
3019
|
+
{data.description ? (
|
|
3020
|
+
<p data-jp-field="description" className="mt-3 max-w-2xl text-muted-foreground">
|
|
3021
|
+
{data.description}
|
|
3022
|
+
</p>
|
|
3023
|
+
) : null}
|
|
3024
|
+
|
|
3025
|
+
<ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
3026
|
+
{prodotti.map((prodotto) => (
|
|
3027
|
+
<li
|
|
3028
|
+
key={prodotto.id}
|
|
3029
|
+
data-jp-item-id={prodotto.id}
|
|
3030
|
+
data-jp-item-field="items"
|
|
3031
|
+
className="rounded-2xl border border-border bg-card p-6 shadow-sm"
|
|
3032
|
+
>
|
|
3033
|
+
<a
|
|
3034
|
+
href={`/prodotti/${prodotto.id}`}
|
|
3035
|
+
className="text-lg font-medium hover:underline"
|
|
3036
|
+
>
|
|
3037
|
+
{prodotto.title}
|
|
3038
|
+
</a>
|
|
3039
|
+
<p className="mt-2 text-sm text-muted-foreground">{formatPrice(prodotto)}</p>
|
|
3040
|
+
{prodotto.description ? (
|
|
3041
|
+
<p className="mt-3 text-sm text-muted-foreground">{prodotto.description}</p>
|
|
3042
|
+
) : null}
|
|
3043
|
+
</li>
|
|
3044
|
+
))}
|
|
3045
|
+
</ul>
|
|
3046
|
+
|
|
3047
|
+
{prodotti.length === 0 ? (
|
|
3048
|
+
<p className="mt-10 text-muted-foreground">No products available.</p>
|
|
3049
|
+
) : null}
|
|
3050
|
+
</div>
|
|
3051
|
+
</section>
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
END_OF_FILE_CONTENT
|
|
3056
|
+
echo "Creating src/components/prodotti-list/index.ts..."
|
|
3057
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/index.ts"
|
|
3058
|
+
export { ProdottiListView } from './View';
|
|
3059
|
+
export { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
|
|
3060
|
+
export type { ProdottiListData, ProdottiListSettings } from './types';
|
|
3061
|
+
|
|
3062
|
+
END_OF_FILE_CONTENT
|
|
3063
|
+
echo "Creating src/components/prodotti-list/schema.ts..."
|
|
3064
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/schema.ts"
|
|
3065
|
+
import { z } from 'zod';
|
|
3066
|
+
import { BaseSectionData } from '@olonjs/core';
|
|
3067
|
+
import { ProdottoSchema } from '@/collections/prodotti';
|
|
3068
|
+
|
|
3069
|
+
export const ProdottiListSchema = BaseSectionData.extend({
|
|
3070
|
+
eyebrow: z.string().optional().describe('ui:text'),
|
|
3071
|
+
title: z.string().describe('ui:text'),
|
|
3072
|
+
description: z.string().optional().describe('ui:textarea'),
|
|
3073
|
+
items: z.record(z.string(), ProdottoSchema).describe('ui:collection-ref:catalogo'),
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
export const ProdottiListSettingsSchema = z.object({});
|
|
3077
|
+
|
|
3078
|
+
END_OF_FILE_CONTENT
|
|
3079
|
+
echo "Creating src/components/prodotti-list/types.ts..."
|
|
3080
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/types.ts"
|
|
3081
|
+
import type { z } from 'zod';
|
|
3082
|
+
import type { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
|
|
3083
|
+
|
|
3084
|
+
export type ProdottiListData = z.infer<typeof ProdottiListSchema>;
|
|
3085
|
+
export type ProdottiListSettings = z.infer<typeof ProdottiListSettingsSchema>;
|
|
3086
|
+
|
|
3087
|
+
END_OF_FILE_CONTENT
|
|
3088
|
+
mkdir -p "src/components/prodotto-detail"
|
|
3089
|
+
echo "Creating src/components/prodotto-detail/View.tsx..."
|
|
3090
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/View.tsx"
|
|
3091
|
+
import type { ProdottoDetailData, ProdottoDetailSettings } from './types';
|
|
3092
|
+
|
|
3093
|
+
type ProdottoDetailViewProps = {
|
|
3094
|
+
data: ProdottoDetailData;
|
|
3095
|
+
settings?: ProdottoDetailSettings;
|
|
3096
|
+
};
|
|
3097
|
+
|
|
3098
|
+
function formatPrice(price: number, currency?: string): string {
|
|
3099
|
+
return `${(price / 100).toFixed(2)} ${currency ?? 'EUR'}`;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
/** RSC-safe product detail — resolved from `collection:current` (projected source). */
|
|
3103
|
+
export function ProdottoDetailView({ data }: ProdottoDetailViewProps) {
|
|
3104
|
+
const prodotto = data.item;
|
|
3105
|
+
|
|
3106
|
+
return (
|
|
3107
|
+
<main className="min-h-screen bg-background px-6 py-16 text-foreground">
|
|
3108
|
+
<article
|
|
3109
|
+
data-jp-field="item"
|
|
3110
|
+
data-jp-item-id={prodotto.id}
|
|
3111
|
+
data-jp-item-field="item"
|
|
3112
|
+
className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-card p-8 shadow-sm"
|
|
3113
|
+
>
|
|
3114
|
+
<a href="/prodotti" className="text-sm font-medium text-muted-foreground hover:text-foreground">
|
|
3115
|
+
{data.backLabel}
|
|
3116
|
+
</a>
|
|
3117
|
+
<h1 className="mt-10 text-3xl font-semibold">{prodotto.title}</h1>
|
|
3118
|
+
<p className="mt-3 text-lg">{formatPrice(prodotto.price, prodotto.currency)}</p>
|
|
3119
|
+
{prodotto.description ? (
|
|
3120
|
+
<p className="mt-6 text-muted-foreground">{prodotto.description}</p>
|
|
3121
|
+
) : null}
|
|
3122
|
+
</article>
|
|
3123
|
+
</main>
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
END_OF_FILE_CONTENT
|
|
3128
|
+
echo "Creating src/components/prodotto-detail/index.ts..."
|
|
3129
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/index.ts"
|
|
3130
|
+
export { ProdottoDetailView } from './View';
|
|
3131
|
+
export { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
|
|
3132
|
+
export type { ProdottoDetailData, ProdottoDetailSettings } from './types';
|
|
3133
|
+
|
|
3134
|
+
END_OF_FILE_CONTENT
|
|
3135
|
+
echo "Creating src/components/prodotto-detail/schema.ts..."
|
|
3136
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/schema.ts"
|
|
3137
|
+
import { z } from 'zod';
|
|
3138
|
+
import { BaseSectionData } from '@olonjs/core';
|
|
3139
|
+
import { ProdottoSchema } from '@/collections/prodotti';
|
|
3140
|
+
|
|
3141
|
+
export const ProdottoDetailSchema = BaseSectionData.extend({
|
|
3142
|
+
item: ProdottoSchema.describe('ui:collection-ref'),
|
|
3143
|
+
backLabel: z.string().default('Torna al catalogo').describe('ui:text'),
|
|
3144
|
+
});
|
|
3145
|
+
|
|
3146
|
+
export const ProdottoDetailSettingsSchema = z.object({});
|
|
3147
|
+
|
|
3148
|
+
END_OF_FILE_CONTENT
|
|
3149
|
+
echo "Creating src/components/prodotto-detail/types.ts..."
|
|
3150
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/types.ts"
|
|
3151
|
+
import type { z } from 'zod';
|
|
3152
|
+
import type { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
|
|
3153
|
+
|
|
3154
|
+
export type ProdottoDetailData = z.infer<typeof ProdottoDetailSchema>;
|
|
3155
|
+
export type ProdottoDetailSettings = z.infer<typeof ProdottoDetailSettingsSchema>;
|
|
3156
|
+
|
|
2664
3157
|
END_OF_FILE_CONTENT
|
|
2665
3158
|
mkdir -p "src/components/webmcp"
|
|
2666
3159
|
echo "Creating src/components/webmcp/WebMcpVisitorRuntime.tsx..."
|
|
@@ -2668,16 +3161,38 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/webmcp/WebMcpVisitorRuntime.tsx"
|
|
|
2668
3161
|
'use client';
|
|
2669
3162
|
|
|
2670
3163
|
import { useEffect } from 'react';
|
|
3164
|
+
import { registerWebMcpTool } from '@olonjs/core';
|
|
2671
3165
|
import { bootstrapVisitorWebMcpRuntime } from '@/lib/webmcp/bootstrapVisitorWebMcpRuntime';
|
|
3166
|
+
// Side effect: registers the submit-form executor (spec 03). Client-only import.
|
|
3167
|
+
import '@/lib/webmcp/submitFormTool';
|
|
3168
|
+
import { visitorToolDefs } from '@/lib/webmcp/visitorToolDefs';
|
|
2672
3169
|
|
|
2673
3170
|
/**
|
|
2674
|
-
* Client island: WebMCP imperative API on public pages
|
|
2675
|
-
*
|
|
3171
|
+
* Client island: WebMCP imperative API on public pages.
|
|
3172
|
+
* Installs the polyfill and registers the public tools enabled for the page
|
|
3173
|
+
* (spec 02-webmcp-tool-public-runtime). Must not import @olonjs/studio.
|
|
3174
|
+
*
|
|
3175
|
+
* `toolNames` are computed server-side from the sections present on the page
|
|
3176
|
+
* (functions cannot cross the RSC boundary — only names travel).
|
|
2676
3177
|
*/
|
|
2677
|
-
export function WebMcpVisitorRuntime() {
|
|
3178
|
+
export function WebMcpVisitorRuntime({ toolNames = [] }: { toolNames?: string[] }) {
|
|
2678
3179
|
useEffect(() => {
|
|
2679
3180
|
bootstrapVisitorWebMcpRuntime();
|
|
2680
|
-
|
|
3181
|
+
const unregister = toolNames.map((name) => {
|
|
3182
|
+
const def = visitorToolDefs[name];
|
|
3183
|
+
if (!def) {
|
|
3184
|
+
console.warn(`[WebMCP] visitor tool "${name}" has no executor definition.`);
|
|
3185
|
+
return () => undefined;
|
|
3186
|
+
}
|
|
3187
|
+
return registerWebMcpTool({
|
|
3188
|
+
...def,
|
|
3189
|
+
inputSchema: def.inputSchema ?? { type: 'object', additionalProperties: false, properties: {} },
|
|
3190
|
+
});
|
|
3191
|
+
});
|
|
3192
|
+
return () => {
|
|
3193
|
+
unregister.forEach((stop) => stop());
|
|
3194
|
+
};
|
|
3195
|
+
}, [toolNames]);
|
|
2681
3196
|
|
|
2682
3197
|
return null;
|
|
2683
3198
|
}
|
|
@@ -3062,8 +3577,8 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
|
|
|
3062
3577
|
"summary": "Fuga, destino e sogno si intrecciano in un romanzo sospeso tra reale e mitico."
|
|
3063
3578
|
},
|
|
3064
3579
|
"americanah": {
|
|
3065
|
-
"id": "
|
|
3066
|
-
"title": "
|
|
3580
|
+
"id": "americana",
|
|
3581
|
+
"title": "Americana",
|
|
3067
3582
|
"author": {
|
|
3068
3583
|
"$ref": "../autori/autori.json#/chimamanda-ngozi-adichie"
|
|
3069
3584
|
},
|
|
@@ -3134,6 +3649,10 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
|
|
|
3134
3649
|
"label": "Get started",
|
|
3135
3650
|
"href": "#Getstarted"
|
|
3136
3651
|
},
|
|
3652
|
+
{
|
|
3653
|
+
"label": "Prodotti",
|
|
3654
|
+
"href": "/prodotti"
|
|
3655
|
+
},
|
|
3137
3656
|
{
|
|
3138
3657
|
"label": "GitHub",
|
|
3139
3658
|
"href": "https://github.com/olonjs/core"
|
|
@@ -3388,31 +3907,30 @@ mkdir -p "src/data/pages"
|
|
|
3388
3907
|
mkdir -p "src/data/pages/authors"
|
|
3389
3908
|
echo "Creating src/data/pages/authors.json..."
|
|
3390
3909
|
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
|
-
|
|
3910
|
+
{
|
|
3911
|
+
"id": "authors-page",
|
|
3912
|
+
"slug": "authors",
|
|
3913
|
+
"meta": {
|
|
3914
|
+
"title": "Authors",
|
|
3915
|
+
"description": "Author directory powered by the autori collection."
|
|
3916
|
+
},
|
|
3917
|
+
"sections": [
|
|
3918
|
+
{
|
|
3919
|
+
"id": "authors-list-1",
|
|
3920
|
+
"type": "authors-list",
|
|
3921
|
+
"data": {
|
|
3922
|
+
"anchorId": "authors",
|
|
3923
|
+
"eyebrow": "Collection demo",
|
|
3924
|
+
"title": "Authors",
|
|
3925
|
+
"description": "A collection-backed directory of authors referenced by books.",
|
|
3926
|
+
"items": {
|
|
3927
|
+
"$ref": "../collections/autori/autori.json"
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
],
|
|
3932
|
+
"global-header": false
|
|
3933
|
+
}
|
|
3416
3934
|
END_OF_FILE_CONTENT
|
|
3417
3935
|
mkdir -p "src/data/pages/authors/[authorId]"
|
|
3418
3936
|
echo "Creating src/data/pages/authors/[authorId]/libri.json..."
|
|
@@ -3559,46 +4077,111 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri/[slug].json"
|
|
|
3559
4077
|
"global-header": false
|
|
3560
4078
|
}
|
|
3561
4079
|
|
|
4080
|
+
END_OF_FILE_CONTENT
|
|
4081
|
+
mkdir -p "src/data/pages/prodotti"
|
|
4082
|
+
echo "Creating src/data/pages/prodotti.json..."
|
|
4083
|
+
cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti.json"
|
|
4084
|
+
{
|
|
4085
|
+
"id": "prodotti-page",
|
|
4086
|
+
"slug": "prodotti",
|
|
4087
|
+
"meta": {
|
|
4088
|
+
"title": "Prodotti",
|
|
4089
|
+
"description": "Catalogo proiettato da una sorgente esterna (demo @olonjs/sources)."
|
|
4090
|
+
},
|
|
4091
|
+
"sections": [
|
|
4092
|
+
{
|
|
4093
|
+
"id": "prodotti-list-1",
|
|
4094
|
+
"type": "prodotti-list",
|
|
4095
|
+
"data": {
|
|
4096
|
+
"anchorId": "catalogo-prodotti",
|
|
4097
|
+
"eyebrow": "Projected source demo",
|
|
4098
|
+
"title": "Prodotti",
|
|
4099
|
+
"description": "Catalogo letto da una sorgente esterna, validato con la sua Zod shape e proiettato nel contratto OlonJS.",
|
|
4100
|
+
"items": {
|
|
4101
|
+
"$ref": "../collections/catalogo/catalogo.json"
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
],
|
|
4106
|
+
"global-header": false
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
END_OF_FILE_CONTENT
|
|
4110
|
+
echo "Creating src/data/pages/prodotti/[slug].json..."
|
|
4111
|
+
cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti/[slug].json"
|
|
4112
|
+
{
|
|
4113
|
+
"id": "prodotto-detail-page",
|
|
4114
|
+
"slug": "prodotti/[slug]",
|
|
4115
|
+
"meta": {
|
|
4116
|
+
"title": "Dettaglio prodotto",
|
|
4117
|
+
"description": "Pagina dinamica alimentata dalla sorgente esterna catalogo."
|
|
4118
|
+
},
|
|
4119
|
+
"sections": [
|
|
4120
|
+
{
|
|
4121
|
+
"id": "prodotto-detail-1",
|
|
4122
|
+
"type": "prodotto-detail",
|
|
4123
|
+
"data": {
|
|
4124
|
+
"anchorId": "dettaglio-prodotto",
|
|
4125
|
+
"item": {
|
|
4126
|
+
"$ref": "collection:current"
|
|
4127
|
+
},
|
|
4128
|
+
"backLabel": "← Torna al catalogo"
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
],
|
|
4132
|
+
"collection": {
|
|
4133
|
+
"source": "catalogo",
|
|
4134
|
+
"paramKey": "slug"
|
|
4135
|
+
},
|
|
4136
|
+
"global-header": false
|
|
4137
|
+
}
|
|
4138
|
+
|
|
3562
4139
|
END_OF_FILE_CONTENT
|
|
3563
4140
|
# SKIP: src/index.css is binary and cannot be embedded as text.
|
|
3564
4141
|
mkdir -p "src/lib"
|
|
3565
4142
|
echo "Creating src/lib/CollectionRegistry.ts..."
|
|
3566
4143
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/CollectionRegistry.ts"
|
|
3567
|
-
import { AutoriCollectionSchema } from '@/collections/autori';
|
|
3568
|
-
import { LibriCollectionSchema } from '@/collections/libri';
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
4144
|
+
import { AutoriCollectionSchema } from '@/collections/autori';
|
|
4145
|
+
import { LibriCollectionSchema } from '@/collections/libri';
|
|
4146
|
+
import { ProdottiCollectionSchema } from '@/collections/prodotti';
|
|
4147
|
+
|
|
4148
|
+
export const CollectionRegistry = {
|
|
4149
|
+
autori: AutoriCollectionSchema,
|
|
4150
|
+
libri: LibriCollectionSchema,
|
|
4151
|
+
catalogo: ProdottiCollectionSchema,
|
|
4152
|
+
} as const;
|
|
4153
|
+
|
|
4154
|
+
export type CollectionType = keyof typeof CollectionRegistry;
|
|
3576
4155
|
|
|
3577
4156
|
END_OF_FILE_CONTENT
|
|
3578
4157
|
echo "Creating src/lib/ComponentRegistry.tsx..."
|
|
3579
4158
|
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
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
'
|
|
3597
|
-
'
|
|
3598
|
-
|
|
3599
|
-
'
|
|
3600
|
-
|
|
3601
|
-
|
|
4159
|
+
import type React from 'react';
|
|
4160
|
+
import type { SectionType } from '@/types';
|
|
4161
|
+
import type { SectionComponentPropsMap } from '@/types';
|
|
4162
|
+
import { AuthorsListView } from '@/components/authors-list';
|
|
4163
|
+
import { BookDetailView } from '@/components/book-detail';
|
|
4164
|
+
import { BooksListView } from '@/components/books-list';
|
|
4165
|
+
import { EmptyTenantView } from '@/components/empty-tenant';
|
|
4166
|
+
import { FooterView } from '@/components/footer';
|
|
4167
|
+
import { FormDemoView } from '@/components/form-demo';
|
|
4168
|
+
import { HeaderView } from '@/components/header';
|
|
4169
|
+
import { ProdottoDetailView } from '@/components/prodotto-detail';
|
|
4170
|
+
import { ProdottiListView } from '@/components/prodotti-list';
|
|
4171
|
+
|
|
4172
|
+
export const ComponentRegistry: {
|
|
4173
|
+
[K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
|
|
4174
|
+
} = {
|
|
4175
|
+
'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
|
|
4176
|
+
'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
|
|
4177
|
+
'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
|
|
4178
|
+
'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
|
|
4179
|
+
footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
|
|
4180
|
+
'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
|
|
4181
|
+
header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
|
|
4182
|
+
'prodotti-list': ProdottiListView as React.FC<SectionComponentPropsMap['prodotti-list']>,
|
|
4183
|
+
'prodotto-detail': ProdottoDetailView as React.FC<SectionComponentPropsMap['prodotto-detail']>,
|
|
4184
|
+
};
|
|
3602
4185
|
|
|
3603
4186
|
END_OF_FILE_CONTENT
|
|
3604
4187
|
echo "Creating src/lib/IconResolver.tsx..."
|
|
@@ -5152,13 +5735,57 @@ END_OF_FILE_CONTENT
|
|
|
5152
5735
|
echo "Creating src/lib/loaders/loadPublicPageBundleForRequest.ts..."
|
|
5153
5736
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadPublicPageBundleForRequest.ts"
|
|
5154
5737
|
import type { PublicPageContentBundle } from '@olonjs/next/server';
|
|
5738
|
+
import { resolvePageMatchFromRegistry } from '@olonjs/core';
|
|
5739
|
+
import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
|
|
5155
5740
|
import {
|
|
5156
5741
|
buildServerApiCandidates,
|
|
5157
5742
|
type ServerCloudBootSource,
|
|
5158
5743
|
} from '@/lib/env/serverCloudPolicy';
|
|
5744
|
+
import { collectionSources } from '@/sources';
|
|
5159
5745
|
import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
|
|
5160
5746
|
import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
|
|
5161
5747
|
|
|
5748
|
+
/**
|
|
5749
|
+
* Project external collection sources referenced by the requested slug's page
|
|
5750
|
+
* (ADR-0018). Without this, page JSON endpoints would expose unresolved $refs.
|
|
5751
|
+
*/
|
|
5752
|
+
export async function withProjectedSources(
|
|
5753
|
+
bundle: PublicPageContentBundle,
|
|
5754
|
+
slug: string,
|
|
5755
|
+
appRoot?: string,
|
|
5756
|
+
): Promise<PublicPageContentBundle> {
|
|
5757
|
+
const match = resolvePageMatchFromRegistry(
|
|
5758
|
+
bundle.pages as never,
|
|
5759
|
+
slug
|
|
5760
|
+
);
|
|
5761
|
+
if (!match) return bundle;
|
|
5762
|
+
|
|
5763
|
+
const needed = collectCollectionSources(match.page).filter(
|
|
5764
|
+
(name) => name in collectionSources
|
|
5765
|
+
);
|
|
5766
|
+
if (needed.length === 0) return bundle;
|
|
5767
|
+
|
|
5768
|
+
const resolved = await resolveCollectionSources({
|
|
5769
|
+
sources: collectionSources as never,
|
|
5770
|
+
needed,
|
|
5771
|
+
params: match.params,
|
|
5772
|
+
});
|
|
5773
|
+
|
|
5774
|
+
return {
|
|
5775
|
+
...bundle,
|
|
5776
|
+
collections: {
|
|
5777
|
+
...(bundle.collections ?? {}),
|
|
5778
|
+
...(resolved.collections as NonNullable<PublicPageContentBundle['collections']>),
|
|
5779
|
+
},
|
|
5780
|
+
collectionSchemas: {
|
|
5781
|
+
...(bundle.collectionSchemas ?? {}),
|
|
5782
|
+
...(resolved.collectionSchemas as NonNullable<
|
|
5783
|
+
PublicPageContentBundle['collectionSchemas']
|
|
5784
|
+
>),
|
|
5785
|
+
},
|
|
5786
|
+
};
|
|
5787
|
+
}
|
|
5788
|
+
|
|
5162
5789
|
export type LoadPublicPageBundleForRequestInput = {
|
|
5163
5790
|
bootSource: ServerCloudBootSource;
|
|
5164
5791
|
slug: string;
|
|
@@ -5190,18 +5817,76 @@ export async function loadPublicPageBundleForRequest(
|
|
|
5190
5817
|
if (!apiUrl || !apiKey) {
|
|
5191
5818
|
throw new Error('Live public page JSON requires cloud API URL and key');
|
|
5192
5819
|
}
|
|
5193
|
-
|
|
5820
|
+
const liveBundle = await loadLivePublicPageBundle({
|
|
5194
5821
|
slug: input.slug,
|
|
5195
5822
|
apiBases: buildServerApiCandidates(apiUrl),
|
|
5196
5823
|
apiKey,
|
|
5197
5824
|
appRoot,
|
|
5198
5825
|
fetchImpl: input.fetchImpl,
|
|
5199
5826
|
});
|
|
5827
|
+
return withProjectedSources(liveBundle, input.slug, appRoot);
|
|
5200
5828
|
}
|
|
5201
5829
|
|
|
5202
|
-
|
|
5830
|
+
const localBundle = await loadLocalPublicPageBundle(appRoot);
|
|
5831
|
+
return withProjectedSources(localBundle, input.slug, appRoot);
|
|
5203
5832
|
}
|
|
5204
5833
|
|
|
5834
|
+
END_OF_FILE_CONTENT
|
|
5835
|
+
echo "Creating src/lib/loaders/loadVisitorPageBundle.ts..."
|
|
5836
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadVisitorPageBundle.ts"
|
|
5837
|
+
import type { JsonPagesConfig, PageConfig } from '@olonjs/core';
|
|
5838
|
+
import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
|
|
5839
|
+
import { CollectionRegistry } from '@/lib/CollectionRegistry';
|
|
5840
|
+
import { collectionSources } from '@/sources';
|
|
5841
|
+
import { getFileCollections } from './getFileCollections';
|
|
5842
|
+
|
|
5843
|
+
export interface VisitorPageBundle {
|
|
5844
|
+
collections: NonNullable<JsonPagesConfig['collections']>;
|
|
5845
|
+
collectionSchemas: JsonPagesConfig['collectionSchemas'];
|
|
5846
|
+
/** Provenance of the projected sources resolved for this route. */
|
|
5847
|
+
sourceProvenance: Record<string, { fetchedAt: string; readOnly: boolean }>;
|
|
5848
|
+
}
|
|
5849
|
+
|
|
5850
|
+
/**
|
|
5851
|
+
* Load the per-route content bundle: file-backed collections plus the external
|
|
5852
|
+
* sources this page needs (projected, validated, read-only).
|
|
5853
|
+
*
|
|
5854
|
+
* Sources are resolved ONLY when referenced by the page (ADR-0018 / spec
|
|
5855
|
+
* external-collection-sources). Source names not registered as external
|
|
5856
|
+
* sources are file-backed and skipped here.
|
|
5857
|
+
*/
|
|
5858
|
+
export async function loadVisitorPageBundle(input: {
|
|
5859
|
+
page: PageConfig;
|
|
5860
|
+
params: Record<string, string>;
|
|
5861
|
+
appRoot?: string;
|
|
5862
|
+
}): Promise<VisitorPageBundle> {
|
|
5863
|
+
const fileCollections = getFileCollections(input.appRoot) as NonNullable<
|
|
5864
|
+
JsonPagesConfig['collections']
|
|
5865
|
+
>;
|
|
5866
|
+
|
|
5867
|
+
const needed = collectCollectionSources(input.page).filter(
|
|
5868
|
+
(name) => name in collectionSources
|
|
5869
|
+
);
|
|
5870
|
+
|
|
5871
|
+
const resolved = await resolveCollectionSources({
|
|
5872
|
+
sources: collectionSources as never,
|
|
5873
|
+
needed,
|
|
5874
|
+
params: input.params,
|
|
5875
|
+
});
|
|
5876
|
+
|
|
5877
|
+
return {
|
|
5878
|
+
collections: {
|
|
5879
|
+
...fileCollections,
|
|
5880
|
+
...(resolved.collections as NonNullable<JsonPagesConfig['collections']>),
|
|
5881
|
+
},
|
|
5882
|
+
collectionSchemas: {
|
|
5883
|
+
...(CollectionRegistry as JsonPagesConfig['collectionSchemas']),
|
|
5884
|
+
...(resolved.collectionSchemas as JsonPagesConfig['collectionSchemas']),
|
|
5885
|
+
},
|
|
5886
|
+
sourceProvenance: resolved.provenance,
|
|
5887
|
+
};
|
|
5888
|
+
}
|
|
5889
|
+
|
|
5205
5890
|
END_OF_FILE_CONTENT
|
|
5206
5891
|
echo "Creating src/lib/loaders/publishedContent.test.ts..."
|
|
5207
5892
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/publishedContent.test.ts"
|
|
@@ -5347,45 +6032,49 @@ export function resolveVisitorShell(page: PageConfig, site: SiteConfig): Visitor
|
|
|
5347
6032
|
END_OF_FILE_CONTENT
|
|
5348
6033
|
echo "Creating src/lib/schemas.ts..."
|
|
5349
6034
|
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
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
'
|
|
5362
|
-
'
|
|
5363
|
-
|
|
5364
|
-
'
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
*
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
export
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
6035
|
+
import { AuthorsListSchema } from '@/components/authors-list';
|
|
6036
|
+
import { BookDetailSchema } from '@/components/book-detail';
|
|
6037
|
+
import { BooksListSchema } from '@/components/books-list';
|
|
6038
|
+
import { EmptyTenantSchema } from '@/components/empty-tenant';
|
|
6039
|
+
import { FooterSchema } from '@/components/footer';
|
|
6040
|
+
import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
|
|
6041
|
+
import { HeaderSchema } from '@/components/header';
|
|
6042
|
+
import { ProdottoDetailSchema } from '@/components/prodotto-detail';
|
|
6043
|
+
import { ProdottiListSchema } from '@/components/prodotti-list';
|
|
6044
|
+
|
|
6045
|
+
export const SECTION_SCHEMAS = {
|
|
6046
|
+
'authors-list': AuthorsListSchema,
|
|
6047
|
+
'book-detail': BookDetailSchema,
|
|
6048
|
+
'books-list': BooksListSchema,
|
|
6049
|
+
'empty-tenant': EmptyTenantSchema,
|
|
6050
|
+
footer: FooterSchema,
|
|
6051
|
+
'form-demo': FormDemoSchema,
|
|
6052
|
+
header: HeaderSchema,
|
|
6053
|
+
'prodotti-list': ProdottiListSchema,
|
|
6054
|
+
'prodotto-detail': ProdottoDetailSchema,
|
|
6055
|
+
} as const;
|
|
6056
|
+
|
|
6057
|
+
/**
|
|
6058
|
+
* Registry of per-section-type submission schemas. Keys MUST match a key of
|
|
6059
|
+
* SECTION_SCHEMAS. A section type appearing here is declaring itself as
|
|
6060
|
+
* MCP-submittable: the Zod schema describes the payload accepted by the form.
|
|
6061
|
+
*
|
|
6062
|
+
* See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
|
|
6063
|
+
*/
|
|
6064
|
+
export const SECTION_SUBMISSION_SCHEMAS = {
|
|
6065
|
+
'form-demo': FormDemoSubmissionSchema,
|
|
6066
|
+
} as const;
|
|
6067
|
+
|
|
6068
|
+
export type SectionType = keyof typeof SECTION_SCHEMAS;
|
|
6069
|
+
|
|
6070
|
+
export {
|
|
6071
|
+
BaseSectionData,
|
|
6072
|
+
BaseArrayItem,
|
|
6073
|
+
BaseCollectionItem,
|
|
6074
|
+
BaseSectionSettingsSchema,
|
|
6075
|
+
CtaSchema,
|
|
6076
|
+
ImageSelectionSchema,
|
|
6077
|
+
} from '@olonjs/core';
|
|
5389
6078
|
|
|
5390
6079
|
END_OF_FILE_CONTENT
|
|
5391
6080
|
echo "Creating src/lib/utils.ts..."
|
|
@@ -5497,6 +6186,66 @@ export function buildEnabledWebMcpConfig(namespace?: string): {
|
|
|
5497
6186
|
};
|
|
5498
6187
|
}
|
|
5499
6188
|
|
|
6189
|
+
END_OF_FILE_CONTENT
|
|
6190
|
+
echo "Creating src/lib/webmcp/resolvePublicToolNames.test.ts..."
|
|
6191
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/resolvePublicToolNames.test.ts"
|
|
6192
|
+
import { describe, expect, it } from 'vitest';
|
|
6193
|
+
import { resolvePublicToolNames } from './resolvePublicToolNames';
|
|
6194
|
+
|
|
6195
|
+
const sectionPublicTools = {
|
|
6196
|
+
'form-demo': ['submit-form'],
|
|
6197
|
+
'shop-catalog': ['search-product', 'add-to-cart'],
|
|
6198
|
+
};
|
|
6199
|
+
|
|
6200
|
+
describe('resolvePublicToolNames', () => {
|
|
6201
|
+
it('returns the public tools of the sections present on the page, deduplicated, in page order', () => {
|
|
6202
|
+
const sections = [
|
|
6203
|
+
{ type: 'form-demo' },
|
|
6204
|
+
{ type: 'hero' },
|
|
6205
|
+
{ type: 'form-demo' },
|
|
6206
|
+
{ type: 'shop-catalog' },
|
|
6207
|
+
];
|
|
6208
|
+
expect(resolvePublicToolNames(sections, sectionPublicTools)).toEqual([
|
|
6209
|
+
'submit-form',
|
|
6210
|
+
'search-product',
|
|
6211
|
+
'add-to-cart',
|
|
6212
|
+
]);
|
|
6213
|
+
});
|
|
6214
|
+
|
|
6215
|
+
it('returns an empty list when no section declares tools', () => {
|
|
6216
|
+
expect(resolvePublicToolNames([{ type: 'hero' }], sectionPublicTools)).toEqual([]);
|
|
6217
|
+
});
|
|
6218
|
+
|
|
6219
|
+
it('returns an empty list for an empty page', () => {
|
|
6220
|
+
expect(resolvePublicToolNames([], sectionPublicTools)).toEqual([]);
|
|
6221
|
+
});
|
|
6222
|
+
|
|
6223
|
+
it('ignores tool names not present in the section map', () => {
|
|
6224
|
+
expect(resolvePublicToolNames([{ type: 'footer' }], sectionPublicTools)).toEqual([]);
|
|
6225
|
+
});
|
|
6226
|
+
});
|
|
6227
|
+
|
|
6228
|
+
END_OF_FILE_CONTENT
|
|
6229
|
+
echo "Creating src/lib/webmcp/resolvePublicToolNames.ts..."
|
|
6230
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/resolvePublicToolNames.ts"
|
|
6231
|
+
/**
|
|
6232
|
+
* Pure helper (spec 02-webmcp-tool-public-runtime): computes which public
|
|
6233
|
+
* tool names are enabled for a page, from the section types present.
|
|
6234
|
+
* Deduplicated, in page order.
|
|
6235
|
+
*/
|
|
6236
|
+
export function resolvePublicToolNames(
|
|
6237
|
+
sections: ReadonlyArray<{ type: string }>,
|
|
6238
|
+
sectionPublicTools: Record<string, readonly string[]>,
|
|
6239
|
+
): string[] {
|
|
6240
|
+
const out: string[] = [];
|
|
6241
|
+
for (const section of sections) {
|
|
6242
|
+
for (const name of sectionPublicTools[section.type] ?? []) {
|
|
6243
|
+
if (!out.includes(name)) out.push(name);
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
return out;
|
|
6247
|
+
}
|
|
6248
|
+
|
|
5500
6249
|
END_OF_FILE_CONTENT
|
|
5501
6250
|
mkdir -p "src/lib/webmcp/runtime"
|
|
5502
6251
|
echo "Creating src/lib/webmcp/runtime/agenticSurface.test.ts..."
|
|
@@ -5654,6 +6403,36 @@ describe('robots + sitemap', () => {
|
|
|
5654
6403
|
expect(xml).toContain('<lastmod>2026-09-10T10:00:00Z</lastmod>');
|
|
5655
6404
|
});
|
|
5656
6405
|
});
|
|
6406
|
+
|
|
6407
|
+
describe('declared public tools in runtime contracts', () => {
|
|
6408
|
+
const surfaceInput = {
|
|
6409
|
+
bundle,
|
|
6410
|
+
schemas,
|
|
6411
|
+
declaredTools: [
|
|
6412
|
+
{
|
|
6413
|
+
name: 'submit-form',
|
|
6414
|
+
description: 'Submit the contact form',
|
|
6415
|
+
surface: 'public',
|
|
6416
|
+
inputSchema: { type: 'object', properties: {} },
|
|
6417
|
+
},
|
|
6418
|
+
] as const,
|
|
6419
|
+
};
|
|
6420
|
+
|
|
6421
|
+
it('emits declared tools with their surface in the page contract', () => {
|
|
6422
|
+
const contract = buildRuntimePageContract({ ...surfaceInput, slug: 'home' });
|
|
6423
|
+
const submit = contract!.tools.find((t) => t.name === 'submit-form');
|
|
6424
|
+
expect(submit).toBeDefined();
|
|
6425
|
+
expect((submit as { surface?: string }).surface).toBe('public');
|
|
6426
|
+
expect(contract!.tools.map((t) => t.name)).toEqual(['update-section', 'save', 'submit-form']);
|
|
6427
|
+
});
|
|
6428
|
+
|
|
6429
|
+
it('emits declared tools in the page manifest tools list with surface', () => {
|
|
6430
|
+
const manifest = buildRuntimePageManifest({ ...surfaceInput, slug: 'home' });
|
|
6431
|
+
const submit = manifest!.tools.find((t) => t.name === 'submit-form');
|
|
6432
|
+
expect(submit).toBeDefined();
|
|
6433
|
+
expect((submit as { surface?: string }).surface).toBe('public');
|
|
6434
|
+
});
|
|
6435
|
+
});
|
|
5657
6436
|
|
|
5658
6437
|
END_OF_FILE_CONTENT
|
|
5659
6438
|
echo "Creating src/lib/webmcp/runtime/agenticSurface.ts..."
|
|
@@ -5672,6 +6451,7 @@ import {
|
|
|
5672
6451
|
webmcp,
|
|
5673
6452
|
type JsonPagesConfig,
|
|
5674
6453
|
type PageConfig,
|
|
6454
|
+
type WebMcpToolDef,
|
|
5675
6455
|
} from '@olonjs/core';
|
|
5676
6456
|
import type { PublicPageContentBundle } from '@olonjs/next/server';
|
|
5677
6457
|
|
|
@@ -5688,6 +6468,8 @@ export type RuntimeSurfaceInput = {
|
|
|
5688
6468
|
bundle: PublicPageContentBundle;
|
|
5689
6469
|
schemas: JsonPagesConfig['schemas'];
|
|
5690
6470
|
submissionSchemas?: JsonPagesConfig['submissionSchemas'];
|
|
6471
|
+
/** Tenant-declared tools (public surface), appended to the contract's built-ins. */
|
|
6472
|
+
declaredTools?: readonly WebMcpToolDef[];
|
|
5691
6473
|
};
|
|
5692
6474
|
|
|
5693
6475
|
function joinSegments(segments: readonly string[]): string {
|
|
@@ -5776,6 +6558,7 @@ export function buildRuntimeSiteManifest(input: RuntimeSurfaceInput) {
|
|
|
5776
6558
|
siteConfig: input.bundle.siteConfig,
|
|
5777
6559
|
// Bundle carries SchemaLike; the manifest builder expects the Zod registry (same objects).
|
|
5778
6560
|
collectionSchemas: input.bundle.collectionSchemas as never,
|
|
6561
|
+
declaredTools: input.declaredTools,
|
|
5779
6562
|
});
|
|
5780
6563
|
}
|
|
5781
6564
|
|
|
@@ -5788,6 +6571,7 @@ export function buildRuntimePageManifest(input: RuntimeSurfaceInput & { slug: st
|
|
|
5788
6571
|
schemas: input.schemas,
|
|
5789
6572
|
submissionSchemas: input.submissionSchemas,
|
|
5790
6573
|
siteConfig: input.bundle.siteConfig,
|
|
6574
|
+
declaredTools: input.declaredTools,
|
|
5791
6575
|
});
|
|
5792
6576
|
}
|
|
5793
6577
|
|
|
@@ -5800,6 +6584,7 @@ export function buildRuntimePageContract(input: RuntimeSurfaceInput & { slug: st
|
|
|
5800
6584
|
schemas: input.schemas,
|
|
5801
6585
|
submissionSchemas: input.submissionSchemas,
|
|
5802
6586
|
siteConfig: input.bundle.siteConfig,
|
|
6587
|
+
declaredTools: input.declaredTools,
|
|
5803
6588
|
});
|
|
5804
6589
|
}
|
|
5805
6590
|
|
|
@@ -5818,6 +6603,7 @@ export function buildRuntimeLlmsTxt(input: RuntimeSurfaceInput): string {
|
|
|
5818
6603
|
schemas: input.schemas,
|
|
5819
6604
|
submissionSchemas: input.submissionSchemas,
|
|
5820
6605
|
siteConfig: input.bundle.siteConfig,
|
|
6606
|
+
declaredTools: input.declaredTools,
|
|
5821
6607
|
});
|
|
5822
6608
|
}
|
|
5823
6609
|
|
|
@@ -5927,6 +6713,7 @@ import { readServerCloudPolicy } from '@/lib/env/serverCloudPolicy';
|
|
|
5927
6713
|
import { loadLocalPublicPageBundle } from '@/lib/loaders/loadLocalPublicPageBundle';
|
|
5928
6714
|
import { loadPublicPageBundleForRequest } from '@/lib/loaders/loadPublicPageBundleForRequest';
|
|
5929
6715
|
import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '@/lib/schemas';
|
|
6716
|
+
import { declaredPublicTools } from '@/lib/webmcp/toolRegistry';
|
|
5930
6717
|
import type { RuntimeSurfaceInput } from './agenticSurface';
|
|
5931
6718
|
|
|
5932
6719
|
const schemas = SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'];
|
|
@@ -5949,7 +6736,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
|
|
|
5949
6736
|
apiUrl: policy.apiUrl,
|
|
5950
6737
|
apiKey: policy.apiKey,
|
|
5951
6738
|
});
|
|
5952
|
-
return { bundle, schemas, submissionSchemas };
|
|
6739
|
+
return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
|
|
5953
6740
|
}
|
|
5954
6741
|
|
|
5955
6742
|
/**
|
|
@@ -5958,7 +6745,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
|
|
|
5958
6745
|
*/
|
|
5959
6746
|
export function loadRuntimeSurfaceForSite(): RuntimeSurfaceInput {
|
|
5960
6747
|
const bundle = loadLocalPublicPageBundle(path.resolve(process.cwd()));
|
|
5961
|
-
return { bundle, schemas, submissionSchemas };
|
|
6748
|
+
return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
|
|
5962
6749
|
}
|
|
5963
6750
|
|
|
5964
6751
|
/** Public base URL for absolute links (robots/sitemap). */
|
|
@@ -6018,6 +6805,236 @@ describe('next.config rewrites — runtime agentic surface', () => {
|
|
|
6018
6805
|
});
|
|
6019
6806
|
});
|
|
6020
6807
|
|
|
6808
|
+
END_OF_FILE_CONTENT
|
|
6809
|
+
echo "Creating src/lib/webmcp/submitFormTool.test.ts..."
|
|
6810
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/submitFormTool.test.ts"
|
|
6811
|
+
// @vitest-environment jsdom
|
|
6812
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6813
|
+
|
|
6814
|
+
const CLOUD_URL = 'https://cloud.example.com';
|
|
6815
|
+
|
|
6816
|
+
async function loadModules() {
|
|
6817
|
+
vi.resetModules();
|
|
6818
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', CLOUD_URL);
|
|
6819
|
+
vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', 'k');
|
|
6820
|
+
const [{ sectionPublicTools, declaredPublicTools }, { visitorToolDefs }] = await Promise.all([
|
|
6821
|
+
import('@/lib/webmcp/toolRegistry'),
|
|
6822
|
+
import('@/lib/webmcp/visitorToolDefs'),
|
|
6823
|
+
]);
|
|
6824
|
+
// Same side-effect import the visitor island performs: registers the executor.
|
|
6825
|
+
await import('@/lib/webmcp/submitFormTool');
|
|
6826
|
+
return { sectionPublicTools, declaredPublicTools, visitorToolDefs };
|
|
6827
|
+
}
|
|
6828
|
+
|
|
6829
|
+
beforeEach(() => {
|
|
6830
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
|
6831
|
+
});
|
|
6832
|
+
|
|
6833
|
+
afterEach(() => {
|
|
6834
|
+
vi.unstubAllEnvs();
|
|
6835
|
+
vi.unstubAllGlobals();
|
|
6836
|
+
document.body.innerHTML = '';
|
|
6837
|
+
});
|
|
6838
|
+
|
|
6839
|
+
describe('submit-form tool wiring', () => {
|
|
6840
|
+
it('maps form-demo to submit-form and declares the public tool def', async () => {
|
|
6841
|
+
const { sectionPublicTools, declaredPublicTools } = await loadModules();
|
|
6842
|
+
expect(sectionPublicTools['form-demo']).toEqual(['submit-form']);
|
|
6843
|
+
|
|
6844
|
+
const def = declaredPublicTools.find((t) => t.name === 'submit-form');
|
|
6845
|
+
expect(def).toBeDefined();
|
|
6846
|
+
expect(def!.surface).toBe('public');
|
|
6847
|
+
expect(def!.inputSchema).toMatchObject({
|
|
6848
|
+
type: 'object',
|
|
6849
|
+
properties: { name: { type: 'string' }, email: { type: 'string' }, message: { type: 'string' } },
|
|
6850
|
+
});
|
|
6851
|
+
});
|
|
6852
|
+
|
|
6853
|
+
it('registers the executor and submits a valid payload to {cloudUrl}/form/send', async () => {
|
|
6854
|
+
const { visitorToolDefs } = await loadModules();
|
|
6855
|
+
document.body.innerHTML =
|
|
6856
|
+
'<form id="form-demo" data-olon-recipient="lead@example.com"></form>';
|
|
6857
|
+
|
|
6858
|
+
const tool = visitorToolDefs['submit-form'];
|
|
6859
|
+
expect(tool).toBeDefined();
|
|
6860
|
+
|
|
6861
|
+
await expect(
|
|
6862
|
+
tool.execute({ name: 'Ada', email: 'ada@example.com', message: 'Ciao' }),
|
|
6863
|
+
).resolves.toEqual({ ok: true });
|
|
6864
|
+
|
|
6865
|
+
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
|
|
6866
|
+
expect(url).toBe(`${CLOUD_URL}/form/send`);
|
|
6867
|
+
const payload = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
6868
|
+
expect(payload).toMatchObject({
|
|
6869
|
+
recipientEmail: 'lead@example.com',
|
|
6870
|
+
section: 'form-demo',
|
|
6871
|
+
source: 'olon-webmcp',
|
|
6872
|
+
});
|
|
6873
|
+
});
|
|
6874
|
+
|
|
6875
|
+
it('rejects an invalid payload with a Zod validation error before any fetch', async () => {
|
|
6876
|
+
const { visitorToolDefs } = await loadModules();
|
|
6877
|
+
const tool = visitorToolDefs['submit-form'];
|
|
6878
|
+
|
|
6879
|
+
await expect(tool.execute({ name: '', email: 'not-an-email', message: '' })).rejects.toThrow();
|
|
6880
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
6881
|
+
});
|
|
6882
|
+
});
|
|
6883
|
+
|
|
6884
|
+
END_OF_FILE_CONTENT
|
|
6885
|
+
echo "Creating src/lib/webmcp/submitFormTool.ts..."
|
|
6886
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/submitFormTool.ts"
|
|
6887
|
+
import { FormDemoSubmissionSchema } from '@/components/form-demo/schema';
|
|
6888
|
+
import { submitFormLead, type SubmitFormArgs } from '@/components/form-demo/submitFormLead';
|
|
6889
|
+
import { webmcpTools } from '@/components/form-demo/webmcpTools';
|
|
6890
|
+
import { visitorToolDefs } from './visitorToolDefs';
|
|
6891
|
+
|
|
6892
|
+
const submitFormDef = webmcpTools.find((tool) => tool.name === 'submit-form');
|
|
6893
|
+
if (!submitFormDef) {
|
|
6894
|
+
throw new Error('[WebMCP] form-demo capsule must declare the submit-form tool.');
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
/**
|
|
6898
|
+
* Client-side executor registration for submit-form (spec 03).
|
|
6899
|
+
*
|
|
6900
|
+
* Imported as a side effect ONLY by the visitor island — never by server code:
|
|
6901
|
+
* the executor chain (`submitFormLead` → `tenantEnv` → `@olonjs/react`) is
|
|
6902
|
+
* client-bound. Validation uses the section's submission schema (ADR-0002),
|
|
6903
|
+
* the same shape agents see in `sectionSubmissionSchemas` of the page contract.
|
|
6904
|
+
*/
|
|
6905
|
+
visitorToolDefs['submit-form'] = {
|
|
6906
|
+
...submitFormDef,
|
|
6907
|
+
execute: async (rawArgs: unknown) => {
|
|
6908
|
+
const parsed = FormDemoSubmissionSchema.parse(rawArgs);
|
|
6909
|
+
const form = document.querySelector<HTMLFormElement>('form[data-olon-recipient]');
|
|
6910
|
+
const args = rawArgs as Record<string, unknown>;
|
|
6911
|
+
const sectionId =
|
|
6912
|
+
typeof args.sectionId === 'string' && args.sectionId.trim()
|
|
6913
|
+
? args.sectionId.trim()
|
|
6914
|
+
: form?.id || 'form-demo';
|
|
6915
|
+
return submitFormLead(parsed as SubmitFormArgs, {
|
|
6916
|
+
recipientEmail: form?.dataset.olonRecipient ?? '',
|
|
6917
|
+
sectionId,
|
|
6918
|
+
pageSlug: window.location.pathname,
|
|
6919
|
+
path: 'form/send',
|
|
6920
|
+
});
|
|
6921
|
+
},
|
|
6922
|
+
};
|
|
6923
|
+
|
|
6924
|
+
END_OF_FILE_CONTENT
|
|
6925
|
+
echo "Creating src/lib/webmcp/toolRegistry.ts..."
|
|
6926
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/toolRegistry.ts"
|
|
6927
|
+
import type { WebMcpToolDef } from '@olonjs/core';
|
|
6928
|
+
import { webmcpTools as formDemoTools } from '@/components/form-demo';
|
|
6929
|
+
|
|
6930
|
+
/**
|
|
6931
|
+
* Tenant tool registry (spec 01-webmcp-tool-contract).
|
|
6932
|
+
*
|
|
6933
|
+
* Section capsules declare tools by exporting `webmcpTools` from their
|
|
6934
|
+
* `index.ts`. Aggregate them here:
|
|
6935
|
+
* - admin-surface defs + executors flow into the Studio island
|
|
6936
|
+
* (`AdminStudioClient` → `JsonPagesConfig.webmcp`);
|
|
6937
|
+
* - public-surface tool names flow into the visitor page via
|
|
6938
|
+
* `sectionPublicTools` (module 02).
|
|
6939
|
+
*/
|
|
6940
|
+
|
|
6941
|
+
/** Admin-surface tool defs registered in the Studio island. */
|
|
6942
|
+
export const declaredAdminTools: readonly WebMcpToolDef[] = [];
|
|
6943
|
+
|
|
6944
|
+
/** Runtime executors for admin tools, keyed by tool name. */
|
|
6945
|
+
export const adminToolExecutors: Readonly<
|
|
6946
|
+
Record<string, (args: unknown) => Promise<unknown> | unknown>
|
|
6947
|
+
> = {};
|
|
6948
|
+
|
|
6949
|
+
/** Public tool names exposed per section type (consumed by module 02). */
|
|
6950
|
+
export const sectionPublicTools: Record<string, readonly string[]> = {
|
|
6951
|
+
'form-demo': formDemoTools.map((tool) => tool.name),
|
|
6952
|
+
};
|
|
6953
|
+
|
|
6954
|
+
/** Public-surface tool defs emitted in the runtime page contracts (module 02). */
|
|
6955
|
+
export const declaredPublicTools: readonly WebMcpToolDef[] = [...formDemoTools];
|
|
6956
|
+
|
|
6957
|
+
END_OF_FILE_CONTENT
|
|
6958
|
+
echo "Creating src/lib/webmcp/visitorIsland.test.ts..."
|
|
6959
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorIsland.test.ts"
|
|
6960
|
+
// @vitest-environment jsdom
|
|
6961
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6962
|
+
import { act, createElement } from 'react';
|
|
6963
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
6964
|
+
import { WebMcpVisitorRuntime } from '@/components/webmcp/WebMcpVisitorRuntime';
|
|
6965
|
+
import { visitorToolDefs } from '@/lib/webmcp/visitorToolDefs';
|
|
6966
|
+
|
|
6967
|
+
function protocol() {
|
|
6968
|
+
return (document as unknown as {
|
|
6969
|
+
modelContextProtocol: {
|
|
6970
|
+
listTools: () => Array<{ name: string }>;
|
|
6971
|
+
executeTool: (name: string, inputArgsJson: string) => Promise<string>;
|
|
6972
|
+
};
|
|
6973
|
+
}).modelContextProtocol;
|
|
6974
|
+
}
|
|
6975
|
+
|
|
6976
|
+
let root: Root | null = null;
|
|
6977
|
+
let container: HTMLDivElement | null = null;
|
|
6978
|
+
|
|
6979
|
+
afterEach(() => {
|
|
6980
|
+
delete visitorToolDefs['test-tool'];
|
|
6981
|
+
if (root) {
|
|
6982
|
+
act(() => root!.unmount());
|
|
6983
|
+
root = null;
|
|
6984
|
+
}
|
|
6985
|
+
container?.remove();
|
|
6986
|
+
container = null;
|
|
6987
|
+
const doc = document as unknown as { modelContext?: unknown; modelContextProtocol?: unknown };
|
|
6988
|
+
delete doc.modelContext;
|
|
6989
|
+
delete doc.modelContextProtocol;
|
|
6990
|
+
const win = window as unknown as { __olonWebMcpTools__?: unknown; __olonWebMcpControllers__?: unknown };
|
|
6991
|
+
delete win.__olonWebMcpTools__;
|
|
6992
|
+
delete win.__olonWebMcpControllers__;
|
|
6993
|
+
vi.restoreAllMocks();
|
|
6994
|
+
});
|
|
6995
|
+
|
|
6996
|
+
function mount(toolNames: string[]) {
|
|
6997
|
+
container = document.createElement('div');
|
|
6998
|
+
document.body.appendChild(container);
|
|
6999
|
+
root = createRoot(container);
|
|
7000
|
+
act(() => {
|
|
7001
|
+
root!.render(createElement(WebMcpVisitorRuntime, { toolNames }));
|
|
7002
|
+
});
|
|
7003
|
+
}
|
|
7004
|
+
|
|
7005
|
+
describe('WebMcpVisitorRuntime — public tool registration', () => {
|
|
7006
|
+
it('registers the declared tools for the page and executes them', async () => {
|
|
7007
|
+
visitorToolDefs['test-tool'] = {
|
|
7008
|
+
name: 'test-tool',
|
|
7009
|
+
description: 'Test tool',
|
|
7010
|
+
surface: 'public',
|
|
7011
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
7012
|
+
execute: async (args: unknown) => ({ ok: true, args }),
|
|
7013
|
+
};
|
|
7014
|
+
|
|
7015
|
+
mount(['test-tool', 'unknown-tool']);
|
|
7016
|
+
|
|
7017
|
+
await vi.waitFor(() => {
|
|
7018
|
+
expect(protocol().listTools().map((t) => t.name)).toContain('test-tool');
|
|
7019
|
+
});
|
|
7020
|
+
|
|
7021
|
+
const result = await protocol().executeTool('test-tool', '{"q":1}');
|
|
7022
|
+
expect(JSON.parse(result)).toEqual({ ok: true, args: { q: 1 } });
|
|
7023
|
+
|
|
7024
|
+
act(() => root!.unmount());
|
|
7025
|
+
root = null;
|
|
7026
|
+
|
|
7027
|
+
await vi.waitFor(() => {
|
|
7028
|
+
expect(protocol().listTools().map((t) => t.name)).not.toContain('test-tool');
|
|
7029
|
+
});
|
|
7030
|
+
});
|
|
7031
|
+
|
|
7032
|
+
it('does not register anything when the page has no tool names', () => {
|
|
7033
|
+
mount([]);
|
|
7034
|
+
expect(protocol().listTools()).toEqual([]);
|
|
7035
|
+
});
|
|
7036
|
+
});
|
|
7037
|
+
|
|
6021
7038
|
END_OF_FILE_CONTENT
|
|
6022
7039
|
echo "Creating src/lib/webmcp/visitorPageWebMcpWiring.test.ts..."
|
|
6023
7040
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorPageWebMcpWiring.test.ts"
|
|
@@ -6037,55 +7054,216 @@ describe('visitor page WebMCP wiring', () => {
|
|
|
6037
7054
|
});
|
|
6038
7055
|
});
|
|
6039
7056
|
|
|
7057
|
+
END_OF_FILE_CONTENT
|
|
7058
|
+
echo "Creating src/lib/webmcp/visitorToolDefs.ts..."
|
|
7059
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorToolDefs.ts"
|
|
7060
|
+
import type { WebMcpToolDef } from '@olonjs/core';
|
|
7061
|
+
|
|
7062
|
+
export interface VisitorToolRegistration extends WebMcpToolDef {
|
|
7063
|
+
/** Client-side executor — the dev's domain logic (fetch, route handler, external service). */
|
|
7064
|
+
execute: (args: unknown) => Promise<unknown> | unknown;
|
|
7065
|
+
}
|
|
7066
|
+
|
|
7067
|
+
/**
|
|
7068
|
+
* Client-side registry of public tools: name → def with executor
|
|
7069
|
+
* (spec 02-webmcp-tool-public-runtime).
|
|
7070
|
+
*
|
|
7071
|
+
* The RSC page passes only the enabled tool NAMES (functions cannot cross the
|
|
7072
|
+
* RSC boundary); this registry resolves them to their executors at
|
|
7073
|
+
* registration time in the visitor island.
|
|
7074
|
+
*
|
|
7075
|
+
* Section capsules register here — module 03 adds 'submit-form'.
|
|
7076
|
+
*/
|
|
7077
|
+
export const visitorToolDefs: Record<string, VisitorToolRegistration> = {};
|
|
7078
|
+
|
|
7079
|
+
END_OF_FILE_CONTENT
|
|
7080
|
+
mkdir -p "src/sources"
|
|
7081
|
+
echo "Creating src/sources/catalogo.test.ts..."
|
|
7082
|
+
cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.test.ts"
|
|
7083
|
+
import { describe, expect, it } from 'vitest';
|
|
7084
|
+
import { runSourceConformance } from '@olonjs/sources';
|
|
7085
|
+
import { catalogoSource, fetchCatalogoFromExternalService } from './catalogo';
|
|
7086
|
+
|
|
7087
|
+
describe('catalogo (demo external source)', () => {
|
|
7088
|
+
it('passes the provider conformance suite', async () => {
|
|
7089
|
+
await expect(runSourceConformance(catalogoSource)).resolves.toBeUndefined();
|
|
7090
|
+
});
|
|
7091
|
+
|
|
7092
|
+
it('projects a deterministic document keyed by product id', async () => {
|
|
7093
|
+
const document = await fetchCatalogoFromExternalService();
|
|
7094
|
+
|
|
7095
|
+
expect(Object.keys(document).sort()).toEqual([
|
|
7096
|
+
'1984',
|
|
7097
|
+
'dune',
|
|
7098
|
+
'il-nome-della-rosa',
|
|
7099
|
+
'neuromancer',
|
|
7100
|
+
]);
|
|
7101
|
+
for (const [key, item] of Object.entries(document)) {
|
|
7102
|
+
expect(item.id).toBe(key);
|
|
7103
|
+
}
|
|
7104
|
+
});
|
|
7105
|
+
|
|
7106
|
+
it('surfaces failures for fallback/breaker policy to consume', async () => {
|
|
7107
|
+
await expect(fetchCatalogoFromExternalService({ fail: true })).rejects.toThrow(
|
|
7108
|
+
/unavailable/
|
|
7109
|
+
);
|
|
7110
|
+
});
|
|
7111
|
+
});
|
|
7112
|
+
|
|
7113
|
+
END_OF_FILE_CONTENT
|
|
7114
|
+
echo "Creating src/sources/catalogo.ts..."
|
|
7115
|
+
cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.ts"
|
|
7116
|
+
import { z } from 'zod';
|
|
7117
|
+
import { defineCollectionSource, withCache } from '@olonjs/sources';
|
|
7118
|
+
import { ProdottoSchema, type Prodotto } from '@/collections/prodotti';
|
|
7119
|
+
|
|
7120
|
+
/**
|
|
7121
|
+
* Demo provider: a DETERMINISTIC, local stand-in for an external catalog
|
|
7122
|
+
* service (no network — keeps dev, tests and CI hermetic). The real-world twin
|
|
7123
|
+
* would be `src/sources/shopify.ts` implementing the same contract.
|
|
7124
|
+
*
|
|
7125
|
+
* The `shape` is the single declaration: it validates the projected document
|
|
7126
|
+
* and feeds the agent contracts (`/schemas/collections/catalogo.schema.json`).
|
|
7127
|
+
*/
|
|
7128
|
+
|
|
7129
|
+
const CATALOGO_FIXTURE: Record<string, Prodotto> = {
|
|
7130
|
+
dune: {
|
|
7131
|
+
id: 'dune',
|
|
7132
|
+
title: 'Dune — Edizione illustrata',
|
|
7133
|
+
price: 2490,
|
|
7134
|
+
currency: 'EUR',
|
|
7135
|
+
description: 'Arrakis, spezia e politica: il classico di Frank Herbert.',
|
|
7136
|
+
},
|
|
7137
|
+
'1984': {
|
|
7138
|
+
id: '1984',
|
|
7139
|
+
title: '1984',
|
|
7140
|
+
price: 1490,
|
|
7141
|
+
currency: 'EUR',
|
|
7142
|
+
description: 'Il Grande Fratello e il controllo del linguaggio.',
|
|
7143
|
+
},
|
|
7144
|
+
neuromancer: {
|
|
7145
|
+
id: 'neuromancer',
|
|
7146
|
+
title: 'Neuromancer',
|
|
7147
|
+
price: 1890,
|
|
7148
|
+
currency: 'EUR',
|
|
7149
|
+
description: 'Il romanzo che ha definito il cyberpunk.',
|
|
7150
|
+
},
|
|
7151
|
+
'il-nome-della-rosa': {
|
|
7152
|
+
id: 'il-nome-della-rosa',
|
|
7153
|
+
title: 'Il nome della rosa',
|
|
7154
|
+
price: 2100,
|
|
7155
|
+
currency: 'EUR',
|
|
7156
|
+
description: 'Un giallo medievale tra biblioteche e potere.',
|
|
7157
|
+
},
|
|
7158
|
+
};
|
|
7159
|
+
|
|
7160
|
+
export interface ExternalCatalogServiceOptions {
|
|
7161
|
+
/** Simulated latency in ms (default 0 — deterministic tests). */
|
|
7162
|
+
latencyMs?: number;
|
|
7163
|
+
/** Force a failure, to exercise fallback/breaker policy. */
|
|
7164
|
+
fail?: boolean;
|
|
7165
|
+
signal?: AbortSignal;
|
|
7166
|
+
}
|
|
7167
|
+
|
|
7168
|
+
/** Stand-in for the external system of record (Shopify/PIM/…). */
|
|
7169
|
+
export async function fetchCatalogoFromExternalService(
|
|
7170
|
+
options: ExternalCatalogServiceOptions = {}
|
|
7171
|
+
): Promise<Record<string, Prodotto>> {
|
|
7172
|
+
const { latencyMs = 0, fail = false, signal } = options;
|
|
7173
|
+
|
|
7174
|
+
if (latencyMs > 0) {
|
|
7175
|
+
await new Promise((resolve) => setTimeout(resolve, latencyMs));
|
|
7176
|
+
}
|
|
7177
|
+
if (signal?.aborted) throw new Error('The catalog request was aborted');
|
|
7178
|
+
if (fail) throw new Error('External catalog service unavailable');
|
|
7179
|
+
|
|
7180
|
+
return JSON.parse(JSON.stringify(CATALOGO_FIXTURE)) as Record<string, Prodotto>;
|
|
7181
|
+
}
|
|
7182
|
+
|
|
7183
|
+
export const catalogoSource = withCache(
|
|
7184
|
+
defineCollectionSource({
|
|
7185
|
+
shape: z.record(z.string(), ProdottoSchema),
|
|
7186
|
+
resolve: async ({ signal }) => fetchCatalogoFromExternalService({ signal }),
|
|
7187
|
+
readOnly: true,
|
|
7188
|
+
}),
|
|
7189
|
+
{ ttlMs: 60_000, swrMs: 300_000 }
|
|
7190
|
+
);
|
|
7191
|
+
|
|
7192
|
+
END_OF_FILE_CONTENT
|
|
7193
|
+
echo "Creating src/sources/index.ts..."
|
|
7194
|
+
cat << 'END_OF_FILE_CONTENT' > "src/sources/index.ts"
|
|
7195
|
+
import { catalogoSource } from './catalogo';
|
|
7196
|
+
|
|
7197
|
+
/**
|
|
7198
|
+
* Tenant source registry. Adding a provider is a one-line change here — no
|
|
7199
|
+
* changes to @olonjs/core (ADR-0018). Sources not listed here are file-backed
|
|
7200
|
+
* collections loaded from src/data/collections.
|
|
7201
|
+
*/
|
|
7202
|
+
export const collectionSources = {
|
|
7203
|
+
catalogo: catalogoSource,
|
|
7204
|
+
} as const;
|
|
7205
|
+
|
|
7206
|
+
export type CollectionSourceName = keyof typeof collectionSources;
|
|
7207
|
+
|
|
6040
7208
|
END_OF_FILE_CONTENT
|
|
6041
7209
|
echo "Creating src/types.ts..."
|
|
6042
7210
|
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 {
|
|
6051
|
-
import type {
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
'
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
'
|
|
6061
|
-
};
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
'
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
'
|
|
6077
|
-
'
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
7211
|
+
import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
|
|
7212
|
+
import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
|
|
7213
|
+
import type { BooksListData, BooksListSettings } from '@/components/books-list';
|
|
7214
|
+
import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
|
|
7215
|
+
import type { FooterData, FooterSettings } from '@/components/footer';
|
|
7216
|
+
import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
|
|
7217
|
+
import type { HeaderData, HeaderSettings } from '@/components/header';
|
|
7218
|
+
import type { ProdottoDetailData, ProdottoDetailSettings } from '@/components/prodotto-detail';
|
|
7219
|
+
import type { ProdottiListData, ProdottiListSettings } from '@/components/prodotti-list';
|
|
7220
|
+
import type { Autore } from '@/collections/autori';
|
|
7221
|
+
import type { Libro } from '@/collections/libri';
|
|
7222
|
+
import type { Prodotto } from '@/collections/prodotti';
|
|
7223
|
+
|
|
7224
|
+
export type SectionComponentPropsMap = {
|
|
7225
|
+
'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
|
|
7226
|
+
'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
|
|
7227
|
+
'books-list': { data: BooksListData; settings?: BooksListSettings };
|
|
7228
|
+
'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
|
|
7229
|
+
footer: { data: FooterData; settings?: FooterSettings };
|
|
7230
|
+
header: { data: HeaderData; settings?: HeaderSettings };
|
|
7231
|
+
'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
|
|
7232
|
+
'prodotti-list': { data: ProdottiListData; settings?: ProdottiListSettings };
|
|
7233
|
+
'prodotto-detail': { data: ProdottoDetailData; settings?: ProdottoDetailSettings };
|
|
7234
|
+
};
|
|
7235
|
+
|
|
7236
|
+
declare module '@olonjs/core' {
|
|
7237
|
+
export interface SectionDataRegistry {
|
|
7238
|
+
'authors-list': AuthorsListData;
|
|
7239
|
+
'book-detail': BookDetailData;
|
|
7240
|
+
'books-list': BooksListData;
|
|
7241
|
+
'empty-tenant': EmptyTenantData;
|
|
7242
|
+
footer: FooterData;
|
|
7243
|
+
header: HeaderData;
|
|
7244
|
+
'form-demo': FormDemoData;
|
|
7245
|
+
'prodotti-list': ProdottiListData;
|
|
7246
|
+
'prodotto-detail': ProdottoDetailData;
|
|
7247
|
+
}
|
|
7248
|
+
export interface SectionSettingsRegistry {
|
|
7249
|
+
'authors-list': AuthorsListSettings;
|
|
7250
|
+
'book-detail': BookDetailSettings;
|
|
7251
|
+
'books-list': BooksListSettings;
|
|
7252
|
+
'empty-tenant': EmptyTenantSettings;
|
|
7253
|
+
footer: FooterSettings;
|
|
7254
|
+
header: HeaderSettings;
|
|
7255
|
+
'form-demo': FormDemoSettings;
|
|
7256
|
+
'prodotti-list': ProdottiListSettings;
|
|
7257
|
+
'prodotto-detail': ProdottoDetailSettings;
|
|
7258
|
+
}
|
|
7259
|
+
export interface CollectionItemRegistry {
|
|
7260
|
+
autori: Autore;
|
|
7261
|
+
libri: Libro;
|
|
7262
|
+
prodotti: Prodotto;
|
|
7263
|
+
}
|
|
7264
|
+
}
|
|
7265
|
+
|
|
7266
|
+
export * from '@olonjs/core';
|
|
6089
7267
|
|
|
6090
7268
|
END_OF_FILE_CONTENT
|
|
6091
7269
|
mkdir -p "templates"
|
|
@@ -7394,7 +8572,7 @@ export const ContactFormSchema = BaseSectionData.merge(WithFormRecipient).extend
|
|
|
7394
8572
|
});
|
|
7395
8573
|
export const ContactFormSubmissionSchema = z.object({
|
|
7396
8574
|
name: z.string().min(1).describe('Full name'),
|
|
7397
|
-
email: z.
|
|
8575
|
+
email: z.email().describe('Reply email'),
|
|
7398
8576
|
message: z.string().min(1).describe('Message body'),
|
|
7399
8577
|
});
|
|
7400
8578
|
EOF
|
|
@@ -11368,6 +12546,8 @@ import type { TagPostsData, TagPostsSettings } from '@/components/tag-posts';
|
|
|
11368
12546
|
import type { ContentBlockData, ContentBlockSettings } from '@/components/content-block';
|
|
11369
12547
|
import type { StatsBandData, StatsBandSettings } from '@/components/stats-band';
|
|
11370
12548
|
import type { CtaBannerData, CtaBannerSettings } from '@/components/cta-banner';
|
|
12549
|
+
import type { Post } from '@/collections/posts';
|
|
12550
|
+
import type { Tag } from '@/collections/tags';
|
|
11371
12551
|
|
|
11372
12552
|
export type SectionComponentPropsMap = {
|
|
11373
12553
|
'header': { data: HeaderData; settings: HeaderSettings };
|
|
@@ -11416,6 +12596,10 @@ declare module '@olonjs/core' {
|
|
|
11416
12596
|
'stats-band': StatsBandSettings;
|
|
11417
12597
|
'cta-banner': CtaBannerSettings;
|
|
11418
12598
|
}
|
|
12599
|
+
export interface CollectionItemRegistry {
|
|
12600
|
+
posts: Post;
|
|
12601
|
+
tags: Tag;
|
|
12602
|
+
}
|
|
11419
12603
|
}
|
|
11420
12604
|
|
|
11421
12605
|
export * from '@olonjs/core';
|