@thejaredwilcurt/csslop 0.0.20 → 0.0.22

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.
@@ -4,793 +4,79 @@
4
4
 
5
5
  import { minifyValue } from '../value/minify.js';
6
6
  import { hasInvalidQuotesCount } from '../value/quotes.js';
7
- import { collapseShorthandParts } from '../value/shared.js';
8
7
 
8
+ import { absorbBackgroundLonghandsIntoShorthand } from './background.js';
9
+ import { collapseBorderTrioWithPerEdgeColor } from './border.js';
10
+ import { shorthandMap } from './config.js';
11
+ import { hoistCssWideKeywordsIntoShorthands } from './css-wide-keywords.js';
9
12
  import {
10
- shorthandMap,
11
- shorthandOverrideMap
12
- } from './config.js';
13
-
14
- /**
15
- * Reorders declarations so that shorthands appear before any related longhands they would override, preventing cascade issues in the minified output.
16
- *
17
- * @param {Array} declarations The array of CSS declaration objects to reorder.
18
- * @return {Array} A new array with declarations in the corrected order.
19
- */
20
- function orderDeclarations (declarations) {
21
- const ordered = [...declarations];
22
- const moveBefore = (prop, beforeProp) => {
23
- const fromIndex = ordered.findIndex((declaration) => {
24
- return declaration?.property === prop;
25
- });
26
- const toIndex = ordered.findIndex((declaration) => {
27
- return declaration?.property === beforeProp;
28
- });
29
- if (fromIndex === -1 || toIndex === -1 || fromIndex < toIndex) {
30
- return;
31
- }
32
- const [item] = ordered.splice(fromIndex, 1);
33
- ordered.splice(toIndex, 0, item);
34
- };
35
-
36
- moveBefore('border', 'border-image');
37
- moveBefore('font', 'font-feature-settings');
38
- moveBefore('font', 'font-variant-ligatures');
39
- moveBefore('font', 'font-kerning');
40
- moveBefore('font', 'font-variation-settings');
41
- moveBefore('mask', 'mask-border');
42
- moveBefore('margin', 'margin-top');
43
- moveBefore('margin', 'margin-right');
44
- moveBefore('margin', 'margin-bottom');
45
- moveBefore('margin', 'margin-left');
46
-
47
- return ordered;
48
- }
49
-
50
- /**
51
- * Determines which longhand properties are present and eligible for merging into a given shorthand. Returns null when the required longhands for the shorthand are not all available.
52
- *
53
- * @param {string} shorthand The CSS shorthand property name.
54
- * @param {Array} longhands The expected longhand property names for this shorthand.
55
- * @param {Array} declarations The current array of CSS declaration objects.
56
- * @return {Array|null} The list of longhand names to merge, or null if merging is not possible.
57
- */
58
- function getMergeProps (shorthand, longhands, declarations) {
59
- const presentLonghands = longhands.filter((longhand) => {
60
- return declarations.some((declaration) => {
61
- return declaration.property === longhand;
62
- });
63
- });
64
- if (presentLonghands.length === 0) {
65
- return null;
66
- }
67
- if (shorthand === 'font') {
68
- const hasRequiredFontProps = presentLonghands.includes('font-size') && presentLonghands.includes('font-family');
69
- if (hasRequiredFontProps) {
70
- return presentLonghands;
71
- }
72
- return null;
73
- }
74
- if (shorthand === 'background-position') {
75
- const hasBothAxes = presentLonghands.includes('background-position-x') && presentLonghands.includes('background-position-y');
76
- if (hasBothAxes) {
77
- return presentLonghands;
78
- }
79
- return null;
80
- }
81
- if (shorthand === 'background') {
82
- const hasBackgroundProp = presentLonghands.includes('background-color') || presentLonghands.includes('background-image');
83
- if (hasBackgroundProp) {
84
- return presentLonghands;
85
- }
86
- return null;
87
- }
88
- if (shorthand === 'mask') {
89
- if (presentLonghands.includes('mask-image')) {
90
- return presentLonghands;
91
- }
92
- return null;
93
- }
94
- if (shorthand === 'border-image') {
95
- if (presentLonghands.includes('border-image-source')) {
96
- return presentLonghands;
97
- }
98
- return null;
99
- }
100
- if (shorthand === 'border') {
101
- const hasAllBorderParts = (
102
- presentLonghands.includes('border-width') &&
103
- presentLonghands.includes('border-style') &&
104
- presentLonghands.includes('border-color')
105
- );
106
- if (hasAllBorderParts) {
107
- return ['border-width', 'border-style', 'border-color'];
108
- }
109
- return null;
110
- }
111
- if (shorthand === 'flex') {
112
- const hasAllFlexParts = (
113
- presentLonghands.includes('flex-grow') &&
114
- presentLonghands.includes('flex-shrink') &&
115
- presentLonghands.includes('flex-basis')
116
- );
117
- if (hasAllFlexParts) {
118
- return ['flex-grow', 'flex-shrink', 'flex-basis'];
119
- }
120
- return null;
121
- }
122
- if (presentLonghands.length === longhands.length) {
123
- return longhands;
124
- }
125
- return null;
126
- }
13
+ getMergeProps,
14
+ tryMergeToShorthand
15
+ } from './merge.js';
16
+ import {
17
+ getOverriddenLonghands,
18
+ orderDeclarations
19
+ } from './order.js';
127
20
 
