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.
@@ -0,0 +1,57 @@
1
+ // For development: HTML Minifier’s own check that output means the same to browsers
2
+ // as its source, which catches minifier bugs rather than what a site relies on—
3
+ // used where an HTML Minifier checkout sits next to this one, never duplicated here
4
+
5
+ import fs from 'node:fs';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+
8
+ /**
9
+ * @typedef {object} Equivalence
10
+ * @prop {string} source Where the check came from
11
+ * @prop {(name: string) => boolean} supports Whether the check models what an option changes—others would show as differences
12
+ * @prop {(input: string, output: string, options: Record<string, unknown>) => string[]} compare Differences found, if any
13
+ */
14
+
15
+ const FILE_COMPARE = fileURLToPath(new URL('../../html-minifier/test/equivalence/compare.js', import.meta.url));
16
+ // Lists the options the check is run with
17
+ const FILE_RUNNER = fileURLToPath(new URL('../../html-minifier/test/equivalence/runner.js', import.meta.url));
18
+
19
+ /**
20
+ * @param {unknown} difference
21
+ * @returns {string}
22
+ */
23
+ function describe(difference) {
24
+ if (difference && typeof difference === 'object' && 'kind' in difference) {
25
+ const { kind, input, output } = /** @type {{kind: string, input?: string, output?: string}} */ (difference);
26
+ const clip = (/** @type {string | undefined} */ text) => (text ?? '').split('\u0001').join(' ').slice(0, 80);
27
+ return `${kind}: ${clip(input)} → ${clip(output)}`;
28
+ }
29
+ return String(difference);
30
+ }
31
+
32
+ /** @param {URL} url */
33
+ function versionOf(url) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(url, 'utf8')).version ?? '?';
36
+ } catch {
37
+ return '?';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The check of an HTML Minifier checkout next to this one; `null` where there is none
43
+ * @returns {Promise<Equivalence | null>}
44
+ */
45
+ export async function loadEquivalence() {
46
+ if (!fs.existsSync(FILE_COMPARE) || !fs.existsSync(FILE_RUNNER)) return null;
47
+ const module = await Promise.all([import(pathToFileURL(FILE_RUNNER).href), import(pathToFileURL(FILE_COMPARE).href)])
48
+ .then(([runner, compare]) => ({ ...runner, ...compare }))
49
+ .catch(() => null);
50
+ if (typeof module?.compareMarkup !== 'function' || !module.OPTIONS_STRUCTURAL || !module.OPTIONS_CODE) return null;
51
+ const supported = new Set([...Object.keys(module.OPTIONS_STRUCTURAL), ...Object.keys(module.OPTIONS_CODE)]);
52
+ return {
53
+ source: `${FILE_COMPARE} (HTML Minifier ${versionOf(new URL('../../package.json', pathToFileURL(FILE_COMPARE)))}, minifying with installed ${versionOf(new URL(import.meta.resolve('html-minifier/package.json')))})`,
54
+ supports: (name) => supported.has(name),
55
+ compare: (input, output, options) => /** @type {unknown[]} */ (module.compareMarkup(input, output, options)).map(describe)
56
+ };
57
+ }
package/src/hazards.js ADDED
@@ -0,0 +1,329 @@
1
+ // What a page’s own scripts and style sheets rely on that an option may change—found by comparing
2
+ // output with and without the option, and matching what changed against what the page references
3
+
4
+ import { parse } from 'parse5';
5
+
6
+ /**
7
+ * `risk`: likely breaks the page; `check`: may break it, which a render check can settle;
8
+ * `note`: can’t be ruled out
9
+ * @typedef {{level: 'risk' | 'check' | 'note', message: string}} Hazard
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} PageContext
14
+ * @prop {Set<string>} scriptStrings Words in string literals of scripts and event handlers
15
+ * @prop {string} scriptText
16
+ * @prop {string[]} scriptsUnread Script URLs that couldn’t be read
17
+ * @prop {Set<string>} attributeRefs Attribute names that style sheets select on or scripts query
18
+ * @prop {{selector: string, text: string}[]} whitespaceRules Rules that keep whitespace, outside `pre` and `textarea`
19
+ */
20
+
21
+ /** @typedef {import('parse5').DefaultTreeAdapterMap['node']} Node */
22
+ /** @typedef {import('parse5').DefaultTreeAdapterMap['element']} Element */
23
+
24
+ const MAX_PER_KIND = 3;
25
+
26
+ /** @type {Record<string, string>} */
27
+ const MODES = { 'quirks': 'quirks mode', 'limited-quirks': 'almost standards mode', 'no-quirks': 'standards mode' };
28
+ const RE_STRING_LITERAL = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
29
+ const RE_WORD = /[\w-]+/g;
30
+ const RE_ATTRIBUTE_SELECTOR = /\[\s*([\w-]+)\s*(?=[~|^$*]?=|\])/g;
31
+ const RE_CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
32
+ // At-rules without a block, like `@import`, taking quoted strings and escapes whole
33
+ const RE_CSS_STATEMENT = /@[\w-]+(?:\\.|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|[^{};"'\\])*;/g;
34
+ const RE_IMPORT = /@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/gi;
35
+ const RE_ATTRIBUTE_CALL = /\b(?:get|has|remove|set|toggle)Attribute(?:NS)?\(\s*(?:[^,()]*,\s*)?["'`]([\w-]+)/g;
36
+ const RE_WHITESPACE_KEPT = /white-space(?:-collapse)?\s*:\s*(?:pre-wrap|pre-line|pre|break-spaces|preserve-breaks|preserve-spaces|preserve)(?![\w-])/i;
37
+ const RE_READS_COMMENTS = /SHOW_COMMENT|COMMENT_NODE|nodeType\s*[!=]==?\s*8\b|\.childNodes\b[^;]*\.data\b/;
38
+ const RE_SCRIPT_JS = /^(?:|module|(?:application|text)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/jscript|text\/livescript)$/i;
39
+ const SELECTORS_PREFORMATTED = new Set(['pre', 'textarea']);
40
+
41
+ // Conditional comments only matter to Internet Explorer 9 and older
42
+ const RE_COMMENT_CONDITIONAL = /^\[if\b|<!\[endif\]$/i;
43
+ /** @type {[RegExp, string][]} */
44
+ const COMMENTS_MEANINGFUL = [
45
+ [/^\s*\/?ko(?:\s|$)/, 'Knockout binding'],
46
+ [/^\s*google(?:off|on)\s*:/i, 'search appliance directive'],
47
+ [/^\s*\/?esi\b|^\s*<\/?esi:/i, 'Edge Side Includes'],
48
+ [/^\?lit\$|^\s*lit-part\b/, 'Lit marker'],
49
+ [/^\s*\/?\$[!?]?\s*$|^\s*[[\]]\s*$|^\s*(?:teleport|v-if)\b/, 'hydration marker'],
50
+ [/^\s*\/?noindex\s*$/i, 'indexing directive'],
51
+ [/^\s*\/?wp:/, 'WordPress block delimiter'],
52
+ [/^\s*(?:Instance|Template)(?:Begin|End)\b|^\s*#(?:Begin|End)(?:Library|Template)/, 'template editor region']
53
+ ];
54
+
55
+ const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
56
+ const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
57
+
58
+ /** @param {string} text */
59
+ const clip = (text, length = 60) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
60
+
61
+ /**
62
+ * @param {Node} node
63
+ * @param {(node: Node) => void} visit
64
+ */
65
+ function walk(node, visit) {
66
+ visit(node);
67
+ const content = /** @type {{content?: Node}} */ (node).content;
68
+ for (const child of [...('childNodes' in node ? node.childNodes : []), ...(content ? [content] : [])]) {
69
+ walk(/** @type {Node} */ (child), visit);
70
+ }
71
+ }
72
+
73
+ /** @param {string} html */
74
+ function elementsOf(html) {
75
+ /** @type {Element[]} */
76
+ const elements = [];
77
+ walk(parse(html), (node) => {
78
+ if ('tagName' in node) elements.push(node);
79
+ });
80
+ return elements;
81
+ }
82
+
83
+ /**
84
+ * @param {Element} element
85
+ * @param {string} name
86
+ */
87
+ const attr = (element, name) => element.attrs.find((item) => item.name === name)?.value;
88
+
89
+ /** @param {Element} element */
90
+ const textOf = (element) => element.childNodes.map((child) => 'value' in child ? child.value : '').join('');
91
+
92
+ /**
93
+ * Keys counted more often in `before` than in `after`
94
+ * @param {string[]} before
95
+ * @param {string[]} after
96
+ */
97
+ function removed(before, after) {
98
+ /** @type {Map<string, number>} */
99
+ const counts = new Map();
100
+ for (const key of before) counts.set(key, (counts.get(key) ?? 0) + 1);
101
+ for (const key of after) counts.set(key, (counts.get(key) ?? 0) - 1);
102
+ return [...counts].filter(([, count]) => count > 0).map(([key]) => key);
103
+ }
104
+
105
+ /**
106
+ * Whether any element may match a selector, judged by the tag, classes, IDs, and attributes
107
+ * of the last compound of each selector in the list—erring toward a match
108
+ * @param {string} selector
109
+ * @param {Element[]} elements
110
+ */
111
+ function mayMatch(selector, elements) {
112
+ return selector.split(',').some((part) => {
113
+ const compound = (part.trim().replace(/::?[\w-]+(?:\([^)]*\))?/g, '').split(/[\s>+~]+/).pop() ?? '').trim();
114
+ const tag = compound.match(/^[a-z][\w-]*/i)?.[0].toLowerCase();
115
+ const classes = [...compound.matchAll(/\.([\w-]+)/g)].map((match) => match[1] ?? '');
116
+ const ids = [...compound.matchAll(/#([\w-]+)/g)].map((match) => match[1] ?? '');
117
+ const attributes = [...compound.matchAll(/\[\s*([\w-]+)/g)].map((match) => (match[1] ?? '').toLowerCase());
118
+ return elements.some((element) => {
119
+ const own = (attr(element, 'class') ?? '').split(/\s+/);
120
+ return (!tag || element.tagName === tag) &&
121
+ classes.every((name) => own.includes(name)) &&
122
+ ids.every((id) => attr(element, 'id') === id) &&
123
+ attributes.every((name) => element.attrs.some((item) => item.name === name));
124
+ });
125
+ });
126
+ }
127
+
128
+ /** @param {string} css */
129
+ function whitespaceRulesOf(css) {
130
+ /** @type {{selector: string, text: string}[]} */
131
+ const rules = [];
132
+ // Without comments and statements like `@import`, which would otherwise lead the next selector
133
+ for (const match of css.replace(RE_CSS_COMMENT, '').replace(RE_CSS_STATEMENT, '').matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
134
+ const selector = (match[1] ?? '').trim();
135
+ const declaration = (match[2] ?? '').match(RE_WHITESPACE_KEPT)?.[0];
136
+ if (!declaration || selector.startsWith('@')) continue;
137
+ if (selector.split(',').every((part) => SELECTORS_PREFORMATTED.has(part.trim().toLowerCase()))) continue;
138
+ rules.push({ selector, text: `${clip(selector, 40)} { ${declaration} }` });
139
+ }
140
+ return rules;
141
+ }
142
+
143
+ /** @param {string} css */
144
+ const selectorTokens = (css) => {
145
+ let selectors = css.replace(RE_CSS_COMMENT, '');
146
+ // Strips declaration blocks innermost first, so that nested at-rules leave their selectors
147
+ for (let previous = ''; previous !== selectors;) {
148
+ previous = selectors;
149
+ selectors = selectors.replace(/\{[^{}]*\}/g, ',');
150
+ }
151
+ return [...selectors.matchAll(/[.#](-?[_a-zA-Z\u00A0-\uFFFF][\w\u00A0-\uFFFF-]*)/g)].map((match) => `${match[0][0]}${match[1]}`);
152
+ };
153
+
154
+ /**
155
+ * Collects what a page’s scripts and style sheets reference
156
+ * @param {import('./pages.js').Page} page
157
+ * @returns {Promise<PageContext>}
158
+ */
159
+ export async function analyzePage(page) {
160
+ /** @type {string[]} */
161
+ const scripts = [];
162
+ /** @type {string[]} */
163
+ const scriptsUnread = [];
164
+ /** @type {string[]} */
165
+ const styles = [];
166
+ /** @type {Promise<void>[]} */
167
+ const pending = [];
168
+ /** @type {Set<string>} */
169
+ const imported = new Set();
170
+
171
+ /**
172
+ * Adds a style sheet and, where local or same-origin, what it imports
173
+ * @param {string} css
174
+ * @param {string | null} base Reference of the style sheet, if not in the page
175
+ */
176
+ const addStyles = (css, base) => {
177
+ styles.push(css);
178
+ for (const [, , ref = ''] of css.replace(RE_CSS_COMMENT, '').matchAll(RE_IMPORT)) {
179
+ let target;
180
+ try {
181
+ target = base === null ? ref : page.resolve(ref, base);
182
+ } catch {
183
+ continue;
184
+ }
185
+ if (imported.has(target)) continue;
186
+ imported.add(target);
187
+ pending.push(page.load(target).then((sheet) => { if (sheet) addStyles(sheet.text, sheet.ref); }));
188
+ }
189
+ };
190
+
191
+ for (const element of elementsOf(page.html)) {
192
+ for (const { name, value } of element.attrs) {
193
+ if (/^on[a-z]{3,}$/.test(name)) scripts.push(value);
194
+ if (name === 'style') styles.push(`[style]{${value}}`);
195
+ }
196
+ if (element.tagName === 'script' && RE_SCRIPT_JS.test((attr(element, 'type') ?? '').trim())) {
197
+ const src = attr(element, 'src');
198
+ if (src === undefined) {
199
+ scripts.push(textOf(element));
200
+ } else {
201
+ pending.push(page.load(src).then((script) => { script === null ? scriptsUnread.push(src) : scripts.push(script.text); }));
202
+ }
203
+ } else if (element.tagName === 'style') {
204
+ addStyles(textOf(element), null);
205
+ } else if (element.tagName === 'link' && /(?:^|\s)stylesheet(?:\s|$)/i.test(attr(element, 'rel') ?? '')) {
206
+ const href = attr(element, 'href');
207
+ if (href) pending.push(page.load(href).then((sheet) => { if (sheet) addStyles(sheet.text, sheet.ref); }));
208
+ }
209
+ }
210
+ // Imports add to what is pending while it settles
211
+ while (pending.length) await Promise.all(pending.splice(0));
212
+
213
+ const scriptText = scripts.join('\n;\n');
214
+ const css = styles.join('\n');
215
+ /** @type {Set<string>} */
216
+ const scriptStrings = new Set();
217
+ /** @type {Set<string>} */
218
+ const attributeRefs = new Set();
219
+ for (const literal of scriptText.matchAll(RE_STRING_LITERAL)) {
220
+ const content = literal[2] ?? '';
221
+ for (const word of content.match(RE_WORD) ?? []) scriptStrings.add(word);
222
+ for (const match of content.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
223
+ }
224
+ for (const match of scriptText.matchAll(RE_ATTRIBUTE_CALL)) attributeRefs.add((match[1] ?? '').toLowerCase());
225
+ for (const match of css.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
226
+
227
+ return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules: whitespaceRulesOf(css) };
228
+ }
229
+
230
+ /**
231
+ * What may break when an option turns `before` into `after`
232
+ * @param {string} name Candidate name
233
+ * @param {PageContext} context
234
+ * @param {string} before Output without the option
235
+ * @param {string} after Output with the option
236
+ * @returns {Hazard[]}
237
+ */
238
+ export function findHazards(name, context, before, after) {
239
+ if (before === after) return [];
240
+ /** @type {Hazard[]} */
241
+ const hazards = [];
242
+
243
+ if (OPTIONS_WHITESPACE.has(name) && context.whitespaceRules.length) {
244
+ const elements = elementsOf(before);
245
+ const applying = context.whitespaceRules.filter((rule) => mayMatch(rule.selector, elements));
246
+ if (applying.length) {
247
+ hazards.push({ level: 'check', message: `Collapses whitespace that CSS keeps (${applying.slice(0, 2).map((rule) => rule.text).join('; ')})` });
248
+ }
249
+ }
250
+
251
+ if (name === 'removeComments') {
252
+ /** @param {string} html */
253
+ const comments = (html) => {
254
+ /** @type {string[]} */
255
+ const data = [];
256
+ walk(parse(html), (node) => { if (node.nodeName === '#comment' && 'data' in node) data.push(node.data); });
257
+ return data;
258
+ };
259
+ const gone = removed(comments(before), comments(after));
260
+ /** @type {Map<string, number>} */
261
+ const perLabel = new Map();
262
+ for (const data of gone) {
263
+ const conditional = RE_COMMENT_CONDITIONAL.test(data);
264
+ const label = conditional ? 'conditional comment' : COMMENTS_MEANINGFUL.find(([re]) => re.test(data))?.[1];
265
+ if (!label || (perLabel.get(label) ?? 0) >= MAX_PER_KIND) continue;
266
+ perLabel.set(label, (perLabel.get(label) ?? 0) + 1);
267
+ hazards.push(conditional
268
+ ? { level: 'note', message: `Removes a conditional comment, which only Internet Explorer 9 and older read: <!--${clip(data, 40)}-->` }
269
+ : { level: 'risk', message: `Removes a comment that looks meaningful (${label}): <!--${clip(data, 40)}-->` });
270
+ }
271
+ if (gone.length && RE_READS_COMMENTS.test(context.scriptText)) {
272
+ hazards.push({ level: 'risk', message: 'Removes comments, and scripts read comments' });
273
+ }
274
+ }
275
+
276
+ if (name === 'removeEmptyElements') {
277
+ /** @param {string} html */
278
+ const signatures = (html) => elementsOf(html).map((element) => JSON.stringify([element.tagName, attr(element, 'id') ?? '', attr(element, 'class') ?? '']));
279
+ const gone = removed(signatures(before), signatures(after));
280
+ let count = 0;
281
+ for (const signature of gone) {
282
+ const [tag, id, classes] = /** @type {[string, string, string]} */ (JSON.parse(signature));
283
+ const named = [id, ...classes.split(/\s+/)].filter((token) => token && context.scriptStrings.has(token));
284
+ if (named.length && count++ < MAX_PER_KIND) {
285
+ const shown = `<${tag}${id ? ` id="${id}"` : ''}${classes ? ` class="${clip(classes, 30)}"` : ''}>`;
286
+ hazards.push({ level: 'risk', message: `Removes empty ${shown}, which scripts may fill (${named.map((token) => `\`${token}\``).join(', ')})` });
287
+ }
288
+ }
289
+ if (gone.length && context.scriptsUnread.length) {
290
+ hazards.push({ level: 'note', message: `Removes empty elements that unread scripts may fill (${clip(context.scriptsUnread.join(', '))})` });
291
+ }
292
+ }
293
+
294
+ if (name === 'removeUnusedCSS') {
295
+ /** @param {string} html */
296
+ const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) => selectorTokens(textOf(element))))];
297
+ const gone = removed(tokens(before), tokens(after));
298
+ const named = gone.filter((token) => context.scriptStrings.has(token.slice(1)));
299
+ if (named.length) {
300
+ hazards.push({ level: 'risk', message: `Removes rules for ${named.slice(0, MAX_PER_KIND).map((token) => `\`${token}\``).join(', ')}, which scripts name and may add` });
301
+ }
302
+ if (gone.length && context.scriptsUnread.length) {
303
+ hazards.push({ level: 'note', message: `Removes rules that unread scripts may need (${clip(context.scriptsUnread.join(', '))})` });
304
+ }
305
+ }
306
+
307
+ if (OPTIONS_ATTRIBUTES.has(name)) {
308
+ /** @param {string} html */
309
+ const attributes = (html) => elementsOf(html).flatMap((element) => element.attrs.map(({ name: attrName, value }) => JSON.stringify([attrName, value])));
310
+ const changed = new Set(removed(attributes(before), attributes(after)).map((key) => /** @type {string} */ (JSON.parse(key)[0])));
311
+ const referenced = [...changed].filter((attrName) => context.attributeRefs.has(attrName)).sort();
312
+ if (referenced.length) {
313
+ hazards.push({ level: 'risk', message: `Changes ${referenced.map((attrName) => `\`${attrName}\``).join(', ')} attributes, which style sheets or scripts select on` });
314
+ }
315
+ }
316
+
317
+ if (name === 'useShortDoctype') {
318
+ const [modeBefore = '', modeAfter = ''] = [before, after].map((html) => String(parse(html).mode));
319
+ if (modeBefore !== modeAfter) {
320
+ hazards.push({ level: 'check', message: `Switches the page from ${MODES[modeBefore] ?? modeBefore} to ${MODES[modeAfter] ?? modeAfter}, which can change layout` });
321
+ }
322
+ }
323
+
324
+ if (name === 'mergeScripts' && /\bcurrentScript\b/.test(context.scriptText)) {
325
+ hazards.push({ level: 'check', message: 'Scripts use `document.currentScript`, which merging changes' });
326
+ }
327
+
328
+ return hazards;
329
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { CANDIDATES, CARRIED, EXCLUDED, FALLBACKS, MODIFIERS, PRESERVED, splitConfig } from './catalog.js';
2
+ export { analyzePage, findHazards } from './hazards.js';
3
+ export { measure } from './measure.js';
4
+ export { createPage, loadPages } from './pages.js';
5
+ export { createRenderer } from './render.js';
6
+ export { formatReport, toConfig } from './report.js';
7
+ export { customize } from './select.js';
package/src/measure.js ADDED
@@ -0,0 +1,70 @@
1
+ import { promisify } from 'node:util';
2
+ import zlib from 'node:zlib';
3
+
4
+ // As in HTML Minifier’s benchmarks: common defaults for on-the-fly compression (quality 11, for
5
+ // files precompressed at build time, takes seconds per large file)
6
+ const GZIP_LEVEL = 6;
7
+ const BROTLI_QUALITY = 6;
8
+
9
+ const brotliCompress = promisify(zlib.brotliCompress);
10
+ const gzipCompress = promisify(zlib.gzip);
11
+
12
+ /** @typedef {{raw: number, gzip: number, brotli: number}} Sizes */
13
+ /** @typedef {keyof Sizes} Metric */
14
+
15
+ /** @type {Metric[]} */
16
+ export const METRICS = ['raw', 'gzip', 'brotli'];
17
+
18
+ // For prose
19
+ /** @type {Record<Metric, string>} */
20
+ export const NAMES_METRIC = { raw: 'uncompressed', gzip: 'Gzip', brotli: 'Brotli' };
21
+
22
+ /**
23
+ * How much an option saves by the metric, in prose (e.g., “saves nothing with Gzip”)
24
+ * @param {Metric} metric
25
+ */
26
+ export const byMetric = (metric) => metric === 'raw' ? 'uncompressed' : `with ${NAMES_METRIC[metric]}`;
27
+
28
+ /** @type {Sizes} */
29
+ const SIZES_ZERO = Object.freeze({ raw: 0, gzip: 0, brotli: 0 });
30
+
31
+ /**
32
+ * Size by one metric as servers would send it, compressed off the main thread
33
+ * @param {string} text
34
+ * @param {Metric} metric
35
+ * @returns {Promise<number>}
36
+ */
37
+ export async function sizeOf(text, metric) {
38
+ const buffer = Buffer.from(text, 'utf8');
39
+ if (metric === 'raw') return buffer.length;
40
+ if (metric === 'gzip') return (await gzipCompress(buffer, { level: GZIP_LEVEL })).length;
41
+ return (await brotliCompress(buffer, {
42
+ params: {
43
+ [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
44
+ [zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
45
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length
46
+ }
47
+ })).length;
48
+ }
49
+
50
+ /**
51
+ * @param {string} text
52
+ * @returns {Promise<Sizes>}
53
+ */
54
+ export async function measure(text) {
55
+ const [raw, gzip, brotli] = await Promise.all([sizeOf(text, 'raw'), sizeOf(text, 'gzip'), sizeOf(text, 'brotli')]);
56
+ return { raw, gzip, brotli };
57
+ }
58
+
59
+ /**
60
+ * @param {Sizes} a
61
+ * @param {Sizes} b
62
+ * @returns {Sizes}
63
+ */
64
+ export const addSizes = (a, b) => ({ raw: a.raw + b.raw, gzip: a.gzip + b.gzip, brotli: a.brotli + b.brotli });
65
+
66
+ /**
67
+ * @param {Sizes[]} list
68
+ * @returns {Sizes}
69
+ */
70
+ export const sumSizes = (list) => list.reduce(addSizes, SIZES_ZERO);
@@ -0,0 +1,14 @@
1
+ // Minifies what the pool in workers.js sends, answering with the output or the error message
2
+
3
+ import { parentPort } from 'node:worker_threads';
4
+ import { minify } from 'html-minifier';
5
+
6
+ const port = /** @type {import('node:worker_threads').MessagePort} */ (parentPort);
7
+
8
+ port.on('message', async (/** @type {{html: string, options: Record<string, unknown>}} */ { html, options }) => {
9
+ try {
10
+ port.postMessage({ output: await minify(html, { ...options, log: () => {} }) });
11
+ } catch (err) {
12
+ port.postMessage({ error: err instanceof Error ? err.message : String(err) });
13
+ }
14
+ });