html-minifier-setup 0.2.0 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/hazards.js +189 -60
package/package.json CHANGED
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "commander": "^15.1.0",
10
10
  "html-minifier": "^5.1.0",
11
+ "lightningcss": "^1.33.0",
11
12
  "parse5": "^8.1.1",
12
13
  "playwright": "^1.65.0"
13
14
  },
@@ -57,5 +58,5 @@
57
58
  "test:watch": "node --test --watch \"test/**/*.test.js\""
58
59
  },
59
60
  "type": "module",
60
- "version": "0.2.0"
61
- }
61
+ "version": "0.3.0"
62
+ }
package/src/hazards.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // What a page’s own scripts and style sheets rely on that an option may change—found by comparing
2
2
  // output with and without the option, and matching what changed against what the page references
3
3
 
4
+ import { transform, transformStyleAttribute } from 'lightningcss';
4
5
  import { parse } from 'parse5';
5
6
 
6
7
  /**
@@ -15,9 +16,18 @@ import { parse } from 'parse5';
15
16
  * @prop {string} scriptText
16
17
  * @prop {string[]} scriptsUnread Script URLs that couldn’t be read
17
18
  * @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
+ * @prop {(CssRule & {text: string})[]} whitespaceRules Rules that keep whitespace, outside `pre` and `textarea`
19
20
  */
20
21
 
22
+ /**
23
+ * A style rule, with its selector as written and, if nested, its parent
24
+ * @typedef {{selectors: Selector[], parent: CssRule | null, declarations: Declaration[], selector: string}} CssRule
25
+ */
26
+
27
+ /** @typedef {import('lightningcss').Declaration} Declaration */
28
+ /** @typedef {import('lightningcss').Rule} Rule */
29
+ /** @typedef {import('lightningcss').Selector} Selector */
30
+ /** @typedef {import('lightningcss').SelectorComponent} SelectorComponent */
21
31
  /** @typedef {import('parse5').DefaultTreeAdapterMap['node']} Node */
22
32
  /** @typedef {import('parse5').DefaultTreeAdapterMap['element']} Element */
23
33
 
@@ -27,15 +37,17 @@ const MAX_PER_KIND = 3;
27
37
  const MODES = { 'quirks': 'quirks mode', 'limited-quirks': 'almost standards mode', 'no-quirks': 'standards mode' };
28
38
  const RE_STRING_LITERAL = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
29
39
  const RE_WORD = /[\w-]+/g;
40
+ // What separates classes in HTML, unlike JavaScript’s `\s`, which includes no-break spaces
41
+ const RE_WHITESPACE_ASCII = /[ \t\n\f\r]+/;
42
+ const RE_ESCAPE_JS = /\\(?:u\{([\da-f]+)\}|u([\da-f]{4})|x([\da-f]{2})|([\s\S]))/gi;
43
+ /** @type {Record<string, string>} */
44
+ const ESCAPES_JS = { 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t', 'v': '\v', '0': '\0' };
30
45
  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
46
  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
47
  const RE_READS_COMMENTS = /SHOW_COMMENT|COMMENT_NODE|nodeType\s*[!=]==?\s*8\b|\.childNodes\b[^;]*\.data\b/;
38
48
  const RE_SCRIPT_JS = /^(?:|module|(?:application|text)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/jscript|text\/livescript)$/i;
49
+ const KEYWORDS_WHITESPACE_KEPT = new Set(['break-spaces', 'pre', 'pre-line', 'pre-wrap', 'preserve', 'preserve-breaks', 'preserve-spaces']);
50
+ const PROPERTIES_WHITESPACE = new Set(['white-space', 'white-space-collapse']);
39
51
  const SELECTORS_PREFORMATTED = new Set(['pre', 'textarea']);
40
52
 
41
53
  // Conditional comments only matter to Internet Explorer 9 and older
@@ -103,53 +115,148 @@ function removed(before, after) {
103
115
  }
104
116
 
105
117
  /**
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
118
+ * Text of a JavaScript string literal, with escapes decoded
119
+ * @param {string} text
110
120
  */
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
- });
121
+ const unescapeJs = (text) => text.replace(RE_ESCAPE_JS, (_, braced, unicode, hex, char) => {
122
+ if (char !== undefined) return ESCAPES_JS[char] ?? char;
123
+ const code = parseInt(braced ?? unicode ?? hex, 16);
124
+ return code <= 0x10FFFF ? String.fromCodePoint(code) : '\uFFFD';
125
+ });
126
+
127
+ /**
128
+ * Components of selectors, including those in arguments like `:is()`, however deeply nested
129
+ * @param {unknown} value
130
+ */
131
+ function componentsOf(value) {
132
+ /** @type {{type?: unknown, name?: unknown}[]} */
133
+ const components = [];
134
+ for (const stack = [value]; stack.length;) {
135
+ const item = stack.pop();
136
+ if (!item || typeof item !== 'object') continue;
137
+ if (!Array.isArray(item) && 'type' in item) components.push(item);
138
+ stack.push(...Object.values(item).reverse());
139
+ }
140
+ return components;
126
141
  }
