html-minifier-setup 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }