@olonjs/cli 3.0.159 → 3.0.160

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.
@@ -10,6 +10,8 @@ cat << 'END_OF_FILE_CONTENT' > "app/[[...slug]]/page.tsx"
10
10
  import type { Metadata } from 'next';
11
11
  import { notFound } from 'next/navigation';
12
12
  import {
13
+ buildPageContractHref,
14
+ buildPageManifestHref,
13
15
  resolveCollectionContext,
14
16
  resolveRuntimeConfig,
15
17
  type PageConfig,
@@ -17,6 +19,7 @@ import {
17
19
  import { loadVisitorPage } from '@olonjs/next/server';
18
20
  import { EmptyTenantView } from '@/components/empty-tenant';
19
21
  import { CollectionRegistry } from '@/lib/CollectionRegistry';
22
+ import { buildVisitorWebPageJsonLd } from '@/lib/buildVisitorWebPageJsonLd';
20
23
  import { getFileCollections } from '@/lib/loaders/getFileCollections';
21
24
  import { getFilePages } from '@/lib/loaders/getFilePages';
22
25
  import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
@@ -109,9 +112,23 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
109
112
  const pageNum = Number(firstSearchValue(query.page) ?? '1') || 1;
110
113
  const shell = resolveVisitorShell(page, resolved.siteConfig ?? siteConfig);
111
114
  const sectionExtras = { authorId, page: pageNum, pathname };
115
+ const title = page.meta?.title ?? result.registrySlug;
116
+ const description = typeof page.meta?.description === 'string' ? page.meta.description : '';
117
+ const jsonLd = buildVisitorWebPageJsonLd({
118
+ title,
119
+ description,
120
+ slug: requestSlug,
121
+ });
112
122
 
113
123
  return (
114
124
  <>
125
+ <link rel="mcp-manifest" href={buildPageManifestHref(requestSlug)} />
126
+ <link rel="olon-contract" href={buildPageContractHref(requestSlug)} />
127
+ <script
128
+ type="application/ld+json"
129
+ // eslint-disable-next-line react/no-danger -- Schema.org JSON-LD payload (parity alpha bake)
130
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
131
+ />
115
132
  {shell.header ? (
116
133
  <VisitorSection section={shell.header} extras={sectionExtras} />
117
134
  ) : null}
@@ -481,20 +498,21 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
481
498
  "type": "module",
482
499
  "scripts": {
483
500
  "dev": "next dev",
484
- "prebuild": "node scripts/sync-pages-to-public.mjs",
501
+ "prebuild": "node scripts/sync-pages-to-public.mjs && node scripts/generate-llms-txt.mjs && node scripts/bake.mjs && node scripts/sitemap.mjs && node scripts/robots.mjs",
485
502
  "build": "next build",
486
503
  "start": "next start",
487
504
  "lint": "next lint",
488
505
  "test": "vitest run",
489
506
  "typecheck": "tsc --noEmit",
507
+ "verify:webmcp": "node scripts/webmcp-feature-check.mjs",
490
508
  "dist": "bash ./src2Code.sh --template next app src scripts templates middleware.ts next.config.ts postcss.config.mjs tsconfig.json package.json next-env.d.ts vitest.config.ts",
491
509
  "dist:dna": "npm run dist"
492
510
  },
493
511
  "dependencies": {
494
- "@olonjs/core": "^1.1.27",
495
- "@olonjs/next": "^0.0.7",
496
- "@olonjs/react": "^0.1.10",
497
- "@olonjs/studio": "^0.1.10",
512
+ "@olonjs/core": "^1.1.28",
513
+ "@olonjs/next": "^0.0.8",
514
+ "@olonjs/react": "^0.1.11",
515
+ "@olonjs/studio": "^0.1.11",
498
516
  "clsx": "^2.1.1",
499
517
  "lucide-react": "^0.474.0",
500
518
  "next": "^15.5.0",
@@ -510,6 +528,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
510
528
  "@types/react": "^19.0.0",
511
529
  "@types/react-dom": "^19.0.0",
512
530
  "tailwindcss": "^4.0.0",
531
+ "tsx": "^4.20.5",
513
532
  "typescript": "^5.7.3",
514
533
  "vitest": "^3.0.0"
515
534
  }
@@ -529,6 +548,245 @@ export default config;
529
548
 
530
549
  END_OF_FILE_CONTENT
531
550
  mkdir -p "scripts"
551
+ echo "Creating scripts/bake.mjs..."
552
+ cat << 'END_OF_FILE_CONTENT' > "scripts/bake.mjs"
553
+ /**
554
+ * Next bake entry — agentic WebMCP artifacts only (no Vite / no HTML SSG).
555
+ * Runs scripts/bake.ts via tsx so SECTION_SCHEMAS can be imported from the tenant.
556
+ */
557
+ import { spawnSync } from 'node:child_process';
558
+ import path from 'node:path';
559
+ import { fileURLToPath } from 'node:url';
560
+
561
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
562
+ const rootDir = path.resolve(__dirname, '..');
563
+ const bakeTs = path.join(__dirname, 'bake.ts');
564
+
565
+ const result = spawnSync(
566
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
567
+ ['tsx', '--tsconfig', 'tsconfig.json', bakeTs],
568
+ {
569
+ cwd: rootDir,
570
+ stdio: 'inherit',
571
+ env: process.env,
572
+ shell: process.platform === 'win32',
573
+ },
574
+ );
575
+
576
+ process.exit(result.status ?? 1);
577
+
578
+ END_OF_FILE_CONTENT
579
+ echo "Creating scripts/bake.ts..."
580
+ cat << 'END_OF_FILE_CONTENT' > "scripts/bake.ts"
581
+ /**
582
+ * Next bake — agentic WebMCP artifacts only (no Vite / no HTML SSG).
583
+ * Invoked by scripts/bake.mjs via tsx.
584
+ */
585
+ import fs from 'node:fs/promises';
586
+ import path from 'node:path';
587
+ import { fileURLToPath } from 'node:url';
588
+ import {
589
+ resolvePageMatchFromRegistry,
590
+ resolvePublicPageDocument,
591
+ webmcp,
592
+ } from '@olonjs/core';
593
+ import { CollectionRegistry } from '../src/lib/CollectionRegistry';
594
+ import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '../src/lib/schemas';
595
+ import { getFileCollections } from '../src/lib/loaders/getFileCollections';
596
+ import { getFilePages } from '../src/lib/loaders/getFilePages';
597
+ import { getFileSiteBundle } from '../src/lib/loaders/getFileSiteConfig';
598
+
599
+ const {
600
+ buildPageContract,
601
+ buildPageManifest,
602
+ buildPageManifestHref,
603
+ buildSiteManifest,
604
+ buildLlmsTxt,
605
+ } = webmcp;
606
+
607
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
608
+ const root = path.resolve(__dirname, '..');
609
+ const publicDir = path.join(root, 'public');
610
+ const pagesDir = path.join(root, 'src', 'data', 'pages');
611
+ const collectionsDir = path.join(root, 'src', 'data', 'collections');
612
+
613
+ async function writePublic(relativePath: string, content: string): Promise<void> {
614
+ const target = path.join(publicDir, relativePath);
615
+ await fs.mkdir(path.dirname(target), { recursive: true });
616
+ await fs.writeFile(target, content, 'utf-8');
617
+ }
618
+
619
+ async function writePublicJson(relativePath: string, value: unknown): Promise<void> {
620
+ await writePublic(relativePath, `${JSON.stringify(value, null, 2)}\n`);
621
+ }
622
+
623
+ async function readJsonFile(filePath: string): Promise<unknown> {
624
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
625
+ }
626
+
627
+ async function listJsonFilesRecursive(dir: string): Promise<string[]> {
628
+ const items = await fs.readdir(dir, { withFileTypes: true });
629
+ const files: string[] = [];
630
+ for (const item of items) {
631
+ const fullPath = path.join(dir, item.name);
632
+ if (item.isDirectory()) {
633
+ files.push(...(await listJsonFilesRecursive(fullPath)));
634
+ continue;
635
+ }
636
+ if (item.isFile() && item.name.toLowerCase().endsWith('.json')) files.push(fullPath);
637
+ }
638
+ return files;
639
+ }
640
+
641
+ function toCanonicalSlug(relativeJsonPath: string): string {
642
+ const slug = relativeJsonPath.replace(/\\/g, '/').replace(/\.json$/i, '').replace(/^\/+|\/+$/g, '');
643
+ if (!slug) throw new Error('[bake] Invalid page slug: empty path segment');
644
+ return slug;
645
+ }
646
+
647
+ async function expandCollectionTarget(slug: string, pageFilePath: string): Promise<string[]> {
648
+ let pageConfig: Record<string, unknown>;
649
+ try {
650
+ pageConfig = (await readJsonFile(pageFilePath)) as Record<string, unknown>;
651
+ } catch {
652
+ return [slug];
653
+ }
654
+
655
+ const binding = pageConfig?.collection as { source?: string; paramKey?: string } | undefined;
656
+ if (!binding || typeof binding.source !== 'string' || typeof binding.paramKey !== 'string') {
657
+ return [slug];
658
+ }
659
+
660
+ const token = `[${binding.paramKey}]`;
661
+ const authoredSlug =
662
+ typeof pageConfig.slug === 'string'
663
+ ? String(pageConfig.slug).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
664
+ : '';
665
+ const routePattern =
666
+ authoredSlug.includes(token) ? authoredSlug : slug.includes(token) ? slug : '';
667
+ if (!routePattern) return [slug];
668
+
669
+ const collectionPath = path.resolve(collectionsDir, binding.source, `${binding.source}.json`);
670
+ let collection: Record<string, unknown>;
671
+ try {
672
+ collection = (await readJsonFile(collectionPath)) as Record<string, unknown>;
673
+ } catch {
674
+ return [slug];
675
+ }
676
+
677
+ if (!collection || typeof collection !== 'object' || Array.isArray(collection)) return [slug];
678
+ const itemIds = Object.keys(collection).sort((a, b) => a.localeCompare(b));
679
+ return itemIds.length > 0
680
+ ? itemIds.map((itemId) => routePattern.replace(token, itemId))
681
+ : [slug];
682
+ }
683
+
684
+ async function discoverSlugs(): Promise<string[]> {
685
+ let files: string[] = [];
686
+ try {
687
+ files = await listJsonFilesRecursive(pagesDir);
688
+ } catch {
689
+ files = [];
690
+ }
691
+
692
+ const rawSlugs = (
693
+ await Promise.all(
694
+ files.map(async (fullPath) => {
695
+ const slug = toCanonicalSlug(path.relative(pagesDir, fullPath));
696
+ return expandCollectionTarget(slug, fullPath);
697
+ }),
698
+ )
699
+ ).flat();
700
+
701
+ return Array.from(new Set(rawSlugs)).sort((a, b) => a.localeCompare(b));
702
+ }
703
+
704
+ async function main(): Promise<void> {
705
+ console.log('\n[bake] Next agentic artifacts (no Vite SSG)...');
706
+
707
+ const pages = getFilePages(root);
708
+ const collections = getFileCollections(root);
709
+ const { siteConfig, themeConfig, menuConfig } = getFileSiteBundle(root);
710
+ const collectionSchemas = CollectionRegistry as unknown as Record<string, unknown>;
711
+ const schemas = SECTION_SCHEMAS as unknown as Record<string, unknown>;
712
+ const submissionSchemas = SECTION_SUBMISSION_SCHEMAS as unknown as Record<string, unknown>;
713
+ const refDocuments = {
714
+ 'menu.json': menuConfig,
715
+ 'config/menu.json': menuConfig,
716
+ 'src/data/config/menu.json': menuConfig,
717
+ };
718
+
719
+ const slugs = await discoverSlugs();
720
+ if (slugs.length === 0) {
721
+ throw new Error('[bake] No pages discovered under src/data/pages');
722
+ }
723
+ console.log(`[bake] Targets: ${slugs.join(', ')}`);
724
+
725
+ const pagesForManifest: Record<string, (typeof pages)[string]> = { ...pages };
726
+
727
+ for (const slug of slugs) {
728
+ const pageConfig = resolvePageMatchFromRegistry(pages, slug)?.page;
729
+ if (!pageConfig) continue;
730
+
731
+ const resolvedPageDocument = resolvePublicPageDocument({
732
+ slug,
733
+ pages,
734
+ siteConfig,
735
+ themeConfig,
736
+ menuConfig,
737
+ collections,
738
+ collectionSchemas: collectionSchemas as never,
739
+ refDocuments,
740
+ });
741
+ const publicPageConfig = resolvedPageDocument?.page ?? pageConfig;
742
+ pagesForManifest[slug] = publicPageConfig;
743
+
744
+ await writePublicJson(`pages/${slug}.json`, publicPageConfig);
745
+
746
+ const contract = buildPageContract({
747
+ slug,
748
+ pageConfig: publicPageConfig,
749
+ schemas: schemas as never,
750
+ submissionSchemas: submissionSchemas as never,
751
+ siteConfig,
752
+ });
753
+ await writePublicJson(`schemas/${slug}.schema.json`, contract);
754
+
755
+ const pageManifest = buildPageManifest({
756
+ slug,
757
+ pageConfig: publicPageConfig,
758
+ schemas: schemas as never,
759
+ siteConfig,
760
+ });
761
+ await writePublicJson(buildPageManifestHref(slug).replace(/^\//, ''), pageManifest);
762
+ }
763
+
764
+ await writePublicJson('config/site.json', siteConfig);
765
+
766
+ const mcpManifest = buildSiteManifest({
767
+ pages: pagesForManifest,
768
+ schemas: schemas as never,
769
+ siteConfig,
770
+ });
771
+ await writePublicJson('mcp-manifest.json', mcpManifest);
772
+
773
+ const llmsTxtContent = buildLlmsTxt({
774
+ pages: pagesForManifest,
775
+ schemas: schemas as never,
776
+ siteConfig,
777
+ });
778
+ await writePublic('llms.txt', `${llmsTxtContent}\n`);
779
+
780
+ console.log('[bake] Wrote public/mcp-manifest.json, mcp-manifests/, schemas/, pages/, llms.txt, config/site.json');
781
+ console.log('[bake] OK\n');
782
+ }
783
+
784
+ main().catch((error) => {
785
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
786
+ process.exit(1);
787
+ });
788
+
789
+ END_OF_FILE_CONTENT
532
790
  echo "Creating scripts/generate-SystemsArchitect-next.test.mjs..."
533
791
  cat << 'END_OF_FILE_CONTENT' > "scripts/generate-SystemsArchitect-next.test.mjs"
534
792
  /**
@@ -581,12 +839,15 @@ describe('generate_SystemsArchitect_next.sh harness gates', () => {
581
839
  assert.match(src, /cd "\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)\/\.\." && pwd\)"/);
582
840
  });
583
841
 
584
- it('must clean DNA demo capsules before writing SystemsArchitect', () => {
842
+ it('must wipe tenant content surfaces without DNA denylist', () => {
585
843
  const src = readScript();
586
- assert.match(src, /Cleaning demo capsules/);
587
- assert.match(src, /rm -rf \\\s*\n\s*src\/components\/books-list/m);
844
+ assert.match(src, /Wiping tenant content surfaces/);
845
+ assert.match(src, /find src\/components -mindepth 1 -maxdepth 1 ! -name 'ui' ! -name 'admin'/);
846
+ assert.match(src, /rm -rf \\\s*\n\s*src\/collections/m);
847
+ assert.match(src, /rm -rf \\\s*\n[\s\S]*?src\/data\/pages/m);
588
848
  assert.match(src, /cat > src\/lib\/VisitorSection\.tsx/);
589
849
  assert.doesNotMatch(src, /from '@\/components\/books-list'/);
850
+ assert.doesNotMatch(src, /src\/components\/books-list/);
590
851
  });
591
852
 
592
853
  it('must install react-markdown deps used by post-detail', () => {
@@ -597,11 +858,10 @@ describe('generate_SystemsArchitect_next.sh harness gates', () => {
597
858
  assert.match(src, /from 'rehype-sanitize'/);
598
859
  });
599
860
 
600
- it('must not generate empty-tenant registry wiring (cleanup rm path ok)', () => {
861
+ it('must not generate empty-tenant registry wiring', () => {
601
862
  const src = readScript();
602
863
  assert.doesNotMatch(src, /@\/components\/empty-tenant/);
603
864
  assert.doesNotMatch(src, /['"]empty-tenant['"]/);
604
- assert.match(src, /src\/components\/empty-tenant/);
605
865
  });
606
866
 
607
867
  it('must force shadcn radix base non-interactively', () => {
@@ -664,15 +924,435 @@ describe('generate_inkwell_next.sh harness gates', () => {
664
924
  assert.match(src, /cd "\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)\/\.\." && pwd\)"/);
665
925
  });
666
926
 
667
- it('must clean DNA demo capsules before writing Inkwell', () => {
927
+ it('must wipe tenant content surfaces without DNA denylist', () => {
668
928
  const src = readScript();
669
- assert.match(src, /Cleaning demo capsules/);
670
- assert.match(src, /rm -rf \\\s*\n[\s\S]*?src\/components\/books-list/m);
929
+ assert.match(src, /Wiping tenant content surfaces/);
930
+ assert.match(src, /find src\/components -mindepth 1 -maxdepth 1 ! -name 'ui' ! -name 'admin'/);
931
+ assert.match(src, /rm -rf \\\s*\n\s*src\/collections/m);
932
+ assert.match(src, /rm -rf \\\s*\n[\s\S]*?src\/data\/pages/m);
671
933
  assert.match(src, /cat > src\/lib\/VisitorSection\.tsx/);
934
+ assert.match(src, /EmptyTenantView empty-branch/);
672
935
  assert.doesNotMatch(src, /from '@\/components\/books-list'/);
936
+ assert.doesNotMatch(src, /src\/components\/books-list/);
673
937
  });
674
938
  });
675
939
 
940
+ END_OF_FILE_CONTENT
941
+ echo "Creating scripts/generate-llms-txt.mjs..."
942
+ cat << 'END_OF_FILE_CONTENT' > "scripts/generate-llms-txt.mjs"
943
+ import fs from 'fs';
944
+ import path from 'path';
945
+ import { fileURLToPath } from 'url';
946
+ import { webmcp } from '@olonjs/core';
947
+
948
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
949
+ const rootDir = path.resolve(__dirname, '..');
950
+ const { buildLlmsTxt } = webmcp;
951
+
952
+ const pagesDir = path.join(rootDir, 'src', 'data', 'pages');
953
+ const siteConfig = JSON.parse(fs.readFileSync(path.join(rootDir, 'src', 'data', 'config', 'site.json'), 'utf-8'));
954
+
955
+ function listJsonFilesRecursive(dir) {
956
+ const items = fs.readdirSync(dir, { withFileTypes: true });
957
+ const files = [];
958
+ for (const item of items) {
959
+ const fullPath = path.join(dir, item.name);
960
+ if (item.isDirectory()) {
961
+ files.push(...listJsonFilesRecursive(fullPath));
962
+ continue;
963
+ }
964
+ if (item.isFile() && item.name.toLowerCase().endsWith('.json')) files.push(fullPath);
965
+ }
966
+ return files;
967
+ }
968
+
969
+ const pages = {};
970
+ for (const fullPath of listJsonFilesRecursive(pagesDir)) {
971
+ const slug = path.relative(pagesDir, fullPath).replace(/\\/g, '/').replace(/\.json$/i, '');
972
+ pages[slug] = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
973
+ }
974
+
975
+ const llmsTxt = buildLlmsTxt({ pages, schemas: {}, siteConfig });
976
+
977
+ const outPath = path.join(rootDir, 'public', 'llms.txt');
978
+ fs.writeFileSync(outPath, llmsTxt, 'utf-8');
979
+ console.log('[generate-llms-txt] Written -> public/llms.txt');
980
+
981
+ END_OF_FILE_CONTENT
982
+ echo "Creating scripts/prebuild-bake.test.mjs..."
983
+ cat << 'END_OF_FILE_CONTENT' > "scripts/prebuild-bake.test.mjs"
984
+ /**
985
+ * Gates for bake.mjs (Task 4 — plan 001). Agentic artifacts only; no Vite SSG.
986
+ * Run: node --test scripts/prebuild-bake.test.mjs
987
+ */
988
+ import assert from 'node:assert/strict';
989
+ import fs from 'node:fs';
990
+ import path from 'node:path';
991
+ import { spawnSync } from 'node:child_process';
992
+ import { describe, it } from 'node:test';
993
+ import { fileURLToPath } from 'node:url';
994
+
995
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
996
+ const ROOT = path.resolve(__dirname, '..');
997
+ const SCRIPT = path.join(__dirname, 'bake.mjs');
998
+
999
+ describe('next bake.mjs (agentic artifacts)', () => {
1000
+ it('exists and does not import vite / write HTML SSG', () => {
1001
+ assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
1002
+ const src = fs.readFileSync(SCRIPT, 'utf8');
1003
+ assert.doesNotMatch(src, /from ['"]vite['"]/);
1004
+ assert.doesNotMatch(src, /vite\.build|entry-ssg/);
1005
+ // May spawn tsx helper — check companion if present
1006
+ const impl = path.join(__dirname, 'bake.ts');
1007
+ if (fs.existsSync(impl)) {
1008
+ const implSrc = fs.readFileSync(impl, 'utf8');
1009
+ assert.doesNotMatch(implSrc, /from ['"]vite['"]/);
1010
+ assert.doesNotMatch(implSrc, /entry-ssg/);
1011
+ }
1012
+ });
1013
+
1014
+ it('writes reachable public agentic artifacts', () => {
1015
+ const result = spawnSync(process.execPath, [SCRIPT], {
1016
+ cwd: ROOT,
1017
+ encoding: 'utf8',
1018
+ env: { ...process.env },
1019
+ });
1020
+ assert.equal(result.status, 0, result.stderr || result.stdout);
1021
+
1022
+ assert.ok(fs.existsSync(path.join(ROOT, 'public', 'mcp-manifest.json')));
1023
+ assert.ok(fs.existsSync(path.join(ROOT, 'public', 'llms.txt')));
1024
+ assert.ok(fs.existsSync(path.join(ROOT, 'public', 'schemas', 'home.schema.json')));
1025
+ assert.ok(fs.existsSync(path.join(ROOT, 'public', 'mcp-manifests', 'home.json')));
1026
+ assert.ok(fs.existsSync(path.join(ROOT, 'public', 'config', 'site.json')));
1027
+
1028
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'public', 'mcp-manifest.json'), 'utf8'));
1029
+ assert.equal(manifest.kind, 'olonjs-mcp-manifest-index');
1030
+ assert.ok(Array.isArray(manifest.pages));
1031
+ });
1032
+ });
1033
+
1034
+ END_OF_FILE_CONTENT
1035
+ echo "Creating scripts/prebuild-generate-llms-txt.test.mjs..."
1036
+ cat << 'END_OF_FILE_CONTENT' > "scripts/prebuild-generate-llms-txt.test.mjs"
1037
+ /**
1038
+ * Gates for generate-llms-txt.mjs (Task 2 — plan 001).
1039
+ * Run: node --test scripts/prebuild-generate-llms-txt.test.mjs
1040
+ */
1041
+ import assert from 'node:assert/strict';
1042
+ import fs from 'node:fs';
1043
+ import path from 'node:path';
1044
+ import { spawnSync } from 'node:child_process';
1045
+ import { describe, it } from 'node:test';
1046
+ import { fileURLToPath } from 'node:url';
1047
+
1048
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1049
+ const ROOT = path.resolve(__dirname, '..');
1050
+ const SCRIPT = path.join(__dirname, 'generate-llms-txt.mjs');
1051
+
1052
+ describe('next prebuild generate-llms-txt', () => {
1053
+ it('script exists and uses @olonjs/core webmcp', () => {
1054
+ assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
1055
+ const src = fs.readFileSync(SCRIPT, 'utf8');
1056
+ assert.match(src, /@olonjs\/core/);
1057
+ assert.match(src, /buildLlmsTxt|webmcp/);
1058
+ assert.match(src, /public\/llms\.txt|llms\.txt/);
1059
+ });
1060
+
1061
+ it('writes public/llms.txt when run', () => {
1062
+ const out = path.join(ROOT, 'public', 'llms.txt');
1063
+ if (fs.existsSync(out)) fs.unlinkSync(out);
1064
+
1065
+ const result = spawnSync(process.execPath, [SCRIPT], { cwd: ROOT, encoding: 'utf8' });
1066
+ assert.equal(result.status, 0, result.stderr || result.stdout);
1067
+ assert.ok(fs.existsSync(out));
1068
+ const body = fs.readFileSync(out, 'utf8');
1069
+ assert.ok(body.trim().length > 0);
1070
+ });
1071
+ });
1072
+
1073
+ END_OF_FILE_CONTENT
1074
+ echo "Creating scripts/prebuild-robots-sitemap.test.mjs..."
1075
+ cat << 'END_OF_FILE_CONTENT' > "scripts/prebuild-robots-sitemap.test.mjs"
1076
+ /**
1077
+ * Static + smoke gates for robots.mjs / sitemap.mjs (Task 1 — plan 001).
1078
+ * Run: node --test scripts/prebuild-robots-sitemap.test.mjs
1079
+ */
1080
+ import assert from 'node:assert/strict';
1081
+ import fs from 'node:fs';
1082
+ import path from 'node:path';
1083
+ import { spawnSync } from 'node:child_process';
1084
+ import { describe, it } from 'node:test';
1085
+ import { fileURLToPath } from 'node:url';
1086
+
1087
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1088
+ const ROOT = path.resolve(__dirname, '..');
1089
+ const ROBOTS = path.join(__dirname, 'robots.mjs');
1090
+ const SITEMAP = path.join(__dirname, 'sitemap.mjs');
1091
+
1092
+ describe('next prebuild robots + sitemap', () => {
1093
+ it('scripts exist', () => {
1094
+ assert.ok(fs.existsSync(ROBOTS), `missing ${ROBOTS}`);
1095
+ assert.ok(fs.existsSync(SITEMAP), `missing ${SITEMAP}`);
1096
+ });
1097
+
1098
+ it('defaults to Next localhost:3000 (not Vite 5173)', () => {
1099
+ const robots = fs.readFileSync(ROBOTS, 'utf8');
1100
+ const sitemap = fs.readFileSync(SITEMAP, 'utf8');
1101
+ assert.match(robots, /localhost:3000/);
1102
+ assert.match(sitemap, /localhost:3000/);
1103
+ assert.doesNotMatch(robots, /localhost:5173/);
1104
+ assert.doesNotMatch(sitemap, /localhost:5173/);
1105
+ });
1106
+
1107
+ it('writes public/robots.txt and public/sitemap.xml when run', () => {
1108
+ const robotsOut = path.join(ROOT, 'public', 'robots.txt');
1109
+ const sitemapOut = path.join(ROOT, 'public', 'sitemap.xml');
1110
+ for (const p of [robotsOut, sitemapOut]) {
1111
+ if (fs.existsSync(p)) fs.unlinkSync(p);
1112
+ }
1113
+
1114
+ const r1 = spawnSync(process.execPath, [ROBOTS], { cwd: ROOT, encoding: 'utf8' });
1115
+ assert.equal(r1.status, 0, r1.stderr || r1.stdout);
1116
+ const r2 = spawnSync(process.execPath, [SITEMAP], { cwd: ROOT, encoding: 'utf8' });
1117
+ assert.equal(r2.status, 0, r2.stderr || r2.stdout);
1118
+
1119
+ assert.ok(fs.existsSync(robotsOut));
1120
+ assert.ok(fs.existsSync(sitemapOut));
1121
+ const robotsTxt = fs.readFileSync(robotsOut, 'utf8');
1122
+ const sitemapXml = fs.readFileSync(sitemapOut, 'utf8');
1123
+ assert.match(robotsTxt, /Sitemap: http:\/\/localhost:3000\/sitemap\.xml/);
1124
+ assert.match(sitemapXml, /<urlset/);
1125
+ assert.match(sitemapXml, /\/home\.json|PAGE: HOME|loc>http:\/\/localhost:3000\/</);
1126
+ });
1127
+ });
1128
+
1129
+ END_OF_FILE_CONTENT
1130
+ echo "Creating scripts/prebuild-webmcp-feature-check.test.mjs..."
1131
+ cat << 'END_OF_FILE_CONTENT' > "scripts/prebuild-webmcp-feature-check.test.mjs"
1132
+ /**
1133
+ * Gates for webmcp-feature-check.mjs (Task 3 — plan 001).
1134
+ * Run: node --test scripts/prebuild-webmcp-feature-check.test.mjs
1135
+ */
1136
+ import assert from 'node:assert/strict';
1137
+ import fs from 'node:fs';
1138
+ import path from 'node:path';
1139
+ import { describe, it } from 'node:test';
1140
+ import { fileURLToPath } from 'node:url';
1141
+
1142
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1143
+ const ROOT = path.resolve(__dirname, '..');
1144
+ const SCRIPT = path.join(__dirname, 'webmcp-feature-check.mjs');
1145
+ const PKG = path.join(ROOT, 'package.json');
1146
+
1147
+ describe('next verify:webmcp script', () => {
1148
+ it('exists and uses document.modelContextTesting only', () => {
1149
+ assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
1150
+ const src = fs.readFileSync(SCRIPT, 'utf8');
1151
+ assert.match(src, /document\.modelContextTesting/);
1152
+ assert.doesNotMatch(src, /navigator\.modelContextTesting/);
1153
+ assert.doesNotMatch(src, /navigator\.modelContext(?!Testing)/);
1154
+ });
1155
+
1156
+ it('is wired as verify:webmcp and not in prebuild', () => {
1157
+ const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8'));
1158
+ assert.equal(pkg.scripts?.['verify:webmcp'], 'node scripts/webmcp-feature-check.mjs');
1159
+ assert.ok(pkg.scripts?.prebuild);
1160
+ assert.doesNotMatch(pkg.scripts.prebuild, /webmcp-feature-check/);
1161
+ });
1162
+ });
1163
+
1164
+ END_OF_FILE_CONTENT
1165
+ echo "Creating scripts/prebuild-wire.test.mjs..."
1166
+ cat << 'END_OF_FILE_CONTENT' > "scripts/prebuild-wire.test.mjs"
1167
+ /**
1168
+ * Gates for package.json prebuild wiring (Task 5 — plan 001).
1169
+ * Run: node --test scripts/prebuild-wire.test.mjs
1170
+ */
1171
+ import assert from 'node:assert/strict';
1172
+ import fs from 'node:fs';
1173
+ import path from 'node:path';
1174
+ import { describe, it } from 'node:test';
1175
+ import { fileURLToPath } from 'node:url';
1176
+
1177
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1178
+ const PKG = path.resolve(__dirname, '../package.json');
1179
+
1180
+ describe('next prebuild wiring', () => {
1181
+ it('runs sync → llms → bake → sitemap → robots (alpha order)', () => {
1182
+ const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8'));
1183
+ const prebuild = pkg.scripts?.prebuild ?? '';
1184
+ assert.match(prebuild, /sync-pages-to-public\.mjs/);
1185
+ assert.match(prebuild, /generate-llms-txt\.mjs/);
1186
+ assert.match(prebuild, /bake\.mjs/);
1187
+ assert.match(prebuild, /sitemap\.mjs/);
1188
+ assert.match(prebuild, /robots\.mjs/);
1189
+ assert.doesNotMatch(prebuild, /webmcp-feature-check/);
1190
+
1191
+ const order = ['sync-pages-to-public', 'generate-llms-txt', 'bake', 'sitemap', 'robots'].map((name) =>
1192
+ prebuild.indexOf(name),
1193
+ );
1194
+ for (let i = 1; i < order.length; i += 1) {
1195
+ assert.ok(order[i] > order[i - 1], `expected ${i} after previous in: ${prebuild}`);
1196
+ }
1197
+ });
1198
+
1199
+ it('dist DNA includes scripts/', () => {
1200
+ const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8'));
1201
+ assert.match(pkg.scripts?.dist ?? '', /\bscripts\b/);
1202
+ });
1203
+ });
1204
+
1205
+ END_OF_FILE_CONTENT
1206
+ echo "Creating scripts/robots.mjs..."
1207
+ cat << 'END_OF_FILE_CONTENT' > "scripts/robots.mjs"
1208
+ import fs from 'fs';
1209
+ import path from 'path';
1210
+ import { fileURLToPath } from 'url';
1211
+
1212
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1213
+ const rootDir = path.resolve(__dirname, '..');
1214
+
1215
+ const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
1216
+ ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
1217
+ : 'http://localhost:3000';
1218
+
1219
+ const robotsTxt = `User-agent: *
1220
+ Allow: /
1221
+ Disallow: /api/
1222
+
1223
+ User-agent: GPTBot
1224
+ User-agent: ChatGPT-User
1225
+ User-agent: ClaudeBot
1226
+ User-agent: Claude-Web
1227
+ User-agent: PerplexityBot
1228
+ User-agent: OAI-SearchBot
1229
+ Allow: /
1230
+ Allow: /*.json
1231
+ Allow: /schemas/
1232
+ Allow: /llms.txt
1233
+ Allow: /mcp-manifest.json
1234
+ Disallow: /api/
1235
+
1236
+ Sitemap: ${baseUrl}/sitemap.xml
1237
+ `;
1238
+
1239
+ const outPath = path.join(rootDir, 'public', 'robots.txt');
1240
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1241
+ fs.writeFileSync(outPath, robotsTxt, 'utf-8');
1242
+ console.log('[robots] Written -> public/robots.txt');
1243
+
1244
+ END_OF_FILE_CONTENT
1245
+ echo "Creating scripts/sitemap.mjs..."
1246
+ cat << 'END_OF_FILE_CONTENT' > "scripts/sitemap.mjs"
1247
+ import fs from 'fs';
1248
+ import path from 'path';
1249
+ import { fileURLToPath } from 'url';
1250
+
1251
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1252
+ const rootDir = path.resolve(__dirname, '..');
1253
+
1254
+ const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
1255
+ ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
1256
+ : 'http://localhost:3000';
1257
+
1258
+ function listJsonFilesRecursive(dir) {
1259
+ const items = fs.readdirSync(dir, { withFileTypes: true });
1260
+ const files = [];
1261
+ for (const item of items) {
1262
+ const fullPath = path.join(dir, item.name);
1263
+ if (item.isDirectory()) {
1264
+ files.push(...listJsonFilesRecursive(fullPath));
1265
+ continue;
1266
+ }
1267
+ if (item.isFile() && item.name.toLowerCase().endsWith('.json')) files.push(fullPath);
1268
+ }
1269
+ return files;
1270
+ }
1271
+
1272
+ function toW3CDate(date) {
1273
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
1274
+ }
1275
+
1276
+ function urlEntry({ loc, lastmod, changefreq, priority, comment }) {
1277
+ const lines = [];
1278
+ if (comment) lines.push(` <!-- ${comment} -->`);
1279
+ lines.push(` <url>`);
1280
+ lines.push(` <loc>${loc}</loc>`);
1281
+ lines.push(` <lastmod>${lastmod}</lastmod>`);
1282
+ lines.push(` <changefreq>${changefreq}</changefreq>`);
1283
+ lines.push(` <priority>${priority}</priority>`);
1284
+ lines.push(` </url>`);
1285
+ return lines.join('\n');
1286
+ }
1287
+
1288
+ function sectionComment(label) {
1289
+ const bar = '='.repeat(42);
1290
+ return [
1291
+ ` <!-- ${bar} -->`,
1292
+ ` <!-- ${label.padEnd(42)} -->`,
1293
+ ` <!-- ${bar} -->`,
1294
+ ].join('\n');
1295
+ }
1296
+
1297
+ const pagesDir = path.join(rootDir, 'src', 'data', 'pages');
1298
+ const buildTime = toW3CDate(new Date());
1299
+
1300
+ const pageFiles = listJsonFilesRecursive(pagesDir);
1301
+ const pages = pageFiles.map((fullPath) => {
1302
+ const slug = path
1303
+ .relative(pagesDir, fullPath)
1304
+ .replace(/\\/g, '/')
1305
+ .replace(/\.json$/i, '');
1306
+ const lastmod = toW3CDate(fs.statSync(fullPath).mtime);
1307
+ return { slug, lastmod };
1308
+ });
1309
+
1310
+ const entries = [];
1311
+
1312
+ entries.push(sectionComment('GLOBAL AGENT DISCOVERY NODES'));
1313
+ entries.push(
1314
+ urlEntry({ loc: `${baseUrl}/llms.txt`, lastmod: buildTime, changefreq: 'weekly', priority: '1.0' }),
1315
+ );
1316
+ entries.push(
1317
+ urlEntry({ loc: `${baseUrl}/mcp-manifest.json`, lastmod: buildTime, changefreq: 'weekly', priority: '1.0' }),
1318
+ );
1319
+
1320
+ for (const { slug, lastmod } of pages) {
1321
+ const humanPath = slug === 'home' ? '/' : `/${slug}`;
1322
+ const label = `PAGE: ${slug.toUpperCase()}`;
1323
+
1324
+ entries.push(sectionComment(label));
1325
+ entries.push(
1326
+ urlEntry({ loc: `${baseUrl}${humanPath}`, lastmod, changefreq: 'daily', priority: '0.9', comment: 'Human UI' }),
1327
+ );
1328
+ entries.push(
1329
+ urlEntry({ loc: `${baseUrl}/${slug}.json`, lastmod, changefreq: 'daily', priority: '0.9', comment: 'Machine Payload' }),
1330
+ );
1331
+ entries.push(
1332
+ urlEntry({
1333
+ loc: `${baseUrl}/schemas/${slug}.schema.json`,
1334
+ lastmod: buildTime,
1335
+ changefreq: 'weekly',
1336
+ priority: '0.8',
1337
+ comment: 'Machine Contract (Schema)',
1338
+ }),
1339
+ );
1340
+ }
1341
+
1342
+ const xml = [
1343
+ `<?xml version="1.0" encoding="UTF-8"?>`,
1344
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
1345
+ ``,
1346
+ entries.join('\n'),
1347
+ ``,
1348
+ `</urlset>`,
1349
+ ].join('\n');
1350
+
1351
+ const outPath = path.join(rootDir, 'public', 'sitemap.xml');
1352
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1353
+ fs.writeFileSync(outPath, xml, 'utf-8');
1354
+ console.log('[sitemap] Written -> public/sitemap.xml');
1355
+
676
1356
  END_OF_FILE_CONTENT
677
1357
  echo "Creating scripts/sync-pages-to-public.mjs..."
678
1358
  cat << 'END_OF_FILE_CONTENT' > "scripts/sync-pages-to-public.mjs"
@@ -723,6 +1403,308 @@ if (fs.existsSync(sourceSiteConfigPath)) {
723
1403
 
724
1404
  console.log('[sync-pages-to-public] Synced pages, collections, and site config to public/');
725
1405
 
1406
+ END_OF_FILE_CONTENT
1407
+ echo "Creating scripts/webmcp-feature-check.mjs..."
1408
+ cat << 'END_OF_FILE_CONTENT' > "scripts/webmcp-feature-check.mjs"
1409
+ import fs from 'fs/promises';
1410
+ import path from 'path';
1411
+ import { fileURLToPath } from 'url';
1412
+ import { createRequire } from 'module';
1413
+
1414
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1415
+ const rootDir = path.resolve(__dirname, '..');
1416
+ const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
1417
+
1418
+ function pageFilePathFromSlug(slug) {
1419
+ return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1420
+ }
1421
+
1422
+ function adminUrlFromSlug(slug) {
1423
+ return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1424
+ }
1425
+
1426
+ function isStringSchema(schema) {
1427
+ if (!schema || typeof schema !== 'object') return false;
1428
+ if (schema.type === 'string') return true;
1429
+ if (Array.isArray(schema.anyOf)) {
1430
+ return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1431
+ }
1432
+ return false;
1433
+ }
1434
+
1435
+ function findTopLevelStringField(sectionSchema) {
1436
+ const properties = sectionSchema?.properties;
1437
+ if (!properties || typeof properties !== 'object') return null;
1438
+ const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1439
+ for (const key of preferred) {
1440
+ if (isStringSchema(properties[key])) return key;
1441
+ }
1442
+ for (const [key, value] of Object.entries(properties)) {
1443
+ if (isStringSchema(value)) return key;
1444
+ }
1445
+ return null;
1446
+ }
1447
+
1448
+ async function loadPlaywright() {
1449
+ const require = createRequire(import.meta.url);
1450
+ try {
1451
+ return require('playwright');
1452
+ } catch (error) {
1453
+ const message = error instanceof Error ? error.message : String(error);
1454
+ throw new Error(
1455
+ `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1456
+ );
1457
+ }
1458
+ }
1459
+
1460
+ async function readPageJson(slug) {
1461
+ const pageFilePath = pageFilePathFromSlug(slug);
1462
+ const raw = await fs.readFile(pageFilePath, 'utf8');
1463
+ return { raw, json: JSON.parse(raw), pageFilePath };
1464
+ }
1465
+
1466
+ async function waitFor(predicate, timeoutMs, label) {
1467
+ const startedAt = Date.now();
1468
+ for (;;) {
1469
+ const result = await predicate();
1470
+ if (result) return result;
1471
+ if (Date.now() - startedAt > timeoutMs) {
1472
+ throw new Error(`Timed out while waiting for ${label}.`);
1473
+ }
1474
+ await new Promise((resolve) => setTimeout(resolve, 150));
1475
+ }
1476
+ }
1477
+
1478
+ async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1479
+ await waitFor(async () => {
1480
+ const { json } = await readPageJson(slug);
1481
+ const section = Array.isArray(json.sections)
1482
+ ? json.sections.find((item) => item?.id === sectionId)
1483
+ : null;
1484
+ return section?.data?.[fieldKey] === expectedValue;
1485
+ }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1486
+ }
1487
+
1488
+ async function ensureResponseOk(response, label) {
1489
+ if (!response.ok) {
1490
+ const text = await response.text();
1491
+ throw new Error(`${label} failed with ${response.status}: ${text}`);
1492
+ }
1493
+ return response;
1494
+ }
1495
+
1496
+ async function fetchJson(relativePath, label) {
1497
+ const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1498
+ return response.json();
1499
+ }
1500
+
1501
+ async function selectTarget() {
1502
+ const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1503
+ const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1504
+ ? process.env.WEBMCP_TARGET_SLUG.trim()
1505
+ : null;
1506
+
1507
+ const candidatePages = requestedSlug
1508
+ ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1509
+ : (siteIndex.pages ?? []);
1510
+
1511
+ for (const pageEntry of candidatePages) {
1512
+ if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1513
+ const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1514
+ const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1515
+ const localInstances = Array.isArray(pageContract.sectionInstances)
1516
+ ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1517
+ : [];
1518
+ const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1519
+
1520
+ for (const tool of tools) {
1521
+ const sectionType = tool?.sectionType;
1522
+ if (typeof tool?.name !== 'string' || typeof sectionType !== 'string') continue;
1523
+ const targetInstance = localInstances.find((section) => section?.type === sectionType);
1524
+ if (!targetInstance?.id) continue;
1525
+ const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1526
+ if (!targetFieldKey) continue;
1527
+ const pageState = await readPageJson(pageEntry.slug);
1528
+ const section = Array.isArray(pageState.json.sections)
1529
+ ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1530
+ : null;
1531
+ const originalValue = section?.data?.[targetFieldKey];
1532
+ if (typeof originalValue !== 'string') continue;
1533
+
1534
+ return {
1535
+ slug: pageEntry.slug,
1536
+ manifestHref: pageEntry.manifestHref,
1537
+ contractHref: pageEntry.contractHref,
1538
+ toolName: tool.name,
1539
+ sectionId: targetInstance.id,
1540
+ fieldKey: targetFieldKey,
1541
+ originalValue,
1542
+ originalState: pageState,
1543
+ };
1544
+ }
1545
+ }
1546
+
1547
+ throw new Error(
1548
+ requestedSlug
1549
+ ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1550
+ : 'No valid WebMCP verification target found in manifest index.'
1551
+ );
1552
+ }
1553
+
1554
+ async function main() {
1555
+ const { chromium } = await loadPlaywright();
1556
+ const target = await selectTarget();
1557
+ const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1558
+ const browser = await chromium.launch({ headless: true });
1559
+ const page = await browser.newPage();
1560
+ const consoleEvents = [];
1561
+ let mutationApplied = false;
1562
+
1563
+ page.on('console', (message) => {
1564
+ if (message.type() === 'error' || message.type() === 'warning') {
1565
+ consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1566
+ }
1567
+ });
1568
+ page.on('pageerror', (error) => {
1569
+ consoleEvents.push(`[pageerror] ${error.message}`);
1570
+ });
1571
+
1572
+ const restoreOriginal = async () => {
1573
+ try {
1574
+ await page.evaluate(
1575
+ async ({ toolName, slug, sectionId, fieldKey, value }) => {
1576
+ const runtime = document.modelContextTesting;
1577
+ if (!runtime?.executeTool) return;
1578
+ await runtime.executeTool(
1579
+ toolName,
1580
+ JSON.stringify({
1581
+ slug,
1582
+ sectionId,
1583
+ fieldKey,
1584
+ value,
1585
+ })
1586
+ );
1587
+ },
1588
+ {
1589
+ toolName: target.toolName,
1590
+ slug: target.slug,
1591
+ sectionId: target.sectionId,
1592
+ fieldKey: target.fieldKey,
1593
+ value: target.originalValue,
1594
+ }
1595
+ );
1596
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1597
+ } catch {
1598
+ await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1599
+ }
1600
+ };
1601
+
1602
+ try {
1603
+ const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1604
+ if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1605
+ throw new Error(`Manifest does not expose ${target.toolName}.`);
1606
+ }
1607
+
1608
+ const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
1609
+ if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1610
+ throw new Error(`Page contract does not expose ${target.toolName}.`);
1611
+ }
1612
+
1613
+ await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1614
+
1615
+ try {
1616
+ await page.waitForFunction(
1617
+ ({ manifestHref, contractHref }) => {
1618
+ const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1619
+ const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1620
+ return manifestLink?.getAttribute('href') === manifestHref
1621
+ && contractLink?.getAttribute('href') === contractHref;
1622
+ },
1623
+ { manifestHref: target.manifestHref, contractHref: target.contractHref },
1624
+ { timeout: 10_000 }
1625
+ );
1626
+ } catch (error) {
1627
+ const diagnostics = await page.evaluate(() => ({
1628
+ head: document.head.innerHTML,
1629
+ bodyText: document.body.innerText,
1630
+ }));
1631
+ throw new Error(
1632
+ [
1633
+ error instanceof Error ? error.message : String(error),
1634
+ `head=${diagnostics.head}`,
1635
+ `body=${diagnostics.bodyText}`,
1636
+ ...consoleEvents,
1637
+ ].join('\n')
1638
+ );
1639
+ }
1640
+
1641
+ const toolNames = await page.evaluate(() => {
1642
+ const runtime = document.modelContextTesting;
1643
+ return runtime?.listTools?.().map((tool) => tool.name) ?? [];
1644
+ });
1645
+ if (!toolNames.includes(target.toolName)) {
1646
+ throw new Error(`Runtime did not register ${target.toolName}. Found: ${toolNames.join(', ')}`);
1647
+ }
1648
+
1649
+ const rawResult = await page.evaluate(
1650
+ async ({ toolName, slug, sectionId, fieldKey, value }) => {
1651
+ const runtime = document.modelContextTesting;
1652
+ if (!runtime?.executeTool) {
1653
+ throw new Error('document.modelContextTesting.executeTool is unavailable.');
1654
+ }
1655
+ return runtime.executeTool(
1656
+ toolName,
1657
+ JSON.stringify({
1658
+ slug,
1659
+ sectionId,
1660
+ fieldKey,
1661
+ value,
1662
+ })
1663
+ );
1664
+ },
1665
+ {
1666
+ toolName: target.toolName,
1667
+ slug: target.slug,
1668
+ sectionId: target.sectionId,
1669
+ fieldKey: target.fieldKey,
1670
+ value: nextValue,
1671
+ }
1672
+ );
1673
+
1674
+ const parsedResult = JSON.parse(rawResult);
1675
+ if (parsedResult?.isError) {
1676
+ throw new Error(`WebMCP tool returned an error: ${rawResult}`);
1677
+ }
1678
+
1679
+ mutationApplied = true;
1680
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1681
+ await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1682
+
1683
+ console.log(
1684
+ JSON.stringify({
1685
+ ok: true,
1686
+ slug: target.slug,
1687
+ manifestHref: target.manifestHref,
1688
+ contractHref: target.contractHref,
1689
+ toolName: target.toolName,
1690
+ sectionId: target.sectionId,
1691
+ fieldKey: target.fieldKey,
1692
+ toolNames,
1693
+ })
1694
+ );
1695
+ } finally {
1696
+ if (mutationApplied) {
1697
+ await restoreOriginal();
1698
+ }
1699
+ await browser.close();
1700
+ }
1701
+ }
1702
+
1703
+ main().catch((error) => {
1704
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1705
+ process.exit(1);
1706
+ });
1707
+
726
1708
  END_OF_FILE_CONTENT
727
1709
  mkdir -p "src"
728
1710
  # SKIP: src/App.tsx is binary and cannot be embedded as text.
@@ -3421,6 +4403,74 @@ export function useCloudSave({ apiUrl, apiKey }: UseCloudSaveOptions) {
3421
4403
  return { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave };
3422
4404
  }
3423
4405
 
4406
+ END_OF_FILE_CONTENT
4407
+ echo "Creating src/lib/buildVisitorWebPageJsonLd.test.ts..."
4408
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/buildVisitorWebPageJsonLd.test.ts"
4409
+ import { describe, expect, it } from 'vitest';
4410
+ import { buildVisitorWebPageJsonLd } from './buildVisitorWebPageJsonLd';
4411
+
4412
+ describe('buildVisitorWebPageJsonLd', () => {
4413
+ it('builds Schema.org WebPage for home at /', () => {
4414
+ expect(
4415
+ buildVisitorWebPageJsonLd({
4416
+ title: 'Home',
4417
+ description: 'Welcome',
4418
+ slug: 'home',
4419
+ }),
4420
+ ).toEqual({
4421
+ '@context': 'https://schema.org',
4422
+ '@type': 'WebPage',
4423
+ name: 'Home',
4424
+ description: 'Welcome',
4425
+ url: '/',
4426
+ });
4427
+ });
4428
+
4429
+ it('builds Schema.org WebPage for nested slug paths', () => {
4430
+ expect(
4431
+ buildVisitorWebPageJsonLd({
4432
+ title: 'Dune',
4433
+ description: 'Book',
4434
+ slug: 'libri/dune',
4435
+ }),
4436
+ ).toEqual({
4437
+ '@context': 'https://schema.org',
4438
+ '@type': 'WebPage',
4439
+ name: 'Dune',
4440
+ description: 'Book',
4441
+ url: '/libri/dune',
4442
+ });
4443
+ });
4444
+ });
4445
+
4446
+ END_OF_FILE_CONTENT
4447
+ echo "Creating src/lib/buildVisitorWebPageJsonLd.ts..."
4448
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/buildVisitorWebPageJsonLd.ts"
4449
+ /**
4450
+ * Schema.org WebPage JSON-LD for Next visitor RSC (parity with tenant-alpha bake HTML injection).
4451
+ */
4452
+ export type VisitorWebPageJsonLd = {
4453
+ '@context': 'https://schema.org';
4454
+ '@type': 'WebPage';
4455
+ name: string;
4456
+ description: string;
4457
+ url: string;
4458
+ };
4459
+
4460
+ export function buildVisitorWebPageJsonLd(input: {
4461
+ title: string;
4462
+ description?: string;
4463
+ slug: string;
4464
+ }): VisitorWebPageJsonLd {
4465
+ return {
4466
+ '@context': 'https://schema.org',
4467
+ '@type': 'WebPage',
4468
+ name: input.title,
4469
+ description: input.description ?? '',
4470
+ url: input.slug === 'home' ? '/' : `/${input.slug}`,
4471
+ };
4472
+ }
4473
+
3424
4474
  END_OF_FILE_CONTENT
3425
4475
  mkdir -p "src/lib/css"
3426
4476
  echo "Creating src/lib/css/serializeThemeRootCss.test.ts..."
@@ -4465,38 +5515,26 @@ else
4465
5515
  fi
4466
5516
 
4467
5517
  # -----------------------------------------------------------------------------
4468
- # CLEAN DNA DEMO — remove marketplace seed capsules before SystemsArchitect write
4469
- # (same contract as Vite SA + Next public sync paths — leftover imports break build)
5518
+ # WIPE tenant content — no DNA name denylist (orphans break the compiler).
5519
+ # Preserve: src/components/ui (shadcn), src/components/admin (studio).
5520
+ # Wipe includes overlap dirs (e.g. header) — generators rewrite them fresh.
4470
5521
  # -----------------------------------------------------------------------------
4471
- echo "-- Cleaning demo capsules / collections / pages..."
5522
+ echo "-- Wiping tenant content surfaces (components/collections/pages/config)..."
5523
+ if [[ -d src/components ]]; then
5524
+ find src/components -mindepth 1 -maxdepth 1 ! -name 'ui' ! -name 'admin' -exec rm -rf {} +
5525
+ fi
4472
5526
  rm -rf \
4473
- src/components/books-list \
4474
- src/components/authors-list \
4475
- src/components/book-detail \
4476
- src/components/form-demo \
4477
- src/components/empty-tenant \
4478
- src/collections/autori \
4479
- src/collections/libri \
4480
- src/data/collections/autori \
4481
- src/data/collections/libri \
4482
- src/data/pages/authors \
4483
- src/data/pages/libri \
4484
- src/data/pages/work \
4485
- src/data/pages/blog \
5527
+ src/collections \
5528
+ src/data/collections \
5529
+ src/data/pages \
4486
5530
  public/pages \
4487
5531
  public/collections
4488
- rm -f \
4489
- src/data/pages/home.json \
4490
- src/data/pages/authors.json \
4491
- src/data/pages/libri.json \
4492
- src/data/pages/form.json \
4493
- src/data/pages/about.json \
4494
- src/data/pages/work.json \
4495
- src/data/pages/blog.json \
4496
- src/data/pages/contact.json \
4497
- public/config/site.json
4498
-
4499
- # Drop books-list special-case so deleted capsules cannot break the visitor RSC path.
5532
+ rm -f public/config/site.json
5533
+ if [[ -d src/data/config ]]; then
5534
+ find src/data/config -mindepth 1 -maxdepth 1 -exec rm -rf {} +
5535
+ fi
5536
+
5537
+ # Drop DNA special-cases so wiped capsules cannot break the visitor RSC path.
4500
5538
  echo "-- Resetting VisitorSection to registry-only..."
4501
5539
  mkdir -p src/lib
4502
5540
  cat > src/lib/VisitorSection.tsx << 'EOF'
@@ -7157,31 +8195,26 @@ else
7157
8195
  fi
7158
8196
 
7159
8197
  # -----------------------------------------------------------------------------
7160
- # CLEAN DNA DEMO — remove marketplace seed capsules before Inkwell write
7161
- # (same contract as generate_SystemsArchitect.sh — leftover imports break build)
8198
+ # WIPE tenant content — no DNA name denylist (orphans break the compiler).
8199
+ # Preserve: src/components/ui (shadcn), src/components/admin (studio).
8200
+ # Wipe includes overlap dirs (e.g. header) — generators rewrite them fresh.
7162
8201
  # -----------------------------------------------------------------------------
7163
- echo "-- Cleaning demo capsules / collections / pages..."
8202
+ echo "-- Wiping tenant content surfaces (components/collections/pages/config)..."
8203
+ if [[ -d src/components ]]; then
8204
+ find src/components -mindepth 1 -maxdepth 1 ! -name 'ui' ! -name 'admin' -exec rm -rf {} +
8205
+ fi
7164
8206
  rm -rf \
7165
- src/components/authors-list \
7166
- src/components/books-list \
7167
- src/components/book-detail \
7168
- src/components/form-demo \
7169
- src/collections/autori \
7170
- src/collections/libri \
7171
- src/data/collections/autori \
7172
- src/data/collections/libri \
7173
- src/data/pages/authors \
7174
- src/data/pages/libri \
8207
+ src/collections \
8208
+ src/data/collections \
8209
+ src/data/pages \
7175
8210
  public/pages \
7176
8211
  public/collections
7177
- rm -f \
7178
- src/data/pages/home.json \
7179
- src/data/pages/authors.json \
7180
- src/data/pages/libri.json \
7181
- src/data/pages/form.json \
7182
- public/config/site.json
7183
-
7184
- # Drop books-list special-case so deleted capsules cannot break the visitor RSC path.
8212
+ rm -f public/config/site.json
8213
+ if [[ -d src/data/config ]]; then
8214
+ find src/data/config -mindepth 1 -maxdepth 1 -exec rm -rf {} +
8215
+ fi
8216
+
8217
+ # Drop DNA special-cases so wiped capsules cannot break the visitor RSC path.
7185
8218
  echo "-- Resetting VisitorSection to registry-only..."
7186
8219
  mkdir -p src/lib
7187
8220
  cat > src/lib/VisitorSection.tsx << 'EOF'
@@ -7217,6 +8250,31 @@ export function VisitorSection({
7217
8250
  }
7218
8251
  EOF
7219
8252
 
8253
+ # DNA catch-all imports EmptyTenantView — drop it after wipe of that capsule.
8254
+ if [[ -f 'app/[[...slug]]/page.tsx' ]]; then
8255
+ echo "-- Patching app/[[...slug]]/page.tsx empty fallback..."
8256
+ python3 - <<'PY'
8257
+ from pathlib import Path
8258
+ p = Path("app/[[...slug]]/page.tsx")
8259
+ src = p.read_text()
8260
+ capsule = "empty" + "-tenant"
8261
+ src = src.replace(f"import {{ EmptyTenantView }} from '@/components/{capsule}';\n", "")
8262
+ old = " if (result.kind === 'empty') {\n return <EmptyTenantView />;\n }"
8263
+ new = """ if (result.kind === 'empty') {
8264
+ return (
8265
+ <main className="mx-auto max-w-3xl px-8 py-24">
8266
+ <h1 className="text-2xl font-bold">Your tenant is empty.</h1>
8267
+ <p className="mt-2 text-muted-foreground">Create your first page.</p>
8268
+ </main>
8269
+ );
8270
+ }"""
8271
+ if old not in src:
8272
+ raise SystemExit("EmptyTenantView empty-branch not found in page.tsx")
8273
+ p.write_text(src.replace(old, new))
8274
+ print(" page.tsx empty fallback inlined")
8275
+ PY
8276
+ fi
8277
+
7220
8278
  # -----------------------------------------------------------------------------
7221
8279
  # DIRECTORIES
7222
8280
  # -----------------------------------------------------------------------------