@thejaredwilcurt/csslop 0.0.20 → 0.0.21

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,78 @@
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';
9
11
  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
- }
12
+ getMergeProps,
13
+ tryMergeToShorthand
14
+ } from './merge.js';
15
+ import {
16
+ getOverriddenLonghands,
17
+ orderDeclarations
18
+ } from './order.js';
127
19
 
128
20
  /**
129
- * Get all longhands that a shorthand would override.
21
+ * Shorthands that keep their non-important longhands in the output, so a mixed
22
+ * `!important` group still merges the important longhands into the shorthand.
130
23
  *
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.
24
+ * @type {Set<string>}
133
25
  */
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
- }
26
+ const MIXED_IMPORTANT_SHORTHANDS = new Set(['margin', 'padding', 'inset']);
144
27
 
145
28
  /**
146
- * Check if a value contains var() - don't merge if it does (safest approach).
29
+ * Functions and syntaxes that older browsers do not understand, so an earlier
30
+ * declaration using only classic syntax is kept as a fallback for them.
147
31
  *
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.
32
+ * @type {Array}
150
33
  */
151
- function hasVarFallback (value) {
152
- // Match var() containing a comma (indicating a fallback value)
153
- return /var\([^)]*,/.test(value);
154
- }
34
+ const MODERN_SYNTAX_MARKERS = ['calc(', 'env(', 'var(', '-webkit-'];
155
35
 
156
36
  /**
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.
37
+ * Determines whether a value relies on syntax that older browsers cannot parse,
38
+ * which means a preceding declaration for the same property is an intentional
39
+ * fallback rather than a redundant duplicate.
158
40
  *
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.
41
+ * @param {string} value The minified CSS value string.
42
+ * @return {boolean} Whether the value uses modern syntax.
162
43
  */
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);
44
+ function usesModernSyntax (value) {
45
+ return MODERN_SYNTAX_MARKERS.some((marker) => {
46
+ return value.includes(marker);
176
47
  });
177
48
  }
178
49
 
179
50
  /**
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.
51
+ * Finds the index of the last declaration matching a predicate, which is the
52
+ * declaration that wins the cascade within a rule.
286
53
  *
287
- * @param {string} value The background shorthand value.
288
- * @return {Array} The extracted top-level tokens.
54
+ * @param {Array} declarations The declarations to search.
55
+ * @param {function(object): boolean} predicate Called with each declaration, returning whether it matches.
56
+ * @return {number} The index of the matching declaration, or -1 when absent.
289
57
  */
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--;
58
+ function findLastIndex (declarations, predicate) {
59
+ for (let index = declarations.length - 1; index >= 0; index--) {
60
+ if (predicate(declarations[index])) {
61
+ return index;
300
62
  }
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
63
  }
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;
64
+ return -1;
331
65
  }
332
66
 
333
67
  /**
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.
68
+ * Removes declarations that a later declaration for the same property makes
69
+ * redundant, including vendor-prefixed duplicates, while keeping intentional
70
+ * fallbacks for values that use modern syntax.
337
71
  *
338
- * @param {string} value The background shorthand value.
339
- * @return {Map|null} A component map for safe reconstruction, or null.
72
+ * @param {Array} declarations The declarations of a single rule, in source order.
73
+ * @return {Array} The surviving declarations, in source order.
340
74
  */
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
-
75
+ function deduplicateDeclarations (declarations) {
387
76
  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
77
 
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) {
78
+ for (const declaration of declarations) {
794
79
  if (declaration.type === 'rule' || declaration.type === 'media') {
795
80
  result.push(declaration);
796
81
  continue;
@@ -805,34 +90,26 @@ function processDeclarations (declarations, context) {
805
90
  continue;
806
91
  }
807
92
 
808
- let minifiedValue = minifyValue(declaration);
93
+ const minifiedValue = minifyValue(declaration);
809
94
 
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
- }
95
+ let previousIndex = findLastIndex(result, (candidate) => {
96
+ return candidate.property === propertyName;
97
+ });
817
98
 
818
- // Also check if there's a prefixed version we can replace
99
+ // An unprefixed property with the same value also replaces its prefixed form
819
100
  let prefixedIndex = -1;
820
101
  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('-')
102
+ prefixedIndex = findLastIndex(result, (candidate) => {
103
+ return (
104
+ candidate.property &&
105
+ candidate.property.endsWith(propertyName) &&
106
+ candidate.property.startsWith('-')
826
107
  );
827
- if (isPrefixedMatch) {
828
- prefixedIndex = i;
829
- break;
830
- }
831
- }
108
+ });
832
109
  }
