@thejaredwilcurt/csslop 0.0.38 → 0.0.40

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/src/value/math.js CHANGED
@@ -4,9 +4,12 @@
4
4
 
5
5
  import { calc } from '@csstools/css-calc';
6
6
 
7
+ import { findMatchingParenthesis } from '../parser/source-search.js';
8
+
7
9
  import {
8
10
  convertAbsoluteLengthToPx,
9
- formatDimension,
11
+ formatResolvedDimension,
12
+ formatResolvedNumber,
10
13
  roundCompactNumber
11
14
  } from './shared.js';
12
15
 
@@ -124,28 +127,6 @@ function findFunctionNameStart (value, openIndex) {
124
127
  return index;
125
128
  }
126
129
 
127
- /**
128
- * Finds the parenthesis closing the one at the given index.
129
- *
130
- * @param {string} value The CSS value string being scanned.
131
- * @param {number} openIndex The index of the opening parenthesis.
132
- * @return {number} The index of the matching closing parenthesis, or -1 when it never closes.
133
- */
134
- function findClosingParenthesis (value, openIndex) {
135
- let depth = 0;
136
- for (let index = openIndex; index < value.length; index++) {
137
- if (value[index] === '(') {
138
- depth++;
139
- } else if (value[index] === ')') {
140
- depth--;
141
- if (depth === 0) {
142
- return index;
143
- }
144
- }
145
- }
146
- return -1;
147
- }
148
-
149
130
  /**
150
131
  * Rewrites the contents of every math function in a value, leaving the rest of
151
132
  * the value untouched. A `*` only means multiplication inside these functions,
@@ -166,7 +147,7 @@ function rewriteMathFunctionContents (rewriteExpression, value) {
166
147
  break;
167
148
  }
168
149
  const functionName = value.slice(findFunctionNameStart(value, openIndex), openIndex).toLowerCase();
169
- const closeIndex = findClosingParenthesis(value, openIndex);
150
+ const closeIndex = findMatchingParenthesis(value, openIndex);
170
151
  if (!MATH_FUNCTION_NAMES.has(functionName) || closeIndex === -1) {
171
152
  result += value.slice(index, openIndex + 1);
172
153
  index = openIndex + 1;
@@ -265,6 +246,158 @@ function convertReciprocalMultiplicationToDivision (expression) {
265
246
  });
266
247
  }
267
248
 
249
+ /**
250
+ * Matches a whole expression that divides a percentage by a plain number,
251
+ * capturing the percentage and the divisor it is shared out between.
252
+ *
253
+ * @type {RegExp}
254
+ */
255
+ const PERCENTAGE_DIVISION = /^([+-]?(?:\d+|\d*\.\d+))%\s*\/\s*([+-]?(?:\d+|\d*\.\d+))$/;
256
+
257
+ /**
258
+ * Picks between what a calculation resolved to and the calculation itself,
259
+ * keeping whichever takes fewer characters to write. A tie goes to the
260
+ * resolved value, which states the same thing without the arithmetic.
261
+ *
262
+ * @param {string} resolved The value the calculation resolves to.
263
+ * @param {string} calculation The calculation, written as compactly as it can be.
264
+ * @return {string} The shorter of the two.
265
+ */
266
+ function shorterOfResolutionAndCalculation (resolved, calculation) {
267
+ if (resolved.length <= calculation.length) {
268
+ return resolved;
269
+ }
270
+ return calculation;
271
+ }
272
+
273
+ /**
274
+ * Resolves a percentage divided by a number into the single percentage it
275
+ * equals, which is how `calc(100%/16)` comes to be written as `6.25%`. The
276
+ * division stays as it was written when the result is no shorter, and when
277
+ * the divisor is zero, since that resolves to nothing a stylesheet can state.
278
+ *
279
+ * @param {string} expression The contents of a `calc()`, with its whitespace collapsed.
280
+ * @return {string|null} The shorter of the resolved percentage and the division, or null when the expression is not one.
281
+ */
282
+ function resolvePercentageDivision (expression) {
283
+ const division = expression.match(PERCENTAGE_DIVISION);
284
+ if (!division) {
285
+ return null;
286
+ }
287
+ const [, percentage, divisor] = division;
288
+ const calculation = 'calc(' + percentage + '%/' + divisor + ')';
289
+ if (Number(divisor) === 0) {
290
+ return calculation;
291
+ }
292
+ const resolved = formatResolvedNumber(Number(percentage) / Number(divisor)) + '%';
293
+ return shorterOfResolutionAndCalculation(resolved, calculation);
294
+ }
295
+
296
+ /**
297
+ * The properties whose values every CSS specification defines as
298
+ * non-negative, so that a math function declared on one of them has its
299
+ * result clamped rather than read as written.
300
+ *
301
+ * @type {Set<string>}
302
+ */
303
+ const NON_NEGATIVE_PROPERTIES = new Set([
304
+ 'animation-duration',
305
+ 'animation-iteration-count',
306
+ 'background-size',
307
+ 'block-size',
308
+ 'border-block-end-width',
309
+ 'border-block-start-width',
310
+ 'border-block-width',
311
+ 'border-bottom-left-radius',
312
+ 'border-bottom-right-radius',
313
+ 'border-bottom-width',
314
+ 'border-end-end-radius',
315
+ 'border-end-start-radius',
316
+ 'border-image-outset',
317
+ 'border-image-width',
318
+ 'border-inline-end-width',
319
+ 'border-inline-start-width',
320
+ 'border-inline-width',
321
+ 'border-left-width',
322
+ 'border-radius',
323
+ 'border-right-width',
324
+ 'border-start-end-radius',
325
+ 'border-start-start-radius',
326
+ 'border-top-left-radius',
327
+ 'border-top-right-radius',
328
+ 'border-top-width',
329
+ 'border-width',
330
+ 'column-count',
331
+ 'column-gap',
332
+ 'column-rule-width',
333
+ 'column-width',
334
+ 'flex-basis',
335
+ 'flex-grow',
336
+ 'flex-shrink',
337
+ 'font-size',
338
+ 'gap',
339
+ 'height',
340
+ 'inline-size',
341
+ 'line-height',
342
+ 'mask-size',
343
+ 'max-block-size',
344
+ 'max-height',
345
+ 'max-inline-size',
346
+ 'max-width',
347
+ 'min-block-size',
348
+ 'min-height',
349
+ 'min-inline-size',
350
+ 'min-width',
351
+ 'outline-width',
352
+ 'padding',
353
+ 'padding-block',
354
+ 'padding-block-end',
355
+ 'padding-block-start',
356
+ 'padding-bottom',
357
+ 'padding-inline',
358
+ 'padding-inline-end',
359
+ 'padding-inline-start',
360
+ 'padding-left',
361
+ 'padding-right',
362
+ 'padding-top',
363
+ 'perspective',
364
+ 'row-gap',
365
+ 'shape-margin',
366
+ 'stroke-width',
367
+ 'tab-size',
368
+ 'transition-duration',
369
+ 'width'
370
+ ]);
371
+
372
+ /**
373
+ * Matches a value written as a single negative number, with or without the
374
+ * unit or percent sign that follows its digits.
375
+ *
376
+ * @type {RegExp}
377
+ */
378
+ const NEGATIVE_NUMBER_VALUE = /^-(?:\d+|\d*\.\d+)(?:[a-z]+|%)?$/i;
379
+
380
+ /**
381
+ * Clamps what a math function resolved to into the range its property
382
+ * accepts. A negative length written out directly makes the declaration
383
+ * invalid and the CSS engine throws it away, but a math function is allowed
384
+ * to resolve to one: its result is clamped to the nearest value the property
385
+ * does take, which on a property that only accepts non-negative values is
386
+ * zero, as the range checking section of the CSS Values specification
387
+ * describes: https://drafts.csswg.org/css-values/#calc-range.
388
+ *
389
+ * @param {string} resolvedValue The value the math function resolved to.
390
+ * @param {string} property The property the math function is declared on.
391
+ * @return {string} The resolved value, held inside the property's range.
392
+ */
393
+ function clampResolvedValueToPropertyRange (resolvedValue, property) {
394
+ const acceptsNegativeValues = !NON_NEGATIVE_PROPERTIES.has(String(property).toLowerCase());
395
+ if (acceptsNegativeValues || !NEGATIVE_NUMBER_VALUE.test(resolvedValue)) {
396
+ return resolvedValue;
397
+ }
398
+ return '0';
399
+ }
400
+
268
401
  /**
269
402
  * The units a folded calc() expression leads with, in the order they are
270
403
  * written. Every other unit follows them alphabetically.
@@ -296,15 +429,15 @@ function tryFoldCalcExpression (expression) {
296
429
  expr = expr.replace(/\(([^()]+)\)/g, '$1');
297
430
  // Fold: <number> * <number><unit> → computed result in same unit
298
431
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))\s*\*\s*(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)/gi, (match, a, b, unit) => {
299
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
432
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
300
433
  });
301
434
  // Fold: <number><unit> * <number> → computed result in same unit
302
435
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)\s*\*\s*(-?(?:\d*\.\d+|\d+))/gi, (match, a, unit, b) => {
303
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
436
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
304
437
  });
305
438
  // Fold: <number><unit> / <number> → computed result in same unit
306
439
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q)\s*\/\s*(-?(?:\d*\.\d+|\d+))/gi, (match, value, unit, divisor) => {
307
- return formatDimension(parseFloat(value) / parseFloat(divisor), unit);
440
+ return formatResolvedDimension(parseFloat(value) / parseFloat(divisor), unit);
308
441
  });
309
442
  // Collapse zero-with-unit terms (e.g. 0px, 0%) to plain 0 or remove them
310
443
  expr = expr.replace(/(^|[+-])\s*0(?:px|pt|pc|in|cm|mm|q|%)\b/g, (match, sign) => {
@@ -381,26 +514,100 @@ function tryFoldCalcExpression (expression) {
381
514
 
382
515
  if (outputTerms.length === 1) {
383
516
  const { unit, value } = outputTerms[0];
384
- if (unit) {
385
- return roundCompactNumber(value) + unit;
386
- }
387
- return roundCompactNumber(value);
517
+ return formatResolvedDimension(value, unit);
388
518
  }
389
519
 
390
520
  const [first, ...rest] = outputTerms;
391
- let result = roundCompactNumber(first.value) + first.unit;
521
+ let result = formatResolvedDimension(first.value, first.unit);
392
522
  for (const term of rest) {
393
523
  const sign = term.value < 0 ? '-' : '+';
394
- result += ' ' + sign + ' ' + roundCompactNumber(Math.abs(term.value)) + term.unit;
524
+ result += ' ' + sign + ' ' + formatResolvedDimension(Math.abs(term.value), term.unit);
395
525
  }
396
526
  return 'calc(' + result + ')';
397
527
  }
398
528
 
529
+ /**
530
+ * Matches a whole expression that scales a percentage by a plain number,
531
+ * written either way around. Both operands are already as short as they can
532
+ * be, so the expression is only ever squeezed, never resolved.
533
+ *
534
+ * @type {RegExp}
535
+ */
536
+ const PERCENTAGE_MULTIPLICATION = /^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/;
537
+
538
+ /**
539
+ * Matches a value that is a single percentage, which is all a calculation
540
+ * leaves behind once every term of it has resolved into one.
541
+ *
542
+ * @type {RegExp}
543
+ */
544
+ const RESOLVED_PERCENTAGE = /^-?(?:\d+|\d*\.\d+)%$/;
545
+
546
+ /**
547
+ * Chooses how a calculation that shares a percentage out resolves. The
548
+ * library states the result to the full precision a float carries, which is
549
+ * both longer than the calculation it replaces and finer than a stylesheet
550
+ * has any use for, so the result is rounded into the budget a resolved number
551
+ * is written within and kept only when it is the shorter of the two.
552
+ *
553
+ * @param {string} simplified The result the calculation library returned.
554
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
555
+ * @param {string} match The whole `calc()` as it was written.
556
+ * @return {string} How the resolved calculation is written.
557
+ */
558
+ function chooseResolvedPercentage (simplified, compactInner, match) {
559
+ // A division sign written after a percentage, which is what the resolved percentage was shared out by
560
+ const dividesAPercentage = RESOLVED_PERCENTAGE.test(simplified) && /%\s*\//.test(match);
561
+ if (!dividesAPercentage) {
562
+ return simplified;
563
+ }
564
+ // Remove whitespace around division operator
565
+ const calculation = 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
566
+ const resolved = formatResolvedNumber(parseFloat(simplified)) + '%';
567
+ return shorterOfResolutionAndCalculation(resolved, calculation);
568
+ }
569
+
570
+ /**
571
+ * Resolves the contents of a `calc()` into the shortest way of writing the
572
+ * same value, which is either the value the arithmetic works out to or the
573
+ * calculation itself with the whitespace squeezed out of it.
574
+ *
575
+ * @param {string} match The whole `calc()` as it was written.
576
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
577
+ * @return {string} The shortest way of writing what the calculation states.
578
+ */
579
+ function resolveCalcExpression (match, compactInner) {
580
+ if (PERCENTAGE_MULTIPLICATION.test(compactInner)) {
581
+ // Remove whitespace around multiplication/division operators
582
+ return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
583
+ }
584
+
585
+ const dividedPercentage = resolvePercentageDivision(compactInner);
586
+ if (dividedPercentage) {
587
+ return dividedPercentage;
588
+ }
589
+
590
+ const folded = tryFoldCalcExpression(compactInner);
591
+ if (folded) {
592
+ return folded;
593
+ }
594
+
595
+ try {
596
+ const simplified = calc(match);
597
+ if (typeof simplified !== 'string') {
598
+ return match;
599
+ }
600
+ return chooseResolvedPercentage(simplified, compactInner, match);
601
+ } catch {
602
+ return match;
603
+ }
604
+ }
605
+
399
606
  /**
400
607
  * Simplifies calc(), min(), and max() expressions within a CSS value string using the `@csstools`/css-calc library, falling back to the original value on failure.
401
608
  *
402
609
  * @param {string} value The CSS value string containing math functions to simplify.
403
- * @param {string} property The CSS property name, used for context-aware simplification.
610
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
404
611
  * @param {string} originalValue The original unmodified value to fall back to if simplification produces an invalid result.
405
612
  * @return {string} The value with math functions simplified where possible.
406
613
  */
