@thejaredwilcurt/csslop 0.0.35 → 0.0.37

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/README.md CHANGED
@@ -285,12 +285,13 @@ Two different cases:
285
285
  ## Updating tests
286
286
 
287
287
  1. All test changes must occur [upstream](https://github.com/keithamus/css-minify-tests), be written by a human, and be merged in to the `css-minify-tests` repo.
288
- 1. After that, delete the `package-lock.json` and `node_modules` folder.
289
- 1. Then run `npm i && npm run copy` to download the latest tests and copy them to this repo.
288
+ 1. Clone down a copy of that repo next to the cloned copy of this one (sibling folders)
289
+ 1. Make sure the `css-minify-tests` clone is on the `main` branch and up-to-date (`git pull origin main`)
290
+ 1. Then run `npm run copy` in the CSSLOP repo to copy the test from `css-minify-tests` to the `copiedTests` folder in this repo.
290
291
  1. `git add -A && git commit -m "Updated tests"`
291
292
  1. Then run `npm t` to see if any tests fail
292
293
  1. If they fail, give an AI this prompt:
293
- * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
294
+ * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
294
295
  1. Verify only code in the `src` folder was modified
295
296
  1. Verify `npm t` passes with a 100% score
296
297
  1. Run `npm run lint`, if anything fails, have the AI fix it.
@@ -298,9 +299,7 @@ Two different cases:
298
299
  1. `npm run bump`
299
300
  1. `git add -A && git commit -m "Fix newly added tests" && git push`
300
301
  1. Merge the code into `main` on GitHub
301
- 1. Do a new release on GitHub
302
- 1. `git checkout main && git pull origin main && git pull`
303
- 1. `npm run publish`
302
+ 1. Do a new release on GitHub, this will also release to npm
304
303
 
305
304
 
306
305
  ## Code Organization prompt:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.35",
5
+ "version": "0.0.37",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -36,12 +36,12 @@
36
36
  "eslint-config-tjw-import-x": "^1.0.1",
37
37
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
38
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^64.3.5",
39
+ "eslint-plugin-jsdoc": "^64.3.9",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.12.0",
42
42
  "pretty-ms": "^9.3.1",
43
43
  "real-world-css-libraries": "^1.0.8",
44
- "vite": "^8.2.2"
44
+ "vite": "^8.3.0"
45
45
  },
46
46
  "author": "The Jared Wilcurt",
