@olonjs/cli 3.0.159 → 3.0.161

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