@thejaredwilcurt/csslop 0.0.27 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.27",
5
+ "version": "0.0.28",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -39,7 +39,7 @@
39
39
  "eslint-plugin-jsdoc": "^64.2.1",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.11.0",
42
- "pretty-ms": "^9.3.0",
42
+ "pretty-ms": "^9.3.1",
43
43
  "real-world-css-libraries": "^1.0.8",
44
44
  "vite": "^8.2.2"
45
45
  },
@@ -3,14 +3,72 @@
3
3
  */
4
4
 
5
5
  import { minifyValue } from '../value/minify.js';
6
+ import { namedColors } from '../value/named-colors.js';
7
+ import { collapseShorthandParts } from '../value/shared.js';
6
8
  import { splitTopLevelComponents } from '../value/syntax.js';
7
9
 
8
- import { CSS_WIDE_KEYWORDS } from './config.js';
10
+ import {
11
+ BORDER_EDGE_PROPERTIES,
12
+ CSS_WIDE_KEYWORDS
13
+ } from './config.js';
9
14
  import { collectDeclaredProperties } from './lookup.js';
10
15
  import { resetsPropertyDeclaredElsewhere } from './reset-hazards.js';
11
16
 
12
17
  const BORDER_TRIO_PROPERTIES = ['border-width', 'border-style', 'border-color'];
13
18
 
