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/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
|
+
}
|