@salesforce/storefront-next-dev 1.4.0-alpha.1 → 1.4.1-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 (136) 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/error-boundaries.md +19 -0
  6. package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
  7. package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
  8. package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
  9. package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
  10. package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
  11. package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
  12. package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
  13. package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
  14. package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
  15. package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
  16. package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
  17. package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
  18. package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
  19. package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
  20. package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
  21. package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
  22. package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
  23. package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
  24. package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
  25. package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
  26. package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
  27. package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
  28. package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
  29. package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
  30. package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
  31. package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
  32. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
  33. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
  34. package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
  35. package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
  36. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
  37. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
  38. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
  39. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
  40. package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
  41. package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
  42. package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
  43. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
  44. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
  45. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
  46. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
  47. package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
  48. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
  49. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
  50. package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
  51. package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
  52. package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
  53. package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
  54. package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
  55. package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
  56. package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
  57. package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
  58. package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
  59. package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
  60. package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
  61. package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
  62. package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
  63. package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
  64. package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
  65. package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
  66. package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
  67. package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
  68. package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
  69. package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
  70. package/dist/audit/instructions/evaluate-rule.md +42 -0
  71. package/dist/audit/schemas/audit-result.schema.json +1237 -0
  72. package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
  73. package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
  74. package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
  75. package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
  76. package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
  77. package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
  78. package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
  79. package/dist/audit/ui/assets/index-BKCWlO9b.css +1 -0
  80. package/dist/audit/ui/assets/index-CUPt8e4A.js +44 -0
  81. package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
  82. package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
  83. package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
  84. package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
  85. package/dist/audit/ui/einstein.png +0 -0
  86. package/dist/audit/ui/index.html +13 -0
  87. package/dist/audit.js +1896 -0
  88. package/dist/cartridge-services/index.d.ts +5 -0
  89. package/dist/cartridge-services/index.d.ts.map +1 -1
  90. package/dist/cartridge-services/index.js +94 -7
  91. package/dist/cartridge-services/index.js.map +1 -1
  92. package/dist/catalog.js +89 -0
  93. package/dist/commands/audit/catalog.js +27 -0
  94. package/dist/commands/audit/finalize.js +42 -0
  95. package/dist/commands/audit/prepare.js +84 -0
  96. package/dist/commands/audit/progress.js +51 -0
  97. package/dist/commands/audit/serve.js +63 -0
  98. package/dist/commands/config/aggregate-extensions.js +1 -0
  99. package/dist/commands/create-bundle.js +2 -1
  100. package/dist/commands/create-instructions.js +1 -0
  101. package/dist/commands/create-storefront.js +1 -0
  102. package/dist/commands/deploy-cartridge.js +11 -3
  103. package/dist/commands/dev.js +2 -0
  104. package/dist/commands/extensions/create.js +1 -0
  105. package/dist/commands/extensions/install.js +1 -0
  106. package/dist/commands/extensions/list.js +1 -0
  107. package/dist/commands/extensions/remove.js +1 -0
  108. package/dist/commands/generate-cartridge.js +49 -5
  109. package/dist/commands/locales/aggregate-extensions.js +1 -0
  110. package/dist/commands/prepare-local.js +1 -0
  111. package/dist/commands/preview.js +2 -0
  112. package/dist/commands/push.js +3 -1
  113. package/dist/commands/validate-cartridge.js +13 -4
  114. package/dist/config.js +13 -2
  115. package/dist/document-size.js +231 -0
  116. package/dist/events.js +688 -0
  117. package/dist/flags2.js +34 -0
  118. package/dist/generate-cartridge.js +41 -6
  119. package/dist/har.js +205 -0
  120. package/dist/images.js +146 -0
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/live.js +277 -0
  123. package/dist/logger.js +1 -4
  124. package/dist/measured-run.js +91 -0
  125. package/dist/metric-budget.js +125 -0
  126. package/dist/mrt/ssr.mjs +19 -19
  127. package/dist/mrt/ssr.mjs.map +1 -1
  128. package/dist/mrt/streamingHandler.mjs +26 -26
  129. package/dist/mrt/streamingHandler.mjs.map +1 -1
  130. package/dist/package.js +5 -0
  131. package/dist/sanitize.js +185 -0
  132. package/dist/schema.js +121 -0
  133. package/dist/server.js +8 -60
  134. package/dist/server2.js +185 -0
  135. package/dist/ts-import.js +57 -0
  136. package/package.json +21 -4
