html-minifier-setup 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +21 -0
- package/README.md +111 -2
- package/cli.js +199 -0
- package/package.json +57 -3
- package/src/catalog.js +158 -0
- package/src/color.js +17 -0
- package/src/equivalence.js +68 -0
- package/src/hazards.js +329 -0
- package/src/index.js +8 -0
- package/src/measure.js +60 -0
- package/src/pages.js +230 -0
- package/src/progress.js +165 -0
- package/src/render.js +396 -0
- package/src/report.js +251 -0
- package/src/select.js +508 -0
- package/src/setup.js +227 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
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 } from './measure.js';
|
|
5
|
+
import { MESSAGE_RENDERS_SAME } from './select.js';
|
|
6
|
+
|
|
7
|
+
export 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;
|
|
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} · 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} · ${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(`${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.pages.length} page${result.pages.length === 1 ? '' : 's'}${dirShared ? ` in ${dirShared}` : ''}, judging gains by ${metric === 'raw' ? 'uncompressed' : 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
|
+
result.equivalence ? `• Equivalence-checked with ${result.equivalence}` : '• Not equivalence-checked',
|
|
183
|
+
'',
|
|
184
|
+
table([
|
|
185
|
+
['Option', 'Status', 'Saves alone', 'Contributes', 'Variant'],
|
|
186
|
+
...evaluations.map((evaluation) => [
|
|
187
|
+
evaluation.name,
|
|
188
|
+
formatStatus(evaluation.status),
|
|
189
|
+
evaluation.status === 'skipped' ? '' : formatBytes(evaluation.savings),
|
|
190
|
+
evaluation.contribution === undefined ? '' : formatBytes(evaluation.contribution),
|
|
191
|
+
evaluation.variant ?? ''
|
|
192
|
+
])
|
|
193
|
+
]),
|
|
194
|
+
'',
|
|
195
|
+
...METRICS.map((m) => `${m === 'raw' ? 'Uncompressed' : m === 'gzip' ? 'Gzip' : 'Brotli'}: ${formatChange(result.baseline[m], result.final[m], color)}`)
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
if (result.presets.length) {
|
|
199
|
+
const finalMetric = result.final[metric];
|
|
200
|
+
lines.push('', `Compared with HTML Minifier’s presets (${metric === 'raw' ? 'uncompressed' : metric}):`, table(result.presets.map((preset) => {
|
|
201
|
+
const share = finalMetric ? (preset.size / finalMetric - 1) * 100 : 0;
|
|
202
|
+
const size = preset.errors ? `fails on ${preset.errors} of ${result.pages.length}` : formatBytes(preset.size);
|
|
203
|
+
const relative = preset.errors ? '' : Math.abs(share) < 0.05 ? 'same size' : `${Math.abs(share).toFixed(1)}% ${share > 0 ? 'larger' : 'smaller'}`;
|
|
204
|
+
// Over the pages the preset could minify
|
|
205
|
+
const checked = result.pages.length - preset.errors;
|
|
206
|
+
const ofPages = `of ${checked} page${checked === 1 ? '' : 's'}`;
|
|
207
|
+
const rendering = preset.failing === null || !checked
|
|
208
|
+
? 'not render-checked'
|
|
209
|
+
: preset.failing
|
|
210
|
+
? paint('failure', `looks different on ${preset.failing} ${ofPages}${preset.inconclusive ? `, inconclusive on ${preset.inconclusive}` : ''}`, color)
|
|
211
|
+
: preset.inconclusive
|
|
212
|
+
? `inconclusive on ${preset.inconclusive} ${ofPages}`
|
|
213
|
+
: paint('success', preset.errors ? `looks the same on the other ${checked} page${checked === 1 ? '' : 's'}` : 'looks the same', color);
|
|
214
|
+
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` : '';
|
|
215
|
+
return [` ${preset.name}`, size, relative, `${rendering}${rejected}`];
|
|
216
|
+
})));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Unminified pages have whitespace to collapse; where there’s next to none, options that change
|
|
220
|
+
// nothing can’t be judged, as minification may have done their work
|
|
221
|
+
const whitespace = evaluations.find((evaluation) => evaluation.name === 'collapseWhitespace');
|
|
222
|
+
if (whitespace && whitespace.status !== 'skipped' && whitespace.savings < result.baseline[metric] * SHARE_MINIFIED) {
|
|
223
|
+
const evaluated = evaluations.filter((evaluation) => evaluation.status !== 'skipped');
|
|
224
|
+
const ineffective = evaluated.filter((evaluation) => evaluation.status === 'no-effect');
|
|
225
|
+
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).`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const explained = evaluations.filter((evaluation) => evaluation.reasons.some((reason) => reason.level !== 'info' || (evaluation.status !== 'no-gain' && evaluation.status !== 'no-effect')));
|
|
229
|
+
if (explained.length) {
|
|
230
|
+
lines.push('', 'Details:');
|
|
231
|
+
let condensed = false;
|
|
232
|
+
for (const evaluation of explained) {
|
|
233
|
+
lines.push(` ${evaluation.name} (${formatStatus(evaluation.status)})`);
|
|
234
|
+
const reasonLines = verbose
|
|
235
|
+
? evaluation.reasons.map((reason) => `${labelOf(reason.level)}${reason.page ? ` · ${reason.page}` : ''}: ${reason.message}`)
|
|
236
|
+
: condenseReasons(evaluation.reasons);
|
|
237
|
+
condensed ||= reasonLines.length < evaluation.reasons.length;
|
|
238
|
+
if (evaluation.reasons.some((reason) => reason.level === 'render')) {
|
|
239
|
+
const design = VISIBLE_BY_DESIGN[evaluation.name];
|
|
240
|
+
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'}`);
|
|
241
|
+
}
|
|
242
|
+
lines.push(...reasonLines.map((line) => ` ${line}`));
|
|
243
|
+
}
|
|
244
|
+
if (condensed) lines.push(' (Run with `-v` for every page and viewport)');
|
|
245
|
+
}
|
|
246
|
+
if (result.notes.length) {
|
|
247
|
+
lines.push('', 'Notes:', ...result.notes.map((note) => `• ${note}`));
|
|
248
|
+
}
|
|
249
|
+
lines.push('', 'Checks can show that options change these pages, not that they never will—test the configuration on your site.');
|
|
250
|
+
return lines.join('\n');
|
|
251
|
+
}
|