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.
- package/README.md +41 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.js +57 -24
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- 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
|
|
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;
|
package/dist/migrate-content.js
CHANGED
|
@@ -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,
|
|
221
|
-
|
|
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
|
package/dist/programmatic.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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/`.
|
package/dist/site-manifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 = [];
|
package/dist/site-roster.d.ts
CHANGED
|
@@ -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 {};
|