@thejaredwilcurt/csslop 0.0.52 → 0.0.54

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.52",
5
+ "version": "0.0.54",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -32,15 +32,15 @@
32
32
  "codemirror": "^6.0.2",
33
33
  "eslint": "^10.14.0",
34
34
  "eslint-config-tjw-base": "^5.0.0",
35
- "eslint-config-tjw-import-x": "^1.0.1",
35
+ "eslint-config-tjw-import-x": "^1.0.2",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
- "eslint-plugin-import-x": "^4.17.0",
38
- "eslint-plugin-jsdoc": "^65.4.2",
37
+ "eslint-plugin-import-x": "^4.18.0",
38
+ "eslint-plugin-jsdoc": "^65.4.5",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.14.0",
41
41
  "pretty-ms": "^10.0.0",
42
42
  "real-world-css-libraries": "^1.0.9",
43
- "vite": "^8.3.5"
43
+ "vite": "^8.3.6"
44
44
  },
45
45
  "author": "The Jared Wilcurt",
46
46
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "packageManager": {
63
63
  "name": "npm",
64
- "version": "12.2.1"
64
+ "version": "12.2.2"
65
65
  }
66
66
  }
67
67
  }
@@ -12,13 +12,34 @@ import {
12
12
 
13
13
  import {
14
14
  BORDER_EDGE_PROPERTIES,
15
- CSS_WIDE_KEYWORDS
15
+ CSS_WIDE_KEYWORDS,
16
+ expandToLeafProperties
16
17
  } from './config.js';
17
18
  import { collectDeclaredProperties } from './lookup.js';
18
19
  import { resetsPropertyDeclaredElsewhere } from './reset-hazards.js';
19
20
 
20
21
  const BORDER_TRIO_PROPERTIES = ['border-width', 'border-style', 'border-color'];
21
22
 
23
+ /**
24
+ * Every property the `border` shorthand sets: the physical edge shorthands, such
25
+ * as `border-top`, and the longhands they expand into, such as `border-top-width`.
26
+ * A declaration of one of them beside the trio competes with the shorthand.
27
+ *
28
+ * @type {Set<string>}
29
+ */
30
+ const BORDER_SHORTHAND_PROPERTIES = new Set([
31
+ ...BORDER_EDGE_PROPERTIES,
32
+ ...expandToLeafProperties('border')
33
+ ]);
34
+
35
+ /**
36
+ * Matches the logical border longhands, such as `border-inline-start-color`
37
+ * and `border-block-width`, which set the same edges as the physical ones.
38
+ *
39
+ * @type {RegExp}
40
+ */
41
+ const LOGICAL_BORDER_PATTERN = /^border-(?:inline|block)(?:-|$)/;
42
+
22
43
  /**
23
44
  * The keywords that state a border line style.
24
45
  *
@@ -140,7 +161,7 @@ function collapseBorderTrioWithPerEdgeOverrides (declarations, context) {
140
161
  return entry.property === property;
141
162
  }).length !== 1;
142
163
  }
143
- return property === 'border' || (property.startsWith('border-') && !property.startsWith('border-image'));
164
+ return property === 'border' || BORDER_SHORTHAND_PROPERTIES.has(property) || LOGICAL_BORDER_PATTERN.test(property);
144
165
  });
145
166
  if (hasCompetingBorderWrites) {
146
167
  return declarations;
@@ -179,10 +200,14 @@ function collapseBorderTrioWithPerEdgeOverrides (declarations, context) {
179
200
  }
180
201
 
181
202
  const [widthComponents, styleComponents, colorComponents] = componentsByProperty;
203
+ // The `border` shorthand resets its color to currentcolor, so a color that
204
+ // states currentcolor is left out of the shorthand rather than repeated.
205
+ const statesShorthandColor = colorComponents.length === 1 &&
206
+ colorComponents[0].toLowerCase() !== INITIAL_BORDER_EDGE_PARTS['border-color'];
182
207
  const borderValue = buildBorderShorthandValue(
183
208
  widthComponents.length === 1 ? width : '',
184
209
  styleComponents.length === 1 ? style : '',
185
- colorComponents.length === 1 ? colorComponents[0] : ''
210
+ statesShorthandColor ? colorComponents[0] : ''
186
211
  ) + importantSuffix;
187
212
  const overrides = BORDER_TRIO_PROPERTIES.flatMap((property, index) => {
188
213
  if (componentsByProperty[index].length === 1) {
@@ -186,6 +186,17 @@ function usesVendorPrefixedDisplayValue (value) {
186
186
  });
187
187
  }
188
188
 
189
+ /**
190
+ * Checks if a declaration value contains the IE6-IE9 double !important hack.
191
+ *
192
+ * @param {string} value The minified CSS value string.
193
+ * @return {boolean} Whether the value contains double !important.
194
+ */
195
+ function hasDoubleImportant (value) {
196
+ // Match !important appearing twice in the value
197
+ return /!important.*!important/.test(value);
198
+ }
199
+
189
200
  /**
190
201
  * Normalizes a vendor-prefixed display value to its standard equivalent.
191
202
  *
@@ -366,6 +377,11 @@ function deduplicateDeclarations (declarations) {
366
377
  continue;
367
378
  }
368
379
 
380
+ // IE6-IE9 double !important properties are invalid and should be removed
381
+ if (hasDoubleImportant(minifiedValue)) {
382
+ continue;
383
+ }
384
+
369
385
  // A vendor-prefixed property that repeats the value of its unprefixed
370
386
  // form is dead code for browsers that read the unprefixed one
371
387
  if (propertyName.startsWith('-') && !propertyName.startsWith('--')) {
@@ -141,6 +141,17 @@ function normalizeLayerNames (layerNames) {
141
141
  return String(layerNames ?? '').trim().replace(/\s*,\s*/g, ',');
