@thejaredwilcurt/csslop 0.0.36 → 0.0.38

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,8 +285,9 @@ 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:
@@ -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.36",
5
+ "version": "0.0.38",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -14,6 +14,7 @@
14
14
  "copy": "node ./scripts/copyTests.js",
15
15
  "test": "node ./tests/index.test.js",
16
16
  "real": "node ./tests/realworld.test.js",
17
+ "idem": "node ./tests/idempotency.test.js",
17
18
  "lint": "eslint *.js scripts src tests --fix",
18
19
  "fix": "npm run lint",
19
20
  "bump": "npx --yes -- @jsdevtools/version-bump-prompt patch && npm i",
@@ -21,7 +22,7 @@
21
22
  "proto": "proto pin node latest --resolve --tool-native && proto pin npm latest --resolve --tool-native && git status"
22
23
  },
23
24
  "dependencies": {
24
- "@csstools/css-calc": "^3.3.0",
25
+ "@csstools/css-calc": "^3.4.0",
25
26
  "@node-projects/css-parser": "^5.2.0"
26
27
  },
27
28
  "devDependencies": {
@@ -36,12 +37,12 @@
36
37
  "eslint-config-tjw-import-x": "^1.0.1",
37
38
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
39
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^64.3.6",
40
+ "eslint-plugin-jsdoc": "^64.3.10",
40
41
  "fflate": "^0.8.3",
41
42
  "globals": "^17.12.0",
42
43
  "pretty-ms": "^9.3.1",
43
44
  "real-world-css-libraries": "^1.0.8",
44
- "vite": "^8.2.2"
45
+ "vite": "^8.3.0"
45
46
  },
46
47
  "author": "The Jared Wilcurt",
47
48
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -58,7 +59,7 @@
58
59
  "devEngines": {
59
60
  "runtime": {
60
61
  "name": "node",
61
- "version": "26.8.1"
62
+ "version": "26.8.2"
62
63
  },
63
64
  "packageManager": {
64
65
  "name": "npm",
@@ -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);
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  preprocessDeclarationBlocks,
26
26
  restoreEscapeSequences
27
27
  } from './preprocess.js';
