create-ailk 0.30.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 (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
@@ -38,13 +38,10 @@ import { join, relative, sep } from "node:path";
38
38
  import { isDeepStrictEqual } from "node:util";
39
39
  import { AddModuleRefusedError } from "./add-module-core.js";
40
40
  import { MODULE_SLICES, normalizeModules } from "./module-architecture.js";
41
- import { SITE_ROSTER_PATH, moduleRosterRows, parseSiteRoster, planRosterAppend, resolveRosterTemplate, } from "./site-roster.js";
41
+ import { SITE_ROSTER_PATH, moduleRosterRows, parseRosterFile, parseSiteRoster, planRosterAppend, resolveRosterTemplate, servedRoutes, withoutUnservedRouteRows, } from "./site-roster.js";
42
42
  import { TEMPLATE_MANIFESTS, rosterDeclares, templateTreeDir, } from "./template-manifest.js";
43
43
  /** Prettier's default print width — the generator formats the manifest with it. */
44
44
  const PRINT_WIDTH = 80;
45
- /** Where the site's App Router pages live. */
46
- const ROUTE_ROOT = join("apps", "web", "app", "[locale]");
47
- const PAGE_FILE = /^page\.(tsx|ts|jsx|js|mdx)$/;
48
45
  /**
49
46
  * The generator's row for a content-less route (`build-template-manifest.mjs`:
50
47
  * every row renders under the locale layout's MarketingShell, chrome on).
@@ -89,7 +86,10 @@ export function planSiteManifest(opts) {
89
86
  templatesDir: opts.templatesDir,
90
87
  markers: opts.markers,
91
88
  });
92
- const appended = plan.next ?? roster.text;
89
+ // #10774 D4.2 — the template's own code-route rows a spoke does not serve
90
+ // (`/sign-in`, `/sign-up`) leave with the files the spoke filter dropped.
91
+ const planned = plan.next ?? roster.text;
92
+ const appended = withoutUnservedRouteRows(parseRosterFile(planned, roster.path), opts.targetDir).next ?? planned;
93
93
  const declared = new Set(JSON.parse(appended).rows.map((row) => row["route"]));
94
94
  const extra = [
95
95
  ...sampleRows(opts, declared),
@@ -226,42 +226,10 @@ function sampleRows(opts, declared) {
226
226
  }
227
227
  /** A code-route row for every page route on disk that `declared` does not name. */
228
228
  function codeRouteRows(targetDir, declared) {
229
- return routesOnDisk(targetDir)
229
+ return servedRoutes(targetDir)
230
230
  .filter((route) => !declared.has(route))
231
231
  .map((route) => ({ route, ...CODE_ROUTE_ROW }));
232
232
  }
233
- /**
234
- * Every route the site's App Router serves from a page file, sorted: route
235
- * groups `(x)` dropped, a trailing optional catch-all `[[...x]]` folded into
236
- * its parent (docs-blog's `/blog/[[...slug]]` serves `/blog`). The root
237
- * content renderers (`/[slug]`, `/[slug]/[detail]`) are not routes of their
238
- * own — they are how the content rows render — so they are left out.
239
- */
240
- function routesOnDisk(targetDir) {
241
- const root = join(targetDir, ROUTE_ROOT);
242
- const routes = new Set();
243
- const stack = existsSync(root) ? [root] : [];
244
- for (let cur = stack.pop(); cur !== undefined; cur = stack.pop()) {
245
- const entries = readdirSync(cur, { withFileTypes: true });
246
- for (const entry of entries) {
247
- if (entry.isDirectory())
248
- stack.push(join(cur, entry.name));
249
- }
250
- if (!entries.some((entry) => entry.isFile() && PAGE_FILE.test(entry.name))) {
251
- continue;
252
- }
253
- const segments = relative(root, cur)
254
- .split(sep)
255
- .filter((segment) => segment !== "" && !/^\(.+\)$/.test(segment));
256
- const last = segments[segments.length - 1];
257
- if (last !== undefined && /^\[\[\.\.\..+\]\]$/.test(last))
258
- segments.pop();
259
- if (segments[0]?.startsWith("["))
260
- continue;
261
- routes.add(`/${segments.join("/")}`);
262
- }
263
- return [...routes].sort();
264
- }
265
233
  /** Every `.mdx` under `dir`, as sorted `/`-separated paths relative to `base`. */
266
234
  function listMdx(dir, base) {
267
235
  const out = [];
@@ -20,7 +20,7 @@ export interface RosterRowSlice {
20
20
  text: string;
21
21
  }
22
22
  /** A parsed manifest file, with the `rows` array located in its text. */
23
- interface RosterFile {
23
+ export interface RosterFile {
24
24
  doc: Record<string, unknown>;
25
25
  text: string;
26
26
  rows: RosterRowSlice[];
@@ -52,6 +52,12 @@ export interface ModuleRosterRow {
52
52
  /** A `contentDir` contribution's seeded page form. */
53
53
  seeded?: RosterRowSlice;
54
54
  }
55
+ /**
56
+ * Parse a manifest file and locate its `rows` array in the text. The element
57
+ * spans are cross-checked against `JSON.parse`, so a splice can only ever
58
+ * move bytes the parser agrees are those rows.
59
+ */
60
+ export declare function parseRosterFile(text: string, label: string): RosterFile;
55
61
  /**
56
62
  * The site's roster of record, or `undefined` when the site has none (a
57
63
  * scaffold that named no template installs no manifest). A file that is not
@@ -176,6 +182,42 @@ export interface PlanRosterAppendOptions {
176
182
  * other row on a contributed route is the site's own and is kept as is.
177
183
  */
178
184
  export declare function planRosterAppend(opts: PlanRosterAppendOptions): RosterAppendPlan;
185
+ /**
186
+ * Every route the site's App Router serves from a page file, sorted: route
187
+ * groups `(x)` dropped, a trailing optional catch-all `[[...x]]` folded into
188
+ * its parent (docs-blog's `/blog/[[...slug]]` serves `/blog`). The root
189
+ * content renderers (`/[slug]`, `/[slug]/[detail]`) are not routes of their
190
+ * own — they are how the content rows render — so they are left out.
191
+ */
192
+ export declare function servedRoutes(targetDir: string): string[];
193
+ /**
194
+ * `file`'s next text with its unserved code-route rows removed — every other
195
+ * byte verbatim — and the dropped routes in file order; `next` is undefined
196
+ * when none is dropped.
197
+ */
198
+ export declare function withoutUnservedRouteRows(file: RosterFile, targetDir: string): {
199
+ next: string | undefined;
200
+ dropped: string[];
201
+ };
202
+ /**
203
+ * The routes of the site roster's page rows — the rows that carry content —
204
+ * or undefined when it has none. A code-route row is left out: it records a
205
+ * route already on disk (signal-collectors' `/careers/apply` walk under the
206
+ * unrelated careers segment), never a surface the site serves. The template
207
+ * is not resolved, so a retired template name reads as any other.
208
+ * @throws AddModuleRefusedError when the file's `rows` cannot be located.
209
+ */
210
+ export declare function readRosterPageRoutes(targetDir: string): string[] | undefined;
211
+ /**
212
+ * The site roster with its unserved code-route rows removed, or undefined
213
+ * when the site has no roster.
214
+ * @throws AddModuleRefusedError when the file's `rows` cannot be located.
215
+ */
216
+ export declare function planServedRoster(targetDir: string): {
217
+ path: string;
218
+ next: string | undefined;
219
+ dropped: string[];
220
+ } | undefined;
179
221
  export interface SiteRosterCheckOptions {
180
222
  targetDir: string;
181
223
  siteTemplatesDir: string;
@@ -191,9 +233,10 @@ export interface SiteRosterCheckOptions {
191
233
  * the shipped reference row;
192
234
  * 3. an installed seed — one some layer lands, present in the site — that
193
235
  * the effective roster does not declare. A page the customer wrote is
194
- * installed by no layer and is never reported.
236
+ * installed by no layer and is never reported;
237
+ * 4. a code-route row no page file serves (#10774). A module contribution
238
+ * whose page file the site lacks is not expected under 2.
195
239
  *
196
240
  * A site with no roster (a template-less scaffold) has no problems.
197
241
  */
198
242
  export declare function siteRosterProblems(opts: SiteRosterCheckOptions): string[];
199
- export {};
@@ -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.
@@ -215,6 +215,18 @@ export declare const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
215
215
  * the hub issues those.
216
216
  */
217
217
  export declare function writeHubOriginEnv(targetDir: string, origin: string): void;
218
+ /**
219
+ * `<name>=<value()>` into `apps/web/.env.local`, unless any env file Next
220
+ * loads for `apps/web` already assigns `name` (`value` is then never called);
221
+ * and `.env.local` gitignored either way. `true` when the line was written.
222
+ */
223
+ export declare function writeWebEnv(targetDir: string, name: string, value: () => string): boolean;
224
+ /**
225
+ * `<name>=<value()>` into `apps/api/.env` — the one file the API's
226
+ * `dotenv.config()` reads — under `writeWebEnv`'s never-clobber rule, and
227
+ * `.env` gitignored either way (#10659 D6). `true` when the line was written.
228
+ */
229
+ export declare function writeApiEnv(targetDir: string, name: string, value: () => string): boolean;
218
230
  /** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
219
231
  export declare const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
220
232
  /** The env var holding a hub-mode spoke's own cookie key material (#7894 D7, D11). */
package/dist/topology.js CHANGED
@@ -903,19 +903,32 @@ export function writeHubOriginEnv(targetDir, origin) {
903
903
  writeWebEnv(targetDir, HUB_ORIGIN_ENV_VAR, () => origin);
904
904
  }
905
905
  /**
906
- * `<name>=<value()>` into the spoke's `apps/web/.env.local`, unless any env
907
- * file Next loads for `apps/web` already assigns `name` (`value` is then never
908
- * called); and `.env.local` gitignored either way.
906
+ * `<name>=<value()>` into `apps/web/.env.local`, unless any env file Next
907
+ * loads for `apps/web` already assigns `name` (`value` is then never called);
908
+ * and `.env.local` gitignored either way. `true` when the line was written.
909
909
  */
910
- function writeWebEnv(targetDir, name, value) {
911
- const webDir = join(targetDir, "apps", "web");
910
+ export function writeWebEnv(targetDir, name, value) {
911
+ return writeEnvLine(join(targetDir, "apps", "web"), NEXT_ENV_FILES, ".env.local", targetDir, ENV_LOCAL_IGNORE_LINES, name, value);
912
+ }
913
+ /** The lines that gitignore `apps/api/.env`; `.env` is appended when none is there. */
914
+ const ENV_IGNORE_LINES = new Set([".env", "**/.env", ".env*"]);
915
+ /**
916
+ * `<name>=<value()>` into `apps/api/.env` — the one file the API's
917
+ * `dotenv.config()` reads — under `writeWebEnv`'s never-clobber rule, and
918
+ * `.env` gitignored either way (#10659 D6). `true` when the line was written.
919
+ */
920
+ export function writeApiEnv(targetDir, name, value) {
921
+ return writeEnvLine(join(targetDir, "apps", "api"), [".env"], ".env", targetDir, ENV_IGNORE_LINES, name, value);
922
+ }
923
+ /** One app's never-clobber env append, and the root `.gitignore` guard. */
924
+ function writeEnvLine(appDir, files, target, targetDir, ignoreLines, name, value) {
912
925
  const assigned = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*\\S`, "m");
913
- const already = NEXT_ENV_FILES.some((file) => {
914
- const path = join(webDir, file);
926
+ const already = files.some((file) => {
927
+ const path = join(appDir, file);
915
928
  return existsSync(path) && assigned.test(readFileSync(path, "utf8"));
916
929
  });
917
930
  if (!already) {
918
- const file = join(webDir, ".env.local");
931
+ const file = join(appDir, target);
919
932
  const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
920
933
  writeOwnerOnly(file, appendLine(existing, `${name}=${value()}`));
921
934
  }
@@ -923,9 +936,10 @@ function writeWebEnv(targetDir, name, value) {
923
936
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
924
937
  const covered = ignore
925
938
  .split("\n")
926
- .some((line) => ENV_LOCAL_IGNORE_LINES.has(line.trim()));
939
+ .some((line) => ignoreLines.has(line.trim()));
927
940
  if (!covered)
928
- writeFileSync(ignorePath, appendLine(ignore, ".env.local"));
941
+ writeFileSync(ignorePath, appendLine(ignore, target));
942
+ return !already;
929
943
  }
930
944
  /** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
931
945
  export const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
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;