create-ailk 0.31.0 → 0.33.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 (86) hide show
  1. package/README.md +50 -8
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cutover.d.ts +6 -1
  7. package/dist/cutover.js +23 -6
  8. package/dist/export-content.d.ts +6 -3
  9. package/dist/export-content.js +79 -10
  10. package/dist/language-import.d.ts +16 -3
  11. package/dist/language-import.js +27 -11
  12. package/dist/lib/import-mdx.d.ts +42 -0
  13. package/dist/lib/import-mdx.js +584 -65
  14. package/dist/lib/render-comparison.d.ts +51 -0
  15. package/dist/lib/render-comparison.js +205 -6
  16. package/dist/mdx-braces.d.ts +73 -0
  17. package/dist/mdx-braces.js +285 -0
  18. package/dist/migrate-content.d.ts +1 -1
  19. package/dist/migrate-content.js +4 -2
  20. package/dist/programmatic.js +2 -8
  21. package/dist/seed-flows.d.ts +1 -1
  22. package/dist/seed-flows.js +9 -4
  23. package/dist/site-manifest.js +6 -38
  24. package/dist/site-roster.d.ts +46 -3
  25. package/dist/site-roster.js +124 -4
  26. package/dist/surfaces.d.ts +8 -0
  27. package/dist/surfaces.js +17 -0
  28. package/dist/update.d.ts +21 -1
  29. package/dist/update.js +142 -11
  30. package/package.json +3 -3
  31. package/templates/.github/workflows/ci.yml +2 -0
  32. package/templates/apps/api/CLAUDE.md +7 -5
  33. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  34. package/templates/apps/mcp/.env.example +7 -1
  35. package/templates/apps/web/__tests__/setup.ts +9 -0
  36. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  37. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  38. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  39. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  40. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  43. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  44. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  45. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  46. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  47. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  48. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  49. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  50. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  51. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  52. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  53. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  54. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  55. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  56. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  57. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  58. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  59. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  60. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  61. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  67. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  68. package/templates/apps/web/auth.ts +22 -6
  69. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  70. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  71. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  72. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  73. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  74. package/templates/apps/web/lib/account-config.ts +52 -14
  75. package/templates/apps/web/lib/auth-email.ts +87 -5
  76. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  77. package/templates/apps/web/messages/en.json +26 -1
  78. package/templates/apps/web/src/lib/routes.ts +5 -3
  79. package/templates/database/seed.ts +7 -7
  80. package/templates/package.json +2 -1
  81. package/templates/tools/body-heading.ts +440 -0
  82. package/templates/tools/check-mdx-body.ts +123 -0
  83. package/templates/tools/mdx-braces.ts +303 -0
  84. package/versions.json +48 -48
  85. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  86. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -14,6 +14,47 @@ export declare const IN_FLIGHT = 4;
14
14
  export declare const TEXT_EXPRESSION = "(() => { const root = document.querySelector(\"main\") ?? document.body; return root ? root.innerText : \"\"; })()";
15
15
  /** Every JSON-LD script's text, in document order. */
16
16
  export declare const JSONLD_EXPRESSION = "Array.from(document.querySelectorAll('script[type=\"application/ld+json\"]'), (s) => s.textContent ?? \"\")";
