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