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
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Bare `{…}` braces on mdx-body pages (#10773, spec
3
+ * docs/specs/10773-update-never-refreshes-0-19-spec.md D3, D4).
4
+ *
5
+ * MDX reads every `{` in body text as the start of a JavaScript expression.
6
+ * Under the renderer's `next-mdx-remote` defaults (`blockJS`), an expression
7
+ * that does not parse fails the page, and one that parses is deleted — so on
8
+ * an mdx-body page every `{` in text other than a `{/* … *\/}` comment is a
9
+ * defect. This module is the one lexical rule for that brace:
10
+ *
11
+ * - `findBareBraces` names every bare brace in a body, by offset, line and
12
+ * column, in one left-to-right pass;
13
+ * - `escapeBareBraces` inserts one `\` before each, and keeps the result only
14
+ * when the site's own MDX parser reads it with no expression left but
15
+ * comments — the lexer proposes, the parser disposes.
16
+ *
17
+ * Used by `create-ailk update` and, through a byte-identical copy in the
18
+ * scaffold's `tools/`, by `pnpm mdx-body:check`. Pure: no filesystem and no
19
+ * imports. It must never contain a double open brace, which the scaffold copy
20
+ * substitutes as a marker.
21
+ */
22
+ const ESM_RE = /^(?:import|export)[ {*]/;
23
+ // MDX has no indented code, so a fence opens at any indentation — and inside
24
+ // a list item or a blockquote, after its markers.
25
+ const FENCE_RE = /^[ \t]*(?:(?:>|[-*+]|\d{1,9}[.)])[ \t]*)*(`{3,}|~{3,})/;
26
+ const COMMENT_CLOSE_RE = /\*\/\s*\}/g;
27
+ const WHITESPACE_RE = /\s/;
28
+ const TAG_START_RE = /[A-Za-z/>]/;
29
+ const isBlank = (line) => line.trim() === "";
30
+ /**
31
+ * Every bare `{` in an MDX body. Skips, in this order of precedence: fenced
32
+ * code, ESM blocks, backslash escapes, inline code, JSX tags (attribute
33
+ * expressions included) and `{/* … *\/}` comments. Linear in the body.
34
+ */
35
+ export function findBareBraces(body) {
36
+ const found = [];
37
+ const lineEnd = (from) => {
38
+ const end = body.indexOf("\n", from);
39
+ return end === -1 ? body.length : end;
40
+ };
41
+ // A code span or a tag never crosses a blank line: true at a `\n` that ends
42
+ // the body or starts a blank line.
43
+ const paragraphBreak = (at) => {
44
+ let j = at + 1;
45
+ while (body[j] === " " || body[j] === "\t" || body[j] === "\r")
46
+ j += 1;
47
+ return j >= body.length || body[j] === "\n";
48
+ };
49
+ // Memo so a failed search is never repeated over the same text.
50
+ const spanFailedUntil = new Map();
51
+ let tagFailedUntil = -1;
52
+ let commentClose;
53
+ let i = 0;
54
+ while (i < body.length) {
55
+ if (i === 0 || body[i - 1] === "\n") {
56
+ const end = lineEnd(i);
57
+ const line = body.slice(i, end);
58
+ const fence = FENCE_RE.exec(line)?.[1];
59
+ if (fence !== undefined) {
60
+ // Skip to after the closing fence line, or to the end of the body.
61
+ let at = end;
62
+ while (at < body.length) {
63
+ const next = lineEnd(at + 1);
64
+ const candidate = body.slice(at + 1, next);
65
+ const run = FENCE_RE.exec(candidate);
66
+ at = next;
67
+ if (run !== null &&
68
+ run[1][0] === fence[0] &&
69
+ run[1].length >= fence.length &&
70
+ candidate.slice(run[0].length).trim() === "") {
71
+ break;
72
+ }
73
+ }
74
+ i = at + 1;
75
+ continue;
76
+ }
77
+ if (ESM_RE.test(line)) {
78
+ let at = end;
79
+ while (at < body.length) {
80
+ const next = lineEnd(at + 1);
81
+ if (isBlank(body.slice(at + 1, next)))
82
+ break;
83
+ at = next;
84
+ }
85
+ i = at + 1;
86
+ continue;
87
+ }
88
+ }
89
+ const char = body[i];
90
+ if (char === "\\") {
91
+ // A backslash before a line break is a hard break; the next line is read as a line.
92
+ i += body[i + 1] === "\n" ? 1 : 2;
93
+ continue;
94
+ }
95
+ if (char === "`") {
96
+ let run = 1;
97
+ while (body[i + run] === "`")
98
+ run += 1;
99
+ const close = spanFailedUntil.get(run) ?? -1;
100
+ let closed = -1;
101
+ if (close < i) {
102
+ let at = i + run;
103
+ for (; at < body.length; at += 1) {
104
+ if (body[at] === "\n" && (paragraphBreak(at) || FENCE_RE.test(body.slice(at + 1, lineEnd(at + 1)))))
105
+ break;
106
+ if (body[at] !== "`")
107
+ continue;
108
+ let length = 1;
109
+ while (body[at + length] === "`")
110
+ length += 1;
111
+ if (length === run) {
112
+ closed = at + length;
113
+ break;
114
+ }
115
+ at += length - 1;
116
+ }
117
+ if (closed === -1)
118
+ spanFailedUntil.set(run, at);
119
+ }
120
+ i = closed === -1 ? i + run : closed;
121
+ continue;
122
+ }
123
+ if (char === "<" && TAG_START_RE.test(body[i + 1] ?? "") && i >= tagFailedUntil) {
124
+ let depth = 0;
125
+ let quote = "";
126
+ let closed = -1;
127
+ let at = i + 1;
128
+ for (; at < body.length; at += 1) {
129
+ const c = body[at];
130
+ if (c === "\n" && paragraphBreak(at))
131
+ break;
132
+ if (quote !== "") {
133
+ if (c === quote)
134
+ quote = "";
135
+ }
136
+ else if (depth > 0) {
137
+ if (c === "{")
138
+ depth += 1;
139
+ else if (c === "}")
140
+ depth -= 1;
141
+ }
142
+ else if (c === '"' || c === "'") {
143
+ quote = c;
144
+ }
145
+ else if (c === "{") {
146
+ depth = 1;
147
+ }
148
+ else if (c === ">") {
149
+ closed = at + 1;
150
+ break;
151
+ }
152
+ }
153
+ if (closed !== -1) {
154
+ i = closed;
155
+ continue;
156
+ }
157
+ // An unclosed tag already breaks the page; no later `<` before `at` is
158
+ // read as a tag, which keeps the scan linear.
159
+ tagFailedUntil = at;
160
+ i += 1;
161
+ continue;
162
+ }
163
+ if (char === "{") {
164
+ let next = i + 1;
165
+ while (next < body.length && WHITESPACE_RE.test(body[next]))
166
+ next += 1;
167
+ if (body[next] === "/" && body[next + 1] === "*") {
168
+ if (commentClose === undefined || commentClose.from > i || (commentClose.at !== -1 && commentClose.at < i)) {
169
+ COMMENT_CLOSE_RE.lastIndex = next + 2;
170
+ const match = COMMENT_CLOSE_RE.exec(body);
171
+ commentClose = { from: i, at: match === null ? -1 : match.index + match[0].length };
172
+ }
173
+ if (commentClose.at !== -1) {
174
+ i = commentClose.at;
175
+ continue;
176
+ }
177
+ }
178
+ found.push({ offset: i, kind: body[next] === "/" ? "comment-like" : "placeholder" });
179
+ }
180
+ i += 1;
181
+ }
182
+ // Offsets to 1-based line and column, in one pass.
183
+ const braces = [];
184
+ let line = 1;
185
+ let lineStart = 0;
186
+ let scanned = 0;
187
+ for (const brace of found) {
188
+ for (; scanned < brace.offset; scanned += 1) {
189
+ if (body[scanned] === "\n") {
190
+ line += 1;
191
+ lineStart = scanned + 1;
192
+ }
193
+ }
194
+ braces.push({ ...brace, line, column: brace.offset - lineStart + 1 });
195
+ }
196
+ return braces;
197
+ }
198
+ const COMMENT_VALUE_RE = /^\s*\/\*[\s\S]*\*\/\s*$/;
199
+ /** The first expression node left in the tree that is not a comment. */
200
+ export function firstExpression(node) {
201
+ if ((node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") &&
202
+ !(typeof node.value === "string" && COMMENT_VALUE_RE.test(node.value))) {
203
+ return node;
204
+ }
205
+ for (const child of node.children ?? []) {
206
+ const hit = firstExpression(child);
207
+ if (hit !== undefined)
208
+ return hit;
209
+ }
210
+ return undefined;
211
+ }
212
+ /** The first `code` or `inlineCode` node whose span holds one of `offsets` (sorted). */
213
+ function codeNodeHolding(node, offsets) {
214
+ if (node.type === "code" || node.type === "inlineCode") {
215
+ const start = node.position?.start?.offset;
216
+ const end = node.position?.end?.offset;
217
+ if (typeof start === "number" && typeof end === "number") {
218
+ let lo = 0;
219
+ let hi = offsets.length;
220
+ while (lo < hi) {
221
+ const mid = (lo + hi) >> 1;
222
+ if (offsets[mid] < start)
223
+ lo = mid + 1;
224
+ else
225
+ hi = mid;
226
+ }
227
+ if (lo < offsets.length && offsets[lo] < end)
228
+ return node;
229
+ }
230
+ }
231
+ for (const child of node.children ?? []) {
232
+ const hit = codeNodeHolding(child, offsets);
233
+ if (hit !== undefined)
234
+ return hit;
235
+ }
236
+ return undefined;
237
+ }
238
+ /**
239
+ * Escape every bare brace in `body`, all or nothing: `clean` when there is
240
+ * none (`parse` is not called), `refused` when a brace is a mangled comment,
241
+ * the escaped text does not parse, or an expression the escape did not reach
242
+ * remains; otherwise `escaped`.
243
+ */
244
+ export function escapeBareBraces(body, parse) {
245
+ const braces = findBareBraces(body);
246
+ if (braces.length === 0)
247
+ return { kind: "clean" };
248
+ const mangled = braces.find((brace) => brace.kind === "comment-like");
249
+ if (mangled !== undefined) {
250
+ return { kind: "refused", reason: `{/ at line ${mangled.line} is not a {/* … */} comment` };
251
+ }
252
+ const parts = [];
253
+ let from = 0;
254
+ for (const { offset } of braces) {
255
+ parts.push(body.slice(from, offset), "\\");
256
+ from = offset;
257
+ }
258
+ parts.push(body.slice(from));
259
+ const text = parts.join("");
260
+ let tree;
261
+ try {
262
+ tree = parse(text);
263
+ }
264
+ catch (err) {
265
+ const { line, reason } = (err ?? {});
266
+ const why = typeof reason === "string" ? reason : err instanceof Error ? err.message : String(err);
267
+ return { kind: "refused", reason: `line ${typeof line === "number" ? line : "?"}: ${why}` };
268
+ }
269
+ const escapes = braces.map((brace, k) => brace.offset + k);
270
+ const inCode = codeNodeHolding(tree, escapes);
271
+ if (inCode !== undefined) {
272
+ return {
273
+ kind: "refused",
274
+ reason: `a brace at line ${inCode.position?.start?.line ?? "?"} is inside code the lexer did not see`,
275
+ };
276
+ }
277
+ const left = firstExpression(tree);
278
+ if (left !== undefined) {
279
+ return {
280
+ kind: "refused",
281
+ reason: `an expression at line ${left.position?.start?.line ?? "?"} the escape did not reach`,
282
+ };
283
+ }
284
+ return { kind: "escaped", text, count: braces.length };
285
+ }
@@ -45,5 +45,5 @@ export interface MigrateContentArgs {
45
45
  workspaceId: string | undefined;
46
46
  help: boolean;
47
47
  }
48
- export declare const MIGRATE_CONTENT_HELP = "create-ailk migrate-content \u2014 import an existing site's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --apply Write the pages. Without it the command reads the\n workspace, prints what it would do, and writes\n nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL. https: only\n (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page (and each legacy\ncontent/<Type>/<slug>.mdx) is written to the workspace as a draft through\nthe API's content routes, with the agent key, which is read from\nAILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,\nso the live site does not change. The site needs no .ailk-site-manifest.json\nand may be a spoke; its dependencies must be installed. A page whose\nfrontmatter carries sections is converted: its words are written to the\npage's Markdown and its sections to its layout.\n\nEach FAQ item and glossary-index term becomes one Language item of its kind,\nset Live when the run creates it, and its section pins the items in the\norder the file lists them. A question on several pages is one item, with\nthe FAQ page's words (the glossary page's, for a term). Lines prefixed\nlanguage: report each item, and each page the database read will not show\nas its file does. An API with no Language collection leaves them inline.\n\nWhen the site has apps/web/content, each blog post and docs page in it is\nwritten too: apps/web/content/en/blog/<slug>.mdx (or <slug>/index.mdx) as a\nBlogPosting, the same under docs as a DocsDetail, and each directory's own\nindex.mdx as its BlogList or DocsList index. Their lines are prefixed\neditorial: \u2014 each outcome's root is content or editorial.\n\nA dry run prints create, update, unchanged, skipped or failed per page. It\ncannot see a write the API would refuse at --apply (a conflict or a server\nerror), nor a layout change on a page whose words did not change, and a\npage whose content fails its page type is planned and saved\nwith errors the editor shows, not failed. --apply prints created, updated,\nunchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no\npage failed, 1 when any did, 2 on a refusal (nothing is sent).\n";
48
+ export declare const MIGRATE_CONTENT_HELP = "create-ailk migrate-content \u2014 import an existing site's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --apply Write the pages. Without it the command reads the\n workspace, prints what it would do, and writes\n nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL. https: only\n (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page (and each legacy\ncontent/<Type>/<slug>.mdx) is written to the workspace as a draft through\nthe API's content routes, with the agent key, which is read from\nAILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,\nso the live site does not change. The site needs no .ailk-site-manifest.json\nand may be a spoke; its dependencies must be installed. A page whose\nfrontmatter carries sections is converted: its words are written to the\npage's Markdown and its sections to its layout.\n\nEach FAQ item and glossary-index term becomes one Language item of its kind,\nset Live when the run creates it, and its section pins the items in the\norder the file lists them. A question on several pages is one item, with\nthe FAQ page's words (the glossary page's, for a term). Lines prefixed\nlanguage: report each item, and each page the database read will not show\nas its file does. An API with no Language collection leaves them inline.\n\nWhen the site has apps/web/content, each blog post and docs page in it is\nwritten too: apps/web/content/en/blog/<slug>.mdx (or <slug>/index.mdx) as a\nBlogPosting, or one series down, blog/<series>/<post>.mdx, as a BlogPosting\nwhose parent is the series; the same under docs as a DocsDetail, and each\ndirectory's own index.mdx as its BlogList or DocsList index. A file the walk\nskips is listed with the rule it failed. Their lines are prefixed\neditorial: \u2014 each outcome's root is content or editorial.\n\nA dry run prints create, update, unchanged, skipped or failed per page. It\ncannot see a write the API would refuse at --apply (a conflict or a server\nerror), nor a layout change on a page whose words did not change, and a\npage whose content fails its page type is planned and saved\nwith errors the editor shows, not failed. --apply prints created, updated,\nunchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no\npage failed, 1 when any did, 2 on a refusal (nothing is sent).\n";
49
49
  export declare function parseMigrateContentArgs(argv: string[]): MigrateContentArgs;
@@ -217,8 +217,10 @@ as its file does. An API with no Language collection leaves them inline.
217
217
 
218
218
  When the site has apps/web/content, each blog post and docs page in it is
219
219
  written too: apps/web/content/en/blog/<slug>.mdx (or <slug>/index.mdx) as a
220
- BlogPosting, the same under docs as a DocsDetail, and each directory's own
221
- index.mdx as its BlogList or DocsList index. Their lines are prefixed
220
+ BlogPosting, or one series down, blog/<series>/<post>.mdx, as a BlogPosting
221
+ whose parent is the series; the same under docs as a DocsDetail, and each
222
+ directory's own index.mdx as its BlogList or DocsList index. A file the walk
223
+ skips is listed with the rule it failed. Their lines are prefixed
222
224
  editorial: — each outcome's root is content or editorial.
223
225
 
224
226
  A dry run prints create, update, unchanged, skipped or failed per page. It
@@ -46,7 +46,7 @@ import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
46
46
  import { planSiteManifest } from "./site-manifest.js";
47
47
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, withMeetPlaceholderSeeds, } from "./site-roster.js";
48
48
  import { SPOKE_CHECKOUT_TEMPLATE_PATHS, dropSpokeCheckoutDependency, spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
49
- import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, isUnservedSurfaceContent, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
49
+ import { GATEABLE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, isUnservedSurfaceContent, renderNavConfig, resolveSurfaces, surfacesNamedByRoutes, } from "./surfaces.js";
50
50
  import { syncRoutes } from "./sync-routes.js";
51
51
  import { BASE_LAYER_DIR, SHAPE_STARTERS, isOwnTree, shippedManifestPath, TEMPLATE_MANIFESTS, TemplateError, mergeRoster, resolveTemplate, resolveTemplateLineage, rosterDeclares, templateDelivery, templateTreeDir, } from "./template-manifest.js";
52
52
  import { TEMPLATE_RECORD_PATH, isRecordable, planTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
@@ -1075,13 +1075,7 @@ function pruneUnrosteredSeeds(targetDir, roster, surfaces, ownership) {
1075
1075
  * path segment is the surface's top-level route directory.
1076
1076
  */
1077
1077
  function rosterSurfaces(template) {
1078
- const routeRoot = "apps/web/app/[locale]";
1079
- const moduleOwned = new Set(Object.values(MODULE_SURFACES).flat());
1080
- const topSegments = new Set(mergeRoster(template, template.requires)
1081
- .map((row) => row.route.split("/").filter(Boolean)[0])
1082
- .filter((segment) => segment !== undefined));
1083
- return GATEABLE_SURFACES.filter((surface) => !moduleOwned.has(surface.id) &&
1084
- surface.routes.some((dir) => topSegments.has(dir.slice(routeRoot.length + 1)))).map((surface) => surface.id);
1078
+ return surfacesNamedByRoutes(mergeRoster(template, template.requires).map((row) => row.route));
1085
1079
  }
1086
1080
  // Re-exported so a consumer of the async `scaffold()` above can type its own
1087
1081
  // wrapper (e.g. the MCP scaffold-site tool) without reaching into `lib/`.
@@ -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 {};