@thejaredwilcurt/csslop 0.0.23 → 0.0.25

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.
@@ -103,6 +103,35 @@ function expandPureNestedRules (rules) {
103
103
  return result;
104
104
  }
105
105
 
106
+ /**
107
+ * Builds a reusable matcher that expresses child selectors as nested selectors
108
+ * relative to one parent. The combinator pattern is compiled once per parent so
109
+ * that testing many children against the same parent does not rebuild it.
110
+ *
111
+ * @param {string} parentSelector The parent selector string.
112
+ * @return {function(string): (string|null)} A matcher returning the nested selector, or null when the child cannot be nested.
113
+ */
114
+ function createNestedSelectorMatcher (parentSelector) {
115
+ const parent = parentSelector.trim();
116
+ // Match a child selector that starts with the parent followed by a combinator (>, +, ~)
117
+ const combinatorPattern = new RegExp('^' + escapeRegexString(parent) + '\\s*([>+~])\\s*(.+)$');
118
+
119
+ return (childSelector) => {
120
+ const child = childSelector.trim();
121
+ if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
122
+ return '&' + child.slice(parent.length);
123
+ }
124
+ if (child.startsWith(parent + ' ')) {
125
+ return child.slice(parent.length + 1);
126
+ }
127
+ const combinatorMatch = child.match(combinatorPattern);
128
+ if (combinatorMatch) {
129
+ return combinatorMatch[1] + combinatorMatch[2];
130
+ }
131
+ return null;
132
+ };
133
+ }
134
+
106
135
  /**
107
136
  * Attempts to express a child selector as a nested selector relative to a parent, returning the nested form or null if nesting is not possible.
108
137
  *
@@ -111,22 +140,7 @@ function expandPureNestedRules (rules) {
111
140
  * @return {string|null} The nested selector using & syntax, or null if the child cannot be nested under the parent.
112
141
  */
113
142
  function tryNestSelector (parentSel, childSel) {
114
- const parent = parentSel.trim();
115
- const child = childSel.trim();
116
- if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
117
- return '&' + child.slice(parent.length);
118
- }
119
- if (child.startsWith(parent + ' ')) {
120
- return child.slice(parent.length + 1);
121
- }
122
- // Match child selector that starts with the parent followed by a combinator (>, +, ~)
123
- const combinatorMatch = child.match(
124
- new RegExp('^' + escapeRegexString(parent) + '\\s*([>+~])\\s*(.+)$')
125
- );
126
- if (combinatorMatch) {
127
- return combinatorMatch[1] + combinatorMatch[2];
128
- }
129
- return null;
143
+ return createNestedSelectorMatcher(parentSel)(childSel);
130
144
  }
131
145
 