833
110
 
834
111
  if (prefixedIndex !== -1) {
835
- let prefixedValue = minifyValue(result[prefixedIndex]);
112
+ const prefixedValue = minifyValue(result[prefixedIndex]);
836
113
  if (minifiedValue === prefixedValue) {
837
114
  result.splice(prefixedIndex, 1);
838
115
  // Re-adjust previousIndex if we removed an item before it
@@ -850,19 +127,7 @@ function processDeclarations (declarations, context) {
850
127
  }
851
128
 
852
129
  // 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) {
130
+ if (usesModernSyntax(minifiedValue) && !usesModernSyntax(previousValue)) {
866
131
  result.push(declaration);
867
132
  continue;
868
133
  }
@@ -874,37 +139,115 @@ function processDeclarations (declarations, context) {
874
139
  result.push(declaration);
875
140
  }
876
141
 
877
- // Handle shorthand merging
142
+ return result;
143
+ }
878
144
 
879
- // First, remove longhand properties that are overridden by existing shorthands
145
+ /**
146
+ * Removes longhand declarations that appear before a shorthand which resets
147
+ * them, since the shorthand discards whatever the earlier longhand set.
148
+ *
149
+ * @param {Array} declarations The declarations of a single rule, in source order.
150
+ * @return {Array} The declarations without the overridden longhands.
151
+ */
152
+ function removeLonghandsOverriddenByShorthands (declarations) {
880
153
  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
- }
154
+
155
+ declarations.forEach((declaration, shorthandIndex) => {
156
+ if (!declaration.property || !shorthandMap[declaration.property]) {
157
+ return;
158
+ }
159
+ const overridden = getOverriddenLonghands(declaration.property);
160
+ for (const longhandProperty of overridden) {
161
+ const longhandIndex = declarations.findIndex((candidate, index) => {
162
+ return candidate.property === longhandProperty && index < shorthandIndex;
163
+ });
164
+ if (longhandIndex !== -1) {
165
+ propertiesToRemove.add(longhandProperty);
893
166
  }
894
167
  }
895
- }
168
+ });
896
169
 