28
+ import { combineLanguageSelectors } from './rules/lang.js';
28
29
  import {
29
30
  deduplicateKeyframes,
30
31
  expandPureNestedRules,
@@ -83,7 +84,8 @@ function splitSelectorList (selectorList) {
83
84
 
84
85
  /**
85
86
  * Deduplicates a minified CSS selector list string by splitting at top-level
86
- * commas, removing duplicate selectors, and rejoining with commas.
87
+ * commas, removing duplicate selectors, combining the selectors that differ
88
+ * only in language-code, and rejoining with commas.
87
89
  *
88
90
  * @param {string} selectorList The comma-separated selector list string.
89
91
  * @return {string} The deduplicated selector list string.
@@ -98,7 +100,7 @@ function deduplicateSelectorList (selectorList) {
98
100
  seen.add(selector);
99
101
  return true;
100
102
  });
101
- return unique.join(',');
103
+ return combineLanguageSelectors(unique).join(',');
102
104
  }
103
105
 
104
106
  /**
@@ -0,0 +1,366 @@
1
+ /**
2
+ * @file Minifies the `:lang()` pseudo-class, by writing each language-code in
3
+ * its shortest valid form and by combining selectors that differ only in the
4
+ * language-code they ask for.
5
+ */
6
+
7
+ import {
8
+ findMatchingCloseParenthesis,
9
+ findNextFunctionCallOutsideStrings
10
+ } from './selectors.js';
11
+
12
+ /**
13
+ * The `:lang()` pseudo-class opening, as it is written in minified output.
14
+ * Pseudo-class names are case-insensitive, so the scan that looks for this
15
+ * token ignores case, while everything written back out uses this spelling.
16
+ *
17
+ * @type {string}
18
+ */
19
+ const LANGUAGE_FUNCTION = ':lang(';
20
+
21
+ /**
22
+ * Matches a backslash escape of a character that cannot be read as the start
23
+ * of a hexadecimal code point escape, and captures the character it escapes.
24
+ *
25
+ * @type {RegExp}
26
+ */
27
+ const SINGLE_CHARACTER_ESCAPE = /\\([^0-9a-fA-F])/g;
28
+
29
+ /**
30
+ * Matches a backslash followed by a hexadecimal digit, which starts a code
31
+ * point escape whose meaning cannot be judged without decoding it.
32
+ *
33
+ * @type {RegExp}
34
+ */
35
+ const CODE_POINT_ESCAPE = /\\[0-9a-fA-F]/;
36
+
37
+ /**
38
+ * Matches a language-code holding a character that would end the code, or the
39
+ * selector, if the code were written without quotes or escapes.
40
+ *
41
+ * @type {RegExp}
42
+ */
43
+ const UNWRITABLE_CHARACTER = /[\s"'()\\]/;
44
+
45
+ /**
46
+ * Matches a language-code whose first characters read as the beginning of a
47
+ * number, which no unquoted identifier may do.
48
+ *
49
+ * @type {RegExp}
50
+ */
51
+ const NUMERIC_START = /^-?[0-9]/;
52
+
53
+ /**
54
+ * Matches a single character an identifier may hold as written.
55
+ *
56
+ * @type {RegExp}
57
+ */
58
+ const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
59
+
60
+ /**
61
+ * The first code point that is an identifier character in its own right, so
62
+ * that letters beyond ASCII never need escaping.
63
+ *
64
+ * @type {number}
65
+ */
66
+ const FIRST_NON_ASCII_CODE_POINT = 0x80;
67
+
68
+ /**
69
+ * Reports whether a character has to be escaped to survive in an unquoted
70
+ * language-code.
71
+ *
72
+ * @param {string} character A single character of a language-code.
73
+ * @return {boolean} True when the character needs a backslash in front of it.
74
+ */
75
+ function requiresEscape (character) {
76
+ return (
77
+ character.codePointAt(0) < FIRST_NON_ASCII_CODE_POINT &&
78
+ !IDENTIFIER_CHARACTER.test(character)
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Splits the argument list of a `:lang()` into its individual language-codes,
84
+ * treating only the commas outside of a quoted code as separators.
85
+ *
86
+ * @param {string} argumentList The text between the parentheses of a `:lang()`.
87
+ * @return {Array} The language-codes, still in the form they were written in.
88
+ */
89
+ function splitLanguageCodes (argumentList) {
90
+ const codes = [];
91
+ let current = '';
92
+ let openQuote = '';
93
+ for (let index = 0; index < argumentList.length; index++) {
94
+ const character = argumentList[index];
95
+ const nextCharacter = argumentList[index + 1] ?? '';
96
+ if (character === '\\') {
97
+ // An escape carries the character behind it along, whatever it may be
98
+ current += character + nextCharacter;
99
+ index++;
100
+ continue;
101
+ }
102
+ if (openQuote) {
103
+ current += character;
104
+ if (character === openQuote) {
105
+ openQuote = '';
106
+ }
107
+ continue;
108
+ }
109
+ if (character === '"' || character === '\'') {
110
+ openQuote = character;
111
+ current += character;
112
+ continue;
113
+ }
114
+ if (character === ',') {
115
+ codes.push(current);
116
+ current = '';
117
+ continue;
118
+ }
119
+ current += character;
120
+ }
121
+ codes.push(current);
122
+ return codes;
123
+ }
124
+
125
+ /**
126
+ * Reads the plain language-code out of one `:lang()` argument, stripping the
127
+ * quotes or escapes that were only there to let it be written. Arguments this
128
+ * minifier cannot read with certainty, such as one holding a code point
129
+ * escape, report no code so they are left exactly as their author wrote them.
130
+ *
131
+ * @param {string} argument One argument of a `:lang()`, as written.
132
+ * @return {string|null} The language-code itself, or null when it cannot be read.
133
+ */
134
+ function readLanguageCode (argument) {
135
+ const text = argument.trim();
136
+ if (!text || CODE_POINT_ESCAPE.test(text)) {
137
+ return null;
138
+ }
139
+ const firstCharacter = text[0];
140
+ const isQuoted = (
141
+ (firstCharacter === '"' || firstCharacter === '\'') &&
142
+ text.length > 1 &&
143
+ text.endsWith(firstCharacter)
144
+ );
145
+ let code = text;
146
+ if (isQuoted) {
147
+ code = text.slice(1, -1);
148
+ }
149
+ code = code.replace(SINGLE_CHARACTER_ESCAPE, '$1');
150
+ if (!code || UNWRITABLE_CHARACTER.test(code)) {
151
+ return null;
152
+ }
153
+ return code;
154
+ }
155
+
156
+ /**
157
+ * Writes a language-code in the shortest form a browser still reads as that
158
+ * same code. A code of plain identifier characters needs nothing around it; a
159
+ * code holding others is either escaped character by character or wrapped in
160
+ * quotes once, whichever costs fewer characters.
161
+ *
162
+ * @param {string} code The language-code itself.
163
+ * @return {string} The shortest way to write the code inside a `:lang()`.
164
+ */
165
+ function formatLanguageCode (code) {
166
+ const quoted = '"' + code + '"';
167
+ // A leading digit can only be escaped as a code point, which is never short
168
+ if (NUMERIC_START.test(code)) {
169
+ return quoted;
170
+ }
171
+ const escaped = [...code].map((character) => {
172
+ if (requiresEscape(character)) {
173
+ return '\\' + character;
174
+ }
175
+ return character;
176
+ }).join('');
177
+ if (escaped.length <= quoted.length) {
178
+ return escaped;
179
+ }
180
+ return quoted;
181
+ }
182
+
183
+ /**
184
+ * Rewrites the argument list of one `:lang()`, lowercasing every code because
185
+ * language-codes are matched case-insensitively (one casing throughout a
186
+ * stylesheet compresses better), dropping repeated codes, and writing each
187
+ * remaining code in its shortest form.
188
+ *
189
+ * @param {string} argumentList The text between the parentheses of a `:lang()`.
190
+ * @return {string|null} The minified argument list, or null when an argument could not be read.
191
+ */
192
+ function minifyLanguageArguments (argumentList) {
193
+ const codes = [];
194
+ for (const argument of splitLanguageCodes(argumentList)) {
195
+ const code = readLanguageCode(argument);
196
+ if (code === null) {
197
+ return null;
198
+ }
199
+ codes.push(code.toLowerCase());
200
+ }
201
+ return [...new Set(codes)].map(formatLanguageCode).join(',');
202
+ }
203
+
204
+ /**
205
+ * Locates every `:lang()` of a selector, reporting where each one starts,
206
+ * where its argument list starts, and where it ends.
207
+ *
208
+ * @param {string} selector A minified CSS selector string.
209
+ * @return {Array} One entry per `:lang()`, holding its `startIndex`, `openIndex`, and `closeIndex`.
210
+ */
211
+ function findLanguageFunctions (selector) {
212
+ const languageFunctions = [];
213
+ let position = 0;
214
+ while (position < selector.length) {
215
+ const startIndex = findNextFunctionCallOutsideStrings(selector, LANGUAGE_FUNCTION, position);
216
+ if (startIndex === -1) {
217
+ break;
218
+ }
219
+ const openIndex = startIndex + LANGUAGE_FUNCTION.length - 1;
220
+ const closeIndex = findMatchingCloseParenthesis(selector, openIndex);
221
+ if (closeIndex === -1) {
222
+ break;
223
+ }
224
+ languageFunctions.push({
225
+ closeIndex,
226
+ openIndex,
227
+ startIndex
228
+ });
229
+ position = closeIndex + 1;
230
+ }
231
+ return languageFunctions;
232
+ }
233
+
234
+ /**
235
+ * Minifies every `:lang()` a selector holds.
236
+ *
237
+ * @param {string} selector A minified CSS selector string.
238
+ * @return {string} The selector with each `:lang()` written in its shortest form.
239
+ */
240
+ function minifyLanguageSelector (selector) {
241
+ const languageFunctions = findLanguageFunctions(selector);
242
+ if (!languageFunctions.length) {
243
+ return selector;
244
+ }
245
+ let result = '';
246
+ let index = 0;
247
+ for (const languageFunction of languageFunctions) {
248
+ const {
249
+ closeIndex,
250
+ openIndex,
251
+ startIndex
252
+ } = languageFunction;
253
+ const minifiedArguments = minifyLanguageArguments(selector.slice(openIndex + 1, closeIndex));
254
+ result += selector.slice(index, startIndex);
255
+ if (minifiedArguments === null) {
256
+ result += selector.slice(startIndex, closeIndex + 1);
257
+ } else {
258
+ result += LANGUAGE_FUNCTION + minifiedArguments + ')';
259
+ }
260
+ index = closeIndex + 1;
261
+ }
262
+ return result + selector.slice(index);
263
+ }
264
+
265
+ /**
266
+ * Reports whether a position in a selector sits inside a functional
267
+ * pseudo-class, such as the `:lang()` of `:not(:lang(en))`.
268
+ *
269
+ * @param {string} selector A minified CSS selector string.
270
+ * @param {number} index The position to judge.
271
+ * @return {boolean} True when an enclosing parenthesis is still open at that position.
272
+ */
273
+ function isInsideFunction (selector, index) {
274
+ let depth = 0;
275
+ for (const character of selector.slice(0, index)) {
276
+ if (character === '(') {
277
+ depth++;
278
+ } else if (character === ')') {
279
+ depth--;
280
+ }
281
+ }
282
+ return depth > 0;
283
+ }
284
+
285
+ /**
286
+ * Splits a selector into the text around its one and only `:lang()` and the
287
+ * language-codes that `:lang()` asks for. A selector holding no `:lang()`, or
288
+ * more than one, has no single list of codes to vary, and a `:lang()` nested
289
+ * in another functional pseudo-class cannot have codes added to it without
290
+ * changing what the pseudo-class it sits in means.
291
+ *
292
+ * @param {string} selector A minified CSS selector string.
293
+ * @return {object|null} The `prefix`, `codes`, and `suffix` of the selector, or null when it has no lone top-level `:lang()`.
294
+ */
295
+ function splitAroundLanguageCodes (selector) {
296
+ const languageFunctions = findLanguageFunctions(selector);
297
+ if (languageFunctions.length !== 1) {
298
+ return null;
299
+ }
300
+ const {
301
+ closeIndex,
302
+ openIndex,
303
+ startIndex
304
+ } = languageFunctions[0];
305
+ if (isInsideFunction(selector, startIndex)) {
306
+ return null;
307
+ }
308
+ const codes = splitLanguageCodes(selector.slice(openIndex + 1, closeIndex)).map((code) => {
309
+ return code.trim();
310
+ });
311
+ if (codes.some((code) => {
312
+ return !code;
313
+ })) {
314
+ return null;
315
+ }
316
+ return {
317
+ codes,
318
+ prefix: selector.slice(0, startIndex),
319
+ suffix: selector.slice(closeIndex + 1)
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Combines the selectors of one rule that are identical but for the
325
+ * language-codes of a single `:lang()`. A `:lang()` takes a comma-separated
326
+ * list of codes and matches an element written in any of them, so the several
327
+ * selectors say as one what they used to say apart, at the same specificity.
328
+ *
329
+ * @param {Array} selectors The rule's minified selector strings.
330
+ * @return {Array} The selector list with language-only variants combined.
331
+ */
332
+ function combineLanguageSelectors (selectors) {
333
+ const groupsBySurroundings = new Map();
334
+ const result = [];
335
+ for (const selector of selectors) {
336
+ const parts = splitAroundLanguageCodes(selector);
337
+ if (!parts) {
338
+ result.push(selector);
339
+ continue;
340
+ }
341
+ // The prefix and suffix are joined by a character no selector may hold, so
342
+ // that text moving from one side to the other cannot look like a match
343
+ const surroundings = parts.prefix + '\u0000' + parts.suffix;
344
+ const existingGroup = groupsBySurroundings.get(surroundings);
345
+ if (existingGroup) {
346
+ existingGroup.codes.push(...parts.codes);
347
+ continue;
348
+ }
349
+ groupsBySurroundings.set(surroundings, {
350
+ codes: [...parts.codes],
351
+ parts,
352
+ position: result.length
353
+ });
354
+ result.push(selector);
355
+ }
356
+ for (const group of groupsBySurroundings.values()) {
357
+ const codes = [...new Set(group.codes)];
358
+ result[group.position] = group.parts.prefix + LANGUAGE_FUNCTION + codes.join(',') + ')' + group.parts.suffix;
359
+ }
360
+ return result;
361
+ }
362
+
363
+ export {
364
+ combineLanguageSelectors,
365
+ minifyLanguageSelector
366
+ };
@@ -13,13 +13,82 @@ import {
13
13
  normalizeMedia
14
14
  } from './normalize.js';
15
15
 
16
+ /**
17
+ * Reports whether an entry of a rule's body is itself a nested rule rather
18
+ * than a plain declaration.
19
+ *
20
+ * @param {object} entry An entry of a rule's declaration list.
21
+ * @return {boolean} True when the entry is a nested rule.
22
+ */
23
+ function isNestedRule (entry) {
24
+ return entry.type === 'rule';
25
+ }
26
+
27
+ /**
28
+ * Expands the rules nested inside each rule's body, so a chain of wrappers
29
+ * collapses from the inside out and the outermost wrapper is then judged
30
+ * against an already flattened child. A rule's body holds the same kinds of
31
+ * entries a stylesheet does, so the body is expanded by the same rules, and
32
+ * the plain declarations it also holds pass through untouched.
33
+ *
34
+ * @param {Array} rules The AST rule nodes whose bodies may hold nested rules.
35
+ * @return {Array} The rules, each with its body expanded.
36
+ */
37
+ function expandNestedRuleBodies (rules) {
38
+ return rules.map((rule) => {
39
+ if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
40
+ return rule;
41
+ }
42
+ return {
43
+ ...rule,
44
+ declarations: expandPureNestedRules(rule.declarations)
45
+ };
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Reports whether a nested child selector still means the same thing once it
51
+ * is written out in full under its parent.
52
+ *
53
+ * @param {string} childSelector The selector of a rule nested inside another.
54
+ * @return {boolean} True when the child can be written as a flat selector.
55
+ */
56
+ function canFlattenChildSelector (childSelector) {
57
+ const child = childSelector.trim();
58
+ if (!child) {
59
+ return false;
60
+ }
61
+ // A `&` anywhere but the very start puts the parent where flattening cannot:
62
+ // `.b &` means the parent follows the child rather than leading it
63
+ return !child.slice(1).includes('&');
64
+ }
65
+
66
+ /**
67
+ * Writes a nested child selector out in full, as the selector it stands for
68
+ * when read through its parent.
69
+ *
70
+ * @param {string} parentSelector The selector of the rule the child is nested in.
71
+ * @param {string} childSelector The selector of the nested child rule.
72
+ * @return {string} The flat selector the nested pair stands for.
73
+ */
74
+ function combineNestedSelector (parentSelector, childSelector) {
75
+ const parent = parentSelector.trim();
76
+ const child = childSelector.trim();
77
+ // A leading `&` names the parent itself, so it is what the parent replaces
78
+ if (child.startsWith('&')) {
79
+ return parent + child.slice(1);
80
+ }
81
+ return parent + ' ' + child;
82
+ }
83
+
16
84
  /**
17
85
  * Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
18
86
  *
19
- * @param {Array} rules The AST rule nodes to process.
20
- * @return {Array} A new array of rules with pure-nested rules expanded.
87
+ * @param {Array} inputRules The AST rule nodes to process.
88
+ * @return {Array} A new array of rules with pure-nested rules expanded.
21
89
  */
22
- function expandPureNestedRules (rules) {
90
+ function expandPureNestedRules (inputRules) {
91
+ const rules = expandNestedRuleBodies(inputRules);
23
92
  const flatSelectors = new Set();
24
93
  for (const rule of rules) {
25
94
  if (rule.type !== 'rule' || !rule.selectors?.length) {
@@ -77,16 +146,14 @@ function expandPureNestedRules (rules) {
77
146
  canExpand = false;
78
147
  break;
79
148
  }
149
+ if (!nestedRule.selectors.every(canFlattenChildSelector)) {
150
+ canExpand = false;
151
+ break;
152
+ }
80
153
  const combinedSelectors = [];
81
154
  for (const parentSelector of rule.selectors) {
82
155
  for (const childSelector of nestedRule.selectors) {
83
- const trimmedChild = childSelector.trim();
84
- let combinedSelector;
85
- if (trimmedChild.startsWith('&')) {
86
- combinedSelector = trimmedChild.replace(/^&/, parentSelector.trim());
87
- } else {
88
- combinedSelector = parentSelector.trim() + ' ' + trimmedChild;
89
- }
156
+ const combinedSelector = combineNestedSelector(parentSelector, childSelector);
90
157
  combinedSelectors.push(combinedSelector);
91
158
  if (flatSelectors.has(combinedSelector)) {
92
159
  anyMatch = true;
@@ -118,10 +118,11 @@ function findMatchingCloseParenthesis (text, openIndex) {
118
118
  /**
119
119
  * Finds the next occurrence of a pseudo-class function token (e.g. `:is(`)
120
120
  * that sits at the top level of a selector, outside any quoted string or
121
- * attribute selector.
121
+ * attribute selector. Pseudo-class names are case-insensitive, so the token is
122
+ * matched without regard to case and must be given in lowercase.
122
123
  *
123
124
  * @param {string} text The selector string to scan.
124
- * @param {string} functionCall The function token to find, including its opening parenthesis.
125
+ * @param {string} functionCall The lowercase function token to find, including its opening parenthesis.
125
126
  * @param {number} start The index to start scanning from.
126
127
  * @return {number} The index of the next top-level occurrence, or -1 if none remains.
127
128
  */
@@ -141,7 +142,13 @@ function findNextFunctionCallOutsideStrings (text, functionCall, start) {
141
142
  index += 2;
142
143
  continue;
143
144
  }
144
- if (text.startsWith(functionCall, index)) {
145
+ // The token's first character is compared before its whole length is read,
146
+ // so only the few positions that could start it are lowercased
147
+ const startsToken = (
148
+ character === functionCall[0] &&
149
+ text.slice(index, index + functionCall.length).toLowerCase() === functionCall
150
+ );
151
+ if (startsToken) {
145
152
  return index;
146
153
  }
147
154
  index++;
@@ -876,6 +883,8 @@ function flattenNestingParentIsSelector (selector) {
876
883
  return parts;
877
884
  }
878
885
  export {
886
+ findMatchingCloseParenthesis,
887
+ findNextFunctionCallOutsideStrings,
879
888
  flattenNestingParentIsSelector,
880
889
  mergeAdjacentWherePseudoClasses,
881
890
  processIsSelector,
@@ -3,12 +3,16 @@
3
3
  */
4
4
 
5
5
  import { processDeclarations } from '../declarations/process.js';
6
- import { minifyValue } from '../value/minify.js';
6
+ import { minifyValueForOutput } from '../value/minify.js';
7
7
 
8
8
  import {
9
9
  collapseCustomPropertyWhitespace,
10
10
  processCustomPropertyComments
11
11
  } from './custom-properties.js';
12
+ import {
13
+ combineLanguageSelectors,
14
+ minifyLanguageSelector
15
+ } from './lang.js';
12
16
  import {
13
17
  canUnwrapSupports,
14
18
  normalizeLayerNames,
@@ -37,7 +41,7 @@ function stringifyDeclarations (declarations) {
37
41
  return declaration.type !== 'whitespace' && declaration.type !== 'comment' && declaration.property;
38
42
  })
39
43
  .map((declaration) => {
40
- return [declaration.property, ':', minifyValue(declaration)].join('');
44
+ return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
41
45
  })
42
46
  .join(';');
43
47
  }
@@ -251,6 +255,7 @@ function stringifyRule (rule, context) {
251
255
  // Remove redundant leading "& " nesting selector
252
256
  minified = minified.replace(/^& /, '');
253
257
  minified = mergeAdjacentWherePseudoClasses(minified);
258
+ minified = minifyLanguageSelector(minified);
254
259
  return minified;
255
260
  });
256
261
  // When this rule is a nesting parent, its whole selector list is treated
@@ -267,6 +272,7 @@ function stringifyRule (rule, context) {
267
272
  return processIsSelector(selector, hasSiblingSelectors);
268
273
  });
269
274
  uniqueSelectors = [...new Set(uniqueSelectors)];
275
+ uniqueSelectors = combineLanguageSelectors(uniqueSelectors);
270
276
  output.push(uniqueSelectors.join(','));
271
277
  }
272
278
  output.push('{');
@@ -295,7 +301,7 @@ function stringifyRule (rule, context) {
295
301
  if (property.startsWith('--')) {
296
302
  const syntax = context.registeredCustomPropertySyntax.get(property);
297
303
  if (syntax === '"<color>"') {
298
- value = minifyValue(declaration);
304
+ value = minifyValueForOutput(declaration);
299
305
  } else {
300
306
  const rawValue = declaration.rawValue || declaration.value || '';
301
307
  const commentProcessedValue = processCustomPropertyComments(rawValue);
@@ -323,7 +329,7 @@ function stringifyRule (rule, context) {
323
329
  }
324
330
  }
325
331
  } else {
326
- value = minifyValue(declaration);
332
+ value = minifyValueForOutput(declaration);
327
333
  }
328
334
  return [property, ':', value].join('');
329
335
  })
@@ -362,7 +368,7 @@ function stringifyRule (rule, context) {
362
368
  return item.type !== 'declaration';
363
369
  });
364
370
  const renderedDeclarations = mediaDeclarations.map((declaration) => {
365
- return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
371
+ return [unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('');
366
372
  }).join(';');
367
373
  const renderedRules = stringifyChildRules(subRules, context);
368
374
  const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
@@ -440,7 +446,7 @@ function stringifyRule (rule, context) {
440
446
  return declaration.type !== 'whitespace' && declaration.type !== 'comment';
441
447
  })
442
448
  ?.map((declaration) => {
443
- return [declaration.property, ':', minifyValue(declaration)].join('');
449
+ return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
444
450
  })
445
451
  .join(';') || '';
446
452
  output.push(renderedKeyframeDeclarations);
@@ -558,7 +564,7 @@ function stringifyRule (rule, context) {
558
564
  return innerDeclaration.type !== 'whitespace' && innerDeclaration.property;
559
565
  })
560
566
  .map((innerDeclaration) => {
561
- return [unescapeIdent(innerDeclaration.property), ':', minifyValue(innerDeclaration)].join('');
567
+ return [unescapeIdent(innerDeclaration.property), ':', minifyValueForOutput(innerDeclaration)].join('');
562
568
  })
563
569
  .join(';');
564
570
  if (innerDeclarations) {
@@ -567,7 +573,7 @@ function stringifyRule (rule, context) {
567
573
  return [];
568
574
  }
569
575
  if (declaration.property) {
570
- return [[unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('')];
576
+ return [[unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('')];
571
577
  }
572
578
  return [];
573
579
  });
package/src/value/math.js CHANGED
@@ -10,6 +10,261 @@ 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
+
86
+ /**
87
+ * The functions whose arguments are a math expression, where `*` and `/` are
88
+ * arithmetic operators rather than characters of some other syntax.
89
+ *
90
+ * @type {Set<string>}
91
+ */
92
+ const MATH_FUNCTION_NAMES = new Set([
93
+ 'abs',
94
+ 'calc',
95
+ 'clamp',
96
+ 'hypot',
97
+ 'max',
98
+ 'min',
99
+ 'mod',
100
+ 'rem',
101
+ 'round',
102
+ 'sign'
103
+ ]);
104
+
105
+ /**
106
+ * Matches a single character that may appear in a CSS function name.
107
+ *
108
+ * @type {RegExp}
109
+ */
110
+ const FUNCTION_NAME_CHARACTER = /[a-zA-Z-]/;
111
+
112
+ /**
113
+ * Finds where the function name that ends at an opening parenthesis begins.
114
+ *
115
+ * @param {string} value The CSS value string being scanned.
116
+ * @param {number} openIndex The index of the opening parenthesis.
117
+ * @return {number} The index of the function name's first character.
118
+ */
119
+ function findFunctionNameStart (value, openIndex) {
120
+ let index = openIndex;
121
+ while (index > 0 && FUNCTION_NAME_CHARACTER.test(value[index - 1])) {
122
+ index--;
123
+ }
124
+ return index;
125
+ }
126
+
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
+ /**
150
+ * Rewrites the contents of every math function in a value, leaving the rest of
151
+ * the value untouched. A `*` only means multiplication inside these functions,
152
+ * so an arithmetic rewrite may only reach the text between their parentheses.
153
+ * The whole of a math function is handed over at once, so a math function
154
+ * nested in another is rewritten as part of its parent.
155
+ *
156
+ * @param {function(string): string} rewriteExpression Receives a math function's contents and returns its replacement.
157
+ * @param {string} value The CSS value string to rewrite.
158
+ * @return {string} The value with every math function's contents rewritten.
159
+ */
160
+ function rewriteMathFunctionContents (rewriteExpression, value) {
161
+ let result = '';
162
+ let index = 0;
163
+ while (index < value.length) {
164
+ const openIndex = value.indexOf('(', index);
165
+ if (openIndex === -1) {
166
+ break;
167
+ }
168
+ const functionName = value.slice(findFunctionNameStart(value, openIndex), openIndex).toLowerCase();
169
+ const closeIndex = findClosingParenthesis(value, openIndex);
170
+ if (!MATH_FUNCTION_NAMES.has(functionName) || closeIndex === -1) {
171
+ result += value.slice(index, openIndex + 1);
172
+ index = openIndex + 1;
173
+ continue;
174
+ }
175
+ result += value.slice(index, openIndex + 1);
176
+ result += rewriteExpression(value.slice(openIndex + 1, closeIndex));
177
+ index = closeIndex;
178
+ }
179
+ return result + value.slice(index);
180
+ }
181
+
182
+ /**
183
+ * Matches a multiplication by a decimal below one, capturing the digits after
184
+ * its decimal point and the unit written on it. The trailing lookahead keeps
185
+ * the match away from numbers that only begin this way, such as the exponent
186
+ * of `.5e3` or a unit name that is still being read.
187
+ *
188
+ * @type {RegExp}
189
+ */
190
+ const FRACTIONAL_MULTIPLIER = /\s*\*\s*0*\.(\d+)([a-z%]*)(?![\w.%(])/gi;
191
+
192
+ /**
193
+ * The most decimal places a fraction may be built from while the power of ten
194
+ * it needs is still exactly representable as an integer.
195
+ *
196
+ * @type {number}
197
+ */
198
+ const MAXIMUM_FRACTION_DIGITS = 15;
199
+
200
+ /**
201
+ * Computes the greatest common divisor of two non-negative integers, which is
202
+ * what a fraction has to be divided by to be stated in lowest terms.
203
+ *
204
+ * @param {number} first The first integer.
205
+ * @param {number} second The second integer.
206
+ * @return {number} The greatest common divisor of both integers.
207
+ */
208
+ function greatestCommonDivisor (first, second) {
209
+ let dividend = first;
210
+ let divisor = second;
211
+ while (divisor) {
212
+ const remainder = dividend % divisor;
213
+ dividend = divisor;
214
+ divisor = remainder;
215
+ }
216
+ return dividend;
217
+ }
218
+
219
+ /**
220
+ * Converts the digits behind a decimal point into the whole number that
221
+ * dividing by scales a value exactly as multiplying by that decimal does.
222
+ * The digits `25` (of `.25`) state the fraction 25/100, which reduces to 1/4,
223
+ * so the divisor is `4`. A fraction that does not reduce to a numerator of one
224
+ * cannot be stated as a single division, and so has no divisor to give.
225
+ *
226
+ * @param {string} fractionDigits The digits written after the decimal point.
227
+ * @return {number|null} The equivalent whole number divisor, or null when there is none.
228
+ */
229
+ function reciprocalOfDecimalFraction (fractionDigits) {
230
+ if (fractionDigits.length > MAXIMUM_FRACTION_DIGITS) {
231
+ return null;
232
+ }
233
+ const numerator = Number(fractionDigits);
234
+ const denominator = 10 ** fractionDigits.length;
235
+ const commonFactor = greatestCommonDivisor(numerator, denominator);
236
+ if (numerator / commonFactor !== 1) {
237
+ return null;
238
+ }
239
+ return denominator / commonFactor;
240
+ }
241
+
242
+ /**
243
+ * Rewrites a multiplication by a decimal below one as a division by the whole
244
+ * number that decimal is the reciprocal of, which is never longer to write and
245
+ * usually shorter (`calc(var(--x)*.25)` → `calc(var(--x)/4)`). A unit written
246
+ * on the multiplier moves onto the divisor, since only how the operand is
247
+ * stated changes.
248
+ *
249
+ * @param {string} expression The contents of a math function.
250
+ * @return {string} The expression with reciprocal multipliers stated as divisions.
251
+ */
252
+ function convertReciprocalMultiplicationToDivision (expression) {
253
+ return expression.replace(FRACTIONAL_MULTIPLIER, (match, fractionDigits, unit) => {
254
+ const divisor = reciprocalOfDecimalFraction(fractionDigits);
255
+ if (divisor === null) {
256
+ return match;
257
+ }
258
+ const division = '/' + divisor + unit;
259
+ // Compared against the shortest the multiplication could have been written
260
+ const multiplication = '*.' + fractionDigits + unit;
261
+ if (division.length > multiplication.length) {
262
+ return match;
263
+ }
264
+ return division;
265
+ });
266
+ }
267
+
13
268
  /**
14
269
  * The units a folded calc() expression leads with, in the order they are
15
270
  * written. Every other unit follows them alphabetically.
@@ -150,33 +405,33 @@ function tryFoldCalcExpression (expression) {
150
405
  * @return {string} The value with math functions simplified where possible.
151
406
  */
152
407
  function normalizeMathFunctions (value, property, originalValue = '') {
153
- let result = value;
408
+ let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
154
409
 
155
410
  // 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) => {
411
+ result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
157
412
  return inner.trim();
158
- });
413
+ }));
159
414
  // Flatten calc(calc(a) ± b) → calc(a ± b)
160
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, (match, inner, operator, tail) => {
415
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner, operator, tail) => {
161
416
  return 'calc(' + inner + ' ' + operator + ' ' + tail + ')';
162
- });
417
+ }));
163
418
  // Unwrap calc(calc(x)) → calc(x)
164
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, (match, inner) => {
419
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
165
420
  return 'calc(' + inner + ')';
166
- });
421
+ }));
167
422
 
168
423
  // Simplify min()/max() expressions using @csstools/css-calc
169
- result = result.replace(/\b(min|max)\(([^()]+)\)/gi, (match) => {
424
+ result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
170
425
  try {
171
426
  const simplified = calc(match);
172
427
  return typeof simplified === 'string' ? simplified : match;
173
428
  } catch {
174
429
  return match;
175
430
  }
176
- });
431
+ }));
177
432
 
