create-ailk 0.31.0 → 0.32.0

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.
Files changed (45) hide show
  1. package/README.md +41 -5
  2. package/dist/body-heading.d.ts +13 -0
  3. package/dist/body-heading.js +19 -5
  4. package/dist/cutover.d.ts +6 -1
  5. package/dist/cutover.js +23 -6
  6. package/dist/language-import.d.ts +16 -3
  7. package/dist/language-import.js +27 -11
  8. package/dist/lib/import-mdx.js +57 -24
  9. package/dist/lib/render-comparison.d.ts +51 -0
  10. package/dist/lib/render-comparison.js +205 -6
  11. package/dist/mdx-braces.d.ts +73 -0
  12. package/dist/mdx-braces.js +285 -0
  13. package/dist/migrate-content.d.ts +1 -1
  14. package/dist/migrate-content.js +4 -2
  15. package/dist/programmatic.js +2 -8
  16. package/dist/site-manifest.js +6 -38
  17. package/dist/site-roster.d.ts +46 -3
  18. package/dist/site-roster.js +124 -4
  19. package/dist/surfaces.d.ts +8 -0
  20. package/dist/surfaces.js +16 -0
  21. package/dist/update.d.ts +21 -1
  22. package/dist/update.js +142 -11
  23. package/package.json +3 -3
  24. package/templates/.github/workflows/ci.yml +2 -0
  25. package/templates/apps/mcp/.env.example +7 -1
  26. package/templates/apps/web/__tests__/setup.ts +9 -0
  27. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  28. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  29. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  30. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  31. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  32. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  33. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  34. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  35. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  36. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  37. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  38. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  39. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  40. package/templates/apps/web/src/lib/routes.ts +5 -3
  41. package/templates/package.json +2 -1
  42. package/templates/tools/body-heading.ts +440 -0
  43. package/templates/tools/check-mdx-body.ts +123 -0
  44. package/templates/tools/mdx-braces.ts +303 -0
  45. package/versions.json +47 -47
@@ -19,7 +19,9 @@
19
19
  * - `planRosterAppend` — the next `rows`, spliced into the file's text so
20
20
  * every untouched byte stays prettier's;
21
21
  * - `siteRosterProblems` — the scaffold-grained twin of the generator's
