imgtune 0.2.0 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +69 -6
  3. package/dist/audit.js +1 -0
  4. package/dist/backgrounds.js +31 -7
  5. package/dist/broken.js +5 -1
  6. package/dist/browser.js +4 -4
  7. package/dist/candidates.js +25 -0
  8. package/dist/check-report.js +37 -4
  9. package/dist/check.js +18 -1
  10. package/dist/cli.js +20 -0
  11. package/dist/egress-proxy.js +2 -1
  12. package/dist/ffmpeg.js +35 -0
  13. package/dist/hints.js +294 -18
  14. package/dist/image-open.js +9 -0
  15. package/dist/images.js +116 -7
  16. package/dist/layout.js +7 -7
  17. package/dist/lcp.js +2 -1
  18. package/dist/measure.js +39 -1
  19. package/dist/media-encode.js +5 -1
  20. package/dist/media-model.js +8 -4
  21. package/dist/media-phase.js +25 -2
  22. package/dist/px.js +11 -0
  23. package/dist/recheck.js +9 -1
  24. package/dist/remote.js +616 -63
  25. package/dist/replay.js +20 -0
  26. package/dist/resources/audio.js +248 -0
  27. package/dist/resources/brotli.js +22 -0
  28. package/dist/resources/cache.js +59 -0
  29. package/dist/resources/capture.js +225 -0
  30. package/dist/resources/compression.js +122 -0
  31. package/dist/resources/favicon.js +130 -0
  32. package/dist/resources/font.js +440 -0
  33. package/dist/resources/index.js +8 -0
  34. package/dist/resources/lottie.js +491 -0
  35. package/dist/resources/model.js +478 -0
  36. package/dist/resources/phase.js +171 -0
  37. package/dist/resources/pool.js +47 -0
  38. package/dist/resources/report.js +57 -0
  39. package/dist/resources/sfnt.js +305 -0
  40. package/dist/resources/svg.js +178 -0
  41. package/dist/resources/types.js +1 -0
  42. package/dist/resources/zip.js +67 -0
  43. package/dist/run.js +24 -4
  44. package/dist/sizes.js +7 -5
  45. package/dist/summary.js +47 -1
  46. package/dist/vector-art.js +294 -0
  47. package/dist/vector-phase.js +222 -0
  48. package/dist/video-analysis.js +119 -21
  49. package/package.json +25 -4
  50. package/skills/imgtune/SKILL.md +8 -4
  51. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,440 @@