@@ -424,7 +631,10 @@ function normalizeMathFunctions (value, property, originalValue = '') {
424
631
  result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
425
632
  try {
426
633
  const simplified = calc(match);
427
- return typeof simplified === 'string' ? simplified : match;
634
+ if (typeof simplified !== 'string') {
635
+ return match;
636
+ }
637
+ return clampResolvedValueToPropertyRange(simplified, property);
428
638
  } catch {
429
639
  return match;
430
640
  }
@@ -434,35 +644,8 @@ function normalizeMathFunctions (value, property, originalValue = '') {
434
644
  result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
435
645
  // Collapse whitespace inside calc expression
436
646
  const compactInner = inner.replace(/\s+/g, ' ').trim();
437
- // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
438
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(compactInner)) {
439
- // Remove whitespace around multiplication/division operators
440
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
441
- }
442
- // Preserve percent/number division expressions (e.g. 100%/3) — just strip inner spaces
443
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
444
- // Remove whitespace around division operator
445
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
446
- }
447
-
448
- const folded = tryFoldCalcExpression(compactInner);
449
- if (folded) {
450
- return folded;
451
- }
452
-
453
- try {
454
- const simplified = calc(match);
455
- if (typeof simplified !== 'string') {
456
- return match;
457
- }
458
- // If simplified to a bare percentage but original had division, preserve the calc form
459
- if (/^-?(?:\d+|\d*\.\d+)%$/.test(simplified) && /%\s*\//.test(match)) {
460
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
461
- }
462
- return simplified;
463
- } catch {
464
- return match;
465
- }
647
+ const resolved = resolveCalcExpression(match, compactInner);
648
+ return clampResolvedValueToPropertyRange(resolved, property);
466
649
  }));