22
- * `--check`, which cannot target a site.
22
+ * `--check`, which cannot target a site;
23
+ * - `planServedRoster` — the roster without the code-route rows no page
24
+ * file serves (#10774), which `update` writes.
23
25
  *
24
26
  * Pure module: no `import.meta.url`. Package-rooted directories are supplied
25
27
  * by the caller, the same split as `add-module.ts` / `programmatic.ts`.
@@ -46,6 +48,9 @@ export const SITE_ROSTER_PATH = ".ailk-site-manifest.json";
46
48
  export const LEGACY_SITE_ROSTER_PATH = "TEMPLATE-MANIFEST.json";
47
49
  /** Prettier's default print width — the generator formats the manifest with it. */
48
50
  const PRINT_WIDTH = 80;
51
+ /** Where the site's App Router pages live. */
52
+ const ROUTE_ROOT = join("apps", "web", "app", "[locale]");
53
+ const PAGE_FILE = /^page\.(tsx|ts|jsx|js|mdx)$/;
49
54
  // ── A string-aware JSON scanner, for the text splice ──────────────────────
50
55
  function skipWhitespace(text, i) {
51
56
  while (i < text.length && /\s/.test(text[i]))
@@ -120,7 +125,7 @@ function arrayElements(text, open) {
120
125
  * spans are cross-checked against `JSON.parse`, so a splice can only ever
121
126
  * move bytes the parser agrees are those rows.
122
127
  */
123
- function parseRosterFile(text, label) {
128
+ export function parseRosterFile(text, label) {
124
129
  const refuse = (why) => new AddModuleRefusedError(`${label} ${why}, so the page roster is unknown`);
125
130
  let doc;
126
131
  try {
@@ -538,6 +543,7 @@ export function planRosterAppend(opts) {
538
543
  const refs = opts.references.filter((ref) => inSet.has(ref.module));
539
544
  const contributed = new Set(refs.map((ref) => ref.contribution.route));
540
545
  const shipped = readShippedManifest(opts.siteTemplatesDir, roster.template.name);
546
+ const unserved = unservedRouteRow(targetDir);
541
547
  const templateRows = [];
542
548
  const moduleRows = new Map();
543
549
  const superseded = [];
@@ -567,7 +573,12 @@ export function planRosterAppend(opts) {
567
573
  next.push(...kept);
568
574
  }
569
575
  else {
570
- next.push(expectedRow(ref, targetDir));
576
+ const row = expectedRow(ref, targetDir);
577
+ // #10774 D4.1 — a contribution whose page file the site lacks (a
578
+ // spoke's `/appointments`) is not the site's to declare.
579
+ if (unserved?.(row.value) === true)
580
+ continue;
581
+ next.push(row);
571
582
  appended.push(route);
572
583
  }
573
584
  }
@@ -616,6 +627,104 @@ function spliceRows(file, rows) {
616
627
  : `${lead}${rows.map((r) => r.text).join(`,${lead}`)}${trail}`;
617
628
  return text.slice(0, open + 1) + body + text.slice(close);
618
629
  }
630
+ // ── #10774 D4: a code-route row names a route the site serves ─────────────
631
+ /**
632
+ * Every route the site's App Router serves from a page file, sorted: route
633
+ * groups `(x)` dropped, a trailing optional catch-all `[[...x]]` folded into
634
+ * its parent (docs-blog's `/blog/[[...slug]]` serves `/blog`). The root
635
+ * content renderers (`/[slug]`, `/[slug]/[detail]`) are not routes of their
636
+ * own — they are how the content rows render — so they are left out.
637
+ */
638
+ export function servedRoutes(targetDir) {
639
+ const root = join(targetDir, ROUTE_ROOT);
640
+ const routes = new Set();
641
+ const stack = existsSync(root) ? [root] : [];
642
+ for (let cur = stack.pop(); cur !== undefined; cur = stack.pop()) {
643
+ const entries = readdirSync(cur, { withFileTypes: true });
644
+ for (const entry of entries) {
645
+ if (entry.isDirectory())
646
+ stack.push(join(cur, entry.name));
647
+ }
648
+ if (!entries.some((entry) => entry.isFile() && PAGE_FILE.test(entry.name))) {
649
+ continue;
650
+ }
651
+ const segments = relative(root, cur)
652
+ .split(sep)
653
+ .filter((segment) => segment !== "" && !/^\(.+\)$/.test(segment));
654
+ const last = segments[segments.length - 1];
655
+ if (last !== undefined && /^\[\[\.\.\..+\]\]$/.test(last))
656
+ segments.pop();
657
+ if (segments[0]?.startsWith("["))
658
+ continue;
659
+ routes.add(`/${segments.join("/")}`);
660
+ }
661
+ return [...routes].sort();
662
+ }
663
+ /**
664
+ * Whether a row is a code-route row no page file of the site serves — or
665
+ * `undefined` when the site has no route tree (a roster-only fixture, a web
666
+ * app elsewhere), where no row is judged. A route whose first segment is
667
+ * dynamic is never judged either: the root renderers are not in
668
+ * `servedRoutes`.
669
+ */
670
+ function unservedRouteRow(targetDir) {
671
+ if (!existsSync(join(targetDir, ROUTE_ROOT)))
672
+ return undefined;
673
+ const served = new Set(servedRoutes(targetDir));
674
+ return (row) => {
675
+ const route = row["route"];
676
+ return (row["kind"] === "route" &&
677
+ typeof route === "string" &&
678
+ !route.split("/").filter(Boolean)[0]?.startsWith("[") &&
679
+ !served.has(route));
680
+ };
681
+ }
682
+ /**
683
+ * `file`'s next text with its unserved code-route rows removed — every other
684
+ * byte verbatim — and the dropped routes in file order; `next` is undefined
685
+ * when none is dropped.
686
+ */
687
+ export function withoutUnservedRouteRows(file, targetDir) {
688
+ const unserved = unservedRouteRow(targetDir);
689
+ const dropped = file.rows.filter((row) => unserved?.(row.value) === true);
690
+ if (dropped.length === 0)
691
+ return { next: undefined, dropped: [] };
692
+ return {
693
+ next: spliceRows(file, file.rows.filter((row) => !dropped.includes(row))),
694
+ dropped: dropped.map((row) => row.value["route"]),
695
+ };
696
+ }
697
+ /** The site's roster file, read without resolving its template, or undefined when it has none. */
698
+ function readRosterFile(targetDir) {
699
+ const path = [SITE_ROSTER_PATH, LEGACY_SITE_ROSTER_PATH].find((name) => existsSync(join(targetDir, name)));
700
+ if (path === undefined)
701
+ return undefined;
702
+ return { ...parseRosterFile(readFileSync(join(targetDir, path), "utf8"), path), path };
703
+ }
704
+ /**
705
+ * The routes of the site roster's page rows — the rows that carry content —
706
+ * or undefined when it has none. A code-route row is left out: it records a
707
+ * route already on disk (signal-collectors' `/careers/apply` walk under the
708
+ * unrelated careers segment), never a surface the site serves. The template
709
+ * is not resolved, so a retired template name reads as any other.
710
+ * @throws AddModuleRefusedError when the file's `rows` cannot be located.
711
+ */
712
+ export function readRosterPageRoutes(targetDir) {
713
+ return readRosterFile(targetDir)
714
+ ?.rows.filter((row) => row.value["kind"] === "page")
715
+ .map((row) => row.value["route"]);
716
+ }
717
+ /**
718
+ * The site roster with its unserved code-route rows removed, or undefined
719
+ * when the site has no roster.
720
+ * @throws AddModuleRefusedError when the file's `rows` cannot be located.
721
+ */
722
+ export function planServedRoster(targetDir) {
723
+ const file = readRosterFile(targetDir);
724
+ if (file === undefined)
725
+ return undefined;
726
+ return { path: file.path, ...withoutUnservedRouteRows(file, targetDir) };
727
+ }
619
728
  /** Every `content/en/**.mdx` some layer installs into a site of this template and module set. */
620
729
  function layerInstalledSeeds(template, modules, opts, surfaces) {
621
730
  const out = new Set();
@@ -670,7 +779,9 @@ function layerInstalledSeeds(template, modules, opts, surfaces) {
670
779
  * the shipped reference row;
671
780
  * 3. an installed seed — one some layer lands, present in the site — that
672
781
  * the effective roster does not declare. A page the customer wrote is
673
- * installed by no layer and is never reported.
782
+ * installed by no layer and is never reported;
783
+ * 4. a code-route row no page file serves (#10774). A module contribution
784
+ * whose page file the site lacks is not expected under 2.
674
785
  *
675
786
  * A site with no roster (a template-less scaffold) has no problems.
676
787
  */
@@ -695,6 +806,13 @@ export function siteRosterProblems(opts) {
695
806
  problems.push(`${route}: appears on more than one row`);
696
807
  seen.add(route);
697
808
  }
809
+ // #10774 D4.4 — a code-route row no page file serves.
810
+ const unserved = unservedRouteRow(opts.targetDir);
811
+ for (const row of roster.rows) {
812
+ if (unserved?.(row.value) === true) {
813
+ problems.push(`${row.value["route"]}: a route row, but no page file serves it`);
814
+ }
815
+ }
698
816
  const contributions = new Map();
699
817
  // #10040 D3 — a site that dropped `marketing` carries no public route tree,
700
818
  // so no module's public pages are expected on its roster either.
@@ -711,6 +829,8 @@ export function siteRosterProblems(opts) {
711
829
  const route = ref.contribution.route;
712
830
  contributions.set(route, ref.contribution);
713
831
  const wanted = expectedRow(ref, opts.targetDir).value;
832
+ if (unserved?.(wanted) === true)
833
+ continue;
714
834
  const rows = roster.rows.filter((r) => r.value["route"] === route);
715
835
  if (rows.length === 0) {
716
836
  problems.push(`${route}: no row, but module "${moduleName}" contributes it`);
@@ -136,6 +136,14 @@ export declare function resolveSurfaces(modules?: string[], explicit?: string[])
136
136
  * on Windows too.
137
137
  */
138
138
  export declare function excludedRoutePaths(enabled: string[]): string[];
139
+ /**
140
+ * The module-less gateable surfaces a roster's `routes` name (#5469 D10,
141
+ * #10774 D2), in `GATEABLE_SURFACES` order. A route names a surface when its
142
+ * first path segment is one of the surface's top-level route directories
143
+ * (`/pricing` → `apps/web/app/[locale]/pricing`). A module-owned surface is
144
+ * never named: it comes only with its module (#5365 FR5).
145
+ */
146
+ export declare function surfacesNamedByRoutes(routes: readonly string[]): string[];
139
147
  /**
140
148
  * The content directories belonging to gateable surfaces that are NOT enabled,
141
149
  * with `<locale>` left in place for the caller to expand.
package/dist/surfaces.js CHANGED
@@ -540,6 +540,22 @@ export function excludedRoutePaths(enabled) {
540
540
  }
541
541
  return out.sort();
542
542
  }
543
+ /**
544
+ * The module-less gateable surfaces a roster's `routes` name (#5469 D10,
545
+ * #10774 D2), in `GATEABLE_SURFACES` order. A route names a surface when its
546
+ * first path segment is one of the surface's top-level route directories
547
+ * (`/pricing` → `apps/web/app/[locale]/pricing`). A module-owned surface is
548
+ * never named: it comes only with its module (#5365 FR5).
549
+ */
550
+ export function surfacesNamedByRoutes(routes) {
551
+ const routeRoot = "apps/web/app/[locale]";
552
+ const moduleOwned = new Set(Object.values(MODULE_SURFACES).flat());
553
+ const topSegments = new Set(routes
554
+ .map((route) => route.split("/").filter(Boolean)[0])
555
+ .filter((segment) => segment !== undefined));
556
+ return GATEABLE_SURFACES.filter((surface) => !moduleOwned.has(surface.id) &&
557
+ surface.routes.some((dir) => topSegments.has(dir.slice(routeRoot.length + 1)))).map((surface) => surface.id);
558
+ }
543
559
  /**
544
560
  * The content directories belonging to gateable surfaces that are NOT enabled,
545
561
  * with `<locale>` left in place for the caller to expand.
package/dist/update.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type MdxParse } from "./mdx-braces.js";
1
2
  import type { WorkspaceVersionMap } from "./substitute.js";
2
3
  /** A refusal the CLI exits 2 on, thrown before anything is written. */
3
4
  export declare class UpdateRefusedError extends Error {
@@ -23,6 +24,11 @@ export interface UpdateSiteOptions {
23
24
  * at. A site already on both boot sets, or a spoke, never reads it.
24
25
  */
25
26
  versions?: WorkspaceVersionMap;
27
+ /**
28
+ * The MDX parser the brace pass verifies an escape with (#10773 D5);
29
+ * `null` acts as "not installed". Absent: `loadSiteMdxParse(targetDir)`.
30
+ */
31
+ mdxParse?: MdxParse | null;
26
32
  }
27
33
  export interface UpdateResult {
28
34
  targetDir: string;
@@ -58,8 +64,14 @@ export interface UpdateResult {
58
64
  envRowsAdded?: string[];
59
65
  /** Record rows dropped because the path is no longer recordable (#10173). */
60
66
  droppedRows?: string[];
67
+ /** The module-less surfaces the roster names that `architecture.yaml` now declares (#10774 D3). */
68
+ surfacesDeclared?: string[];
69
+ /** The roster's code-route rows dropped because no page file serves them, in roster order (#10774 D4). */
70
+ rosterRowsDropped?: string[];
61
71
  /** The mdx-body pages under `content/` given a `# ` heading this run (#7495). */
62
72
  headingsAdded: string[];
73
+ /** The mdx-body pages under `content/` whose bare `{` were escaped this run (#10773). */
74
+ bracesEscaped: string[];
63
75
  backfilled?: {
64
76
  version: string;
65
77
  files: number;
@@ -83,12 +95,20 @@ export declare function mergeMessagesAdditively(ours: string, theirs: string): {
83
95
  reason: string;
84
96
  };
85
97
  export declare function updateSite(opts: UpdateSiteOptions): Promise<UpdateResult>;
98
+ /**
99
+ * The site's own MDX parser (#10773 D5): `@mdx-js/mdx` resolved through the
100
+ * `next-mdx-remote` its `apps/web` renders with — never create-ailk's own
101
+ * copy, and never fetched. `undefined` when it is not installed or fails to
102
+ * load; never throws.
103
+ */
104
+ export declare function loadSiteMdxParse(targetDir: string): Promise<MdxParse | undefined>;
105
+ export type { MdxParse } from "./mdx-braces.js";
86
106
  /** The printed report (D9): one section per non-empty list, in order. */
87
107
  export declare function formatUpdateReport(result: UpdateResult): string;
88
108
  export interface UpdateArgs {
89
109
  target: string | undefined;
90
110
  help: boolean;
91
111
  }
92
- export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Commit before running, and review the\ndiff after.\n";
112
+ export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA surface the site's .ailk-site-manifest.json names a page on but\narchitecture.yaml does not declare (one no module owns, such as projects)\ngets its route files added and is declared, listed under \"Surfaces declared\nfrom the roster\". A code-route row of that roster no page file serves is\ndropped, listed under \"Roster rows dropped, no route serves them\".\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Each bare { on an mdx-body page is\nescaped as \\{ when the site's own MDX parser then reads the page clean, listed\nunder \"Placeholder brace escaped\"; a page it cannot escape safely, or any page\nwith one while the site's dependencies are not installed, is listed under\n\"Needs review\". Commit before running, and review the diff after.\n";
93
113
  /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
94
114
  export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
package/dist/update.js CHANGED
@@ -36,29 +36,35 @@
36
36
  * before the first write. Last, every mdx-body page in the site's `content/`
37
37
  * with no `# ` heading gets its title and description written into its body
38
38
  * (#7495, `body-heading.ts`); a page with one or more headings is never
39
- * touched, and `content/` is never recorded.
39
+ * touched, and `content/` is never recorded. Then every bare `{` on an
40
+ * mdx-body page is escaped as `\{`, kept only when the site's own MDX parser
41
+ * reads the page clean (#10773, `mdx-braces.ts`).
40
42
  *
41
43
  * Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
42
44
  * `adopt.ts` / `add-route.ts` split.
43
45
  */
44
46
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
47
+ import { createRequire } from "node:module";
45
48
  import { dirname, join, relative, resolve, sep } from "node:path";
49
+ import { pathToFileURL } from "node:url";
46
50
  import { parseArgs } from "node:util";
47
51
  import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
48
- import { headingViolations, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
52
+ import { headingViolations, mdxBodyPages, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
49
53
  import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
54
+ import { deriveArchitecture } from "./derive-architecture.js";
50
55
  import { EnvContractError, planEnvRows, readEnvContract, readManifestTopology, } from "./lib/declared-env.js";
51
56
  import { ENV_CATALOG } from "./lib/env-catalog.js";
52
57
  import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
53
58
  import { exportsName, listFilesUnder, namedImportsOf } from "./lib/import-graph.js";
54
59
  import { mergeThreeWay } from "./lib/merge-three-way.js";
55
60
  import { publicTreeRemoved } from "./lib/site-modules.js";
61
+ import { escapeBareBraces, findBareBraces } from "./mdx-braces.js";
56
62
  import { MODULE_SLICES } from "./module-architecture.js";
57
63
  import { MODULE_FLAGS_PATH, PAID_MCP_TOOLS_PATH, writeModuleFlags, writePaidMcpTools, } from "./paid-sections.js";
58
64
  import { ParseError } from "./parse-args.js";
59
- import { SITE_ROSTER_PATH } from "./site-roster.js";
65
+ import { SITE_ROSTER_PATH, planServedRoster, readRosterPageRoutes } from "./site-roster.js";
60
66
  import { SPOKE_CHECKOUT_TEMPLATE_PATHS } from "./spoke-checkout.js";
61
- import { excludedRoutePaths, isRemovedPublicRoutePath } from "./surfaces.js";
67
+ import { GATEABLE_SURFACES, excludedRoutePaths, isRemovedPublicRoutePath, resolveSurfaces, surfacesNamedByRoutes, } from "./surfaces.js";
62
68
  import { DATABASE_BOOT_FILES, ROOT_TOOL_FILES, TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
63
69
  import { MCP_SEAM_PATH, MCP_SERVER_HUB, TopologyError, isSpokeExcluded, readSpokeShape, spokeExclusions, } from "./topology.js";
64
70
  /** A refusal the CLI exits 2 on, thrown before anything is written. */
@@ -930,6 +936,35 @@ export async function updateSite(opts) {
930
936
  throw err;
931
937
  }
932
938
  const excludedRoutes = excludedRoutePaths(header.surfaces);
939
+ // #10774 D3 — a module-less surface the roster's page rows name but the header omits
940
+ // (the /projects pair a 0.19.0 scaffold rostered without its route) is in
941
+ // scope for the add loop only, and declared after the writes (even when an
942
+ // add is held back, #10173: the next run adds it). The
943
+ // roster is read even when it brings nothing, so an unreadable one is
944
+ // refused here, before the first write, not at the drop after it.
945
+ let rosterRoutes;
946
+ try {
947
+ rosterRoutes = readRosterPageRoutes(targetDir) ?? [];
948
+ }
949
+ catch (err) {
950
+ if (err instanceof AddModuleRefusedError)
951
+ throw new UpdateRefusedError(err.message);
952
+ throw err;
953
+ }
954
+ const declaredByRoster = publicTreeRemoved(targetDir)
955
+ ? []
956
+ : surfacesNamedByRoutes(rosterRoutes).filter((surface) => !header.surfaces.includes(surface));
957
+ const addExcludedRoutes = excludedRoutePaths([...header.surfaces, ...declaredByRoster]);
958
+ const declaredRouteDirs = GATEABLE_SURFACES.filter((surface) => declaredByRoster.includes(surface.id)).flatMap((surface) => surface.routes);
959
+ let declaredArchitecture;
960
+ if (declaredByRoster.length > 0) {
961
+ try {
962
+ declaredArchitecture = deriveArchitecture(header.modules, resolveSurfaces(header.modules, [...header.surfaces, ...declaredByRoster]), spoke !== undefined ? { topology: "spoke", mcp: existsSync(join(targetDir, "apps", "mcp")) } : {});
963
+ }
964
+ catch (err) {
965
+ throw new UpdateRefusedError(`architecture.yaml's \`# Surfaces:\` header cannot declare ${declaredByRoster.join(", ")}: ${err.message}`);
966
+ }
967
+ }
933
968
  const spokeExclusionList = spoke !== undefined ? spokeExclusions(opts.templatesDir, spoke) : undefined;
934
969
  // #8748 D3 — the standard spoke checkout is a commerce spoke's alone.
935
970
  const sellsAsSpoke = spoke !== undefined && header.modules.includes("commerce");
@@ -937,10 +972,10 @@ export async function updateSite(opts) {
937
972
  // never touched: its boot paths are out of scope, reported under the guard.
938
973
  const databaseIsPackage = existsSync(join(targetDir, "database", "package.json"));
939
974
  const bootFileGuarded = (destRel) => databaseIsPackage && DATABASE_BOOT_FILES.includes(destRel);
940
- const outOfScope = (destRel) => {
975
+ const outOfScope = (destRel, excluded = excludedRoutes) => {
941
976
  if (bootFileGuarded(destRel))
942
977
  return true;
943
- if (isExcluded(destRel, excludedRoutes))
978
+ if (isExcluded(destRel, excluded))
944
979
  return true;
945
980
  if (!sellsAsSpoke && isExcluded(destRel, SPOKE_CHECKOUT_TEMPLATE_PATHS))
946
981
  return true;
@@ -1232,9 +1267,11 @@ export async function updateSite(opts) {
1232
1267
  }
1233
1268
  continue;
1234
1269
  }
1235
- if (outOfScope(path))
1270
+ if (outOfScope(path, addExcludedRoutes))
1236
1271
  continue; // rule i/ii
1237
- const highest = highestMissingAncestor(path, targetDir);
1272
+ // #10774 D3 — a surface this run declares was never copied, so its
1273
+ // missing directory is no owner's deletion: rule iii does not apply.
1274
+ const highest = isExcluded(path, declaredRouteDirs) ? undefined : highestMissingAncestor(path, targetDir);
1238
1275
  if (highest !== undefined && containsKnownPath(highest, knownPaths))
1239
1276
  continue; // rule iii
1240
1277
  writes.push({ path, bytes: Buffer.from(newBytes) });
@@ -1484,6 +1521,15 @@ export async function updateSite(opts) {
1484
1521
  if (recordChanged) {
1485
1522
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
1486
1523
  }
1524
+ // #10774 D3 — the surfaces the roster named are declared, as add-route
1525
+ // declares one: the whole generated file, re-derived.
1526
+ if (declaredArchitecture !== undefined)
1527
+ writeFileSync(archPath, declaredArchitecture);
1528
+ // #10774 D4.3 — the roster's code-route rows no page file serves, read
1529
+ // after the writes so a route this run added counts as served.
1530
+ const servedRoster = planServedRoster(targetDir);
1531
+ if (servedRoster?.next !== undefined)
1532
+ writeFileSync(join(targetDir, servedRoster.path), servedRoster.next);
1487
1533
  // #8843 D10 — the module-presence flags are generated, never copied
1488
1534
  // (REGENERATED_APP_PATHS): render them from this site's own
1489
1535
  // apps/web/package.json on every run, so a site updated onto a layout
@@ -1570,6 +1616,34 @@ export async function updateSite(opts) {
1570
1616
  for (const { path, reason } of headings.refused) {
1571
1617
  review.push({ path: sitePath(path), reason: `no '# ' heading added: ${reason}` });
1572
1618
  }
1619
+ // #10773 D6 — a bare `{` on an mdx-body page is MDX code: the page fails or
1620
+ // drops it. Escape every one, after the heading pass so its writes are read
1621
+ // too, keeping a page's write only when the site's own parser reads it clean.
1622
+ const parse = opts.mdxParse === undefined ? await loadSiteMdxParse(targetDir) : opts.mdxParse ?? undefined;
1623
+ const bracesEscaped = [];
1624
+ for (const { path, raw, bodyStart } of mdxBodyPages(contentDir, rule)) {
1625
+ // Blank lines in place of the frontmatter, so every line named is the file's.
1626
+ const pad = "\n".repeat(raw.slice(0, bodyStart).split("\n").length - 1);
1627
+ const body = pad + raw.slice(bodyStart);
1628
+ if (parse === undefined) {
1629
+ const lines = findBareBraces(body).map((brace) => brace.line);
1630
+ if (lines.length === 0)
1631
+ continue;
1632
+ review.push({
1633
+ path: sitePath(path),
1634
+ reason: `${lines.length} bare {…} at line ${[...new Set(lines)].join(", ")}; not escaped: the site's MDX parser is not installed — run pnpm install, then update again`,
1635
+ });
1636
+ continue;
1637
+ }
1638
+ const escape = escapeBareBraces(body, parse);
1639
+ if (escape.kind === "escaped") {
1640
+ writeFileSync(path, raw.slice(0, bodyStart) + escape.text.slice(pad.length));
1641
+ bracesEscaped.push(sitePath(path));
1642
+ }
1643
+ else if (escape.kind === "refused") {
1644
+ review.push({ path: sitePath(path), reason: `bare {…} not escaped: ${escape.reason}` });
1645
+ }
1646
+ }
1573
1647
  return {
1574
1648
  targetDir,
1575
1649
  version,
@@ -1578,11 +1652,48 @@ export async function updateSite(opts) {
1578
1652
  skipped,
1579
1653
  heldBack,
1580
1654
  headingsAdded: headings.written.map(sitePath),
1655
+ bracesEscaped,
1581
1656
  ...(backfilled !== undefined ? { backfilled } : {}),
1582
1657
  ...(envRowsAdded.length > 0 ? { envRowsAdded } : {}),
1583
1658
  ...(droppedRows.length > 0 ? { droppedRows: droppedRows.sort() } : {}),
1659
+ ...(declaredByRoster.length > 0 ? { surfacesDeclared: declaredByRoster } : {}),
1660
+ ...(servedRoster !== undefined && servedRoster.dropped.length > 0
1661
+ ? { rosterRowsDropped: servedRoster.dropped }
1662
+ : {}),
1584
1663
  };
1585
1664
  }
1665
+ /** Whether a `node_modules` at or above `dir` holds `pkg` — so `resolve` never falls back to NODE_PATH. */
1666
+ function installedAbove(dir, pkg) {
1667
+ for (let at = dir;; at = dirname(at)) {
1668
+ if (existsSync(join(at, "node_modules", pkg, "package.json")))
1669
+ return true;
1670
+ if (dirname(at) === at)
1671
+ return false;
1672
+ }
1673
+ }
1674
+ /**
1675
+ * The site's own MDX parser (#10773 D5): `@mdx-js/mdx` resolved through the
1676
+ * `next-mdx-remote` its `apps/web` renders with — never create-ailk's own
1677
+ * copy, and never fetched. `undefined` when it is not installed or fails to
1678
+ * load; never throws.
1679
+ */
1680
+ export async function loadSiteMdxParse(targetDir) {
1681
+ try {
1682
+ const web = join(resolve(targetDir), "apps", "web");
1683
+ if (!installedAbove(web, "next-mdx-remote"))
1684
+ return undefined;
1685
+ const remote = createRequire(join(web, "package.json")).resolve("next-mdx-remote/package.json");
1686
+ const entry = createRequire(remote).resolve("@mdx-js/mdx");
1687
+ const mdx = (await import(pathToFileURL(entry).href));
1688
+ const createProcessor = mdx.createProcessor;
1689
+ if (typeof createProcessor !== "function")
1690
+ return undefined;
1691
+ return (body) => createProcessor().parse(body);
1692
+ }
1693
+ catch {
1694
+ return undefined;
1695
+ }
1696
+ }
1586
1697
  /** The printed report (D9): one section per non-empty list, in order. */
1587
1698
  export function formatUpdateReport(result) {
1588
1699
  const lines = [`[create-ailk] update → ${result.targetDir}`];
@@ -1596,6 +1707,7 @@ export function formatUpdateReport(result) {
1596
1707
  ["Removed", result.removed],
1597
1708
  ["Already current", result.current],
1598
1709
  ["Page heading added", result.headingsAdded],
1710
+ ["Placeholder brace escaped", result.bracesEscaped],
1599
1711
  ];
1600
1712
  for (const [label, paths] of sections) {
1601
1713
  if (paths.length === 0)
@@ -1620,6 +1732,14 @@ export function formatUpdateReport(result) {
1620
1732
  lines.push(`Dropped from ${TEMPLATE_RECORD_PATH}, no longer recorded (${result.droppedRows.length}):`);
1621
1733
  lines.push(...result.droppedRows.map((path) => ` ${path}`));
1622
1734
  }
1735
+ if (result.surfacesDeclared !== undefined) {
1736
+ lines.push(`Surfaces declared from the roster (${result.surfacesDeclared.length}):`);
1737
+ lines.push(...result.surfacesDeclared.map((surface) => ` ${surface}`));
1738
+ }
1739
+ if (result.rosterRowsDropped !== undefined) {
1740
+ lines.push(`Roster rows dropped, no route serves them (${result.rosterRowsDropped.length}):`);
1741
+ lines.push(...result.rosterRowsDropped.map((route) => ` ${route}`));
1742
+ }
1623
1743
  if (result.skipped.length > 0) {
1624
1744
  lines.push(`Skipped (${result.skipped.length}):`);
1625
1745
  lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
@@ -1631,10 +1751,13 @@ export function formatUpdateReport(result) {
1631
1751
  result.removed.length === 0 &&
1632
1752
  result.current.length === 0 &&
1633
1753
  result.headingsAdded.length === 0 &&
1754
+ result.bracesEscaped.length === 0 &&
1634
1755
  result.review.length === 0 &&
1635
1756
  result.skipped.length === 0 &&
1636
1757
  result.envRowsAdded === undefined &&
1637
- result.droppedRows === undefined;
1758
+ result.droppedRows === undefined &&
1759
+ result.surfacesDeclared === undefined &&
1760
+ result.rosterRowsDropped === undefined;
1638
1761
  if (nothingHappened) {
1639
1762
  return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
1640
1763
  }
@@ -1677,6 +1800,11 @@ same way, under "Held back with <file> (not in this site)". Every other file
1677
1800
  in the run still updates. When project.yaml has an env_contract block, each
1678
1801
  env var the written files read that has no row gains one; no row is edited.
1679
1802
  A record row at a path create-ailk no longer records is dropped and listed.
1803
+ A surface the site's .ailk-site-manifest.json names a page on but
1804
+ architecture.yaml does not declare (one no module owns, such as projects)
1805
+ gets its route files added and is declared, listed under "Surfaces declared
1806
+ from the roster". A code-route row of that roster no page file serves is
1807
+ dropped, listed under "Roster rows dropped, no route serves them".
1680
1808
  A site with no record gets one on its first run, from the version its
1681
1809
  .ailk-site-manifest.json already records. A site scaffolded before
1682
1810
  @working-theory/mcp-server has its copied apps/mcp moved to the boot set:
@@ -1691,8 +1819,11 @@ app's package.json gained dependencies. Each mdx-body page in content/ with no
1691
1819
  '# ' heading gets its title and description written into its body as its
1692
1820
  heading and intro, listed under "Page heading added"; a page with one heading
1693
1821
  is left as it is, and one with several, or one whose frontmatter it cannot
1694
- read, is listed under "Needs review". Commit before running, and review the
1695
- diff after.
1822
+ read, is listed under "Needs review". Each bare { on an mdx-body page is
1823
+ escaped as \\{ when the site's own MDX parser then reads the page clean, listed
1824
+ under "Placeholder brace escaped"; a page it cannot escape safely, or any page
1825
+ with one while the site's dependencies are not installed, is listed under
1826
+ "Needs review". Commit before running, and review the diff after.
1696
1827
  `;
1697
1828
  /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
1698
1829
  export function parseUpdateArgs(argv) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.54.0",
32
- "@working-theory/templates": "0.54.0"
31
+ "@working-theory/schema": "0.55.0",
32
+ "@working-theory/templates": "0.55.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -69,6 +69,8 @@ jobs:
69
69
  - run: pnpm claude-md:check
70
70
  # A new page file on a site whose pages come from the database (#10660).
71
71
  - run: pnpm content-source:check
72
+ # A bare { on an mdx-body page, which fails the page or drops it (#10773).
73
+ - run: pnpm mdx-body:check
72
74
 
73
75
  - run: pnpm test
74
76
 
@@ -91,9 +91,15 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
91
91
  # (the Artifact store — the server resolves its own single workspace at
92
92
  # bootstrap; a deployment that isn't unambiguously one-workspace serves no
93
93
  # content rather than guessing).
94
+ # On a SPOKE (#10658), CONTENT_ADAPTER=database reads this spoke's own
95
+ # published pages through the hub at AILK_API_URL under AILK_SPOKE_KEY (below),
96
+ # with no DATABASE_URL — set it once the spoke's page files are removed. A
97
+ # spoke whose key will not be sent (unset, not https or loopback, or no
98
+ # AILK_API_URL) serves no content; the boot writes one stderr line saying
99
+ # which way it went.
94
100
  # CONTENT_ADAPTER=mdx
95
101
 
96
- # Required ONLY when CONTENT_ADAPTER=database.
102
+ # Required ONLY on a hub's CONTENT_ADAPTER=database — never on a spoke.
97
103
  # DATABASE_URL=
98
104
 
99
105
  # ── Spoke key ─────────────────────────────────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import { TextDecoder, TextEncoder } from "node:util";
2
+ import { deserialize, serialize } from "node:v8";
2
3
 
3
4
  import "@testing-library/jest-dom";
4
5
 
@@ -17,6 +18,14 @@ if (typeof globalThis.TextEncoder === "undefined") {
17
18
  (globalThis as unknown as { TextDecoder: unknown }).TextDecoder = TextDecoder;
18
19
  }
19
20
 
21
+ // #10704 D3 — nor `structuredClone`, which Node exposes only as a global. The
22
+ // database render path clones a page's words with it (`@working-theory/
23
+ // validation`'s `words-codec.ts`); the v8 round trip clones the same plain data.
24
+ if (typeof globalThis.structuredClone === "undefined") {
25
+ globalThis.structuredClone = (<T>(value: T): T =>
26
+ deserialize(serialize(value)) as T) as typeof structuredClone;
27
+ }
28
+
20
29
  // SOFT_LOCK_OK: #6510 (D1) — jsdom does not implement ResizeObserver.
21
30
  // BannerConsentIsland (the layout's always-mounted consent Banner,
22
31
  // placement="bottom") now constructs one when reserveSpace is set — the
@@ -105,6 +105,9 @@ import { notFound } from "next/navigation";
105
105
  import { listingExtension } from "../../../../../lib/listings";
106
106
  import DetailPage, { generateMetadata } from "../page";
107
107
 
108
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
109
+ process.env.CONTENT_ADAPTER = "mdx";
110
+
108
111
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
109
112
 
110
113
  function makeSource(slug: string) {
@@ -45,6 +45,9 @@ jest.mock("@working-theory/ui", () => ({
45
45
  import { notFound } from "next/navigation";
46
46
  import SlugPage from "../page";
47
47
 
48
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
49
+ process.env.CONTENT_ADAPTER = "mdx";
50
+
48
51
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
49
52
 
50
53
  function makeSource(slug: string, schema?: Record<string, unknown>) {