178
433
  // Simplify calc() expressions using constant folding and @csstools/css-calc
179
- result = result.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
434
+ result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
180
435
  // Collapse whitespace inside calc expression
181
436
  const compactInner = inner.replace(/\s+/g, ' ').trim();
182
437
  // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
@@ -208,7 +463,7 @@ function normalizeMathFunctions (value, property, originalValue = '') {
208
463
  } catch {
209
464
  return match;
210
465
  }
211
- });
466
+ }));
212
467
 
213
468
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
214
469
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
@@ -165,6 +165,11 @@ function splitValueSegments (value) {
165
165
  end = consumeQuoted(end);
166
166
  continue;
167
167
  }
168
+ // An escaped character is url data, never the parenthesis that ends it
169
+ if (value[end] === '\\') {
170
+ end += 2;
171
+ continue;
172
+ }
168
173
  if (value[end] === '(') {
169
174
  depth++;
170
175
  }
@@ -250,38 +255,42 @@ function restoreSpaceBeforeMathOperators (value) {
250
255
  });
251
256
  }
252
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
+
253
271
  /**
254
272
  * Chooses the shortest valid representation for the path inside a `url(...)`
255
- * 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.
256
276
  *
257
277
  * @param {string} path The resolved url path, without surrounding quotes.
258
278
  * @return {string} The shortest valid url() content string.
259
279
  */
