@salesforce/storefront-next-dev 1.4.1-alpha.0 → 1.5.0-alpha.1

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.
@@ -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`.