html-minifier 5.0.2 → 5.0.4

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.
@@ -41,6 +41,8 @@ const fragmentReferenceAttributes = new Set([
41
41
 
42
42
  const attributePattern = /(?:^|[\s/])([-\w:.]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
43
43
  const identifierPattern = /--[\w-]*|-?[A-Za-z_][\w-]*/g;
44
+ const whitespacePattern = /\s/;
45
+ const whitespaceRunPattern = /\s+/;
44
46
  // SVG paints and filters reach elements by ID through `url(#gradient)`, in
45
47
  // presentation attributes as well as in `style`
46
48
  const fragmentURLPattern = /url\(\s*['"]?#([^)'"\s]+)/gi;
@@ -58,8 +60,8 @@ const cssIdentifierPattern = /(?<!\\)[.#]((?:[-_a-zA-Z]|\\[0-9a-fA-F]{1,6}[ \t\n
58
60
  // name used there is off limits even when no element carries it as a class or ID
59
61
  const reservedAtRulePattern = /@(?:-\w+-)?(?:keyframes|counter-style)\s+(-?[_a-zA-Z][\w-]*)/gi;
60
62
  const escapePattern = /\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/g;
61
- // Run over case-folded markup; matches inside comments and scripts only add names
62
- const startTagPattern = /<([a-z][^\s/>]*)/g;
63
+ // Matches inside comments and scripts only add names
64
+ const startTagPattern = /<([a-z][^\s/>]*)/gi;
63
65
 
64
66
  /**
65
67
  * Resolve CSS escape sequences in an identifier.
@@ -83,20 +85,43 @@ function unescapeIdentifier(identifier) {
83
85
  }
84
86
 
85
87
  /**
86
- * Lowercase for matching without disturbing offsets.
87
- *
88
- * `toLowerCase()` can change a string's length—U+0130 becomes two code units—which
89
- * would misalign every offset taken from the result. Folding just ASCII preserves
90
- * length, and tag names are ASCII anyway.
91
- *
88
+ * Add what `identifierPattern` matches in `text`
89
+ * @param {string} text
90
+ * @param {Set<string>} used
91
+ * @param {Set<string>} [names] - Also receives each identifier lowercased
92
+ * @param {Set<string>} [seen] - Identifiers added before, which are skipped
93
+ */
94
+ function addIdentifiers(text, used, names, seen) {
95
+ for (const identifier of text.match(identifierPattern) ?? []) {
96
+ if (seen) {
97
+ if (seen.has(identifier)) {
98
+ continue;
99
+ }
100
+ seen.add(identifier);
101
+ }
102
+ used.add(identifier);
103
+ names?.add(identifier.toLowerCase());
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Add what splitting `text` at `/\s+/` yields, but empty strings
92
109
  * @param {string} text
93
- * @returns {string} Same length as `text`
110
+ * @param {Set<string>} used
94
111
  */
95
- function foldCase(text) {
96
- const lowercased = text.toLowerCase();
97
- return lowercased.length === text.length
98
- ? lowercased
99
- : text.replace(/[A-Z]+/g, uppercase => uppercase.toLowerCase());
112
+ function addTokens(text, used) {
113
+ // Most values are a single token, which needs no array
114
+ if (!whitespacePattern.test(text)) {
115
+ if (text) {
116
+ used.add(text);
117
+ }
118
+ return;
119
+ }
120
+ for (const token of text.split(whitespaceRunPattern)) {
121
+ if (token) {
122
+ used.add(token);
123
+ }
124
+ }
100
125
  }
101
126
 
102
127
  /**
@@ -107,15 +132,21 @@ function foldCase(text) {
107
132
  * quadratically over a document full of near-matches, and bounding it would make
108
133
  * long start tags go unrecognized.
109
134
  *
110
- * @param {string} haystack - Case-folded markup, as returned by `foldCase`
135
+ * Tag names are matched in ASCII case only, as browsers do, and on the markup itself,
136
+ * so offsets need no mapping back.
137
+ *
138
+ * @param {string} html
111
139
  * @param {string} tagName - Lowercase element name
112
140
  * @returns {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>}
113
141
  */
114
- function findRawTextElements(haystack, tagName) {
142
+ function findRawTextElements(html, tagName) {
115
143
  /** @type {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>} */
116
144
  const found = [];
117
- const openTag = '<' + tagName;
118
- const closeTag = '</' + tagName;
145
+ // Without the `u` flag, `i` folds no non-ASCII character into ASCII
146
+ const openTag = new RegExp('<' + tagName, 'gi');
147
+ const closeTag = new RegExp('</' + tagName, 'gi');
148
+ const lengthOpen = tagName.length + 1;
149
+ const lengthClose = tagName.length + 2;
119
150
  // A tag name ends at whitespace, a slash, or the closing bracket—so `<styles>`
120
151
  // and `</scriptfoo>` name different elements and must not match
121
152
  const endsName = (/** @type {string} */ character) =>
@@ -123,17 +154,18 @@ function findRawTextElements(haystack, tagName) {
123
154
 
124
155
  let cursor = 0;
125
156
  for (;;) {
126
- const start = haystack.indexOf(openTag, cursor);
157
+ openTag.lastIndex = cursor;
158
+ const start = openTag.exec(html)?.index ?? -1;
127
159
  if (start === -1) {
128
160
  break;
129
161
  }
130
- if (!endsName(haystack.charAt(start + openTag.length))) {
131
- cursor = start + openTag.length;
162
+ if (!endsName(html.charAt(start + lengthOpen))) {
163
+ cursor = start + lengthOpen;
132
164
  continue;
133
165
  }
134
166
  // A quoted attribute value may hold a `>`, so the tag ends where the parser
135
167
  // says it does, not at the next bracket
136
- const startTagEnd = findTagEnd(haystack, start + openTag.length);
168
+ const startTagEnd = findTagEnd(html, start + lengthOpen);
137
169
  if (startTagEnd === -1) {
138
170
  break;
139
171
  }
@@ -143,30 +175,31 @@ function findRawTextElements(haystack, tagName) {
143
175
  let bodyEnd = -1;
144
176
  let end = -1;
145
177
  for (;;) {
146
- const candidate = haystack.indexOf(closeTag, search);
178
+ closeTag.lastIndex = search;
179
+ const candidate = closeTag.exec(html)?.index ?? -1;
147
180
  if (candidate === -1) {
148
181
  break;
149
182
  }
150
- if (endsName(haystack.charAt(candidate + closeTag.length))) {
151
- const closeEnd = findTagEnd(haystack, candidate + closeTag.length);
183
+ if (endsName(html.charAt(candidate + lengthClose))) {
184
+ const closeEnd = findTagEnd(html, candidate + lengthClose);
152
185
  if (closeEnd !== -1) {
153
186
  bodyEnd = candidate;
154
187
  end = closeEnd + 1;
155
188
  }
156
189
  break;
157
190
  }
158
- search = candidate + closeTag.length;
191
+ search = candidate + lengthClose;
159
192
  }
160
193
 
161
194
  const closed = bodyEnd !== -1;
162
195
  found.push({
163
196
  start,
164
197
  bodyStart,
165
- bodyEnd: closed ? bodyEnd : haystack.length,
166
- end: closed ? end : haystack.length,
198
+ bodyEnd: closed ? bodyEnd : html.length,
199
+ end: closed ? end : html.length,
167
200
  closed
168
201
  });
169
- cursor = closed ? end : haystack.length;
202
+ cursor = closed ? end : html.length;
170
203
  }
171
204
 
172
205
  return found;
@@ -189,39 +222,20 @@ function findRawTextElements(haystack, tagName) {
189
222
  */
190
223
  function collectUsedSymbols(html, includeScripts, decode, elements) {
191
224
  const used = new Set();
192
- const haystack = foldCase(html);
193
-
194
- // Style sheets are skipped rather than cut out, so both scans share one folded
195
- // copy and offsets keep pointing into `html`. Only the body is skipped—the start
196
- // tag carries ordinary attributes, and `<style id="theme">` could be what
197
- // `#theme` refers to. An unclosed element is not skipped: Reading its contents
198
- // as markup can only add symbols, whereas ignoring the rest of the document
199
- // would lose them.
200
- const skipped = findRawTextElements(haystack, 'style')
225
+
226
+ // Style sheets are skipped rather than cut out, so offsets keep pointing into
227
+ // `html`. Only the body is skipped—the start tag carries ordinary attributes,
228
+ // and `<style id="theme">` could be what `#theme` refers to. An unclosed
229
+ // element is not skipped: Reading its contents as markup can only add symbols,
230
+ // whereas ignoring the rest of the document would lose them.
231
+ const skipped = findRawTextElements(html, 'style')
201
232
  .filter(element => element.closed)
202
233
  .map(element => ({ start: element.bodyStart, end: element.bodyEnd }));
203
234
 
204
235
  if (elements) {
205
- collectUsedElements(haystack, skipped, elements);
236
+ collectUsedElements(html, skipped, elements);
206
237
  }
207
238
 
208
- const addIdentifiers = (/** @type {string} */ text, /** @type {Set<string> | undefined} */ names = undefined) => {
209
- identifierPattern.lastIndex = 0;
210
- let identifier;
211
- while ((identifier = identifierPattern.exec(text))) {
212
- used.add(identifier[0]);
213
- names?.add(identifier[0].toLowerCase());
214
- }
215
- };
216
-
217
- const addTokens = (/** @type {string} */ text) => {
218
- for (const token of text.split(/\s+/)) {
219
- if (token) {
220
- used.add(token);
221
- }
222
- }
223
- };
224
-
225
239
  // Both loops below walk forward, so one cursor over the skipped ranges suffices
226
240
  let skipCursor = 0;
227
241
  const isSkipped = (/** @type {number} */ index) => {
@@ -243,20 +257,20 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
243
257
  // `class="us&#101;d"` names the class `used`, so compare against the decoded value
244
258
  const value = (decode && raw.indexOf('&') !== -1) ? decode(raw) : raw;
245
259
  if (name === 'class' || name === 'id' || idReferenceAttributes.has(name)) {
246
- addTokens(value);
260
+ addTokens(value, used);
247
261
  } else if (name.startsWith('data-')) {
248
262
  // Class names are commonly parked in `data-*` attributes for scripts to apply later
249
- addIdentifiers(value);
250
- addTokens(value);
263
+ addIdentifiers(value, used);
264
+ addTokens(value, used);
251
265
  } else if (fragmentReferenceAttributes.has(name) && value.charAt(0) === '#') {
252
266
  // Only a leading `#`: `href="/page#sec"` names a section of another document
253
- addTokens(value.slice(1));
267
+ addTokens(value.slice(1), used);
254
268
  }
255
269
  if (value.indexOf('(') !== -1) {
256
270
  fragmentURLPattern.lastIndex = 0;
257
271
  let reference;
258
272
  while ((reference = fragmentURLPattern.exec(value))) {
259
- addTokens(reference[1] ?? '');
273
+ addTokens(reference[1] ?? '', used);
260
274
  }
261
275
  }
262
276
  }
@@ -267,18 +281,21 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
267
281
  // isn’t tracked here, both readings count where they differ. An unclosed
268
282
  // `script` runs to the end of the document, as it does in a browser.
269
283
  skipCursor = 0;
270
- for (const element of findRawTextElements(haystack, 'script')) {
284
+ // Scripts repeat their identifiers, which need adding only once
285
+ /** @type {Set<string>} */
286
+ const identifiersScript = new Set();
287
+ for (const element of findRawTextElements(html, 'script')) {
271
288
  if (isSkipped(element.start)) {
272
289
  continue;
273
290
  }
274
291
  const body = html.slice(element.bodyStart, element.bodyEnd);
275
292
  const decoded = (decode && body.indexOf('&') !== -1) ? decode(body) : body;
276
293
  for (const text of decoded === body ? [body] : [body, decoded]) {
277
- addIdentifiers(text, elements);
294
+ addIdentifiers(text, used, elements, identifiersScript);
278
295
  stringLiteralPattern.lastIndex = 0;
279
296
  let literal;
280
297
  while ((literal = stringLiteralPattern.exec(text))) {
281
- addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '');
298
+ addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '', used);
282
299
  }
283
300
  }
284
301
  }
@@ -342,11 +359,11 @@ function isSafelisted(symbol, safelist) {
342
359
  /**
343
360
  * Collect the element names a document contains, including those the parser
344
361
  * supplies when the markup leaves them out.
345
- * @param {string} haystack - Case-folded markup, as returned by `foldCase`
362
+ * @param {string} html
346
363
  * @param {Array<{start: number, end: number}>} skipped - Style sheet bodies, in document order
347
364
  * @param {Set<string>} elements - Lowercase element names, added to
348
365
  */
349
- function collectUsedElements(haystack, skipped, elements) {
366
+ function collectUsedElements(html, skipped, elements) {
350
367
  elements.add('html');
351
368
  elements.add('head');
352
369
  elements.add('body');
@@ -354,7 +371,7 @@ function collectUsedElements(haystack, skipped, elements) {
354
371
  let skipIndex = 0;
355
372
  startTagPattern.lastIndex = 0;
356
373
  let match;
357
- while ((match = startTagPattern.exec(haystack))) {
374
+ while ((match = startTagPattern.exec(html))) {
358
375
  while (skipIndex < skipped.length && (skipped[skipIndex]?.end ?? 0) <= match.index) {
359
376
  skipIndex++;
360
377
  }
@@ -362,7 +379,7 @@ function collectUsedElements(haystack, skipped, elements) {
362
379
  if (element && match.index >= element.start) {
363
380
  continue;
364
381
  }
365
- elements.add(match[1] ?? '');
382
+ elements.add((match[1] ?? '').toLowerCase());
366
383
  }
367
384
  }
368
385
 
package/src/lib/utils.js CHANGED
@@ -133,6 +133,14 @@ function escapeXML(value, attribute = false) {
133
133
  return value.replace(attribute ? RE_XML_ESCAPE_ATTR : RE_XML_ESCAPE_TEXT, escapeXMLMatch);
134
134
  }
135
135
 
136
+ /**
137
+ * Script and style text as browsers read it, for code minifiers
138
+ * @param {string} text
139
+ */
140
+ function replaceNUL(text) {
141
+ return text.indexOf('\0') === -1 ? text : text.replace(/\0/g, '\uFFFD');
142
+ }
143
+
136
144
  /**
137
145
  * Read text in foreign content as HTML does: CDATA sections hold text as written,
138
146
  * while what surrounds them reads character references
@@ -938,6 +946,15 @@ function describeQuantifierRisk(pattern) {
938
946
  : null;
939
947
  }
940
948
 
949
+ // The characters `\s` matches, by code: tab through carriage return, space, and the
950
+ // Unicode whitespace and line terminators
951
+ /** @param {number} code */
952
+ function isWhitespaceCode(code) {
953
+ return (code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
954
+ (code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
955
+ code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
956
+ }
957
+
941
958
  /**
942
959
  * Find the index of the `>` that closes an opening tag, correctly skipping
943
960
  * over quoted attribute values (which may contain `>`).
@@ -1011,10 +1028,12 @@ export {
1011
1028
  hashContent,
1012
1029
  uniqueId,
1013
1030
  escapeXML,
1031
+ replaceNUL,
1014
1032
  decodeForeignText,
1015
1033
  identity,
1016
1034
  isQuirksDoctype,
1017
1035
  isThenable,
1036
+ isWhitespaceCode,
1018
1037
  lowercase,
1019
1038
  paramCase,
1020
1039
  replaceAsync,
@@ -18,6 +18,7 @@ import {
18
18
  formControlElementsEither,
19
19
  toEndTags
20
20
  } from './constants.js';
21
+ import { isWhitespaceCode } from './utils.js';
21
22
 
22
23
  const RE_ANY_WS_NBSP = /[ \n\r\t\f\xA0]/;
23
24
  const RE_TAB_NBSP = /[\t\xA0]/;
@@ -53,10 +54,7 @@ function isWsOrNbsp(code) {
53
54
  /** @param {string} str */
54
55
  function isAllWhitespace(str) {
55
56
  for (let i = 0; i < str.length; i++) {
56
- const code = str.charCodeAt(i);
57
- if (!((code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
58
- (code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
59
- code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF)) {
57
+ if (!isWhitespaceCode(str.charCodeAt(i))) {
60
58
  return false;
61
59
  }
62
60
  }