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
@@ -0,0 +1,573 @@
1
+ /**
2
+ * The cutover comparison (#10648, spec
3
+ * docs/specs/10648-the-cutover-kit-frozen-inventory-spec.md D9).
4
+ *
5
+ * Per frozen page URL, at two viewports, in Chromium, on both origins: the
6
+ * render (a direct 200 that settles on the requested path, `render-verdict.ts`'s
7
+ * rule), the text of `main`, every JSON-LD script string for string, and a
8
+ * full-page screenshot compared pixel by pixel. Then a sweep: every frozen URL
9
+ * must answer 200 with no redirect. A DOM diff alone is never the evidence
10
+ * (roadmap § 9, rules/outcome-evidence.md): the pixels gate the verdict, and
11
+ * `comparison.md` links every before/after pair for the eye read.
12
+ *
13
+ * create-ailk has one runtime dependency, so nothing here imports Playwright:
14
+ * the PNG decode and diff are pure functions over buffers (`node:zlib` only),
15
+ * a page is reached only through the `ComparisonPage` slice it is handed, and
16
+ * `loadChromium` resolves the site's own Playwright at run time. Jest drives
17
+ * all of it without a browser.
18
+ */
19
+ import { mkdirSync, writeFileSync } from "node:fs";
20
+ import { createRequire } from "node:module";
21
+ import { join } from "node:path";
22
+ import { inflateSync } from "node:zlib";
23
+ /** The two viewports every page is compared at. */
24
+ export const VIEWPORTS = [
25
+ { name: "desktop", width: 1440, height: 900 },
26
+ { name: "mobile", width: 390, height: 844 },
27
+ ];
28
+ /** How long one navigation may take before the render fails. */
29
+ const NAVIGATION_TIMEOUT_MS = 60_000;
30
+ /** How long one sweep request may take. */
31
+ const SWEEP_TIMEOUT_MS = 15_000;
32
+ /** Requests in flight at once, for a sweep or a crawl. */
33
+ export const IN_FLIGHT = 4;
34
+ /** The page's words: `main`'s rendered text, else `body`'s. */
35
+ export const TEXT_EXPRESSION = '(() => { const root = document.querySelector("main") ?? document.body; return root ? root.innerText : ""; })()';
36
+ /** Every JSON-LD script's text, in document order. */
37
+ export const JSONLD_EXPRESSION = 'Array.from(document.querySelectorAll(\'script[type="application/ld+json"]\'), (s) => s.textContent ?? "")';
38
+ const itemKey = (i) => `${i.kind}/${i.slug}`;
39
+ /** `compareText`'s whitespace rule. */
40
+ const collapse = (s) => s.replace(/\s+/g, " ").trim();
41
+ /**
42
+ * Plain JS run in the after page, so no transform reaches it. Under the kind
43
+ * pull's position rule (#10734 D3) it removes exactly the reported items, or
44
+ * removes nothing and answers why: FAQs are the tail of the last `faq`
45
+ * section's items, matched by question; terms are the tail of their letter
46
+ * group in the last `glossary-index` section, matched by slug, and a letter
47
+ * group they leave empty goes with its nav link. A reason never carries an
48
+ * item's words.
49
+ */
50
+ export const EXCLUDE_SOURCE = `function (document, exclude) {
51
+ var norm = function (s) { return String(s).replace(/\\s+/g, " ").trim(); };
52
+ var firstLine = function (el) {
53
+ var lines = String(el.innerText || "").split("\\n");
54
+ for (var i = 0; i < lines.length; i++) if (norm(lines[i]) !== "") return norm(lines[i]);
55
+ return "";
56
+ };
57
+ var last = function (selector) {
58
+ var all = document.querySelectorAll(selector);
59
+ return all.length === 0 ? null : all[all.length - 1];
60
+ };
61
+ var byTestId = function (root, id) {
62
+ return Array.from(root.querySelectorAll("[data-testid]")).filter(function (el) { return el.getAttribute("data-testid") === id; });
63
+ };
64
+ var faqs = exclude.filter(function (t) { return t.kind === "faq"; });
65
+ var terms = exclude.filter(function (t) { return t.kind === "term"; });
66
+ var items = [];
67
+ if (faqs.length > 0) {
68
+ var faq = last('[data-section="faq"]');
69
+ if (faq === null) return { refused: "no faq section" };
70
+ var all = Array.from(faq.querySelectorAll('[data-testid="faq-item-expanded"], [data-testid="faq-item-accordion"], [data-testid="faq-item-grid"]'));
71
+ var left = faqs.slice();
72
+ for (var i = Math.max(0, all.length - faqs.length); i < all.length; i++) {
73
+ var line = firstLine(all[i]);
74
+ var at = left.findIndex(function (t) { return norm(t.name) === line; });
75
+ if (at < 0) return { refused: "item " + (i + 1) + " of " + all.length + " in the last faq section is not a reported top-up" };
76
+ left.splice(at, 1);
77
+ items.push(all[i]);
78
+ }
79
+ if (left.length > 0) return { refused: "the last faq section has " + all.length + " item(s), fewer than its " + faqs.length + " top-up(s)" };
80
+ }
81
+ var glossary = null;
82
+ var termItems = [];
83
+ if (terms.length > 0) {
84
+ glossary = last('[data-section="glossary-index"]');
85
+ if (glossary === null) return { refused: "no glossary-index section" };
86
+ for (var j = 0; j < terms.length; j++) {
87
+ var links = byTestId(glossary, "glossary-index-term-" + terms[j].slug);
88
+ var li = links.length === 1 ? links[0].closest("li") : null;
89
+ if (li === null) return { refused: "term " + terms[j].slug + " is not shown once in the last glossary-index section" };
90
+ termItems.push(li);
91
+ }
92
+ for (var k = 0; k < terms.length; k++) {
93
+ var siblings = Array.from(termItems[k].parentElement.children);
94
+ var later = siblings.slice(siblings.indexOf(termItems[k]) + 1);
95
+ if (later.some(function (el) { return termItems.indexOf(el) < 0; })) {
96
+ return { refused: "term " + terms[k].slug + " is not at the tail of its letter group" };
97
+ }
98
+ }
99
+ }
100
+ items.forEach(function (el) { el.remove(); });
101
+ var lists = [];
102
+ termItems.forEach(function (el) {
103
+ if (lists.indexOf(el.parentElement) < 0) lists.push(el.parentElement);
104
+ el.remove();
105
+ });
106
+ lists.forEach(function (ul) {
107
+ if (ul.children.length > 0) return;
108
+ var group = ul.closest("section[data-letter]");
109
+ if (group === null) return;
110
+ var link = byTestId(glossary, "glossary-index-letter-" + group.getAttribute("data-letter"))[0];
111
+ group.remove();
112
+ if (link === undefined) return;
113
+ var nav = link.closest("nav");
114
+ link.remove();
115
+ if (nav !== null && nav.children.length === 0) nav.remove();
116
+ });
117
+ return { removed: exclude.map(function (t) { return t.kind + "/" + t.slug; }) };
118
+ }`;
119
+ /** The expression the after page evaluates: `EXCLUDE_SOURCE` called on a JSON literal. */
120
+ export function exclusionExpression(exclude) {
121
+ return `(${EXCLUDE_SOURCE})(document, ${JSON.stringify(exclude)})`;
122
+ }
123
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
124
+ /**
125
+ * The after side's JSON-LD without the top-ups' nodes: per kind, the one array
126
+ * holding a matching node (a `Question` by `name`, a `DefinedTerm` by
127
+ * `termCode`) must end in exactly those nodes and keep one other. The script
128
+ * that changed is re-serialized with `serializeJsonLd`'s rule
129
+ * (`packages/schema/src/json-ld.ts`, restated: no runtime import); every other
130
+ * keeps its string. Anything else is `refused`, with the scripts untouched.
131
+ */
132
+ export function excludeJsonLd(scripts, exclude) {
133
+ const parsed = scripts.map((script) => {
134
+ try {
135
+ return { value: JSON.parse(script) };
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ });
141
+ const changed = new Set();
142
+ for (const kind of ["faq", "term"]) {
143
+ const items = exclude.filter((i) => i.kind === kind);
144
+ if (items.length === 0)
145
+ continue;
146
+ const matches = (node, item) => isRecord(node) &&
147
+ (kind === "faq"
148
+ ? node["@type"] === "Question" && typeof node["name"] === "string" && collapse(node["name"]) === collapse(item.name)
149
+ : node["@type"] === "DefinedTerm" && node["termCode"] === item.slug);
150
+ const found = [];
151
+ const walk = (value, script) => {
152
+ if (Array.isArray(value)) {
153
+ if (value.some((node) => items.some((item) => matches(node, item))))
154
+ found.push({ script, array: value });
155
+ for (const v of value)
156
+ walk(v, script);
157
+ }
158
+ else if (isRecord(value)) {
159
+ for (const v of Object.values(value))
160
+ walk(v, script);
161
+ }
162
+ };
163
+ parsed.forEach((p, script) => p !== undefined && walk(p.value, script));
164
+ if (found.length !== 1)
165
+ return { refused: `${found.length} JSON-LD arrays hold a ${kind} top-up, expected 1` };
166
+ const { script, array } = found[0];
167
+ const left = [...items];
168
+ const tail = array.length > items.length ? array.slice(-items.length) : [];
169
+ for (const node of tail) {
170
+ const at = left.findIndex((item) => matches(node, item));
171
+ if (at < 0)
172
+ break;
173
+ left.splice(at, 1);
174
+ }
175
+ if (tail.length === 0 || left.length > 0) {
176
+ return { refused: `the last ${items.length} node(s) of the ${kind} array are not its top-ups` };
177
+ }
178
+ array.splice(-items.length);
179
+ changed.add(script);
180
+ }
181
+ return {
182
+ scripts: scripts.map((script, i) => changed.has(i) ? JSON.stringify(parsed[i].value).replace(/</g, "\\u003c") : script),
183
+ };
184
+ }
185
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
186
+ function paeth(a, b, c) {
187
+ const p = a + b - c;
188
+ const pa = Math.abs(p - a);
189
+ const pb = Math.abs(p - b);
190
+ const pc = Math.abs(p - c);
191
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
192
+ }
193
+ /**
194
+ * An 8-bit RGB or RGBA, non-interlaced PNG as RGBA, every scanline filter
195
+ * undone. Anything else — another depth or colour type, interlacing, a
196
+ * truncated or corrupt stream — is `unsupported-png`.
197
+ */
198
+ export function decodePng(png) {
199
+ const unsupported = { ok: false, reason: "unsupported-png" };
200
+ try {
201
+ if (png.length < 8 || !png.subarray(0, 8).equals(PNG_SIGNATURE))
202
+ return unsupported;
203
+ let width = 0;
204
+ let height = 0;
205
+ let channels = 0;
206
+ const idat = [];
207
+ for (let at = 8; at + 8 <= png.length;) {
208
+ const length = png.readUInt32BE(at);
209
+ const type = png.toString("ascii", at + 4, at + 8);
210
+ const data = png.subarray(at + 8, at + 8 + length);
211
+ if (type === "IHDR") {
212
+ width = data.readUInt32BE(0);
213
+ height = data.readUInt32BE(4);
214
+ const [depth, colour, compression, filter, interlace] = data.subarray(8, 13);
215
+ if (depth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0)
216
+ return unsupported;
217
+ if (colour === 2)
218
+ channels = 3;
219
+ else if (colour === 6)
220
+ channels = 4;
221
+ else
222
+ return unsupported;
223
+ }
224
+ else if (type === "IDAT") {
225
+ idat.push(data);
226
+ }
227
+ else if (type === "IEND") {
228
+ break;
229
+ }
230
+ at += 12 + length;
231
+ }
232
+ if (channels === 0 || width === 0 || height === 0)
233
+ return unsupported;
234
+ const raw = inflateSync(Buffer.concat(idat));
235
+ const stride = width * channels;
236
+ if (raw.length !== height * (stride + 1))
237
+ return unsupported;
238
+ const rgba = new Uint8Array(width * height * 4);
239
+ let prev = new Uint8Array(stride);
240
+ for (let y = 0; y < height; y++) {
241
+ const filter = raw[y * (stride + 1)];
242
+ const row = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
243
+ for (let i = 0; i < stride; i++) {
244
+ const a = i >= channels ? row[i - channels] : 0;
245
+ const b = prev[i];
246
+ const c = i >= channels ? prev[i - channels] : 0;
247
+ const predicted = filter === 0 ? 0
248
+ : filter === 1 ? a
249
+ : filter === 2 ? b
250
+ : filter === 3 ? (a + b) >> 1
251
+ : filter === 4 ? paeth(a, b, c)
252
+ : -1;
253
+ if (predicted < 0)
254
+ return unsupported;
255
+ row[i] = (row[i] + predicted) & 0xff;
256
+ }
257
+ for (let x = 0; x < width; x++) {
258
+ const to = (y * width + x) * 4;
259
+ const from = x * channels;
260
+ rgba[to] = row[from];
261
+ rgba[to + 1] = row[from + 1];
262
+ rgba[to + 2] = row[from + 2];
263
+ rgba[to + 3] = channels === 4 ? row[from + 3] : 255;
264
+ }
265
+ prev = row;
266
+ }
267
+ return { ok: true, image: { width, height, data: rgba } };
268
+ }
269
+ catch {
270
+ return unsupported;
271
+ }
272
+ }
273
+ /** How many pixels differ in any channel, or `size-differs`. */
274
+ export function diffPixels(a, b) {
275
+ if (a.width !== b.width || a.height !== b.height)
276
+ return "size-differs";
277
+ let differing = 0;
278
+ for (let i = 0; i < a.data.length; i += 4) {
279
+ if (a.data[i] !== b.data[i] ||
280
+ a.data[i + 1] !== b.data[i + 1] ||
281
+ a.data[i + 2] !== b.data[i + 2] ||
282
+ a.data[i + 3] !== b.data[i + 3]) {
283
+ differing += 1;
284
+ }
285
+ }
286
+ return differing;
287
+ }
288
+ function textLines(text) {
289
+ return text
290
+ .split("\n")
291
+ .map((line) => line.replace(/\s+/g, " ").trim())
292
+ .filter((line) => line !== "");
293
+ }
294
+ /** Whitespace runs collapsed, then line for line; `differs` names the first differing line (1-based). */
295
+ export function compareText(before, after) {
296
+ const a = textLines(before);
297
+ const b = textLines(after);
298
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
299
+ if (a[i] !== b[i]) {
300
+ return { verdict: "differs", line: i + 1, before: a[i] ?? "", after: b[i] ?? "" };
301
+ }
302
+ }
303
+ return { verdict: "same" };
304
+ }
305
+ /** `same` only when the two arrays are equal string for string — never after a parse. */
306
+ export function compareJsonLd(before, after) {
307
+ for (let i = 0; i < Math.max(before.length, after.length); i++) {
308
+ if (before[i] !== after[i]) {
309
+ return { verdict: "differs", index: i, before: before[i] ?? null, after: after[i] ?? null };
310
+ }
311
+ }
312
+ return { verdict: "same" };
313
+ }
314
+ function pixelVerdict(before, after, tolerance) {
315
+ const a = decodePng(before);
316
+ const b = decodePng(after);
317
+ if (!a.ok || !b.ok)
318
+ return { verdict: "unsupported-png" };
319
+ const differing = diffPixels(a.image, b.image);
320
+ if (differing === "size-differs")
321
+ return { verdict: "size-differs" };
322
+ const total = a.image.width * a.image.height;
323
+ const verdict = differing === 0 ? "same" : differing / total <= tolerance ? "within-tolerance" : "differs";
324
+ return { verdict, differing, total };
325
+ }
326
+ function passes(v) {
327
+ return v.verdict === "same" || v.verdict === "within-tolerance";
328
+ }
329
+ async function renderSide(side, path, viewport, screenshot, exclude) {
330
+ const failed = (render) => ({ render, text: "", jsonLd: [] });
331
+ await side.page.setViewportSize({ width: viewport.width, height: viewport.height });
332
+ let status;
333
+ try {
334
+ const response = await side.page.goto(side.origin + path, {
335
+ waitUntil: "load",
336
+ timeout: NAVIGATION_TIMEOUT_MS,
337
+ });
338
+ status = response?.status();
339
+ }
340
+ catch (err) {
341
+ return failed(`navigation failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
342
+ }
343
+ if (status !== 200)
344
+ return failed(status === undefined ? "no response" : `status ${status}, expected 200`);
345
+ const settled = new URL(side.page.url()).pathname;
346
+ if (settled !== path)
347
+ return failed(`requested "${path}" but the page settled on "${settled}"`);
348
+ let full;
349
+ let dom;
350
+ if (exclude !== undefined) {
351
+ if (screenshot)
352
+ full = await side.page.screenshot({ fullPage: true, animations: "disabled" });
353
+ const answer = await side.page.evaluate(exclusionExpression(exclude));
354
+ dom =
355
+ isRecord(answer) && typeof answer["refused"] === "string"
356
+ ? { refused: `dom: ${answer["refused"]}` }
357
+ : isRecord(answer) && Array.isArray(answer["removed"])
358
+ ? { removed: exclude.map(itemKey) }
359
+ : { refused: "dom: no answer from the page" };
360
+ }
361
+ const text = await side.page.evaluate(TEXT_EXPRESSION);
362
+ const read = await side.page.evaluate(JSONLD_EXPRESSION);
363
+ let jsonLd = Array.isArray(read) ? read.map(String) : [];
364
+ let exclusion = dom;
365
+ if (exclude !== undefined && dom !== undefined && "removed" in dom) {
366
+ const excluded = excludeJsonLd(jsonLd, exclude);
367
+ if ("refused" in excluded)
368
+ exclusion = { refused: `json-ld: ${excluded.refused}` };
369
+ else
370
+ jsonLd = excluded.scripts;
371
+ }
372
+ return {
373
+ render: "ok",
374
+ text: typeof text === "string" ? text : "",
375
+ jsonLd,
376
+ ...(screenshot && {
377
+ png: await side.page.screenshot({ fullPage: true, animations: "disabled" }),
378
+ }),
379
+ ...(full !== undefined && { full }),
380
+ ...(exclusion !== undefined && { exclusion }),
381
+ };
382
+ }
383
+ /** One frozen URL at every viewport, before against after. */
384
+ export async function comparePage(o) {
385
+ const viewports = [];
386
+ const exclude = o.exclude !== undefined && o.exclude.length > 0 ? o.exclude : undefined;
387
+ for (const viewport of VIEWPORTS) {
388
+ const before = await renderSide(o.before, o.path, viewport, o.screenshots);
389
+ const after = await renderSide(o.after, o.path, viewport, o.screenshots, exclude);
390
+ const rendered = before.render === "ok" && after.render === "ok";
391
+ const result = {
392
+ viewport: viewport.name,
393
+ render: { before: before.render, after: after.render },
394
+ text: rendered ? compareText(before.text, after.text) : { verdict: "not-compared" },
395
+ jsonLd: rendered ? compareJsonLd(before.jsonLd, after.jsonLd) : { verdict: "not-compared" },
396
+ ...(after.exclusion !== undefined && { exclusion: after.exclusion }),
397
+ };
398
+ if (o.screenshots) {
399
+ if (before.png !== undefined && after.png !== undefined) {
400
+ const shots = {
401
+ before: `${o.n}-${viewport.name}-before.png`,
402
+ after: `${o.n}-${viewport.name}-after.png`,
403
+ };
404
+ o.writeShot(shots.before, before.png);
405
+ o.writeShot(shots.after, after.png);
406
+ if (after.full !== undefined) {
407
+ shots.afterFull = `${o.n}-${viewport.name}-after-full.png`;
408
+ o.writeShot(shots.afterFull, after.full);
409
+ }
410
+ result.pixels = pixelVerdict(before.png, after.png, o.tolerance);
411
+ result.shots = shots;
412
+ }
413
+ else {
414
+ result.pixels = { verdict: "not-compared" };
415
+ }
416
+ }
417
+ viewports.push(result);
418
+ }
419
+ const pass = viewports.every((v) => v.render.before === "ok" &&
420
+ v.render.after === "ok" &&
421
+ passes(v.text) &&
422
+ passes(v.jsonLd) &&
423
+ (v.pixels === undefined || passes(v.pixels)) &&
424
+ (v.exclusion === undefined || "removed" in v.exclusion));
425
+ const excluded = exclude !== undefined && viewports.every((v) => v.exclusion !== undefined && "removed" in v.exclusion)
426
+ ? exclude.map(itemKey)
427
+ : undefined;
428
+ return { path: o.path, n: o.n, pass, viewports, ...(excluded !== undefined && { excluded }) };
429
+ }
430
+ // ── The sweep (D9) ──────────────────────────────────────────────────────────
431
+ /** `fn` over `items`, at most `limit` at once; results in input order. */
432
+ export async function mapLimit(items, limit, fn) {
433
+ const results = new Array(items.length);
434
+ let next = 0;
435
+ const worker = async () => {
436
+ while (next < items.length) {
437
+ const i = next++;
438
+ results[i] = await fn(items[i]);
439
+ }
440
+ };
441
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
442
+ return results;
443
+ }
444
+ /** Every path on `origin` must answer 200 directly; a redirect is not a 200. */
445
+ export async function sweep(o) {
446
+ const results = await mapLimit(o.paths, IN_FLIGHT, async (path) => {
447
+ try {
448
+ const response = await o.fetch(o.origin + path, {
449
+ redirect: "manual",
450
+ ...(o.headers !== undefined && { headers: o.headers }),
451
+ signal: AbortSignal.timeout(SWEEP_TIMEOUT_MS),
452
+ });
453
+ return { path, status: response.status };
454
+ }
455
+ catch {
456
+ return { path, status: 0 };
457
+ }
458
+ });
459
+ return { pass: results.every((r) => r.status === 200), results };
460
+ }
461
+ function cell(v) {
462
+ return v === undefined ? "—" : v.verdict;
463
+ }
464
+ /** The eye-read table: one row per URL and viewport, linking its screenshot pair. */
465
+ export function comparisonMarkdown(report) {
466
+ const lines = [
467
+ `# Comparison: ${report.before} → ${report.after}`,
468
+ "",
469
+ "| # | Path | Viewport | Render | Text | JSON-LD | Pixels | Screenshots |",
470
+ "| - | ---- | -------- | ------ | ---- | ------- | ------ | ----------- |",
471
+ ];
472
+ for (const page of report.pages) {
473
+ for (const v of page.viewports) {
474
+ const render = v.render.before === "ok" && v.render.after === "ok"
475
+ ? "ok"
476
+ : `before: ${v.render.before}; after: ${v.render.after}`;
477
+ const shots = v.shots === undefined
478
+ ? "—"
479
+ : `[before](shots/${v.shots.before}) · [after](shots/${v.shots.after})`;
480
+ lines.push(`| ${page.n} | \`${page.path}\` | ${v.viewport} | ${render.replace(/\|/g, "\\|")} | ${cell(v.text)} | ${cell(v.jsonLd)} | ${cell(v.pixels)} | ${shots} |`);
481
+ }
482
+ }
483
+ const excluding = report.pages.filter((p) => p.viewports.some((v) => v.exclusion !== undefined));
484
+ if (excluding.length > 0) {
485
+ lines.push("", "## Excluded kind-pull top-ups (#10734)", "");
486
+ for (const page of excluding) {
487
+ for (const key of page.excluded ?? [])
488
+ lines.push(`- \`${page.path}\`: ${key}`);
489
+ for (const v of page.viewports) {
490
+ if (v.shots?.afterFull !== undefined) {
491
+ lines.push(`- \`${page.path}\` ${v.viewport} before exclusion: [after](shots/${v.shots.afterFull})`);
492
+ }
493
+ if (v.exclusion !== undefined && "refused" in v.exclusion) {
494
+ lines.push(`- \`${page.path}\` (${v.viewport}): not excluded — ${v.exclusion.refused}`);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ const failed = report.sweep.results.filter((r) => r.status !== 200);
500
+ lines.push("", `## Sweep: ${report.sweep.results.length} URL(s) on ${report.after}, ${failed.length} not 200`, "", ...failed.map((r) => `- \`${r.path}\` → ${r.status}`));
501
+ return `${lines.join("\n")}\n`;
502
+ }
503
+ /** `comparison.json` and `comparison.md` in `dir`; the screenshots are already in `dir/shots`. */
504
+ export function writeComparisonEvidence(dir, report) {
505
+ mkdirSync(dir, { recursive: true });
506
+ writeFileSync(join(dir, "comparison.json"), `${JSON.stringify(report, null, 2)}\n`);
507
+ writeFileSync(join(dir, "comparison.md"), comparisonMarkdown(report));
508
+ }
509
+ export const PLAYWRIGHT_INSTALL_HINT = "pnpm add -D @playwright/test && pnpm exec playwright install chromium";
510
+ /** `chromium` from `playwright` or `@playwright/test`, resolved from each dir in turn. */
511
+ export function loadChromium(dirs) {
512
+ for (const dir of dirs) {
513
+ for (const name of ["playwright", "@playwright/test"]) {
514
+ try {
515
+ const mod = createRequire(join(dir, "package.json"))(name);
516
+ if (mod.chromium !== undefined)
517
+ return mod.chromium;
518
+ }
519
+ catch {
520
+ // Not installed here: the next candidate.
521
+ }
522
+ }
523
+ }
524
+ return undefined;
525
+ }
526
+ /** Every page compared, the sweep run, the evidence written; `pass` gates the step. */
527
+ export async function runComparison(o) {
528
+ const shotsDir = join(o.dir, "shots");
529
+ if (o.screenshots)
530
+ mkdirSync(shotsDir, { recursive: true });
531
+ const browser = await o.chromium.launch();
532
+ const pages = [];
533
+ try {
534
+ const before = await (await browser.newContext()).newPage();
535
+ const after = await (await browser.newContext()).newPage();
536
+ const headers = o.afterHeaders;
537
+ if (headers !== undefined) {
538
+ // Only requests to the `after` origin carry the headers (a preview's
539
+ // protection bypass): never a third-party subresource, never another origin.
540
+ const origin = new URL(o.after).origin;
541
+ await after.route("**/*", (route) => new URL(route.request().url()).origin === origin
542
+ ? route.continue({ headers: { ...route.request().headers(), ...headers } })
543
+ : route.continue());
544
+ }
545
+ for (const [i, path] of o.pages.entries()) {
546
+ const exclude = o.exclude !== undefined && Object.hasOwn(o.exclude, path) ? o.exclude[path] : undefined;
547
+ const result = await comparePage({
548
+ path,
549
+ n: i + 1,
550
+ before: { origin: o.before, page: before },
551
+ after: { origin: o.after, page: after },
552
+ tolerance: o.tolerance,
553
+ screenshots: o.screenshots,
554
+ writeShot: (name, png) => writeFileSync(join(shotsDir, name), png),
555
+ ...(exclude !== undefined && { exclude }),
556
+ });
557
+ o.onLine?.(`${result.pass ? "same" : "DIFFERS"} ${path}`);
558
+ pages.push(result);
559
+ }
560
+ }
561
+ finally {
562
+ await browser.close();
563
+ }
564
+ const swept = await sweep({
565
+ origin: o.after,
566
+ paths: o.sweepPaths,
567
+ fetch: o.fetch,
568
+ ...(o.afterHeaders !== undefined && { headers: o.afterHeaders }),
569
+ });
570
+ const report = { before: o.before, after: o.after, pages, sweep: swept };
571
+ writeComparisonEvidence(o.dir, report);
572
+ return { ...report, pass: swept.pass && pages.every((p) => p.pass) };
573
+ }
@@ -0,0 +1,73 @@
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
+ export interface BareBrace {
23
+ /** Offset of the `{` in the body. */
24
+ offset: number;
25
+ /** 1-based line within the body. */
26
+ line: number;
27
+ /** 1-based column within the line. */
28
+ column: number;
29
+ /** `comment-like` when the next non-whitespace character is `/`. */
30
+ kind: "placeholder" | "comment-like";
31
+ }
32
+ /** A minimal mdast node: what `escapeBareBraces` reads from the parser's tree. */
33
+ export interface MdastNode {
34
+ type: string;
35
+ value?: unknown;
36
+ position?: {
37
+ start?: {
38
+ line?: number;
39
+ offset?: number;
40
+ };
41
+ end?: {
42
+ offset?: number;
43
+ };
44
+ };
45
+ children?: MdastNode[];
46
+ }
47
+ /** The site's MDX parser: throws the parser's message (carrying `line` and `reason`) on failure. */
48
+ export type MdxParse = (body: string) => MdastNode;
49
+ export type EscapeResult = {
50
+ kind: "clean";
51
+ } | {
52
+ kind: "escaped";
53
+ text: string;
54
+ count: number;
55
+ } | {
56
+ kind: "refused";
57
+ reason: string;
58
+ };
59
+ /**
60
+ * Every bare `{` in an MDX body. Skips, in this order of precedence: fenced
61
+ * code, ESM blocks, backslash escapes, inline code, JSX tags (attribute
62
+ * expressions included) and `{/* … *\/}` comments. Linear in the body.
63
+ */
64
+ export declare function findBareBraces(body: string): BareBrace[];
65
+ /** The first expression node left in the tree that is not a comment. */
66
+ export declare function firstExpression(node: MdastNode): MdastNode | undefined;
67
+ /**
68
+ * Escape every bare brace in `body`, all or nothing: `clean` when there is
69
+ * none (`parse` is not called), `refused` when a brace is a mangled comment,
70
+ * the escaped text does not parse, or an expression the escape did not reach
71
+ * remains; otherwise `escaped`.
72
+ */
73
+ export declare function escapeBareBraces(body: string, parse: MdxParse): EscapeResult;