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/LICENSE.txt +21 -0
- package/README.md +144 -2
- package/cli.js +213 -0
- package/package.json +58 -3
- package/src/catalog.js +175 -0
- package/src/color.js +17 -0
- package/src/equivalence.js +57 -0
- package/src/hazards.js +329 -0
- package/src/index.js +7 -0
- package/src/measure.js +70 -0
- package/src/minify-worker.js +14 -0
- package/src/pages.js +271 -0
- package/src/progress.js +165 -0
- package/src/render.js +397 -0
- package/src/report.js +252 -0
- package/src/select.js +524 -0
- package/src/setup.js +227 -0
- package/src/workers.js +122 -0
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
|
+
}
|