17
+ /**
18
+ * A Language item #10734's kind pull lists on /faq or /glossary after the pins,
19
+ * which step 1 reported as a top-up: removed from the after side only.
20
+ */
21
+ export interface ExcludedItem {
22
+ kind: "faq" | "term";
23
+ slug: string;
24
+ /** The question or term the read renders it with. */
25
+ name: string;
26
+ }
27
+ /** `removed` names every excluded key; `refused` says why nothing was excluded. */
28
+ export type Exclusion = {
29
+ removed: string[];
30
+ } | {
31
+ refused: string;
32
+ };
33
+ /**
34
+ * Plain JS run in the after page, so no transform reaches it. Under the kind
35
+ * pull's position rule (#10734 D3) it removes exactly the reported items, or
36
+ * removes nothing and answers why: FAQs are the tail of the last `faq`
37
+ * section's items, matched by question; terms are the tail of their letter
38
+ * group in the last `glossary-index` section, matched by slug, and a letter
39
+ * group they leave empty goes with its nav link. A reason never carries an
40
+ * item's words.
41
+ */
42
+ export declare const EXCLUDE_SOURCE = "function (document, exclude) {\n var norm = function (s) { return String(s).replace(/\\s+/g, \" \").trim(); };\n var firstLine = function (el) {\n var lines = String(el.innerText || \"\").split(\"\\n\");\n for (var i = 0; i < lines.length; i++) if (norm(lines[i]) !== \"\") return norm(lines[i]);\n return \"\";\n };\n var last = function (selector) {\n var all = document.querySelectorAll(selector);\n return all.length === 0 ? null : all[all.length - 1];\n };\n var byTestId = function (root, id) {\n return Array.from(root.querySelectorAll(\"[data-testid]\")).filter(function (el) { return el.getAttribute(\"data-testid\") === id; });\n };\n var faqs = exclude.filter(function (t) { return t.kind === \"faq\"; });\n var terms = exclude.filter(function (t) { return t.kind === \"term\"; });\n var items = [];\n if (faqs.length > 0) {\n var faq = last('[data-section=\"faq\"]');\n if (faq === null) return { refused: \"no faq section\" };\n var all = Array.from(faq.querySelectorAll('[data-testid=\"faq-item-expanded\"], [data-testid=\"faq-item-accordion\"], [data-testid=\"faq-item-grid\"]'));\n var left = faqs.slice();\n for (var i = Math.max(0, all.length - faqs.length); i < all.length; i++) {\n var line = firstLine(all[i]);\n var at = left.findIndex(function (t) { return norm(t.name) === line; });\n if (at < 0) return { refused: \"item \" + (i + 1) + \" of \" + all.length + \" in the last faq section is not a reported top-up\" };\n left.splice(at, 1);\n items.push(all[i]);\n }\n if (left.length > 0) return { refused: \"the last faq section has \" + all.length + \" item(s), fewer than its \" + faqs.length + \" top-up(s)\" };\n }\n var glossary = null;\n var termItems = [];\n if (terms.length > 0) {\n glossary = last('[data-section=\"glossary-index\"]');\n if (glossary === null) return { refused: \"no glossary-index section\" };\n for (var j = 0; j < terms.length; j++) {\n var links = byTestId(glossary, \"glossary-index-term-\" + terms[j].slug);\n var li = links.length === 1 ? links[0].closest(\"li\") : null;\n if (li === null) return { refused: \"term \" + terms[j].slug + \" is not shown once in the last glossary-index section\" };\n termItems.push(li);\n }\n for (var k = 0; k < terms.length; k++) {\n var siblings = Array.from(termItems[k].parentElement.children);\n var later = siblings.slice(siblings.indexOf(termItems[k]) + 1);\n if (later.some(function (el) { return termItems.indexOf(el) < 0; })) {\n return { refused: \"term \" + terms[k].slug + \" is not at the tail of its letter group\" };\n }\n }\n }\n items.forEach(function (el) { el.remove(); });\n var lists = [];\n termItems.forEach(function (el) {\n if (lists.indexOf(el.parentElement) < 0) lists.push(el.parentElement);\n el.remove();\n });\n lists.forEach(function (ul) {\n if (ul.children.length > 0) return;\n var group = ul.closest(\"section[data-letter]\");\n if (group === null) return;\n var link = byTestId(glossary, \"glossary-index-letter-\" + group.getAttribute(\"data-letter\"))[0];\n group.remove();\n if (link === undefined) return;\n var nav = link.closest(\"nav\");\n link.remove();\n if (nav !== null && nav.children.length === 0) nav.remove();\n });\n return { removed: exclude.map(function (t) { return t.kind + \"/\" + t.slug; }) };\n}";
43
+ /** The expression the after page evaluates: `EXCLUDE_SOURCE` called on a JSON literal. */
44
+ export declare function exclusionExpression(exclude: readonly ExcludedItem[]): string;
45
+ /**
46
+ * The after side's JSON-LD without the top-ups' nodes: per kind, the one array
47
+ * holding a matching node (a `Question` by `name`, a `DefinedTerm` by
48
+ * `termCode`) must end in exactly those nodes and keep one other. The script
49
+ * that changed is re-serialized with `serializeJsonLd`'s rule
50
+ * (`packages/schema/src/json-ld.ts`, restated: no runtime import); every other
51
+ * keeps its string. Anything else is `refused`, with the scripts untouched.
52
+ */
53
+ export declare function excludeJsonLd(scripts: readonly string[], exclude: readonly ExcludedItem[]): {
54
+ scripts: string[];
55
+ } | {
56
+ refused: string;
57
+ };
17
58
  /** An image as 8-bit RGBA, row-major. */
