@salesforce/storefront-next-dev 1.4.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.
Files changed (140) hide show
  1. package/README.md +13 -2
  2. package/dist/assets.js +25 -0
  3. package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
  4. package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
  5. package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.js +575 -0
  6. package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.md +20 -0
  7. package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
  8. package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
  9. package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
  10. package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
  11. package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
  12. package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
  13. package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
  14. package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
  15. package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
  16. package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
  17. package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
  18. package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
  19. package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
  20. package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
  21. package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
  22. package/dist/audit/catalog/categories/performance/rules/font-display.js +248 -0
  23. package/dist/audit/catalog/categories/performance/rules/font-display.md +21 -0
  24. package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
  25. package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
  26. package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
  27. package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
  28. package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
  29. package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
  30. package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
  31. package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
  32. package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
  33. package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
  34. package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
  35. package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
  36. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
  37. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
  38. package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
  39. package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
  40. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
  41. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
  42. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
  43. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
  44. package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
  45. package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
  46. package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
  47. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
  48. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
  49. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
  50. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
  51. package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
  52. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
  53. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
  54. package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
  55. package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
  56. package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
  57. package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
  58. package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
  59. package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
  60. package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
  61. package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
  62. package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
  63. package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
  64. package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
  65. package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
  66. package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
  67. package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
  68. package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
  69. package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
  70. package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
  71. package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
  72. package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
  73. package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
  74. package/dist/audit/instructions/evaluate-rule.md +42 -0
  75. package/dist/audit/schemas/audit-result.schema.json +1237 -0
  76. package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
  77. package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
  78. package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
  79. package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
  80. package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
  81. package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
  82. package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
  83. package/dist/audit/ui/assets/index-CtE_JUcs.css +1 -0
  84. package/dist/audit/ui/assets/index-aSdbHIeq.js +44 -0
  85. package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
  86. package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
  87. package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
  88. package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
  89. package/dist/audit/ui/einstein.png +0 -0
  90. package/dist/audit/ui/index.html +13 -0
  91. package/dist/audit.js +1898 -0
  92. package/dist/cartridge-services/index.d.ts +5 -0
  93. package/dist/cartridge-services/index.d.ts.map +1 -1
  94. package/dist/cartridge-services/index.js +94 -7
  95. package/dist/cartridge-services/index.js.map +1 -1
  96. package/dist/catalog.js +89 -0
  97. package/dist/commands/audit/catalog.js +27 -0
  98. package/dist/commands/audit/finalize.js +42 -0
  99. package/dist/commands/audit/prepare.js +84 -0
  100. package/dist/commands/audit/progress.js +51 -0
  101. package/dist/commands/audit/serve.js +63 -0
  102. package/dist/commands/config/aggregate-extensions.js +1 -0
  103. package/dist/commands/create-bundle.js +2 -1
  104. package/dist/commands/create-instructions.js +1 -0
  105. package/dist/commands/create-storefront.js +1 -0
  106. package/dist/commands/deploy-cartridge.js +35 -11
  107. package/dist/commands/dev.js +2 -0
  108. package/dist/commands/extensions/create.js +1 -0
  109. package/dist/commands/extensions/install.js +1 -0
  110. package/dist/commands/extensions/list.js +1 -0
  111. package/dist/commands/extensions/remove.js +1 -0
  112. package/dist/commands/generate-cartridge.js +49 -5
  113. package/dist/commands/locales/aggregate-extensions.js +1 -0
  114. package/dist/commands/prepare-local.js +1 -0
  115. package/dist/commands/preview.js +2 -0
  116. package/dist/commands/push.js +37 -7
  117. package/dist/commands/validate-cartridge.js +13 -4
  118. package/dist/config.js +13 -2
  119. package/dist/document-size.js +231 -0
  120. package/dist/events.js +688 -0
  121. package/dist/flags2.js +34 -0
  122. package/dist/generate-cartridge.js +41 -6
  123. package/dist/har.js +205 -0
  124. package/dist/images.js +146 -0
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/live.js +277 -0
  127. package/dist/logger.js +1 -4
  128. package/dist/measured-run.js +91 -0
  129. package/dist/metric-budget.js +125 -0
  130. package/dist/mrt/ssr.mjs +50 -50
  131. package/dist/mrt/ssr.mjs.map +1 -1
  132. package/dist/mrt/streamingHandler.mjs +56 -56
  133. package/dist/mrt/streamingHandler.mjs.map +1 -1
  134. package/dist/package.js +5 -0
  135. package/dist/sanitize.js +185 -0
  136. package/dist/schema.js +121 -0
  137. package/dist/server.js +8 -60
  138. package/dist/server2.js +185 -0
  139. package/dist/ts-import.js +57 -0
  140. package/package.json +24 -6
package/README.md CHANGED
@@ -137,8 +137,17 @@ Run these commands from your project directory:
137
137
  # Generate cartridge metadata for your site
138
138
  pnpm sfnext generate-cartridge
139
139
 