897
- result = result.filter((declaration) => {
170
+ return declarations.filter((declaration) => {
898
171
  return !propertiesToRemove.has(declaration.property);
899
172
  });
900
- result = absorbBackgroundLonghandsIntoShorthand(result);
173
+ }
174
+
175
+ /**
176
+ * Collects the longhand properties that a newly built shorthand replaces. For
177
+ * shorthands that tolerate a mixed `!important` group, the important longhands
178
+ * stay in the output so they keep winning over the shorthand.
179
+ *
180
+ * @param {string} shorthandName The shorthand that was built.
181
+ * @param {Array} mergeableProperties The longhand property names the shorthand covers.
182
+ * @param {Array} relevantDeclarations The declarations the shorthand was built from.
183
+ * @return {Array} The longhand property names to drop.
184
+ */
185
+ function getReplacedLonghands (shorthandName, mergeableProperties, relevantDeclarations) {
186
+ const importantFlags = relevantDeclarations.map((declaration) => {
187
+ return minifyValue(declaration).includes('!important');
188
+ });
189
+ const hasMixedImportant = (
190
+ importantFlags.includes(true) &&
191
+ importantFlags.includes(false)
192
+ );
193
+ if (!hasMixedImportant || !MIXED_IMPORTANT_SHORTHANDS.has(shorthandName)) {
194
+ return mergeableProperties;
195
+ }
196
+ return mergeableProperties.filter((property) => {
197
+ const declaration = relevantDeclarations.find((candidate) => {
198
+ return candidate.property === property;
199
+ });
200
+ return declaration && !minifyValue(declaration).includes('!important');
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Drops shorthands whose longhands are entirely consumed by a higher-level
206
+ * shorthand built in the same pass. For example, `background-position` (x + y)
207
+ * is redundant once `background` has consumed those same longhands.
208
+ *
209
+ * @param {Array} builtDeclarations The shorthand declarations built in one pass.
210
+ * @return {Array} The shorthand declarations that are not subsumed.
211
+ */
212
+ function removeSubsumedShorthands (builtDeclarations) {
213
+ return builtDeclarations.filter((declaration) => {
214
+ const longhands = shorthandMap[declaration.property];
215
+ if (!longhands) {
216
+ return true;
217
+ }
218
+ const isSubsumedByOtherShorthand = builtDeclarations.some((other) => {
219
+ if (other === declaration) {
220
+ return false;
221
+ }
222
+ const otherLonghands = shorthandMap[other.property];
223
+ if (!otherLonghands) {
224
+ return false;
225
+ }
226
+ return longhands.every((longhand) => {
227
+ return otherLonghands.includes(longhand);
228
+ });
229
+ });
230
+ return !isSubsumedByOtherShorthand;
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Builds every shorthand that the remaining longhands support, repeating until
236
+ * no further shorthand can be created, so that shorthands built from other
237
+ * shorthands (such as `border` from `border-width`) are also collapsed.
238
+ *
239
+ * @param {Array} declarations The declarations of a single rule.
240
+ * @param {object} context The minification context with registered custom property data.
241
+ * @return {Array} The declarations with longhands merged into shorthands.
242
+ */
243
+ function mergeLonghandsIntoShorthands (declarations, context) {
244
+ let result = declarations;
245
+ let builtShorthand = true;
901
246
 
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 = [];
247
+ while (builtShorthand) {
248
+ builtShorthand = false;
249
+ const replacedProperties = new Set();
250
+ const builtDeclarations = [];
908
251
 
909
252
  for (const [shorthand, longhands] of Object.entries(shorthandMap)) {
910
253
  const shorthandAlreadyExists = result.some((declaration) => {
@@ -926,69 +269,43 @@ function processDeclarations (declarations, context) {
926
269
  continue;
927
270
  }
928
271
 
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');
272
+ builtDeclarations.push({
273
+ property: shorthand,
274
+ value: mergedValue,
275
+ isAssembledShorthand: true
937
276
  });
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
- }
277
+ const replacedLonghands = getReplacedLonghands(shorthand, mergeableProperties, relevantDeclarations);
278
+ for (const property of replacedLonghands) {
279
+ replacedProperties.add(property);
956
280
  }
957
281
  }
958
282
 
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;
283
+ if (builtDeclarations.length) {
284
+ const keptDeclarations = result.filter((declaration) => {
285
+ return !replacedProperties.has(declaration.property);
982
286
  });
983
-
984
- result = result.filter((declaration) => {
985
- return !mergedProperties.has(declaration.property);
986
- });
987
- result = [...result, ...filteredDeclarations];
988
- changed = true;
287
+ result = [...keptDeclarations, ...removeSubsumedShorthands(builtDeclarations)];
288
+ builtShorthand = true;
989
289
  }
990
290
  }
991
291
 
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Deduplicates, merges, and optimizes CSS declarations within a rule block. Removes overridden longhands, collapses longhands into shorthands, and preserves intentional fallbacks.
297
+ *
298
+ * @param {Array} declarations The array of CSS declaration objects to process.
299
+ * @param {object} context The minification context with registered custom property data.
300
+ * @return {Array} A new array of optimized and reordered declaration objects.
301
+ */
302
+ function processDeclarations (declarations, context) {
303
+ let result = deduplicateDeclarations(declarations);
304
+ result = removeLonghandsOverriddenByShorthands(result);
305
+ result = absorbBackgroundLonghandsIntoShorthand(result);
306
+ result = mergeLonghandsIntoShorthands(result, context);
307
+ result = collapseBorderTrioWithPerEdgeColor(result);
308
+
992
309
  return orderDeclarations(result);
993
310
  }
994
311