html-minifier-setup 0.0.0-stage → 0.2.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/src/select.js ADDED
@@ -0,0 +1,524 @@
1
+ // Finds the options that save the most on the given pages without changing them: Each candidate
2
+ // is measured and checked on its own, then the accepted ones are rendered together, isolating
3
+ // options that break rendering on their own or only in combination
4
+
5
+ import { presets as PRESETS } from 'html-minifier';
6
+ import { CANDIDATES, checkCandidates, FALLBACKS, readSchema, unclassified } from './catalog.js';
7
+ import { analyzePage, findHazards } from './hazards.js';
8
+ import { byMetric, sizeOf, sumSizes } from './measure.js';
9
+ import { minifyInWorkers } from './workers.js';
10
+
11
+ /**
12
+ * @typedef {object} Reason
13
+ * @prop {'risk' | 'check' | 'note' | 'render' | 'equivalence' | 'error' | 'info'} level
14
+ * @prop {string} message
15
+ * @prop {string} [page]
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} Evaluation
20
+ * @prop {string} name
21
+ * @prop {Record<string, unknown>} options
22
+ * @prop {'accepted' | 'kept' | 'rejected' | 'no-gain' | 'no-effect' | 'skipped'} status
23
+ * @prop {string} [variant] Fallback in use, if any
24
+ * @prop {number} savings Bytes saved on its own (on top of what it requires, or with it where that saves nothing alone), by the metric
25
+ * @prop {number} [contribution] Bytes the final configuration would lose without it, by the metric
26
+ * @prop {Reason[]} reasons
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} Result
31
+ * @prop {import('./measure.js').Metric} metric
32
+ * @prop {Record<string, unknown>} options Configuration found, including carried options
33
+ * @prop {import('./measure.js').Sizes} baseline
34
+ * @prop {import('./measure.js').Sizes} final
35
+ * @prop {Evaluation[]} evaluations
36
+ * @prop {string[]} pages
37
+ * @prop {number | null} pagesFound Pages the sample was taken from, where it left any out
38
+ * @prop {boolean} rendered
39
+ * @prop {string | null} equivalence Source of the equivalence check, if used (in development)
40
+ * @prop {string[]} notes
41
+ * @prop {PresetComparison[]} presets
42
+ * @prop {boolean} renderedSame Whether the configuration was found to look the same as the original pages, with every file they requested
43
+ * @prop {string[]} blocked Files pages requested but the render check didn’t get, as symbolic links lead them outside the site
44
+ * @prop {string[]} viewports Viewports rendered at, if known
45
+ * @prop {string | null} browser Browser rendered in, if known
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} PresetComparison
50
+ * @prop {string} name
51
+ * @prop {number} size Over the pages the preset could minify, by the metric
52
+ * @prop {number | null} failing Pages rendering differently, if render-checked
53
+ * @prop {number | null} inconclusive Pages whose render check was inconclusive, if render-checked
54
+ * @prop {number} errors Pages the preset couldn’t minify
55
+ * @prop {string[]} rejected Options the preset includes that were rejected
56
+ */
57
+
58
+ /**
59
+ * @typedef {object} Settings
60
+ * @prop {Record<string, unknown>} [base] Options carried into every run
61
+ * @prop {Record<string, unknown>} [values] Candidate values to evaluate instead of `true`
62
+ * @prop {import('./measure.js').Metric} [metric]
63
+ * @prop {string[]} [keep] Candidates to accept regardless of risks and gains
64
+ * @prop {string[]} [skip] Candidates not to evaluate
65
+ * @prop {import('./render.js').Renderer | null} [renderer]
66
+ * @prop {import('./equivalence.js').Equivalence | null} [equivalence] HTML Minifier’s check, in development
67
+ * @prop {(html: string, options: Record<string, unknown>) => Promise<string>} [minify]
68
+ * @prop {number} [pagesFound] Pages the given ones were sampled from
69
+ * @prop {boolean} [presets] Whether to compare the result with HTML Minifier’s presets
70
+ * @prop {{version: string, options: string[]} | null} [schema] HTML Minifier’s options, by default the installed version’s
71
+ * @prop {(event: import('./progress.js').ProgressEvent) => void} [onProgress]
72
+ */
73
+
74
+ const MAX_RENDER_ROUNDS = 12;
75
+ const MAX_REASONS_PER_PAGE = 2;
76
+ export const MESSAGE_RENDERS_SAME = 'Looks the same where checks were needed';
77
+
78
+ // Lets timers run, such as a progress indicator’s, between stretches of synchronous work
79
+ const yieldTurn = () => new Promise((resolve) => setImmediate(resolve));
80
+
81
+ /** @param {unknown} value */
82
+ const stableKey = (value) => JSON.stringify(value, (_, inner) => inner && typeof inner === 'object' && !Array.isArray(inner)
83
+ ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b)))
84
+ : inner instanceof RegExp || typeof inner === 'function' ? String(inner) : inner);
85
+
86
+ /** @param {unknown} err */
87
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err)).split('\n')[0] ?? '';
88
+
89
+ /**
90
+ * @param {import('./pages.js').Page[]} pagesGiven
91
+ * @param {Settings} [settings]
92
+ * @returns {Promise<Result>}
93
+ */
94
+ export async function customize(pagesGiven, settings = {}) {
95
+ const {
96
+ base = {},
97
+ values = {},
98
+ metric = 'gzip',
99
+ keep = [],
100
+ skip = [],
101
+ renderer = null,
102
+ equivalence = null,
103
+ minify = minifyInWorkers,
104
+ pagesFound,
105
+ presets = true,
106
+ schema = readSchema(),
107
+ onProgress = () => {}
108
+ } = settings;
109
+
110
+ checkCandidates([...keep, ...skip]);
111
+
112
+ // By page rather than by ID, as pages from different roots may share one
113
+ /** @type {Map<import('./pages.js').Page, Map<string, Promise<string>>>} */
114
+ const outputs = new Map();
115
+ /**
116
+ * @param {import('./pages.js').Page} page
117
+ * @param {Record<string, unknown>} options
118
+ */
119
+ const run = (page, options) => {
120
+ const byOptions = outputs.get(page) ?? new Map();
121
+ outputs.set(page, byOptions);
122
+ const key = stableKey(options);
123
+ let output = byOptions.get(key);
124
+ if (!output) {
125
+ output = minify(page.html, { ...options, log: () => {} });
126
+ byOptions.set(key, output);
127
+ }
128
+ return output;
129
+ };
130
+
131
+ // Measured by the metric alone but for baseline and final sizes, as compression takes its time
132
+ /** @type {Map<import('./measure.js').Metric, Map<string, Promise<number>>>} */
133
+ const sizes = new Map();
134
+ /**
135
+ * @param {string} text
136
+ * @param {import('./measure.js').Metric} [by]
137
+ */
138
+ const sizeCached = (text, by = metric) => {
139
+ const cache = sizes.get(by) ?? new Map();
140
+ sizes.set(by, cache);
141
+ let size = cache.get(text);
142
+ if (!size) {
143
+ size = sizeOf(text, by);
144
+ cache.set(text, size);
145
+ }
146
+ return size;
147
+ };
148
+ /** @param {string[]} texts */
149
+ const totalSize = async (texts) => (await Promise.all(texts.map((text) => sizeCached(text)))).reduce((sum, size) => sum + size, 0);
150
+ /** @param {Record<string, unknown>} options */
151
+ const totalOf = async (options) => totalSize(await Promise.all(pages.map((page) => run(page, options))));
152
+ /** @param {Record<string, unknown>} options */
153
+ const sizesOf = async (options) => sumSizes(await Promise.all(pages.map(async (page) => {
154
+ const text = await run(page, options);
155
+ const [raw, gzip, brotli] = await Promise.all([sizeCached(text, 'raw'), sizeCached(text, 'gzip'), sizeCached(text, 'brotli')]);
156
+ return { raw, gzip, brotli };
157
+ })));
158
+
159
+ /** @type {Set<string>} */
160
+ const notes = new Set();
161
+ const optionsNew = unclassified(schema?.options ?? []);
162
+ if (optionsNew.length) notes.add(`HTML Minifier ${schema?.version} has options this version of HTML Minifier Setup doesn’t evaluate yet: ${optionsNew.join(', ')}`);
163
+
164
+ // Pages the minifier can’t read with the carried options are left out
165
+ /** @type {import('./pages.js').Page[]} */
166
+ const pages = [];
167
+ const readable = await Promise.allSettled(pagesGiven.map((page) => run(page, base)));
168
+ for (const [index, page] of pagesGiven.entries()) {
169
+ const outcome = readable[index];
170
+ if (outcome?.status === 'fulfilled') {
171
+ pages.push(page);
172
+ } else {
173
+ notes.add(`Left out ${page.id}, which the minifier can’t read: ${messageOf(outcome?.reason)}`);
174
+ }
175
+ }
176
+ if (!pages.length) throw new Error(`The minifier can read none of the pages (${[...notes].join('; ')})—\`continueOnParseError\` in a starting configuration may help`);
177
+
178
+ const contexts = new Map(await Promise.all(pages.map(async (page) => /** @type {const} */ ([page, await analyzePage(page)]))));
179
+
180
+ /** @type {Map<string, Evaluation>} */
181
+ const evaluations = new Map();
182
+ /** @type {Map<string, number>} */
183
+ const variantIndex = new Map();
184
+ let renderLimitReached = false;
185
+ // Pages whose last render of the options in use was inconclusive
186
+ /** @type {Set<string>} */
187
+ let pagesUnchecked = new Set();
188
+
189
+ /** @param {string} name */
190
+ const requirementOf = (name) => CANDIDATES.find((candidate) => candidate.name === name)?.requires;
191
+ /** @param {Evaluation | undefined} evaluation */
192
+ const isInUse = (evaluation) => evaluation?.status === 'accepted' || evaluation?.status === 'kept';
193
+ // Requirements not in use are part of their dependents’ options instead
194
+ /** @param {string} name */
195
+ const contextOptions = (name) => {
196
+ const requirement = evaluations.get(requirementOf(name) ?? '');
197
+ return { ...base, ...(isInUse(requirement) ? requirement?.options : {}) };
198
+ };
199
+ /** @param {Evaluation[]} list */
200
+ const combine = (list) => Object.assign({}, base, ...list.map((evaluation) => evaluation.options));
201
+ const inUse = () => [...evaluations.values()].filter(isInUse);
202
+ /** @type {Set<string>} */
203
+ const blocked = new Set();
204
+ /**
205
+ * Compares with the renderer, taking a comparison that fails as inconclusive
206
+ * @param {import('./pages.js').Page} page
207
+ * @param {string} html
208
+ * @returns {Promise<import('./render.js').Verdict>}
209
+ */
210
+ const compare = async (page, html) => {
211
+ try {
212
+ const verdict = await /** @type {import('./render.js').Renderer} */ (renderer).compare(page, html);
213
+ for (const file of verdict.blocked ?? []) blocked.add(file);
214
+ return verdict;
215
+ } catch (err) {
216
+ return { equal: false, inconclusive: true, reasons: [], notes: [`Couldn’t be render-checked: ${messageOf(err)}`] };
217
+ }
218
+ };
219
+ /** @param {Reason[]} reasons */
220
+ const isBlocking = (reasons) => reasons.some((reason) => reason.level === 'risk' || reason.level === 'equivalence' || reason.level === 'error' || (reason.level === 'check' && !renderer));
221
+
222
+ /**
223
+ * Measures and checks a candidate without rendering
224
+ * @param {string} name
225
+ * @param {Record<string, unknown>} options
226
+ * @param {(index: number, page: import('./pages.js').Page) => Promise<void>} [onPage]
227
+ * @returns {Promise<Omit<Evaluation, 'name' | 'options'>>}
228
+ */
229
+ async function evaluateStatic(name, options, onPage = async () => {}) {
230
+ const ctx = contextOptions(name);
231
+ const full = { ...ctx, ...options };
232
+ /** @type {Reason[]} */
233
+ const reasons = [];
234
+ let changed = false;
235
+ let savings = 0;
236
+ // Started at once, to minify pages in parallel
237
+ const pairs = pages.map((page) => Promise.all([run(page, ctx), run(page, full)]));
238
+ for (const pair of pairs) pair.catch(() => {});
239
+ for (const [index, page] of pages.entries()) {
240
+ await onPage(index, page);
241
+ let outBefore;
242
+ let outAfter;
243
+ try {
244
+ [outBefore, outAfter] = await /** @type {Promise<[string, string]>} */ (pairs[index]);
245
+ } catch (err) {
246
+ reasons.push({ level: 'error', page: page.id, message: `Minification failed: ${messageOf(err)}` });
247
+ continue;
248
+ }
249
+ const [sizeBefore, sizeAfter] = await Promise.all([sizeCached(outBefore), sizeCached(outAfter)]);
250
+ savings += sizeBefore - sizeAfter;
251
+ if (outBefore === outAfter) continue;
252
+ changed = true;
253
+ const context = /** @type {import('./hazards.js').PageContext} */ (contexts.get(page));
254
+ for (const hazard of findHazards(name, context, outBefore, outAfter)) reasons.push({ ...hazard, page: page.id });
255
+ if (equivalence?.supports(name)) {
256
+ try {
257
+ const [difference] = equivalence.compare(page.html, outAfter, full);
258
+ if (difference) reasons.push({ level: 'equivalence', page: page.id, message: `Output differs from the source, likely an HTML Minifier bug worth reporting (${difference})` });
259
+ } catch (err) {
260
+ notes.add(`Equivalence check failed on ${page.id}: ${messageOf(err)}`);
261
+ }
262
+ }
263
+ }
264
+
265
+ if (reasons.some((reason) => reason.level === 'error')) return { status: 'rejected', savings, reasons };
266
+ if (keep.includes(name)) return { status: 'kept', savings, reasons };
267
+ if (!changed) return { status: 'no-effect', savings, reasons: [{ level: 'info', message: 'No effect on these pages' }] };
268
+ if (savings <= 0) return { status: 'no-gain', savings, reasons: [{ level: 'info', message: `Saves nothing ${byMetric(metric)}` }, ...reasons] };
269
+ return { status: isBlocking(reasons) ? 'rejected' : 'accepted', savings, reasons };
270
+ }
271
+
272
+ /**
273
+ * Moves a candidate to its next fallback, or rejects it
274
+ * @param {Evaluation} evaluation
275
+ * @param {Reason[]} reasons
276
+ */
277
+ async function demote(evaluation, reasons) {
278
+ const variants = FALLBACKS[evaluation.name] ?? [];
279
+ evaluation.reasons.push(...reasons);
280
+ for (let index = (variantIndex.get(evaluation.name) ?? -1) + 1; index < variants.length; index++) {
281
+ const { variant, options } = /** @type {{variant: string, options: Record<string, unknown>}} */ (variants[index]);
282
+ const outcome = await evaluateStatic(evaluation.name, options);
283
+ variantIndex.set(evaluation.name, index);
284
+ if (outcome.status === 'accepted') {
285
+ Object.assign(evaluation, { options, variant, savings: outcome.savings, status: 'accepted' });
286
+ const known = new Set(evaluation.reasons.map((reason) => `${reason.level}${reason.page}${reason.message}`));
287
+ evaluation.reasons.push({ level: 'info', message: `Falls back to \`${variant}\`` }, ...outcome.reasons.filter((reason) => !known.has(`${reason.level}${reason.page}${reason.message}`)));
288
+ return;
289
+ }
290
+ evaluation.reasons.push({ level: 'info', message: `Fallback \`${variant}\` ${outcome.status === 'no-gain' || outcome.status === 'no-effect' ? 'saves nothing' : 'doesn’t fit, either'}` });
291
+ }
292
+ evaluation.status = 'rejected';
293
+ dropDependents(evaluation);
294
+ }
295
+
296
+ /**
297
+ * Takes what requires an evaluation that is no longer in use out of use, too
298
+ * @param {Evaluation} evaluation
299
+ */
300
+ function dropDependents(evaluation) {
301
+ for (const dependent of evaluations.values()) {
302
+ if (requirementOf(dependent.name) === evaluation.name && isInUse(dependent)) {
303
+ dependent.status = evaluation.status;
304
+ delete dependent.contribution;
305
+ dependent.reasons.push({ level: 'info', message: `Needs \`${evaluation.name}\`, which is no longer in use` });
306
+ }
307
+ }
308
+ }
309
+
310
+ // 1. Each candidate on its own
311
+ for (const [index, candidate] of CANDIDATES.entries()) {
312
+ const total = CANDIDATES.length * pages.length;
313
+ onProgress({ phase: 'evaluate', detail: candidate.name, current: index * pages.length, total });
314
+ const own = candidate.name in values ? { [candidate.name]: values[candidate.name] } : candidate.options;
315
+ const requirement = candidate.requires ? evaluations.get(candidate.requires) : undefined;
316
+ // A requirement that is safe but saves nothing alone may still pay off along with what needs it
317
+ const paired = requirement && (requirement.status === 'no-gain' || requirement.status === 'no-effect') && !isBlocking(requirement.reasons);
318
+ const options = paired ? { ...requirement.options, ...own } : own;
319
+ /** @type {Evaluation} */
320
+ const evaluation = { name: candidate.name, options, status: 'skipped', savings: 0, reasons: [] };
321
+ evaluations.set(candidate.name, evaluation);
322
+ if (skip.includes(candidate.name)) {
323
+ evaluation.reasons.push({ level: 'info', message: 'Skipped as asked' });
324
+ } else if (requirement && !isInUse(requirement) && !paired) {
325
+ evaluation.reasons.push({ level: 'info', message: `Not evaluated, as it needs \`${requirement.name}\`, which ${requirement.status === 'skipped' ? 'was skipped' : 'doesn’t fit'}` });
326
+ } else {
327
+ Object.assign(evaluation, await evaluateStatic(candidate.name, options, async (step, page) => {
328
+ onProgress({ phase: 'evaluate', detail: `${candidate.name} · ${page.id}`, current: index * pages.length + step, total });
329
+ await yieldTurn();
330
+ }));
331
+ if (paired && (evaluation.status === 'accepted' || evaluation.status === 'kept')) {
332
+ evaluation.reasons.unshift({ level: 'info', message: `Brings in \`${requirement.name}\`, which saves nothing alone` });
333
+ }
334
+ if (evaluation.status === 'rejected' && FALLBACKS[candidate.name]) {
335
+ const reasons = evaluation.reasons;
336
+ evaluation.reasons = [];
337
+ await demote(evaluation, reasons);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Renders all options in use together, isolating those that break rendering on their own or
344
+ * only in combination, until rendering holds
345
+ */
346
+ async function settleRendering() {
347
+ if (!renderer) return;
348
+ /**
349
+ * @param {import('./pages.js').Page[]} subset
350
+ * @param {Record<string, unknown>} options
351
+ * @param {{detail: string, counter?: {done: number}, total?: number}} step Where this is in the current stage, whose renders `counter` counts
352
+ * @returns {Promise<{failures: {page: import('./pages.js').Page, verdict: import('./render.js').Verdict}[], inconclusive: Set<string>}>}
353
+ */
354
+ const failingPages = async (subset, options, { detail, counter = { done: 0 }, total = subset.length }) => {
355
+ onProgress({ phase: 'render', detail, current: counter.done, total });
356
+ // All at once, as the renderer limits how many render
357
+ const verdicts = await Promise.all(subset.map(async (page) => {
358
+ const verdict = await compare(page, await run(page, options));
359
+ for (const note of verdict.notes) notes.add(`${page.id}: ${note}`);
360
+ onProgress({ phase: 'render', detail, current: ++counter.done, total });
361
+ return { page, verdict };
362
+ }));
363
+ return {
364
+ failures: verdicts.filter(({ verdict }) => !verdict.equal && !verdict.inconclusive),
365
+ inconclusive: new Set(verdicts.filter(({ verdict }) => verdict.inconclusive).map(({ page }) => page.id))
366
+ };
367
+ };
368
+ /** @param {{page: import('./pages.js').Page, verdict: import('./render.js').Verdict}[]} failures */
369
+ const renderReasons = (failures) => failures.flatMap(({ page, verdict }) => verdict.reasons.slice(0, MAX_REASONS_PER_PAGE).map((message) => /** @type {Reason} */ ({ level: 'render', page: page.id, message })));
370
+
371
+ let round = 0;
372
+ for (; round < MAX_RENDER_ROUNDS; round++) {
373
+ const { failures, inconclusive } = await failingPages(pages, combine(inUse()), { detail: `${inUse().length} options together${round ? ` (round ${round + 1})` : ''}` });
374
+ pagesUnchecked = inconclusive;
375
+ if (!failures.length) break;
376
+ const subset = failures.map(({ page }) => page);
377
+ const testable = inUse().filter((evaluation) => evaluation.status === 'accepted');
378
+
379
+ const alone = { detail: `${testable.length} options alone`, counter: { done: 0 }, total: testable.length * subset.length };
380
+ const owns = await Promise.all(testable.map((evaluation) => failingPages(subset, { ...contextOptions(evaluation.name), ...evaluation.options }, alone)));
381
+ /** @type {[Evaluation, Reason[]][]} */
382
+ const culprits = testable.flatMap((evaluation, index) => {
383
+ const own = owns[index]?.failures ?? [];
384
+ return own.length ? [/** @type {[Evaluation, Reason[]]} */ ([evaluation, renderReasons(own)])] : [];
385
+ });
386
+ for (const [evaluation, reasons] of culprits) await demote(evaluation, reasons);
387
+ if (culprits.length) continue;
388
+
389
+ // Options that only break rendering together: Keep those saving most while rendering holds,
390
+ // which depends on order
391
+ const kept = inUse().filter((evaluation) => evaluation.status === 'kept');
392
+ /** @type {Evaluation[]} */
393
+ const together = [];
394
+ const counter = { done: 0 };
395
+ for (const evaluation of testable.sort((a, b) => b.savings - a.savings)) {
396
+ const requirement = requirementOf(evaluation.name);
397
+ const { failures: own } = await failingPages(subset, combine([...kept, ...together, ...(requirement ? [/** @type {Evaluation} */ (evaluations.get(requirement))] : []), evaluation]), { detail: `${evaluation.name} added`, counter, total: testable.length * subset.length });
398
+ if (own.length) {
399
+ const others = together.map((other) => `\`${other.name}\``).join(', ');
400
+ await demote(evaluation, renderReasons(own).map((reason) => ({ ...reason, message: `${reason.message}, together with ${others || 'kept options'}` })));
401
+ } else {
402
+ together.push(evaluation);
403
+ }
404
+ }
405
+ }
406
+ if (round === MAX_RENDER_ROUNDS) {
407
+ renderLimitReached = true;
408
+ notes.add('Rendering still differed after the maximum number of rounds; the configuration may not look the same');
409
+ }
410
+ for (const evaluation of inUse()) {
411
+ const checks = evaluation.reasons.filter((reason) => reason.level === 'check');
412
+ const settled = checks.length && !checks.some((reason) => reason.page !== undefined && pagesUnchecked.has(reason.page));
413
+ if (settled && !evaluation.reasons.some((reason) => reason.message === MESSAGE_RENDERS_SAME)) {
414
+ evaluation.reasons.push({ level: 'info', message: MESSAGE_RENDERS_SAME });
415
+ }
416
+ }
417
+ }
418
+
419
+ // 2. All accepted candidates together, rendered
420
+ await settleRendering();
421
+
422
+ /**
423
+ * Measures what each option in use contributes, dropping those that gain nothing alongside
424
+ * the others
425
+ * @returns {Promise<boolean>} Whether any were dropped
426
+ */
427
+ async function settleContributions() {
428
+ let dropped = false;
429
+ // After a drop, only options that gained nothing are measured again, as a drop rarely makes
430
+ // others gain nothing; a full round then confirms
431
+ /** @type {Set<Evaluation> | null} */
432
+ let suspects = null;
433
+ for (;;) {
434
+ const used = inUse();
435
+ const measured = suspects ? used.filter((evaluation) => suspects?.has(evaluation)) : used;
436
+ const final = await totalOf(combine(used));
437
+ let done = 0;
438
+ onProgress({ phase: 'contribute', detail: `${measured.length} options`, current: 0, total: measured.length });
439
+ await Promise.all(measured.map(async (evaluation) => {
440
+ const without = used.filter((other) => other !== evaluation && requirementOf(other.name) !== evaluation.name);
441
+ evaluation.contribution = await totalOf(combine(without)) - final;
442
+ onProgress({ phase: 'contribute', detail: evaluation.name, current: ++done, total: measured.length });
443
+ }));
444
+ const [weakest] = measured
445
+ .filter((evaluation) => evaluation.status === 'accepted' && (evaluation.contribution ?? 0) <= 0)
446
+ .sort((a, b) => (a.contribution ?? 0) - (b.contribution ?? 0));
447
+ if (weakest) {
448
+ weakest.status = 'no-gain';
449
+ weakest.reasons.push({ level: 'info', message: `Saves nothing alongside the other options (${weakest.contribution} B ${byMetric(metric)})` });
450
+ delete weakest.contribution;
451
+ dropDependents(weakest);
452
+ dropped = true;
453
+ const remaining = inUse().filter((evaluation) => evaluation.status === 'accepted' && (evaluation.contribution ?? 0) <= 0);
454
+ suspects = remaining.length ? new Set(remaining) : null;
455
+ } else if (suspects) {
456
+ suspects = null;
457
+ } else {
458
+ return dropped;
459
+ }
460
+ }
461
+ }
462
+
463
+ // 3. What each option contributes; as fewer options may render differently, too, rendering
464
+ // is settled again until no more are dropped
465
+ while (await settleContributions() && renderer) await settleRendering();
466
+ const options = combine(inUse());
467
+ // Measured while presets render
468
+ const measuring = Promise.all([sizesOf(base), sizesOf(options)]);
469
+
470
+ // 4. HTML Minifier’s presets for comparison
471
+ const presetsCompared = presets ? Object.entries(PRESETS) : [];
472
+ let presetsDone = 0;
473
+ onProgress({ phase: 'presets', detail: presetsCompared.map(([name]) => name).join(', '), current: 0, total: presetsCompared.length });
474
+ /** @type {PresetComparison[]} */
475
+ const comparisons = await Promise.all(presetsCompared.map(async ([name, preset]) => {
476
+ /** @type {Record<string, unknown>} */
477
+ const optionsPreset = { ...base, ...preset };
478
+ /** @type {{page: import('./pages.js').Page, html: string}[]} */
479
+ const minified = [];
480
+ const outcomes = await Promise.allSettled(pages.map((page) => run(page, optionsPreset)));
481
+ for (const [index, page] of pages.entries()) {
482
+ const outcome = outcomes[index];
483
+ if (outcome?.status === 'fulfilled') minified.push({ page, html: outcome.value });
484
+ }
485
+ const errors = pages.length - minified.length;
486
+ const verdicts = renderer ? await Promise.all(minified.map(async ({ page, html }) => {
487
+ const verdict = await compare(page, html);
488
+ for (const note of verdict.notes) notes.add(`${page.id}: ${note}`);
489
+ return verdict;
490
+ })) : null;
491
+ onProgress({ phase: 'presets', detail: name, current: ++presetsDone, total: presetsCompared.length });
492
+ return {
493
+ name,
494
+ size: await totalSize(minified.map(({ html }) => html)),
495
+ failing: verdicts ? verdicts.filter((verdict) => !verdict.equal && !verdict.inconclusive).length : null,
496
+ inconclusive: verdicts ? verdicts.filter((verdict) => verdict.inconclusive).length : null,
497
+ errors,
498
+ rejected: Object.keys(preset).filter((key) => {
499
+ const evaluation = evaluations.get(key);
500
+ // Or in use only as a gentler variant than the preset’s
501
+ return optionsPreset[key] && (evaluation?.status === 'rejected' || (evaluation?.variant && Object.keys(evaluation.options).some((option) => !optionsPreset[option])));
502
+ })
503
+ };
504
+ }));
505
+ const [baseline, final] = await measuring;
506
+
507
+ return {
508
+ metric,
509
+ options,
510
+ baseline,
511
+ final,
512
+ evaluations: [...evaluations.values()],
513
+ pages: pages.map((page) => page.id),
514
+ pagesFound: pagesFound !== undefined && pagesFound > pagesGiven.length ? pagesFound : null,
515
+ rendered: Boolean(renderer),
516
+ equivalence: equivalence?.source ?? null,
517
+ notes: [...notes],
518
+ presets: comparisons,
519
+ renderedSame: Boolean(renderer) && !renderLimitReached && !pagesUnchecked.size && !blocked.size,
520
+ blocked: [...blocked].sort(),
521
+ viewports: (renderer?.viewports ?? []).map(({ width, height }) => `${width}×${height}`),
522
+ browser: renderer?.browser ?? null
523
+ };
524
+ }