@salesforce/storefront-next-dev 1.4.1-alpha.0 → 1.5.0-alpha.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/dist/audit/catalog/categories/best-practices/rules/deferred-ui.js +575 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.js +248 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.md +21 -0
- package/dist/audit/ui/assets/{index-BKCWlO9b.css → index-CtE_JUcs.css} +1 -1
- package/dist/audit/ui/assets/{index-CUPt8e4A.js → index-aSdbHIeq.js} +12 -12
- package/dist/audit/ui/index.html +2 -2
- package/dist/audit.js +3 -1
- package/dist/commands/deploy-cartridge.js +24 -8
- package/dist/commands/push.js +34 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/mrt/ssr.mjs +50 -50
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +56 -56
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { parse } from "parse5";
|
|
3
|
+
|
|
4
|
+
//#region src/audit/rule-helpers/deferred-ui.ts
|
|
5
|
+
const HIDDEN_ELEMENTS_MIN = 100;
|
|
6
|
+
const HIDDEN_SHARE_MIN = .2;
|
|
7
|
+
const hiddenRootSchema = z.object({
|
|
8
|
+
selector: z.string(),
|
|
9
|
+
elements: z.number().int().positive()
|
|
10
|
+
}).strict();
|
|
11
|
+
const BREAKPOINT_WIDTHS = {
|
|
12
|
+
sm: 640,
|
|
13
|
+
md: 768,
|
|
14
|
+
lg: 1024,
|
|
15
|
+
xl: 1280,
|
|
16
|
+
"2xl": 1536
|
|
17
|
+
};
|
|
18
|
+
const VIEWPORT_WIDTHS = {
|
|
19
|
+
mobile: 360,
|
|
20
|
+
desktop: 1728
|
|
21
|
+
};
|
|
22
|
+
const MAX_SOURCE_ELEMENTS = 25e4;
|
|
23
|
+
const MAX_SOURCE_NESTING = 2e3;
|
|
24
|
+
const VOID_ELEMENTS = new Set([
|
|
25
|
+
"area",
|
|
26
|
+
"base",
|
|
27
|
+
"br",
|
|
28
|
+
"col",
|
|
29
|
+
"embed",
|
|
30
|
+
"hr",
|
|
31
|
+
"img",
|
|
32
|
+
"input",
|
|
33
|
+
"link",
|
|
34
|
+
"meta",
|
|
35
|
+
"param",
|
|
36
|
+
"source",
|
|
37
|
+
"track",
|
|
38
|
+
"wbr"
|
|
39
|
+
]);
|
|
40
|
+
const OPTIONAL_END_ELEMENTS = new Set([
|
|
41
|
+
"colgroup",
|
|
42
|
+
"dd",
|
|
43
|
+
"dt",
|
|
44
|
+
"li",
|
|
45
|
+
"option",
|
|
46
|
+
"p",
|
|
47
|
+
"tbody",
|
|
48
|
+
"td",
|
|
49
|
+
"tfoot",
|
|
50
|
+
"th",
|
|
51
|
+
"thead",
|
|
52
|
+
"tr"
|
|
53
|
+
]);
|
|
54
|
+
const FOREIGN_BREAKOUT_ELEMENTS = new Set([
|
|
55
|
+
"b",
|
|
56
|
+
"big",
|
|
57
|
+
"blockquote",
|
|
58
|
+
"body",
|
|
59
|
+
"br",
|
|
60
|
+
"center",
|
|
61
|
+
"code",
|
|
62
|
+
"dd",
|
|
63
|
+
"div",
|
|
64
|
+
"dl",
|
|
65
|
+
"dt",
|
|
66
|
+
"em",
|
|
67
|
+
"embed",
|
|
68
|
+
"h1",
|
|
69
|
+
"h2",
|
|
70
|
+
"h3",
|
|
71
|
+
"h4",
|
|
72
|
+
"h5",
|
|
73
|
+
"h6",
|
|
74
|
+
"head",
|
|
75
|
+
"hr",
|
|
76
|
+
"i",
|
|
77
|
+
"img",
|
|
78
|
+
"li",
|
|
79
|
+
"listing",
|
|
80
|
+
"menu",
|
|
81
|
+
"meta",
|
|
82
|
+
"nobr",
|
|
83
|
+
"ol",
|
|
84
|
+
"p",
|
|
85
|
+
"pre",
|
|
86
|
+
"ruby",
|
|
87
|
+
"s",
|
|
88
|
+
"small",
|
|
89
|
+
"span",
|
|
90
|
+
"strike",
|
|
91
|
+
"strong",
|
|
92
|
+
"sub",
|
|
93
|
+
"sup",
|
|
94
|
+
"table",
|
|
95
|
+
"tt",
|
|
96
|
+
"u",
|
|
97
|
+
"ul",
|
|
98
|
+
"var"
|
|
99
|
+
]);
|
|
100
|
+
const DISPLAY = new Set([
|
|
101
|
+
"block",
|
|
102
|
+
"contents",
|
|
103
|
+
"flex",
|
|
104
|
+
"flow-root",
|
|
105
|
+
"grid",
|
|
106
|
+
"inline",
|
|
107
|
+
"inline-block",
|
|
108
|
+
"inline-flex",
|
|
109
|
+
"inline-grid",
|
|
110
|
+
"inline-table",
|
|
111
|
+
"list-item",
|
|
112
|
+
"ruby",
|
|
113
|
+
"ruby-base",
|
|
114
|
+
"ruby-base-container",
|
|
115
|
+
"ruby-text",
|
|
116
|
+
"ruby-text-container",
|
|
117
|
+
"table",
|
|
118
|
+
"table-caption",
|
|
119
|
+
"table-cell",
|
|
120
|
+
"table-column",
|
|
121
|
+
"table-column-group",
|
|
122
|
+
"table-footer-group",
|
|
123
|
+
"table-header-group",
|
|
124
|
+
"table-row",
|
|
125
|
+
"table-row-group"
|
|
126
|
+
]);
|
|
127
|
+
function isElement(node) {
|
|
128
|
+
return "tagName" in node;
|
|
129
|
+
}
|
|
130
|
+
function attributes(element) {
|
|
131
|
+
return new Map(element.attrs.map((attribute) => [attribute.name, attribute.value]));
|
|
132
|
+
}
|
|
133
|
+
function elementChildren(element) {
|
|
134
|
+
return element.childNodes.filter(isElement);
|
|
135
|
+
}
|
|
136
|
+
function segmentId(attrs) {
|
|
137
|
+
return /S:[0-9a-f]+$/i.test(attrs.get("id") ?? "");
|
|
138
|
+
}
|
|
139
|
+
function isTableScaffold(element) {
|
|
140
|
+
const children = elementChildren(element);
|
|
141
|
+
if (children.length !== 1) return false;
|
|
142
|
+
const child = children[0];
|
|
143
|
+
if (["tbody", "colgroup"].includes(child.tagName) && segmentId(attributes(child))) return true;
|
|
144
|
+
if (child.tagName !== "tbody") return false;
|
|
145
|
+
const rows = elementChildren(child);
|
|
146
|
+
return rows.length === 1 && rows[0].tagName === "tr" && segmentId(attributes(rows[0]));
|
|
147
|
+
}
|
|
148
|
+
function streamingSegment(element, attrs) {
|
|
149
|
+
if (segmentId(attrs)) {
|
|
150
|
+
if (["div", "table"].includes(element.tagName) && attrs.has("hidden")) return true;
|
|
151
|
+
if (["svg", "math"].includes(element.tagName) && attrs.get("aria-hidden") === "true" && /(?:^|;)\s*display\s*:\s*none\s*(?:;|$)/i.test(attrs.get("style") ?? "")) return true;
|
|
152
|
+
}
|
|
153
|
+
return element.tagName === "table" && attrs.has("hidden") && isTableScaffold(element);
|
|
154
|
+
}
|
|
155
|
+
function tagEnd(html, start) {
|
|
156
|
+
let quote = "";
|
|
157
|
+
for (let index = start; index < html.length; index += 1) {
|
|
158
|
+
const character = html[index];
|
|
159
|
+
if (quote) {
|
|
160
|
+
if (character === quote) quote = "";
|
|
161
|
+
} else if (character === "\"" || character === "'") quote = character;
|
|
162
|
+
else if (character === ">") return index;
|
|
163
|
+
}
|
|
164
|
+
return html.length - 1;
|
|
165
|
+
}
|
|
166
|
+
function sourceAttributes(source) {
|
|
167
|
+
const result = /* @__PURE__ */ new Map();
|
|
168
|
+
let index = 1;
|
|
169
|
+
while (index < source.length && !/[\s/>]/.test(source[index])) index += 1;
|
|
170
|
+
while (index < source.length) {
|
|
171
|
+
while (/\s/.test(source[index] ?? "")) index += 1;
|
|
172
|
+
if (source[index] === ">" || source[index] === "/" || source[index] === void 0) break;
|
|
173
|
+
const nameStart = index;
|
|
174
|
+
while (index < source.length && !/[\s=/><]/.test(source[index])) index += 1;
|
|
175
|
+
if (index === nameStart) {
|
|
176
|
+
index += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const name = source.slice(nameStart, index).toLowerCase();
|
|
180
|
+
while (/\s/.test(source[index] ?? "")) index += 1;
|
|
181
|
+
let value = "";
|
|
182
|
+
if (source[index] === "=") {
|
|
183
|
+
index += 1;
|
|
184
|
+
while (/\s/.test(source[index] ?? "")) index += 1;
|
|
185
|
+
const quote = source[index] === "\"" || source[index] === "'" ? source[index++] : "";
|
|
186
|
+
const valueStart = index;
|
|
187
|
+
if (quote) {
|
|
188
|
+
while (index < source.length && source[index] !== quote) index += 1;
|
|
189
|
+
value = source.slice(valueStart, index);
|
|
190
|
+
if (source[index] === quote) index += 1;
|
|
191
|
+
} else {
|
|
192
|
+
while (index < source.length && !/[\s>]/.test(source[index])) index += 1;
|
|
193
|
+
value = source.slice(valueStart, index);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (name) result.set(name, value);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
/** Bound parse work before building a DOM. This intentionally ignores optional-end-tag elements. */
|
|
201
|
+
function assertHtmlComplexity(html) {
|
|
202
|
+
const lower = html.toLowerCase();
|
|
203
|
+
const stack = [];
|
|
204
|
+
const makesHtmlChild = (parent, childName) => {
|
|
205
|
+
if (parent.namespace === "svg") return [
|
|
206
|
+
"desc",
|
|
207
|
+
"foreignobject",
|
|
208
|
+
"title"
|
|
209
|
+
].includes(parent.name);
|
|
210
|
+
if (parent.namespace !== "math") return false;
|
|
211
|
+
if (parent.name === "annotation-xml") return parent.annotationHtml;
|
|
212
|
+
return [
|
|
213
|
+
"mi",
|
|
214
|
+
"mn",
|
|
215
|
+
"mo",
|
|
216
|
+
"ms",
|
|
217
|
+
"mtext"
|
|
218
|
+
].includes(parent.name) && !["malignmark", "mglyph"].includes(childName);
|
|
219
|
+
};
|
|
220
|
+
let elements = 0;
|
|
221
|
+
let index = 0;
|
|
222
|
+
while (index < html.length) {
|
|
223
|
+
const start = html.indexOf("<", index);
|
|
224
|
+
if (start < 0) break;
|
|
225
|
+
if (html.startsWith("<!--", start)) {
|
|
226
|
+
const end$1 = html.indexOf("-->", start + 4);
|
|
227
|
+
index = end$1 < 0 ? html.length : end$1 + 3;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let cursor = start + 1;
|
|
231
|
+
const closing = html[cursor] === "/";
|
|
232
|
+
if (closing) cursor += 1;
|
|
233
|
+
while (/\s/.test(html[cursor] ?? "")) cursor += 1;
|
|
234
|
+
const match = /^[a-z][a-z0-9:-]*/i.exec(html.slice(cursor));
|
|
235
|
+
const end = tagEnd(html, cursor);
|
|
236
|
+
if (!match) {
|
|
237
|
+
index = end + 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const name = match[0].toLowerCase();
|
|
241
|
+
if (closing) {
|
|
242
|
+
let openIndex = -1;
|
|
243
|
+
for (let candidate = stack.length - 1; candidate >= 0; candidate -= 1) if (stack[candidate].name === name) {
|
|
244
|
+
openIndex = candidate;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (openIndex >= 0) stack.length = openIndex;
|
|
248
|
+
index = end + 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
elements += 1;
|
|
252
|
+
if (elements > MAX_SOURCE_ELEMENTS) throw new Error(`HTML exceeds the ${MAX_SOURCE_ELEMENTS}-element measurement limit.`);
|
|
253
|
+
if (name === "script" || name === "style") {
|
|
254
|
+
const closeStart = lower.indexOf(`</${name}`, end + 1);
|
|
255
|
+
index = closeStart < 0 ? html.length : tagEnd(html, closeStart + 2) + 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const source = html.slice(start, end + 1);
|
|
259
|
+
const sourceAttrs = sourceAttributes(source);
|
|
260
|
+
let parent = stack.at(-1);
|
|
261
|
+
const fontBreakout = name === "font" && [
|
|
262
|
+
"color",
|
|
263
|
+
"face",
|
|
264
|
+
"size"
|
|
265
|
+
].some((attribute) => sourceAttrs.has(attribute));
|
|
266
|
+
if (parent && parent.namespace !== "html" && !makesHtmlChild(parent, name) && (FOREIGN_BREAKOUT_ELEMENTS.has(name) || fontBreakout)) while ((parent = stack.at(-1)) && parent.namespace !== "html" && !makesHtmlChild(parent, name)) stack.pop();
|
|
267
|
+
parent = stack.at(-1);
|
|
268
|
+
let namespace = (parent ? makesHtmlChild(parent, name) : false) ? "html" : parent?.namespace ?? "html";
|
|
269
|
+
if (namespace === "html" && name === "svg") namespace = "svg";
|
|
270
|
+
else if (namespace === "html" && name === "math") namespace = "math";
|
|
271
|
+
const foreignSelfClosing = namespace !== "html" && /\/\s*>$/.test(source);
|
|
272
|
+
const htmlVoid = namespace === "html" && VOID_ELEMENTS.has(name);
|
|
273
|
+
const optionalEnd = namespace === "html" && OPTIONAL_END_ELEMENTS.has(name);
|
|
274
|
+
if (!foreignSelfClosing && !htmlVoid && !optionalEnd) {
|
|
275
|
+
const encoding = name === "annotation-xml" ? sourceAttrs.get("encoding")?.toLowerCase() : void 0;
|
|
276
|
+
const annotationHtml = namespace === "math" && name === "annotation-xml" && (encoding === "text/html" || encoding === "application/xhtml+xml");
|
|
277
|
+
stack.push({
|
|
278
|
+
name,
|
|
279
|
+
namespace,
|
|
280
|
+
annotationHtml
|
|
281
|
+
});
|
|
282
|
+
if (stack.length > MAX_SOURCE_NESTING) throw new Error(`HTML exceeds the ${MAX_SOURCE_NESTING}-level nesting limit.`);
|
|
283
|
+
}
|
|
284
|
+
index = end + 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function isBreakpoint(value) {
|
|
288
|
+
return Object.hasOwn(BREAKPOINT_WIDTHS, value);
|
|
289
|
+
}
|
|
290
|
+
function breakpointWidth(value) {
|
|
291
|
+
if (isBreakpoint(value)) return BREAKPOINT_WIDTHS[value];
|
|
292
|
+
const arbitrary = /^\[(\d+(?:\.\d+)?)(px|rem)\]$/.exec(value);
|
|
293
|
+
if (!arbitrary) return void 0;
|
|
294
|
+
const width = Number(arbitrary[1]);
|
|
295
|
+
return arbitrary[2] === "rem" ? width * 16 : width;
|
|
296
|
+
}
|
|
297
|
+
function visibilityRule(className) {
|
|
298
|
+
const parts = className.split(":");
|
|
299
|
+
const utility = parts.pop();
|
|
300
|
+
if (utility !== "hidden" && !DISPLAY.has(utility)) return void 0;
|
|
301
|
+
let min = 0;
|
|
302
|
+
let max = Number.POSITIVE_INFINITY;
|
|
303
|
+
for (const variant of parts) {
|
|
304
|
+
const maxVariant = variant.startsWith("max-");
|
|
305
|
+
const minVariant = variant.startsWith("min-");
|
|
306
|
+
const width = breakpointWidth(maxVariant || minVariant ? variant.slice(4) : variant);
|
|
307
|
+
if (width === void 0) return void 0;
|
|
308
|
+
if (maxVariant) max = Math.min(max, width);
|
|
309
|
+
else min = Math.max(min, width);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
min,
|
|
313
|
+
max,
|
|
314
|
+
utility
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function inlineDisplay(style) {
|
|
318
|
+
let selected;
|
|
319
|
+
for (const declaration of style.split(";")) {
|
|
320
|
+
const match = /^\s*display\s*:\s*([^!]+?)(\s*!important)?\s*$/i.exec(declaration);
|
|
321
|
+
if (!match) continue;
|
|
322
|
+
const value = match[1].trim().toLowerCase();
|
|
323
|
+
if (value !== "none" && !DISPLAY.has(value)) continue;
|
|
324
|
+
const candidate = {
|
|
325
|
+
hidden: value === "none",
|
|
326
|
+
important: match[2] !== void 0
|
|
327
|
+
};
|
|
328
|
+
if (!selected || candidate.important || !selected.important) selected = candidate;
|
|
329
|
+
}
|
|
330
|
+
return selected?.hidden;
|
|
331
|
+
}
|
|
332
|
+
function hiddenAt(element, viewport) {
|
|
333
|
+
const attrs = attributes(element);
|
|
334
|
+
const classes = (attrs.get("class") ?? "").split(/\s+/).filter(Boolean);
|
|
335
|
+
const streaming = streamingSegment(element, attrs);
|
|
336
|
+
const hiddenAttribute = attrs.has("hidden") && !streaming;
|
|
337
|
+
const inlineHidden = streaming ? void 0 : inlineDisplay(attrs.get("style") ?? "");
|
|
338
|
+
if (inlineHidden !== void 0) return inlineHidden;
|
|
339
|
+
if (hiddenAttribute) return true;
|
|
340
|
+
const width = VIEWPORT_WIDTHS[viewport];
|
|
341
|
+
const rules = classes.map(visibilityRule).filter((rule) => rule !== void 0).filter((rule) => width >= rule.min && width < rule.max).slice().sort((a, b) => a.min - b.min || b.max - a.max);
|
|
342
|
+
let hidden = false;
|
|
343
|
+
for (let index = 0; index < rules.length;) {
|
|
344
|
+
const rule = rules[index];
|
|
345
|
+
const sameRange = [];
|
|
346
|
+
while (index < rules.length && rules[index].min === rule.min && rules[index].max === rule.max) sameRange.push(rules[index++]);
|
|
347
|
+
hidden = sameRange.some((candidate) => candidate.utility === "hidden");
|
|
348
|
+
}
|
|
349
|
+
return hidden;
|
|
350
|
+
}
|
|
351
|
+
function selector(element) {
|
|
352
|
+
const attrs = attributes(element);
|
|
353
|
+
const id = attrs.get("id");
|
|
354
|
+
if (id) return `${element.tagName}#${id}`.slice(0, 160);
|
|
355
|
+
const classes = (attrs.get("class") ?? "").split(/\s+/).filter(Boolean).slice(0, 3);
|
|
356
|
+
return `${element.tagName}${classes.map((className) => `.${className}`).join("")}`.slice(0, 160);
|
|
357
|
+
}
|
|
358
|
+
function elementCount(element) {
|
|
359
|
+
let count = 0;
|
|
360
|
+
const stack = [element];
|
|
361
|
+
while (stack.length) {
|
|
362
|
+
const current = stack.pop();
|
|
363
|
+
count += 1;
|
|
364
|
+
for (const child of elementChildren(current)) stack.push(child);
|
|
365
|
+
}
|
|
366
|
+
return count;
|
|
367
|
+
}
|
|
368
|
+
function body(document) {
|
|
369
|
+
const stack = [document];
|
|
370
|
+
while (stack.length) {
|
|
371
|
+
const node = stack.pop();
|
|
372
|
+
if (isElement(node) && node.tagName === "body") return node;
|
|
373
|
+
if ("childNodes" in node) for (let index = node.childNodes.length - 1; index >= 0; index -= 1) stack.push(node.childNodes[index]);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function viewportMeasurement(root, viewport, bodyElements) {
|
|
377
|
+
let hiddenElements = 0;
|
|
378
|
+
const roots = [];
|
|
379
|
+
const stack = [{
|
|
380
|
+
element: root,
|
|
381
|
+
inheritedHidden: false
|
|
382
|
+
}];
|
|
383
|
+
while (stack.length) {
|
|
384
|
+
const { element, inheritedHidden } = stack.pop();
|
|
385
|
+
const hidden = inheritedHidden || hiddenAt(element, viewport);
|
|
386
|
+
if (hidden) hiddenElements += 1;
|
|
387
|
+
if (hidden && !inheritedHidden) roots.push({
|
|
388
|
+
selector: selector(element),
|
|
389
|
+
elements: elementCount(element)
|
|
390
|
+
});
|
|
391
|
+
const children = elementChildren(element);
|
|
392
|
+
for (let index = children.length - 1; index >= 0; index -= 1) stack.push({
|
|
393
|
+
element: children[index],
|
|
394
|
+
inheritedHidden: hidden
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
hiddenElements,
|
|
399
|
+
share: bodyElements ? hiddenElements / bodyElements : 0,
|
|
400
|
+
largestRoots: roots.slice().sort((a, b) => b.elements - a.elements).slice(0, 5)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** Measure hidden element trees in server-rendered HTML at the calibrated 360 px and 1728 px viewports. */
|
|
404
|
+
function measureHiddenMarkup(html) {
|
|
405
|
+
assertHtmlComplexity(html);
|
|
406
|
+
const root = body(parse(html));
|
|
407
|
+
if (!root) return {
|
|
408
|
+
bodyElements: 0,
|
|
409
|
+
mobile: {
|
|
410
|
+
hiddenElements: 0,
|
|
411
|
+
share: 0,
|
|
412
|
+
largestRoots: []
|
|
413
|
+
},
|
|
414
|
+
desktop: {
|
|
415
|
+
hiddenElements: 0,
|
|
416
|
+
share: 0,
|
|
417
|
+
largestRoots: []
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
const bodyElements = elementCount(root);
|
|
421
|
+
return {
|
|
422
|
+
bodyElements,
|
|
423
|
+
mobile: viewportMeasurement(root, "mobile", bodyElements),
|
|
424
|
+
desktop: viewportMeasurement(root, "desktop", bodyElements)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function isHiddenMarkupCandidate(hiddenElements, bodyElements) {
|
|
428
|
+
return hiddenElements >= HIDDEN_ELEMENTS_MIN && bodyElements > 0 && hiddenElements / bodyElements >= HIDDEN_SHARE_MIN;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/audit/catalog/categories/best-practices/rules/deferred-ui.ts
|
|
433
|
+
const FORM_FACTORS = ["mobile", "desktop"];
|
|
434
|
+
const PAGE_CONCURRENCY = 2;
|
|
435
|
+
const SAFE_RUN_ID = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
436
|
+
const summarySchema = z.object({ runs: z.array(z.object({
|
|
437
|
+
runId: z.string().optional(),
|
|
438
|
+
formFactor: z.string().optional(),
|
|
439
|
+
cacheState: z.string().optional(),
|
|
440
|
+
isMedian: z.boolean().optional()
|
|
441
|
+
}).passthrough()).optional().default([]) }).passthrough();
|
|
442
|
+
const cellSchema = z.object({
|
|
443
|
+
cell: z.enum(["mobile-cold", "desktop-cold"]),
|
|
444
|
+
runId: z.string(),
|
|
445
|
+
artifact: z.string(),
|
|
446
|
+
bodyElements: z.number().int().positive(),
|
|
447
|
+
hiddenElements: z.number().int().nonnegative(),
|
|
448
|
+
hiddenShare: z.number().min(0).max(1),
|
|
449
|
+
candidate: z.boolean(),
|
|
450
|
+
largestHiddenRoots: z.array(hiddenRootSchema)
|
|
451
|
+
}).strict();
|
|
452
|
+
const dataSchema = z.object({
|
|
453
|
+
thresholds: z.object({
|
|
454
|
+
hiddenElements: z.number().int().positive(),
|
|
455
|
+
hiddenShare: z.number().min(0).max(1)
|
|
456
|
+
}).strict(),
|
|
457
|
+
pages: z.array(z.object({
|
|
458
|
+
page: z.string(),
|
|
459
|
+
cells: z.array(cellSchema)
|
|
460
|
+
}).strict()),
|
|
461
|
+
skipped: z.array(z.object({
|
|
462
|
+
page: z.string(),
|
|
463
|
+
cell: z.string(),
|
|
464
|
+
reason: z.string()
|
|
465
|
+
}).strict())
|
|
466
|
+
}).strict();
|
|
467
|
+
function runIdFor(summary, formFactor) {
|
|
468
|
+
const runs = (summary?.runs ?? []).filter((run$1) => run$1.runId && SAFE_RUN_ID.test(run$1.runId) && run$1.formFactor === formFactor && run$1.cacheState === "cold");
|
|
469
|
+
return (runs.find((run$1) => run$1.isMedian) ?? runs[0])?.runId ?? `${formFactor}-cold-1`;
|
|
470
|
+
}
|
|
471
|
+
async function measureCell(playhouse, page, summary, formFactor) {
|
|
472
|
+
const cell = `${formFactor}-cold`;
|
|
473
|
+
const runId = runIdFor(summary, formFactor);
|
|
474
|
+
const artifact = `runs/${runId}/main-document.html`;
|
|
475
|
+
let html;
|
|
476
|
+
try {
|
|
477
|
+
html = await playhouse.readText(page, artifact, 10 * 1024 * 1024);
|
|
478
|
+
} catch {
|
|
479
|
+
return {
|
|
480
|
+
cell,
|
|
481
|
+
reason: `${artifact} could not be read safely.`
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (html === void 0) return {
|
|
485
|
+
cell,
|
|
486
|
+
reason: `${artifact} is missing.`
|
|
487
|
+
};
|
|
488
|
+
let measured;
|
|
489
|
+
try {
|
|
490
|
+
measured = measureHiddenMarkup(html);
|
|
491
|
+
} catch {
|
|
492
|
+
return {
|
|
493
|
+
cell,
|
|
494
|
+
reason: `${artifact} could not be measured safely.`
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (!measured.bodyElements) return {
|
|
498
|
+
cell,
|
|
499
|
+
reason: `${artifact} has no body elements.`
|
|
500
|
+
};
|
|
501
|
+
const viewport = measured[formFactor];
|
|
502
|
+
return {
|
|
503
|
+
cell,
|
|
504
|
+
runId,
|
|
505
|
+
artifact,
|
|
506
|
+
bodyElements: measured.bodyElements,
|
|
507
|
+
hiddenElements: viewport.hiddenElements,
|
|
508
|
+
hiddenShare: viewport.share,
|
|
509
|
+
candidate: isHiddenMarkupCandidate(viewport.hiddenElements, measured.bodyElements),
|
|
510
|
+
largestHiddenRoots: viewport.largestRoots
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const run = async ({ pages, playhouse }) => {
|
|
514
|
+
const data = {
|
|
515
|
+
thresholds: {
|
|
516
|
+
hiddenElements: HIDDEN_ELEMENTS_MIN,
|
|
517
|
+
hiddenShare: HIDDEN_SHARE_MIN
|
|
518
|
+
},
|
|
519
|
+
pages: [],
|
|
520
|
+
skipped: []
|
|
521
|
+
};
|
|
522
|
+
const evidence = [];
|
|
523
|
+
let candidate = false;
|
|
524
|
+
let incomplete = false;
|
|
525
|
+
const measuredPages = [];
|
|
526
|
+
for (let offset = 0; offset < pages.length; offset += PAGE_CONCURRENCY) {
|
|
527
|
+
const batch = await Promise.all(pages.slice(offset, offset + PAGE_CONCURRENCY).map(async (page) => {
|
|
528
|
+
const summary = await playhouse.readJson(page.label, "summary.json", summarySchema);
|
|
529
|
+
return {
|
|
530
|
+
page,
|
|
531
|
+
cells: await Promise.all(FORM_FACTORS.map((formFactor) => measureCell(playhouse, page.label, summary, formFactor)))
|
|
532
|
+
};
|
|
533
|
+
}));
|
|
534
|
+
measuredPages.push(...batch);
|
|
535
|
+
}
|
|
536
|
+
for (const { page, cells: results } of measuredPages) {
|
|
537
|
+
const cells = [];
|
|
538
|
+
for (const result of results) {
|
|
539
|
+
if ("reason" in result) {
|
|
540
|
+
incomplete = true;
|
|
541
|
+
data.skipped.push({
|
|
542
|
+
page: page.label,
|
|
543
|
+
cell: result.cell,
|
|
544
|
+
reason: result.reason
|
|
545
|
+
});
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
cells.push(result);
|
|
549
|
+
candidate ||= result.candidate;
|
|
550
|
+
evidence.push({
|
|
551
|
+
type: "playhouse",
|
|
552
|
+
page: page.label,
|
|
553
|
+
artifact: result.artifact,
|
|
554
|
+
value: `${result.cell}: ${result.hiddenElements} of ${result.bodyElements} body elements hidden (${Math.round(result.hiddenShare * 100)}%; candidate gate ${HIDDEN_ELEMENTS_MIN} and ${Math.round(HIDDEN_SHARE_MIN * 100)}%).`
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
data.pages.push({
|
|
558
|
+
page: page.label,
|
|
559
|
+
cells
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (!evidence.length || incomplete || candidate) return { data };
|
|
563
|
+
return {
|
|
564
|
+
data,
|
|
565
|
+
decision: {
|
|
566
|
+
status: "pass",
|
|
567
|
+
summary: `Server-rendered hidden markup stays below the candidate gate on every measured page.`,
|
|
568
|
+
evidence
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
};
|
|
572
|
+
var deferred_ui_default = run;
|
|
573
|
+
|
|
574
|
+
//#endregion
|
|
575
|
+
export { dataSchema, deferred_ui_default as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: best-practices/deferred-ui
|
|
3
|
+
title: Hidden Server-Rendered UI
|
|
4
|
+
severity: medium
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [playhouse.main-document, playhouse.network-har, source.code]
|
|
7
|
+
---
|
|
8
|
+
## Intent
|
|
9
|
+
Finds one specific deferred-UI opportunity: pages that server-render large regions hidden at a target viewport even though shoppers may never reveal them. CSS hiding can avoid layout and paint for a subtree, but it does not avoid transferring and parsing its HTML, constructing its DOM, hydrating it, or starting work requested by the hidden components.
|
|
10
|
+
## Inspect
|
|
11
|
+
The rule script measures the archived server HTML for the median mobile and desktop cold-cache runs. It counts every body element hidden by an ancestor or by the `hidden` attribute, inline `display:none`, base Tailwind `hidden` at 360 px, or breakpoint `hidden` at 1728 px. React streaming containers such as `<div hidden id="S:1">` are not hidden UI. A cell is a candidate only when at least 100 elements and at least 20% of body elements are hidden. For a candidate, inspect the listed largest hidden roots and trace them to current source. Do not count header navigation/search responsive pairs or ordinary carousel slides. Check the matching HAR when a hidden duplicate may start requests during load. Visible below-the-fold UI, such as a product-recommendation carousel, is outside this check because server HTML alone cannot establish its viewport position or whether delaying it preserves UX and SEO; evaluate that separately from source plus viewport/runtime evidence.
|
|
12
|
+
## Evaluate
|
|
13
|
+
Pass this hidden-markup check when every measured mobile and desktop cell is below the candidate gate. This pass does not claim that the page has no other client-rendering or below-the-fold deferral opportunities. For a candidate, report a finding only when current source attributes at least 100 hidden elements to other UI—such as duplicated mobile and desktop content—or when a hidden duplicate starts requests during initial load. Report `not-verified` when the source is missing or stale, attribution does not reach 100 elements, or required capture evidence is missing. A candidate is not a finding by itself.
|
|
14
|
+
## Evidence
|
|
15
|
+
Cite the page, cell, archived HTML artifact, hidden count and share, the largest hidden-root selector, and the source file and line that renders the region. For load-time work, also cite the HAR request and the source connection between that request and the hidden region.
|
|
16
|
+
## Remediation
|
|
17
|
+
Render interaction-only content when it opens, or render one responsive structure and adapt its layout with CSS instead of emitting separate mobile and desktop copies. Keep server-rendered navigation links crawlable. Avoid starting image or data requests for hidden duplicate regions. Re-run both mobile and desktop captures to confirm the hidden markup falls below the gate.
|
|
18
|
+
## Examples
|
|
19
|
+
Compliant for this check: one product gallery changes layout responsively without duplicate markup. Problematic: the HTML contains separate mobile and desktop galleries totaling 120 hidden elements. Ambiguous: 130 hidden elements are detected, but the available source cannot identify whether they belong to an allowed navigation pair. Out of scope: a visible product-recommendation carousel far below the fold may still be worth client-rendering later, but that decision needs viewport/runtime and source evidence rather than hidden-markup counts.
|
|
20
|
+
|