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/render.js ADDED
@@ -0,0 +1,397 @@
1
+ // Renders pages as written and as minified in a headless browser, and compares screenshots, text,
2
+ // and script errors—what catches CSS and scripts relying on what minification changed
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import { createRequire } from 'node:module';
8
+ import path from 'node:path';
9
+ import { isInside } from './pages.js';
10
+
11
+ /**
12
+ * @typedef {object} Verdict
13
+ * @prop {boolean} equal
14
+ * @prop {boolean} inconclusive Whether, at some viewport, nothing but script errors could be compared, and none differed
15
+ * @prop {string[]} reasons Why the minified page isn’t equal
16
+ * @prop {string[]} notes What limited the comparison
17
+ * @prop {string[]} [blocked] Files pages requested but didn’t get, as symbolic links lead them outside the site
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} Renderer
22
+ * @prop {(page: import('./pages.js').Page, html: string) => Promise<Verdict>} compare
23
+ * @prop {() => Promise<void>} close
24
+ * @prop {Viewport[]} [viewports]
25
+ * @prop {string} [browser] Name and major version
26
+ */
27
+
28
+ /** @typedef {{name: string, channel?: string}} Browser */
29
+
30
+ /** @typedef {{width: number, height: number}} Viewport */
31
+ /** @typedef {{shot: Buffer, text: string, errors: string[], blocked: string[]}} Capture */
32
+
33
+ // @@ Probe a higher or core-based default, weighing speed against memory and inconclusive renders
34
+ export const CONCURRENCY_DEFAULT = 4;
35
+
36
+ /** @type {Viewport[]} */
37
+ export const VIEWPORTS_DEFAULT = [{ width: 1280, height: 800 }, { width: 390, height: 844 }];
38
+
39
+ /** @type {Record<string, string>} */
40
+ const TYPES = {
41
+ '.css': 'text/css',
42
+ '.gif': 'image/gif',
43
+ '.htm': 'text/html',
44
+ '.html': 'text/html',
45
+ '.ico': 'image/x-icon',
46
+ '.jpeg': 'image/jpeg',
47
+ '.jpg': 'image/jpeg',
48
+ '.js': 'text/javascript',
49
+ '.json': 'application/json',
50
+ '.mjs': 'text/javascript',
51
+ '.png': 'image/png',
52
+ '.svg': 'image/svg+xml',
53
+ '.txt': 'text/plain',
54
+ '.webp': 'image/webp',
55
+ '.woff': 'font/woff',
56
+ '.woff2': 'font/woff2'
57
+ };
58
+
59
+ /** @param {Viewport} viewport */
60
+ const describeViewport = ({ width, height }) => `${width}×${height}`;
61
+
62
+ /**
63
+ * Settles as the page work does, or rejects where it takes longer than a responsive page would
64
+ * @template T
65
+ * @param {Promise<T>} work
66
+ * @param {number} limit
67
+ * @returns {Promise<T>}
68
+ */
69
+ async function responsive(work, limit) {
70
+ /** @type {NodeJS.Timeout | undefined} */
71
+ let timer;
72
+ // The work rejects as its context closes, which is no longer of interest
73
+ work.catch(() => {});
74
+ try {
75
+ return await Promise.race([work, new Promise((_, reject) => {
76
+ timer = setTimeout(() => reject(new Error(`Page stopped responding (no answer within ${limit} ms)`)), limit);
77
+ })]);
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Serves a directory on a local port, so that pages load their resources as on the web
85
+ * @param {string | null} root
86
+ * @param {{followLinks?: boolean}} [settings] `followLinks`: serve what symbolic links lead to outside the root
87
+ * @returns {Promise<{origin: string, close: () => Promise<void>}>}
88
+ */
89
+ export async function serve(root, { followLinks = false } = {}) {
90
+ const rootReal = root ? await fs.promises.realpath(root).catch(() => null) : null;
91
+ const server = http.createServer(async (request, response) => {
92
+ let pathname;
93
+ try {
94
+ pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
95
+ } catch {
96
+ response.writeHead(400).end();
97
+ return;
98
+ }
99
+ const file = root ? path.join(root, pathname.endsWith('/') ? `${pathname}index.html` : pathname) : null;
100
+ const inside = Boolean(root && file && isInside(root, file));
101
+ // Forbidden rather than not found, for captures to tell what they didn’t get
102
+ const fileReal = inside && !followLinks ? await fs.promises.realpath(/** @type {string} */ (file)).catch(() => null) : null;
103
+ if (fileReal && !(rootReal && isInside(rootReal, fileReal))) {
104
+ response.writeHead(403).end();
105
+ return;
106
+ }
107
+ const body = inside && file ? await fs.promises.readFile(file).catch(() => null) : null;
108
+ if (!body || !file) {
109
+ response.writeHead(404).end();
110
+ return;
111
+ }
112
+ response.writeHead(200, { 'content-type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream' }).end(body);
113
+ });
114
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined)));
115
+ const { port } = /** @type {import('node:net').AddressInfo} */ (server.address());
116
+ return {
117
+ origin: `http://127.0.0.1:${port}`,
118
+ close: () => new Promise((resolve) => {
119
+ server.closeAllConnections();
120
+ server.close(() => resolve());
121
+ })
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The first line each text has that the other lacks, as a hint at what changed
127
+ * @param {string} a
128
+ * @param {string} b
129
+ */
130
+ function firstDifference(a, b) {
131
+ const [linesA, linesB] = [a, b].map((text) => text.split('\n').map((line) => line.trim()).filter(Boolean));
132
+ const [setA, setB] = [new Set(linesA), new Set(linesB)];
133
+ const lost = linesA?.find((line) => !setB.has(line));
134
+ const gained = linesB?.find((line) => !setA.has(line));
135
+ /** @param {string | undefined} line */
136
+ const quote = (line) => line === undefined ? 'nothing' : `“${line.length > 40 ? `${line.slice(0, 39)}…` : line}”`;
137
+ return lost === undefined && gained === undefined ? 'line breaks or spacing changed' : `${quote(lost)} became ${quote(gained)}`;
138
+ }
139
+
140
+ /**
141
+ * The original’s two captures as one, with what stayed the same between loads and every file
142
+ * either load didn’t get
143
+ * @param {Capture} first
144
+ * @param {Capture} second
145
+ */
146
+ export const combineOriginals = (first, second) => ({
147
+ capture: { ...first, blocked: [...new Set([...first.blocked, ...second.blocked])] },
148
+ stableShot: first.shot.equals(second.shot),
149
+ stableText: first.text === second.text
150
+ });
151
+
152
+ // Playwright’s Chromium, or else browsers people tend to have, as original and minified pages are
153
+ // compared in the same browser
154
+ /** @type {Browser[]} */
155
+ const BROWSERS = [{ name: 'Chromium' }, { name: 'Chrome', channel: 'chrome' }, { name: 'Edge', channel: 'msedge' }];
156
+
157
+ /** The path of Playwright’s command line interface, if installed */
158
+ export function playwrightCli() {
159
+ try {
160
+ return path.join(path.dirname(createRequire(import.meta.url).resolve('playwright/package.json')), 'cli.js');
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Downloads Playwright’s Chromium, showing the installer’s output
168
+ * @returns {Promise<boolean>}
169
+ */
170
+ export const installChromium = () => new Promise((resolve) => {
171
+ const fileCli = playwrightCli();
172
+ if (!fileCli) return resolve(false);
173
+ const child = spawn(process.execPath, [fileCli, 'install', 'chromium'], { stdio: 'inherit' });
174
+ child.on('error', () => resolve(false));
175
+ child.on('close', (code) => resolve(code === 0));
176
+ });
177
+
178
+ /**
179
+ * Runs functions given no more than `limit` at a time, in the order given
180
+ * @param {number} limit
181
+ */
182
+ export function createLimit(limit) {
183
+ let active = 0;
184
+ /** @type {(() => void)[]} */
185
+ const queue = [];
186
+ /**
187
+ * @template T
188
+ * @param {() => Promise<T>} fn
189
+ * @returns {Promise<T>}
190
+ */
191
+ return async (fn) => {
192
+ if (active < limit) {
193
+ active++;
194
+ } else {
195
+ await new Promise((resolve) => queue.push(() => resolve(undefined)));
196
+ }
197
+ try {
198
+ return await fn();
199
+ } finally {
200
+ // Hands the slot to the next in line, if any
201
+ const next = queue.shift();
202
+ if (next) {
203
+ next();
204
+ } else {
205
+ active--;
206
+ }
207
+ }
208
+ };
209
+ }
210
+
211
+ /**
212
+ * A renderer using the first browser that starts, or, where none does, why not
213
+ * @param {{viewports?: Viewport[], timeout?: number, browsers?: Browser[], concurrency?: number, followLinks?: boolean}} [settings] `concurrency`: pages rendered at once; `followLinks`: as for `serve`
214
+ * @returns {Promise<{renderer: Renderer | null, reason: string, missing?: boolean}>}
215
+ */
216
+ export async function createRenderer({ viewports = VIEWPORTS_DEFAULT, timeout = 30000, browsers = BROWSERS, concurrency = CONCURRENCY_DEFAULT, followLinks = false } = {}) {
217
+ if (!viewports.length || viewports.some(({ width, height }) => !(width >= 1 && height >= 1))) {
218
+ throw new Error(`Invalid viewports: ${JSON.stringify(viewports)} (at least one, each at least 1×1)`);
219
+ }
220
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`Invalid concurrency: ${concurrency} (pages rendered at once, at least 1)`);
221
+ /** @type {typeof import('playwright') | null} */
222
+ const playwright = await import('playwright').catch(() => null);
223
+ if (!playwright) return { renderer: null, reason: 'Playwright isn’t installed' };
224
+ /** @type {import('playwright').Browser | null} */
225
+ let browser = null;
226
+ let nameBrowser = '';
227
+ for (const { name, channel } of browsers) {
228
+ try {
229
+ browser = await playwright.chromium.launch(channel ? { channel } : {});
230
+ nameBrowser = `${name} ${browser.version().split('.')[0]}`;
231
+ break;
232
+ } catch {
233
+ // Tries the next browser
234
+ }
235
+ }
236
+ if (!browser) {
237
+ return { renderer: null, reason: `No browser found (${browsers.map(({ name }) => name).join(', ')})—\`npx playwright install chromium\` downloads Chromium`, missing: true };
238
+ }
239
+ const browserOpen = browser;
240
+
241
+ /** @type {Map<string | null, ReturnType<typeof serve>>} */
242
+ const servers = new Map();
243
+ /** @type {WeakMap<import('./pages.js').Page, Map<string, Promise<{capture: Capture, stableShot: boolean, stableText: boolean}>>>} */
244
+ const originals = new WeakMap();
245
+ /** @type {WeakMap<import('./pages.js').Page, Map<string, Promise<Verdict>>>} */
246
+ const verdicts = new WeakMap();
247
+ // Bounds all browser work, which is otherwise started at once
248
+ const limit = createLimit(concurrency);
249
+
250
+ /** @param {import('./pages.js').Page} page */
251
+ async function urlOf(page) {
252
+ if (page.url) return page.url;
253
+ let server = servers.get(page.root);
254
+ if (!server) {
255
+ server = serve(page.root, { followLinks });
256
+ servers.set(page.root, server);
257
+ }
258
+ return `${(await server).origin}/${page.id.split('/').map(encodeURIComponent).join('/')}`;
259
+ }
260
+
261
+ /**
262
+ * @param {import('./pages.js').Page} page
263
+ * @param {string} html
264
+ * @param {Viewport} viewport
265
+ * @returns {Promise<Capture>}
266
+ */
267
+ async function capture(page, html, viewport) {
268
+ const url = await urlOf(page);
269
+ // Requests leave out fragments
270
+ const urlRequested = new URL(url);
271
+ urlRequested.hash = '';
272
+ return limit(async () => {
273
+ const context = await browserOpen.newContext({ viewport, deviceScaleFactor: 1, reducedMotion: 'reduce' });
274
+ try {
275
+ const tab = await context.newPage();
276
+ /** @type {string[]} */
277
+ const errors = [];
278
+ tab.on('pageerror', (err) => errors.push(err.message));
279
+ tab.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
280
+ /** @type {string[]} */
281
+ const blocked = [];
282
+ if (!page.url) {
283
+ tab.on('response', (response) => {
284
+ const target = new URL(response.url());
285
+ if (response.status() === 403 && target.origin === urlRequested.origin) blocked.push(decodeURIComponent(target.pathname.slice(1)));
286
+ });
287
+ }
288
+ await tab.route((target) => target.href === urlRequested.href, (route) => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html }));
289
+ await tab.goto(url, { waitUntil: 'load', timeout });
290
+ // For fonts and two frames, but not beyond the timeout, as either may never come
291
+ await responsive(tab.evaluate(async (wait) => {
292
+ const settled = (async () => {
293
+ await document.fonts?.ready;
294
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
295
+ })();
296
+ await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, wait))]);
297
+ }, timeout), 2 * timeout);
298
+ const shot = await tab.screenshot({ fullPage: true, animations: 'disabled', caret: 'hide', timeout });
299
+ const text = await responsive(tab.evaluate(() => document.body?.innerText ?? ''), timeout);
300
+ return { shot, text, errors, blocked };
301
+ } finally {
302
+ await context.close();
303
+ }
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Renders the original twice, as what differs between loads (e.g., animations, ads, or
309
+ * dates) can’t tell minification apart
310
+ * @param {import('./pages.js').Page} page
311
+ * @param {Viewport} viewport
312
+ */
313
+ function original(page, viewport) {
314
+ const byViewport = originals.get(page) ?? new Map();
315
+ originals.set(page, byViewport);
316
+ const key = describeViewport(viewport);
317
+ let pending = byViewport.get(key);
318
+ if (!pending) {
319
+ pending = (async () => {
320
+ const [first, second] = await Promise.all([capture(page, page.html, viewport), capture(page, page.html, viewport)]);
321
+ return combineOriginals(first, second);
322
+ })();
323
+ byViewport.set(key, pending);
324
+ }
325
+ return pending;
326
+ }
327
+
328
+ /**
329
+ * @param {import('./pages.js').Page} page
330
+ * @param {string} html
331
+ * @returns {Promise<Verdict>}
332
+ */
333
+ async function compareUncached(page, html) {
334
+ const results = await Promise.all(viewports.map(async (viewport) => {
335
+ /** @type {string[]} */
336
+ const reasons = [];
337
+ /** @type {string[]} */
338
+ const notes = [];
339
+ let blind = false;
340
+ const at = describeViewport(viewport);
341
+ const [{ capture: before, stableShot, stableText }, after] = await Promise.all([original(page, viewport), capture(page, html, viewport)]);
342
+ if (!stableShot && !stableText) {
343
+ blind = true;
344
+ notes.push(`Looks and reads different between loads at ${at}, so only script errors were compared`);
345
+ } else if (!stableShot) {
346
+ notes.push(`Looks different between loads at ${at}, so screenshots weren’t compared`);
347
+ } else if (!stableText) {
348
+ notes.push(`Text differs between loads at ${at}, so text wasn’t compared`);
349
+ }
350
+ if (stableShot && !before.shot.equals(after.shot)) {
351
+ reasons.push(`Looks different at ${at}`);
352
+ }
353
+ if (stableText && before.text !== after.text) {
354
+ reasons.push(`Text differs at ${at}: ${firstDifference(before.text, after.text)}`);
355
+ }
356
+ const known = [...before.errors];
357
+ for (const error of after.errors) {
358
+ const index = known.indexOf(error);
359
+ if (index === -1) {
360
+ reasons.push(`New script error at ${at}: ${error.split('\n')[0]?.slice(0, 120)}`);
361
+ } else {
362
+ known.splice(index, 1);
363
+ }
364
+ }
365
+ return { reasons, notes, blind, blocked: [...before.blocked, ...after.blocked] };
366
+ }));
367
+ const reasons = [...new Set(results.flatMap((result) => result.reasons))];
368
+ const notes = [...new Set(results.flatMap((result) => result.notes))];
369
+ const inconclusive = results.some((result) => result.blind) && reasons.length === 0;
370
+ const blocked = [...new Set(results.flatMap((result) => result.blocked))].sort();
371
+ return { equal: reasons.length === 0 && !inconclusive, inconclusive, reasons, notes, blocked };
372
+ }
373
+
374
+ return {
375
+ reason: '',
376
+ renderer: {
377
+ viewports,
378
+ browser: nameBrowser,
379
+ compare(page, html) {
380
+ // The same output, as different options may produce, renders the same
381
+ const byHtml = verdicts.get(page) ?? new Map();
382
+ verdicts.set(page, byHtml);
383
+ let verdict = byHtml.get(html);
384
+ if (!verdict) {
385
+ verdict = compareUncached(page, html);
386
+ byHtml.set(html, verdict);
387
+ }
388
+ return verdict;
389
+ },
390
+ async close() {
391
+ await browserOpen.close();
392
+ await Promise.all([...servers.values()].map(async (server) => (await server).close()));
393
+ }
394
+ }
395
+ };
396
+
397
+ }
package/src/report.js ADDED
@@ -0,0 +1,252 @@
1
+ import { presets as PRESETS } from 'html-minifier';
2
+ import { VISIBLE_BY_DESIGN } from './catalog.js';
3
+ import { paint } from './color.js';
4
+ import { METRICS, NAMES_METRIC } from './measure.js';
5
+ import { MESSAGE_RENDERS_SAME } from './select.js';
6
+
7
+ const URL_SCHEMA = 'https://raw.githubusercontent.com/j9t/html-minifier/main/html-minifier.schema.json';
8
+
9
+ const MAX_LISTED = 3;
10
+
11
+ // Render differences, worded so as not to read as errors
12
+ const LABEL_RENDER = 'Looks different';
13
+
14
+ /** @param {string} level */
15
+ const labelOf = (level) => level === 'render' ? LABEL_RENDER : `${level.charAt(0).toUpperCase()}${level.slice(1)}`;
16
+
17
+ // Share of the size below which whitespace collapsing suggests minified pages
18
+ const SHARE_MINIFIED = 0.01;
19
+
20
+ const ORDER_STATUS = ['kept', 'accepted', 'rejected', 'no-gain', 'no-effect', 'skipped'];
21
+
22
+ // Statuses in words, so as not to read as verdicts on options
23
+ /** @type {Record<string, string>} */
24
+ const LABELS_STATUS = { 'accepted': 'recommended', 'kept': 'kept', 'rejected': 'doesn’t fit', 'no-gain': 'no gain', 'no-effect': 'no effect', 'skipped': 'skipped' };
25
+
26
+ /** @param {string} status */
27
+ const formatStatus = (status) => LABELS_STATUS[status] ?? status;
28
+
29
+ /**
30
+ * The result as an HTML Minifier configuration, sorted by option name
31
+ * @param {import('./select.js').Result} result
32
+ * @param {Record<string, unknown>} [preserved] CLI settings to keep (e.g., `fileExt`)
33
+ * @returns {Record<string, unknown>}
34
+ */
35
+ export function toConfig(result, preserved = {}) {
36
+ return {
37
+ $schema: URL_SCHEMA,
38
+ ...Object.fromEntries(Object.entries({ ...preserved, ...result.options }).sort(([a], [b]) => a.localeCompare(b)))
39
+ };
40
+ }
41
+
42
+ /**
43
+ * The configuration as the HTML Minifier preset needing the fewest changes to have the same
44
+ * effect, if that at least halves the options to list
45
+ * @param {import('./select.js').Result} result
46
+ * @param {Record<string, unknown>} [preserved]
47
+ * @returns {{name: string, changes: string[], config: Record<string, unknown>} | null}
48
+ */
49
+ export function toPresetConfig(result, preserved = {}) {
50
+ const { options } = result;
51
+ const [nearest] = Object.entries(PRESETS).map(([name, preset]) => {
52
+ /** @type {Record<string, unknown>} */
53
+ const presetOptions = preset;
54
+ /** @type {Record<string, unknown>} */
55
+ const overrides = {};
56
+ for (const key of new Set([...Object.keys(presetOptions), ...Object.keys(options)])) {
57
+ const value = key in options ? options[key] : false;
58
+ if (JSON.stringify(value) !== JSON.stringify(presetOptions[key] ?? false)) overrides[key] = value;
59
+ }
60
+ return { name, overrides };
61
+ }).sort((a, b) => Object.keys(a.overrides).length - Object.keys(b.overrides).length);
62
+ if (!nearest || Object.keys(nearest.overrides).length * 2 > Object.keys(options).length) return null;
63
+ const { $schema, ...rest } = toConfig({ ...result, options: nearest.overrides }, preserved);
64
+ return { name: nearest.name, changes: Object.keys(nearest.overrides).sort(), config: { $schema, preset: nearest.name, ...rest } };
65
+ }
66
+
67
+ /** @param {number} bytes */
68
+ const formatBytes = (bytes) => `${bytes.toLocaleString('en-US')} B`;
69
+
70
+ /**
71
+ * @param {number} before
72
+ * @param {number} after
73
+ * @param {boolean} color
74
+ */
75
+ const formatChange = (before, after, color) => {
76
+ const share = before ? Math.abs(1 - after / before) * 100 : 0;
77
+ const change = after === before || !share
78
+ ? paint('dim', '±0%', color)
79
+ : paint(after < before ? 'success' : 'failure', `${after < before ? '−' : '+'}${share.toFixed(1)}%`, color);
80
+ return `${formatBytes(before)} → ${formatBytes(after)} (${change})`;
81
+ };
82
+
83
+ /**
84
+ * The folder all pages are in, with a trailing slash, if any
85
+ * @param {string[]} pages
86
+ */
87
+ function sharedFolder(pages) {
88
+ const [first = [], ...rest] = pages.map((page) => page.split('/').slice(0, -1));
89
+ let length = 0;
90
+ while (length < first.length && rest.every((segments) => segments[length] === first[length])) length++;
91
+ return length ? `${first.slice(0, length).join('/')}/` : '';
92
+ }
93
+
94
+ /** @param {string[]} items */
95
+ const listShort = (items) => items.length > MAX_LISTED
96
+ ? `${items.slice(0, MAX_LISTED).join(', ')}, and ${items.length - MAX_LISTED} more`
97
+ : items.join(', ');
98
+
99
+ /**
100
+ * Reasons as lines, summing up render differences by pages (with an example) and other reasons
101
+ * by message
102
+ * @param {import('./select.js').Reason[]} reasons
103
+ * @returns {string[]}
104
+ */
105
+ function condenseReasons(reasons) {
106
+ /** @type {string[]} */
107
+ const lines = [];
108
+ let pagesRenderedBefore = '';
109
+ for (let index = 0; index < reasons.length;) {
110
+ const level = /** @type {import('./select.js').Reason} */ (reasons[index]).level;
111
+ // Reasons of a kind in a row: render differences, info, or anything else
112
+ const kind = level === 'render' || level === 'info' ? level : 'other';
113
+ /** @type {import('./select.js').Reason[]} */
114
+ const run = [];
115
+ for (let next = reasons[index]; next && (next.level === 'render' || next.level === 'info' ? next.level : 'other') === kind; next = reasons[++index]) run.push(next);
116
+
117
+ if (kind === 'render') {
118
+ const pages = [...new Set(run.flatMap((reason) => reason.page ?? []))];
119
+ const count = `${pages.length} page${pages.length === 1 ? '' : 's'}`;
120
+ if (pages.join('\u0000') === pagesRenderedBefore) {
121
+ lines.push(`${LABEL_RENDER} on the same ${count}`);
122
+ } else {
123
+ const example = /** @type {import('./select.js').Reason} */ (run.find((reason) => reason.message.includes('“')) ?? run[0]);
124
+ lines.push(`${LABEL_RENDER} on ${count} (${listShort(pages)}), e.g., ${example.page ? `${example.page}: ` : ''}${example.message}`);
125
+ }
126
+ pagesRenderedBefore = pages.join('\u0000');
127
+ continue;
128
+ }
129
+ /** @type {Map<string, {level: string, message: string, pages: string[]}>} */
130
+ const byMessage = new Map();
131
+ for (const reason of run) {
132
+ const key = `${reason.level}\u0000${reason.message}`;
133
+ const entry = byMessage.get(key) ?? { level: reason.level, message: reason.message, pages: [] };
134
+ if (reason.page) entry.pages.push(reason.page);
135
+ byMessage.set(key, entry);
136
+ }
137
+ for (const { level: levelEntry, message, pages } of byMessage.values()) {
138
+ lines.push(`${labelOf(levelEntry)}${pages.length ? ` (${listShort(pages)})` : ''}: ${message}`);
139
+ }
140
+ }
141
+ return lines;
142
+ }
143
+
144
+ /**
145
+ * @param {string[][]} rows
146
+ */
147
+ function table(rows) {
148
+ const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => (row[column] ?? '').length))) ?? [];
149
+ return rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd()).join('\n');
150
+ }
151
+
152
+ /**
153
+ * @param {import('./select.js').Result} result
154
+ * @param {{renderReason?: string, color?: boolean, verbose?: boolean}} [settings]
155
+ * @returns {string}
156
+ */
157
+ export function formatReport(result, { renderReason = '', color = false, verbose = false } = {}) {
158
+ const { blocked = [] } = result;
159
+ const { metric } = result;
160
+ const dirShared = sharedFolder(result.pages);
161
+ const evaluations = result.evaluations.map((evaluation) => ({
162
+ ...evaluation,
163
+ reasons: evaluation.reasons.map((reason) => reason.page?.startsWith(dirShared) ? { ...reason, page: reason.page.slice(dirShared.length) } : reason)
164
+ .map((reason) => blocked.length && reason.message === MESSAGE_RENDERS_SAME ? { ...reason, message: `${reason.message}, but without files outside the site` } : reason)
165
+ })).sort((a, b) =>
166
+ ORDER_STATUS.indexOf(a.status) - ORDER_STATUS.indexOf(b.status) ||
167
+ (b.contribution ?? b.savings) - (a.contribution ?? a.savings) ||
168
+ a.name.localeCompare(b.name));
169
+
170
+ const renderedAs = `Render-checked in ${result.browser ?? 'Chromium'}${result.viewports.length ? ` at ${result.viewports.join(' and ')}` : ''}`;
171
+ const renderSummary = blocked.length
172
+ ? paint('warning', `${renderedAs}: The recommended configuration may not look the same as the original pages, as files outside the site were missing`, color)
173
+ : result.renderedSame
174
+ ? `${renderedAs}: The recommended configuration looks the same as the original pages`
175
+ : paint('warning', `${renderedAs}: The recommended configuration may not look the same as the original pages (see notes)`, color);
176
+ const lines = [
177
+ `• Evaluated ${result.pagesFound ? `${result.pages.length} of ${result.pagesFound.toLocaleString('en-US')} pages` : `${result.pages.length} page${result.pages.length === 1 ? '' : 's'}`}${dirShared ? ` in ${dirShared}` : ''}${result.pagesFound ? ' (use `--sample` to evaluate more)' : ''}, judging gains by ${NAMES_METRIC[metric]} size`,
178
+ result.rendered
179
+ ? `• ${renderSummary}`
180
+ : `• Not render-checked${renderReason ? `: ${renderReason}` : ''}—options that need a render check aren’t recommended`,
181
+ ...result.rendered && blocked.length ? [`• ${paint('warning', `Rendered without ${blocked.length} file${blocked.length === 1 ? '' : 's'} that symbolic links lead to outside the site (${listShort(blocked)}), which may change how pages look—\`--follow-links\` follows them`, color)}`] : [],
182
+ // Only in development
183
+ ...result.equivalence ? [`• Equivalence-checked with ${result.equivalence}`] : [],
184
+ '',
185
+ table([
186
+ ['Option', 'Status', 'Saves alone', 'Contributes', 'Variant'],
187
+ ...evaluations.map((evaluation) => [
188
+ evaluation.name,
189
+ formatStatus(evaluation.status),
190
+ evaluation.status === 'skipped' ? '' : formatBytes(evaluation.savings),
191
+ evaluation.contribution === undefined ? '' : formatBytes(evaluation.contribution),
192
+ evaluation.variant ?? ''
193
+ ])
194
+ ]),
195
+ '',
196
+ ...METRICS.map((m) => `${m === 'raw' ? 'Uncompressed' : NAMES_METRIC[m]}: ${formatChange(result.baseline[m], result.final[m], color)}`)
197
+ ];
198
+
199
+ if (result.presets.length) {
200
+ const finalMetric = result.final[metric];
201
+ lines.push('', `Compared with HTML Minifier’s presets (${NAMES_METRIC[metric]}):`, table(result.presets.map((preset) => {
202
+ const share = finalMetric ? (preset.size / finalMetric - 1) * 100 : 0;
203
+ const size = preset.errors ? `fails on ${preset.errors} of ${result.pages.length}` : formatBytes(preset.size);
204
+ const relative = preset.errors ? '' : Math.abs(share) < 0.05 ? 'same size' : `${Math.abs(share).toFixed(1)}% ${share > 0 ? 'larger' : 'smaller'}`;
205
+ // Over the pages the preset could minify
206
+ const checked = result.pages.length - preset.errors;
207
+ const ofPages = `of ${checked} page${checked === 1 ? '' : 's'}`;
208
+ const rendering = preset.failing === null || !checked
209
+ ? 'not render-checked'
210
+ : preset.failing
211
+ ? paint('failure', `looks different on ${preset.failing} ${ofPages}${preset.inconclusive ? `, inconclusive on ${preset.inconclusive}` : ''}`, color)
212
+ : preset.inconclusive
213
+ ? `inconclusive on ${preset.inconclusive} ${ofPages}`
214
+ : paint('success', preset.errors ? `looks the same on the other ${checked} page${checked === 1 ? '' : 's'}` : 'looks the same', color);
215
+ const rejected = preset.rejected.length ? `; includes ${paint('failure', preset.rejected.map((name) => `\`${name}\``).join(', '), color)}, which ${preset.rejected.length === 1 ? 'doesn’t' : 'don’t'} fit` : '';
216
+ return [` ${preset.name}`, size, relative, `${rendering}${rejected}`];
217
+ })));
218
+ }
219
+
220
+ // Unminified pages have whitespace to collapse; where there’s next to none, options that change
221
+ // nothing can’t be judged, as minification may have done their work
222
+ const whitespace = evaluations.find((evaluation) => evaluation.name === 'collapseWhitespace');
223
+ if (whitespace && whitespace.status !== 'skipped' && whitespace.savings < result.baseline[metric] * SHARE_MINIFIED) {
224
+ const evaluated = evaluations.filter((evaluation) => evaluation.status !== 'skipped');
225
+ const ineffective = evaluated.filter((evaluation) => evaluation.status === 'no-effect');
226
+ lines.push('', `These pages look minified already: Collapsing whitespace saves less than ${SHARE_MINIFIED * 100}%, and ${ineffective.length} of ${evaluated.length} options have no effect. To evaluate those options, run on pages before minification (e.g., your build output with minification turned off).`);
227
+ }
228
+
229
+ const explained = evaluations.filter((evaluation) => evaluation.reasons.some((reason) => reason.level !== 'info' || (evaluation.status !== 'no-gain' && evaluation.status !== 'no-effect')));
230
+ if (explained.length) {
231
+ lines.push('', 'Details:');
232
+ let condensed = false;
233
+ for (const evaluation of explained) {
234
+ lines.push(` ${evaluation.name} (${formatStatus(evaluation.status)})`);
235
+ const reasonLines = verbose
236
+ ? evaluation.reasons.map((reason) => `${labelOf(reason.level)}${reason.page ? ` (${reason.page})` : ''}: ${reason.message}`)
237
+ : condenseReasons(evaluation.reasons);
238
+ condensed ||= reasonLines.length < evaluation.reasons.length;
239
+ if (evaluation.reasons.some((reason) => reason.level === 'render')) {
240
+ const design = VISIBLE_BY_DESIGN[evaluation.name];
241
+ lines.push(` Why: ${design ? `${design}—that’s how the option works, not an error` : 'Not meant to change how pages look—possibly an HTML Minifier bug worth reporting'}`);
242
+ }
243
+ lines.push(...reasonLines.map((line) => ` ${line}`));
244
+ }
245
+ if (condensed) lines.push(' (Run with `-v` for every page and viewport)');
246
+ }
247
+ if (result.notes.length) {
248
+ lines.push('', 'Notes:', ...result.notes.map((note) => `• ${note}`));
249
+ }
250
+ lines.push('', 'Checks can show that options change these pages, not that they never will—test the configuration on your site.');
251
+ return lines.join('\n');
252
+ }