@salesforce/storefront-next-dev 1.4.1-alpha.0 → 1.5.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.js +575 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.js +248 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.md +21 -0
- package/dist/audit/ui/assets/{index-BKCWlO9b.css → index-CtE_JUcs.css} +1 -1
- package/dist/audit/ui/assets/{index-CUPt8e4A.js → index-aSdbHIeq.js} +12 -12
- package/dist/audit/ui/index.html +2 -2
- package/dist/audit.js +3 -1
- package/dist/commands/deploy-cartridge.js +24 -8
- package/dist/commands/push.js +34 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/mrt/ssr.mjs +50 -50
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +56 -56
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { l as normalizedUrl } from "../../../../../har.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/audit/catalog/categories/performance/rules/font-display.ts
|
|
5
|
+
const FORM_FACTORS = ["mobile", "desktop"];
|
|
6
|
+
const SAFE_RUN_ID = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
7
|
+
const TRACE_MAX_BYTES = 256 * 1024 * 1024;
|
|
8
|
+
const acceptable = new Set(["swap", "optional"]);
|
|
9
|
+
const summarySchema = z.object({ runs: z.array(z.object({
|
|
10
|
+
runId: z.string().optional(),
|
|
11
|
+
formFactor: z.string().optional(),
|
|
12
|
+
cacheState: z.string().optional(),
|
|
13
|
+
isMedian: z.boolean().optional()
|
|
14
|
+
}).passthrough()).optional().default([]) }).passthrough();
|
|
15
|
+
const harSchema = z.object({ log: z.object({ entries: z.array(z.object({
|
|
16
|
+
_resourceType: z.string().optional(),
|
|
17
|
+
request: z.object({ url: z.string() }).passthrough(),
|
|
18
|
+
response: z.object({ status: z.number() }).passthrough()
|
|
19
|
+
}).passthrough()) }).passthrough() }).passthrough();
|
|
20
|
+
const fontSchema = z.object({
|
|
21
|
+
url: z.string(),
|
|
22
|
+
display: z.string(),
|
|
23
|
+
status: z.enum(["pass", "fail"]),
|
|
24
|
+
event: z.number()
|
|
25
|
+
}).strict();
|
|
26
|
+
const unverifiedFontSchema = z.object({
|
|
27
|
+
url: z.string(),
|
|
28
|
+
entry: z.number()
|
|
29
|
+
}).strict();
|
|
30
|
+
const cellSchema = z.object({
|
|
31
|
+
cell: z.enum(["mobile-cold", "desktop-cold"]),
|
|
32
|
+
runId: z.string(),
|
|
33
|
+
status: z.enum([
|
|
34
|
+
"pass",
|
|
35
|
+
"fail",
|
|
36
|
+
"undecided"
|
|
37
|
+
]),
|
|
38
|
+
traceArtifact: z.string(),
|
|
39
|
+
harArtifact: z.string(),
|
|
40
|
+
fonts: z.array(fontSchema),
|
|
41
|
+
unverifiedFonts: z.array(unverifiedFontSchema)
|
|
42
|
+
}).strict();
|
|
43
|
+
const dataSchema = z.object({
|
|
44
|
+
pages: z.array(z.object({
|
|
45
|
+
page: z.string(),
|
|
46
|
+
cells: z.array(cellSchema)
|
|
47
|
+
}).strict()),
|
|
48
|
+
skipped: z.array(z.object({
|
|
49
|
+
page: z.string(),
|
|
50
|
+
cell: z.string(),
|
|
51
|
+
reason: z.string()
|
|
52
|
+
}).strict())
|
|
53
|
+
}).strict();
|
|
54
|
+
function object(value) {
|
|
55
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
56
|
+
}
|
|
57
|
+
function traceFonts(trace) {
|
|
58
|
+
const events = object(trace)?.traceEvents;
|
|
59
|
+
if (!Array.isArray(events)) return void 0;
|
|
60
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
61
|
+
for (const [event, raw] of events.entries()) {
|
|
62
|
+
const item = object(raw);
|
|
63
|
+
if (item?.name !== "BeginRemoteFontLoad") continue;
|
|
64
|
+
const args = object(item.args);
|
|
65
|
+
const url = args?.url;
|
|
66
|
+
const display = args?.display;
|
|
67
|
+
if (typeof url !== "string" || typeof display !== "string") continue;
|
|
68
|
+
const key = normalizedUrl(url) ?? url;
|
|
69
|
+
const policy = display.toLowerCase();
|
|
70
|
+
const candidate = {
|
|
71
|
+
url,
|
|
72
|
+
display: policy,
|
|
73
|
+
status: acceptable.has(policy) ? "pass" : "fail",
|
|
74
|
+
event
|
|
75
|
+
};
|
|
76
|
+
const known = fonts.get(key);
|
|
77
|
+
if (!known || known.status === "pass" && candidate.status === "fail") fonts.set(key, candidate);
|
|
78
|
+
}
|
|
79
|
+
return [...fonts.values()];
|
|
80
|
+
}
|
|
81
|
+
async function medianRun(playhouse, page, formFactor) {
|
|
82
|
+
const runs = ((await playhouse.readJson(page, "summary.json", summarySchema))?.runs ?? []).filter((run$1) => run$1.runId && SAFE_RUN_ID.test(run$1.runId) && run$1.formFactor === formFactor && run$1.cacheState === "cold");
|
|
83
|
+
return (runs.find((run$1) => run$1.isMedian) ?? runs[0])?.runId ?? `${formFactor}-cold-1`;
|
|
84
|
+
}
|
|
85
|
+
async function measureCell(playhouse, page, formFactor) {
|
|
86
|
+
const cell = `${formFactor}-cold`;
|
|
87
|
+
try {
|
|
88
|
+
const runId = await medianRun(playhouse, page, formFactor);
|
|
89
|
+
const traceArtifact = `runs/${runId}/trace.json`;
|
|
90
|
+
const harArtifact = `runs/${runId}/network.har`;
|
|
91
|
+
let traceText;
|
|
92
|
+
let har;
|
|
93
|
+
try {
|
|
94
|
+
[traceText, har] = await Promise.all([playhouse.readText(page, traceArtifact, TRACE_MAX_BYTES), playhouse.readJson(page, harArtifact, harSchema)]);
|
|
95
|
+
} catch {
|
|
96
|
+
return {
|
|
97
|
+
cell,
|
|
98
|
+
reason: `${traceArtifact} could not be read or parsed safely.`
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (traceText === void 0) return {
|
|
102
|
+
cell,
|
|
103
|
+
reason: `${traceArtifact} is missing.`
|
|
104
|
+
};
|
|
105
|
+
let trace;
|
|
106
|
+
try {
|
|
107
|
+
trace = JSON.parse(traceText);
|
|
108
|
+
} catch {
|
|
109
|
+
return {
|
|
110
|
+
cell,
|
|
111
|
+
reason: `${traceArtifact} could not be read or parsed safely.`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const tracePolicies = traceFonts(trace);
|
|
115
|
+
if (!tracePolicies) return {
|
|
116
|
+
cell,
|
|
117
|
+
reason: `${traceArtifact} has no traceEvents array.`
|
|
118
|
+
};
|
|
119
|
+
if (!har) return {
|
|
120
|
+
cell,
|
|
121
|
+
reason: `${harArtifact} is missing.`
|
|
122
|
+
};
|
|
123
|
+
const downloaded = /* @__PURE__ */ new Map();
|
|
124
|
+
for (const [entry, item] of har.log.entries.entries()) {
|
|
125
|
+
if (item._resourceType?.toLowerCase() !== "font" || item.response.status <= 0 || item.response.status >= 400) continue;
|
|
126
|
+
const key = normalizedUrl(item.request.url) ?? item.request.url;
|
|
127
|
+
if (!downloaded.has(key)) downloaded.set(key, {
|
|
128
|
+
url: item.request.url,
|
|
129
|
+
entry
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const fonts = tracePolicies.filter((font) => downloaded.has(normalizedUrl(font.url) ?? font.url));
|
|
133
|
+
const traced = new Set(fonts.map((font) => normalizedUrl(font.url) ?? font.url));
|
|
134
|
+
const unverifiedFonts = [...downloaded].flatMap(([key, item]) => traced.has(key) ? [] : [item]);
|
|
135
|
+
return {
|
|
136
|
+
cell,
|
|
137
|
+
runId,
|
|
138
|
+
status: fonts.some((font) => font.status === "fail") ? "fail" : unverifiedFonts.length ? "undecided" : "pass",
|
|
139
|
+
traceArtifact,
|
|
140
|
+
harArtifact,
|
|
141
|
+
fonts,
|
|
142
|
+
unverifiedFonts
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return {
|
|
146
|
+
cell,
|
|
147
|
+
reason: `${cell} artifacts could not be read or parsed safely.`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function fileName(url) {
|
|
152
|
+
try {
|
|
153
|
+
return decodeURIComponent(new URL(url).pathname.split("/").at(-1) || url);
|
|
154
|
+
} catch {
|
|
155
|
+
return url;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function hostName(url) {
|
|
159
|
+
try {
|
|
160
|
+
return new URL(url).host;
|
|
161
|
+
} catch {
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const run = async ({ pages, playhouse }) => {
|
|
166
|
+
const data = {
|
|
167
|
+
pages: [],
|
|
168
|
+
skipped: []
|
|
169
|
+
};
|
|
170
|
+
const failures = [];
|
|
171
|
+
const passEvidence = [];
|
|
172
|
+
let undecided = false;
|
|
173
|
+
const pageMeasurements = [];
|
|
174
|
+
for (const page of pages) pageMeasurements.push({
|
|
175
|
+
page,
|
|
176
|
+
measured: await Promise.all(FORM_FACTORS.map((formFactor) => measureCell(playhouse, page.label, formFactor)))
|
|
177
|
+
});
|
|
178
|
+
for (const { page, measured } of pageMeasurements) {
|
|
179
|
+
const cells = [];
|
|
180
|
+
for (const result of measured) {
|
|
181
|
+
if ("reason" in result) {
|
|
182
|
+
data.skipped.push({
|
|
183
|
+
page: page.label,
|
|
184
|
+
cell: result.cell,
|
|
185
|
+
reason: result.reason
|
|
186
|
+
});
|
|
187
|
+
undecided = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
cells.push(result);
|
|
191
|
+
undecided ||= result.status === "undecided";
|
|
192
|
+
for (const font of result.fonts) {
|
|
193
|
+
const evidence = {
|
|
194
|
+
type: "playhouse",
|
|
195
|
+
page: page.label,
|
|
196
|
+
artifact: result.traceArtifact,
|
|
197
|
+
pointer: `/traceEvents/${font.event}/args/display`,
|
|
198
|
+
value: `${fileName(font.url)} from ${hostName(font.url)} uses font-display: ${font.display} in ${result.cell}.`
|
|
199
|
+
};
|
|
200
|
+
if (font.status === "fail") failures.push({
|
|
201
|
+
page: page.label,
|
|
202
|
+
cell: result,
|
|
203
|
+
font
|
|
204
|
+
});
|
|
205
|
+
else passEvidence.push(evidence);
|
|
206
|
+
}
|
|
207
|
+
if (!result.fonts.length) passEvidence.push({
|
|
208
|
+
type: "playhouse",
|
|
209
|
+
page: page.label,
|
|
210
|
+
artifact: result.harArtifact,
|
|
211
|
+
pointer: "/log/entries",
|
|
212
|
+
value: `${result.cell} downloaded no web fonts.`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
data.pages.push({
|
|
216
|
+
page: page.label,
|
|
217
|
+
cells
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (failures.length) return {
|
|
221
|
+
data,
|
|
222
|
+
decision: {
|
|
223
|
+
status: "fail",
|
|
224
|
+
severity: "medium",
|
|
225
|
+
summary: failures.map(({ page, cell, font }) => `${page} ${cell.cell}: ${fileName(font.url)} from ${hostName(font.url)} uses font-display: ${font.display}.`).join(" "),
|
|
226
|
+
evidence: failures.slice(0, 10).map(({ page, cell, font }) => ({
|
|
227
|
+
type: "playhouse",
|
|
228
|
+
page,
|
|
229
|
+
artifact: cell.traceArtifact,
|
|
230
|
+
pointer: `/traceEvents/${font.event}/args/display`,
|
|
231
|
+
value: `${fileName(font.url)} from ${hostName(font.url)} uses font-display: ${font.display} in ${cell.cell}.`
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
if (!data.pages.length || undecided || data.skipped.length) return { data };
|
|
236
|
+
return {
|
|
237
|
+
data,
|
|
238
|
+
decision: {
|
|
239
|
+
status: "pass",
|
|
240
|
+
summary: `Every measured web font uses font-display: swap or optional on ${data.pages.map((page) => page.page).join(", ")}.`,
|
|
241
|
+
evidence: passEvidence
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
var font_display_default = run;
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
export { dataSchema, font_display_default as default };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: performance/font-display
|
|
3
|
+
title: Web Font Display Policy
|
|
4
|
+
severity: medium
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [playhouse.summary, playhouse.trace, playhouse.network-har, source]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Intent
|
|
10
|
+
Checks that web fonts do not hide text while they download. A blocking `font-display` policy can leave shoppers looking at invisible text, while `swap` and `optional` allow fallback text to render immediately.
|
|
11
|
+
## Inspect
|
|
12
|
+
The rule script reads the median mobile and desktop cold-cache runs from `summary.json`. In each run's trace it finds `BeginRemoteFontLoad` events and reads the font URL and display policy that Chrome used. It compares those URLs with downloaded font responses in the same run's HAR. Chrome does not emit this trace event for every preloaded font, so a downloaded font with no matching event is left undecided for source inspection rather than treated as a pass. For those candidates, inspect the matching `@font-face` declaration in project or fetched stylesheet CSS.
|
|
13
|
+
The layout-shift portion of the original rule specification is deferred until the audit captures a deterministic mapping between layout-shift events and the web font that caused them; this rule currently reports display policy only.
|
|
14
|
+
## Evaluate
|
|
15
|
+
Fail when Chrome reports `auto`, `block`, `fallback`, or any policy other than `swap` or `optional`. Pass only when every downloaded font in both measured cold-cache runs has a trace event and every event uses `swap` or `optional`. When a downloaded font has no matching event, the script leaves the rule undecided; source analysis fails a matching `@font-face` without `swap` or `optional`, passes it when the policy is present, and reports not verified when the declaration cannot be found. A missing trace or HAR also leaves the rule undecided.
|
|
16
|
+
## Evidence
|
|
17
|
+
For a deterministic failure, the trace event's font URL, display policy, form factor and run. For an undecided downloaded font, its HAR URL and the matching `@font-face` declaration when source analysis can find it.
|
|
18
|
+
## Remediation
|
|
19
|
+
Set `font-display: swap` for brand fonts that should replace fallback text as soon as they arrive, or `font-display: optional` when avoiding a late swap is more important than using the web font on the first visit. Add fallback metrics such as `size-adjust`, `ascent-override`, `descent-override` and `line-gap-override` when a font swap contributes to layout shift. Replace icon fonts with inline SVG or an SVG sprite so icons do not depend on a blocking font. Self-host third-party fonts when licensing permits; otherwise preload only critical faces and preconnect to the font origin. Apply the policy to every relevant `@font-face`, including preloaded faces, and re-run both mobile and desktop cold-cache captures.
|
|
20
|
+
## Examples
|
|
21
|
+
Compliant: `@font-face { font-family: Brand; src: url('/fonts/brand.woff2') format('woff2'); font-display: swap; }`. Problematic: a downloaded face whose trace reports `font-display: block`, or a preloaded face whose declaration omits `font-display`.
|