47
47
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -58,7 +58,7 @@
58
58
  "devEngines": {
59
59
  "runtime": {
60
60
  "name": "node",
61
- "version": "26.8.1"
61
+ "version": "26.8.2"
62
62
  },
63
63
  "packageManager": {
64
64
  "name": "npm",
@@ -199,93 +199,6 @@ function getOverridesOf (shorthandName) {
199
199
  return OVERRIDES_BY_SHORTHAND.get(shorthandName) || NO_PROPERTIES;
200
200
  }
201
201
 
202
- /**
203
- * The words a longhand adds to its shorthand's name to say which side, axis,
204
- * corner, or alignment dimension of the box that longhand applies to, such as
205
- * the `top` of `padding-top` or the `row` of `row-gap`.
206
- *
207
- * @type {Set<string>}
208
- */
209
- const BOX_PART_WORDS = new Set([
210
- 'align',
211
- 'block',
212
- 'bottom',
213
- 'column',
214
- 'end',
215
- 'inline',
216
- 'justify',
217
- 'left',
218
- 'right',
219
- 'row',
220
- 'start',
221
- 'top',
222
- 'x',
223
- 'y'
224
- ]);
225
-
226
- /**
227
- * Removes each of the shorthand's own words from a longhand's words, leaving
228
- * only the words the longhand adds to name the part of the box it covers. Each
229
- * shared word is removed once, so the `border` and the `radius` of
230
- * `border-radius` leave `top` and `left` behind in `border-top-left-radius`.
231
- *
232
- * @param {Array} longhandWords The hyphen-separated words of the longhand's name.
233
- * @param {Array} shorthandWords The hyphen-separated words of the shorthand's name.
234
- * @return {Array} The words the longhand adds on top of the shorthand's.
235
- */
236
- function subtractSharedWords (longhandWords, shorthandWords) {
237
- const remainingWords = [...longhandWords];
238
- for (const shorthandWord of shorthandWords) {
239
- const wordIndex = remainingWords.indexOf(shorthandWord);
240
- if (wordIndex !== -1) {
241
- remainingWords.splice(wordIndex, 1);
242
- }
243
- }
244
- return remainingWords;
245
- }
246
-
247
- /**
248
- * Whether each shorthand takes a positional list of components, computed on
249
- * first use, since the shorthand tables never change.
250
- *
251
- * @type {Map<string, boolean>}
252
- */
253
- const positionalComponentsByShorthand = new Map();
254
-
255
- /**
256
- * Reports whether a shorthand's value is a positional list of same-typed
257
- * components rather than an unordered set of components that its grammar tells
258
- * apart by type. A shorthand is positional when its longhands are the very same
259
- * property repeated for each part of the box, as `padding` repeats a length for
260
- * each side and `gap` repeats one for each axis. Nothing but the order the
261
- * components are written in says which part of the box each one lands on, so
262
- * the whitespace between them delimits the list. A shorthand such as `border`
263
- * or `font`, whose longhands each hold a different kind of value, is not
264
- * positional: its grammar reads each component by type, in any order.
265
- *
266
- * @param {string} shorthandName The CSS property name to test.
267
- * @return {boolean} Whether the shorthand's components are positional.
268
- */
269
- function hasPositionalComponents (shorthandName) {
270
- const cachedAnswer = positionalComponentsByShorthand.get(shorthandName);
271
- if (cachedAnswer !== undefined) {
272
- return cachedAnswer;
273
- }
274
- const longhands = shorthandMap[shorthandName];
275
- let isPositional = false;
276
- if (Array.isArray(longhands)) {
277
- const shorthandWords = shorthandName.split('-');
278
- isPositional = longhands.every((longhand) => {
279
- const addedWords = subtractSharedWords(longhand.split('-'), shorthandWords);
280
- return Boolean(addedWords.length) && addedWords.every((word) => {
281
- return BOX_PART_WORDS.has(word);
282
- });
283
- });
284
- }
285
- positionalComponentsByShorthand.set(shorthandName, isPositional);
286
- return isPositional;
287
- }
288
-
289
202
  /**
290
203
  * The leaf longhands each property ultimately sets, computed on first use. The
291
204
  * shorthand tables never change, so a property always expands the same way.
@@ -329,7 +242,6 @@ export {
329
242
  expandToLeafProperties,
330
243
  getLonghandsOf,
331
244
  getOverridesOf,
332
- hasPositionalComponents,
333
245
  shorthandMap,
334
246
  shorthandOverrideMap,
335
247
  UNIFORM_VALUE_SHORTHANDS
@@ -97,7 +97,8 @@ function keepDeclaration (survivors, declaration) {
97
97
  } else {
98
98
  survivors.positionsByProperty.set(declaration.property, [position]);
99
99
  }
100
- if (declaration.property.startsWith('-')) {
100
+ // Custom properties (`--*`) are named by their author, not vendor-prefixed
101
+ if (declaration.property.startsWith('-') && !declaration.property.startsWith('--')) {
101
102
  survivors.vendorPrefixedPositions.push(position);
102
103
  }
103
104
  }
@@ -137,19 +138,39 @@ function findLastPositionOfProperty (survivors, property) {
137
138
  }
138
139
 
139
140
  /**
140
- * Finds the surviving vendor-prefixed declaration that last set the prefixed
141
- * form of a property, such as `-webkit-transform` for `transform`. Only the
142
- * prefixed declarations are visited, rather than every survivor.
141
+ * Finds the surviving vendor-prefixed declarations that set a prefixed form of
142
+ * a property, such as `-webkit-transform` for `transform`, including legacy
143
+ * spellings like `-webkit-box-flex` for `flex`. Only the prefixed declarations
144
+ * are visited, rather than every survivor.
143
145
  *
144
146
  * @param {object} survivors The surviving-declaration bookkeeping.
145
147
  * @param {string} property The unprefixed property name.
146
- * @return {number} The position of the matching declaration, or -1 when absent.
148
+ * @return {Array} The positions of the matching declarations, in source order.
147
149
  */
148
- function findLastVendorPrefixedPosition (survivors, property) {
149
- for (let entry = survivors.vendorPrefixedPositions.length - 1; entry >= 0; entry--) {
150
- const position = survivors.vendorPrefixedPositions[entry];
151
- if (survivors.slots[position].property.endsWith(property)) {
152
- return position;
150
+ function findVendorPrefixedPositions (survivors, property) {
151
+ return survivors.vendorPrefixedPositions.filter((position) => {
152
+ return survivors.slots[position].property.endsWith(property);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Finds the surviving declaration that last set the unprefixed property a
158
+ * vendor-prefixed declaration spells in prefixed form, such as `transform`
159
+ * for `-moz-transform`.
160
+ *
161
+ * @param {object} survivors The surviving-declaration bookkeeping.
162
+ * @param {string} propertyName The vendor-prefixed property name.
163
+ * @return {number} The position of the matching declaration, or -1 when absent.
164
+ */
165
+ function findUnprefixedTwinPosition (survivors, propertyName) {
166
+ for (const [property, positions] of survivors.positionsByProperty) {
167
+ const isUnprefixedTwin = (
168
+ !property.startsWith('-') &&
169
+ positions.length &&
170
+ propertyName.endsWith(property)
171
+ );
172
+ if (isUnprefixedTwin) {
173
+ return positions[positions.length - 1];
153
174
  }
154
175
  }
155
176
  return -1;
@@ -183,15 +204,24 @@ function deduplicateDeclarations (declarations) {
183
204
 
184
205
  const minifiedValue = minifyValue(declaration);
185
206
 
207
+ // A vendor-prefixed property that repeats the value of its unprefixed
208
+ // form is dead code for browsers that read the unprefixed one
209
+ if (propertyName.startsWith('-') && !propertyName.startsWith('--')) {
210
+ const twinPosition = findUnprefixedTwinPosition(survivors, propertyName);
211
+ if (twinPosition !== -1 && minifyValue(survivors.slots[twinPosition]) === minifiedValue) {
212
+ continue;
213
+ }
214
+ }
215
+
186
216
  const previousPosition = findLastPositionOfProperty(survivors, propertyName);
187
217
 
188
- // An unprefixed property with the same value also replaces its prefixed form
189
- let prefixedPosition = -1;
218
+ // An unprefixed property with the same value also replaces every prefixed form of it
219
+ let prefixedPositions = [];
190
220
  if (!propertyName.startsWith('-')) {
191
- prefixedPosition = findLastVendorPrefixedPosition(survivors, propertyName);
221
+ prefixedPositions = findVendorPrefixedPositions(survivors, propertyName);
192
222
  }
193
223
 
194
- if (prefixedPosition !== -1) {
224
+ for (const prefixedPosition of prefixedPositions) {
195
225
  const prefixedValue = minifyValue(survivors.slots[prefixedPosition]);
196
226
  if (minifiedValue === prefixedValue) {
197
227
  dropDeclaration(survivors, prefixedPosition);
@@ -68,6 +68,15 @@ function expandPureNestedRules (rules) {
68
68
  canExpand = false;
69
69
  break;
70
70
  }
71
+ // A child written as a comma-separated selector list, such as `.b,.c`,
72
+ // repeats the parent in front of every item when expanded (`.a .b,.a .c`),
73
+ // while the nested form states the parent once. Expanding is always
74
+ // longer, and leaving the rule nested keeps one pass from expanding what
75
+ // the next pass would then keep, so the output stays idempotent.
76
+ if (nestedRule.selectors.length > 1) {
77
+ canExpand = false;
78
+ break;
79
+ }
71
80
  const combinedSelectors = [];
72
81
  for (const parentSelector of rule.selectors) {
73
82
  for (const childSelector of nestedRule.selectors) {
package/src/value/math.js CHANGED
@@ -10,6 +10,79 @@ import {
10
10
  roundCompactNumber
11
11
  } from './shared.js';
12
12
 
13
+ /**
14
+ * Characters that start a token able to sit directly against a value that
15
+ * precedes them, so no whitespace is needed to keep the two tokens apart:
16
+ * whitespace already separates, and openers like `#`, `!`, or any bracket or
17
+ * punctuation mark their own token's start. Mirrors the tokenizer's notion of
18
+ * tokens marking their own start, with `/` on top since it delimits a size
19
+ * from a position in layered shorthands.
20
+ *
21
+ * @type {Set<string>}
22
+ */
23
+ const SELF_DELIMITING_FOLLOWERS = new Set([
24
+ ' ',
25
+ '\t',
26
+ '\n',
27
+ '\r',
28
+ '\f',
29
+ '#',
30
+ '!',
31
+ '"',
32
+ '\'',
33
+ '(',
34
+ ')',
35
+ '[',
36
+ ']',
37
+ '{',
38
+ '}',
39
+ ',',
40
+ ':',
41
+ ';',
42
+ '/'
43
+ ]);
44
+
45
+ /**
46
+ * Reports whether a math function's replacement glues itself to the character
47
+ * following the matched function, merging two values into one token. A
48
+ * replacement that keeps its function form ends with a parenthesis, which
49
+ * closes itself; only a bare resolved value needs the whitespace that the
50
+ * parenthesis padding removal took away.
51
+ *
52
+ * @param {string} replacement The string the math function resolved to.
53
+ * @param {string} followingCharacter The character directly after the matched function.
54
+ * @return {boolean} Whether a separating space must be restored.
55
+ */
56
+ function mergesWithFollowingCharacter (replacement, followingCharacter) {
57
+ if (replacement.endsWith(')') || !followingCharacter) {
58
+ return false;
59
+ }
60
+ return !SELF_DELIMITING_FOLLOWERS.has(followingCharacter);
61
+ }
62
+
63
+ /**
64
+ * Wraps a math-function replacer so a function that dissolves into a bare
65
+ * value does not lose the whitespace separating it from the value behind it.
66
+ * The values were once told apart by where the function's parentheses sat, so
67
+ * when the parentheses go, the separator has to stay.
68
+ *
69
+ * @param {function(string, ...(string|number|undefined)): string} replacer The replacement callback for `String.prototype.replace`.
70
+ * @return {function(string, ...(string|number|undefined)): string} A replacer that keeps the trailing separator intact.
71
+ */
72
+ function keepSeparatorAfterDissolvedFunction (replacer) {
73
+ return (match, ...rest) => {
74
+ const replacement = replacer(match, ...rest);
75
+ // A replace callback's trailing arguments are the match offset and the full string
76
+ const fullText = rest[rest.length - 1];
77
+ const matchOffset = rest[rest.length - 2];
78
+ const followingCharacter = fullText[matchOffset + match.length];
79
+ if (mergesWithFollowingCharacter(replacement, followingCharacter)) {
80
+ return replacement + ' ';
81
+ }
82
+ return replacement;
83
+ };
84
+ }
85
+
13
86
  /**
14
87
  * The units a folded calc() expression leads with, in the order they are
15
88
  * written. Every other unit follows them alphabetically.
@@ -153,30 +226,30 @@ function normalizeMathFunctions (value, property, originalValue = '') {
153
226
  let result = value;
154
227
 
155
228
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
156
- result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, (match, inner) => {
229
+ result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
157
230
  return inner.trim();
158
- });
231
+ }));
159
232
  // Flatten calc(calc(a) ± b) → calc(a ± b)
160
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, (match, inner, operator, tail) => {
233
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner, operator, tail) => {
161
234
  return 'calc(' + inner + ' ' + operator + ' ' + tail + ')';
162
- });
235
+ }));
163
236
  // Unwrap calc(calc(x)) → calc(x)
164
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, (match, inner) => {
237
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
165
238
  return 'calc(' + inner + ')';
166
- });
239
+ }));
167
240
 
168
241
  // Simplify min()/max() expressions using @csstools/css-calc
169
- result = result.replace(/\b(min|max)\(([^()]+)\)/gi, (match) => {
242
+ result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
170
243
  try {
171
244
  const simplified = calc(match);
172
245
  return typeof simplified === 'string' ? simplified : match;
173
246
  } catch {
174
247
  return match;
175
248
  }
176
- });
249
+ }));
177
250
 
178
251
  // Simplify calc() expressions using constant folding and @csstools/css-calc
179
- result = result.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
252
+ result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
180
253
  // Collapse whitespace inside calc expression
181
254
  const compactInner = inner.replace(/\s+/g, ' ').trim();
182
255
  // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
@@ -208,7 +281,7 @@ function normalizeMathFunctions (value, property, originalValue = '') {
208
281
  } catch {
209
282
  return match;
210
283
  }
211
- });
284
+ }));
212
285
 
213
286
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
214
287
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { isUnicodeCharset } from '../context.js';
6
- import { hasPositionalComponents } from '../declarations/config.js';
7
6
  import { resolveUnicodeEscape } from '../utilities.js';
8
7
 
9
8
  import { evaluateColorMix } from './color-mix.js';
@@ -166,6 +165,11 @@ function splitValueSegments (value) {
166
165
  end = consumeQuoted(end);
167
166
  continue;
168
167
  }
168
+ // An escaped character is url data, never the parenthesis that ends it
169
+ if (value[end] === '\\') {
170
+ end += 2;
171
+ continue;
172
+ }
169
173
  if (value[end] === '(') {
170
174
  depth++;
171
175
  }
@@ -251,30 +255,42 @@ function restoreSpaceBeforeMathOperators (value) {
251
255
  });
252
256
  }
253
257
 
258
+ /**
259
+ * Escapes the characters an unquoted url() token cannot hold, so the path can
260
+ * drop its quotes. A backslash already escaping a character is left alone, so
261
+ * a path that arrived escaped is never escaped a second time.
262
+ *
263
+ * @param {string} path The url path, without surrounding quotes.
264
+ * @return {string} The path with every bare space and parenthesis escaped.
265
+ */
266
+ function escapeUrlPathCharacters (path) {
267
+ // Match each space and parenthesis that no escaping backslash precedes
268
+ return path.replace(/(?<!\\)([ ()])/g, '\\$1');
269
+ }
270
+
254
271
  /**
255
272
  * Chooses the shortest valid representation for the path inside a `url(...)`
256
- * token, weighing an unquoted form, an escaped single space, and a quoted form.
273
+ * token, weighing an unquoted form — its spaces and parentheses escaped —
274
+ * against a quoted form. Quotation marks can only ever appear in the quoted
275
+ * form, so a path holding one always keeps its wrapper.
257
276
  *
258
277
  * @param {string} path The resolved url path, without surrounding quotes.
259
278
  * @return {string} The shortest valid url() content string.
260
279
  */
261
280
  function formatUrlPath (path) {
262
- // Parentheses and quote characters are invalid inside an unquoted url() token
263
- const hasQuoteForcingCharacters = /[()"']/.test(path);
264
- // Count spaces so escaping them can be compared against keeping the quotes
265
- const spaceCount = (path.match(/ /g) || []).length;
266
-
267
- if (hasQuoteForcingCharacters || spaceCount >= 2) {
268
- // Escape any embedded double quotes so the double-quoted wrapper stays valid
269
- return '"' + path.replace(/"/g, '\\"') + '"';
281
+ // Escape any embedded double quotes so the double-quoted wrapper stays valid
282
+ const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
283
+ // Match a quotation mark, which an unquoted url() token cannot hold
284
+ if (/["']/.test(path)) {
285
+ return quotedForm;
270
286
  }
271
-
272
- if (spaceCount === 1) {
273
- // A lone space is one byte shorter to escape than to wrap the value in quotes
274
- return path.replace(/ /g, '\\ ');
287
+ const unquotedForm = escapeUrlPathCharacters(path);
288
+ // Each escaped character costs one backslash, so dropping the two quotation
289
+ // marks pays off whenever at most one character needs escaping
290
+ if (unquotedForm.length < quotedForm.length) {
291
+ return unquotedForm;
275
292
  }
276
-
277
- return path;
293
+ return quotedForm;
278
294
  }
279
295
 
280
296
  /**
@@ -976,12 +992,11 @@ function reorderBorderWidthBeforeStyle (value) {
976
992
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
977
993
  * background, display, scale, border-radius, shorthand collapsing, etc.).
978
994
  *
979
- * @param {string} val The CSS value string after generic minification.
980
- * @param {string} property The CSS property name.
981
- * @param {boolean} allowsSeparatorElision Whether redundant separator whitespace may be removed.
982
- * @return {string} The value with property-specific optimizations applied.
995
+ * @param {string} val The CSS value string after generic minification.
996
+ * @param {string} property The CSS property name.
997
+ * @return {string} The value with property-specific optimizations applied.
983
998
  */
984
- function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
999
+ function applyPropertyOptimizations (val, property) {
985
1000
  if (property === 'font-weight' && isUnicodeCharset()) {
986
1001
  // Replace font-weight keyword "bold" with its numeric equivalent
987
1002
  val = val.replace(/\bbold\b/gi, '700');
@@ -1023,8 +1038,11 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1023
1038
  val = val.replace(/\s+0px/g, ' ');
1024
1039
  // Remove leading zero-pixel value
1025
1040
  val = val.replace(/^0px\s*/, '');
1026
- // Remove trailing zero
1027
- val = val.replace(/\s+0$/, '');
1041
+ // Remove an explicit zero basis: a trailing zero is only a basis when grow
1042
+ // and shrink precede it, and the shorthand already reads an unwritten basis
1043
+ // as 0. In `0 0` the trailing zero is the shrink, which the shorthand
1044
+ // defaults to 1 instead, so dropping it would size the element differently.
1045
+ val = val.replace(/^(\S+\s+\S+)\s+0$/, '$1');
1028
1046
  // Remove standalone zero-pixel value
1029
1047
  val = val.replace(/^0px$/, '');
1030
1048
  val = val.trim();
@@ -1141,7 +1159,7 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1141
1159
 
1142
1160
  // Shorten all color tokens (second pass after property-specific color evaluations)
1143
1161
  val = replaceOutsideStringsAndUrls(val, (segment) => {
1144
- return shortenColorValues(segment, allowsSeparatorElision);
1162
+ return shortenColorValues(segment);
1145
1163
  });
1146
1164
 
1147
1165
  if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
@@ -1243,21 +1261,6 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1243
1261
  return val;
1244
1262
  }
1245
1263
 
1246
- /**
1247
- * Reports whether a declaration holds a shorthand that was assembled by joining
1248
- * already-minified longhand values with a separator, and whose grammar reads
1249
- * those components by their position in the list. Nothing but that separator
1250
- * says where one component ends and the next begins, so it is kept even where
1251
- * the two components happen to be tokens that would survive being written
1252
- * together.
1253
- *
1254
- * @param {object} declaration The CSS declaration object with property and value fields.
1255
- * @return {boolean} Whether the assembled components keep their separators.
1256
- */
1257
- function keepsAssembledComponentSeparators (declaration) {
1258
- return Boolean(declaration.isAssembledShorthand) && hasPositionalComponents(declaration.property);
1259
- }
1260
-
1261
1264
  /**
1262
1265
  * Minifies a CSS declaration's value by applying color conversion, math simplification, shorthand compression, gradient optimization, and other property-specific optimizations.
1263
1266
  *
@@ -1275,7 +1278,6 @@ function computeMinifiedValue (declaration) {
1275
1278
  return 'none';
1276
1279
  }
1277
1280
  let val = declaration.value;
1278
- const allowsSeparatorElision = !keepsAssembledComponentSeparators(declaration);
1279
1281
 
1280
1282
  if (typeof val === 'string') {
1281
1283
  val = val.trim();
@@ -1323,19 +1325,16 @@ function computeMinifiedValue (declaration) {
1323
1325
  // Convert color functions to hex equivalents
1324
1326
  val = convertColorsToHex(val);
1325
1327
 
1326
- // Shorten all color tokens (hex and named) to their shortest representation.
1327
- // A value that keeps the whitespace between its components saves nothing by
1328
- // switching to a spelling of the same length, so its colors keep the
1329
- // spelling they were written with.
1328
+ // Shorten all color tokens (hex and named) to their shortest representation
1330
1329
  val = replaceOutsideStringsAndUrls(val, (segment) => {
1331
- return shortenColorValues(segment, allowsSeparatorElision);
1330
+ return shortenColorValues(segment);
1332
1331
  });
1333
1332
 
1334
1333
  // Collapse light-dark() when both normalized branches are identical
1335
1334
  val = simplifyEquivalentLightDarkFunctions(val);
1336
1335
 
1337
1336
  // Property-specific optimizations
1338
- val = applyPropertyOptimizations(val, declaration.property, allowsSeparatorElision);
1337
+ val = applyPropertyOptimizations(val, declaration.property);
1339
1338
 
1340
1339
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1341
1340
  val = minifyRelativeColorSyntax(val);
@@ -1356,7 +1355,6 @@ function computeMinifiedValue (declaration) {
1356
1355
  // so the ones that turned out to be redundant are only dropped at the end.
1357
1356
  const elidesRedundantSeparators = (
1358
1357
  typeof val === 'string' &&
1359
- allowsSeparatorElision &&
1360
1358
  !isCustomProperty(declaration.property)
1361
1359
  );
1362
1360
  if (elidesRedundantSeparators) {
@@ -29,6 +29,13 @@ function findMatchingParenthesis (value, openParenIndex) {
29
29
  continue;
30
30
  }
31
31
 
32
+ // An escaped character is data rather than a delimiter, even outside a
33
+ // quoted string, as in the `\)` of an unquoted url()
34
+ if (character === '\\') {
35
+ index += 2;
36
+ continue;
37
+ }
38
+
32
39
  if (character === '"' || character === '\'') {
33
40
  activeQuote = character;
34
41
  index++;