1
+ // Fonts: per font file, the format, glyph coverage and variation axes, and three checks:
2
+ // 1. not WOFF2: the WOFF2 size, measured with wawoff2 (brotli);
3
+ // 2. subsetting: the characters the page's scripts need, measured with subset-font (harfbuzz) as WOFF2;
4
+ // 3. unused faces: a downloaded face that no rendered text uses (informational: several static weights of one
5
+ // family that could be one variable font).
6
+ // Subsetting is by Unicode block (the scripts the page's text uses), not to the exact text, because text changes.
7
+ import { createHash } from "node:crypto";
8
+ import subsetFont from "subset-font";
9
+ import * as wawoff2 from "wawoff2";
10
+ import { digestOf, heavyValue } from "./cache.js";
11
+ import { countRanges, detectFormat, intersectRanges, inRanges, parseSfnt, toSfnt } from "./sfnt.js";
12
+ /** Fonts above this size are not subsetted or re-encoded within the per-resource budget. */
13
+ const MEASURE_MAX_BYTES = 12 * 1024 * 1024;
14
+ /**
15
+ * The subset is cut in WASM (harfbuzz) only up to this size: a big CJK face takes seconds to subset, and the scaled estimate
16
+ * (the share of code points kept, of the WOFF2 size) agrees with a measured subset on the census fonts (within 10%).
17
+ */
18
+ const SUBSET_MEASURE_MAX_BYTES = 1024 * 1024;
19
+ /**
20
+ * WOFF2 is measured (brotli 11 in WASM, about 3 s for 0.6 MB, about 1 s for 0.25 MB) only up to this size. Above it the
21
+ * size is estimated from the ratio of a typical WOFF2. On the census fonts a sfnt compresses to 0.37–0.56 of its size.
22
+ */
23
+ const WOFF2_MEASURE_MAX_BYTES = 256 * 1024;
24
+ const WOFF2_RATIO_ESTIMATE = 0.45;
25
+ /** Savings below this are not worth an agent's edit: at least 1 KB and 10% of the file. */
26
+ const MIN_SAVING_BYTES = 1024;
27
+ const MIN_SAVING_SHARE = 0.1;
28
+ /** Text shorter than this says too little about the scripts in use. */
29
+ const MIN_TEXT_CHARS = 20;
30
+ /** Blocks the subset always keeps: basic Latin, Latin-1, general punctuation and currency. */
31
+ const ALWAYS_BLOCKS = [
32
+ { start: 0x0000, end: 0x007f, name: "Basic Latin" },
33
+ { start: 0x0080, end: 0x00ff, name: "Latin-1 Supplement" },
34
+ { start: 0x2000, end: 0x206f, name: "General Punctuation" },
35
+ { start: 0x20a0, end: 0x20cf, name: "Currency Symbols" },
36
+ ];
37
+ /** Blocks a page's text can switch on. Latin Extended Additional holds Vietnamese. */
38
+ const SCRIPT_BLOCKS = [
39
+ { start: 0x0100, end: 0x017f, name: "Latin Extended-A" },
40
+ { start: 0x0180, end: 0x024f, name: "Latin Extended-B" },
41
+ { start: 0x0250, end: 0x02af, name: "IPA Extensions" },
42
+ { start: 0x02b0, end: 0x02ff, name: "Spacing Modifier Letters" },
43
+ { start: 0x0300, end: 0x036f, name: "Combining Diacritical Marks" },
44
+ { start: 0x0370, end: 0x03ff, name: "Greek and Coptic" },
45
+ { start: 0x0400, end: 0x04ff, name: "Cyrillic" },
46
+ { start: 0x0500, end: 0x052f, name: "Cyrillic Supplement" },
47
+ { start: 0x0530, end: 0x058f, name: "Armenian" },
48
+ { start: 0x0590, end: 0x05ff, name: "Hebrew" },
49
+ { start: 0x0600, end: 0x06ff, name: "Arabic" },
50
+ { start: 0x0900, end: 0x097f, name: "Devanagari" },
51
+ { start: 0x0e00, end: 0x0e7f, name: "Thai" },
52
+ { start: 0x10a0, end: 0x10ff, name: "Georgian" },
53
+ { start: 0x1100, end: 0x11ff, name: "Hangul Jamo" },
54
+ { start: 0x1e00, end: 0x1eff, name: "Latin Extended Additional" },
55
+ { start: 0x1f00, end: 0x1fff, name: "Greek Extended" },
56
+ { start: 0x2100, end: 0x214f, name: "Letterlike Symbols" },
57
+ { start: 0x2190, end: 0x21ff, name: "Arrows" },
58
+ { start: 0x2200, end: 0x22ff, name: "Mathematical Operators" },
59
+ { start: 0x2500, end: 0x257f, name: "Box Drawing" },
60
+ { start: 0x25a0, end: 0x25ff, name: "Geometric Shapes" },
61
+ { start: 0x2600, end: 0x26ff, name: "Miscellaneous Symbols" },
62
+ { start: 0x2700, end: 0x27bf, name: "Dingbats" },
63
+ { start: 0x3000, end: 0x303f, name: "CJK Symbols and Punctuation" },
64
+ { start: 0x3040, end: 0x309f, name: "Hiragana" },
65
+ { start: 0x30a0, end: 0x30ff, name: "Katakana" },
66
+ { start: 0xac00, end: 0xd7af, name: "Hangul Syllables" },
67
+ { start: 0x4e00, end: 0x9fff, name: "CJK Unified Ideographs" },
68
+ { start: 0xff00, end: 0xffef, name: "Halfwidth and Fullwidth Forms" },
69
+ ];
70
+ const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
71
+ const hashOf = (text) => createHash("sha1").update(text).digest("hex").slice(0, 12);
72
+ const norm = (family) => family.replace(/["']/g, "").trim().toLowerCase();
73
+ const isFullRange = (range) => !range || /^U\+0{1,4}-10FFFF$/i.test(range.replace(/\s+/g, ""));
74
+ /** Font names and file names come from the page. Keep printable ASCII and cap the length. */
75
+ const printable = (text, limit) => text.replace(/[^\x20-\x7e]/g, "?").slice(0, limit);
76
+ const fileSafe = (text) => text.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 60);
77
+ function isFontResponse(r) {
78
+ if (r.resourceType === "font")
79
+ return true;
80
+ const type = r.contentType;
81
+ if (type.startsWith("font/") || /font-|x-font|woff|opentype|truetype|vnd\.ms-fontobject/.test(type))
82
+ return true;
83
+ return /\.(woff2?|ttf|otf|eot)(\?|#|$)/i.test(r.url);
84
+ }
85
+ function fileName(url) {
86
+ try {
87
+ const last = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
88
+ return fileSafe(last) || "font";
89
+ }
90
+ catch {
91
+ return "font";
92
+ }
93
+ }
94
+ function formatLabel(format, body) {
95
+ if (format === "woff2")
96
+ return "WOFF2";
97
+ if (format === "woff")
98
+ return "WOFF";
99
+ if (format === "eot")
100
+ return "EOT";
101
+ return body.toString("latin1", 0, 4) === "OTTO" ? "OTF" : "TTF";
102
+ }
103
+ /** Unicode block indices the text uses. */
104
+ function blocksUsed(codepoints) {
105
+ const used = new Set();
106
+ for (const cp of codepoints) {
107
+ const block = SCRIPT_BLOCKS.find((b) => cp >= b.start && cp <= b.end);
108
+ if (block)
109
+ used.add(block);
110
+ }
111
+ return used;
112
+ }
113
+ const coversBasicAscii = (ranges) => countRanges(intersectRanges(ranges, [[0x21, 0x7e]])) === 94;
114
+ const toRanges = (blocks) => blocks.map((b) => [b.start, b.end]);
115
+ /** A `pyftsubset --unicodes` value: U+XXXX-YYYY ranges. */
116
+ const unicodesArg = (blocks) => blocks.map((b) => `U+${b.start.toString(16).toUpperCase().padStart(4, "0")}-${b.end.toString(16).toUpperCase().padStart(4, "0")}`).join(",");
117
+ async function inspect(r, ctx, codepoints) {
118
+ const body = r.body;
119
+ if (!body)
120
+ return null;
121
+ const format = detectFormat(body);
122
+ const sfnt = await toSfnt(body, format);
123
+ if (!sfnt)
124
+ return null;
125
+ const info = parseSfnt(sfnt);
126
+ if (!info || info.codepoints === 0)
127
+ return null;
128
+ let textCodepoints = 0;
129
+ for (const cp of codepoints)
130
+ if (inRanges(info.ranges, cp))
131
+ textCodepoints++;
132
+ const privateUse = intersectRanges(info.ranges, [
133
+ [0xe000, 0xf8ff],
134
+ [0xf0000, 0x10ffff],
135
+ ]);
136
+ const iconic = countRanges(privateUse) > info.codepoints / 2;
137
+ const face = ctx.fontFaces.find((f) => f.url !== undefined && f.url === r.url);
138
+ const item = {
139
+ record: r,
140
+ format,
141
+ label: formatLabel(format, body),
142
+ info,
143
+ face: face
144
+ ? { family: face.family, weight: face.weight, style: face.style, status: face.status, unicodeRange: face.unicodeRange }
145
+ : undefined,
146
+ textCodepoints,
147
+ iconic,
148
+ // A slice: the site's own unicode-range narrows it, or (no face URL to tell) it is a partial script without basic
149
+ // Latin, such as one unicode-range file of a family.
150
+ sliced: !isFullRange(face?.unicodeRange) || (info.codepoints < 1000 && !coversBasicAscii(info.ranges)),
151
+ };
152
+ // WOFF2 size of the whole font. Brotli 11 in WASM is slow (about 3 s for 0.6 MB), so it is measured only for small
153
+ // fonts; a larger font gets the ratio of a typical WOFF2 (estimate). A WOFF2 input is its own size.
154
+ const woff2Ratio = format === "woff2" ? r.bytes / sfnt.length : WOFF2_RATIO_ESTIMATE;
155
+ if (format === "woff2")
156
+ item.woff2Bytes = r.bytes;
157
+ else if (sfnt.length <= WOFF2_MEASURE_MAX_BYTES) {
158
+ try {
159
+ // The WOFF2 encode is heavy: once per run by content; over the run's budget the size is estimated instead.
160
+ const woff2 = await heavyValue(`font:woff2:${digestOf(sfnt)}`, async () => (await wawoff2.compress(sfnt)).length);
161
+ if (woff2 !== undefined)
162
+ item.woff2Bytes = woff2;
163
+ else {
164
+ item.woff2Bytes = Math.round(sfnt.length * woff2Ratio);
165
+ item.woff2Estimate = true;
166
+ }
167
+ }
168
+ catch {
169
+ /* no WOFF2 figure: the check is skipped for this font */
170
+ }
171
+ }
172
+ else if (sfnt.length <= MEASURE_MAX_BYTES) {
173
+ item.woff2Bytes = Math.round(sfnt.length * woff2Ratio);
174
+ item.woff2Estimate = true;
175
+ }
176
+ // An unused face is reported as wasted bytes; no subset is needed for it.
177
+ item.unused = unusedReason(item, ctx);
178
+ // Subsetting: keep the blocks the page's scripts use. Skipped for icon fonts, for slices the site already split by
179
+ // unicode-range, for unused faces, and for pages with too little text to say which scripts they use.
180
+ if (!item.unused && !iconic && !item.sliced && ctx.text.length >= MIN_TEXT_CHARS) {
181
+ const blocks = [...new Set([...ALWAYS_BLOCKS, ...blocksUsed(codepoints)])].sort((a, b) => a.start - b.start);
182
+ const keep = intersectRanges(info.ranges, toRanges(blocks));
183
+ const kept = countRanges(keep);
184
+ if (kept > 0 && kept < info.codepoints) {
185
+ if (sfnt.length <= SUBSET_MEASURE_MAX_BYTES) {
186
+ // The sfnt subset is fast; only its WOFF2 encode is slow, so that is measured for small subsets only.
187
+ let text = "";
188
+ for (const [a, b] of keep)
189
+ for (let cp = a; cp <= b; cp++)
190
+ text += String.fromCodePoint(cp);
191
+ const subsetSfnt = Buffer.from(await subsetFont(sfnt, text, { targetFormat: "sfnt" }));
192
+ if (subsetSfnt.length <= WOFF2_MEASURE_MAX_BYTES) {
193
+ const subsetWoff2 = await wawoff2.compress(subsetSfnt);
194
+ item.subset = { bytes: subsetWoff2.length, estimate: false, keptCodepoints: kept, blocks };
195
+ }
196
+ else {
197
+ item.subset = { bytes: Math.round(subsetSfnt.length * woff2Ratio), estimate: true, keptCodepoints: kept, blocks };
198
+ }
199
+ }
200
+ else if (item.woff2Bytes !== undefined) {
201
+ // Too big to subset within budget: scale the WOFF2 size by the share of characters kept.
202
+ item.subset = {
203
+ bytes: Math.round((item.woff2Bytes * kept) / info.codepoints),
204
+ estimate: true,
205
+ keptCodepoints: kept,
206
+ blocks,
207
+ };
208
+ }
209
+ }
210
+ }
211
+ return item;
212
+ }
213
+ /** Why a downloaded face is unused, or undefined when it may be used. Needs the face's URL match and page text. */
214
+ function unusedReason(item, ctx) {
215
+ const face = item.face;
216
+ if (!face || item.iconic || ctx.usedFonts.length === 0 || ctx.text.length < MIN_TEXT_CHARS)
217
+ return undefined;
218
+ // "loaded" means the browser rendered some text with this face (also as a fallback), so it is used.
219
+ if (face.status === "loaded")
220
+ return undefined;
221
+ const family = norm(face.family);
222
+ const sameFamily = ctx.usedFonts.filter((u) => norm(u.family) === family);
223
+ if (sameFamily.length === 0) {
224
+ return `face status ${printable(face.status, 20)}; no rendered text uses the family ${printable(face.family, 60)}`;
225
+ }
226
+ const [lo, hi] = weightRange(face.weight, item.info);
227
+ const styleOk = (faceStyle, usedStyle) => faceStyle.startsWith("normal") ? usedStyle.startsWith("normal") : !usedStyle.startsWith("normal");
228
+ if (!sameFamily.some((u) => u.weight >= lo && u.weight <= hi && styleOk(face.style, u.style))) {
229
+ return `face status ${printable(face.status, 20)}; no rendered text uses this weight and style (${printable(face.weight, 20)} ${printable(face.style, 20)})`;
230
+ }
231
+ return undefined;
232
+ }
233
+ function weightRange(weight, info) {
234
+ const w = weight.trim();
235
+ if (w === "normal")
236
+ return [400, 400];
237
+ if (w === "bold")
238
+ return [700, 700];
239
+ const parts = w.split(/\s+/).map(Number);
240
+ if (parts.length === 2 && parts.every((n) => Number.isFinite(n)))
241
+ return [parts[0], parts[1]];
242
+ if (parts.length === 1 && Number.isFinite(parts[0]))
243
+ return [parts[0], parts[0]];
244
+ const wght = info.axes.find((a) => a.tag === "wght");
245
+ if (wght)
246
+ return [wght.min, wght.max];
247
+ const fallback = info.weightClass ?? 400;
248
+ return [fallback, fallback];
249
+ }
250
+ function findingsOf(items, ctx) {
251
+ const out = [];
252
+ for (const item of items) {
253
+ const { record: r, info, format, label } = item;
254
+ const name = fileName(r.url);
255
+ const displayFamily = printable(item.face?.family ?? info.families[0] ?? name, 60);
256
+ const bytes = r.bytes;
257
+ const id = `font:${hashOf(r.url)}`;
258
+ const common = {
259
+ kind: "font",
260
+ url: r.url,
261
+ bytes,
262
+ details: {
263
+ format,
264
+ numGlyphs: info.numGlyphs,
265
+ codepoints: info.codepoints,
266
+ textCodepoints: item.textCodepoints,
267
+ families: info.families.map((f) => printable(f, 60)),
268
+ weightClass: info.weightClass,
269
+ italic: info.italic,
270
+ variable: info.axes.length > 0,
271
+ axes: info.axes.map((a) => ({ tag: a.tag, min: a.min, max: a.max })),
272
+ woff2Bytes: item.woff2Bytes,
273
+ woff2Estimate: item.woff2Estimate,
274
+ subsetBytes: item.subset?.bytes,
275
+ subsetCodepoints: item.subset?.keptCodepoints,
276
+ iconic: item.iconic,
277
+ },
278
+ };
279
+ const reason = item.unused;
280
+ if (reason) {
281
+ out.push({
282
+ ...common,
283
+ id,
284
+ savedBytes: bytes,
285
+ estimate: false,
286
+ title: `font ${name}: ${label}, ${kb(bytes)}, not used (${reason})`,
287
+ fix: [
288
+ `Remove the @font-face and any <link rel="preload"> for ${displayFamily} (${name}), or stop requesting this weight and style.`,
289
+ "Check before removing: the face may be used after an interaction or on another page, which one scan does not see.",
290
+ ],
291
+ });
292
+ continue;
293
+ }
294
+ const candidates = [];
295
+ if (item.woff2Bytes !== undefined && format !== "woff2") {
296
+ candidates.push({ bytes: item.woff2Bytes, estimate: item.woff2Estimate === true, what: "woff2" });
297
+ }
298
+ if (item.subset)
299
+ candidates.push({ bytes: item.subset.bytes, estimate: item.subset.estimate, what: "subset" });
300
+ if (candidates.length === 0)
301
+ continue;
302
+ const best = candidates.reduce((a, b) => (b.bytes < a.bytes ? b : a));
303
+ const savedBytes = bytes - best.bytes;
304
+ if (savedBytes >= MIN_SAVING_BYTES && savedBytes >= bytes * MIN_SAVING_SHARE) {
305
+ const parts = [];
306
+ if (item.woff2Bytes !== undefined && format !== "woff2") {
307
+ parts.push(`WOFF2 ${kb(item.woff2Bytes)}${item.woff2Estimate ? " (estimate)" : ""}`);
308
+ }
309
+ if (item.subset) {
310
+ parts.push(`subset to the page's scripts ${kb(item.subset.bytes)}${item.subset.estimate ? " (estimate)" : ""}`);
311
+ }
312
+ const fix = [];
313
+ const stem = name.replace(/\.(woff2?|ttf|otf|eot)$/i, "");
314
+ if (item.woff2Bytes !== undefined && format !== "woff2") {
315
+ fix.push(`Serve WOFF2 instead of ${label}: \`woff2_compress ${name}\` (expected ${kb(item.woff2Bytes)} per view${item.woff2Estimate ? ", estimate" : ""}).`);
316
+ }
317
+ if (item.subset) {
318
+ const blocks = item.subset.blocks;
319
+ fix.push(`Subset to the scripts the page uses (${blocks.map((b) => b.name).join(", ")}): ` +
320
+ `\`pyftsubset ${name} --unicodes=${unicodesArg(blocks)} --flavor=woff2 --output-file=${stem}.subset.woff2\` ` +
321
+ `(expected ${kb(item.subset.bytes)} per view${item.subset.estimate ? ", estimate" : ""}).`);
322
+ fix.push("Framework: next/font subsets: ['latin'] (or the scripts above); Google Fonts css2 &subset=latin; @fontsource import the latin.css file. " +
323
+ "Or split the family with unicode-range so each page fetches only the ranges it renders.");
324
+ }
325
+ out.push({
326
+ ...common,
327
+ id,
328
+ savedBytes,
329
+ estimate: best.estimate,
330
+ title: `font ${name}: ${label}, ${kb(bytes)}; ${parts.join("; ")}`,
331
+ fix,
332
+ });
333
+ }
334
+ }
335
+ out.push(...familyFindings(items));
336
+ return out;
337
+ }
338
+ function duplicateFinding(r, first) {
339
+ const name = fileName(r.url);
340
+ return {
341
+ id: `font:${hashOf(r.url)}-dup`,
342
+ kind: "font",
343
+ url: r.url,
344
+ bytes: r.bytes,
345
+ savedBytes: r.bytes,
346
+ estimate: false,
347
+ title: `font ${name}: same bytes as ${fileName(first.url)}, downloaded again (${kb(r.bytes)})`,
348
+ fix: [
349
+ `Load ${fileName(first.url)} from one URL only: keep one @font-face src for it and drop the other copy and any preload that fetches it.`,
350
+ ],
351
+ details: { duplicateOf: printable(first.url, 300) },
352
+ };
353
+ }
354
+ /** Several static files of one family: informational, one variable font would replace them. */
355
+ function familyFindings(items) {
356
+ const groups = new Map();
357
+ for (const item of items) {
358
+ // Variable files and slices of one weight (unicode-range splits) are not a family of static weights.
359
+ if (item.info.axes.length > 0 || item.sliced)
360
+ continue;
361
+ const family = norm(item.face?.family ?? item.info.families[0] ?? "");
362
+ if (!family)
363
+ continue;
364
+ groups.set(family, [...(groups.get(family) ?? []), item]);
365
+ }
366
+ const out = [];
367
+ for (const [family, group] of groups) {
368
+ // One face is a weight and a style: count distinct weight classes and italics, not files.
369
+ const faces = new Map();
370
+ for (const g of group) {
371
+ const weight = g.info.weightClass ?? (g.face ? Number(g.face.weight) : NaN);
372
+ if (Number.isFinite(weight))
373
+ faces.set(`${weight}|${g.info.italic ? "i" : "n"}`, weight);
374
+ }
375
+ if (faces.size < 3)
376
+ continue;
377
+ const weights = [...faces.values()].sort((a, b) => a - b);
378
+ const first = group[0].record;
379
+ const bytes = group.reduce((sum, g) => sum + g.record.bytes, 0);
380
+ out.push({
381
+ id: `font:${hashOf(first.url)}-family`,
382
+ kind: "font",
383
+ url: first.url,
384
+ bytes,
385
+ savedBytes: 0,
386
+ estimate: false,
387
+ title: `font family ${printable(family, 60)}: ${faces.size} static faces in ${group.length} files (weights ${weights[0]}–${weights[weights.length - 1]}) could be one variable font`,
388
+ fix: [
389
+ `Serve one variable font with the wght axis (weights ${weights[0]}–${weights[weights.length - 1]}) instead of ${faces.size} static faces, then drop the static @font-face rules.`,
390
+ ],
391
+ details: { files: group.map((g) => g.record.url), faces: faces.size, weights },
392
+ });
393
+ }
394
+ return out;
395
+ }
396
+ export const analyzer = {
397
+ kind: "font",
398
+ wants(r) {
399
+ return isFontResponse(r);
400
+ },
401
+ async analyze(records, ctx) {
402
+ try {
403
+ const unique = new Map();
404
+ for (const r of records)
405
+ if (r.body && isFontResponse(r) && !unique.has(r.url))
406
+ unique.set(r.url, r);
407
+ if (unique.size === 0)
408
+ return [];
409
+ const codepoints = new Set();
410
+ for (const ch of ctx.text)
411
+ codepoints.add(ch.codePointAt(0) ?? 0);
412
+ const items = [];
413
+ const duplicates = [];
414
+ // The same font bytes under two URLs (a copy on a CDN and a local one) are one font, downloaded twice: the first
415
+ // URL is analysed and each later copy is a finding of its own.
416
+ const firstByDigest = new Map();
417
+ for (const r of unique.values()) {
418
+ try {
419
+ const digest = createHash("sha1").update(r.body ?? "").digest("hex");
420
+ const first = firstByDigest.get(digest);
421
+ if (first) {
422
+ duplicates.push(duplicateFinding(r, first));
423
+ continue;
424
+ }
425
+ firstByDigest.set(digest, r);
426
+ const item = await inspect(r, ctx, codepoints);
427
+ if (item)
428
+ items.push(item);
429
+ }
430
+ catch {
431
+ /* one unreadable font does not stop the others */
432
+ }
433
+ }
434
+ return [...findingsOf(items, ctx), ...duplicates];
435
+ }
436
+ catch {
437
+ return [];
438
+ }
439
+ },
440
+ };
@@ -0,0 +1,8 @@
1
+ import { analyzer as audio } from "./audio.js";
2
+ import { analyzer as compression } from "./compression.js";
3
+ import { analyzer as favicon } from "./favicon.js";
4
+ import { analyzer as font } from "./font.js";
5
+ import { analyzer as lottie } from "./lottie.js";
6
+ import { analyzer as model } from "./model.js";
7
+ import { analyzer as svg } from "./svg.js";
8
+ export const ANALYZERS = [compression, favicon, font, lottie, svg, model, audio];