18
59
  export interface RgbaImage {
19
60
  width: number;
@@ -96,16 +137,22 @@ export interface ViewportComparison {
96
137
  jsonLd: JsonLdVerdict;
97
138
  /** Absent when the comparison takes no screenshots (the verify step). */
98
139
  pixels?: PixelVerdict;
140
+ /** `afterFull`: the after page before its exclusion, for the eye read. */
99
141
  shots?: {
100
142
  before: string;
101
143
  after: string;
144
+ afterFull?: string;
102
145
  };
146
+ /** Present only on a page with top-ups whose after side rendered (#10753). */
147
+ exclusion?: Exclusion;
103
148
  }
104
149
  export interface PageComparison {
105
150
  path: string;
106
151
  n: number;
107
152
  pass: boolean;
108
153
  viewports: ViewportComparison[];
154
+ /** The excluded top-ups' keys, when every viewport removed them (#10753). */
155
+ excluded?: string[];
109
156
  }
110
157
  interface Side {
111
158
  origin: string;
@@ -122,6 +169,8 @@ export interface ComparePageOptions {
122
169
  screenshots: boolean;
123
170
  /** Stores one screenshot under `shots/<name>`. */
124
171
  writeShot: (name: string, png: Buffer) => void;
172
+ /** The top-ups removed from the after side before it is read (#10753); `[]` is none. */
173
+ exclude?: readonly ExcludedItem[];
125
174
  }
126
175
  /** One frozen URL at every viewport, before against after. */
127
176
  export declare function comparePage(o: ComparePageOptions): Promise<PageComparison>;
@@ -192,6 +241,8 @@ export interface RunComparisonOptions {
192
241
  dir: string;
193
242
  fetch: typeof globalThis.fetch;
194
243
  onLine?: (line: string) => void;
244
+ /** Per frozen page path, the top-ups its after side leaves out (#10753). */
245
+ exclude?: Readonly<Record<string, readonly ExcludedItem[]>>;
195
246
  }
196
247
  /** Every page compared, the sweep run, the evidence written; `pass` gates the step. */
197
248
  export declare function runComparison(o: RunComparisonOptions): Promise<ComparisonReport & {
@@ -35,6 +35,153 @@ export const IN_FLIGHT = 4;
35
35
  export const TEXT_EXPRESSION = '(() => { const root = document.querySelector("main") ?? document.body; return root ? root.innerText : ""; })()';
36
36
  /** Every JSON-LD script's text, in document order. */
37
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
+ }
38
185
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
39
186
  function paeth(a, b, c) {
40
187
  const p = a + b - c;
@@ -179,7 +326,7 @@ function pixelVerdict(before, after, tolerance) {
179
326
  function passes(v) {
180
327
  return v.verdict === "same" || v.verdict === "within-tolerance";
181
328
  }
182
- async function renderSide(side, path, viewport, screenshot) {
329
+ async function renderSide(side, path, viewport, screenshot, exclude) {
183
330
  const failed = (render) => ({ render, text: "", jsonLd: [] });
184
331
  await side.page.setViewportSize({ width: viewport.width, height: viewport.height });
185
332
  let status;
@@ -198,29 +345,55 @@ async function renderSide(side, path, viewport, screenshot) {
198
345
  const settled = new URL(side.page.url()).pathname;
199
346
  if (settled !== path)
200
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
+ }
201
361
  const text = await side.page.evaluate(TEXT_EXPRESSION);
202
- const jsonLd = await side.page.evaluate(JSONLD_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
+ }
203
372
  return {
204
373
  render: "ok",
205
374
  text: typeof text === "string" ? text : "",
206
- jsonLd: Array.isArray(jsonLd) ? jsonLd.map(String) : [],
375
+ jsonLd,
207
376
  ...(screenshot && {
208
377
  png: await side.page.screenshot({ fullPage: true, animations: "disabled" }),
209
378
  }),
379
+ ...(full !== undefined && { full }),
380
+ ...(exclusion !== undefined && { exclusion }),
210
381
  };
211
382
  }
212
383
  /** One frozen URL at every viewport, before against after. */
213
384
  export async function comparePage(o) {
214
385
  const viewports = [];
386
+ const exclude = o.exclude !== undefined && o.exclude.length > 0 ? o.exclude : undefined;
215
387
  for (const viewport of VIEWPORTS) {
216
388
  const before = await renderSide(o.before, o.path, viewport, o.screenshots);
217
- const after = await renderSide(o.after, o.path, viewport, o.screenshots);
389
+ const after = await renderSide(o.after, o.path, viewport, o.screenshots, exclude);
218
390
  const rendered = before.render === "ok" && after.render === "ok";
219
391
  const result = {
220
392
  viewport: viewport.name,
221
393
  render: { before: before.render, after: after.render },
222
394
  text: rendered ? compareText(before.text, after.text) : { verdict: "not-compared" },
223
395
  jsonLd: rendered ? compareJsonLd(before.jsonLd, after.jsonLd) : { verdict: "not-compared" },
396
+ ...(after.exclusion !== undefined && { exclusion: after.exclusion }),
224
397
  };
225
398
  if (o.screenshots) {
226
399
  if (before.png !== undefined && after.png !== undefined) {
@@ -230,6 +403,10 @@ export async function comparePage(o) {
230
403
  };
231
404
  o.writeShot(shots.before, before.png);
232
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
+ }
233
410
  result.pixels = pixelVerdict(before.png, after.png, o.tolerance);
234
411
  result.shots = shots;
235
412
  }
@@ -243,8 +420,12 @@ export async function comparePage(o) {
243
420
  v.render.after === "ok" &&
244
421
  passes(v.text) &&
245
422
  passes(v.jsonLd) &&
246
- (v.pixels === undefined || passes(v.pixels)));
247
- return { path: o.path, n: o.n, pass, viewports };
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 }) };
248
429
  }
249
430
  // ── The sweep (D9) ──────────────────────────────────────────────────────────
250
431
  /** `fn` over `items`, at most `limit` at once; results in input order. */
@@ -299,6 +480,22 @@ export function comparisonMarkdown(report) {
299
480
  lines.push(`| ${page.n} | \`${page.path}\` | ${v.viewport} | ${render.replace(/\|/g, "\\|")} | ${cell(v.text)} | ${cell(v.jsonLd)} | ${cell(v.pixels)} | ${shots} |`);
300
481
  }
301
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
+ }
302
499
  const failed = report.sweep.results.filter((r) => r.status !== 200);
303
500
  lines.push("", `## Sweep: ${report.sweep.results.length} URL(s) on ${report.after}, ${failed.length} not 200`, "", ...failed.map((r) => `- \`${r.path}\` → ${r.status}`));
304
501
  return `${lines.join("\n")}\n`;
@@ -346,6 +543,7 @@ export async function runComparison(o) {
346
543
  : route.continue());
347
544
  }
348
545
  for (const [i, path] of o.pages.entries()) {
546
+ const exclude = o.exclude !== undefined && Object.hasOwn(o.exclude, path) ? o.exclude[path] : undefined;
349
547
  const result = await comparePage({
350
548
  path,
351
549
  n: i + 1,
@@ -354,6 +552,7 @@ export async function runComparison(o) {
354
552
  tolerance: o.tolerance,
355
553
  screenshots: o.screenshots,
356
554
  writeShot: (name, png) => writeFileSync(join(shotsDir, name), png),
555
+ ...(exclude !== undefined && { exclude }),
357
556
  });
358
557
  o.onLine?.(`${result.pass ? "same" : "DIFFERS"} ${path}`);
359
558
  pages.push(result);
@@ -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;