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.
- package/package.json +3 -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.
|
|
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 {
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 {
|
|
165
|
-
const
|
|
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
|
|
178
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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) =>
|
|
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) {
|