142
142
  }
143
143
 
144
+ /**
145
+ * Checks if a media query contains the IE10/IE11 exclusive `\0` hack.
146
+ *
147
+ * @param {string} media The media query string to check.
148
+ * @return {boolean} True if the media query contains the IE hack.
149
+ */
150
+ function containsIEHack (media) {
151
+ // Match the \0 character which is used as an IE10/IE11 exclusive hack
152
+ return /\\0/.test(media);
153
+ }
154
+
144
155
  /**
145
156
  * Normalizes a `@media` query string by collapsing whitespace, stripping the default "all and" prefix, and converting min/max-width to range syntax.
146
157
  *
@@ -236,6 +247,7 @@ function canUnwrapSupports (supports) {
236
247
 
237
248
  export {
238
249
  canUnwrapSupports,
250
+ containsIEHack,
239
251
  normalizeLayerNames,
240
252
  normalizeMedia,
241
253
  normalizeRuleSelectors,
@@ -3,10 +3,26 @@
3
3
  */
4
4
 
5
5
  import {
6
+ containsIEHack,
6
7
  normalizeLayerNames,
7
8
  normalizeMedia
8
9
  } from '../normalize.js';
9
10
 
11
+ /**
12
+ * Removes `@media` rules that contain IE10/IE11 exclusive hacks (the `\0` character).
13
+ *
14
+ * @param {Array} rules The AST rule nodes to process.
15
+ * @return {Array} A new array of rules with IE-exclusive media queries removed.
16
+ */
17
+ function removeIEMediaRules (rules) {
18
+ return rules.filter((rule) => {
19
+ if (rule.type === 'media' && containsIEHack(rule.media)) {
20
+ return false;
21
+ }
22
+ return true;
23
+ });
24
+ }
25
+
10
26
  /**
11
27
  * Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
12
28
  *
@@ -109,5 +125,6 @@ function mergeLayerRules (rules, mergeSelectorRules) {
109
125
  export {
110
126
  deduplicateKeyframes,
111
127
  mergeLayerRules,
112
- mergeMediaRules
128
+ mergeMediaRules,
129
+ removeIEMediaRules
113
130
  };
@@ -155,22 +155,78 @@ function collectSharedParentCandidates (rule) {
155
155
  }
156
156
 
157
157
  /**
158
- * Counts how many characters wrapping a run behind a shared parent selector
159
- * saves: the parent is written once with its braces, and every wrapped
160
- * selector shrinks to its nested form.
158
+ * Counts the characters a list of rules takes up as selector text. Each rule
159
+ * counts its selectors, its braces, and every rule nested within it, while
160
+ * plain declarations count for nothing.
161
161
  *
162
- * @param {Array} run The rules of the run paired with their nested selectors.
163
- * @param {string} parent The shared parent selector string.
164
- * @return {number} The character savings of the wrapper, or a non-positive number when it costs more than it saves.
162
+ * @param {Array} rules The AST entries to measure.
163
+ * @return {number} The number of characters the rules take up.
165
164
  */