140
- # Deploy generated metadata to B2C Commerce (uses dw.json for all settings)
141
- pnpm sfnext deploy-cartridge
140
+ # Select a site other than app.defaultSiteId for PDP/PLP route metadata
141
+ pnpm sfnext generate-cartridge --site-id RefArch
142
+
143
+ # Generate a site-specific cartridge (scaffolds identity files once; no base required)
144
+ pnpm sfnext generate-cartridge --site-id RefArch --cartridge-name app_storefrontnext_refarch
145
+
146
+ # Validate a custom cartridge
147
+ pnpm sfnext validate-cartridge --cartridge-name app_storefrontnext_refarch
148
+
149
+ # Deploy a named cartridge with the existing filter
150
+ pnpm sfnext deploy-cartridge -c app_storefrontnext_refarch
142
151
  ```
143
152
 
144
153
  ## CLI Options
@@ -273,6 +282,8 @@ The cartridge commands are independent tools for working with B2C Commerce metad
273
282
  - Scans `src/` directory for `@Component`, `@PageType`, and `@Aspect` decorators
274
283
  - Generates JSON metadata files in `cartridge/cartridge/experience/` directory
275
284
  - Creates separate files for components, page types, and aspects
285
+ - Canonical PDP/PLP route metadata uses the selected site's complete `app.url.seoRoutes` entry (default: `app.defaultSiteId`); `--site-id` overrides it. A `'*'` entry is used only when no site-specific entry exists. Without `seoRoutes`, the metadata route prefixes are `p` and `c`.
286
+ - `--cartridge-name` writes to `cartridges/<name>` instead of `app_storefrontnext_base`. A new name scaffolds only `.project` and `<name>.properties` (no base cartridge, hooks, or sample assets). An existing target is left unchanged, so non-metadata files are preserved; generated components/pages/aspects are replaced. Put the named cartridge **before** the base entry in that site's cartridge path so the base still supplies hooks and shared behavior. Deploy with existing `-c`. Separate outputs must be regenerated explicitly.
276
287
 
277
288
  **Deploy Metadata**: `deploy-cartridge` uploads metadata directories to B2C Commerce
278
289
 
package/dist/assets.js ADDED
@@ -0,0 +1,25 @@
1
+ import { t as version } from "./package.js";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ //#region src/audit/assets.ts
7
+ /** The storefront-next-dev version, not the host CLI's: under `b2c sfnext`, oclif's config version is b2c's. */
8
+ const VERSION = version;
9
+ function packageRoot(from) {
10
+ for (let dir = from;; dir = path.dirname(dir)) {
11
+ if (existsSync(path.join(dir, "package.json"))) return dir;
12
+ if (path.dirname(dir) === dir) throw new Error(`No package.json above ${from}`);
13
+ }
14
+ }
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const root = packageRoot(here);
17
+ /**
18
+ * The directory holding the audit's runtime assets (catalog, instructions, UI). tsdown moves shared code into
19
+ * chunks at the top of `dist`, so paths relative to a module break after bundling. This resolves to `src/audit`
20
+ * when running from source (tests) and `dist/audit` when running the build.
21
+ */
22
+ const AUDIT_ROOT = path.join(root, path.relative(root, here).split(path.sep)[0] === "src" ? "src" : "dist", "audit");
23
+
24
+ //#endregion
25
+ export { VERSION as n, AUDIT_ROOT as t };
@@ -0,0 +1,21 @@
1
+ # Best Practices
2
+
3
+ ## Purpose and boundaries
4
+
5
+ Inspect storefront-next framework use, server loaders/actions, and production integrations. Follow data flow before judging a text match.
6
+
7
+ ## Investigation sequence
8
+
9
+ Map routes and data boundaries, inspect every rule, and corroborate source conclusions with captured behavior.
10
+
11
+ ## Evidence and redaction
12
+
13
+ Cite source lines and Playhouse diagnostics without secrets or personal data.
14
+
15
+ ## Severity and blind spots
16
+
17
+ Opaque integrations without independent end-to-end evidence are `not-verified`.
18
+
19
+ ## Draft rules and migration gaps
20
+
21
+ Application/data caching strategy, authenticated cache safety, observability, localization, resilient image handling, CSP integration, and framework-version-specific recommendations remain uncodified. Deployed public-resource caching is owned by `performance/cache-control`. Do not silently treat the remaining gaps as active rules; record relevant review as an ad hoc observation and coverage gap.
@@ -0,0 +1,20 @@
1
+ ---
2
+ id: best-practices/commerce-api-client
3
+ title: Commerce API Calls Use the SDK Clients
4
+ severity: high
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that calls to the Commerce APIs (SCAPI and OCAPI) go through the Storefront Next SDK clients instead of hand-built `fetch` requests. The SDK clients handle authentication, token refresh, routing and request correlation, which hand-built calls tend to get wrong.
11
+ ## Inspect
12
+ Trace raw fetches and SCAPI/OCAPI URLs through authentication, routing, token refresh, and correlation handling; exclude generated clients.
13
+ ## Evaluate
14
+ Finding requires a hand-built commerce call that bypasses the supported client, not a URL text match alone.
15
+ ## Evidence
16
+ Cite call site and the data/auth path it bypasses.
17
+ ## Remediation
18
+ Route the operation through the supported request-scoped SDK client.
19
+ ## Examples
20
+ Compliant: createApiClients call in a server loader. Problematic: direct fetch to a commerce endpoint. Ambiguous: URL in documentation.
@@ -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
+