@olonjs/cli 3.0.97 → 3.0.98

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.
@@ -583,6 +583,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
583
583
  "scripts": {
584
584
  "dev": "vite",
585
585
  "dev:clean": "vite --force",
586
+ "verify:webmcp": "node scripts/webmcp-feature-check.mjs",
586
587
  "prebuild": "node scripts/sync-pages-to-public.mjs",
587
588
  "build": "tsc && vite build",
588
589
  "dist": "bash ./src2Code.sh --template agritourism src vercel.json index.html vite.config.ts scripts docs package.json",
@@ -596,7 +597,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
596
597
  "@tiptap/extension-link": "^2.11.5",
597
598
  "@tiptap/react": "^2.11.5",
598
599
  "@tiptap/starter-kit": "^2.11.5",
599
- "@olonjs/core": "^1.0.85",
600
+ "@olonjs/core": "^1.0.86",
600
601
  "clsx": "^2.1.1",
601
602
  "lucide-react": "^0.474.0",
602
603
  "react": "^19.0.0",
@@ -892,6 +893,206 @@ main().catch((error) => {
892
893
  process.exit(1);
893
894
  });
894
895
 
896
+ END_OF_FILE_CONTENT
897
+ echo "Creating scripts/bake.mjs..."
898
+ cat << 'END_OF_FILE_CONTENT' > "scripts/bake.mjs"
899
+ /**
900
+ * olon bake - production SSG
901
+ *
902
+ * 1) Build client bundle (dist/)
903
+ * 2) Build SSR entry bundle (dist-ssr/)
904
+ * 3) Discover all page slugs from JSON files under src/data/pages
905
+ * 4) Render each slug via SSR and write dist/<slug>/index.html
906
+ */
907
+
908
+ import { build } from 'vite';
909
+ import path from 'path';
910
+ import { fileURLToPath, pathToFileURL } from 'url';
911
+ import fs from 'fs/promises';
912
+ import {
913
+ buildPageContract,
914
+ buildPageManifest,
915
+ buildPageManifestHref,
916
+ buildSiteManifest,
917
+ } from '../../../packages/core/src/lib/webmcp-contracts.mjs';
918
+
919
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
920
+ const root = path.resolve(__dirname, '..');
921
+ const pagesDir = path.resolve(root, 'src/data/pages');
922
+ const publicDir = path.resolve(root, 'public');
923
+ const distDir = path.resolve(root, 'dist');
924
+
925
+ async function writeJsonTargets(relativePath, value) {
926
+ const targets = [
927
+ path.resolve(publicDir, relativePath),
928
+ path.resolve(distDir, relativePath),
929
+ ];
930
+
931
+ for (const targetPath of targets) {
932
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
933
+ await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
934
+ }
935
+ }
936
+
937
+ function escapeHtmlAttribute(value) {
938
+ return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
939
+ }
940
+
941
+ function toCanonicalSlug(relativeJsonPath) {
942
+ const normalized = relativeJsonPath.replace(/\\/g, '/');
943
+ const slug = normalized.replace(/\.json$/i, '').replace(/^\/+|\/+$/g, '');
944
+ if (!slug) throw new Error('[bake] Invalid page slug: empty path segment');
945
+ return slug;
946
+ }
947
+
948
+ async function listJsonFilesRecursive(dir) {
949
+ const items = await fs.readdir(dir, { withFileTypes: true });
950
+ const files = [];
951
+ for (const item of items) {
952
+ const fullPath = path.join(dir, item.name);
953
+ if (item.isDirectory()) {
954
+ files.push(...(await listJsonFilesRecursive(fullPath)));
955
+ continue;
956
+ }
957
+ if (item.isFile() && item.name.toLowerCase().endsWith('.json')) files.push(fullPath);
958
+ }
959
+ return files;
960
+ }
961
+
962
+ async function discoverTargets() {
963
+ let files = [];
964
+ try {
965
+ files = await listJsonFilesRecursive(pagesDir);
966
+ } catch {
967
+ files = [];
968
+ }
969
+
970
+ const rawSlugs = files.map((fullPath) => toCanonicalSlug(path.relative(pagesDir, fullPath)));
971
+ const slugs = Array.from(new Set(rawSlugs)).sort((a, b) => a.localeCompare(b));
972
+
973
+ return slugs.map((slug) => {
974
+ const depth = slug === 'home' ? 0 : slug.split('/').length;
975
+ const out = slug === 'home' ? 'dist/index.html' : `dist/${slug}/index.html`;
976
+ return { slug, out, depth };
977
+ });
978
+ }
979
+
980
+ console.log('\n[bake] Building client...');
981
+ await build({ root, mode: 'production', logLevel: 'warn' });
982
+ console.log('[bake] Client build done.');
983
+
984
+ console.log('\n[bake] Building SSR bundle...');
985
+ await build({
986
+ root,
987
+ mode: 'production',
988
+ logLevel: 'warn',
989
+ build: {
990
+ ssr: 'src/entry-ssg.tsx',
991
+ outDir: 'dist-ssr',
992
+ rollupOptions: {
993
+ output: { format: 'esm' },
994
+ },
995
+ },
996
+ ssr: {
997
+ noExternal: ['@olonjs/core'],
998
+ },
999
+ });
1000
+ console.log('[bake] SSR build done.');
1001
+
1002
+ const targets = await discoverTargets();
1003
+ if (targets.length === 0) {
1004
+ throw new Error('[bake] No pages discovered under src/data/pages');
1005
+ }
1006
+ console.log(`[bake] Targets: ${targets.map((t) => t.slug).join(', ')}`);
1007
+
1008
+ const ssrEntryUrl = pathToFileURL(path.resolve(root, 'dist-ssr/entry-ssg.js')).href;
1009
+ const { render, getCss, getPageMeta, getWebMcpBuildState } = await import(ssrEntryUrl);
1010
+
1011
+ const template = await fs.readFile(path.resolve(root, 'dist/index.html'), 'utf-8');
1012
+ const hasCommentMarker = template.includes('<!--app-html-->');
1013
+ const hasRootDivMarker = template.includes('<div id="root"></div>');
1014
+ if (!hasCommentMarker && !hasRootDivMarker) {
1015
+ throw new Error('[bake] Missing template marker. Expected <!--app-html--> or <div id="root"></div>.');
1016
+ }
1017
+
1018
+ const inlinedCss = getCss();
1019
+ const styleTag = `<style data-bake="inline">${inlinedCss}</style>`;
1020
+ const webMcpBuildState = getWebMcpBuildState();
1021
+
1022
+ for (const { slug } of targets) {
1023
+ const pageConfig = webMcpBuildState.pages[slug];
1024
+ if (!pageConfig) continue;
1025
+ const contract = buildPageContract({
1026
+ slug,
1027
+ pageConfig,
1028
+ schemas: webMcpBuildState.schemas,
1029
+ siteConfig: webMcpBuildState.siteConfig,
1030
+ });
1031
+ await writeJsonTargets(`schemas/${slug}.schema.json`, contract);
1032
+ const pageManifest = buildPageManifest({
1033
+ slug,
1034
+ pageConfig,
1035
+ schemas: webMcpBuildState.schemas,
1036
+ siteConfig: webMcpBuildState.siteConfig,
1037
+ });
1038
+ await writeJsonTargets(buildPageManifestHref(slug).replace(/^\//, ''), pageManifest);
1039
+ }
1040
+
1041
+ const mcpManifest = buildSiteManifest({
1042
+ pages: webMcpBuildState.pages,
1043
+ schemas: webMcpBuildState.schemas,
1044
+ siteConfig: webMcpBuildState.siteConfig,
1045
+ });
1046
+ await writeJsonTargets('mcp-manifest.json', mcpManifest);
1047
+
1048
+ for (const { slug, out, depth } of targets) {
1049
+ console.log(`\n[bake] Rendering /${slug === 'home' ? '' : slug}...`);
1050
+
1051
+ const appHtml = render(slug);
1052
+ const { title, description } = getPageMeta(slug);
1053
+ const safeTitle = String(title).replace(/"/g, '&quot;');
1054
+ const safeDescription = String(description).replace(/"/g, '&quot;');
1055
+ const metaTags = [
1056
+ `<meta name="description" content="${safeDescription}">`,
1057
+ `<meta property="og:title" content="${safeTitle}">`,
1058
+ `<meta property="og:description" content="${safeDescription}">`,
1059
+ ].join('\n ');
1060
+ const jsonLd = JSON.stringify({
1061
+ '@context': 'https://schema.org',
1062
+ '@type': 'WebPage',
1063
+ name: title,
1064
+ description,
1065
+ url: slug === 'home' ? '/' : `/${slug}`,
1066
+ });
1067
+
1068
+ const prefix = depth > 0 ? '../'.repeat(depth) : './';
1069
+ const fixedTemplate = depth > 0 ? template.replace(/(['"])\.\//g, `$1${prefix}`) : template;
1070
+ const mcpManifestHref = `${prefix}${buildPageManifestHref(slug).replace(/^\//, '')}`;
1071
+ const contractHref = `${prefix}schemas/${slug}.schema.json`;
1072
+ const contractLinks = [
1073
+ `<link rel="mcp-manifest" href="${escapeHtmlAttribute(mcpManifestHref)}">`,
1074
+ `<link rel="olon-contract" href="${escapeHtmlAttribute(contractHref)}">`,
1075
+ `<script type="application/ld+json">${jsonLd}</script>`,
1076
+ ].join('\n ');
1077
+
1078
+ let bakedHtml = fixedTemplate
1079
+ .replace('</head>', ` ${styleTag}\n ${contractLinks}\n</head>`)
1080
+ .replace(/<title>.*?<\/title>/, `<title>${safeTitle}</title>\n ${metaTags}`);
1081
+
1082
+ if (hasCommentMarker) {
1083
+ bakedHtml = bakedHtml.replace('<!--app-html-->', appHtml);
1084
+ } else {
1085
+ bakedHtml = bakedHtml.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
1086
+ }
1087
+
1088
+ const outPath = path.resolve(root, out);
1089
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
1090
+ await fs.writeFile(outPath, bakedHtml, 'utf-8');
1091
+ console.log(`[bake] Written -> ${out} [title: "${safeTitle}"]`);
1092
+ }
1093
+
1094
+ console.log('\n[bake] All pages baked. OK\n');
1095
+
895
1096
  END_OF_FILE_CONTENT
896
1097
  echo "Creating scripts/sync-pages-to-public.mjs..."
897
1098
  cat << 'END_OF_FILE_CONTENT' > "scripts/sync-pages-to-public.mjs"
@@ -916,6 +1117,308 @@ fs.cpSync(sourceDir, targetDir, { recursive: true });
916
1117
 
917
1118
  console.log('[sync-pages-to-public] Synced pages to public/pages');
918
1119
 
1120
+ END_OF_FILE_CONTENT
1121
+ echo "Creating scripts/webmcp-feature-check.mjs..."
1122
+ cat << 'END_OF_FILE_CONTENT' > "scripts/webmcp-feature-check.mjs"
1123
+ import fs from 'fs/promises';
1124
+ import path from 'path';
1125
+ import { fileURLToPath } from 'url';
1126
+ import { createRequire } from 'module';
1127
+
1128
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1129
+ const rootDir = path.resolve(__dirname, '..');
1130
+ const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:4173';
1131
+
1132
+ function pageFilePathFromSlug(slug) {
1133
+ return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1134
+ }
1135
+
1136
+ function adminUrlFromSlug(slug) {
1137
+ return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1138
+ }
1139
+
1140
+ function isStringSchema(schema) {
1141
+ if (!schema || typeof schema !== 'object') return false;
1142
+ if (schema.type === 'string') return true;
1143
+ if (Array.isArray(schema.anyOf)) {
1144
+ return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1145
+ }
1146
+ return false;
1147
+ }
1148
+
1149
+ function findTopLevelStringField(sectionSchema) {
1150
+ const properties = sectionSchema?.properties;
1151
+ if (!properties || typeof properties !== 'object') return null;
1152
+ const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1153
+ for (const key of preferred) {
1154
+ if (isStringSchema(properties[key])) return key;
1155
+ }
1156
+ for (const [key, value] of Object.entries(properties)) {
1157
+ if (isStringSchema(value)) return key;
1158
+ }
1159
+ return null;
1160
+ }
1161
+
1162
+ async function loadPlaywright() {
1163
+ const require = createRequire(import.meta.url);
1164
+ try {
1165
+ return require('playwright');
1166
+ } catch (error) {
1167
+ const message = error instanceof Error ? error.message : String(error);
1168
+ throw new Error(
1169
+ `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1170
+ );
1171
+ }
1172
+ }
1173
+
1174
+ async function readPageJson(slug) {
1175
+ const pageFilePath = pageFilePathFromSlug(slug);
1176
+ const raw = await fs.readFile(pageFilePath, 'utf8');
1177
+ return { raw, json: JSON.parse(raw), pageFilePath };
1178
+ }
1179
+
1180
+ async function waitFor(predicate, timeoutMs, label) {
1181
+ const startedAt = Date.now();
1182
+ for (;;) {
1183
+ const result = await predicate();
1184
+ if (result) return result;
1185
+ if (Date.now() - startedAt > timeoutMs) {
1186
+ throw new Error(`Timed out while waiting for ${label}.`);
1187
+ }
1188
+ await new Promise((resolve) => setTimeout(resolve, 150));
1189
+ }
1190
+ }
1191
+
1192
+ async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1193
+ await waitFor(async () => {
1194
+ const { json } = await readPageJson(slug);
1195
+ const section = Array.isArray(json.sections)
1196
+ ? json.sections.find((item) => item?.id === sectionId)
1197
+ : null;
1198
+ return section?.data?.[fieldKey] === expectedValue;
1199
+ }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1200
+ }
1201
+
1202
+ async function ensureResponseOk(response, label) {
1203
+ if (!response.ok) {
1204
+ const text = await response.text();
1205
+ throw new Error(`${label} failed with ${response.status}: ${text}`);
1206
+ }
1207
+ return response;
1208
+ }
1209
+
1210
+ async function fetchJson(relativePath, label) {
1211
+ const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1212
+ return response.json();
1213
+ }
1214
+
1215
+ async function selectTarget() {
1216
+ const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1217
+ const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1218
+ ? process.env.WEBMCP_TARGET_SLUG.trim()
1219
+ : null;
1220
+
1221
+ const candidatePages = requestedSlug
1222
+ ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1223
+ : (siteIndex.pages ?? []);
1224
+
1225
+ for (const pageEntry of candidatePages) {
1226
+ if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1227
+ const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1228
+ const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1229
+ const localInstances = Array.isArray(pageContract.sectionInstances)
1230
+ ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1231
+ : [];
1232
+ const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1233
+
1234
+ for (const tool of tools) {
1235
+ const sectionType = tool?.sectionType;
1236
+ if (typeof tool?.name !== 'string' || typeof sectionType !== 'string') continue;
1237
+ const targetInstance = localInstances.find((section) => section?.type === sectionType);
1238
+ if (!targetInstance?.id) continue;
1239
+ const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1240
+ if (!targetFieldKey) continue;
1241
+ const pageState = await readPageJson(pageEntry.slug);
1242
+ const section = Array.isArray(pageState.json.sections)
1243
+ ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1244
+ : null;
1245
+ const originalValue = section?.data?.[targetFieldKey];
1246
+ if (typeof originalValue !== 'string') continue;
1247
+
1248
+ return {
1249
+ slug: pageEntry.slug,
1250
+ manifestHref: pageEntry.manifestHref,
1251
+ contractHref: pageEntry.contractHref,
1252
+ toolName: tool.name,
1253
+ sectionId: targetInstance.id,
1254
+ fieldKey: targetFieldKey,
1255
+ originalValue,
1256
+ originalState: pageState,
1257
+ };
1258
+ }
1259
+ }
1260
+
1261
+ throw new Error(
1262
+ requestedSlug
1263
+ ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1264
+ : 'No valid WebMCP verification target found in manifest index.'
1265
+ );
1266
+ }
1267
+
1268
+ async function main() {
1269
+ const { chromium } = await loadPlaywright();
1270
+ const target = await selectTarget();
1271
+ const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1272
+ const browser = await chromium.launch({ headless: true });
1273
+ const page = await browser.newPage();
1274
+ const consoleEvents = [];
1275
+ let mutationApplied = false;
1276
+
1277
+ page.on('console', (message) => {
1278
+ if (message.type() === 'error' || message.type() === 'warning') {
1279
+ consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1280
+ }
1281
+ });
1282
+ page.on('pageerror', (error) => {
1283
+ consoleEvents.push(`[pageerror] ${error.message}`);
1284
+ });
1285
+
1286
+ const restoreOriginal = async () => {
1287
+ try {
1288
+ await page.evaluate(
1289
+ async ({ toolName, slug, sectionId, fieldKey, value }) => {
1290
+ const runtime = navigator.modelContextTesting;
1291
+ if (!runtime?.executeTool) return;
1292
+ await runtime.executeTool(
1293
+ toolName,
1294
+ JSON.stringify({
1295
+ slug,
1296
+ sectionId,
1297
+ fieldKey,
1298
+ value,
1299
+ })
1300
+ );
1301
+ },
1302
+ {
1303
+ toolName: target.toolName,
1304
+ slug: target.slug,
1305
+ sectionId: target.sectionId,
1306
+ fieldKey: target.fieldKey,
1307
+ value: target.originalValue,
1308
+ }
1309
+ );
1310
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1311
+ } catch {
1312
+ await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1313
+ }
1314
+ };
1315
+
1316
+ try {
1317
+ const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1318
+ if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1319
+ throw new Error(`Manifest does not expose ${target.toolName}.`);
1320
+ }
1321
+
1322
+ const pageContract = await fetchJson(target.contractHref, `Contract request for ${target.slug}`);
1323
+ if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1324
+ throw new Error(`Page contract does not expose ${target.toolName}.`);
1325
+ }
1326
+
1327
+ await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1328
+
1329
+ try {
1330
+ await page.waitForFunction(
1331
+ ({ manifestHref, contractHref }) => {
1332
+ const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1333
+ const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1334
+ return manifestLink?.getAttribute('href') === manifestHref
1335
+ && contractLink?.getAttribute('href') === contractHref;
1336
+ },
1337
+ { manifestHref: target.manifestHref, contractHref: target.contractHref },
1338
+ { timeout: 10_000 }
1339
+ );
1340
+ } catch (error) {
1341
+ const diagnostics = await page.evaluate(() => ({
1342
+ head: document.head.innerHTML,
1343
+ bodyText: document.body.innerText,
1344
+ }));
1345
+ throw new Error(
1346
+ [
1347
+ error instanceof Error ? error.message : String(error),
1348
+ `head=${diagnostics.head}`,
1349
+ `body=${diagnostics.bodyText}`,
1350
+ ...consoleEvents,
1351
+ ].join('\n')
1352
+ );
1353
+ }
1354
+
1355
+ const toolNames = await page.evaluate(() => {
1356
+ const runtime = navigator.modelContextTesting;
1357
+ return runtime?.listTools?.().map((tool) => tool.name) ?? [];
1358
+ });
1359
+ if (!toolNames.includes(target.toolName)) {
1360
+ throw new Error(`Runtime did not register ${target.toolName}. Found: ${toolNames.join(', ')}`);
1361
+ }
1362
+
1363
+ const rawResult = await page.evaluate(
1364
+ async ({ toolName, slug, sectionId, fieldKey, value }) => {
1365
+ const runtime = navigator.modelContextTesting;
1366
+ if (!runtime?.executeTool) {
1367
+ throw new Error('navigator.modelContextTesting.executeTool is unavailable.');
1368
+ }
1369
+ return runtime.executeTool(
1370
+ toolName,
1371
+ JSON.stringify({
1372
+ slug,
1373
+ sectionId,
1374
+ fieldKey,
1375
+ value,
1376
+ })
1377
+ );
1378
+ },
1379
+ {
1380
+ toolName: target.toolName,
1381
+ slug: target.slug,
1382
+ sectionId: target.sectionId,
1383
+ fieldKey: target.fieldKey,
1384
+ value: nextValue,
1385
+ }
1386
+ );
1387
+
1388
+ const parsedResult = JSON.parse(rawResult);
1389
+ if (parsedResult?.isError) {
1390
+ throw new Error(`WebMCP tool returned an error: ${rawResult}`);
1391
+ }
1392
+
1393
+ mutationApplied = true;
1394
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1395
+ await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1396
+
1397
+ console.log(
1398
+ JSON.stringify({
1399
+ ok: true,
1400
+ slug: target.slug,
1401
+ manifestHref: target.manifestHref,
1402
+ contractHref: target.contractHref,
1403
+ toolName: target.toolName,
1404
+ sectionId: target.sectionId,
1405
+ fieldKey: target.fieldKey,
1406
+ toolNames,
1407
+ })
1408
+ );
1409
+ } finally {
1410
+ if (mutationApplied) {
1411
+ await restoreOriginal();
1412
+ }
1413
+ await browser.close();
1414
+ }
1415
+ }
1416
+
1417
+ main().catch((error) => {
1418
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1419
+ process.exit(1);
1420
+ });
1421
+
919
1422
  END_OF_FILE_CONTENT
920
1423
  mkdir -p "src"
921
1424
  echo "Creating src/App.tsx..."
@@ -1114,6 +1617,10 @@ function App() {
1114
1617
  menuConfig,
1115
1618
  themeCss: { tenant: fontsCss + '\n' + tenantCss },
1116
1619
  addSection: addSectionConfig,
1620
+ webmcp: {
1621
+ enabled: true,
1622
+ namespace: typeof window !== 'undefined' ? window.location.href : '',
1623
+ },
1117
1624
  persistence: {
1118
1625
  async saveToFile(state: ProjectState, slug: string): Promise<void> {
1119
1626
  if (isCloudMode) { await runCloudSave({ state, slug }, true); return; }
@@ -9945,6 +10452,170 @@ export function LeadSenderConfirmationEmail({
9945
10452
 
9946
10453
  export default LeadSenderConfirmationEmail;
9947
10454
 
10455
+ END_OF_FILE_CONTENT
10456
+ echo "Creating src/entry-ssg.tsx..."
10457
+ cat << 'END_OF_FILE_CONTENT' > "src/entry-ssg.tsx"
10458
+ import { renderToString } from 'react-dom/server';
10459
+ import { StaticRouter } from 'react-router-dom/server';
10460
+ import { ConfigProvider, PageRenderer, StudioProvider, resolveRuntimeConfig } from '@olonjs/core';
10461
+ import type { JsonPagesConfig, MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
10462
+ import { ComponentRegistry } from '@/lib/ComponentRegistry';
10463
+ import { SECTION_SCHEMAS } from '@/lib/schemas';
10464
+ import { getFilePages } from '@/lib/getFilePages';
10465
+ import siteData from '@/data/config/site.json';
10466
+ import menuData from '@/data/config/menu.json';
10467
+ import themeData from '@/data/config/theme.json';
10468
+ import tenantCss from '@/index.css?inline';
10469
+
10470
+ const siteConfig = siteData as unknown as SiteConfig;
10471
+ const menuConfig: MenuConfig = { main: [] };
10472
+ const themeConfig = themeData as unknown as ThemeConfig;
10473
+ const pages = getFilePages();
10474
+ const refDocuments = {
10475
+ 'menu.json': menuData,
10476
+ 'config/menu.json': menuData,
10477
+ 'src/data/config/menu.json': menuData,
10478
+ } satisfies NonNullable<JsonPagesConfig['refDocuments']>;
10479
+
10480
+ function isRecord(value: unknown): value is Record<string, unknown> {
10481
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10482
+ }
10483
+
10484
+ function normalizeSlug(input: string): string {
10485
+ return input.trim().toLowerCase().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
10486
+ }
10487
+
10488
+ function getSortedSlugs(): string[] {
10489
+ return Object.keys(pages).sort((a, b) => a.localeCompare(b));
10490
+ }
10491
+
10492
+ function resolvePage(slug: string): { slug: string; page: PageConfig } {
10493
+ const normalized = normalizeSlug(slug);
10494
+ if (normalized && pages[normalized]) {
10495
+ return { slug: normalized, page: pages[normalized] };
10496
+ }
10497
+
10498
+ const slugs = getSortedSlugs();
10499
+ if (slugs.length === 0) {
10500
+ throw new Error('[SSG_CONFIG_ERROR] No pages found under src/data/pages');
10501
+ }
10502
+
10503
+ const home = slugs.find((item) => item === 'home');
10504
+ const fallbackSlug = home ?? slugs[0];
10505
+ return { slug: fallbackSlug, page: pages[fallbackSlug] };
10506
+ }
10507
+
10508
+ function flattenThemeTokens(
10509
+ input: unknown,
10510
+ pathSegments: string[] = [],
10511
+ out: Array<{ name: string; value: string }> = []
10512
+ ): Array<{ name: string; value: string }> {
10513
+ if (typeof input === 'string') {
10514
+ const cleaned = input.trim();
10515
+ if (cleaned.length > 0 && pathSegments.length > 0) {
10516
+ out.push({ name: `--theme-${pathSegments.join('-')}`, value: cleaned });
10517
+ }
10518
+ return out;
10519
+ }
10520
+
10521
+ if (!isRecord(input)) return out;
10522
+
10523
+ const entries = Object.entries(input).sort(([a], [b]) => a.localeCompare(b));
10524
+ for (const [key, value] of entries) {
10525
+ flattenThemeTokens(value, [...pathSegments, key], out);
10526
+ }
10527
+ return out;
10528
+ }
10529
+
10530
+ function buildThemeCssFromSot(theme: ThemeConfig): string {
10531
+ const root: Record<string, unknown> = isRecord(theme) ? theme : {};
10532
+ const tokens = root['tokens'];
10533
+ const flattened = flattenThemeTokens(tokens);
10534
+ if (flattened.length === 0) return '';
10535
+ const serialized = flattened.map((item) => `${item.name}:${item.value}`).join(';');
10536
+ return `:root{${serialized}}`;
10537
+ }
10538
+
10539
+ function resolveTenantId(): string {
10540
+ const site: Record<string, unknown> = isRecord(siteConfig) ? siteConfig : {};
10541
+ const identityRaw = site['identity'];
10542
+ const identity: Record<string, unknown> = isRecord(identityRaw) ? identityRaw : {};
10543
+ const titleRaw = typeof identity.title === 'string' ? identity.title : '';
10544
+ const title = titleRaw.trim();
10545
+ if (title.length > 0) {
10546
+ const normalized = title.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
10547
+ if (normalized.length > 0) return normalized;
10548
+ }
10549
+
10550
+ const slugs = getSortedSlugs();
10551
+ if (slugs.length === 0) {
10552
+ throw new Error('[SSG_CONFIG_ERROR] Cannot resolve tenantId without site.identity.title or pages');
10553
+ }
10554
+ return slugs[0].replace(/\//g, '-');
10555
+ }
10556
+
10557
+ export function render(slug: string): string {
10558
+ const resolved = resolvePage(slug);
10559
+ const location = resolved.slug === 'home' ? '/' : `/${resolved.slug}`;
10560
+ const resolvedRuntime = resolveRuntimeConfig({
10561
+ pages,
10562
+ siteConfig,
10563
+ themeConfig,
10564
+ menuConfig,
10565
+ refDocuments,
10566
+ });
10567
+ const resolvedPage = resolvedRuntime.pages[resolved.slug] ?? resolved.page;
10568
+
10569
+ return renderToString(
10570
+ <StaticRouter location={location}>
10571
+ <ConfigProvider
10572
+ config={{
10573
+ registry: ComponentRegistry as JsonPagesConfig['registry'],
10574
+ schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
10575
+ tenantId: resolveTenantId(),
10576
+ }}
10577
+ >
10578
+ <StudioProvider mode="visitor">
10579
+ <PageRenderer
10580
+ pageConfig={resolvedPage}
10581
+ siteConfig={resolvedRuntime.siteConfig}
10582
+ menuConfig={resolvedRuntime.menuConfig}
10583
+ />
10584
+ </StudioProvider>
10585
+ </ConfigProvider>
10586
+ </StaticRouter>
10587
+ );
10588
+ }
10589
+
10590
+ export function getCss(): string {
10591
+ const themeCss = buildThemeCssFromSot(themeConfig);
10592
+ if (!themeCss) return tenantCss;
10593
+ return `${themeCss}\n${tenantCss}`;
10594
+ }
10595
+
10596
+ export function getPageMeta(slug: string): { title: string; description: string } {
10597
+ const resolved = resolvePage(slug);
10598
+ const rawMeta = isRecord((resolved.page as unknown as { meta?: unknown }).meta)
10599
+ ? ((resolved.page as unknown as { meta?: Record<string, unknown> }).meta as Record<string, unknown>)
10600
+ : {};
10601
+
10602
+ const title = typeof rawMeta.title === 'string' ? rawMeta.title : resolved.slug;
10603
+ const description = typeof rawMeta.description === 'string' ? rawMeta.description : '';
10604
+ return { title, description };
10605
+ }
10606
+
10607
+ export function getWebMcpBuildState(): {
10608
+ pages: Record<string, PageConfig>;
10609
+ schemas: JsonPagesConfig['schemas'];
10610
+ siteConfig: SiteConfig;
10611
+ } {
10612
+ return {
10613
+ pages,
10614
+ schemas: SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'],
10615
+ siteConfig,
10616
+ };
10617
+ }
10618
+
9948
10619
  END_OF_FILE_CONTENT
9949
10620
  echo "Creating src/fonts.css..."
9950
10621
  cat << 'END_OF_FILE_CONTENT' > "src/fonts.css"
@@ -10919,14 +11590,14 @@ END_OF_FILE_CONTENT
10919
11590
  echo "Creating vite.config.ts..."
10920
11591
  cat << 'END_OF_FILE_CONTENT' > "vite.config.ts"
10921
11592
  /**
10922
- * Generated by @olonjs/cli. Dev server API: /api/save-to-file, /api/upload-asset, /api/list-assets.
11593
+ * Generated by @jsonpages/cli. Dev server API: /api/save-to-file, /api/upload-asset, /api/list-assets.
10923
11594
  */
10924
11595
  import { defineConfig } from 'vite';
10925
11596
  import react from '@vitejs/plugin-react';
10926
11597
  import tailwindcss from '@tailwindcss/vite';
10927
11598
  import path from 'path';
10928
11599
  import fs from 'fs';
10929
- import { fileURLToPath } from 'url';
11600
+ import { fileURLToPath, pathToFileURL } from 'url';
10930
11601
 
10931
11602
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10932
11603
 
@@ -10974,6 +11645,20 @@ function isTenantPageJsonRequest(req, pathname) {
10974
11645
  const viteOrStaticPrefixes = ['/api/', '/assets/', '/src/', '/node_modules/', '/public/', '/@'];
10975
11646
  return !viteOrStaticPrefixes.some((prefix) => pathname.startsWith(prefix));
10976
11647
  }
11648
+
11649
+ function normalizeManifestSlug(raw) {
11650
+ return decodeURIComponent(raw || '')
11651
+ .replace(/^\/+|\/+$/g, '')
11652
+ .replace(/\\/g, '/')
11653
+ .replace(/(\.schema)?\.json$/i, '');
11654
+ }
11655
+
11656
+ async function loadWebMcpBuilders() {
11657
+ const moduleUrl = pathToFileURL(
11658
+ path.resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'lib', 'webmcp-contracts.mjs')
11659
+ ).href;
11660
+ return import(moduleUrl);
11661
+ }
10977
11662
  export default defineConfig({
10978
11663
  plugins: [
10979
11664
  react(),
@@ -10985,6 +11670,75 @@ export default defineConfig({
10985
11670
  const pathname = (req.url || '').split('?')[0];
10986
11671
  const isPageJsonRequest = isTenantPageJsonRequest(req, pathname);
10987
11672
 
11673
+ const handleManifestRequest = async () => {
11674
+ const { buildPageContract, buildPageManifest, buildSiteManifest } = await loadWebMcpBuilders();
11675
+ const ssrEntry = await server.ssrLoadModule('/src/entry-ssg.tsx');
11676
+ const buildState = ssrEntry.getWebMcpBuildState();
11677
+
11678
+ if (req.method === 'GET' && pathname === '/mcp-manifest.json') {
11679
+ sendJson(res, 200, buildSiteManifest({
11680
+ pages: buildState.pages,
11681
+ schemas: buildState.schemas,
11682
+ siteConfig: buildState.siteConfig,
11683
+ }));
11684
+ return true;
11685
+ }
11686
+
11687
+ const pageManifestMatch = pathname.match(/^\/mcp-manifests\/(.+)\.json$/i);
11688
+ if (pageManifestMatch && req.method === 'GET') {
11689
+ const slug = normalizeManifestSlug(pageManifestMatch[1]);
11690
+ const pageConfig = buildState.pages[slug];
11691
+ if (!pageConfig) {
11692
+ sendJson(res, 404, { error: 'Page manifest not found' });
11693
+ return true;
11694
+ }
11695
+
11696
+ sendJson(res, 200, buildPageManifest({
11697
+ slug,
11698
+ pageConfig,
11699
+ schemas: buildState.schemas,
11700
+ siteConfig: buildState.siteConfig,
11701
+ }));
11702
+ return true;
11703
+ }
11704
+
11705
+ const schemaMatch = pathname.match(/^\/schemas\/(.+)\.schema\.json$/i);
11706
+ if (!schemaMatch || req.method !== 'GET') return false;
11707
+
11708
+ const slug = normalizeManifestSlug(schemaMatch[1]);
11709
+ const pageConfig = buildState.pages[slug];
11710
+ if (!pageConfig) {
11711
+ sendJson(res, 404, { error: 'Schema contract not found' });
11712
+ return true;
11713
+ }
11714
+
11715
+ sendJson(res, 200, buildPageContract({
11716
+ slug,
11717
+ pageConfig,
11718
+ schemas: buildState.schemas,
11719
+ siteConfig: buildState.siteConfig,
11720
+ }));
11721
+ return true;
11722
+ };
11723
+
11724
+ if (
11725
+ req.method === 'GET' &&
11726
+ (
11727
+ pathname === '/mcp-manifest.json'
11728
+ || /^\/mcp-manifests\/.+\.json$/i.test(pathname)
11729
+ || /^\/schemas\/.+\.schema\.json$/i.test(pathname)
11730
+ )
11731
+ ) {
11732
+ void handleManifestRequest()
11733
+ .then((handled) => {
11734
+ if (!handled) next();
11735
+ })
11736
+ .catch((error) => {
11737
+ sendJson(res, 500, { error: error?.message || 'Manifest generation failed' });
11738
+ });
11739
+ return;
11740
+ }
11741
+
10988
11742
  if (isPageJsonRequest) {
10989
11743
  const normalizedPath = decodeURIComponent(pathname).replace(/\\/g, '/');
10990
11744
  const slug = normalizedPath.replace(/^\/+/, '').replace(/\.json$/i, '').replace(/^\/+|\/+$/g, '');
@@ -11015,7 +11769,6 @@ export default defineConfig({
11015
11769
  if (!fs.existsSync(DATA_PAGES_DIR)) fs.mkdirSync(DATA_PAGES_DIR, { recursive: true });
11016
11770
  if (projectState.site != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'site.json'), JSON.stringify(projectState.site, null, 2), 'utf8');
11017
11771
  if (projectState.theme != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'theme.json'), JSON.stringify(projectState.theme, null, 2), 'utf8');
11018
- if (projectState.menu != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'menu.json'), JSON.stringify(projectState.menu, null, 2), 'utf8');
11019
11772
  if (projectState.page != null) {
11020
11773
  const safeSlug = (slug.replace(/[^a-zA-Z0-9-_]/g, '_') || 'page');
11021
11774
  fs.writeFileSync(path.join(DATA_PAGES_DIR, `${safeSlug}.json`), JSON.stringify(projectState.page, null, 2), 'utf8');
@@ -11048,7 +11801,24 @@ export default defineConfig({
11048
11801
  },
11049
11802
  },
11050
11803
  ],
11051
- resolve: { alias: { '@': path.resolve(__dirname, './src') } },
11804
+ resolve: {
11805
+ alias: {
11806
+ '@': path.resolve(__dirname, './src'),
11807
+ '@olonjs/core': path.resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'),
11808
+ 'next/link': path.resolve(__dirname, './src/shims/next-link.tsx'),
11809
+ },
11810
+ },
11811
+ server: {
11812
+ fs: {
11813
+ allow: [
11814
+ path.resolve(__dirname, '..', '..'),
11815
+ ],
11816
+ },
11817
+ watch: {
11818
+ usePolling: true,
11819
+ interval: 300,
11820
+ },
11821
+ },
11052
11822
  });
11053
11823
 
11054
11824