@thejaredwilcurt/csslop 0.0.48 → 0.0.49

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.
@@ -33,6 +33,11 @@ const COMPOUND_SELECTOR_BOUNDARIES = '>+~ \t\n\r\f';
33
33
  * @return {number} The index of the next top-level occurrence, or -1 if none remains.
34
34
  */
35
35
  function findNextFunctionCallOutsideStrings (text, functionCall, start) {
36
+ // A token that appears nowhere in the selector cannot appear at its top
37
+ // level either, and one search rules that out without walking every character
38
+ if (!text.toLowerCase().includes(functionCall)) {
39
+ return -1;
40
+ }
36
41
  let index = start;
37
42
  while (index < text.length) {
38
43
  const character = text[index];
@@ -655,6 +660,10 @@ function readWholeSelectorIsParts (selector) {
655
660
  * @return {Array} An array of one or more processed selector strings.
656
661
  */
657
662
  function processIsSelector (selector, hasSiblingSelectors) {
663
+ // Every rewrite below starts from an `:is(`, so a selector without one is left as it is
664
+ if (!selector.toLowerCase().includes(':is(')) {
665
+ return [selector];
666
+ }
658
667
  // Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
659
668
  selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
660
669
  selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
@@ -276,6 +276,143 @@ function stringifyAtRule (rule, context) {
276
276
  const separator = minifiedPrelude ? ' ' : '';
277
277
  return '@' + rule.name + separator + minifiedPrelude + '{' + body + '}';
278
278
  }
279
+
280
+ /**
281
+ * Rewrites `:nth-*()` pseudo-classes into their shortest equivalent forms.
282
+ *
283
+ * @param {string} selector A selector with its whitespace already collapsed.
284
+ * @return {string} The selector with simplified `:nth-*()` pseudo-classes.
285
+ */
286
+ function simplifyNthPseudoClasses (selector) {
287
+ let minified = selector;
288
+ // Simplify :nth-child(2n+1) to :nth-child(odd)
289
+ minified = minified.replace(/:nth-child\(2n\s*\+\s*1\)/g, ':nth-child(odd)');
290
+ // Simplify :nth-child(2n+0) to :nth-child(2n)
291
+ minified = minified.replace(/:nth-child\(2n\s*\+\s*0\)/g, ':nth-child(2n)');
292
+ // Simplify :nth-child(1n) to :nth-child(n)
293
+ minified = minified.replace(/:nth-child\(1n\)/g, ':nth-child(n)');
294
+ // Remove unnecessary leading + sign from :nth-child()
295
+ minified = minified.replace(/:nth-child\(\+\s*(\d+)\)/g, ':nth-child($1)');
296
+ // Simplify :nth-child(0n+N) to :nth-child(N)
297
+ minified = minified.replace(/:nth-child\(0n\s*\+\s*(\d+)\)/g, ':nth-child($1)');
298
+ // Replace :nth-child(1) with :first-child
299
+ minified = minified.replace(/:nth-child\(1\)/g, ':first-child');
300
+ // Replace :nth-last-child(1) with :last-child
301
+ minified = minified.replace(/:nth-last-child\(1\)/g, ':last-child');
302
+ // Replace :nth-of-type(1) with :first-of-type
303
+ minified = minified.replace(/:nth-of-type\(1\)/g, ':first-of-type');
304
+ // Replace :nth-last-of-type(1) with :last-of-type
305
+ minified = minified.replace(/:nth-last-of-type\(1\)/g, ':last-of-type');
306
+ return minified;
307
+ }
308
+
309
+ /**
310
+ * Writes each attribute selector's value quoted or escaped, whichever is
311
+ * shorter, and removes the whitespace inside its brackets.
312
+ *
313
+ * @param {string} selector A selector with its whitespace already collapsed.
314
+ * @return {string} The selector with minified attribute selectors.
315
+ */
316
+ function minifyAttributeSelectors (selector) {
317
+ let minified = selector;
318
+ // Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
319
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
320
+ // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
321
+ let escaped = value.replace(/([ #.:/;])/g, '\\$1');
322
+ if (escaped.length < value.length + 2) {
323
+ return '[' + attribute + '=' + escaped + ']';
324
+ }
325
+ return '[' + attribute + '="' + value + '"]';
326
+ });
327
+ // Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
328
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
329
+ // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
330
+ let escaped = value.replace(/([ #.:/;])/g, '\\$1');
331
+ if (escaped.length < value.length + 2) {
332
+ return '[' + attribute + '=' + escaped + ']';
333
+ }
334
+ return '[' + attribute + '="' + value + '"]';
335
+ });
336
+ // Minify unquoted attribute selectors: quote when unescaping produces a shorter result
337
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
338
+ // Unescape special characters (spaces, #, ., :, /, ;) and compare with quoted form
339
+ let unescaped = value.replace(/\\([ #.:/;])/g, '$1');
340
+ if (unescaped.length + 2 < value.length) {
341
+ return '[' + attribute + '="' + unescaped + '"]';
342
+ }
343
+ return '[' + attribute + '=' + value + ']';
344
+ });
345
+ return minified;
346
+ }
347
+
348
+ /**
349
+ * Rewrites `:not()` pseudo-classes that negate a pseudo-class with a direct
350
+ * opposite, such as `:not(:enabled)`, into that opposite.
351
+ *
352
+ * @param {string} selector A selector with its whitespace already collapsed.
353
+ * @return {string} The selector with simplified negations.
354
+ */
355
+ function simplifyNegatedPseudoClasses (selector) {
356
+ let minified = selector;
357
+ // Minify logical combinations
358
+ // Replace :not(:invalid) with :valid, only directly after a form element type selector
359
+ minified = minified.replace(/(?<=\b(?:button|fieldset|form|input|select|textarea)):not\(:invalid\)/g, ':valid');
360
+ // Replace :not(:dir(ltr)) with :dir(rtl)
361
+ minified = minified.replace(/:not\(:dir\(ltr\)\)/g, ':dir(rtl)');
362
+ // Replace a double negation, :not(:not(X)), with the X it wraps
363
+ minified = minified.replace(/:not\(:not\((.*?)\)\)/g, '$1');
364
+ // Replace :not(:enabled) with :disabled
365
+ minified = minified.replace(/:not\(:enabled\)/g, ':disabled');
366
+ // Replace :not(:required) with :optional
367
+ minified = minified.replace(/:not\(:required\)/g, ':optional');
368
+ // Match a link element type selector at the start or after a combinator, with any attribute selectors and an optional :where( wrapper, followed by :not(:link)
369
+ minified = minified.replace(/(^|[\s,>+~])(a|area|link)(?:\[.*?\])*(?::where\()?:not\(:link\)\)?/g, (match) => {
370
+ return match.replace(':not(:link)', ':visited');
371
+ });
372
+ return minified;
373
+ }
374
+
375
+ /**
376
+ * Minifies a single selector's spacing, pseudo-classes, pseudo-elements, and
377
+ * attribute selectors. Most selectors hold none of the patterns a group of
378
+ * rewrites looks for, so each group only runs when its marker text is present.
379
+ *
380
+ * @param {string} selector A single selector from a rule's selector list.
381
+ * @return {string} The minified selector.
382
+ */
383
+ function minifySelector (selector) {
384
+ let minified = unescapeSelector(selector);
385
+ // Collapse whitespace to single space
386
+ minified = minified.replace(/\s+/g, ' ');
387
+ // Strip whitespace around selector combinators and commas
388
+ minified = minified.replace(/\s*([,>+~])\s*/g, '$1');
389
+ // Strip whitespace inside parentheses for pseudo-class arguments
390
+ minified = minified.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
391
+ if (minified.includes(':nth-')) {
392
+ minified = simplifyNthPseudoClasses(minified);
393
+ }
394
+ if (minified.includes('::')) {
395
+ // Convert double-colon pseudo-elements to single-colon legacy form
396
+ minified = minified.replace(/::before/g, ':before');
397
+ minified = minified.replace(/::after/g, ':after');
398
+ }
399
+ if (minified.includes('*')) {
400
+ // Strip redundant universal selector `*` when it precedes an ID, class, or attribute selector
401
+ minified = minified.replace(/\*([#.[])/g, '$1');
402
+ }
403
+ if (minified.includes('[') && minified.includes('=')) {
404
+ minified = minifyAttributeSelectors(minified);
405
+ }
406
+ if (minified.includes(':not(')) {
407
+ minified = simplifyNegatedPseudoClasses(minified);
408
+ }
409
+ // Remove redundant leading "& " nesting selector
410
+ minified = minified.replace(/^& /, '');
411
+ minified = mergeAdjacentWherePseudoClasses(minified);
412
+ minified = minifyLanguageSelector(minified);
413
+ return minified;
414
+ }
415
+
279
416
  /**
280
417
  * Converts a parsed CSS AST rule node into a minified CSS string, dispatching to specialized handlers for each rule type including selectors, `@media`, `@keyframes`, `@layer`, and other at-rules.
281
418
  *
@@ -305,82 +442,7 @@ function stringifyRule (rule, context) {
305
442
  if (rule.selectors?.length) {
306
443
  let uniqueSelectors = [...new Set(rule.selectors)];
307
444
  // Minify spacing within selectors (e.g. inside :is(), :where(), etc)
308
- uniqueSelectors = uniqueSelectors.map((selector) => {
309
- let minified = unescapeSelector(selector);
310
- // Collapse whitespace to single space
311
- minified = minified.replace(/\s+/g, ' ');
312
- // Strip whitespace around selector combinators and commas
313
- minified = minified.replace(/\s*([,>+~])\s*/g, '$1');
314
- // Strip whitespace inside parentheses for pseudo-class arguments
315
- minified = minified.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
316
- // Simplify :nth-child(2n+1) to :nth-child(odd)
317
- minified = minified.replace(/:nth-child\(2n\s*\+\s*1\)/g, ':nth-child(odd)');
318
- // Simplify :nth-child(2n+0) to :nth-child(2n)
319
- minified = minified.replace(/:nth-child\(2n\s*\+\s*0\)/g, ':nth-child(2n)');
320
- // Simplify :nth-child(1n) to :nth-child(n)
321
- minified = minified.replace(/:nth-child\(1n\)/g, ':nth-child(n)');
322
- // Remove unnecessary leading + sign from :nth-child()
323
- minified = minified.replace(/:nth-child\(\+\s*(\d+)\)/g, ':nth-child($1)');
324
- // Simplify :nth-child(0n+N) to :nth-child(N)
325
- minified = minified.replace(/:nth-child\(0n\s*\+\s*(\d+)\)/g, ':nth-child($1)');
326
- // Replace :nth-child(1) with :first-child
327
- minified = minified.replace(/:nth-child\(1\)/g, ':first-child');
328
- // Replace :nth-last-child(1) with :last-child
329
- minified = minified.replace(/:nth-last-child\(1\)/g, ':last-child');
330
- // Replace :nth-of-type(1) with :first-of-type
331
- minified = minified.replace(/:nth-of-type\(1\)/g, ':first-of-type');
332
- // Replace :nth-last-of-type(1) with :last-of-type
333
- minified = minified.replace(/:nth-last-of-type\(1\)/g, ':last-of-type');
334
- // Convert double-colon pseudo-elements to single-colon legacy form
335
- minified = minified.replace(/::before/g, ':before');
336
- minified = minified.replace(/::after/g, ':after');
337
-
338
- // Strip redundant universal selector `*` when it precedes an ID, class, or attribute selector
339
- minified = minified.replace(/\*([#.[])/g, '$1');
340
-
341
- // Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
342
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
343
- // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
344
- let escaped = value.replace(/([ #.:/;])/g, '\\$1');
345
- if (escaped.length < value.length + 2) {
346
- return '[' + attribute + '=' + escaped + ']';
347
- }
348
- return '[' + attribute + '="' + value + '"]';
349
- });
350
- // Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
351
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
352
- // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
353
- let escaped = value.replace(/([ #.:/;])/g, '\\$1');
354
- if (escaped.length < value.length + 2) {
355
- return '[' + attribute + '=' + escaped + ']';
356
- }
357
- return '[' + attribute + '="' + value + '"]';
358
- });
359
- // Minify unquoted attribute selectors: quote when unescaping produces a shorter result
360
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
361
- // Unescape special characters (spaces, #, ., :, /, ;) and compare with quoted form
362
- let unescaped = value.replace(/\\([ #.:/;])/g, '$1');
363
- if (unescaped.length + 2 < value.length) {
364
- return '[' + attribute + '="' + unescaped + '"]';
365
- }
366
- return '[' + attribute + '=' + value + ']';
367
- });
368
-
369
- // Minify logical combinations
370
- minified = minified.replace(/(?<=\b(?:button|fieldset|form|input|select|textarea)):not\(:invalid\)/g, ':valid');
371
- minified = minified.replace(/:not\(:dir\(ltr\)\)/g, ':dir(rtl)');
372
- minified = minified.replace(/:not\(:not\((.*?)\)\)/g, '$1');
373
- minified = minified.replace(/:not\(:enabled\)/g, ':disabled');
374
- minified = minified.replace(/:not\(:required\)/g, ':optional');
375
- minified = minified.replace(/(^|[\s,>+~])(a|area|link)(?:\[.*?\])*(?::where\()?:not\(:link\)\)?/g, (match) => {
376
- return match.replace(':not(:link)', ':visited');
377
- });
378
- // Remove redundant leading "& " nesting selector
379
- minified = minified.replace(/^& /, '');
380
- minified = mergeAdjacentWherePseudoClasses(minified);
381
- minified = minifyLanguageSelector(minified);
382
- return minified;
383
- });
445
+ uniqueSelectors = uniqueSelectors.map(minifySelector);
384
446
  uniqueSelectors = combineLegacyAnySelectors(uniqueSelectors);
385
447
  // When this rule is a nesting parent, its whole selector list is treated
386
448
  // as :is() for the children's specificity, so a top-level :is() can be
package/src/utilities.js CHANGED
@@ -19,19 +19,6 @@ function resolveUnicodeEscape (hex) {
19
19
  return String.fromCodePoint(codePoint);
20
20
  }
21
21
 
22
- /**
23
- * Escapes special regex metacharacters in a string so it can be safely used
24
- * as a literal pattern in a RegExp constructor.
25
- *
26
- * @param {string} input The string to escape.
27
- * @return {string} The escaped string safe for use in a RegExp.
28
- */
29
- function escapeRegexString (input) {
30
- // Escape all regex metacharacters: . * + ? ^ $ { } ( ) | [ ] backslash
31
- return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- }
33
-
34
22
  export {
35
- escapeRegexString,
36
23
  resolveUnicodeEscape
37
24
  };
@@ -7,6 +7,7 @@ import {
7
7
  findMatchingParenthesis,
8
8
  skipParenthesizedGroup,
9
9
  skipQuotedString,
10
+ splitTopLevelComponents,
10
11
  startsUrlToken
11
12
  } from '../parser/source-search.js';
12
13
  import { tokenizeCssValue } from '../parser/tokenizer.js';
@@ -1606,7 +1607,12 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1606
1607
  // Shorthands: margin, padding, border-width, border-style, border-color, inset
1607
1608
  // Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
1608
1609
  if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
1609
- val = collapseShorthandParts(val.split(' ')).join(' ');
1610
+ // Split components instead of splitting on spaces, so values that legally
1611
+ // touch, like the adjacent hex colors of `#ddd#ddd`, count separately
1612
+ val = collapseShorthandParts(splitTopLevelComponents(val))
1613
+ .join(' ')
1614
+ // A hash token may touch the component before it, so the separator space is not needed
1615
+ .replace(/ #/g, '#');
1610
1616
  }
1611
1617
 
1612
1618
  if (property === 'hyphenate-limit-chars') {
@@ -1760,12 +1766,25 @@ function computeMinifiedValue (declaration) {
1760
1766
  * Memoizes minified values for the current stylesheet. Minification runs many
1761
1767
  * passes over the same declarations (deduplication, shorthand assembly,
1762
1768
  * CSS-wide keyword hoisting, stringification), and the result only depends on
1763
- * the declaration fields that make up the cache key, so each distinct
1769
+ * the declaration's property, its value, and whether it is an assembled
1770
+ * shorthand, so each distinct
1764
1771
  * property/value pair is minified once per stylesheet.
1765
1772
  *
1766
- * @type {Map<string, string>}
1773
+ * Values are filed under their property first, and assembled shorthands are
1774
+ * kept apart from authored declarations, so a lookup reuses the property and
1775
+ * value strings as they are instead of building a combined key on every call.
1776
+ *
1777
+ * @type {Map<string, Map<string, string>>}
1767
1778
  */
1768
- const minifiedValueCache = new Map();
1779
+ const minifiedValuesByProperty = new Map();
1780
+
1781
+ /**
1782
+ * The memoized values of shorthands the minifier assembled itself, filed the
1783
+ * same way as `minifiedValuesByProperty`.
1784
+ *
1785
+ * @type {Map<string, Map<string, string>>}
1786
+ */
1787
+ const assembledMinifiedValuesByProperty = new Map();
1769
1788
 
1770
1789
  /**
1771
1790
  * Discards every memoized value. The minifier calls this at the start of each
@@ -1773,20 +1792,27 @@ const minifiedValueCache = new Map();
1773
1792
  * the cache should not outlive the pass that filled it.
1774
1793
  */
1775
1794
  function clearMinifiedValueCache () {
1776
- minifiedValueCache.clear();
1795
+ minifiedValuesByProperty.clear();
1796
+ assembledMinifiedValuesByProperty.clear();
1777
1797
  }
1778
1798
 
1779
1799
  /**
1780
- * Builds the cache key for a declaration from every field the value minifier
1781
- * reads. A null character cannot appear in a property name or in a parsed CSS
1782
- * value, so it safely delimits the parts.
1800
+ * Finds the memoized values for a declaration's property, creating the empty
1801
+ * collection the first time the property is seen.
1783
1802
  *
1784
- * @param {object} declaration The CSS declaration object.
1785
- * @return {string} The cache key.
1786
- */
1787
- function createMinifiedValueCacheKey (declaration) {
1788
- const assembledFlag = declaration.isAssembledShorthand ? '1' : '0';
1789
- return declaration.property + '\u0000' + assembledFlag + '\u0000' + declaration.value;
1803
+ * @param {object} declaration The CSS declaration object.
1804
+ * @return {Map<string, string>} Map of value to its minified form for that property.
1805
+ */
1806
+ function getMinifiedValuesForProperty (declaration) {
1807
+ const valuesByProperty = declaration.isAssembledShorthand ?
1808
+ assembledMinifiedValuesByProperty :
1809
+ minifiedValuesByProperty;
1810
+ let minifiedValues = valuesByProperty.get(declaration.property);
1811
+ if (!minifiedValues) {
1812
+ minifiedValues = new Map();
1813
+ valuesByProperty.set(declaration.property, minifiedValues);
1814
+ }
1815
+ return minifiedValues;
1790
1816
  }
1791
1817
 
1792
1818
  /**
@@ -1802,13 +1828,13 @@ function minifyValue (declaration) {
1802
1828
  if (typeof declaration.value !== 'string') {
1803
1829
  return computeMinifiedValue(declaration);
1804
1830
  }
1805
- const cacheKey = createMinifiedValueCacheKey(declaration);
1806
- const cachedValue = minifiedValueCache.get(cacheKey);
1831
+ const minifiedValues = getMinifiedValuesForProperty(declaration);
1832
+ const cachedValue = minifiedValues.get(declaration.value);
1807
1833
  if (cachedValue !== undefined) {
1808
1834
  return cachedValue;
1809
1835
  }
1810
1836
  const minifiedValue = computeMinifiedValue(declaration);
1811
- minifiedValueCache.set(cacheKey, minifiedValue);
1837
+ minifiedValues.set(declaration.value, minifiedValue);
1812
1838
  return minifiedValue;
1813
1839
  }
1814
1840