@thejaredwilcurt/csslop 0.0.37 → 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/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.37",
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,7 +37,7 @@
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.9",
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",
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
@@ -83,6 +83,188 @@ function keepSeparatorAfterDissolvedFunction (replacer) {
83
83
  };
84
84
  }
85
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
+
86
268
  /**
87
269
  * The units a folded calc() expression leads with, in the order they are
88
270
  * written. Every other unit follows them alphabetically.
@@ -223,7 +405,7 @@ function tryFoldCalcExpression (expression) {
223
405
  * @return {string} The value with math functions simplified where possible.
224
406
  */
225
407
  function normalizeMathFunctions (value, property, originalValue = '') {
226
- let result = value;
408
+ let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
227
409
 
228
410
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
229
411
  result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
@@ -838,6 +838,26 @@ const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
838
838
  */
839
839
  const AUTO_INITIAL_PROPERTIES = new Set(['min-width', 'min-height']);
840
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
+
841
861
  /**
842
862
  * Properties whose value is a time, where a millisecond amount may be worth
843
863
  * rewriting in seconds.
@@ -988,6 +1008,39 @@ function reorderBorderWidthBeforeStyle (value) {
988
1008
  return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
989
1009
  }
990
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
+
991
1044
  /**
992
1045
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
993
1046
  * background, display, scale, border-radius, shorthand collapsing, etc.).
@@ -1426,5 +1479,6 @@ function minifyValue (declaration) {
1426
1479
 
1427
1480
  export {
1428
1481
  clearMinifiedValueCache,
1429
- minifyValue
1482
+ minifyValue,
1483
+ minifyValueForOutput
1430
1484
  };