@thejaredwilcurt/csslop 0.0.47 → 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,8 +7,10 @@ import {
7
7
  findMatchingParenthesis,
8
8
  skipParenthesizedGroup,
9
9
  skipQuotedString,
10
+ splitTopLevelComponents,
10
11
  startsUrlToken
11
12
  } from '../parser/source-search.js';
13
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
12
14
  import { resolveUnicodeEscape } from '../utilities.js';
13
15
 
14
16
  import { evaluateColorMix } from './color-mix.js';
@@ -30,6 +32,12 @@ import {
30
32
  simplifyStandaloneCalc
31
33
  } from './math.js';
32
34
  import { namedColors } from './named-colors.js';
35
+ import {
36
+ formatShortestNumber,
37
+ parseNumericParts,
38
+ scaleByPowerOfTen,
39
+ shortenNumericText
40
+ } from './numbers.js';
33
41
  import { isQuotesNoneEquivalent } from './quotes.js';
34
42
  import {
35
43
  evaluateRelativeColor,
@@ -918,26 +926,89 @@ function convertBackgroundPositionKeywords (value) {
918
926
  }
919
927
 
920
928
  /**
921
- * Converts millisecond time values to seconds when the result is a
922
- * shorter string. Values of 0ms become 0s (time must keep a unit),
923
- * and values at or below 99ms stay in milliseconds (shorter representation).
929
+ * The power of ten that separates a time in seconds from the same time in
930
+ * milliseconds, keyed by the lowercased time unit.
924
931
  *
925
- * @param {string} value The CSS value string potentially containing ms time values.
926
- * @return {string} The value with eligible ms times converted to seconds.
932
+ * @type {{[unit: string]: number}}
927
933
  */
928
- function convertMillisecondsToSeconds (value) {
929
- // Match numeric values followed by the "ms" unit at word boundaries
930
- return value.replace(/\b(\d+(?:\.\d+)?)ms\b/gi, (match, amount) => {
931
- const milliseconds = parseFloat(amount);
932
- if (milliseconds === 0) {
933
- return '0s';
934
- }
935
- // Keep ms for values at or below 99ms (ms representation is shorter)
936
- if (milliseconds <= 99) {
937
- return match;
934
+ const TIME_UNIT_POWERS = {
935
+ s: 0,
936
+ ms: -3
937
+ };
938
+
939
+ /**
940
+ * Rewrites one time token in whichever of seconds or milliseconds spells it
941
+ * shortest, scientific notation included. Every conversion moves the exponent
942
+ * of the exact digits, so no precision is lost however large the time is.
943
+ * Seconds win a tie, and a zero time becomes `0s`.
944
+ *
945
+ * @param {string} text The text of a dimension token.
946
+ * @return {string} The shortest spelling of the same time.
947
+ */
948
+ function shortenTimeToken (text) {
949
+ const parts = parseNumericParts(text);
950
+ const writtenUnit = parts?.unit.toLowerCase();
951
+ if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
952
+ return text;
953
+ }
954
+ if (!parts.digits) {
955
+ return '0s';
956
+ }
957
+ const writtenSpelling = shortenNumericText(text, true);
958
+ const secondsSpelling = formatShortestNumber(
959
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
960
+ true
961
+ );
962
+ const millisecondsSpelling = formatShortestNumber(
963
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
964
+ true
965
+ );
966
+ // Seconds are the conventional unit, so they win a tie with milliseconds
967
+ const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
968
+ return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
969
+ }
970
+
971
+ /**
972
+ * Reports whether rewriting a number keeps its numeric type. A number written
973
+ * with a decimal point or an exponent has the `<number>` type, while one
974
+ * written with digits alone is an `<integer>`. A custom property can be
975
+ * substituted into, or registered with, a grammar that tells the two apart, so
976
+ * `100.0` cannot become `100` there.
977
+ *
978
+ * @param {string} originalNumber The number as it was written.
979
+ * @param {string} rewrittenNumber The number as it would be rewritten.
980
+ * @return {boolean} True when both spellings have the same type.
981
+ */
982
+ function keepsNumberType (originalNumber, rewrittenNumber) {
983
+ return isIntegerSpelling(originalNumber) === isIntegerSpelling(rewrittenNumber);
984
+ }
985
+
986
+ /**
987
+ * Reports whether a number is spelled as an `<integer>`: digits with an
988
+ * optional sign, and no decimal point or exponent.
989
+ *
990
+ * @param {string} number The number as written.
991
+ * @return {boolean} True when the spelling is an integer.
992
+ */
993
+ function isIntegerSpelling (number) {
994
+ // An optional sign followed by nothing but digits
995
+ return /^[+-]?\d+$/.test(number);
996
+ }
997
+
998
+ /**
999
+ * Converts each time in a value between seconds and milliseconds, whichever
1000
+ * is the shorter string.
1001
+ *
1002
+ * @param {string} value The CSS value string potentially containing time values.
1003
+ * @return {string} The value with each time in its shortest unit.
1004
+ */
1005
+ function convertTimesToShortestUnit (value) {
1006
+ return tokenizeCssValue(value).map((token) => {
1007
+ if (token.type !== 'dimension') {
1008
+ return token.text;
938
1009
  }
939
- return roundCompactNumber(milliseconds / 1000) + 's';
940
- });
1010
+ return shortenTimeToken(token.text);
1011
+ }).join('');
941
1012
  }
942
1013
 
943
1014
  /**
@@ -1304,7 +1375,7 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1304
1375
 
1305
1376
  // Convert ms to s for time-related properties when the seconds form is shorter
1306
1377
  if (TIME_PROPERTIES.has(property)) {
1307
- val = convertMillisecondsToSeconds(val);
1378
+ val = convertTimesToShortestUnit(val);
1308
1379
  }
1309
1380
 
1310
1381
  // Transition: remove " 0s" duration (transition: all 0s -> transition: all)
@@ -1536,7 +1607,12 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1536
1607
  // Shorthands: margin, padding, border-width, border-style, border-color, inset
1537
1608
  // Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
1538
1609
  if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
1539
- 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, '#');
1540
1616
  }
1541
1617
 
1542
1618
  if (property === 'hyphenate-limit-chars') {
@@ -1616,12 +1692,17 @@ function computeMinifiedValue (declaration) {
1616
1692
  // character budget a number is written within
1617
1693
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1618
1694
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1619
- const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
1695
+ const isCustom = isCustomProperty(declaration.property);
1696
+ const scientificValue = isCustom ? val : shortenDimensionNumbers(val, declaration.property);
1620
1697
  const roundedNumber = formatResolvedNumber(rawNumber);
1621
1698
  const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1622
1699
  // Preserve tiny dimensions whose significant digits would otherwise be
1623
1700
  // rounded away before their shorter exponent form can be considered.
1624
- val = losesPrecision && scientificValue !== val ? scientificValue : roundedNumber + rawUnit;
1701
+ if (losesPrecision && scientificValue !== val) {
1702
+ val = scientificValue;
1703
+ } else if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
1704
+ val = roundedNumber + rawUnit;
1705
+ }
1625
1706
  }
1626
1707
 
1627
1708
  val = lowercaseHexColors(val);
@@ -1670,7 +1751,7 @@ function computeMinifiedValue (declaration) {
1670
1751
  !isCustomProperty(declaration.property)
1671
1752
  );
1672
1753
  if (elidesRedundantSeparators) {
1673
- val = shortenDimensionNumbers(val);
1754
+ val = shortenDimensionNumbers(val, declaration.property);
1674
1755
  // A property that punctuates its own components already had the whitespace
1675
1756
  // after its closing parentheses taken out, and only the separators its
1676
1757
  // grammar still needs put back, so those are the ones left alone here.
@@ -1685,12 +1766,25 @@ function computeMinifiedValue (declaration) {
1685
1766
  * Memoizes minified values for the current stylesheet. Minification runs many
1686
1767
  * passes over the same declarations (deduplication, shorthand assembly,
1687
1768
  * CSS-wide keyword hoisting, stringification), and the result only depends on
1688
- * 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
1689
1771
  * property/value pair is minified once per stylesheet.
1690
1772
  *
1691
- * @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>>}
1778
+ */
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>>}
1692
1786
  */
1693
- const minifiedValueCache = new Map();
1787
+ const assembledMinifiedValuesByProperty = new Map();
1694
1788
 
1695
1789
  /**
1696
1790
  * Discards every memoized value. The minifier calls this at the start of each
@@ -1698,20 +1792,27 @@ const minifiedValueCache = new Map();
1698
1792
  * the cache should not outlive the pass that filled it.
1699
1793
  */
1700
1794
  function clearMinifiedValueCache () {
1701
- minifiedValueCache.clear();
1795
+ minifiedValuesByProperty.clear();
1796
+ assembledMinifiedValuesByProperty.clear();
1702
1797
  }
1703
1798
 
1704
1799
  /**
1705
- * Builds the cache key for a declaration from every field the value minifier
1706
- * reads. A null character cannot appear in a property name or in a parsed CSS
1707
- * 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.
1708
1802
  *
1709
- * @param {object} declaration The CSS declaration object.
1710
- * @return {string} The cache key.
1803
+ * @param {object} declaration The CSS declaration object.
1804
+ * @return {Map<string, string>} Map of value to its minified form for that property.
1711
1805
  */
1712
- function createMinifiedValueCacheKey (declaration) {
1713
- const assembledFlag = declaration.isAssembledShorthand ? '1' : '0';
1714
- return declaration.property + '\u0000' + assembledFlag + '\u0000' + declaration.value;
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;
1715
1816
  }
1716
1817
 
1717
1818
  /**
@@ -1727,13 +1828,13 @@ function minifyValue (declaration) {
1727
1828
  if (typeof declaration.value !== 'string') {
1728
1829
  return computeMinifiedValue(declaration);
1729
1830
  }
1730
- const cacheKey = createMinifiedValueCacheKey(declaration);
1731
- const cachedValue = minifiedValueCache.get(cacheKey);
1831
+ const minifiedValues = getMinifiedValuesForProperty(declaration);
1832
+ const cachedValue = minifiedValues.get(declaration.value);
1732
1833
  if (cachedValue !== undefined) {
1733
1834
  return cachedValue;
1734
1835
  }
1735
1836
  const minifiedValue = computeMinifiedValue(declaration);
1736
- minifiedValueCache.set(cacheKey, minifiedValue);
1837
+ minifiedValues.set(declaration.value, minifiedValue);
1737
1838
  return minifiedValue;
1738
1839
  }
1739
1840