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/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
|
+
export const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
|
|
56
|
+
export 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,8 @@
|
|
|
1
|
+
export { CANDIDATES, CARRIED, EXCLUDED, FALLBACKS, MODIFIERS, PRESERVED, splitConfig } from './catalog.js';
|
|
2
|
+
export { loadEquivalence } from './equivalence.js';
|
|
3
|
+
export { analyzePage, findHazards } from './hazards.js';
|
|
4
|
+
export { measure } from './measure.js';
|
|
5
|
+
export { createPage, loadPages } from './pages.js';
|
|
6
|
+
export { createRenderer } from './render.js';
|
|
7
|
+
export { formatReport, toConfig } from './report.js';
|
|
8
|
+
export { customize } from './select.js';
|
package/src/measure.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
export const GZIP_LEVEL = 6;
|
|
7
|
+
export 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
|
+
/** @type {Sizes} */
|
|
19
|
+
export const SIZES_ZERO = Object.freeze({ raw: 0, gzip: 0, brotli: 0 });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Size by one metric as servers would send it, compressed off the main thread
|
|
23
|
+
* @param {string} text
|
|
24
|
+
* @param {Metric} metric
|
|
25
|
+
* @returns {Promise<number>}
|
|
26
|
+
*/
|
|
27
|
+
export async function sizeOf(text, metric) {
|
|
28
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
29
|
+
if (metric === 'raw') return buffer.length;
|
|
30
|
+
if (metric === 'gzip') return (await gzipCompress(buffer, { level: GZIP_LEVEL })).length;
|
|
31
|
+
return (await brotliCompress(buffer, {
|
|
32
|
+
params: {
|
|
33
|
+
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
34
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
|
|
35
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length
|
|
36
|
+
}
|
|
37
|
+
})).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {Promise<Sizes>}
|
|
43
|
+
*/
|
|
44
|
+
export async function measure(text) {
|
|
45
|
+
const [raw, gzip, brotli] = await Promise.all([sizeOf(text, 'raw'), sizeOf(text, 'gzip'), sizeOf(text, 'brotli')]);
|
|
46
|
+
return { raw, gzip, brotli };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {Sizes} a
|
|
51
|
+
* @param {Sizes} b
|
|
52
|
+
* @returns {Sizes}
|
|
53
|
+
*/
|
|
54
|
+
export const addSizes = (a, b) => ({ raw: a.raw + b.raw, gzip: a.gzip + b.gzip, brotli: a.brotli + b.brotli });
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Sizes[]} list
|
|
58
|
+
* @returns {Sizes}
|
|
59
|
+
*/
|
|
60
|
+
export const sumSizes = (list) => list.reduce(addSizes, SIZES_ZERO);
|
package/src/pages.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} Page
|
|
6
|
+
* @prop {string} id How the page is named in reports
|
|
7
|
+
* @prop {string} html
|
|
8
|
+
* @prop {string | null} url Where the page lives online, if read from there
|
|
9
|
+
* @prop {string | null} file
|
|
10
|
+
* @prop {string | null} root Directory that root-relative references resolve against
|
|
11
|
+
* @prop {(ref: string) => Promise<Resource | null>} load Reads a resource the page references, where local or same-origin
|
|
12
|
+
* @prop {(ref: string, base: string) => string} resolve Turns a reference made by a resource the page references into one made by the page
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {{text: string, ref: string}} Resource `ref` tells where the resource ended up after redirects */
|
|
16
|
+
|
|
17
|
+
// As HTML Minifier’s
|
|
18
|
+
export const EXTENSIONS_DEFAULT = ['html', 'htm', 'shtml', 'shtm'];
|
|
19
|
+
const MAX_REDIRECTS = 5;
|
|
20
|
+
const RE_SCHEME = /^[a-z][\w+.-]*:|^\/\//i;
|
|
21
|
+
const RE_URL_INPUT = /^https?:\/\//i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a path lies within the root (or is the root)
|
|
25
|
+
* @param {string} root
|
|
26
|
+
* @param {string} target
|
|
27
|
+
*/
|
|
28
|
+
export function isInside(root, target) {
|
|
29
|
+
const relative = path.relative(root, target);
|
|
30
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{id: string, html: string, url?: string | null, file?: string | null, root?: string | null, resources?: Record<string, string>}} spec
|
|
35
|
+
* @returns {Page}
|
|
36
|
+
*/
|
|
37
|
+
export function createPage({ id, html, url = null, file = null, root = null, resources }) {
|
|
38
|
+
/** @type {Map<string, Promise<Resource | null>>} */
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} ref
|
|
42
|
+
* @returns {Promise<Resource | null>}
|
|
43
|
+
*/
|
|
44
|
+
const read = async (ref) => {
|
|
45
|
+
if (url) return fetchSameOrigin(url, ref);
|
|
46
|
+
const text = resources ? resources[ref] ?? null : file && root ? await readLocal(file, root, ref) : null;
|
|
47
|
+
return text === null ? null : { text, ref };
|
|
48
|
+
};
|
|
49
|
+
// Local pages resolve as if served from their root
|
|
50
|
+
const location = url ?? `http://page.invalid/${id.split('/').map(encodeURIComponent).join('/')}`;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
html,
|
|
54
|
+
url,
|
|
55
|
+
file,
|
|
56
|
+
root,
|
|
57
|
+
resolve(ref, base) {
|
|
58
|
+
const target = new URL(ref, new URL(base, location));
|
|
59
|
+
return url || target.origin !== new URL(location).origin ? target.href : `${target.pathname}${target.search}`;
|
|
60
|
+
},
|
|
61
|
+
load(ref) {
|
|
62
|
+
let pending = cache.get(ref);
|
|
63
|
+
if (!pending) {
|
|
64
|
+
pending = read(ref).catch(() => null);
|
|
65
|
+
cache.set(ref, pending);
|
|
66
|
+
}
|
|
67
|
+
return pending;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} file
|
|
74
|
+
* @param {string} root
|
|
75
|
+
* @param {string} ref
|
|
76
|
+
*/
|
|
77
|
+
async function readLocal(file, root, ref) {
|
|
78
|
+
if (RE_SCHEME.test(ref)) return null;
|
|
79
|
+
const clean = decodeURI(ref.replace(/[?#].*$/s, ''));
|
|
80
|
+
if (!clean) return null;
|
|
81
|
+
const target = clean.startsWith('/') ? path.join(root, clean) : path.resolve(path.dirname(file), clean);
|
|
82
|
+
if (!isInside(root, target)) return null;
|
|
83
|
+
return fs.promises.readFile(target, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} pageUrl
|
|
88
|
+
* @param {string} ref
|
|
89
|
+
* @returns {Promise<Resource | null>}
|
|
90
|
+
*/
|
|
91
|
+
async function fetchSameOrigin(pageUrl, ref) {
|
|
92
|
+
const { origin } = new URL(pageUrl);
|
|
93
|
+
let target = new URL(ref, pageUrl);
|
|
94
|
+
// Redirects are followed one by one, so that none leads to another origin
|
|
95
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS && target.origin === origin; redirects++) {
|
|
96
|
+
const response = await fetch(target, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
|
97
|
+
const location = response.headers.get('location');
|
|
98
|
+
if (response.status < 300 || response.status > 399 || !location) return response.ok ? { text: await response.text(), ref: target.href } : null;
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
target = new URL(location, target);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Picks pages round-robin across directories, so that a sample covers a site’s sections (and
|
|
107
|
+
* so, likely, its templates) rather than the first directory’s pages
|
|
108
|
+
* @param {string[]} paths
|
|
109
|
+
* @param {number} count
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
export function samplePaths(paths, count) {
|
|
113
|
+
if (paths.length <= count) return paths;
|
|
114
|
+
/** @type {Map<string, string[]>} */
|
|
115
|
+
const byDir = new Map();
|
|
116
|
+
for (const p of [...paths].sort()) {
|
|
117
|
+
const dir = path.dirname(p);
|
|
118
|
+
const queue = byDir.get(dir) ?? [];
|
|
119
|
+
queue.push(p);
|
|
120
|
+
byDir.set(dir, queue);
|
|
121
|
+
}
|
|
122
|
+
// Within a directory, a golden-ratio order spreads picks rather than taking its first pages
|
|
123
|
+
const queues = [...byDir.values()].map((queue) => queue
|
|
124
|
+
.map((item, index) => ({ item, key: (index * 0.6180339887) % 1 }))
|
|
125
|
+
.sort((a, b) => a.key - b.key)
|
|
126
|
+
.map(({ item }) => item));
|
|
127
|
+
/** @type {string[]} */
|
|
128
|
+
const sample = [];
|
|
129
|
+
for (let round = 0; sample.length < count; round++) {
|
|
130
|
+
for (const queue of queues) {
|
|
131
|
+
const item = queue[round];
|
|
132
|
+
if (item !== undefined && sample.length < count) sample.push(item);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return sample;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} dir
|
|
140
|
+
* @param {string[]} extensions
|
|
141
|
+
* @param {string[]} ignore
|
|
142
|
+
* @returns {Promise<string[]>}
|
|
143
|
+
*/
|
|
144
|
+
async function walk(dir, extensions, ignore) {
|
|
145
|
+
/** @type {string[]} */
|
|
146
|
+
const files = [];
|
|
147
|
+
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
148
|
+
if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
|
|
149
|
+
const full = path.join(dir, entry.name);
|
|
150
|
+
if (entry.isDirectory()) {
|
|
151
|
+
files.push(...await walk(full, extensions, ignore));
|
|
152
|
+
} else if (entry.isFile() && (extensions.includes('*') || extensions.includes(path.extname(entry.name).slice(1).toLowerCase()))) {
|
|
153
|
+
files.push(full);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return files;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Symbolic links under the root that lead outside it, which the render check follows only where
|
|
161
|
+
* allowed, as a page’s scripts could send on what they lead to
|
|
162
|
+
* @param {string} root
|
|
163
|
+
* @param {string[]} [ignore]
|
|
164
|
+
* @returns {Promise<{link: string, target: string}[]>}
|
|
165
|
+
*/
|
|
166
|
+
export async function findOutsideLinks(root, ignore = ['node_modules']) {
|
|
167
|
+
const rootReal = await fs.promises.realpath(root);
|
|
168
|
+
/** @type {{link: string, target: string}[]} */
|
|
169
|
+
const links = [];
|
|
170
|
+
/** @param {string} dir */
|
|
171
|
+
const visit = async (dir) => {
|
|
172
|
+
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
173
|
+
if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
|
|
174
|
+
const link = path.join(dir, entry.name);
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
await visit(link);
|
|
177
|
+
} else if (entry.isSymbolicLink()) {
|
|
178
|
+
const target = await fs.promises.realpath(link).catch(() => null);
|
|
179
|
+
if (target && !isInside(rootReal, target)) links.push({ link, target });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
await visit(root);
|
|
184
|
+
return links;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reads pages from files, directories, and URLs
|
|
189
|
+
* @param {string[]} inputs
|
|
190
|
+
* @param {{extensions?: string[], ignore?: string[], sample?: number}} [settings]
|
|
191
|
+
* @returns {Promise<Page[]>}
|
|
192
|
+
*/
|
|
193
|
+
export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignore = ['node_modules'], sample = 20 } = {}) {
|
|
194
|
+
// As HTML Minifier reads them, with `*` for all files
|
|
195
|
+
const extensionsRead = extensions.map((ext) => ext.trim().replace(/^\.+/, '').toLowerCase());
|
|
196
|
+
/** @type {{file: string, root: string}[]} */
|
|
197
|
+
const files = [];
|
|
198
|
+
/** @type {string[]} */
|
|
199
|
+
const urls = [];
|
|
200
|
+
for (const input of inputs) {
|
|
201
|
+
if (RE_URL_INPUT.test(input)) {
|
|
202
|
+
urls.push(input);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const resolved = path.resolve(input);
|
|
206
|
+
const stat = await fs.promises.stat(resolved).catch(() => null);
|
|
207
|
+
if (!stat) throw new Error(`Not found: ${input}`);
|
|
208
|
+
if (stat.isDirectory()) {
|
|
209
|
+
for (const file of await walk(resolved, extensionsRead, ignore)) files.push({ file, root: resolved });
|
|
210
|
+
} else {
|
|
211
|
+
files.push({ file: resolved, root: path.dirname(resolved) });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const keys = samplePaths([...files.map(({ file }) => file), ...urls], sample);
|
|
216
|
+
return Promise.all(keys.map(async (key) => {
|
|
217
|
+
if (RE_URL_INPUT.test(key)) {
|
|
218
|
+
const response = await fetch(key, { signal: AbortSignal.timeout(30000) });
|
|
219
|
+
if (!response.ok) throw new Error(`Couldn’t fetch ${key}: ${response.status}`);
|
|
220
|
+
return createPage({ id: key, html: await response.text(), url: response.url });
|
|
221
|
+
}
|
|
222
|
+
const { root } = /** @type {{file: string, root: string}} */ (files.find(({ file }) => file === key));
|
|
223
|
+
return createPage({
|
|
224
|
+
id: path.relative(root, key).split(path.sep).join('/'),
|
|
225
|
+
html: await fs.promises.readFile(key, 'utf8'),
|
|
226
|
+
file: key,
|
|
227
|
+
root
|
|
228
|
+
});
|
|
229
|
+
}));
|
|
230
|
+
}
|