19
+ /**
20
+ * The keywords that state a border line style.
21
+ *
22
+ * @type {Set<string>}
23
+ */
24
+ const BORDER_STYLE_KEYWORDS = new Set([
25
+ 'none',
26
+ 'hidden',
27
+ 'dotted',
28
+ 'dashed',
29
+ 'solid',
30
+ 'double',
31
+ 'groove',
32
+ 'ridge',
33
+ 'inset',
34
+ 'outset'
35
+ ]);
36
+
37
+ /**
38
+ * The keywords that state a border line width without writing a length.
39
+ *
40
+ * @type {Set<string>}
41
+ */
42
+ const BORDER_WIDTH_KEYWORDS = new Set(['thin', 'medium', 'thick']);
43
+
44
+ /**
45
+ * The functions that resolve to a length, so a border edge component calling one
46
+ * of them states the line width.
47
+ *
48
+ * @type {RegExp}
49
+ */
50
+ const LENGTH_FUNCTION_PATTERN = /^(?:calc|min|max|clamp|round|rem|mod|abs|sign|anchor-size)\(/i;
51
+
52
+ /**
53
+ * The functions that resolve to a color, so a border edge component calling one
54
+ * of them states the line color.
55
+ *
56
+ * @type {RegExp}
57
+ */
58
+ const COLOR_FUNCTION_PATTERN = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark|contrast-color|device-cmyk)\(/i;
59
+
60
+ /**
61
+ * The value that each part of a border edge takes when the edge leaves it out,
62
+ * since an edge shorthand resets every part it does not state.
63
+ *
64
+ * @type {{[key: string]: string}}
65
+ */
66
+ const INITIAL_BORDER_EDGE_PARTS = {
67
+ 'border-width': 'medium',
68
+ 'border-style': 'none',
69
+ 'border-color': 'currentcolor'
70
+ };
71
+
14
72
  /**
15
73
  * Finds the index of the last declaration for a property, which is the one that
16
74
  * wins the cascade within a rule.
@@ -126,4 +184,212 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
126
184
  });
127
185
  }
128
186
 
129
- export { collapseBorderTrioWithPerEdgeColor };
187
+ /**
188
+ * Decides which part of a border edge a single component states. Every
189
+ * component has to be recognized, because a component sorted into the wrong
190
+ * part would change what the rule renders.
191
+ *
192
+ * @param {string} component One component of a border edge value.
193
+ * @return {string|null} The trio property the component states, or null when it is unrecognized.
194
+ */
195
+ function classifyBorderEdgeComponent (component) {
196
+ const keyword = component.toLowerCase();
197
+ if (BORDER_STYLE_KEYWORDS.has(keyword)) {
198
+ return 'border-style';
199
+ }
200
+ // Match a number with an optional unit, which is how a length is written
201
+ const isLength = /^[+-]?(?:\d+|\d*\.\d+)[a-z]*$/i.test(component);
202
+ if (BORDER_WIDTH_KEYWORDS.has(keyword) || isLength || LENGTH_FUNCTION_PATTERN.test(component)) {
203
+ return 'border-width';
204
+ }
205
+ const isColor = (
206
+ keyword === 'currentcolor' ||
207
+ component.startsWith('#') ||
208
+ Object.hasOwn(namedColors, keyword) ||
209
+ COLOR_FUNCTION_PATTERN.test(component)
210
+ );
211
+ if (isColor) {
212
+ return 'border-color';
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * Splits one border edge value into the width, style, and color it sets. A part
219
+ * the edge leaves unstated is reset to its initial value by the edge shorthand,
220
+ * so the split states that initial value in its place.
221
+ *
222
+ * @param {string} value The value of a `border-<side>` declaration, without its importance.
223
+ * @return {object|null} The value each trio property takes, or null when the edge cannot be split.
224
+ */
225
+ function splitBorderEdgeValue (value) {
226
+ const statedProperties = new Set();
227
+ const parts = { ...INITIAL_BORDER_EDGE_PARTS };
228
+
229
+ for (const component of splitTopLevelComponents(value)) {
230
+ const property = classifyBorderEdgeComponent(component);
231
+ if (!property || statedProperties.has(property)) {
232
+ return null;
233
+ }
234
+ statedProperties.add(property);
235
+ parts[property] = component;
236
+ }
237
+
238
+ if (!statedProperties.size) {
239
+ return null;
240
+ }
241
+ return parts;
242
+ }
243
+
244
+ /**
245
+ * Collects the one declaration of each border edge, in top/right/bottom/left
246
+ * order. An edge that a rule states more than once is kept as it is, since the
247
+ * repeat is a deliberate fallback whose order the split would not preserve.
248
+ *
249
+ * @param {Array} declarations The declarations of a single rule.
250
+ * @return {Array|null} The four edge declarations, or null when the rule does not state each edge exactly once.
251
+ */
252
+ function collectBorderEdgeDeclarations (declarations) {
253
+ const edgeDeclarations = [];
254
+ for (const edgeProperty of BORDER_EDGE_PROPERTIES) {
255
+ const matches = declarations.filter((declaration) => {
256
+ return declaration.property === edgeProperty;
257
+ });
258
+ if (matches.length !== 1) {
259
+ return null;
260
+ }
261
+ edgeDeclarations.push(matches[0]);
262
+ }
263
+ return edgeDeclarations;
264
+ }
265
+
266
+ /**
267
+ * Builds the `border-width`, `border-style`, and `border-color` declarations
268
+ * that state per side what a set of border edges stated per edge. The edges are
269
+ * read as authored rather than minified, so that each component is still a
270
+ * component of its own and keeps the spelling it was written with.
271
+ *
272
+ * @param {Array} edgeDeclarations The four edge declarations, in top/right/bottom/left order.
273
+ * @return {Array|null} The trio declarations, or null when the edges cannot be split.
274
+ */
275
+ function buildBorderTrioDeclarations (edgeDeclarations) {
276
+ const edgeParts = [];
277
+ for (const declaration of edgeDeclarations) {
278
+ if (typeof declaration.value !== 'string') {
279
+ return null;
280
+ }
281
+ const parts = splitBorderEdgeValue(declaration.value.trim());
282
+ if (!parts) {
283
+ return null;
284
+ }
285
+ edgeParts.push(parts);
286
+ }
287
+
288
+ // The three parts do not affect one another, so they are written in a fixed
289
+ // alphabetical order rather than in an order the values would decide.
290
+ return [...BORDER_TRIO_PROPERTIES].sort().map((property) => {
291
+ const sideValues = edgeParts.map((parts) => {
292
+ return parts[property];
293
+ });
294
+ return {
295
+ property,
296
+ value: collapseShorthandParts(sideValues).join(' '),
297
+ isAssembledShorthand: true
298
+ };
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Measures what a set of declarations costs in the output, which is the text of
304
+ * each of them joined by the semicolons that separate them.
305
+ *
306
+ * @param {Array} declarations The declarations to measure.
307
+ * @return {number} The number of characters the declarations take up.
308
+ */
309
+ function measureDeclarations (declarations) {
310
+ return declarations.map((declaration) => {
311
+ return declaration.property + ':' + minifyValue(declaration);
312
+ }).join(';').length;
313
+ }
314
+
315
+ /**
316
+ * Rewrites four `border-<side>` declarations whose values differ into the
317
+ * `border-width`, `border-style`, and `border-color` trio. Unlike `margin`, the
318
+ * `border` shorthand is not a four-sided one: it takes a single width, style,
319
+ * and color, so four differing edges have no `border` value to collapse into.
320
+ * The trio, on the other hand, states each of the three parts once per side,
321
+ * which spells the same borders out in fewer characters.
322
+ *
323
+ * @param {Array} declarations The declarations of a single rule.
324
+ * @return {Array} The declarations, with the edges rewritten when the trio is shorter.
325
+ */
326
+ function splitBorderEdgesIntoTrio (declarations) {
327
+ const declaredProperties = collectDeclaredProperties(declarations);
328
+ // A rule that already states a part of the trio, or the whole border, would
329
+ // gain a second declaration of it rather than a shorter spelling of the edges.
330
+ const statesTrioAlready = ['border', ...BORDER_TRIO_PROPERTIES].some((property) => {
331
+ return declaredProperties.has(property);
332
+ });
333
+ if (statesTrioAlready) {
334
+ return declarations;
335
+ }
336
+
337
+ const edgeDeclarations = collectBorderEdgeDeclarations(declarations);
338
+ if (!edgeDeclarations) {
339
+ return declarations;
340
+ }
341
+
342
+ const edgeValues = edgeDeclarations.map((declaration) => {
343
+ return minifyValue(declaration);
344
+ });
345
+ const importantEdges = edgeValues.filter((value) => {
346
+ return value.includes('!important');
347
+ });
348
+ // An edge whose importance differs from its siblings cannot join a shared
349
+ // declaration, which carries one importance for all four sides.
350
+ const hasUniformImportance = importantEdges.length === 0 || importantEdges.length === edgeValues.length;
351
+ if (!hasUniformImportance) {
352
+ return declarations;
353
+ }
354
+ const importantSuffix = importantEdges.length ? '!important' : '';
355
+
356
+ const trioDeclarations = buildBorderTrioDeclarations(edgeDeclarations.map((declaration) => {
357
+ return {
358
+ ...declaration,
359
+ // Match a trailing importance flag, which the trio carries once instead
360
+ value: String(declaration.value).replace(/\s*!\s*important\s*$/i, '')
361
+ };
362
+ }));
363
+ if (!trioDeclarations) {
364
+ return declarations;
365
+ }
366
+ const importantTrioDeclarations = trioDeclarations.map((declaration) => {
367
+ return {
368
+ ...declaration,
369
+ value: declaration.value + importantSuffix
370
+ };
371
+ });
372
+
373
+ const isShorter = measureDeclarations(importantTrioDeclarations) < measureDeclarations(edgeDeclarations);
374
+ if (!isShorter) {
375
+ return declarations;
376
+ }
377
+
378
+ const insertionIndex = declarations.findIndex((declaration) => {
379
+ return BORDER_EDGE_PROPERTIES.includes(declaration.property);
380
+ });
381
+ return declarations.flatMap((declaration, index) => {
382
+ if (index === insertionIndex) {
383
+ return importantTrioDeclarations;
384
+ }
385
+ if (BORDER_EDGE_PROPERTIES.includes(declaration.property)) {
386
+ return [];
387
+ }
388
+ return [declaration];
389
+ });
390
+ }
391
+
392
+ export {
393
+ collapseBorderTrioWithPerEdgeColor,
394
+ splitBorderEdgesIntoTrio
395
+ };
@@ -66,7 +66,7 @@ const shorthandMap = {
66
66
  'place-items': ['align-items', 'justify-items'],
67
67
  'place-content': ['align-content', 'justify-content'],
68
68
  'place-self': ['align-self', 'justify-self'],
69
- columns: ['column-width', 'column-count'],
69
+ columns: ['column-width', 'column-count', 'column-height'],
70
70
  'list-style': ['list-style-position', 'list-style-image', 'list-style-type'],
71
71
  'margin-inline': ['margin-inline-start', 'margin-inline-end'],
72
72
  'margin-block': ['margin-block-start', 'margin-block-end'],
@@ -89,9 +89,10 @@ const shorthandMap = {
89
89
 
90
90
  const shorthandOverrideMap = {
91
91
  animation: ['animation-timeline', 'animation-range', 'animation-range-start', 'animation-range-end'],
92
+ columns: ['column-wrap'],
92
93
  border: ['border-image', 'border-image-source', 'border-image-slice', 'border-image-width', 'border-image-outset', 'border-image-repeat'],
93
94
  font: ['font-variant', 'font-variant-alternates', 'font-variant-caps', 'font-variant-east-asian', 'font-variant-ligatures', 'font-variant-numeric', 'font-variant-position', 'font-feature-settings', 'font-kerning', 'font-language-override', 'font-optical-sizing', 'font-size-adjust', 'font-variation-settings'],
94
- mask: ['mask-border', 'mask-border-source', 'mask-border-slice', 'mask-border-width', 'mask-border-outset', 'mask-border-repeat', 'mask-border-mode']
95
+ mask: ['mask-border', 'mask-border-source', 'mask-border-slice', 'mask-border-width', 'mask-border-outset', 'mask-border-repeat', 'mask-border-mode', 'mask-position', 'mask-origin', 'mask-clip', 'mask-composite', 'mask-mode']
95
96
  };
96
97
 
97
98
  /**
@@ -35,6 +35,7 @@ const PARTIAL_MERGE_REQUIREMENTS = {
35
35
  background: [['background-color', 'background-image']],
36
36
  'background-position': [['background-position-x'], ['background-position-y']],
37
37
  'border-image': [['border-image-source']],
38
+ columns: [['column-width', 'column-count', 'column-height']],
38
39
  font: [['font-size'], ['font-family']],
39
40
  mask: [['mask-image']]
40
41
  };
@@ -6,7 +6,10 @@ import { minifyValue } from '../value/minify.js';
6
6
  import { hasInvalidQuotesCount } from '../value/quotes.js';
7
7
 
8
8
  import { absorbBackgroundLonghandsIntoShorthand } from './background.js';
9
- import { collapseBorderTrioWithPerEdgeColor } from './border.js';
9
+ import {
10
+ collapseBorderTrioWithPerEdgeColor,
11
+ splitBorderEdgesIntoTrio
12
+ } from './border.js';
10
13
  import {
11
14
  getLonghandsOf,
12
15
  shorthandMap
@@ -406,6 +409,7 @@ function processDeclarations (declarations, context) {
406
409
  result = mergeLonghandsIntoShorthands(result, context);
407
410
  result = foldLonghandOverridesIntoShorthands(result, context);
408
411
  result = hoistCssWideKeywordsIntoShorthands(result, context);
412
+ result = splitBorderEdgesIntoTrio(result);
409
413
  result = collapseBorderTrioWithPerEdgeColor(result, context);
410
414
 
411
415
  return orderDeclarations(result);
@@ -193,14 +193,27 @@ function buildTextDecorationValue ({ valueMap, importantSuffix }) {
193
193
  }
194
194
 
195
195
  /**
196
- * Builds a `columns` value, which keeps both components even when they match,
197
- * because the width and count are not interchangeable.
196
+ * Builds a `columns` value from its width, count, and height components. The
197
+ * grammar writes the width and the count next to each other and places the
198
+ * height behind a `/`. Every one of the three starts out as `auto`, so a
199
+ * component that holds `auto` states nothing and is left out, and a value whose
200
+ * components are all `auto` shrinks to a single `auto`.
198
201
  *
199
202
  * @param {ShorthandComponents} components The collected longhand values.
200
203
  * @return {string|null} The shorthand value, or null when it cannot be built.
201
204
  */
202
- function buildColumnsValue ({ cleanValues, importantSuffix }) {
203
- return cleanValues.join(' ') + importantSuffix;
205
+ function buildColumnsValue ({ valueMap, importantSuffix }) {
206
+ const height = valueMap.get('column-height');
207
+ const statedParts = ['column-width', 'column-count'].map((property) => {
208
+ return valueMap.get(property);
209
+ }).filter((value) => {
210
+ return value && value !== 'auto';
211
+ });
212
+ const inlineSize = statedParts.join(' ') || 'auto';
213
+ if (height && height !== 'auto') {
214
+ return inlineSize + '/' + height + importantSuffix;
215
+ }
216
+ return inlineSize + importantSuffix;
204
217
  }
205
218
 
206
219
  /**
@@ -84,10 +84,11 @@ const COLOR_TOKEN_PATTERN = new RegExp(
84
84
  * the shortest equivalent representation, comparing full hex, shortened hex,
85
85
  * and any matching named color keyword.
86
86
  *
87
- * @param {string} segment A CSS value segment (outside strings and urls).
88
- * @return {string} The segment with all colors shortened to their minimal form.
87
+ * @param {string} segment A CSS value segment (outside strings and urls).
88
+ * @param {boolean} rewritesEqualLengthSpelling Whether a spelling of the same length is worth switching to.
89
+ * @return {string} The segment with all colors shortened to their minimal form.
89
90
  */
90
- function shortenColorValues (segment) {
91
+ function shortenColorValues (segment, rewritesEqualLengthSpelling = true) {
91
92
  // Match "color-mix(" as a whole word, case-insensitive
92
93
  const hasColorMix = /\bcolor-mix\(/i.test(segment);
93
94
  return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
@@ -108,19 +109,30 @@ function shortenColorValues (segment) {
108
109
  if (!channels) {
109
110
  return match;
110
111
  }
111
- return shortestColor(channels[0], channels[1], channels[2], channels[3]);
112
+ const shortest = shortestColor(channels[0], channels[1], channels[2], channels[3]);
113
+ if (!rewritesEqualLengthSpelling && shortest.length >= match.length) {
114
+ return match;
115
+ }
116
+ return shortest;
112
117
  });
113
118
  }
114
119
 
115
120
  /**
116
- * Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
121
+ * @typedef {object} ValueSegment
122
+ * @property {string} text The segment's slice of the value.
123
+ * @property {boolean} isLiteral Whether the segment is a quoted string or a url() token.
124
+ */
125
+
126
+ /**
127
+ * Splits a CSS value into literal and syntax segments. A quoted string and a
128
+ * `url()` token are literals: they hold data rather than CSS syntax, so no pass
129
+ * may rewrite what is inside them. Everything between the literals is syntax.
117
130
  *
118
- * @param {string} value The full CSS value string.
119
- * @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
120
- * @return {string} The value with the replacer applied to all eligible segments.
131
+ * @param {string} value The full CSS value string.
132
+ * @return {Array} The value's segments, in order.
121
133
  */
122
- function replaceOutsideStringsAndUrls (value, replacer) {
123
- let result = '';
134
+ function splitValueSegments (value) {
135
+ const segments = [];
124
136
  let index = 0;
125
137
 
126
138
  const consumeQuoted = (start) => {
@@ -144,31 +156,36 @@ function replaceOutsideStringsAndUrls (value, replacer) {
144
156
  return value.slice(start, start + 4).toLowerCase() === 'url(';
145
157
  };
146
158
 
159
+ const consumeUrl = (start) => {
160
+ let depth = 1;
161
+ let end = start + 4;
162
+ while (end < value.length && depth > 0) {
163
+ if (value[end] === '"' || value[end] === '\'') {
164
+ end = consumeQuoted(end);
165
+ continue;
166
+ }
167
+ if (value[end] === '(') {
168
+ depth++;
169
+ }
170
+ if (value[end] === ')') {
171
+ depth--;
172
+ }
173
+ end++;
174
+ }
175
+ return end;
176
+ };
177
+
147
178
  while (index < value.length) {
148
179
  if (value[index] === '"' || value[index] === '\'') {
149
180
  const end = consumeQuoted(index);
150
- result += value.slice(index, end);
181
+ segments.push({ text: value.slice(index, end), isLiteral: true });
151
182
  index = end;
152
183
  continue;
153
184
  }
154
185
 
155
186
  if (startsUrl(index)) {
156
- let depth = 1;
157
- let end = index + 4;
158
- while (end < value.length && depth > 0) {
159
- if (value[end] === '"' || value[end] === '\'') {
160
- end = consumeQuoted(end);
161
- continue;
162
- }
163
- if (value[end] === '(') {
164
- depth++;
165
- }
166
- if (value[end] === ')') {
167
- depth--;
168
- }
169
- end++;
170
- }
171
- result += value.slice(index, end);
187
+ const end = consumeUrl(index);
188
+ segments.push({ text: value.slice(index, end), isLiteral: true });
172
189
  index = end;
173
190
  continue;
174
191
  }
@@ -177,10 +194,40 @@ function replaceOutsideStringsAndUrls (value, replacer) {
177
194
  while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrl(index)) {
178
195
  index++;
179
196
  }
180
- result += replacer(value.slice(start, index));
197
+ segments.push({ text: value.slice(start, index), isLiteral: false });
181
198
  }
182
199
 
183
- return result;
200
+ return segments;
201
+ }
202
+
203
+ /**
204
+ * Reports whether a segment is a quoted string. Every literal segment is either
205
+ * a quoted string or a url() token, so anything else is one of the latter.
206
+ *
207
+ * @param {ValueSegment} [segment] The segment to test, when the value has one there.
208
+ * @return {boolean} Whether the segment is a quoted string.
209
+ */
210
+ function isQuotedStringSegment (segment) {
211
+ if (!segment?.isLiteral) {
212
+ return false;
213
+ }
214
+ return segment.text.startsWith('"') || segment.text.startsWith('\'');
215
+ }
216
+
217
+ /**
218
+ * Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
219
+ *
220
+ * @param {string} value The full CSS value string.
221
+ * @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
222
+ * @return {string} The value with the replacer applied to all eligible segments.
223
+ */
224
+ function replaceOutsideStringsAndUrls (value, replacer) {
225
+ return splitValueSegments(value).map((segment) => {
226
+ if (segment.isLiteral) {
227
+ return segment.text;
228
+ }
229
+ return replacer(segment.text);
230
+ }).join('');
184
231
  }
185
232
 
186
233
  /**
@@ -214,6 +261,108 @@ function elideSpaceBeforeHexColors (value) {
214
261
  });
215
262
  }
216
263
 
264
+ /**
265
+ * Matches the separator whitespace at the head of a value segment. Whitespace
266
+ * that precedes a `+` or a `-` is left alone, because a math function requires
267
+ * those two operators to be surrounded by it.
268
+ *
269
+ * @type {RegExp}
270
+ */
271
+ const LEADING_SEPARATOR_PATTERN = /^\s+(?![+-])/;
272
+
273
+ /**
274
+ * Removes the separator whitespace that follows a closing parenthesis. A
275
+ * parenthesis ends its own token, so the whitespace after one never keeps two
276
+ * tokens from merging: `url(a.png) 30 round` holds the same tokens as
277
+ * `url(a.png)30 round`.
278
+ *
279
+ * @param {string} value The CSS value string.
280
+ * @return {string} The value without the redundant separators.
281
+ */
282
+ function elideSpaceAfterParentheses (value) {
283
+ const segments = splitValueSegments(value);
284
+ return segments.map((segment, index) => {
285
+ if (segment.isLiteral) {
286
+ return segment.text;
287
+ }
288
+ let text = segment.text;
289
+ const previousSegment = segments[index - 1];
290
+ // A url() token ends with the parenthesis that closes it, so whitespace at
291
+ // the head of the segment after one is a separator of the same kind
292
+ if (previousSegment?.isLiteral && !isQuotedStringSegment(previousSegment)) {
293
+ text = text.replace(LEADING_SEPARATOR_PATTERN, '');
294
+ }
295
+ // Match whitespace that follows a closing parenthesis
296
+ return text.replace(/\)\s+(?![+-])/g, ')');
297
+ }).join('');
298
+ }
299
+
300
+ /**
301
+ * Removes the separator whitespace that follows a closing quote. A string ends
302
+ * its own token, so no separator is needed between it and the token that comes
303
+ * next: `"smcp" 1` holds the same tokens as `"smcp"1`.
304
+ *
305
+ * @param {string} value The CSS value string.
306
+ * @return {string} The value without the redundant separators.
307
+ */
308
+ function elideSpaceAfterStrings (value) {
309
+ const segments = splitValueSegments(value);
310
+ return segments.map((segment, index) => {
311
+ if (segment.isLiteral || !isQuotedStringSegment(segments[index - 1])) {
312
+ return segment.text;
313
+ }
314
+ return segment.text.replace(LEADING_SEPARATOR_PATTERN, '');
315
+ }).join('');
316
+ }
317
+
318
+ /**
319
+ * Reports whether a value separates top-level entries with commas, the way the
320
+ * repeatable values of properties such as `font-variation-settings` and
321
+ * `transition` do. Every literal is skipped, so a comma inside a string or a
322
+ * url does not count, and the parenthesis depth tells a function's argument
323
+ * separators apart from the value's own.
324
+ *
325
+ * @param {string} value The CSS value string.
326
+ * @return {boolean} Whether the value holds more than one comma-separated entry.
327
+ */
328
+ function hasCommaSeparatedEntries (value) {
329
+ let depth = 0;
330
+ for (const segment of splitValueSegments(value)) {
331
+ if (segment.isLiteral) {
332
+ continue;
333
+ }
334
+ for (const character of segment.text) {
335
+ if (character === '(') {
336
+ depth++;
337
+ }
338
+ if (character === ')') {
339
+ depth = Math.max(0, depth - 1);
340
+ }
341
+ if (character === ',' && depth === 0) {
342
+ return true;
343
+ }
344
+ }
345
+ }
346
+ return false;
347
+ }
348
+
349
+ /**
350
+ * Restores the whitespace that a math function requires before a `+` or a `-`
351
+ * operator. Both operators have to be surrounded by whitespace to be read as
352
+ * operators rather than as the sign of the term that follows, and the
353
+ * whitespace before one that trails a closing parenthesis is removed together
354
+ * with the rest of the parenthesis padding.
355
+ *
356
+ * @param {string} value The CSS value string with parenthesis padding removed.
357
+ * @return {string} The value with the operator separator restored.
358
+ */
359
+ function restoreSpaceBeforeMathOperators (value) {
360
+ return replaceOutsideStringsAndUrls(value, (segment) => {
361
+ // Match a closing parenthesis immediately followed by a `+` or `-` operator
362
+ return segment.replace(/\)(?=[+-])/g, ') ');
363
+ });
364
+ }
365
+
217
366
  /**
218
367
  * Chooses the shortest valid representation for the path inside a `url(...)`
219
368
  * token, weighing an unquoted form, an escaped single space, and a quoted form.
@@ -800,16 +949,77 @@ const TIME_PROPERTIES = new Set([
800
949
  'animation-delay'
801
950
  ]);
802
951
 
952
+ /**
953
+ * Properties whose grammar delimits its own components, either with punctuation
954
+ * (the `/` and `,` of the `background`, `mask`, and `src` layers) or by taking
955
+ * nothing but functions (`transform`). Their components stay readable without
956
+ * the whitespace that follows a closing parenthesis, so that whitespace is left
957
+ * elided and each of them restores only the separators its grammar still needs.
958
+ *
959
+ * @type {Set<string>}
960
+ */
961
+ const PUNCTUATED_COMPONENT_PROPERTIES = new Set([
962
+ 'background',
963
+ 'mask',
964
+ 'src',
965
+ 'transform'
966
+ ]);
967
+
968
+ /**
969
+ * Matches one offset of a position: an edge keyword or a numeric distance.
970
+ *
971
+ * @type {string}
972
+ */
973
+ const POSITION_OFFSET_SOURCE = '(?:left|center|right|top|bottom|[+-]?(?:\\d+|\\d*\\.\\d+)(?:%|[a-z]+)?)';
974
+
975
+ /**
976
+ * Matches a whole position component, which is one or two of those offsets.
977
+ *
978
+ * @type {string}
979
+ */
980
+ const POSITION_COMPONENT_SOURCE = '(' + POSITION_OFFSET_SOURCE + '(?:\\s+' + POSITION_OFFSET_SOURCE + ')?)';
981
+
982
+ /**
983
+ * Matches a close-paren directly followed by a position that no slash follows,
984
+ * which is the position that needs its separator put back.
985
+ *
986
+ * @type {RegExp}
987
+ */
988
+ const UNSEPARATED_IMAGE_POSITION_PATTERN = new RegExp('\\)' + POSITION_COMPONENT_SOURCE + '(?!\\/)', 'gi');
989
+
990
+ /**
991
+ * Matches a close-paren separated from a position that a slash follows, where
992
+ * the slash already delimits the position from the size behind it.
993
+ *
994
+ * @type {RegExp}
995
+ */
996
+ const SEPARATED_IMAGE_SIZE_POSITION_PATTERN = new RegExp('\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?=\\/)', 'gi');
997
+
998
+ /**
999
+ * Restores the separator between an image function and the position that
1000
+ * follows it. Both `background` and `mask` take a `<position> [ / <size> ]`
1001
+ * component after their image, and a bare position only reads as its own
1002
+ * component while whitespace separates it from the image function. A position
1003
+ * that a `/` follows needs no separator, since the slash delimits the pair.
1004
+ *
1005
+ * @param {string} value The layered image value, with the parenthesis padding removed.
1006
+ * @return {string} The value with the position separator restored.
1007
+ */
1008
+ function restoreImagePositionSeparator (value) {
1009
+ const separated = value.replace(UNSEPARATED_IMAGE_POSITION_PATTERN, ') $1');
1010
+ return separated.replace(SEPARATED_IMAGE_SIZE_POSITION_PATTERN, ')$1');
1011
+ }
1012
+
803
1013
  /**
804
1014
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
805
1015
  * background, display, scale, border-radius, shorthand collapsing, etc.).
806
1016
  *
807
- * @param {string} val The CSS value string after generic minification.
808
- * @param {string} property The CSS property name.
809
- * @param {boolean} allowsHexSpaceElision Whether the space preceding a hex color may be removed.
810
- * @return {string} The value with property-specific optimizations applied.
1017
+ * @param {string} val The CSS value string after generic minification.
1018
+ * @param {string} property The CSS property name.
1019
+ * @param {boolean} allowsSeparatorElision Whether redundant separator whitespace may be removed.
1020
+ * @return {string} The value with property-specific optimizations applied.
811
1021
  */
812
- function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
1022
+ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
813
1023
  if (property === 'font-weight' && isUnicodeCharset()) {
814
1024
  // Replace font-weight keyword "bold" with its numeric equivalent
815
1025
  val = val.replace(/\bbold\b/gi, '700');
@@ -971,16 +1181,19 @@ function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
971
1181
  });
972
1182
 
973
1183
  // Shorten all color tokens (second pass after property-specific color evaluations)
974
- val = replaceOutsideStringsAndUrls(val, shortenColorValues);
1184
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1185
+ return shortenColorValues(segment, allowsSeparatorElision);
1186
+ });
975
1187
 
976
1188
  // Remove space before hex colors (second pass after color evaluations)
977
- if (allowsHexSpaceElision) {
1189
+ if (allowsSeparatorElision) {
978
1190
  val = elideSpaceBeforeHexColors(val);
979
1191
  }
980
- if (property !== 'transform' && property !== 'background' && property !== 'src') {
1192
+ if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
981
1193
  // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
982
1194
  val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
983
1195
  }
1196
+ val = restoreSpaceBeforeMathOperators(val);
984
1197
 
985
1198
  if (property === 'font') {
986
1199
  // Split font shorthand on whitespace
@@ -1014,10 +1227,11 @@ function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
1014
1227
  if (normalized) {
1015
1228
  val = normalized;
1016
1229
  }
1017
- // Restore the required separator between an image function and a following
1018
- // background-position when that position is not immediately followed by `/size`.
1019
- val = val.replace(/\)((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?!\/)/gi, ') $1');
1020
- val = val.replace(/\)\s+((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?=\/)/gi, ')$1');
1230
+ val = restoreImagePositionSeparator(val);
1231
+ }
1232
+
1233
+ if (property === 'mask') {
1234
+ val = restoreImagePositionSeparator(val);
1021
1235
  }
1022
1236
 
1023
1237
  if (property === 'border') {
@@ -1094,7 +1308,7 @@ function computeMinifiedValue (declaration) {
1094
1308
  // Values assembled from already-minified longhands keep the separator spaces
1095
1309
  // between their components, because those spaces delimit the shorthand's
1096
1310
  // parts rather than the authored whitespace of a single written value.
1097
- const allowsHexSpaceElision = !declaration.isAssembledShorthand;
1311
+ const allowsSeparatorElision = !declaration.isAssembledShorthand;
1098
1312
 
1099
1313
  if (typeof val === 'string') {
1100
1314
  val = val.trim();
@@ -1138,21 +1352,26 @@ function computeMinifiedValue (declaration) {
1138
1352
  }
1139
1353
 
1140
1354
  val = lowercaseHexColors(val);
1141
- if (allowsHexSpaceElision) {
1355
+ if (allowsSeparatorElision) {
1142
1356
  val = elideSpaceBeforeHexColors(val);
1143
1357
  }
1144
1358
 
1145
1359
  // Convert color functions to hex equivalents
1146
1360
  val = convertColorsToHex(val);
1147
1361
 
1148
- // Shorten all color tokens (hex and named) to their shortest representation
1149
- val = replaceOutsideStringsAndUrls(val, shortenColorValues);
1362
+ // Shorten all color tokens (hex and named) to their shortest representation.
1363
+ // An assembled shorthand keeps the whitespace between its components, so a
1364
+ // spelling of the same length saves it nothing and its components keep the
1365
+ // spelling they were written with.
1366
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1367
+ return shortenColorValues(segment, allowsSeparatorElision);
1368
+ });
1150
1369
 
1151
1370
  // Collapse light-dark() when both normalized branches are identical
1152
1371
  val = simplifyEquivalentLightDarkFunctions(val);
1153
1372
 
1154
1373
  // Property-specific optimizations
1155
- val = applyPropertyOptimizations(val, declaration.property, allowsHexSpaceElision);
1374
+ val = applyPropertyOptimizations(val, declaration.property, allowsSeparatorElision);
1156
1375
 
1157
1376
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1158
1377
  val = minifyRelativeColorSyntax(val);
@@ -1169,6 +1388,23 @@ function computeMinifiedValue (declaration) {
1169
1388
  val = optimizeUnicodeRange(val);
1170
1389
  }
1171
1390
 
1391
+ // Every earlier pass reads the value with its component separators in place,
1392
+ // so the ones that turned out to be redundant are only dropped at the end.
1393
+ // The properties that punctuate their own components never got them back.
1394
+ const elidesRedundantSeparators = (
1395
+ typeof val === 'string' &&
1396
+ allowsSeparatorElision &&
1397
+ !PUNCTUATED_COMPONENT_PROPERTIES.has(declaration.property)
1398
+ );
1399
+ if (elidesRedundantSeparators) {
1400
+ val = elideSpaceAfterParentheses(val);
1401
+ // A value written as one entry is elided down to its tokens, while a
1402
+ // comma-separated list keeps the whitespace that groups each of its entries.
1403
+ if (!hasCommaSeparatedEntries(val)) {
1404
+ val = elideSpaceAfterStrings(val);
1405
+ }
1406
+ }
1407
+
1172
1408
  return val;
1173
1409
  }
1174
1410
 
@@ -143,6 +143,31 @@ function parseAngleToDegrees (angleToken) {
143
143
  return parseFloat(match[1]) * ANGLE_UNIT_TO_DEGREES[unit];
144
144
  }
145
145
 
146
+ /**
147
+ * The substitution functions whose result is only known at computed-value time.
148
+ * Each of them may stand in for any number of components, so a shorthand part
149
+ * that holds one cannot be counted as the single value it looks like.
150
+ *
151
+ * @type {RegExp}
152
+ */
153
+ const SUBSTITUTION_FUNCTION_PATTERN = /\b(?:var|env|attr|if)\(/i;
154
+
155
+ /**
156
+ * Reports whether a shorthand part stands in for an unknown number of
157
+ * components. `margin: 1px 1px` reduces to `margin: 1px`, but the same
158
+ * reduction across two `var()` references is unsafe: a custom property that
159
+ * expands to two values makes the doubled form a valid four-component margin,
160
+ * while the reduced form is a two-component one.
161
+ *
162
+ * @param {Array} parts The shorthand value strings to test.
163
+ * @return {boolean} Whether any part substitutes a value of unknown length.
164
+ */
165
+ function hasSubstitutedParts (parts) {
166
+ return parts.some((part) => {
167
+ return SUBSTITUTION_FUNCTION_PATTERN.test(part);
168
+ });
169
+ }
170
+
146
171
  /**
147
172
  * Collapses redundant CSS shorthand parts using the standard box-model
148
173
  * reduction rules: 4-value → 3-value → 2-value → 1-value.
@@ -153,6 +178,9 @@ function parseAngleToDegrees (angleToken) {
153
178
  * @return {Array} The same array, mutated with redundant entries removed.
154
179
  */
155
180
  function collapseShorthandParts (parts) {
181
+ if (hasSubstitutedParts(parts)) {
182
+ return parts;
183
+ }
156
184
  if (parts.length === 4 && parts[1] === parts[3]) {
157
185
  parts.splice(3, 1);
158
186
  }