@@ -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 };
@@ -0,0 +1,22 @@
1
+ ---
2
+ id: performance/large-images
3
+ title: Large Images
4
+ severity: medium
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.lighthouse, playhouse.network-har, source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks for images that are much larger than they need to be: delivered at more pixels than the screen shows, in an older format, or animated GIFs that would be far smaller as video. Each image is compared with what its own spot on the page needs, at the phone's pixel density, so there is no page-wide image budget. When an image would save 10 KiB or more, the report points to the component that renders it.
11
+ ## Inspect
12
+ The rule script reads Lighthouse's image delivery insight (`image-delivery-insight`) from each page's mobile cold-cache median run. Lighthouse compares each image with its displayed size in CSS pixels, which overstates the savings on high-density screens, so the script recomputes the resize savings at the emulated pixel density (`dpr`, usually 3). It keeps an image only when it has at least twice the pixels the screen needs and would save 10 KiB or more, and keeps Lighthouse's format and animated GIF savings of 10 KiB or more. Lighthouse's compression-only estimate is left out: it assumes a fixed number of bytes per CSS pixel, so it flags images that are right-sized for the screen. If no image qualifies, it passes and this rule needs no analysis.
13
+
14
+ Otherwise, use the `data` file (sizes are in bytes). Each page lists its `images`, largest savings first, with the delivered (`intrinsic`) and displayed (`shown`) sizes, the `reasons` and their savings, the element's `selector` and `label` (usually its alt text; the URL's file name also helps find it), and whether it is above the fold or requested before the LCP. `dropped` counts the images Lighthouse listed that are right-sized for the pixel density, and `totalSavingsBytes` sums the page's savings. For each image, find the component that renders it: product images usually come from the `DynamicImage` component or a helper that builds DIS URLs, and content images from Page Designer components or content assets. Check the `widths` (or `sizes`) it passes, or whether it uses a fixed `sw` or the original image URL. Images bundled with the app (imported from the project, not served by B2C Commerce) don't go through DIS.
15
+ ## Evaluate
16
+ Report one `finding` for the rule, covering every page with qualifying images. Group images rendered by the same component, and lead with the largest savings. Use medium severity when an image is above the fold or requested before the LCP, since it competes with the most important content, or when a page's images would save 100 KiB or more in total; otherwise low. Typical fixes: give `DynamicImage` `widths` that match the layout at each breakpoint, so the browser can pick a smaller file; set the widths of Page Designer image components; show a still image or a video instead of an animated GIF; for a bundled asset such as a logo, use SVG or ship it as WebP. Phrase each one as a suggestion.
17
+ ## Evidence
18
+ For each component: one image it renders (`playhouse`, citing the Lighthouse result with pointer `/audits/image-delivery-insight/details/items/<item>` and a `value` such as `1200×1200 delivered for a 186×186 slot; about 56 KiB smaller at 558×558`), and `source` evidence for the component and the widths it passes.
19
+ ## Remediation
20
+ Request each image at the width its slot needs on each breakpoint, through `DynamicImage` `widths` or the DIS `sw` parameter, and replace animated GIFs with video or a still image. Re-run Playhouse to confirm the images no longer appear in the data.
21
+ ## Examples
22
+ Compliant: a product tile that requests 360-, 720- and 1080-pixel candidates for a tile shown 360 pixels wide on a 3× phone. Problematic: a home page content card that loads a 1200-pixel image into a 186-pixel slot, or a 2.7 MB animated GIF shown as a 60-pixel thumbnail.
@@ -0,0 +1,13 @@
1
+ import "../../../../../measured-run.js";
2
+ import { n as metricBudget, r as milliseconds, t as dataSchema } from "../../../../../metric-budget.js";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/lcp-budget.ts
5
+ var lcp_budget_default = metricBudget({
6
+ metric: "lcp",
7
+ label: "LCP",
8
+ threshold: 2500,
9
+ format: milliseconds
10
+ });
11
+
12
+ //#endregion
13
+ export { dataSchema, lcp_budget_default as default };
@@ -0,0 +1,20 @@
1
+ ---
2
+ id: performance/lcp-budget
3
+ title: Largest Contentful Paint (LCP)
4
+ severity: high
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.summary]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that each page's median Largest Contentful Paint, measured by Lighthouse on mobile with a cold cache, is 2.5 s or less, the Core Web Vitals "good" threshold. LCP is when the largest image or block of text on screen appears, the main sign to shoppers that the page has loaded.
11
+ ## Inspect
12
+ The rule script reads each page's `summary.json` and takes the mobile cold-cache cell's `medianRun.lcp`. Playhouse writes 0 for metrics it could not measure, and a rendered page always has a positive LCP, so a run with an LCP of 0 or less measured nothing. Such runs are left out: when any run failed, the script picks the median again from the measured runs the way Playhouse does (the run closest to the median LCP, ties to the earlier run) and reads that run's `runs[].row.lcp`.
13
+ ## Evaluate
14
+ Pass when every measured page's median LCP is at or under 2500 ms; fail otherwise. A page without a mobile cold-cache cell, or without any measured run, is skipped. If no page can be measured, the rule is not decided.
15
+ ## Evidence
16
+ One `summary.json` pointer per page, `/cells/<i>/medianRun/lcp` (or `/runs/<j>/row/lcp` when runs were left out), with the measured value and how many runs were not measured.
17
+ ## Remediation
18
+ Use `opportunities.json` and `summary-lcp.json` for the failing page to find the dominant LCP subpart (TTFB, load delay, load duration or render delay). Preload or prioritize the LCP resource, serve it at the displayed size, and remove render-blocking work before it. Re-run the same Playhouse capture to confirm the mobile cold-cache median is at or under 2500 ms.
19
+ ## Examples
20
+ Compliant: home median LCP 2180 ms. Problematic: pdp median LCP 3400 ms.