128
21
  /**
129
- * Get all longhands that a shorthand would override.
22
+ * Shorthands that keep their non-important longhands in the output, so a mixed
23
+ * `!important` group still merges the important longhands into the shorthand.
130
24
  *
131
- * @param {string} shorthandProp The CSS shorthand property name.
132
- * @return {Array} A deduplicated array of all longhand property names that the shorthand overrides, including nested longhands.
25
+ * @type {Set<string>}
133
26
  */
134
- function getOverriddenLonghands (shorthandProp) {
135
- const direct = shorthandMap[shorthandProp] || [];
136
- const overrides = shorthandOverrideMap[shorthandProp] || [];
137
- const all = [...direct, ...overrides];
138
- for (const prop of direct) {
139
- const nested = shorthandMap[prop] || [];
140
- all.push(...nested);
141
- }
142
- return [...new Set(all)];
143
- }
27
+ const MIXED_IMPORTANT_SHORTHANDS = new Set(['margin', 'padding', 'inset']);
144
28
 
145
29
  /**
146
- * Check if a value contains var() - don't merge if it does (safest approach).
30
+ * Functions and syntaxes that older browsers do not understand, so an earlier
31
+ * declaration using only classic syntax is kept as a fallback for them.
147
32
  *
148
- * @param {string} value The minified CSS value string to check.
149
- * @return {boolean} True if the value contains a var() with a fallback comma.
33
+ * @type {Array}
150
34
  */
151
- function hasVarFallback (value) {
152
- // Match var() containing a comma (indicating a fallback value)
153
- return /var\([^)]*,/.test(value);
154
- }
35
+ const MODERN_SYNTAX_MARKERS = ['calc(', 'env(', 'var(', '-webkit-'];
155
36
 
156
37
  /**
157
- * Determines whether a value containing var() references can safely be merged into a shorthand. Values with fallback commas or unregistered custom properties are not mergeable.
38
+ * Determines whether a value relies on syntax that older browsers cannot parse,
39
+ * which means a preceding declaration for the same property is an intentional
40
+ * fallback rather than a redundant duplicate.
158
41
  *
159
- * @param {string} value The minified CSS value string to check.
160
- * @param {object} context The minification context with registered custom property data.
161
- * @return {boolean} True if the value is safe to merge into a shorthand.
42
+ * @param {string} value The minified CSS value string.
43
+ * @return {boolean} Whether the value uses modern syntax.
162
44
  */