467
650
 
468
651
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
@@ -476,9 +659,9 @@ function normalizeMathFunctions (value, property, originalValue = '') {
476
659
  }
477
660
  }
478
661
 
479
- // Round results with excessive decimal places (4+ digits after the decimal)
662
+ // Round a result whose decimal places run past the budget a resolved number is written within
480
663
  result = result.replace(/(-?(?:\d+|\d*\.\d+)\.\d{4,})([a-z%]+)/gi, (match, number, unit) => {
481
- return roundCompactNumber(number) + unit;
664
+ return formatResolvedNumber(number) + unit;
482
665
  });
483
666
  return result;
484
667
  }
@@ -486,18 +669,14 @@ function normalizeMathFunctions (value, property, originalValue = '') {
486
669
  /**
487
670
  * Simplifies a standalone calc() value by flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
488
671
  *
489
- * @param {string} value The CSS value string that may be a standalone calc() expression.
672
+ * @param {string} value The CSS value string, already known to be a single `calc()`.
490
673
  * @return {string} The simplified value, or the original value if simplification is not applicable.
491
674
  */
492
- function simplifyStandaloneCalc (value) {
493
- // Check if value starts with calc( and ends with )
494
- if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
495
- return value;
496
- }
675
+ function foldStandaloneCalc (value) {
497
676
  let inner = value.slice(5, -1).trim();
498
677
 
499
678
  // Preserve percent-times-number expressions, only stripping whitespace around operators
500
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(inner.replace(/\s+/g, ' ').trim())) {
679
+ if (PERCENTAGE_MULTIPLICATION.test(inner.replace(/\s+/g, ' ').trim())) {
501
680
  return 'calc(' + inner.replace(/\s*([*/])\s*/g, '$1') + ')';
502
681
  }
503
682
 
@@ -536,15 +715,31 @@ function simplifyStandaloneCalc (value) {
536
715
 
537
716
  // Collapse whitespace and check for percent-division expressions
538
717
  const compactInner = inner.replace(/\s+/g, ' ').trim();
539
- // Preserve percent/number division, just strip whitespace around the operator
540
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
541
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
718
+ const dividedPercentage = resolvePercentageDivision(compactInner);
719
+ if (dividedPercentage) {
720
+ return dividedPercentage;
542
721
  }
543
722
 
544
723
  // Default: strip whitespace around multiplication/division operators
545
724
  return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
546
725
  }
547
726
 
727
+ /**
728
+ * Simplifies a value that is a single `calc()`, holding whatever it resolves
729
+ * to inside the range the property it is declared on accepts.
730
+ *
731
+ * @param {string} value The CSS value string that may be a standalone calc() expression.
732
+ * @param {string} property The CSS property the value is declared on.
733
+ * @return {string} The simplified value, or the original value if simplification is not applicable.
734
+ */
735
+ function simplifyStandaloneCalc (value, property = '') {
736
+ // Check if value starts with calc( and ends with )
737
+ if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
738
+ return value;
739
+ }
740
+ return clampResolvedValueToPropertyRange(foldStandaloneCalc(value), property);
741
+ }
742
+
548
743
  export {
549
744
  normalizeMathFunctions,
550
745
  simplifyStandaloneCalc
@@ -3,6 +3,12 @@
3
3
  */
4
4
 
5
5
  import { isUnicodeCharset } from '../context.js';
6
+ import {
7
+ findMatchingParenthesis,
8
+ skipParenthesizedGroup,
9
+ skipQuotedString,
10
+ startsUrlToken
11
+ } from '../parser/source-search.js';
6
12
  import { resolveUnicodeEscape } from '../utilities.js';
7
13
 
8
14
  import { evaluateColorMix } from './color-mix.js';
@@ -31,12 +37,13 @@ import {
31
37
  import {
32
38
  collapseShorthandParts,
33
39
  convertAbsoluteLengthToPx,
40
+ formatResolvedNumber,
41
+ hasSubstitutedParts,
34
42
  normalizeScaleComponent,
35
43
  parseAlphaString,
36
44
  parseAngleToDegrees,
37
45
  roundCompactNumber
38
46
  } from './shared.js';
39
- import { findMatchingParenthesis } from './syntax.js';
40
47
  import { elideRedundantSeparators } from './tokens.js';
41
48
  import { minifyTransformValue } from './transforms.js';
42
49
  import { optimizeUnicodeRange } from './unicode-range.js';
@@ -136,68 +143,24 @@ function splitValueSegments (value) {
136
143
  const segments = [];
137
144
  let index = 0;
138
145
 
139
- const consumeQuoted = (start) => {
140
- const quote = value[start];
141
- let end = start + 1;
142
- while (end < value.length) {
143
- if (value[end] === '\\') {
144
- end += 2;
145
- continue;
146
- }
147
- if (value[end] === quote) {
148
- end++;
149
- break;
150
- }
151
- end++;
152
- }
153
- return end;
154
- };
155
-
156
- const startsUrl = (start) => {
157
- return value.slice(start, start + 4).toLowerCase() === 'url(';
158
- };
159
-
160
- const consumeUrl = (start) => {
161
- let depth = 1;
162
- let end = start + 4;
163
- while (end < value.length && depth > 0) {
164
- if (value[end] === '"' || value[end] === '\'') {
165
- end = consumeQuoted(end);
166
- continue;
167
- }
168
- // An escaped character is url data, never the parenthesis that ends it
169
- if (value[end] === '\\') {
170
- end += 2;
171
- continue;
172
- }
173
- if (value[end] === '(') {
174
- depth++;
175
- }
176
- if (value[end] === ')') {
177
- depth--;
178
- }
179
- end++;
180
- }
181
- return end;
182
- };
183
-
184
146
  while (index < value.length) {
185
147
  if (value[index] === '"' || value[index] === '\'') {
186
- const end = consumeQuoted(index);
148
+ const end = skipQuotedString(value, index);
187
149
  segments.push({ text: value.slice(index, end), isLiteral: true });
188
150
  index = end;
189
151
  continue;
190
152
  }
191
153
 
192
- if (startsUrl(index)) {
193
- const end = consumeUrl(index);
154
+ if (startsUrlToken(value, index)) {
155
+ // The `url(` token opens its parenthesized group at its fourth character
156
+ const end = skipParenthesizedGroup(value, index + 3);
194
157
  segments.push({ text: value.slice(index, end), isLiteral: true });
195
158
  index = end;
196
159
  continue;
197
160
  }
198
161
 
199
162
  const start = index;
200
- while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrl(index)) {
163
+ while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrlToken(value, index)) {
201
164
  index++;
202
165
  }
203
166
  segments.push({ text: value.slice(start, index), isLiteral: false });
@@ -836,7 +799,11 @@ const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
836
799
  *
837
800
  * @type {Set<string>}
838
801
  */
839
- const AUTO_INITIAL_PROPERTIES = new Set(['min-width', 'min-height']);
802
+ const AUTO_INITIAL_PROPERTIES = new Set([
803
+ 'min-width',
804
+ 'min-height',
805
+ 'hyphenate-limit-chars'
806
+ ]);
840
807
 
841
808
  /**
842
809
  * Properties whose initial value is a single keyword longer than the global
@@ -1008,6 +975,69 @@ function reorderBorderWidthBeforeStyle (value) {
1008
975
  return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
1009
976
  }
1010
977
 
978
+ /**
979
+ * Matches a trailing `!important` flag, so the components in front of it can be
980
+ * rewritten without the flag being mistaken for part of the last one.
981
+ *
982
+ * @type {RegExp}
983
+ */
984
+ const IMPORTANT_SUFFIX_PATTERN = /!important$/i;
985
+
986
+ /**
987
+ * Separates a value from the `!important` flag it may end with.
988
+ *
989
+ * @param {string} value The declaration value, possibly flagged important.
990
+ * @return {object} The components in front of the flag, and the flag itself or an empty string.
991
+ */
992
+ function splitImportantSuffix (value) {
993
+ const match = value.match(IMPORTANT_SUFFIX_PATTERN);
994
+ if (!match) {
995
+ return { components: value, importantSuffix: '' };
996
+ }
997
+ return {
998
+ components: value.slice(0, match.index).trim(),
999
+ importantSuffix: match[0]
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Collapses a `hyphenate-limit-chars` value to its shortest equivalent form.
1005
+ * The property states up to three components: the shortest word that may be
1006
+ * hyphenated, then the fewest characters allowed before a hyphen, then the
1007
+ * fewest allowed after it. An unstated "before" component is read as `auto`,
1008
+ * and an unstated "after" component repeats whatever "before" holds, so a
1009
+ * trailing component that only restates the default it stands in for is
1010
+ * redundant.
1011
+ *
1012
+ * @param {string} value The `hyphenate-limit-chars` value.
1013
+ * @return {string} The value with its redundant trailing components dropped.
1014
+ */
1015
+ function collapseHyphenateLimitChars (value) {
1016
+ const {
1017
+ components,
1018
+ importantSuffix
1019
+ } = splitImportantSuffix(value);
1020
+ const parts = components.split(' ').filter(Boolean);
1021
+ if (hasSubstitutedParts(parts)) {
1022
+ return value;
1023
+ }
1024
+ const isAfterMatchingBefore = (
1025
+ parts.length === 3 &&
1026
+ parts[2].toLowerCase() === parts[1].toLowerCase()
1027
+ );
1028
+ if (isAfterMatchingBefore) {
1029
+ parts.pop();
1030
+ }
1031
+ const isBeforeLeftAtDefault = (
1032
+ parts.length === 2 &&
1033
+ parts[1].toLowerCase() === 'auto'
1034
+ );
1035
+ if (isBeforeLeftAtDefault) {
1036
+ parts.pop();
1037
+ }
1038
+ return parts.join(' ') + importantSuffix;
1039
+ }
1040
+
1011
1041
  /**
1012
1042
  * Minifies a declaration's value for output, rewriting its initial keyword to
1013
1043
  * the shorter global `initial` keyword after shorthand assembly. Both are
@@ -1301,6 +1331,10 @@ function applyPropertyOptimizations (val, property) {
1301
1331
  val = collapseShorthandParts(val.split(' ')).join(' ');
1302
1332
  }
1303
1333
 
1334
+ if (property === 'hyphenate-limit-chars') {
1335
+ val = collapseHyphenateLimitChars(val);
1336
+ }
1337
+
1304
1338
  if (property === 'border-radius') {
1305
1339
  const segments = val.split('/').map((segment) => {
1306
1340
  return segment.trim();
@@ -1343,7 +1377,7 @@ function computeMinifiedValue (declaration) {
1343
1377
  // Remove whitespace around * and / operators (safe outside calc context)
1344
1378
  val = val.replace(/\s*([*/])\s*/g, '$1');
1345
1379
  val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
1346
- val = simplifyStandaloneCalc(val);
1380
+ val = simplifyStandaloneCalc(val, declaration.property);
1347
1381
  // Simplify calc() expressions containing zero-percent additive terms
1348
1382
  val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
1349
1383
  // Collapse whitespace inside calc expression
@@ -1367,10 +1401,11 @@ function computeMinifiedValue (declaration) {
1367
1401
  }
1368
1402
  val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
1369
1403
 
1370
- // If value is a standalone number with optional unit, round it compactly
1404
+ // If value is a standalone number with optional unit, round it into the
1405
+ // character budget a number is written within
1371
1406
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1372
1407
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1373
- val = roundCompactNumber(rawNumber, 4) + rawUnit;
1408
+ val = formatResolvedNumber(rawNumber) + rawUnit;
1374
1409
  }
1375
1410
 
1376
1411
  val = lowercaseHexColors(val);