@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
@@ -0,0 +1,20 @@
1
+ ---
2
+ id: performance/html-document-size
3
+ title: HTML Document Size
4
+ severity: medium
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.network-har, playhouse.scripts]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that each page's HTML is 350 KiB or less before compression. The browser has to download and parse all of the HTML, and React then hydrates it, before the page responds, so large HTML slows the first render on mobile. The result splits each page into its main parts (page data, app config, other inline scripts and markup) so the biggest one is easy to spot.
11
+ ## Inspect
12
+ The rule script takes the decoded size of each page's document response from `network-mobile-cold.har`. It splits that size using the inline scripts Playhouse archived from the same HTML (`scripts/index.json`): React Router page data (`streamController.enqueue(...)` scripts), the app config (`window.__APP_CONFIG__`), other inline scripts, and everything else, counted as markup.
13
+ ## Evaluate
14
+ Pass when every measured page's decoded HTML is at or under 350 KiB; fail otherwise. A page whose document can't be matched in the HAR is skipped. If no page can be measured, the rule is not decided.
15
+ ## Evidence
16
+ The document's HAR entry for each failing page, with its size and breakdown.
17
+ ## Remediation
18
+ Start with the largest part of the breakdown: page data is covered by `performance/page-data-size`, the app config by `performance/app-config-size`, and markup by `performance/dom-size`. Re-run Playhouse to confirm each page's HTML is at or under 350 KiB.
19
+ ## Examples
20
+ Compliant: a PLP of 290 KiB. Problematic: a PDP of 501 KiB, of which 297 KiB is page data.
@@ -0,0 +1,141 @@
1
+ import { o as readImageRun } from "../../../../../images.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/image-dimensions.ts
5
+ /** Shifts smaller than this aren't noticeable. */
6
+ const MIN_SHIFT = .01;
7
+ const UNSIZED_CAUSE = "Unsized image element";
8
+ const elementSchema = z.object({
9
+ selector: z.string().nullable(),
10
+ top: z.number().nullable(),
11
+ bottom: z.number().nullable()
12
+ }).strict();
13
+ const dataSchema = z.object({
14
+ cell: z.string(),
15
+ minShift: z.number(),
16
+ pages: z.array(z.object({
17
+ page: z.string(),
18
+ lighthouseArtifact: z.string(),
19
+ cls: z.number().nullable(),
20
+ unsizedShift: z.number(),
21
+ shifts: z.array(z.object({
22
+ cluster: z.number(),
23
+ score: z.number(),
24
+ element: elementSchema,
25
+ culprits: z.array(elementSchema.extend({
26
+ url: z.string().nullable(),
27
+ plausible: z.boolean()
28
+ }).strict())
29
+ }).strict()),
30
+ unsizedImages: z.array(elementSchema.extend({ url: z.string() }).strict())
31
+ }).strict()),
32
+ skipped: z.array(z.object({
33
+ page: z.string(),
34
+ reason: z.string()
35
+ }).strict())
36
+ }).strict();
37
+ const SRC = /\ssrc="([^"]+)"/;
38
+ const element = (node) => ({
39
+ selector: node?.selector ?? null,
40
+ top: node?.boundingRect?.top ?? null,
41
+ bottom: node?.boundingRect?.bottom ?? null
42
+ });
43
+ /**
44
+ * Lighthouse's layout shift culprits insight links a shift to unsized images by timing, so it can name an image that
45
+ * is nowhere near the shifted element. An image moves an element only from above it or beside it, so culprits that
46
+ * start below the element's top are marked implausible and don't count.
47
+ */
48
+ function unsizedShifts(run$1) {
49
+ const urls = new Map((run$1.lhr.audits["unsized-images"]?.details?.items ?? []).flatMap((item) => item.url && item.node?.path ? [[item.node.path, item.url]] : []));
50
+ const shifts = [];
51
+ let unsizedShift = 0;
52
+ (run$1.lhr.audits["cls-culprits-insight"]?.details?.items ?? []).forEach((cluster, index) => {
53
+ let clusterShift = 0;
54
+ for (const row of cluster.items ?? []) {
55
+ const causes = (row.subItems?.items ?? []).filter((item) => item.cause === UNSIZED_CAUSE);
56
+ if (!causes.length || !row.node?.selector) continue;
57
+ const shifted = element(row.node);
58
+ const culprits = causes.flatMap(({ extra }) => {
59
+ if (!extra?.selector) return [];
60
+ const src = extra.snippet?.match(SRC)?.[1];
61
+ const url = extra.path && urls.get(extra.path) || (src && !src.includes("…") ? src : null);
62
+ const image = element(extra);
63
+ return [{
64
+ ...image,
65
+ url,
66
+ plausible: image.top === null || shifted.top === null || image.top <= shifted.top
67
+ }];
68
+ });
69
+ const score = row.score ?? 0;
70
+ shifts.push({
71
+ cluster: index,
72
+ score,
73
+ element: shifted,
74
+ culprits
75
+ });
76
+ if (causes.length > culprits.length || culprits.some((culprit) => culprit.plausible)) clusterShift += score;
77
+ }
78
+ unsizedShift = Math.max(unsizedShift, clusterShift);
79
+ });
80
+ return {
81
+ unsizedShift,
82
+ shifts
83
+ };
84
+ }
85
+ const round = (value) => value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
86
+ const run = async ({ pages, playhouse }) => {
87
+ const data = {
88
+ cell: "mobile-cold",
89
+ minShift: MIN_SHIFT,
90
+ pages: [],
91
+ skipped: []
92
+ };
93
+ for (const page of pages) {
94
+ const imageRun = await readImageRun(playhouse, page.label);
95
+ if ("reason" in imageRun) {
96
+ data.skipped.push({
97
+ page: page.label,
98
+ reason: imageRun.reason
99
+ });
100
+ continue;
101
+ }
102
+ if (!imageRun.lhr.audits["cls-culprits-insight"]) {
103
+ data.skipped.push({
104
+ page: page.label,
105
+ reason: "The Lighthouse result has no layout shift culprits insight."
106
+ });
107
+ continue;
108
+ }
109
+ data.pages.push({
110
+ page: page.label,
111
+ lighthouseArtifact: imageRun.lighthouseArtifact,
112
+ cls: imageRun.lhr.audits["cumulative-layout-shift"]?.numericValue ?? null,
113
+ ...unsizedShifts(imageRun),
114
+ unsizedImages: (imageRun.lhr.audits["unsized-images"]?.details?.items ?? []).flatMap((item) => item.url ? [{
115
+ url: item.url,
116
+ ...element(item.node)
117
+ }] : [])
118
+ });
119
+ }
120
+ if (!data.pages.length) return { data };
121
+ if (data.pages.some((item) => item.unsizedShift >= MIN_SHIFT)) return { data };
122
+ const unsized = data.pages.reduce((total, item) => total + item.unsizedImages.length, 0);
123
+ return {
124
+ data,
125
+ decision: {
126
+ status: "pass",
127
+ summary: `Images without dimensions cause no noticeable layout shift on ${data.pages.map((item) => item.page).join(", ")}${unsized ? `; Lighthouse lists ${unsized} image${unsized === 1 ? "" : "s"} without width and height, but their layout reserves the space` : ""}.`,
128
+ evidence: data.pages.map((item) => ({
129
+ type: "playhouse",
130
+ page: item.page,
131
+ artifact: item.lighthouseArtifact,
132
+ pointer: "/audits/cls-culprits-insight",
133
+ value: `CLS ${round(item.cls ?? 0)}, ${round(item.unsizedShift)} from unsized images`
134
+ }))
135
+ }
136
+ };
137
+ };
138
+ var image_dimensions_default = run;
139
+
140
+ //#endregion
141
+ export { dataSchema, image_dimensions_default as default };
@@ -0,0 +1,22 @@
1
+ ---
2
+ id: performance/image-dimensions
3
+ title: Image Dimensions
4
+ severity: medium
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.lighthouse, source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that images without `width` and `height` don't shift the page's layout as they load. A browser can only reserve space for an image when it knows its size, from its attributes or from CSS such as a fixed height or `aspect-ratio`. An image without attributes is fine when its CSS or container already reserves the space, so only images that actually shift the layout are reported.
11
+ ## Inspect
12
+ The rule script reads Lighthouse's layout shift culprits insight (`cls-culprits-insight`) from each page's mobile cold-cache median run. Lighthouse links a shift to unsized images by timing, not position, so it can name the wrong image or none: the script marks a named culprit `plausible: false` when it starts below the shifted element's top (an image can only move content below or beside it), and `unsizedShift` is the worst cluster's shift that a plausible or unnamed image could have caused. `unsizedImages` lists every image Lighthouse found without dimensions, with its position. The script passes when no page's `unsizedShift` reaches `minShift`.
13
+
14
+ Otherwise, for each page with a shift of at least `minShift`, find the candidates: plausible culprits, and unsized images above or beside the shifted element (`top` at most the element's `top`). Images inside a shifted container don't move it. In the project source, find the component that renders each one and check whether its size is fixed before it loads: `width` and `height` attributes, a CSS height and width (a fixed height alone still lets the width change), `aspect-ratio`, or a wrapper with a fixed size or aspect ratio. A candidate whose size is fixed can't cause the shift.
15
+ ## Evaluate
16
+ Fail when a candidate's size isn't fixed before it loads and it can move the shifted element; medium severity when the page's CLS is over 0.1, low when it's within. Pass when there is no candidate or every candidate's size is fixed, and say the shift comes from something other than an image (for example a web font or content inserted after load), naming the shifted element. Undecided only when no page could be measured.
17
+ ## Evidence
18
+ The layout shift culprits insight for each page with a shift, with the shift score and the shifted element as its `value`, and the source file and line of each image that can cause it.
19
+ ## Remediation
20
+ Give the image `width` and `height` attributes (`DynamicImage` passes them through), or fix its size or its container's `aspect-ratio` in CSS. Re-run Playhouse to confirm the shift is gone.
21
+ ## Examples
22
+ Compliant: product tiles whose image wrapper has `aspect-ratio: 1`; a logo with a fixed CSS height and width. Problematic: a banner image with only `w-full` and no dimensions above the product grid, so the grid moves down when it loads.
@@ -0,0 +1,117 @@
1
+ import { a as kib, i as isImage, o as readImageRun, t as MIN_SAVINGS_BYTES } from "../../../../../images.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/image-transformation.ts
5
+ /** Dynamic Imaging Service URLs: `/dw/image/v2/<realm>/on/demandware.static/...`. */
6
+ const DIS_PATH = /\/dw\/image\/v\d+\//i;
7
+ /** B2C Commerce static files (catalog and library images) served as uploaded. */
8
+ const STATIC_PATH = /\/on\/demandware\.static\//i;
9
+ /** Formats DIS isn't used for here: vector images, and animated GIFs (`performance/large-images` suggests a video). */
10
+ const OUT_OF_SCOPE = /^image\/(svg\+xml|gif|x-icon|vnd\.microsoft\.icon)/;
11
+ const LEGACY = /^image\/(jpeg|png)/;
12
+ const problemSchema = z.enum([
13
+ "bypasses-dis",
14
+ "not-resized",
15
+ "legacy-format"
16
+ ]);
17
+ const dataSchema = z.object({
18
+ cell: z.string(),
19
+ minBytes: z.number(),
20
+ pages: z.array(z.object({
21
+ page: z.string(),
22
+ harArtifact: z.string(),
23
+ checked: z.number(),
24
+ dis: z.number(),
25
+ images: z.array(z.object({
26
+ url: z.string(),
27
+ entry: z.number(),
28
+ bytes: z.number(),
29
+ mimeType: z.string(),
30
+ problems: z.array(problemSchema),
31
+ lcp: z.boolean(),
32
+ requestedBeforeLcp: z.boolean().nullable()
33
+ }).strict())
34
+ }).strict()),
35
+ skipped: z.array(z.object({
36
+ page: z.string(),
37
+ reason: z.string()
38
+ }).strict())
39
+ }).strict();
40
+ function problems(url, mimeType) {
41
+ if (!DIS_PATH.test(url.pathname)) return ["bypasses-dis"];
42
+ const found = [];
43
+ if (!url.searchParams.has("sw") && !url.searchParams.has("sh")) found.push("not-resized");
44
+ if (LEGACY.test(mimeType)) found.push("legacy-format");
45
+ return found;
46
+ }
47
+ const run = async ({ pages, playhouse }) => {
48
+ const data = {
49
+ cell: "mobile-cold",
50
+ minBytes: MIN_SAVINGS_BYTES,
51
+ pages: [],
52
+ skipped: []
53
+ };
54
+ for (const page of pages) {
55
+ const imageRun = await readImageRun(playhouse, page.label);
56
+ if ("reason" in imageRun) {
57
+ data.skipped.push({
58
+ page: page.label,
59
+ reason: imageRun.reason
60
+ });
61
+ continue;
62
+ }
63
+ const result = {
64
+ page: page.label,
65
+ harArtifact: imageRun.harArtifact,
66
+ checked: 0,
67
+ dis: 0,
68
+ images: []
69
+ };
70
+ imageRun.entries.forEach((entry, index) => {
71
+ const mimeType = entry.response.content?.mimeType ?? "";
72
+ const bytes = entry.response.bodySize ?? 0;
73
+ if (!isImage(entry) || entry.response.status !== 200 || OUT_OF_SCOPE.test(mimeType) || bytes < MIN_SAVINGS_BYTES) return;
74
+ let url;
75
+ try {
76
+ url = new URL(entry.request.url);
77
+ } catch {
78
+ return;
79
+ }
80
+ if (!DIS_PATH.test(url.pathname) && !STATIC_PATH.test(url.pathname)) return;
81
+ result.checked += 1;
82
+ if (DIS_PATH.test(url.pathname)) result.dis += 1;
83
+ const found = problems(url, mimeType);
84
+ const start = imageRun.startMs(entry);
85
+ if (found.length) result.images.push({
86
+ url: entry.request.url,
87
+ entry: index,
88
+ bytes,
89
+ mimeType,
90
+ problems: found,
91
+ lcp: entry.request.url === imageRun.lcpUrl,
92
+ requestedBeforeLcp: Number.isFinite(start) && imageRun.lcpMs !== null ? start < imageRun.lcpMs : null
93
+ });
94
+ });
95
+ data.pages.push(result);
96
+ }
97
+ const checked = data.pages.reduce((total, item) => total + item.checked, 0);
98
+ if (!checked) return { data };
99
+ if (data.pages.some((item) => item.images.length)) return { data };
100
+ return {
101
+ data,
102
+ decision: {
103
+ status: "pass",
104
+ summary: `All ${checked} Commerce images of ${kib(MIN_SAVINGS_BYTES)} or more on ${data.pages.filter((item) => item.checked).map((item) => item.page).join(", ")} are resized by DIS and served as WebP or AVIF.`,
105
+ evidence: data.pages.filter((item) => item.checked).map((item) => ({
106
+ type: "playhouse",
107
+ page: item.page,
108
+ artifact: item.harArtifact,
109
+ value: `${item.checked} Commerce image${item.checked === 1 ? "" : "s"} checked, all through DIS`
110
+ }))
111
+ }
112
+ };
113
+ };
114
+ var image_transformation_default = run;
115
+
116
+ //#endregion
117
+ export { dataSchema, image_transformation_default as default };
@@ -0,0 +1,22 @@
1
+ ---
2
+ id: performance/image-transformation
3
+ title: Image Transformation
4
+ severity: high
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.network-har, source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that product and content images from B2C Commerce are served through the Dynamic Imaging Service (DIS), which resizes them to the width the page asks for and converts them to WebP or AVIF. An image linked as uploaded (`/on/demandware.static/...` instead of `/dw/image/v2/...`) is sent at its full original size and format, often several times larger than needed. Vector images (SVG) and animated GIFs are left out: DIS doesn't help them, and `performance/large-images` covers GIFs.
11
+ ## Inspect
12
+ The rule script lists every image of 10 KiB or more that each page's mobile cold-cache median run downloaded from B2C Commerce, and flags three problems: `bypasses-dis` (a static file URL, not DIS), `not-resized` (a DIS URL without `sw` or `sh`) and `legacy-format` (DIS returned JPEG or PNG instead of WebP or AVIF). If no image has a problem, it passes and this rule needs no analysis.
13
+
14
+ Otherwise, use the `data` file (sizes are in bytes): each page lists the flagged `images` with their HAR `entry`, size, response type, problems, and whether the image is the page's LCP image (`lcp`) or was requested before the LCP. The HAR is the run's `harArtifact`, which redacts query values; cite it as the data does. For each, find where the project builds the image URL. The storefront's `DynamicImage` component and its URL helpers (`toImageUrl`, `getSrc`) produce DIS URLs; images usually bypass DIS when a component renders the API's `link` directly instead of `disBaseLink`, when a content asset's HTML embeds static URLs, or when a host isn't recognized as DIS-capable (check `images.host` and `images.realmHostMappings` in the app config).
15
+ ## Evaluate
16
+ Report one `finding` for the rule, covering every flagged image. Group images by the component or content that renders them. Base severity on size: high when a flagged image is 200 KiB or more, or the flagged images on a page total 200 KiB or more; medium at 50 KiB or more; low otherwise. Raise it one level when a flagged image is the page's LCP image or is requested before the LCP, since it then slows the page's main content. Suggest rendering the image through `DynamicImage` (or its URL helpers) with widths that match the layout.
17
+ ## Evidence
18
+ For each component or content source: one flagged image (`playhouse`, citing the HAR with pointer `/log/entries/<entry>` and a `value` such as `CEP754_B1245_01.jpg, 640 KiB, image/jpeg: not through DIS`), and `source` evidence for where its URL is built.
19
+ ## Remediation
20
+ Build Commerce image URLs through `DynamicImage` or its URL helpers from the image's `disBaseLink`, with `widths` for each breakpoint, so DIS resizes and converts them. Re-run Playhouse to confirm every Commerce image is a resized DIS URL served as WebP or AVIF.
21
+ ## Examples
22
+ Compliant: `/dw/image/v2/ABCD_PRD/on/demandware.static/.../shoe.webp?sw=720&q=70&sfrm=jpg`, served as `image/webp`. Problematic: `/on/demandware.static/-/Sites-catalog/default/dw1234/images/large/shoe.jpg`, the 2400-pixel original, used as a gallery thumbnail.
@@ -0,0 +1,126 @@
1
+ import { t as readLivePage } from "../../../../../live.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/javascript-size.ts
5
+ const BUDGET_BYTES = 14e5;
6
+ const HIGH_BYTES = 27e5;
7
+ const LARGEST = 10;
8
+ const pageSchema = z.object({
9
+ page: z.string(),
10
+ lighthouseArtifact: z.string(),
11
+ scripts: z.number().int(),
12
+ decodedBytes: z.number(),
13
+ transferBytes: z.number(),
14
+ beforeLoad: z.object({
15
+ scripts: z.number().int(),
16
+ decodedBytes: z.number()
17
+ }).strict(),
18
+ afterLoad: z.object({
19
+ scripts: z.number().int(),
20
+ decodedBytes: z.number()
21
+ }).strict(),
22
+ largest: z.array(z.object({
23
+ file: z.string(),
24
+ item: z.number().int(),
25
+ decodedBytes: z.number(),
26
+ transferBytes: z.number()
27
+ }).strict())
28
+ }).strict();
29
+ const dataSchema = z.object({
30
+ budget: z.number(),
31
+ pages: z.array(pageSchema),
32
+ skipped: z.array(z.object({
33
+ page: z.string(),
34
+ reason: z.string()
35
+ }).strict())
36
+ }).strict();
37
+ const mb = (bytes) => `${(bytes / 1e6).toFixed(2)} MB`;
38
+ const kib = (bytes) => `${Math.round(bytes / 1024)} KiB`;
39
+ function measured(live) {
40
+ const sum = (scripts) => scripts.reduce((total, script) => total + script.decodedBytes, 0);
41
+ const before = live.scripts.filter((script) => script.beforeLoad);
42
+ const after = live.scripts.filter((script) => !script.beforeLoad);
43
+ return {
44
+ page: live.page,
45
+ lighthouseArtifact: live.lighthouseArtifact,
46
+ scripts: live.scripts.length,
47
+ decodedBytes: sum(live.scripts),
48
+ transferBytes: live.scripts.reduce((total, script) => total + script.transferBytes, 0),
49
+ beforeLoad: {
50
+ scripts: before.length,
51
+ decodedBytes: sum(before)
52
+ },
53
+ afterLoad: {
54
+ scripts: after.length,
55
+ decodedBytes: sum(after)
56
+ },
57
+ largest: live.scripts.slice().sort((a, b) => b.decodedBytes - a.decodedBytes).slice(0, LARGEST).map(({ file, item, decodedBytes, transferBytes }) => ({
58
+ file,
59
+ item,
60
+ decodedBytes,
61
+ transferBytes
62
+ }))
63
+ };
64
+ }
65
+ const totalEvidence = (page) => ({
66
+ type: "playhouse",
67
+ page: page.page,
68
+ artifact: page.lighthouseArtifact,
69
+ pointer: "/audits/network-requests/details/items",
70
+ value: `${mb(page.decodedBytes)} first-party JavaScript (${kib(page.transferBytes)} transferred) in ${page.scripts} scripts, ${page.beforeLoad.scripts} before load (budget ${mb(BUDGET_BYTES)})`
71
+ });
72
+ /** Measured on the live site only: the local build may be built from other code, so it never adds to these numbers. */
73
+ async function javascriptSize({ pages, playhouse }) {
74
+ const data = {
75
+ budget: BUDGET_BYTES,
76
+ pages: [],
77
+ skipped: []
78
+ };
79
+ for (const { label } of pages) {
80
+ const live = await readLivePage(playhouse, label);
81
+ if ("reason" in live) {
82
+ data.skipped.push({
83
+ page: label,
84
+ reason: live.reason
85
+ });
86
+ continue;
87
+ }
88
+ if (!live.scripts.length) {
89
+ data.skipped.push({
90
+ page: label,
91
+ reason: "The run loaded no scripts from its own bundle path (mobify/bundle)."
92
+ });
93
+ continue;
94
+ }
95
+ data.pages.push(measured(live));
96
+ }
97
+ if (!data.pages.length) return { data };
98
+ const over = data.pages.filter((page) => page.decodedBytes > BUDGET_BYTES);
99
+ const list = data.pages.map((page) => `${page.page} ${mb(page.decodedBytes)}`).join(", ");
100
+ if (!over.length) return {
101
+ data,
102
+ decision: {
103
+ status: "pass",
104
+ summary: `First-party JavaScript is within ${mb(BUDGET_BYTES)} on every measured page (${list}).`,
105
+ evidence: data.pages.map(totalEvidence)
106
+ }
107
+ };
108
+ return {
109
+ data,
110
+ decision: {
111
+ status: "fail",
112
+ severity: over.some((page) => page.decodedBytes >= HIGH_BYTES) ? "high" : "medium",
113
+ summary: `First-party JavaScript is over ${mb(BUDGET_BYTES)} on ${over.map((page) => `${page.page} (${mb(page.decodedBytes)} in ${page.scripts} scripts)`).join("; ")}.`,
114
+ evidence: over.flatMap((page) => [totalEvidence(page), ...page.largest.slice(0, 3).map((script) => ({
115
+ type: "playhouse",
116
+ page: page.page,
117
+ artifact: page.lighthouseArtifact,
118
+ pointer: `/audits/network-requests/details/items/${script.item}/resourceSize`,
119
+ value: `${script.file} ${kib(script.decodedBytes)}`
120
+ }))])
121
+ }
122
+ };
123
+ }
124
+
125
+ //#endregion
126
+ export { dataSchema, javascriptSize as default };
@@ -0,0 +1,20 @@
1
+ ---
2
+ id: performance/javascript-size
3
+ title: First-Party JavaScript Size
4
+ severity: high
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.lighthouse]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that the storefront's own JavaScript on each page, everything served from the bundle path (`/mobify/bundle/…`) including the npm packages bundled into it, is 1.4 MB or less before compression, just above the Storefront Next template's heaviest page (its PDP, about 1.36 MB). The browser downloads, parses and runs all of it before the page responds to taps, so it adds directly to mobile load and blocking time. Scripts loaded by tags (analytics, pixels, tag managers) are third-party and aren't counted here.
11
+ ## Inspect
12
+ The rule script reads the mobile cold-cache median run's `runs/<runId>/lhr.json` (the run named by `summary-lcp.json`) and keeps the `network-requests` items that are scripts served from `/mobify/bundle/`. It sums their decoded size (`resourceSize`) and transfer size, and splits them at the run's own load event (`observedLoad`). Everything here is measured on the live site; the local build never adds to it, because the live site may be built from other code.
13
+ ## Evaluate
14
+ Pass when every measured page's decoded first-party JavaScript is at or under 1,400,000 bytes; fail otherwise, with high severity when a page is at or over 2,700,000 bytes and medium below that. A page whose run has no `network-requests` audit, or that loaded no first-party scripts, is skipped. If no page can be measured, the rule is not decided.
15
+ ## Evidence
16
+ For each page, the run's `network-requests` items with the total decoded and transfer bytes and the script count; for a failing page, the largest scripts' `resourceSize` too.
17
+ ## Remediation
18
+ See `performance/reduce-javascript-on-load`, which analyzes a local build of the project and recommends code to load later. Load code for closed overlays, below-the-fold widgets and forms with `React.lazy` or a dynamic `import()` when it's first needed. Deploy the changes and re-run the audit to confirm each page is at or under 1.4 MB.
19
+ ## Examples
20
+ Compliant: a home page that loads 131 first-party scripts, 1.16 MB decoded. Problematic: a PDP that loads 2.9 MB, a third of it a carousel and a date library imported by the page route.
@@ -0,0 +1,187 @@
1
+ import { a as kib, o as readImageRun, t as MIN_SAVINGS_BYTES } from "../../../../../images.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/large-images.ts
5
+ /** An image is oversized when it has at least this many times the pixels the screen needs (srcset steps leave some slack). */
6
+ const MIN_PIXEL_RATIO = 2;
7
+ const reasonSchema = z.object({
8
+ kind: z.enum([
9
+ "oversized",
10
+ "animated-gif",
11
+ "format"
12
+ ]),
13
+ savingsBytes: z.number(),
14
+ detail: z.string()
15
+ }).strict();
16
+ const imageSchema = z.object({
17
+ url: z.string(),
18
+ item: z.number(),
19
+ bytes: z.number(),
20
+ shown: z.object({
21
+ width: z.number(),
22
+ height: z.number()
23
+ }).strict().nullable(),
24
+ intrinsic: z.object({
25
+ width: z.number(),
26
+ height: z.number()
27
+ }).strict().nullable(),
28
+ savingsBytes: z.number(),
29
+ reasons: z.array(reasonSchema),
30
+ selector: z.string().nullable(),
31
+ label: z.string().nullable(),
32
+ aboveFold: z.boolean().nullable(),
33
+ requestedBeforeLcp: z.boolean().nullable()
34
+ }).strict();
35
+ const dataSchema = z.object({
36
+ cell: z.string(),
37
+ minSavingsBytes: z.number(),
38
+ minPixelRatio: z.number(),
39
+ pages: z.array(z.object({
40
+ page: z.string(),
41
+ dpr: z.number(),
42
+ lcpMs: z.number().nullable(),
43
+ lighthouseArtifact: z.string(),
44
+ harArtifact: z.string(),
45
+ lighthouseItems: z.number(),
46
+ dropped: z.number(),
47
+ totalSavingsBytes: z.number(),
48
+ images: z.array(imageSchema)
49
+ }).strict()),
50
+ skipped: z.array(z.object({
51
+ page: z.string(),
52
+ reason: z.string()
53
+ }).strict())
54
+ }).strict();
55
+ const SIZE_REASON = /\((\d+)x(\d+)\) for its displayed dimensions/;
56
+ /**
57
+ * Lighthouse's compression-only estimate isn't used: it targets a fixed number of bytes per CSS pixel, so it counts
58
+ * an image right-sized for a 3× screen as badly compressed (it put a q=70 DIS WebP of 21 KiB at 4 KiB, below what
59
+ * DIS produces even at q=30).
60
+ */
61
+ function otherReason(reason) {
62
+ if (/video formats/i.test(reason)) return "animated-gif";
63
+ if (/modern image format|webp|avif/i.test(reason)) return "format";
64
+ return null;
65
+ }
66
+ /**
67
+ * Lighthouse's image delivery insight compares each image with its displayed size in CSS pixels, which overstates the
68
+ * savings on high-density screens. This recomputes the resize savings at the emulated pixel density, and keeps
69
+ * Lighthouse's format and animated GIF savings.
70
+ */
71
+ function oversizedImages(run$1) {
72
+ const result = [];
73
+ (run$1.lhr.audits["image-delivery-insight"]?.details?.items ?? []).forEach((item, index) => {
74
+ const rect = item.node?.boundingRect;
75
+ const shown = rect && rect.width > 0 && rect.height > 0 ? {
76
+ width: Math.round(rect.width),
77
+ height: Math.round(rect.height)
78
+ } : null;
79
+ const reasons = [];
80
+ let intrinsic = null;
81
+ for (const sub of item.subItems?.items ?? []) {
82
+ const size = sub.reason.match(SIZE_REASON);
83
+ if (size) {
84
+ intrinsic = {
85
+ width: Number(size[1]),
86
+ height: Number(size[2])
87
+ };
88
+ if (!shown) continue;
89
+ const needed = {
90
+ width: Math.min(intrinsic.width, Math.ceil(shown.width * run$1.dpr)),
91
+ height: Math.min(intrinsic.height, Math.ceil(shown.height * run$1.dpr))
92
+ };
93
+ const ratio = intrinsic.width * intrinsic.height / (needed.width * needed.height);
94
+ const savings = Math.round(item.totalBytes * (1 - 1 / ratio));
95
+ if (ratio >= MIN_PIXEL_RATIO && savings >= MIN_SAVINGS_BYTES) reasons.push({
96
+ kind: "oversized",
97
+ savingsBytes: savings,
98
+ detail: `${intrinsic.width}×${intrinsic.height} delivered; ${needed.width}×${needed.height} shows it at ${shown.width}×${shown.height} on a ${run$1.dpr}× screen`
99
+ });
100
+ continue;
101
+ }
102
+ const kind = otherReason(sub.reason);
103
+ if (kind && sub.wastedBytes >= MIN_SAVINGS_BYTES) reasons.push({
104
+ kind,
105
+ savingsBytes: Math.round(sub.wastedBytes),
106
+ detail: sub.reason
107
+ });
108
+ }
109
+ if (!reasons.length) return;
110
+ const entry = run$1.entries.find((candidate) => candidate.request.url === item.url);
111
+ const start = entry ? run$1.startMs(entry) : NaN;
112
+ result.push({
113
+ url: item.url,
114
+ item: index,
115
+ bytes: Math.round(item.totalBytes),
116
+ shown,
117
+ intrinsic,
118
+ savingsBytes: Math.max(...reasons.map((reason) => reason.savingsBytes)),
119
+ reasons,
120
+ selector: item.node?.selector ?? null,
121
+ label: item.node?.nodeLabel ?? null,
122
+ aboveFold: rect ? rect.top < run$1.viewportHeight : null,
123
+ requestedBeforeLcp: Number.isFinite(start) && run$1.lcpMs !== null ? start < run$1.lcpMs : null
124
+ });
125
+ });
126
+ return result.slice().sort((a, b) => b.savingsBytes - a.savingsBytes);
127
+ }
128
+ const run = async ({ pages, playhouse }) => {
129
+ const data = {
130
+ cell: "mobile-cold",
131
+ minSavingsBytes: MIN_SAVINGS_BYTES,
132
+ minPixelRatio: MIN_PIXEL_RATIO,
133
+ pages: [],
134
+ skipped: []
135
+ };
136
+ for (const page of pages) {
137
+ const imageRun = await readImageRun(playhouse, page.label);
138
+ if ("reason" in imageRun) {
139
+ data.skipped.push({
140
+ page: page.label,
141
+ reason: imageRun.reason
142
+ });
143
+ continue;
144
+ }
145
+ const insight = imageRun.lhr.audits["image-delivery-insight"];
146
+ if (!insight) {
147
+ data.skipped.push({
148
+ page: page.label,
149
+ reason: "The Lighthouse result has no image delivery insight."
150
+ });
151
+ continue;
152
+ }
153
+ const items = insight.details?.items?.length ?? 0;
154
+ const images = oversizedImages(imageRun);
155
+ data.pages.push({
156
+ page: page.label,
157
+ dpr: imageRun.dpr,
158
+ lcpMs: imageRun.lcpMs,
159
+ lighthouseArtifact: imageRun.lighthouseArtifact,
160
+ harArtifact: imageRun.harArtifact,
161
+ lighthouseItems: items,
162
+ dropped: items - images.length,
163
+ totalSavingsBytes: images.reduce((total, image) => total + image.savingsBytes, 0),
164
+ images
165
+ });
166
+ }
167
+ if (!data.pages.length) return { data };
168
+ if (data.pages.some((item) => item.images.length)) return { data };
169
+ return {
170
+ data,
171
+ decision: {
172
+ status: "pass",
173
+ summary: `No image on ${data.pages.map((item) => item.page).join(", ")} would save ${kib(MIN_SAVINGS_BYTES)} or more by resizing, converting to WebP or AVIF, or replacing an animated GIF.`,
174
+ evidence: data.pages.map((item) => ({
175
+ type: "playhouse",
176
+ page: item.page,
177
+ artifact: item.lighthouseArtifact,
178
+ pointer: "/audits/image-delivery-insight",
179
+ value: item.lighthouseItems ? `Lighthouse listed ${item.lighthouseItems} image${item.lighthouseItems === 1 ? "" : "s"}; each is right-sized for a ${item.dpr}× screen or would save less than ${kib(MIN_SAVINGS_BYTES)}` : "Lighthouse found no image delivery savings"
180
+ }))
181
+ }
182
+ };
183
+ };
184
+ var large_images_default = run;
185
+
186
+ //#endregion
187
+ export { dataSchema, large_images_default as default };