163
- function canMergeVarValue (value, context) {
164
- // Check if the value contains any var() reference
165
- const containsVar = /var\(/.test(value);
166
- if (!containsVar || hasVarFallback(value)) {
167
- return !hasVarFallback(value);
168
- }
169
- // Extract all var() references with their custom property names
170
- const matches = [...value.matchAll(/var\((--[A-Za-z0-9_-]+)\)/g)];
171
- if (!matches.length) {
172
- return false;
173
- }
174
- return matches.every(([, propertyName]) => {
175
- return context.registeredCustomProperties.has(propertyName);
45
+ function usesModernSyntax (value) {
46
+ return MODERN_SYNTAX_MARKERS.some((marker) => {
47
+ return value.includes(marker);
176
48
  });
177
49
  }
178
50
 
179
51
  /**
180
- * Resolves the background position from a value map. Prefers the combined
181
- * `background-position` property if present, otherwise combines
182
- * `background-position-x` and `background-position-y` into a single value.
183
- *
184
- * @param {Map} valueMap A map of property names to their minified values.
185
- * @return {string|null} The resolved position string, or null if no position data is available.
186
- */
187
- function resolveBackgroundPosition (valueMap) {
188
- const position = valueMap.get('background-position');
189
- if (position) {
190
- return position;
191
- }
192
- const positionX = valueMap.get('background-position-x');
193
- const positionY = valueMap.get('background-position-y');
194
- if (positionX && positionY) {
195
- return positionX + ' ' + positionY;
196
- }
197
- return null;
198
- }
199
-
200
- const BACKGROUND_POSITION_KEYWORDS = new Set(['left', 'center', 'right', 'top', 'bottom']);
201
- const BACKGROUND_REPEAT_KEYWORDS = new Set(['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'space', 'round']);
202
- const BACKGROUND_ATTACHMENT_KEYWORDS = new Set(['scroll', 'fixed', 'local']);
203
- const BACKGROUND_BOX_KEYWORDS = new Set(['border-box', 'padding-box', 'content-box']);
204
-
205
- /**
206
- * Determines whether a token is a background image component such as `none`,
207
- * `url(...)`, or an image-producing function like `linear-gradient(...)`.
208
- *
209
- * @param {string} token The token to classify.
210
- * @return {boolean} Whether the token is a background image token.
211
- */
212
- function isBackgroundImageToken (token) {
213
- if (token === 'none' || token.startsWith('url(')) {
214
- return true;
215
- }
216
- if (!token.endsWith(')')) {
217
- return false;
218
- }
219
- const functionNameMatch = token.match(/^([a-z-]+)\(/i);
220
- if (!functionNameMatch) {
221
- return false;
222
- }
223
- const functionName = functionNameMatch[1].toLowerCase();
224
- return !['calc', 'min', 'max', 'clamp', 'var', 'env', 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color'].includes(functionName);
225
- }
226
-
227
- /**
228
- * Determines whether a token can participate in a background-position value.
229
- *
230
- * @param {string} token The token to classify.
231
- * @return {boolean} Whether the token is a valid background-position token.
232
- */
233
- function isBackgroundPositionToken (token) {
234
- const lowercaseToken = token.toLowerCase();
235
- if (BACKGROUND_POSITION_KEYWORDS.has(lowercaseToken)) {
236
- return true;
237
- }
238
- if (/^[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?$/i.test(token)) {
239
- return true;
240
- }
241
- return /^(?:calc|min|max|clamp|var|env)\(/i.test(token);
242
- }
243
-
244
- /**
245
- * Determines whether a token is a background color component after excluding
246
- * known image, position, repeat, attachment, and box tokens.
247
- *
248
- * @param {string} token The token to classify.
249
- * @return {boolean} Whether the token is a background color token.
250
- */
251
- function isBackgroundColorToken (token) {
252
- if (token === '/' || isBackgroundImageToken(token) || isBackgroundPositionToken(token)) {
253
- return false;
254
- }
255
- const lowercaseToken = token.toLowerCase();
256
- if (BACKGROUND_REPEAT_KEYWORDS.has(lowercaseToken) || BACKGROUND_ATTACHMENT_KEYWORDS.has(lowercaseToken) || BACKGROUND_BOX_KEYWORDS.has(lowercaseToken)) {
257
- return false;
258
- }
259
- return /^#/i.test(token) || /^[a-z-]+$/i.test(token) || /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/i.test(token);
260
- }
261
-
262
- /**
263
- * Splits a token like `linear-gradient(...)100%` into separate image and
264
- * position tokens when the function output is immediately followed by a
265
- * background-position token.
266
- *
267
- * @param {string} token The token to inspect.
268
- * @return {Array} The original token, or separate image/position tokens.
269
- */
270
- function splitAttachedBackgroundImageToken (token) {
271
- const lastCloseParenthesis = token.lastIndexOf(')');
272
- if (lastCloseParenthesis === -1 || lastCloseParenthesis === token.length - 1) {
273
- return [token];
274
- }
275
- const imageToken = token.slice(0, lastCloseParenthesis + 1);
276
- const followingToken = token.slice(lastCloseParenthesis + 1);
277
- if (!isBackgroundImageToken(imageToken) || !isBackgroundPositionToken(followingToken)) {
278
- return [token];
279
- }
280
- return [imageToken, followingToken];
281
- }
282
-
283
- /**
284
- * Splits a single-layer background shorthand into top-level tokens while
285
- * respecting nested parentheses and preserving `/` as its own token.
52
+ * Finds the index of the last declaration matching a predicate, which is the
53
+ * declaration that wins the cascade within a rule.
286
54
  *
287
- * @param {string} value The background shorthand value.
288
- * @return {Array} The extracted top-level tokens.
55
+ * @param {Array} declarations The declarations to search.
56
+ * @param {function(object): boolean} predicate Called with each declaration, returning whether it matches.
57
+ * @return {number} The index of the matching declaration, or -1 when absent.
289
58
  */
290
- function splitBackgroundTokens (value) {
291
- const tokens = [];
292
- let currentToken = '';
293
- let parenthesisDepth = 0;
294
-
295
- for (const character of value) {
296
- if (character === '(') {
297
- parenthesisDepth++;
298
- } else if (character === ')') {
299
- parenthesisDepth--;
59
+ function findLastIndex (declarations, predicate) {
60
+ for (let index = declarations.length - 1; index >= 0; index--) {
61
+ if (predicate(declarations[index])) {
62
+ return index;
300
63
  }
301
-
302
- if (parenthesisDepth === 0 && character === '/') {
303
- if (currentToken) {
304
- tokens.push(currentToken);
305
- currentToken = '';
306
- }
307
- tokens.push('/');
308
- continue;
309
- }
310
-
311
- if (parenthesisDepth === 0 && /\s/.test(character)) {
312
- if (currentToken) {
313
- tokens.push(currentToken);
314
- currentToken = '';
315
- }
316
- continue;
317
- }
318
-
319
- currentToken += character;
320
64
  }
321
-
322
- if (currentToken) {
323
- tokens.push(currentToken);
324
- }
325
-
326
- const normalizedTokens = [];
327
- for (const token of tokens) {
328
- normalizedTokens.push(...splitAttachedBackgroundImageToken(token));
329
- }
330
- return normalizedTokens;
65
+ return -1;
331
66
  }
332
67
 
333
68
  /**
334
- * Extracts the simple image/color base from an existing background shorthand.
335
- * Returns null for shorthands that already contain size, position, or any
336
- * token that cannot be safely reconstructed by the background builder.
69
+ * Removes declarations that a later declaration for the same property makes
70
+ * redundant, including vendor-prefixed duplicates, while keeping intentional
71
+ * fallbacks for values that use modern syntax.
337
72
  *
338
- * @param {string} value The background shorthand value.
339
- * @return {Map|null} A component map for safe reconstruction, or null.
73
+ * @param {Array} declarations The declarations of a single rule, in source order.
74
+ * @return {Array} The surviving declarations, in source order.
340
75
  */
341
- function extractSimpleBackgroundBase (value) {
342
- const tokens = splitBackgroundTokens(value);
343
- const componentMap = new Map();
344
-
345
- for (const token of tokens) {
346
- if (token === '/') {
347
- return null;
348
- }
349
- if (isBackgroundImageToken(token)) {
350
- if (componentMap.has('background-image')) {
351
- return null;
352
- }
353
- componentMap.set('background-image', token);
354
- continue;
355
- }
356
- if (isBackgroundColorToken(token)) {
357
- if (componentMap.has('background-color')) {
358
- return null;
359
- }
360
- componentMap.set('background-color', token);
361
- continue;
362
- }
363
- return null;
364
- }
365
-
366
- return componentMap;
367
- }
368
-
369
- /**
370
- * Serializes normalized background components into a minified background
371
- * shorthand value while omitting default sub-values.
372
- *
373
- * @param {Map} valueMap The normalized background component map.
374
- * @param {string} importantSuffix A trailing `!important` suffix, if needed.
375
- * @return {string|null} The minified background shorthand, or null.
376
- */
377
- function buildBackgroundShorthandValue (valueMap, importantSuffix) {
378
- const color = valueMap.get('background-color');
379
- const image = valueMap.get('background-image');
380
- const repeat = valueMap.get('background-repeat');
381
- const attachment = valueMap.get('background-attachment');
382
- const size = valueMap.get('background-size');
383
- const origin = valueMap.get('background-origin');
384
- const clip = valueMap.get('background-clip');
385
- const position = resolveBackgroundPosition(valueMap);
386
-
76
+ function deduplicateDeclarations (declarations) {
387
77
  const result = [];
388
- if (color && color !== 'transparent') {
389
- result.push(color);
390
- }
391
- if (image && image !== 'none') {
392
- result.push(image);
393
- }
394
- if (position && position !== '0 0' && position !== '0% 0%') {
395
- result.push(position);
396
- }
397
- if (size && size !== 'auto') {
398
- if (position && position !== '0 0' && position !== '0% 0%') {
399
- result.push('/' + size);
400
- } else {
401
- result.push('0 0/' + size);
402
- }
403
- }
404
- if (repeat && repeat !== 'repeat') {
405
- result.push(repeat);
406
- }
407
- if (attachment && attachment !== 'scroll') {
408
- result.push(attachment);
409
- }
410
- const hasNonDefaultOrigin = origin && origin !== 'padding-box';
411
- const hasNonDefaultClip = clip && clip !== 'border-box';
412
- if (hasNonDefaultOrigin && hasNonDefaultClip) {
413
- result.push(origin);
414
- result.push(clip);
415
- } else if (hasNonDefaultOrigin || hasNonDefaultClip) {
416
- if (origin) {
417
- result.push(origin);
418
- }
419
- if (clip) {
420
- result.push(clip);
421
- }
422
- }
423
- if (!result.length) {
424
- return null;
425
- }
426
- return result.join(' ') + importantSuffix;
427
- }
428
-
429
- /**
430
- * Merges later background longhands into an earlier simple background shorthand
431
- * when their combined value can be reconstructed without changing semantics.
432
- *
433
- * @param {Array} declarations The declarations in source order.
434
- * @return {Array} The updated declarations with absorbed longhands.
435
- */
436
- function absorbBackgroundLonghandsIntoShorthand (declarations) {
437
- const backgroundIndex = declarations.findIndex((declaration) => {
438
- return declaration.property === 'background';
439
- });
440
- if (backgroundIndex === -1) {
441
- return declarations;
442
- }
443
-
444
- const backgroundDeclaration = declarations[backgroundIndex];
445
- const backgroundValue = minifyValue(backgroundDeclaration);
446
- const backgroundIsImportant = backgroundValue.includes('!important');
447
- const simpleBase = extractSimpleBackgroundBase(backgroundValue.replace('!important', '').trim());
448
- if (!simpleBase) {
449
- return declarations;
450
- }
451
-
452
- const absorbableProperties = new Set(shorthandMap.background.filter((property) => {
453
- return property !== 'background';
454
- }));
455
- const relevantDeclarations = declarations.filter((declaration, index) => {
456
- return index > backgroundIndex && absorbableProperties.has(declaration.property);
457
- });
458
- if (!relevantDeclarations.length) {
459
- return declarations;
460
- }
461
-
462
- const sharesImportance = relevantDeclarations.every((declaration) => {
463
- return minifyValue(declaration).includes('!important') === backgroundIsImportant;
464
- });
465
- if (!sharesImportance) {
466
- return declarations;
467
- }
468
-
469
- for (const declaration of relevantDeclarations) {
470
- simpleBase.set(declaration.property, minifyValue(declaration).replace('!important', '').trim());
471
- }
472
-
473
- const mergedValue = buildBackgroundShorthandValue(simpleBase, backgroundIsImportant ? '!important' : '');
474
- if (!mergedValue) {
475
- return declarations;
476
- }
477
-
478
- return declarations.flatMap((declaration, index) => {
479
- if (index === backgroundIndex) {
480
- return [{ ...declaration, value: mergedValue }];
481
- }
482
- if (index > backgroundIndex && absorbableProperties.has(declaration.property)) {
483
- return [];
484
- }
485
- return [declaration];
486
- });
487
- }
488
-
489
- /**
490
- * Try to merge longhand properties into a shorthand.
491
- *
492
- * @param {Array} properties The longhand property names to merge.
493
- * @param {Array} declarations The CSS declaration objects to draw values from.
494
- * @param {string} shorthandName The target shorthand property name.
495
- * @param {object} context The minification context with registered custom property data.
496
- * @return {string|null} The merged shorthand value string, or null if merging is not possible.
497
- */
498
- function tryMergeToShorthand (properties, declarations, shorthandName = '', context) {
499
- if (properties.length < 2) {
500
- return null;
501
- }
502
-
503
- const values = properties.map((property) => {
504
- const declaration = declarations.find((candidate) => {
505
- return candidate.property === property;
506
- });
507
- if (declaration) {
508
- return minifyValue(declaration);
509
- }
510
- return null;
511
- });
512
78
 
513
- // If any value is null, can't merge
514
- const hasNullValue = values.some((value) => {
515
- return value === null;
516
- });
517
- if (hasNullValue) {
518
- return null;
519
- }
520
-
521
- // Don't merge if any value has var() with fallback or unknown custom properties
522
- const hasUnmergeableVar = values.some((value) => {
523
- return !canMergeVarValue(value, context);
524
- });
525
- if (hasUnmergeableVar) {
526
- return null;
527
- }
528
-
529
- // Check if all values have the same !important status
530
- const importantFlags = values.map((value) => {
531
- return value.includes('!important');
532
- });
533
- const allImportant = importantFlags.every((flag) => {
534
- return flag;
535
- });
536
- const noneImportant = importantFlags.every((flag) => {
537
- return !flag;
538
- });
539
-
540
- // Allow mixed important flags for margin/padding/inset - merge without !important on shorthand
541
- // For other properties, mixed important flags are not allowed
542
- const allowsMixedImportant = (
543
- shorthandName === 'margin' ||
544
- shorthandName === 'padding' ||
545
- shorthandName === 'inset' ||
546
- shorthandName === 'position-try'
547
- );
548
- if (!allImportant && !noneImportant && !allowsMixedImportant) {
549
- return null;
550
- }
551
-
552
- const cleanValues = values.map((value) => {
553
- return value
554
- .replace('!important', '')
555
- .trim();
556
- });
557
- const valueMap = new Map(properties.map((property, index) => {
558
- return [property, cleanValues[index]];
559
- }));
560
-
561
- // For margin/padding with mixed important, don't use !important on the shorthand
562
- // Only use !important if ALL values have it
563
- const useImportant = allImportant;
564
- const importantSuffix = useImportant ? '!important' : '';
565
-
566
- if (shorthandName === 'position-try') {
567
- const order = valueMap.get('position-try-order');
568
- const fallbacks = valueMap.get('position-try-fallbacks');
569
- if (order === 'normal' && fallbacks) {
570
- return fallbacks + importantSuffix;
571
- }
572
- return null;
573
- }
574
-
575
- if (shorthandName === 'transition') {
576
- const transitionProperty = valueMap.get('transition-property');
577
- const duration = valueMap.get('transition-duration');
578
- const timing = valueMap.get('transition-timing-function');
579
- const delay = valueMap.get('transition-delay');
580
- if (!transitionProperty || !duration) {
581
- return null;
582
- }
583
- const result = [transitionProperty, duration];
584
- if (timing && timing !== 'ease') {
585
- result.push(timing);
586
- }
587
- if (delay && delay !== '0' && delay !== '0s') {
588
- result.push(delay);
589
- }
590
- return result.join(' ') + importantSuffix;
591
- }
592
-
593
- if (shorthandName === 'animation') {
594
- const animationName = valueMap.get('animation-name');
595
- const duration = valueMap.get('animation-duration');
596
- if (!animationName || !duration) {
597
- return null;
598
- }
599
- const result = [animationName, duration];
600
- const timing = valueMap.get('animation-timing-function');
601
- const delay = valueMap.get('animation-delay');
602
- const iteration = valueMap.get('animation-iteration-count');
603
- const direction = valueMap.get('animation-direction');
604
- const fillMode = valueMap.get('animation-fill-mode');
605
- const playState = valueMap.get('animation-play-state');
606
- if (timing && timing !== 'ease') {
607
- result.push(timing);
608
- }
609
- if (delay && delay !== '0' && delay !== '0s') {
610
- result.push(delay);
611
- }
612
- if (iteration && iteration !== '1') {
613
- result.push(iteration);
614
- }
615
- if (direction && direction !== 'normal') {
616
- result.push(direction);
617
- }
618
- if (fillMode && fillMode !== 'none') {
619
- result.push(fillMode);
620
- }
621
- if (playState && playState !== 'running') {
622
- result.push(playState);
623
- }
624
- return result.join(' ') + importantSuffix;
625
- }
626
-
627
- if (shorthandName === 'background-position') {
628
- const positionX = valueMap.get('background-position-x');
629
- const positionY = valueMap.get('background-position-y');
630
- if (!positionX || !positionY) {
631
- return null;
632
- }
633
- return positionX + ' ' + positionY + importantSuffix;
634
- }
635
-
636
- if (shorthandName === 'background') {
637
- return buildBackgroundShorthandValue(valueMap, importantSuffix);
638
- }
639
-
640
- if (shorthandName === 'mask') {
641
- const image = valueMap.get('mask-image');
642
- const repeat = valueMap.get('mask-repeat');
643
- const size = valueMap.get('mask-size');
644
- if (!image) {
645
- return null;
646
- }
647
- let result = image;
648
- if (repeat) {
649
- result += ' ' + repeat;
650
- }
651
- if (size) {
652
- result += '/' + size;
653
- }
654
- return result + importantSuffix;
655
- }
656
-
657
- if (shorthandName === 'border-image') {
658
- const source = valueMap.get('border-image-source');
659
- const slice = valueMap.get('border-image-slice');
660
- const repeat = valueMap.get('border-image-repeat');
661
- if (!source) {
662
- return null;
663
- }
664
- const result = [source];
665
- if (slice) {
666
- result.push(slice);
667
- }
668
- if (repeat) {
669
- result.push(repeat);
670
- }
671
- return result.join(' ') + importantSuffix;
672
- }
673
-
674
- if (shorthandName === 'text-decoration') {
675
- const line = valueMap.get('text-decoration-line');
676
- const style = valueMap.get('text-decoration-style');
677
- const color = valueMap.get('text-decoration-color');
678
- if (!line) {
679
- return null;
680
- }
681
- const result = [line];
682
- if (style && style !== 'solid') {
683
- result.push(style);
684
- }
685
- if (color && color !== 'currentcolor') {
686
- result.push(color);
687
- }
688
- return result.join(' ') + importantSuffix;
689
- }
690
-
691
- if (shorthandName === 'columns') {
692
- return cleanValues.join(' ') + importantSuffix;
693
- }
694
-
695
- if (shorthandName === 'list-style') {
696
- const position = valueMap.get('list-style-position');
697
- const image = valueMap.get('list-style-image');
698
- const type = valueMap.get('list-style-type');
699
- const result = [];
700
- if (position && position !== 'outside') {
701
- result.push(position);
702
- }
703
- if (image && image !== 'none') {
704
- result.push(image);
705
- }
706
- if (type && type !== 'disc') {
707
- result.push(type);
708
- }
709
- const joined = result.join(' ') || 'inside';
710
- return joined + importantSuffix;
711
- }
712
-
713
- if (shorthandName === 'font') {
714
- const fontSize = valueMap.get('font-size');
715
- const fontFamily = valueMap.get('font-family');
716
- if (!fontSize || !fontFamily) {
717
- return null;
718
- }
719
- const result = [];
720
- const fontStyle = valueMap.get('font-style');
721
- const fontWeight = valueMap.get('font-weight');
722
- const lineHeight = valueMap.get('line-height');
723
- if (fontStyle && fontStyle !== 'normal') {
724
- result.push(fontStyle);
725
- }
726
- if (fontWeight && fontWeight !== '400' && fontWeight !== 'normal') {
727
- result.push(fontWeight);
728
- }
729
- if (lineHeight) {
730
- result.push(fontSize + '/' + lineHeight);
731
- } else {
732
- result.push(fontSize);
733
- }
734
- result.push(fontFamily);
735
- return result.join(' ') + importantSuffix;
736
- }
737
-
738
- if (shorthandName === 'flex') {
739
- const grow = valueMap.get('flex-grow');
740
- const shrink = valueMap.get('flex-shrink');
741
- const basis = valueMap.get('flex-basis');
742
- if (!grow || !shrink || !basis) {
743
- return null;
744
- }
745
- return [grow, shrink, basis].join(' ') + importantSuffix;
746
- }
747
-
748
- // Build shorthand value
749
- if (properties.length === 2) {
750
- // For 2-value shorthands (logical properties)
751
- if (cleanValues[0] === cleanValues[1]) {
752
- return cleanValues[0] + importantSuffix;
753
- }
754
- return cleanValues.join(' ') + importantSuffix;
755
- }
756
-
757
- if (properties.length === 4) {
758
- // For 4-value shorthands (margin, padding, inset, etc.)
759
- // Collapse redundant values: top right bottom left → fewer values when sides match
760
- return collapseShorthandParts([...cleanValues]).join(' ') + importantSuffix;
761
- }
762
-
763
- // For border shorthand (3 values: width, style, color)
764
- const isBorderLikeShorthand = (
765
- properties.length === 3 &&
766
- (properties.includes('border-width') || properties.includes('outline-width')) &&
767
- properties.some((property) => {
768
- // Check if one longhand ends with "-style" (e.g. border-style, outline-style)
769
- return /-style$/.test(property);
770
- }) &&
771
- properties.some((property) => {
772
- // Check if one longhand ends with "-color" (e.g. border-color, outline-color)
773
- return /-color$/.test(property);
774
- })
775
- );
776
- if (isBorderLikeShorthand) {
777
- return cleanValues.join(' ') + importantSuffix;
778
- }
779
-
780
- return null;
781
- }
782
-
783
- /**
784
- * Deduplicates, merges, and optimizes CSS declarations within a rule block. Removes overridden longhands, collapses longhands into shorthands, and preserves intentional fallbacks.
785
- *
786
- * @param {Array} declarations The array of CSS declaration objects to process.
787
- * @param {object} context The minification context with registered custom property data.
788
- * @return {Array} A new array of optimized and reordered declaration objects.
789
- */
790
- function processDeclarations (declarations, context) {
791
- let result = [];
792
-
793
- for (let declaration of declarations) {
79
+ for (const declaration of declarations) {
794
80
  if (declaration.type === 'rule' || declaration.type === 'media') {
795
81
  result.push(declaration);
796
82
  continue;
@@ -805,34 +91,26 @@ function processDeclarations (declarations, context) {
805
91
  continue;
806
92
  }
807
93
 
808
- let minifiedValue = minifyValue(declaration);
94
+ const minifiedValue = minifyValue(declaration);
809
95
 
810
- let previousIndex = -1;
811
- for (let i = result.length - 1; i >= 0; i--) {
812
- if (result[i].property === propertyName) {
813
- previousIndex = i;
814
- break;
815
- }
816
- }
96
+ let previousIndex = findLastIndex(result, (candidate) => {
97
+ return candidate.property === propertyName;
98
+ });
817
99
 
818
- // Also check if there's a prefixed version we can replace
100
+ // An unprefixed property with the same value also replaces its prefixed form
819
101
  let prefixedIndex = -1;
820
102
  if (!propertyName.startsWith('-')) {
821
- for (let i = result.length - 1; i >= 0; i--) {
822
- const isPrefixedMatch = (
823
- result[i].property &&
824
- result[i].property.endsWith(propertyName) &&
825
- result[i].property.startsWith('-')
103
+ prefixedIndex = findLastIndex(result, (candidate) => {
104
+ return (
105
+ candidate.property &&
106
+ candidate.property.endsWith(propertyName) &&
107
+ candidate.property.startsWith('-')
826
108
  );
827
- if (isPrefixedMatch) {
828
- prefixedIndex = i;
829
- break;
830
- }
831
- }
109
+ });
832
110
  }
833
111
 
834
112
  if (prefixedIndex !== -1) {
835
- let prefixedValue = minifyValue(result[prefixedIndex]);
113
+ const prefixedValue = minifyValue(result[prefixedIndex]);
836
114
  if (minifiedValue === prefixedValue) {
837
115
  result.splice(prefixedIndex, 1);
838
116
  // Re-adjust previousIndex if we removed an item before it
@@ -850,19 +128,7 @@ function processDeclarations (declarations, context) {
850
128
  }
851
129
 
852
130
  // Fallbacks for custom variables or older browser functions should be kept
853
- const currentUsesModernSyntax = (
854
- minifiedValue.includes('calc(') ||
855
- minifiedValue.includes('env(') ||
856
- minifiedValue.includes('var(') ||
857
- minifiedValue.includes('-webkit-')
858
- );
859
- const previousUsesModernSyntax = (
860
- previousValue.includes('calc(') ||
861
- previousValue.includes('env(') ||
862
- previousValue.includes('var(') ||
863
- previousValue.includes('-webkit-')
864
- );
865
- if (currentUsesModernSyntax && !previousUsesModernSyntax) {
131
+ if (usesModernSyntax(minifiedValue) && !usesModernSyntax(previousValue)) {
866
132
  result.push(declaration);
867
133
  continue;
868
134
  }
@@ -874,37 +140,115 @@ function processDeclarations (declarations, context) {
874
140
  result.push(declaration);
875
141
  }
876
142
 
877
- // Handle shorthand merging
143
+ return result;
144
+ }
878
145
 
879
- // First, remove longhand properties that are overridden by existing shorthands
146
+ /**
147
+ * Removes longhand declarations that appear before a shorthand which resets
148
+ * them, since the shorthand discards whatever the earlier longhand set.
149
+ *
150
+ * @param {Array} declarations The declarations of a single rule, in source order.
151
+ * @return {Array} The declarations without the overridden longhands.
152
+ */
153
+ function removeLonghandsOverriddenByShorthands (declarations) {
880
154
  const propertiesToRemove = new Set();
881
- for (let i = 0; i < result.length; i++) {
882
- const declaration = result[i];
883
- if (declaration.property && shorthandMap[declaration.property]) {
884
- // This is a shorthand, check if any longhands come before it
885
- const overridden = getOverriddenLonghands(declaration.property);
886
- for (const longhandProperty of overridden) {
887
- const longhandIndex = result.findIndex((candidate, index) => {
888
- return candidate.property === longhandProperty && index < i;
889
- });
890
- if (longhandIndex !== -1) {
891
- propertiesToRemove.add(longhandProperty);
892
- }
155
+
156
+ declarations.forEach((declaration, shorthandIndex) => {
157
+ if (!declaration.property || !shorthandMap[declaration.property]) {
158
+ return;
159
+ }
160
+ const overridden = getOverriddenLonghands(declaration.property);
161
+ for (const longhandProperty of overridden) {
162
+ const longhandIndex = declarations.findIndex((candidate, index) => {
163
+ return candidate.property === longhandProperty && index < shorthandIndex;
164
+ });
165
+ if (longhandIndex !== -1) {
166
+ propertiesToRemove.add(longhandProperty);
893
167
  }
894
168
  }
895
- }
169
+ });
896
170
 
897
- result = result.filter((declaration) => {
171
+ return declarations.filter((declaration) => {
898
172
  return !propertiesToRemove.has(declaration.property);
899
173
  });
900
- result = absorbBackgroundLonghandsIntoShorthand(result);
174
+ }
175
+
176
+ /**
177
+ * Collects the longhand properties that a newly built shorthand replaces. For
178
+ * shorthands that tolerate a mixed `!important` group, the important longhands
179
+ * stay in the output so they keep winning over the shorthand.
180
+ *
181
+ * @param {string} shorthandName The shorthand that was built.
182
+ * @param {Array} mergeableProperties The longhand property names the shorthand covers.
183
+ * @param {Array} relevantDeclarations The declarations the shorthand was built from.
184
+ * @return {Array} The longhand property names to drop.
185
+ */
186
+ function getReplacedLonghands (shorthandName, mergeableProperties, relevantDeclarations) {
187
+ const importantFlags = relevantDeclarations.map((declaration) => {
188
+ return minifyValue(declaration).includes('!important');
189
+ });
190
+ const hasMixedImportant = (
191
+ importantFlags.includes(true) &&
192
+ importantFlags.includes(false)
193
+ );
194
+ if (!hasMixedImportant || !MIXED_IMPORTANT_SHORTHANDS.has(shorthandName)) {
195
+ return mergeableProperties;
196
+ }
197
+ return mergeableProperties.filter((property) => {
198
+ const declaration = relevantDeclarations.find((candidate) => {
199
+ return candidate.property === property;
200
+ });
201
+ return declaration && !minifyValue(declaration).includes('!important');
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Drops shorthands whose longhands are entirely consumed by a higher-level
207
+ * shorthand built in the same pass. For example, `background-position` (x + y)
208
+ * is redundant once `background` has consumed those same longhands.
209
+ *
210
+ * @param {Array} builtDeclarations The shorthand declarations built in one pass.
211
+ * @return {Array} The shorthand declarations that are not subsumed.
212
+ */
213
+ function removeSubsumedShorthands (builtDeclarations) {
214
+ return builtDeclarations.filter((declaration) => {
215
+ const longhands = shorthandMap[declaration.property];
216
+ if (!longhands) {
217
+ return true;
218
+ }
219
+ const isSubsumedByOtherShorthand = builtDeclarations.some((other) => {
220
+ if (other === declaration) {
221
+ return false;
222
+ }
223
+ const otherLonghands = shorthandMap[other.property];
224
+ if (!otherLonghands) {
225
+ return false;
226
+ }
227
+ return longhands.every((longhand) => {
228
+ return otherLonghands.includes(longhand);
229
+ });
230
+ });
231
+ return !isSubsumedByOtherShorthand;
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Builds every shorthand that the remaining longhands support, repeating until
237
+ * no further shorthand can be created, so that shorthands built from other
238
+ * shorthands (such as `border` from `border-width`) are also collapsed.
239
+ *
240
+ * @param {Array} declarations The declarations of a single rule.
241
+ * @param {object} context The minification context with registered custom property data.
242
+ * @return {Array} The declarations with longhands merged into shorthands.
243
+ */
244
+ function mergeLonghandsIntoShorthands (declarations, context) {
245
+ let result = declarations;
246
+ let builtShorthand = true;
901
247
 
902
- // Try to merge remaining longhands into shorthands
903
- let changed = true;
904
- while (changed) {
905
- changed = false;
906
- const mergedProperties = new Set();
907
- const newDeclarations = [];
248
+ while (builtShorthand) {
249
+ builtShorthand = false;
250
+ const replacedProperties = new Set();
251
+ const builtDeclarations = [];
908
252
 
909
253
  for (const [shorthand, longhands] of Object.entries(shorthandMap)) {
910
254
  const shorthandAlreadyExists = result.some((declaration) => {
@@ -926,69 +270,44 @@ function processDeclarations (declarations, context) {
926
270
  continue;
927
271
  }
928
272
 
929
- newDeclarations.push({ property: shorthand, value: mergedValue });
930
- const isMarginPaddingInset = (
931
- shorthand === 'margin' ||
932
- shorthand === 'padding' ||
933
- shorthand === 'inset'
934
- );
935
- const someAreImportant = relevantDeclarations.some((declaration) => {
936
- return minifyValue(declaration).includes('!important');
273
+ builtDeclarations.push({
274
+ property: shorthand,
275
+ value: mergedValue,
276
+ isAssembledShorthand: true
937
277
  });
938
- const allAreImportant = relevantDeclarations.every((declaration) => {
939
- return minifyValue(declaration).includes('!important');
940
- });
941
- const hasMixedImportant = someAreImportant && !allAreImportant;
942
-
943
- if (isMarginPaddingInset && hasMixedImportant) {
944
- for (const property of mergeableProperties) {
945
- const declaration = relevantDeclarations.find((candidate) => {
946
- return candidate.property === property;
947
- });
948
- if (declaration && !minifyValue(declaration).includes('!important')) {
949
- mergedProperties.add(property);
950
- }
951
- }
952
- } else {
953
- for (const property of mergeableProperties) {
954
- mergedProperties.add(property);
955
- }
278
+ const replacedLonghands = getReplacedLonghands(shorthand, mergeableProperties, relevantDeclarations);
279
+ for (const property of replacedLonghands) {
280
+ replacedProperties.add(property);
956
281
  }
957
282
  }
958
283
 
959
- if (newDeclarations.length) {
960
- // Filter out intermediate shorthands whose longhands are entirely
961
- // consumed by a higher-level shorthand created in the same iteration.
962
- // For example, background-position (x + y) is redundant when
963
- // background already consumed those same longhands.
964
- const filteredDeclarations = newDeclarations.filter((declaration) => {
965
- const longhands = shorthandMap[declaration.property];
966
- if (!longhands) {
967
- return true;
968
- }
969
- const isSubsumedByOtherShorthand = newDeclarations.some((other) => {
970
- if (other === declaration) {
971
- return false;
972
- }
973
- const otherLonghands = shorthandMap[other.property];
974
- if (!otherLonghands) {
975
- return false;
976
- }
977
- return longhands.every((longhand) => {
978
- return otherLonghands.includes(longhand);
979
- });
980
- });
981
- return !isSubsumedByOtherShorthand;
284
+ if (builtDeclarations.length) {
285
+ const keptDeclarations = result.filter((declaration) => {
286
+ return !replacedProperties.has(declaration.property);
982
287
  });
983
-
984
- result = result.filter((declaration) => {
985
- return !mergedProperties.has(declaration.property);
986
- });
987
- result = [...result, ...filteredDeclarations];
988
- changed = true;
288
+ result = [...keptDeclarations, ...removeSubsumedShorthands(builtDeclarations)];
289
+ builtShorthand = true;
989
290
  }
990
291
  }
991
292
 
293
+ return result;
294
+ }
295
+
296
+ /**
297
+ * Deduplicates, merges, and optimizes CSS declarations within a rule block. Removes overridden longhands, collapses longhands into shorthands, and preserves intentional fallbacks.
298
+ *
299
+ * @param {Array} declarations The array of CSS declaration objects to process.
300
+ * @param {object} context The minification context with registered custom property data.
301
+ * @return {Array} A new array of optimized and reordered declaration objects.
302
+ */
303
+ function processDeclarations (declarations, context) {
304
+ let result = deduplicateDeclarations(declarations);
305
+ result = removeLonghandsOverriddenByShorthands(result);
306
+ result = absorbBackgroundLonghandsIntoShorthand(result);
307
+ result = mergeLonghandsIntoShorthands(result, context);
308
+ result = hoistCssWideKeywordsIntoShorthands(result);
309
+ result = collapseBorderTrioWithPerEdgeColor(result);
310
+
992
311
  return orderDeclarations(result);
993
312
  }
994
313