260
280
  function formatUrlPath (path) {
261
- // Parentheses and quote characters are invalid inside an unquoted url() token
262
- const hasQuoteForcingCharacters = /[()"']/.test(path);
263
- // An escaped space is already the shortest representation of a space, so it
264
- // is counted apart from unescaped ones to keep from being escaped twice
265
- const escapedSpaceCount = (path.match(/\\ /g) || []).length;
266
- // A space only needs escaping when no escaping backslash precedes it
267
- const unescapedSpaceCount = (path.match(/(?<!\\) /g) || []).length;
268
- const spaceCount = escapedSpaceCount + unescapedSpaceCount;
269
-
270
- if (hasQuoteForcingCharacters || spaceCount >= 2) {
271
- // Escape any embedded double quotes so the double-quoted wrapper stays valid
272
- 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;
273
286
  }
274
-
275
- if (spaceCount === 1) {
276
- // A lone space is one byte shorter to escape than to wrap the value in
277
- // quotes, and one that arrived already escaped stays exactly as it is
278
- if (!unescapedSpaceCount) {
279
- return path;
280
- }
281
- 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;
282
292
  }
283
-
284
- return path;
293
+ return quotedForm;
285
294
  }
286
295
 
287
296
  /**
@@ -829,6 +838,26 @@ const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
829
838
  */
830
839
  const AUTO_INITIAL_PROPERTIES = new Set(['min-width', 'min-height']);
831
840
 
841
+ /**
842
+ * Properties whose initial value is a single keyword longer than the global
843
+ * `initial` keyword itself. These replacements are applied when serializing
844
+ * declarations, because shorthand builders reconstruct their value from the
845
+ * actual longhand keywords, and a CSS-wide keyword such as `initial` is only
846
+ * valid as a declaration's entire value.
847
+ *
848
+ * @type {Map<string, string>}
849
+ */
850
+ const LENGTHY_INITIAL_KEYWORDS = new Map([
851
+ ['background-clip', 'border-box'],
852
+ ['background-origin', 'padding-box'],
853
+ ['border-collapse', 'separate'],
854
+ ['box-sizing', 'content-box'],
855
+ ['mask-clip', 'border-box'],
856
+ ['mask-origin', 'border-box'],
857
+ ['vertical-align', 'baseline'],
858
+ ['writing-mode', 'horizontal-tb']
859
+ ]);
860
+
832
861
  /**
833
862
  * Properties whose value is a time, where a millisecond amount may be worth
834
863
  * rewriting in seconds.
@@ -979,6 +1008,39 @@ function reorderBorderWidthBeforeStyle (value) {
979
1008
  return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
980
1009
  }
981
1010
 
1011
+ /**
1012
+ * Minifies a declaration's value for output, rewriting its initial keyword to
1013
+ * the shorter global `initial` keyword after shorthand assembly. Both are
1014
+ * equivalent, but `initial` is shorter than these particular keywords and,
1015
+ * being repeated across properties, gives gzip more opportunities to deduplicate it.
1016
+ *
1017
+ * @param {object} declaration The CSS declaration object with property and value fields.
1018
+ * @return {string} The minified value with its initial keyword shortened to `initial` when that saves bytes.
1019
+ */
1020
+ function minifyValueForOutput (declaration) {
1021
+ const value = minifyValue(declaration);
1022
+ const property = declaration.property;
1023
+ // Custom properties hold an arbitrary token stream that JavaScript can read
1024
+ // back, so their keywords are left exactly as written.
1025
+ if (isCustomProperty(property)) {
1026
+ return value;
1027
+ }
1028
+ // A keyword spelling the property's own initial value is shorter as the
1029
+ // global keyword (box-sizing: content-box -> box-sizing: initial)
1030
+ const initialKeyword = LENGTHY_INITIAL_KEYWORDS.get(property?.toLowerCase());
1031
+ if (!initialKeyword || 'initial'.length >= initialKeyword.length) {
1032
+ return value;
1033
+ }
1034
+ // A trailing `!important` rides along inside the value, so the keyword
1035
+ // comparison and the rewrite only concern what sits in front of it.
1036
+ const isImportant = value.endsWith('!important');
1037
+ const keyword = isImportant ? value.slice(0, value.length - '!important'.length) : value;
1038
+ if (keyword.toLowerCase() !== initialKeyword) {
1039
+ return value;
1040
+ }
1041
+ return 'initial' + (isImportant ? '!important' : '');
1042
+ }
1043
+
982
1044
  /**
983
1045
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
984
1046
  * background, display, scale, border-radius, shorthand collapsing, etc.).
@@ -1417,5 +1479,6 @@ function minifyValue (declaration) {
1417
1479
 
1418
1480
  export {
1419
1481
  clearMinifiedValueCache,
1420
- minifyValue
1482
+ minifyValue,
1483
+ minifyValueForOutput
1421
1484
  };
@@ -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++;