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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|