127
142
 
128
- /** @param {string} css */
129
- function whitespaceRulesOf(css) {
130
- /** @type {{selector: string, text: string}[]} */
143
+ /**
144
+ * Names of the classes (`.name`), IDs (`#name`), and attributes (`[name]`) rules select on
145
+ * @param {CssRule[]} rules
146
+ */
147
+ const namesOf = (rules) => rules.flatMap((rule) => componentsOf(rule.selectors).flatMap(({ type, name }) => {
148
+ const prefix = { class: '.', id: '#', attribute: '[' }[String(type)];
149
+ return prefix && typeof name === 'string' ? [prefix + name] : [];
150
+ }));
151
+
152
+ /**
153
+ * A declaration that keeps whitespace, as text
154
+ * @param {Declaration} declaration
155
+ */
156
+ function keepingWhitespace(declaration) {
157
+ const [name, values] = declaration.property === 'custom' ? [declaration.value.name, declaration.value.value]
158
+ : declaration.property === 'unparsed' ? [declaration.value.propertyId.property, declaration.value.value]
159
+ : [declaration.property, [declaration.value]];
160
+ if (!PROPERTIES_WHITESPACE.has(name.toLowerCase())) return;
161
+ const keywords = /** @type {unknown[]} */ (values).flatMap((value) => {
162
+ if (typeof value === 'string') return value.split(' ');
163
+ const token = /** @type {{type?: unknown, value?: {type?: unknown, value?: unknown}}} */ (value);
164
+ return token.type === 'token' && token.value?.type === 'ident' ? [String(token.value.value)] : [];
165
+ }).map((keyword) => keyword.toLowerCase());
166
+ return keywords.some((keyword) => KEYWORDS_WHITESPACE_KEPT.has(keyword)) ? `${name.toLowerCase()}: ${keywords.join(' ')}` : undefined;
167
+ }
168
+
169
+ /**
170
+ * Style rules of a style sheet, including nested ones, and what it imports; as in browsers,
171
+ * invalid rules are dropped
172
+ * @param {string} css
173
+ */
174
+ function readStyles(css) {
175
+ /** @type {CssRule[]} */
131
176
  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} }` });
177
+ /** @type {string[]} */
178
+ const imports = [];
179
+ const lineStarts = [0, ...[...css.matchAll(/\r\n|[\n\r\f]/g)].map((match) => match.index + match[0].length)];
180
+ /** @param {{line: number, column: number}} loc */
181
+ const selectorAt = ({ line, column }) => {
182
+ const offset = (lineStarts[line] ?? 0) + column - 1;
183
+ const end = css.indexOf('{', offset);
184
+ return css.slice(offset, end < 0 ? undefined : end).trim();
185
+ };
186
+ /**
187
+ * @param {Rule[]} children
188
+ * @param {CssRule | null} parent
189
+ */
190
+ const collect = (children, parent) => {
191
+ for (const child of children) {
192
+ if (child.type === 'import') {
193
+ imports.push(child.value.url);
194
+ } else if (child.type === 'style') {
195
+ const { declarations = [], importantDeclarations = [] } = child.value.declarations ?? {};
196
+ const rule = { selectors: child.value.selectors, parent, declarations: [...declarations, ...importantDeclarations], selector: selectorAt(child.value.loc) };
197
+ rules.push(rule);
198
+ collect(child.value.rules ?? [], rule);
199
+ } else if (child.type === 'scope') {
200
+ // `&` in a scope stands for its root, which, without a selector, may be any element
201
+ const { scopeStart, scopeEnd, rules: scoped, loc } = child.value;
202
+ const scope = { selectors: scopeStart ?? [[]], parent, declarations: [], selector: selectorAt(loc) };
203
+ rules.push(scope);
204
+ if (scopeEnd) rules.push({ ...scope, selectors: scopeEnd, parent: scope });
205
+ collect(scoped, scope);
206
+ } else if (child.type === 'nested-declarations' && parent) {
207
+ const { declarations = [], importantDeclarations = [] } = child.value.declarations ?? {};
208
+ rules.push({ ...parent, selectors: [[{ type: 'nesting' }]], parent, declarations: [...declarations, ...importantDeclarations] });
209
+ } else if ('value' in child && child.value && 'rules' in child.value && Array.isArray(child.value.rules)) {
210
+ collect(/** @type {Rule[]} */ (child.value.rules), parent);
211
+ }
212
+ }
213
+ };
214
+ try {
215
+ transform({ filename: 'style.css', code: Buffer.from(css), errorRecovery: true, visitor: { StyleSheet: (sheet) => collect(sheet.rules, null) } });
216
+ } catch {
217
+ // Errors aren’t recovered from in every case, and a style sheet that can’t be read has no rules
139
218
  }
140
- return rules;
219
+ return { rules, imports };
141
220
  }
142
221
 
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
- };
222
+ /**
223
+ * Rules that some element may match, judged by the tag, classes, IDs, and attributes of the last
224
+ * compound of each selector—erring toward a match
225
+ * @template {CssRule} T
226
+ * @param {T[]} rules
227
+ * @param {Element[]} elements
228
+ */
229
+ function rulesMatching(rules, elements) {
230
+ /** @type {Map<CssRule, Set<Element>>} */
231
+ const matched = new Map();
232
+ /**
233
+ * @param {CssRule} rule
234
+ * @returns {Set<Element>}
235
+ */
236
+ const matching = (rule) => {
237
+ const known = matched.get(rule);
238
+ if (known) return known;
239
+ const compounds = rule.selectors.map((selector) => selector.slice(selector.findLastIndex((component) => component.type === 'combinator') + 1));
240
+ /**
241
+ * @param {SelectorComponent} component
242
+ * @param {Element} element
243
+ */
244
+ const fits = (component, element) => {
245
+ switch (component.type) {
246
+ case 'type': return element.tagName.toLowerCase() === component.name.toLowerCase();
247
+ case 'class': return (attr(element, 'class') ?? '').split(RE_WHITESPACE_ASCII).includes(component.name);
248
+ case 'id': return attr(element, 'id') === component.name;
249
+ case 'attribute': return element.attrs.some((item) => item.name === component.name.toLowerCase());
250
+ case 'nesting': return !rule.parent || matching(rule.parent).has(element);
251
+ default: return true;
252
+ }
253
+ };
254
+ const found = new Set(elements.filter((element) => compounds.some((compound) => compound.every((component) => fits(component, element)))));
255
+ matched.set(rule, found);
256
+ return found;
257
+ };
258
+ return rules.filter((rule) => matching(rule).size);
259
+ }
153
260
 
154
261
  /**
155
262
  * Collects what a page’s scripts and style sheets reference
@@ -161,8 +268,10 @@ export async function analyzePage(page) {
161
268
  const scripts = [];
162
269
  /** @type {string[]} */
163
270
  const scriptsUnread = [];
164
- /** @type {string[]} */
165
- const styles = [];
271
+ /** @type {CssRule[]} */
272
+ const rules = [];
273
+ /** @type {Set<string>} */
274
+ const attributeRefs = new Set();
166
275
  /** @type {Promise<void>[]} */
167
276
  const pending = [];
168
277
  /** @type {Set<string>} */
@@ -174,8 +283,10 @@ export async function analyzePage(page) {
174
283
  * @param {string | null} base Reference of the style sheet, if not in the page
175
284
  */
176
285
  const addStyles = (css, base) => {
177
- styles.push(css);
178
- for (const [, , ref = ''] of css.replace(RE_CSS_COMMENT, '').matchAll(RE_IMPORT)) {
286
+ const styles = readStyles(css);
287
+ rules.push(...styles.rules);
288
+ for (const name of namesOf(styles.rules)) if (name.startsWith('[')) attributeRefs.add(name.slice(1).toLowerCase());
289
+ for (const ref of styles.imports) {
179
290
  let target;
180
291
  try {
181
292
  target = base === null ? ref : page.resolve(ref, base);
@@ -191,7 +302,16 @@ export async function analyzePage(page) {
191
302
  for (const element of elementsOf(page.html)) {
192
303
  for (const { name, value } of element.attrs) {
193
304
  if (/^on[a-z]{3,}$/.test(name)) scripts.push(value);
194
- if (name === 'style') styles.push(`[style]{${value}}`);
305
+ if (name === 'style') {
306
+ /** @type {Declaration[]} */
307
+ const declarations = [];
308
+ try {
309
+ transformStyleAttribute({ code: Buffer.from(value), errorRecovery: true, visitor: { Declaration: (declaration) => { declarations.push(declaration); } } });
310
+ } catch {
311
+ // As with style sheets
312
+ }
313
+ rules.push({ selectors: [[{ type: 'attribute', name: 'style', namespace: null, operation: null }]], parent: null, declarations, selector: '[style]' });
314
+ }
195
315
  }
196
316
  if (element.tagName === 'script' && RE_SCRIPT_JS.test((attr(element, 'type') ?? '').trim())) {
197
317
  const src = attr(element, 'src');
@@ -211,20 +331,29 @@ export async function analyzePage(page) {
211
331
  while (pending.length) await Promise.all(pending.splice(0));
212
332
 
213
333
  const scriptText = scripts.join('\n;\n');
214
- const css = styles.join('\n');
215
334
  /** @type {Set<string>} */
216
335
  const scriptStrings = new Set();
217
- /** @type {Set<string>} */
218
- const attributeRefs = new Set();
219
336
  for (const literal of scriptText.matchAll(RE_STRING_LITERAL)) {
220
337
  const content = literal[2] ?? '';
221
- for (const word of content.match(RE_WORD) ?? []) scriptStrings.add(word);
338
+ // Words, and what whitespace separates, for names like `md:open`
339
+ for (const word of [...content.match(RE_WORD) ?? [], ...content.split(RE_WHITESPACE_ASCII)]) if (word) scriptStrings.add(word);
340
+ // Names in selectors, as in `querySelector('.md\\:open')`, decoded as in style sheets
341
+ if (content.includes('\\')) {
342
+ for (const name of namesOf(readStyles(`${unescapeJs(content)}{}`).rules)) if (!name.startsWith('[')) scriptStrings.add(name.slice(1));
343
+ }
222
344
  for (const match of content.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
223
345
  }
224
346
  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
347
 
227
- return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules: whitespaceRulesOf(css) };
348
+ /** @type {(CssRule & {text: string})[]} */
349
+ const whitespaceRules = [];
350
+ for (const rule of rules) {
351
+ const kept = rule.declarations.map(keepingWhitespace).find(Boolean);
352
+ if (!kept || rule.selectors.every(([component, ...rest]) => !rest.length && component?.type === 'type' && SELECTORS_PREFORMATTED.has(component.name.toLowerCase()))) continue;
353
+ whitespaceRules.push({ ...rule, text: `${clip(rule.selector, 40)} { ${kept} }` });
354
+ }
355
+
356
+ return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules };
228
357
  }
229
358
 
230
359
  /**
@@ -242,7 +371,7 @@ export function findHazards(name, context, before, after) {
242
371
 
243
372
  if (OPTIONS_WHITESPACE.has(name) && context.whitespaceRules.length) {
244
373
  const elements = elementsOf(before);
245
- const applying = context.whitespaceRules.filter((rule) => mayMatch(rule.selector, elements));
374
+ const applying = rulesMatching(context.whitespaceRules, elements);
246
375
  if (applying.length) {
247
376
  hazards.push({ level: 'check', message: `Collapses whitespace that CSS keeps (${applying.slice(0, 2).map((rule) => rule.text).join('; ')})` });
248
377
  }
@@ -280,7 +409,7 @@ export function findHazards(name, context, before, after) {
280
409
  let count = 0;
281
410
  for (const signature of gone) {
282
411
  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));
412
+ const named = [id, ...classes.split(RE_WHITESPACE_ASCII)].filter((token) => token && context.scriptStrings.has(token));
284
413
  if (named.length && count++ < MAX_PER_KIND) {
285
414
  const shown = `<${tag}${id ? ` id="${id}"` : ''}${classes ? ` class="${clip(classes, 30)}"` : ''}>`;
286
415
  hazards.push({ level: 'risk', message: `Removes empty ${shown}, which scripts may fill (${named.map((token) => `\`${token}\``).join(', ')})` });
@@ -293,7 +422,7 @@ export function findHazards(name, context, before, after) {
293
422
 
294
423
  if (name === 'removeUnusedCSS') {
295
424
  /** @param {string} html */
296
- const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) => selectorTokens(textOf(element))))];
425
+ const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) => namesOf(readStyles(textOf(element)).rules).filter((token) => !token.startsWith('['))))];
297
426
  const gone = removed(tokens(before), tokens(after));
298
427
  const named = gone.filter((token) => context.scriptStrings.has(token.slice(1)));
299
428
  if (named.length) {