166
- function measureWrapperSavings (run, parent) {
167
- const charactersSaved = run.reduce((total, entry) => {
168
- return total + entry.sibling.selectors.reduce((sum, selector, position) => {
169
- return sum + selector.trim().length - entry.nestedSelectors[position].length;
170
- }, 0);
165
+ function measureRuleCharacters (rules) {
166
+ return rules.reduce((total, rule) => {
167
+ if (rule.type !== 'rule') {
168
+ return total;
169
+ }
170
+ const selectorText = (rule.selectors || []).map((selector) => {
171
+ return selector.trim();
172
+ }).join(',');
173
+ const nestedRules = (rule.declarations || []).filter((declaration) => {
174
+ return declaration.type === 'rule';
175
+ });
176
+ // The two braces wrap the rule's declarations
177
+ return total + selectorText.length + 2 + measureRuleCharacters(nestedRules);
171
178
  }, 0);
179
+ }
180
+
181
+ /**
182
+ * Counts how many characters wrapping a run behind a shared parent selector
183
+ * saves: the run is measured as it is written, against the declarations the
184
+ * wrapper would hold plus the parent written once with its braces.
185
+ *
186
+ * @param {Array} run The rules of the run paired with their nested selectors.
187
+ * @param {Array} declarations The declarations the wrapper would hold, including any factoring of the nested rules.
188
+ * @param {string} parent The shared parent selector string.
189
+ * @return {number} The character savings of the wrapper, or a non-positive number when it costs more than it saves.
190
+ */
191
+ function measureWrapperSavings (run, declarations, parent) {
192
+ const charactersBefore = measureRuleCharacters(run.map((entry) => {
193
+ return entry.sibling;
194
+ }));
172
195
  // The wrapper writes the shared selector once plus its surrounding braces.
173
- return charactersSaved - (parent.length + 2);
196
+ const charactersAfter = measureRuleCharacters(declarations) + parent.length + 2;
197
+ return charactersBefore - charactersAfter;
198
+ }
199
+
200
+ /**
201
+ * Expresses every selector of a sibling rule relative to a candidate parent.
202
+ * A nested selector must be shorter than the selector it replaces, so each
203
+ * factoring pass leaves the rules smaller and factoring them again terminates.
204
+ *
205
+ * @param {object} rule The sibling rule whose selectors are nested.
206
+ * @param {function(string): (string|null)} matchNestedSelector The matcher built for the candidate parent.
207
+ * @return {Array|null} The nested selectors, or null when any selector cannot nest under the parent.
208
+ */
209
+ function nestSelectorsUnderParent (rule, matchNestedSelector) {
210
+ const nestedSelectors = [];
211
+ for (const selector of rule.selectors) {
212
+ const trimmedSelector = selector.trim();
213
+ const nestedSelector = matchNestedSelector(trimmedSelector);
214
+ if (nestedSelector === null || nestedSelector.length >= trimmedSelector.length) {
215
+ return null;
216
+ }
217
+ nestedSelectors.push(nestedSelector);
218
+ }
219
+ return nestedSelectors;
220
+ }
221
+
222
+ /**
223
+ * Re-expresses a run entry as a rule whose selectors are the nested selectors.
224
+ *
225
+ * @param {object} entry A run entry pairing a sibling rule with its nested selectors.
226
+ * @return {object} The rule, with its selectors replaced by the nested ones.
227
+ */
228
+ function createNestedChild (entry) {
229
+ return { ...entry.sibling, selectors: entry.nestedSelectors };
174
230
  }
175
231
 
176
232
  /**
@@ -187,15 +243,49 @@ function orderRulesBySourcePosition (rules) {
187
243
  });
188
244
  }
189
245
 
246
+ /**
247
+ * Builds the declarations of a wrapper that holds a run of sibling rules nested
248
+ * under their shared parent. A first rule whose only nested selector is `&` is
249
+ * the parent itself, so its declarations are hoisted into the wrapper. Otherwise
250
+ * the nested rules are factored again, so the selectors they still share can be
251
+ * wrapped in turn.
252
+ *
253
+ * @param {Array} run The rules of the run paired with their nested selectors.
254
+ * @return {Array} The declarations of the wrapper rule.
255
+ */
256
+ function createWrapperDeclarations (run) {
257
+ const firstEntry = run[0];
258
+ const firstSelectorIsParent = (
259
+ firstEntry.nestedSelectors.length === 1 &&
260
+ firstEntry.nestedSelectors[0] === '&'
261
+ );
262
+ if (!firstSelectorIsParent) {
263
+ return factorCommonParents(run.map(createNestedChild), true);
264
+ }
265
+ const directDeclarations = firstEntry.sibling.declarations;
266
+ const directNestedRules = directDeclarations.filter((declaration) => {
267
+ return declaration.type === 'rule';
268
+ });
269
+ const directEntries = directDeclarations.filter((declaration) => {
270
+ return declaration.type !== 'rule';
271
+ });
272
+ const children = run.slice(1).map(createNestedChild);
273
+ return [
274
+ ...directEntries,
275
+ ...orderRulesBySourcePosition([...directNestedRules, ...children])
276
+ ];
277
+ }
278
+
190
279
  /**
191
280
  * Groups consecutive sibling rules that share a common leading selector
192
281
  * portion (such as `.foo` in `.foo .a`, `.foo .b`, or `.menu .item` in
193
282
  * `.menu .item:hover .x`) into a synthesized parent rule with the shared
194
283
  * portion factored out. Every viable prefix of the first rule's selector is
195
284
  * tried as the parent, and the grouping that saves the most characters wins,
196
- * but only when it saves more than the wrapper itself costs. Rules with a
197
- * comma-separated selector list can join a run when every one of their
198
- * selectors nests under the parent.
285
+ * measured with the rules nested under it factored in turn, but only when it
286
+ * saves more than the wrapper itself costs. Rules with a comma-separated
287
+ * selector list can join a run when every one of their selectors nests under
288
+ * the parent.
199
289
  *
200
290
  * @param {Array} rules The flat AST rule nodes to factor.
201
291
  * @param {boolean} allowTrailingCompound True when the rules are children of a factored wrapper, whose `&`-relative selectors may share a trailing compound that can be factored out in turn.
@@ -225,12 +315,8 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
225
315
  if (sibling.type !== 'rule' || !sibling.selectors?.length) {
226
316
  break;
227
317
  }
228
- const nestedSelectors = sibling.selectors.map((selector) => {
229
- return matchNestedSelector(selector.trim());
230
- });
231
- if (nestedSelectors.some((nestedSelector) => {
232
- return nestedSelector === null;
233
- })) {
318
+ const nestedSelectors = nestSelectorsUnderParent(sibling, matchNestedSelector);
319
+ if (!nestedSelectors) {
234
320
  break;
235
321
  }
236
322
  run.push({ sibling, nestedSelectors });
@@ -243,12 +329,13 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
243
329
  if (run.length < 2 && selectorCount < 2) {
244
330
  continue;
245
331
  }
246
- const savings = measureWrapperSavings(run, candidateParent);
332
+ const declarations = createWrapperDeclarations(run);
333
+ const savings = measureWrapperSavings(run, declarations, candidateParent);
247
334
  if (savings > 0 && (!bestGrouping || savings > bestGrouping.savings)) {
248
335
  bestGrouping = {
336
+ declarations,
249
337
  lookahead,
250
338
  parent: candidateParent,
251
- run,
252
339
  savings
253
340
  };
254
341
  }
@@ -262,39 +349,10 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
262
349
  index++;
263
350
  continue;
264
351
  }
265
- const firstEntry = bestGrouping.run[0];
266
- const firstSelectorIsParent = (
267
- firstEntry.nestedSelectors.length === 1 &&
268
- firstEntry.nestedSelectors[0] === '&'
269
- );
270
- const directDeclarations = firstSelectorIsParent ?
271
- firstEntry.sibling.declarations :
272
- [];
273
- const entriesToNest = firstSelectorIsParent ?
274
- bestGrouping.run.slice(1) :
275
- bestGrouping.run;
276
- const children = entriesToNest.map((entry) => {
277
- return { ...entry.sibling, selectors: entry.nestedSelectors };
278
- });
279
- let declarations;
280
- if (firstSelectorIsParent) {
281
- const directNestedRules = directDeclarations.filter((declaration) => {
282
- return declaration.type === 'rule';
283
- });
284
- const directEntries = directDeclarations.filter((declaration) => {
285
- return declaration.type !== 'rule';
286
- });
287
- declarations = [
288
- ...directEntries,
289
- ...orderRulesBySourcePosition([...directNestedRules, ...children])
290
- ];
291
- } else {
292
- declarations = factorCommonParents(children, true);
293
- }
294
352
  result.push({
295
353
  type: 'rule',
296
354
  selectors: [bestGrouping.parent],
297
- declarations
355
+ declarations: bestGrouping.declarations
298
356
  });
299
357
  index = bestGrouping.lookahead;
300
358
  }
@@ -5,7 +5,8 @@
5
5
  import {
6
6
  deduplicateKeyframes,
7
7
  mergeLayerRules,
8
- mergeMediaRules
8
+ mergeMediaRules,
9
+ removeIEMediaRules
9
10
  } from './at-rules.js';
10
11
  import { removeEmptyRules } from './empty-rules.js';
11
12
  import { expandPureNestedRules } from './expand-nesting.js';
@@ -43,6 +44,7 @@ function clearSelectorCaches () {
43
44
  */
44
45
  function optimizeRuleList (rules) {
45
46
  let result = expandPureNestedRules(rules);
47
+ result = removeIEMediaRules(result);
46
48
  result = mergeLayerRules(result, mergeSelectorRules);
47
49
  result = mergeMediaRules(result, mergeSelectorRules);
48
50
  result = deduplicateKeyframes(result);
@@ -175,7 +175,193 @@ function mergeRuleIntoPredecessor (previousRule, rule) {
175
175
  }
176
176
 
177
177
  /**
178
- * Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
178
+ * Finds properties that are present in all adjacent rules with matching values.
179
+ *
180
+ * @param {Array} adjacentRules The consecutive rule nodes to compare.
181
+ * @return {Map} Map of property name to value for properties common to all rules.
182
+ */
183
+ function findCommonProperties (adjacentRules) {
184
+ if (adjacentRules.length < 2) {
185
+ return new Map();
186
+ }
187
+
188
+ const firstRuleBody = partitionRuleBody(adjacentRules[0].declarations);
189
+ const commonProperties = new Map(
190
+ firstRuleBody.plainDeclarations.map((declaration) => {
191
+ return [declaration.property, (declaration.value || '').trim()];
192
+ })
193
+ );
194
+
195
+ for (let ruleIndex = 1; ruleIndex < adjacentRules.length; ruleIndex++) {
196
+ const currentRuleBody = partitionRuleBody(adjacentRules[ruleIndex].declarations);
197
+ const currentValueByProperty = new Map(
198
+ currentRuleBody.plainDeclarations.map((declaration) => {
199
+ return [declaration.property, (declaration.value || '').trim()];
200
+ })
201
+ );
202
+
203
+ for (const [property, value] of commonProperties) {
204
+ if (currentValueByProperty.get(property) !== value) {
205
+ commonProperties.delete(property);
206
+ }
207
+ }
208
+ }
209
+
210
+ return commonProperties;
211
+ }
212
+
213
+ /**
214
+ * Checks if a rule contains the `all` property, which resets all other properties.
215
+ *
216
+ * @param {object} rule The rule node to check.
217
+ * @return {boolean} True if the rule contains the `all` property.
218
+ */
219
+ function hasAllProperty (rule) {
220
+ const ruleBody = partitionRuleBody(rule.declarations);
221
+ return ruleBody.plainDeclarations.some((declaration) => {
222
+ return declaration.property === 'all';
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Checks if any rule in a list contains the `all` property.
228
+ *
229
+ * @param {Array} rules The rule nodes to check.
230
+ * @return {boolean} True if any rule contains the `all` property.
231
+ */
232
+ function anyRuleHasAllProperty (rules) {
233
+ return rules.some(hasAllProperty);
234
+ }
235
+
236
+ /**
237
+ * Checks if any rule has nested blocks.
238
+ *
239
+ * @param {Array} rules The rule nodes to check.
240
+ * @return {boolean} True if any rule has nested blocks.
241
+ */
242
+ function anyRuleHasNestedBlocks (rules) {
243
+ return rules.some((rule) => {
244
+ const ruleBody = partitionRuleBody(rule.declarations);
245
+ return ruleBody.nestedBlocks.length > 0;
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Combines adjacent rules that share common properties by creating a merged rule
251
+ * with all properties from the first rule, and keeping only the differing properties
252
+ * in each subsequent rule.
253
+ *
254
+ * @param {Array} adjacentRules The consecutive rule nodes to merge.
255
+ * @param {Map} commonProps The properties common to all rules.
256
+ * @return {Array} The rules after merging common properties.
257
+ */
258
+ function mergeCommonProperties (adjacentRules, commonProps) {
259
+ if (commonProps.size === 0) {
260
+ return adjacentRules;
261
+ }
262
+
263
+ // Skip merging if any rule has the `all` property
264
+ if (anyRuleHasAllProperty(adjacentRules)) {
265
+ return adjacentRules;
266
+ }
267
+
268
+ // Skip merging if any rule has nested blocks
269
+ if (anyRuleHasNestedBlocks(adjacentRules)) {
270
+ return adjacentRules;
271
+ }
272
+
273
+ const firstRuleBody = partitionRuleBody(adjacentRules[0].declarations);
274
+
275
+ const combinedSelectors = [];
276
+ for (const rule of adjacentRules) {
277
+ combinedSelectors.push(...rule.selectors);
278
+ }
279
+
280
+ const mergedRule = {
281
+ ...adjacentRules[0],
282
+ selectors: deduplicateSelectors(combinedSelectors),
283
+ declarations: firstRuleBody.plainDeclarations
284
+ };
285
+
286
+ const result = [mergedRule];
287
+
288
+ for (let ruleIndex = 1; ruleIndex < adjacentRules.length; ruleIndex++) {
289
+ const rule = adjacentRules[ruleIndex];
290
+ const ruleBody = partitionRuleBody(rule.declarations);
291
+ const differingDeclarations = ruleBody.plainDeclarations.filter((declaration) => {
292
+ const firstRuleValue = firstRuleBody.plainDeclarations.find((decl) => {
293
+ return decl.property === declaration.property;
294
+ });
295
+ if (!firstRuleValue) {
296
+ return true;
297
+ }
298
+ return (declaration.value || '').trim() !== (firstRuleValue.value || '').trim();
299
+ });
300
+
301
+ if (differingDeclarations.length > 0) {
302
+ result.push({
303
+ ...rule,
304
+ declarations: differingDeclarations
305
+ });
306
+ }
307
+ }
308
+
309
+ // Check if the merge actually reduces size
310
+ const originalSize = adjacentRules.reduce((sum, rule) => {
311
+ return sum + calculateRuleSize(rule);
312
+ }, 0);
313
+
314
+ const mergedSize = result.reduce((sum, rule) => {
315
+ return sum + calculateRuleSize(rule);
316
+ }, 0);
317
+
318
+ if (mergedSize >= originalSize) {
319
+ return adjacentRules;
320
+ }
321
+
322
+ return result;
323
+ }
324
+
325
+ /**
326
+ * Calculates the approximate character size of a rule's selectors.
327
+ *
328
+ * @param {object} rule The rule node.
329
+ * @return {number} The total character count of all selectors.
330
+ */
331
+ function calculateSelectorSize (rule) {
332
+ if (!rule.selectors) {
333
+ return 0;
334
+ }
335
+ return rule.selectors.join(',').length;
336
+ }
337
+
338
+ /**
339
+ * Calculates the approximate character size of a rule's declarations.
340
+ *
341
+ * @param {object} rule The rule node.
342
+ * @return {number} The total character count of all declarations.
343
+ */
344
+ function calculateDeclarationSize (rule) {
345
+ const ruleBody = partitionRuleBody(rule.declarations);
346
+ let size = 0;
347
+ for (const declaration of ruleBody.plainDeclarations) {
348
+ size += declaration.property.length + 1 + (declaration.value || '').length;
349
+ }
350
+ return size;
351
+ }
352
+
353
+ /**
354
+ * Calculates the approximate character size of a rule.
355
+ *
356
+ * @param {object} rule The rule node.
357
+ * @return {number} The total character count of the rule.
358
+ */
359
+ function calculateRuleSize (rule) {
360
+ return calculateSelectorSize(rule) + calculateDeclarationSize(rule);
361
+ }
362
+
363
+ /**
364
+ * Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations. Also combines common properties across adjacent rules.
179
365
  *
180
366
  * @param {Array} rules The AST rule nodes to merge.
181
367
  * @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
@@ -191,7 +377,36 @@ function mergeByDeclarations (rules) {
191
377
  }
192
378
  result.push(rule);
193
379
  }
194
- return result;
380
+
381
+ // Pass to combine common properties across adjacent rules
382
+ const finalResult = [];
383
+ let adjacentRules = [];
384
+
385
+ for (const rule of result) {
386
+ if (rule.type === 'rule' && rule.selectors?.length) {
387
+ adjacentRules.push(rule);
388
+ } else {
389
+ if (adjacentRules.length >= 2) {
390
+ const commonProps = findCommonProperties(adjacentRules);
391
+ const merged = mergeCommonProperties(adjacentRules, commonProps);
392
+ finalResult.push(...merged);
393
+ } else if (adjacentRules.length === 1) {
394
+ finalResult.push(adjacentRules[0]);
395
+ }
396
+ adjacentRules = [];
397
+ finalResult.push(rule);
398
+ }
399
+ }
400
+
401
+ if (adjacentRules.length >= 2) {
402
+ const commonProps = findCommonProperties(adjacentRules);
403
+ const merged = mergeCommonProperties(adjacentRules, commonProps);
404
+ finalResult.push(...merged);
405
+ } else if (adjacentRules.length === 1) {
406
+ finalResult.push(adjacentRules[0]);
407
+ }
408
+
409
+ return finalResult;
195
410
  }
196
411
 
197
412
  export {
@@ -169,15 +169,17 @@ function simplifyMathFunctionsOnce (value, property) {
169
169
  */
170
170
  function normalizeMathFunctions (value, property, originalValue = '') {
171
171
  let result = value;
172
- let previous;
173
-
174
- do {
175
- previous = result;
176
- // Resolving a math function leaves a plain value where it stood, which can
177
- // bring the function it was nested in within reach of the next pass, so
178
- // the value is simplified until it settles
179
- result = simplifyMathFunctionsOnce(result, property);
180
- } while (result !== previous);
172
+ if (value.includes('(')) {
173
+ let previous;
174
+
175
+ do {
176
+ previous = result;
177
+ // Resolving a math function leaves a plain value where it stood, which can
178
+ // bring the function it was nested in within reach of the next pass, so
179
+ // the value is simplified until it settles
180
+ result = simplifyMathFunctionsOnce(result, property);
181
+ } while (result !== previous);
182
+ }
181
183
 
182
184
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
183
185
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
@@ -48,8 +48,13 @@ function rewriteMathFunctionContents (rewriteExpression, value) {
48
48
  break;
49
49
  }
50
50
  const functionName = value.slice(findFunctionNameStart(value, openIndex), openIndex).toLowerCase();
51
+ if (!MATH_FUNCTION_NAMES.has(functionName)) {
52
+ result += value.slice(index, openIndex + 1);
53
+ index = openIndex + 1;
54
+ continue;
55
+ }
51
56
  const closeIndex = findMatchingParenthesis(value, openIndex);
52
- if (!MATH_FUNCTION_NAMES.has(functionName) || closeIndex === -1) {
57
+ if (closeIndex === -1) {
53
58
  result += value.slice(index, openIndex + 1);
54
59
  index = openIndex + 1;
55
60
  continue;