132
146
  /**
@@ -173,6 +187,87 @@ function removeEmptyRules (rules) {
173
187
  });
174
188
  }
175
189
 
190
+ /**
191
+ * Determines whether a character is CSS whitespace.
192
+ *
193
+ * @param {string} character A single character from a selector.
194
+ * @return {boolean} Whether the character is whitespace.
195
+ */
196
+ function isSelectorWhitespace (character) {
197
+ // Match a single whitespace character
198
+ return (/\s/).test(character);
199
+ }
200
+
201
+ /**
202
+ * Collects every prefix of a child selector that could act as its nesting
203
+ * parent. A parent always ends immediately before a pseudo-class colon, a
204
+ * descendant whitespace run, or the whitespace run leading into a combinator,
205
+ * so only those few positions can start a nestable remainder. Enumerating them
206
+ * turns "which earlier rule can host this one" into a set of key lookups
207
+ * instead of a comparison against every preceding rule.
208
+ *
209
+ * @param {string} childSelector The trimmed child selector.
210
+ * @return {Set} The candidate parent selector strings.
211
+ */
212
+ function collectCandidateParentSelectors (childSelector) {
213
+ const candidates = new Set();
214
+ for (let index = 1; index < childSelector.length; index++) {
215
+ const character = childSelector[index];
216
+ if (character === ':' || isSelectorWhitespace(character)) {
217
+ candidates.add(childSelector.slice(0, index));
218
+ continue;
219
+ }
220
+ if (character !== '>' && character !== '+' && character !== '~') {
221
+ continue;
222
+ }
223
+ // A combinator may be separated from its parent by whitespace, which
224
+ // belongs to neither side, so the parent ends where that run begins.
225
+ let boundary = index;
226
+ while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
227
+ boundary--;
228
+ }
229
+ if (boundary > 0) {
230
+ candidates.add(childSelector.slice(0, boundary));
231
+ }
232
+ }
233
+ return candidates;
234
+ }
235
+
236
+ /**
237
+ * Finds the most recently emitted rule that can host a child selector as a
238
+ * nested rule, matching the behaviour of scanning backwards through the emitted
239
+ * rules but only visiting the handful of rules whose selector is a viable
240
+ * parent prefix.
241
+ *
242
+ * @param {Array} emittedRules The rules emitted so far.
243
+ * @param {Map} indexBySelector Map of each emitted single-selector rule's selector to its index.
244
+ * @param {string} childSelector The trimmed child selector to nest.
245
+ * @return {object|null} The `parentIndex` and `nestedSelector`, or null when nothing can host the child.
246
+ */
247
+ function findNestingParent (emittedRules, indexBySelector, childSelector) {
248
+ const parentIndexes = [];
249
+ for (const candidateParent of collectCandidateParentSelectors(childSelector)) {
250
+ const parentIndex = indexBySelector.get(candidateParent);
251
+ if (parentIndex !== undefined) {
252
+ parentIndexes.push(parentIndex);
253
+ }
254
+ }
255
+ // The nearest preceding parent wins, exactly as a backwards scan would.
256
+ parentIndexes.sort((first, second) => {
257
+ return second - first;
258
+ });
259
+ for (const parentIndex of parentIndexes) {
260
+ const nestedSelector = tryNestSelector(emittedRules[parentIndex].selectors[0], childSelector);
261
+ if (nestedSelector !== null) {
262
+ return {
263
+ nestedSelector,
264
+ parentIndex
265
+ };
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
176
271
  /**
177
272
  * Groups flat CSS rules into nested structures where a child selector can be expressed relative to a preceding parent, reducing output size through CSS nesting.
178
273
  *
@@ -181,29 +276,22 @@ function removeEmptyRules (rules) {
181
276
  */
182
277
  function nestFlatRules (rules) {
183
278
  const result = [];
279
+ const indexBySelector = new Map();
184
280
  for (const rule of rules) {
185
281
  if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
186
282
  result.push(rule);
187
283
  continue;
188
284
  }
189
285
  const childSelector = rule.selectors[0].trim();
190
- let wasNested = false;
191
- for (let j = result.length - 1; j >= 0; j--) {
192
- const parentRule = result[j];
193
- if (parentRule.type !== 'rule' || parentRule.selectors?.length !== 1) {
194
- continue;
195
- }
196
- const nestedSelector = tryNestSelector(parentRule.selectors[0], childSelector);
197
- if (nestedSelector !== null) {
198
- parentRule.declarations = parentRule.declarations || [];
199
- parentRule.declarations.push({ ...rule, selectors: [nestedSelector] });
200
- wasNested = true;
201
- break;
202
- }
203
- }
204
- if (!wasNested) {
205
- result.push(rule);
286
+ const nesting = findNestingParent(result, indexBySelector, childSelector);
287
+ if (nesting) {
288
+ const parentRule = result[nesting.parentIndex];
289
+ parentRule.declarations = parentRule.declarations || [];
290
+ parentRule.declarations.push({ ...rule, selectors: [nesting.nestedSelector] });
291
+ continue;
206
292
  }
293
+ indexBySelector.set(childSelector, result.length);
294
+ result.push(rule);
207
295
  }
208
296
  for (const rule of result) {
209
297
  if (rule.type === 'rule' && rule.declarations) {
@@ -277,6 +365,7 @@ function factorCommonParents (rules) {
277
365
  continue;
278
366
  }
279
367
  // Collect the run of consecutive rules that can all nest under the candidate.
368
+ const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
280
369
  const run = [];
281
370
  const nestedForms = [];
282
371
  let lookahead = index;
@@ -285,7 +374,7 @@ function factorCommonParents (rules) {
285
374
  if (sibling.type !== 'rule' || sibling.selectors?.length !== 1) {
286
375
  break;
287
376
  }
288
- const nestedSelector = tryNestSelector(candidateParent, sibling.selectors[0]);
377
+ const nestedSelector = matchNestedSelector(sibling.selectors[0]);
289
378
  if (nestedSelector === null) {
290
379
  break;
291
380
  }
@@ -745,8 +834,12 @@ function mergeIdenticalNestedRules (rules) {
745
834
  * @return {Array} A new array of rules with same-selector rules combined.
746
835
  */
747
836
  function mergeSelectorRules (rules) {
748
- let result = [];
749
- let selectorMap = new Map();
837
+ // A merged rule moves to the end of the output. Its old slot is emptied
838
+ // instead of spliced out so that every recorded position stays valid, and the
839
+ // position map locates that slot without searching the output.
840
+ const slots = [];
841
+ const positionByRule = new Map();
842
+ const selectorMap = new Map();
750
843
  for (const rule of rules) {
751
844
  if (rule.type === 'rule') {
752
845
  const selectorKey = rule.selectors ?
@@ -757,23 +850,26 @@ function mergeSelectorRules (rules) {
757
850
  if (selectorKey && selectorMap.has(selectorKey)) {
758
851
  const existingRule = selectorMap.get(selectorKey);
759
852
  existingRule.declarations.push(...(rule.declarations || []));
760
- result = result.filter((candidate) => {
761
- return candidate !== existingRule;
762
- });
763
- result.push(existingRule);
853
+ slots[positionByRule.get(existingRule)] = null;
854
+ positionByRule.set(existingRule, slots.length);
855
+ slots.push(existingRule);
764
856
  } else {
765
857
  selectorMap.set(selectorKey, rule);
766
- result.push(rule);
858
+ positionByRule.set(rule, slots.length);
859
+ slots.push(rule);
767
860
  }
768
861
  } else {
769
862
  if (rule.type === 'whitespace') {
770
863
  continue;
771
864
  }
772
- result.push(rule);
865
+ slots.push(rule);
773
866
  selectorMap.clear();
867
+ positionByRule.clear();
774
868
  }
775
869
  }
776
- return result;
870
+ return slots.filter((slot) => {
871
+ return slot !== null;
872
+ });
777
873
  }
778
874
 
779
875
  /**
@@ -835,6 +931,61 @@ function normalizeSelector (selector) {
835
931
  .replace(/::after\b/g, ':after');
836
932
  }
837
933
 
934
+ /**
935
+ * Indexes, for every normalized selector in the stylesheet, the last rule that
936
+ * declares each property under it. Comparing that index against a rule's own
937
+ * position answers "is this selector's property overridden later" in constant
938
+ * time, instead of rescanning and renormalizing every following rule.
939
+ *
940
+ * @param {Array} rules The flat list of AST rule nodes.
941
+ * @return {Map} Map of normalized selector to a map of property name to its last declaring rule index.
942
+ */
943
+ function indexLastDeclaringRuleBySelector (rules) {
944
+ const lastRuleIndexBySelector = new Map();
945
+ rules.forEach((rule, ruleIndex) => {
946
+ if (rule.type !== 'rule' || !rule.selectors) {
947
+ return;
948
+ }
949
+ const declaredProperties = (rule.declarations || []).filter((declaration) => {
950
+ return declaration.type === 'declaration';
951
+ }).map((declaration) => {
952
+ return declaration.property;
953
+ });
954
+ if (declaredProperties.length === 0) {
955
+ return;
956
+ }
957
+ for (const selector of rule.selectors) {
958
+ const normalizedSelector = normalizeSelector(selector);
959
+ let lastRuleIndexByProperty = lastRuleIndexBySelector.get(normalizedSelector);
960
+ if (!lastRuleIndexByProperty) {
961
+ lastRuleIndexByProperty = new Map();
962
+ lastRuleIndexBySelector.set(normalizedSelector, lastRuleIndexByProperty);
963
+ }
964
+ for (const property of declaredProperties) {
965
+ lastRuleIndexByProperty.set(property, ruleIndex);
966
+ }
967
+ }
968
+ });
969
+ return lastRuleIndexBySelector;
970
+ }
971
+
972
+ /**
973
+ * Checks whether a given selector has a specific property overridden
974
+ * by any later rule in the stylesheet. A property is considered
975
+ * overridden if a subsequent rule contains that selector (as its only
976
+ * selector or among its selectors) and declares the same property.
977
+ *
978
+ * @param {Map} lastRuleIndexBySelector The index built by `indexLastDeclaringRuleBySelector`.
979
+ * @param {number} startIndex The index of the current rule (search starts after this).
980
+ * @param {string} selector The normalized selector to check.
981
+ * @param {string} property The CSS property name to check.
982
+ * @return {boolean} True if a later rule overrides this selector+property.
983
+ */
984
+ function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex, selector, property) {
985
+ const lastRuleIndex = lastRuleIndexBySelector.get(selector)?.get(property);
986
+ return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
987
+ }
988
+
838
989
  /**
839
990
  * Removes properties from multi-selector rules when every selector in
840
991
  * the rule has that property overridden by a later rule. For example,
@@ -847,6 +998,10 @@ function normalizeSelector (selector) {
847
998
  * @return {Array} The rules with overridden multi-selector properties removed.
848
999
  */
849
1000
  function removeOverriddenMultiSelectorProperties (rules) {
1001
+ // Only rules that follow the one being pruned are ever consulted, and pruning
1002
+ // never adds a declaration, so a single index built up front stays accurate.
1003
+ const lastRuleIndexBySelector = indexLastDeclaringRuleBySelector(rules);
1004
+
850
1005
  for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
851
1006
  const rule = rules[ruleIndex];
852
1007
  if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
@@ -866,7 +1021,7 @@ function removeOverriddenMultiSelectorProperties (rules) {
866
1021
  for (const declaration of declarations) {
867
1022
  const property = declaration.property;
868
1023
  const allSelectorsOverridden = normalizedSelectors.every((selector) => {
869
- return isSelectorPropertyOverriddenLater(rules, ruleIndex, selector, property);
1024
+ return isSelectorPropertyOverriddenLater(lastRuleIndexBySelector, ruleIndex, selector, property);
870
1025
  });
871
1026
  if (allSelectorsOverridden) {
872
1027
  propertiesToRemove.add(property);
@@ -885,41 +1040,6 @@ function removeOverriddenMultiSelectorProperties (rules) {
885
1040
  return rules;
886
1041
  }
887
1042
 
888
- /**
889
- * Checks whether a given selector has a specific property overridden
890
- * by any later rule in the stylesheet. A property is considered
891
- * overridden if a subsequent rule contains that selector (as its only
892
- * selector or among its selectors) and declares the same property.
893
- *
894
- * @param {Array} rules The full list of AST rule nodes.
895
- * @param {number} startIndex The index of the current rule (search starts after this).
896
- * @param {string} selector The normalized selector to check.
897
- * @param {string} property The CSS property name to check.
898
- * @return {boolean} True if a later rule overrides this selector+property.
899
- */
900
- function isSelectorPropertyOverriddenLater (rules, startIndex, selector, property) {
901
- for (let laterIndex = startIndex + 1; laterIndex < rules.length; laterIndex++) {
902
- const laterRule = rules[laterIndex];
903
- if (laterRule.type !== 'rule' || !laterRule.selectors) {
904
- continue;
905
- }
906
- const laterSelectors = laterRule.selectors.map(normalizeSelector);
907
- if (!laterSelectors.includes(selector)) {
908
- continue;
909
- }
910
- const laterDeclarations = (laterRule.declarations || []).filter((declaration) => {
911
- return declaration.type === 'declaration';
912
- });
913
- const hasOverride = laterDeclarations.some((declaration) => {
914
- return declaration.property === property;
915
- });
916
- if (hasOverride) {
917
- return true;
918
- }
919
- }
920
- return false;
921
- }
922
-
923
1043
  export {
924
1044
  deduplicateKeyframes,
925
1045
  expandPureNestedRules,
@@ -41,6 +41,14 @@ function stringifyDeclarations (declarations) {
41
41
  .join(';');
42
42
  }
43
43
 
44
+ /**
45
+ * The heading element selectors, which collapse into the `:heading`
46
+ * pseudo-class when a rule targets every one of them.
47
+ *
48
+ * @type {Set<string>}
49
+ */
50
+ const HEADING_SELECTORS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
51
+
44
52
  /**
45
53
  * Matches a complete `@layer` statement, which declares layer names without a
46
54
  * block and ends with the semicolon that separates it from the CSS that follows
@@ -263,12 +271,11 @@ function stringifyRule (rule, context) {
263
271
  }
264
272
  uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
265
273
  uniqueSelectors = [...new Set(uniqueSelectors)];
266
- const headingSet = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
267
274
  const isAllHeadings = (
268
- rule.selectors.length === 6 &&
269
- uniqueSelectors.length === 6 &&
275
+ rule.selectors.length === HEADING_SELECTORS.size &&
276
+ uniqueSelectors.length === HEADING_SELECTORS.size &&
270
277
  uniqueSelectors.every((selector) => {
271
- return headingSet.has(selector);
278
+ return HEADING_SELECTORS.has(selector);
272
279
  })
273
280
  );
274
281
  if (isAllHeadings) {
@@ -350,6 +350,88 @@ function combineAdjacentIdenticalStops (args) {
350
350
  return normalizeBoundaryPositionTokens(mergedStops);
351
351
  }
352
352
 
353
+ /**
354
+ * The `to <side>` linear-gradient directions that are shorter to write as an
355
+ * angle, keyed by the normalized keyword form. Corner directions are listed
356
+ * under both keyword orders, since either spells the same corner.
357
+ *
358
+ * @type {Map<string, string>}
359
+ */
360
+ const LINEAR_DIRECTION_ANGLES = new Map([
361
+ ['to top', '0deg'],
362
+ ['to right', '90deg'],
363
+ ['to left', '270deg'],
364
+ ['to top right', '45deg'],
365
+ ['to right top', '45deg'],
366
+ ['to bottom right', '135deg'],
367
+ ['to right bottom', '135deg'],
368
+ ['to bottom left', '225deg'],
369
+ ['to left bottom', '225deg'],
370
+ ['to top left', '315deg'],
371
+ ['to left top', '315deg']
372
+ ]);
373
+
374
+ /**
375
+ * The linear-gradient directions that are already the default, so writing them
376
+ * out adds nothing.
377
+ *
378
+ * @type {Set<string>}
379
+ */
380
+ const DEFAULT_LINEAR_DIRECTIONS = new Set(['to bottom', '180deg']);
381
+
382
+ /**
383
+ * The radial-gradient shapes that are already the default.
384
+ *
385
+ * @type {Set<string>}
386
+ */
387
+ const DEFAULT_RADIAL_SHAPES = new Set(['ellipse at center', 'circle at center']);
388
+
389
+ /**
390
+ * Rewrites the leading direction argument of a linear gradient into its
391
+ * shortest form, dropping it when it is the default.
392
+ *
393
+ * @param {Array} args The gradient arguments, rewritten in place.
394
+ * @return {number} The number of leading arguments that are not color stops.
395
+ */
396
+ function normalizeLinearGradientDirection (args) {
397
+ const firstDirection = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
398
+ if (DEFAULT_LINEAR_DIRECTIONS.has(firstDirection)) {
399
+ args.shift();
400
+ return 0;
401
+ }
402
+ const angle = LINEAR_DIRECTION_ANGLES.get(firstDirection);
403
+ if (angle) {
404
+ args[0] = angle;
405
+ return 1;
406
+ }
407
+ // Check if first arg looks like a direction (angle or "to ..." keyword)
408
+ const looksLikeDirection = /^\d+(\.\d+)?deg$/i.test(firstDirection) || firstDirection.startsWith('to ');
409
+ if (looksLikeDirection) {
410
+ return 1;
411
+ }
412
+ return 0;
413
+ }
414
+
415
+ /**
416
+ * Drops the leading shape argument of a radial gradient when it is the default.
417
+ *
418
+ * @param {Array} args The gradient arguments, rewritten in place.
419
+ * @return {number} The number of leading arguments that are not color stops.
420
+ */
421
+ function normalizeRadialGradientShape (args) {
422
+ const firstShape = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
423
+ if (DEFAULT_RADIAL_SHAPES.has(firstShape)) {
424
+ args.shift();
425
+ return 0;
426
+ }
427
+ // Check if first arg is a radial shape/size descriptor
428
+ const looksLikeShape = /\b(circle|ellipse|closest|farthest|at)\b/i.test(firstShape);
429
+ if (looksLikeShape) {
430
+ return 1;
431
+ }
432
+ return 0;
433
+ }
434
+
353
435
  /**
354
436
  * Optimizes gradient arguments by removing default direction or shape keywords, combining adjacent identical color stops, and trimming redundant 0% or 100% stop positions from the first and last stops.
355
437
  *
@@ -363,52 +445,11 @@ function processGradientArgs (func, argsStr) {
363
445
 
364
446
  let directionArgCount = 0;
365
447
 
366
- if (functionLower.includes('linear')) {
367
- if (args.length > 1) {
368
- const firstDirection = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
369
- if (firstDirection === 'to bottom' || firstDirection === '180deg') {
370
- args.shift();
371
- } else if (firstDirection === 'to top') {
372
- args[0] = '0deg';
373
- directionArgCount = 1;
374
- } else if (firstDirection === 'to right') {
375
- args[0] = '90deg';
376
- directionArgCount = 1;
377
- } else if (firstDirection === 'to left') {
378
- args[0] = '270deg';
379
- directionArgCount = 1;
380
- } else if (firstDirection === 'to top right' || firstDirection === 'to right top') {
381
- args[0] = '45deg';
382
- directionArgCount = 1;
383
- } else if (firstDirection === 'to bottom right' || firstDirection === 'to right bottom') {
384
- args[0] = '135deg';
385
- directionArgCount = 1;
386
- } else if (firstDirection === 'to bottom left' || firstDirection === 'to left bottom') {
387
- args[0] = '225deg';
388
- directionArgCount = 1;
389
- } else if (firstDirection === 'to top left' || firstDirection === 'to left top') {
390
- args[0] = '315deg';
391
- directionArgCount = 1;
392
- } else {
393
- // Check if first arg looks like a direction (angle or "to ..." keyword)
394
- const looksLikeDirection = /^\d+(\.\d+)?deg$/i.test(firstDirection) || firstDirection.startsWith('to ');
395
- if (looksLikeDirection) {
396
- directionArgCount = 1;
397
- }
398
- }
399
- }
400
- } else if (functionLower.includes('radial')) {
401
- if (args.length > 1) {
402
- const firstShape = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
403
- if (firstShape === 'ellipse at center' || firstShape === 'circle at center') {
404
- args.shift();
405
- } else {
406
- // Check if first arg is a radial shape/size descriptor
407
- const looksLikeShape = /\b(circle|ellipse|closest|farthest|at)\b/i.test(firstShape);
408
- if (looksLikeShape) {
409
- directionArgCount = 1;
410
- }
411
- }
448
+ if (args.length > 1) {
449
+ if (functionLower.includes('linear')) {
450
+ directionArgCount = normalizeLinearGradientDirection(args);
451
+ } else if (functionLower.includes('radial')) {
452
+ directionArgCount = normalizeRadialGradientShape(args);
412
453
  }
413
454
  }
414
455
 
package/src/value/math.js CHANGED
@@ -10,6 +10,21 @@ import {
10
10
  roundCompactNumber
11
11
  } from './shared.js';
12
12
 
13
+ /**
14
+ * The units a folded calc() expression leads with, in the order they are
15
+ * written. Every other unit follows them alphabetically.
16
+ *
17
+ * @type {Array}
18
+ */
19
+ const PREFERRED_UNIT_ORDER = ['%', '', 'px'];
20
+
21
+ /**
22
+ * The same leading units, for excluding them from the alphabetical remainder.
23
+ *
24
+ * @type {Set<string>}
25
+ */
26
+ const PREFERRED_UNITS = new Set(PREFERRED_UNIT_ORDER);
27
+
13
28
  /**
14
29
  * Attempts to simplify a calc() expression by combining like-unit terms and evaluating pure arithmetic, returning the simplified string or null if folding is not possible.
15
30
  *
@@ -89,8 +104,8 @@ function tryFoldCalcExpression (expression) {
89
104
  totals.set(unit, (totals.get(unit) || 0) + number);
90
105
  }
91
106
 
92
- const orderedUnits = ['%', '', 'px', ...[...totals.keys()].filter((unit) => {
93
- return !['%', '', 'px'].includes(unit);
107
+ const orderedUnits = [...PREFERRED_UNIT_ORDER, ...[...totals.keys()].filter((unit) => {
108
+ return !PREFERRED_UNITS.has(unit);
94
109
  }).sort()];
95
110
  const outputTerms = [];
96
111