@thejaredwilcurt/csslop 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/declarations/config.js +2 -1
- package/src/declarations/process.js +352 -25
- package/src/rules/optimize.js +7 -3
- package/src/value/gradients.js +335 -4
- package/src/value/minify.js +4 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.13",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@codemirror/autocomplete": "^6.20.3",
|
|
28
28
|
"@codemirror/lang-css": "^6.3.1",
|
|
29
|
-
"@codemirror/view": "^6.43.
|
|
29
|
+
"@codemirror/view": "^6.43.6",
|
|
30
30
|
"@eslint/js": "^10.0.1",
|
|
31
31
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
32
32
|
"codemirror": "^6.0.2",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
37
37
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
38
38
|
"eslint-plugin-import-x": "^4.17.0",
|
|
39
|
-
"eslint-plugin-jsdoc": "^63.0.
|
|
39
|
+
"eslint-plugin-jsdoc": "^63.0.12",
|
|
40
40
|
"fflate": "^0.8.3",
|
|
41
41
|
"globals": "^17.7.0",
|
|
42
42
|
"pretty-ms": "^9.3.0",
|
|
@@ -20,7 +20,8 @@ const shorthandMap = {
|
|
|
20
20
|
'border-right': ['border-right-width', 'border-right-style', 'border-right-color'],
|
|
21
21
|
'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'],
|
|
22
22
|
'border-left': ['border-left-width', 'border-left-style', 'border-left-color'],
|
|
23
|
-
|
|
23
|
+
'background-position': ['background-position-x', 'background-position-y'],
|
|
24
|
+
background: ['background-color', 'background-image', 'background-repeat', 'background-position', 'background-position-x', 'background-position-y', 'background-attachment', 'background-size', 'background-origin', 'background-clip'],
|
|
24
25
|
'text-decoration': ['text-decoration-line', 'text-decoration-style', 'text-decoration-color'],
|
|
25
26
|
'place-items': ['align-items', 'justify-items'],
|
|
26
27
|
'place-content': ['align-content', 'justify-content'],
|
|
@@ -71,6 +71,13 @@ function getMergeProps (shorthand, longhands, declarations) {
|
|
|
71
71
|
}
|
|
72
72
|
return null;
|
|
73
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
|
+
}
|
|
74
81
|
if (shorthand === 'background') {
|
|
75
82
|
const hasBackgroundProp = presentLonghands.includes('background-color') || presentLonghands.includes('background-image');
|
|
76
83
|
if (hasBackgroundProp) {
|
|
@@ -169,6 +176,316 @@ function canMergeVarValue (value, context) {
|
|
|
169
176
|
});
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
/**
|
|
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.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} value The background shorthand value.
|
|
288
|
+
* @return {Array} The extracted top-level tokens.
|
|
289
|
+
*/
|
|
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--;
|
|
300
|
+
}
|
|
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
|
+
}
|
|
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;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
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.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} value The background shorthand value.
|
|
339
|
+
* @return {Map|null} A component map for safe reconstruction, or null.
|
|
340
|
+
*/
|
|
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
|
+
|
|
387
|
+
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
|
+
|
|
172
489
|
/**
|
|
173
490
|
* Try to merge longhand properties into a shorthand.
|
|
174
491
|
*
|
|
@@ -307,32 +624,17 @@ function tryMergeToShorthand (properties, declarations, shorthandName = '', cont
|
|
|
307
624
|
return result.join(' ') + importantSuffix;
|
|
308
625
|
}
|
|
309
626
|
|
|
310
|
-
if (shorthandName === 'background') {
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
const position = valueMap.get('background-position');
|
|
315
|
-
const attachment = valueMap.get('background-attachment');
|
|
316
|
-
const result = [];
|
|
317
|
-
if (color && color !== 'transparent') {
|
|
318
|
-
result.push(color);
|
|
319
|
-
}
|
|
320
|
-
if (image && image !== 'none') {
|
|
321
|
-
result.push(image);
|
|
322
|
-
}
|
|
323
|
-
if (position && position !== '0 0' && position !== '0% 0%') {
|
|
324
|
-
result.push(position);
|
|
325
|
-
}
|
|
326
|
-
if (repeat && repeat !== 'repeat') {
|
|
327
|
-
result.push(repeat);
|
|
328
|
-
}
|
|
329
|
-
if (attachment && attachment !== 'scroll') {
|
|
330
|
-
result.push(attachment);
|
|
331
|
-
}
|
|
332
|
-
if (!result.length) {
|
|
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) {
|
|
333
631
|
return null;
|
|
334
632
|
}
|
|
335
|
-
return
|
|
633
|
+
return positionX + ' ' + positionY + importantSuffix;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (shorthandName === 'background') {
|
|
637
|
+
return buildBackgroundShorthandValue(valueMap, importantSuffix);
|
|
336
638
|
}
|
|
337
639
|
|
|
338
640
|
if (shorthandName === 'mask') {
|
|
@@ -595,6 +897,7 @@ function processDeclarations (declarations, context) {
|
|
|
595
897
|
result = result.filter((declaration) => {
|
|
596
898
|
return !propertiesToRemove.has(declaration.property);
|
|
597
899
|
});
|
|
900
|
+
result = absorbBackgroundLonghandsIntoShorthand(result);
|
|
598
901
|
|
|
599
902
|
// Try to merge remaining longhands into shorthands
|
|
600
903
|
let changed = true;
|
|
@@ -654,10 +957,34 @@ function processDeclarations (declarations, context) {
|
|
|
654
957
|
}
|
|
655
958
|
|
|
656
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;
|
|
982
|
+
});
|
|
983
|
+
|
|
657
984
|
result = result.filter((declaration) => {
|
|
658
985
|
return !mergedProperties.has(declaration.property);
|
|
659
986
|
});
|
|
660
|
-
result = [...result, ...
|
|
987
|
+
result = [...result, ...filteredDeclarations];
|
|
661
988
|
changed = true;
|
|
662
989
|
}
|
|
663
990
|
}
|
package/src/rules/optimize.js
CHANGED
|
@@ -456,8 +456,7 @@ function mergeSelectorRules (rules) {
|
|
|
456
456
|
if (rule.type === 'rule') {
|
|
457
457
|
const selectorKey = rule.selectors ?
|
|
458
458
|
rule.selectors.map((selector) => {
|
|
459
|
-
|
|
460
|
-
return selector.trim().replace(/\s+/g, ' ');
|
|
459
|
+
return normalizeSelector(selector);
|
|
461
460
|
}).sort().join(',') :
|
|
462
461
|
'';
|
|
463
462
|
if (selectorKey && selectorMap.has(selectorKey)) {
|
|
@@ -533,7 +532,12 @@ function mergeLayerRules (rules, mergeSelectorRules) {
|
|
|
533
532
|
* @return {string} The normalized selector.
|
|
534
533
|
*/
|
|
535
534
|
function normalizeSelector (selector) {
|
|
536
|
-
return selector
|
|
535
|
+
return selector
|
|
536
|
+
.trim()
|
|
537
|
+
.replace(/\s+/g, ' ')
|
|
538
|
+
// Convert double-colon ::before/::after to single-colon legacy form
|
|
539
|
+
.replace(/::before\b/g, ':before')
|
|
540
|
+
.replace(/::after\b/g, ':after');
|
|
537
541
|
}
|
|
538
542
|
|
|
539
543
|
/**
|
package/src/value/gradients.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* @file Parses and minifies CSS gradient function calls by splitting arguments, normalizing default directions, and removing redundant stop positions.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
parseHex,
|
|
7
|
+
shortestColor
|
|
8
|
+
} from './colors.js';
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* Splits a gradient function's argument string at top-level commas, correctly handling nested parentheses.
|
|
7
12
|
*
|
|
@@ -32,7 +37,299 @@ function splitGradientArgs (argumentString) {
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
|
-
*
|
|
40
|
+
* Checks whether a string is a valid gradient stop position consisting of one
|
|
41
|
+
* or two numeric tokens with optional CSS units.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} positionText The potential stop position text.
|
|
44
|
+
* @return {boolean} Whether the text is a valid stop position.
|
|
45
|
+
*/
|
|
46
|
+
function isGradientStopPosition (positionText) {
|
|
47
|
+
// Match one or two numeric stop-position tokens, such as `50%`, `10px`, or `0 50%`.
|
|
48
|
+
return /^[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?(?:\s+[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)?$/i.test(positionText);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Splits a hex color stop that has an attached position with no separating
|
|
53
|
+
* whitespace back into distinct color and position parts.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} stop The raw gradient stop text.
|
|
56
|
+
* @return {object|null} Parsed `color` and `position` parts, or null.
|
|
57
|
+
*/
|
|
58
|
+
function splitAttachedHexColorStop (stop) {
|
|
59
|
+
if (!stop.startsWith('#')) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hexLengths = [8, 6, 4, 3];
|
|
64
|
+
for (const hexLength of hexLengths) {
|
|
65
|
+
const colorLength = hexLength + 1;
|
|
66
|
+
if (stop.length <= colorLength) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const colorCandidate = stop.slice(0, colorLength);
|
|
71
|
+
const positionCandidate = stop.slice(colorLength).trim();
|
|
72
|
+
const hexDigits = colorCandidate.slice(1);
|
|
73
|
+
const isHexColor = hexDigits.length === hexLength && /^[0-9a-f]+$/i.test(hexDigits);
|
|
74
|
+
if (!isHexColor || !isGradientStopPosition(positionCandidate)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
color: colorCandidate,
|
|
80
|
+
position: positionCandidate
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Splits a function-based color stop that has an attached position with no
|
|
89
|
+
* separating whitespace back into distinct color and position parts.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} stop The raw gradient stop text.
|
|
92
|
+
* @return {object|null} Parsed `color` and `position` parts, or null.
|
|
93
|
+
*/
|
|
94
|
+
function splitAttachedFunctionColorStop (stop) {
|
|
95
|
+
const lastCloseParenthesis = stop.lastIndexOf(')');
|
|
96
|
+
if (lastCloseParenthesis === -1 || lastCloseParenthesis === stop.length - 1) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const colorCandidate = stop.slice(0, lastCloseParenthesis + 1).trim();
|
|
101
|
+
const positionCandidate = stop.slice(lastCloseParenthesis + 1).trim();
|
|
102
|
+
if (!isGradientStopPosition(positionCandidate)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
color: colorCandidate,
|
|
108
|
+
position: positionCandidate
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalizes a gradient stop color token to the same shortest representation
|
|
114
|
+
* used by the general value minifier so equivalent adjacent stops can merge.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} colorToken The parsed stop color token.
|
|
117
|
+
* @return {string} The normalized color token.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeStopColorToken (colorToken) {
|
|
120
|
+
if (!colorToken.startsWith('#')) {
|
|
121
|
+
return colorToken;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const channels = parseHex(colorToken);
|
|
125
|
+
if (!channels) {
|
|
126
|
+
return colorToken;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return shortestColor(channels[0], channels[1], channels[2], channels[3]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Splits a gradient color stop into its color value and optional position.
|
|
134
|
+
* The position is the trailing percentage/length token(s), while the color
|
|
135
|
+
* is everything before it. Handles colors with parentheses like rgb() and hsl().
|
|
136
|
+
*
|
|
137
|
+
* @param {string} stop A single gradient color stop string (e.g. "red 50%").
|
|
138
|
+
* @return {object} An object with `color` and `position` string properties.
|
|
139
|
+
*/
|
|
140
|
+
function parseColorStop (stop) {
|
|
141
|
+
const trimmed = stop.trim();
|
|
142
|
+
// Match a trailing position: one or two values that are numbers with optional units
|
|
143
|
+
// like "50%", "10px", or "0". Captures the last position token(s) after the color.
|
|
144
|
+
const positionMatch = trimmed.match(/^(.+?)\s+((?:\d+(?:\.\d+)?(?:%|[a-z]+)?\s*){1,2})$/i);
|
|
145
|
+
if (positionMatch) {
|
|
146
|
+
return {
|
|
147
|
+
color: positionMatch[1].trim(),
|
|
148
|
+
position: positionMatch[2].trim()
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const attachedHexStop = splitAttachedHexColorStop(trimmed);
|
|
152
|
+
if (attachedHexStop) {
|
|
153
|
+
return attachedHexStop;
|
|
154
|
+
}
|
|
155
|
+
const attachedFunctionStop = splitAttachedFunctionColorStop(trimmed);
|
|
156
|
+
if (attachedFunctionStop) {
|
|
157
|
+
return attachedFunctionStop;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
color: trimmed,
|
|
161
|
+
position: null
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Splits a stop position into individual start and end tokens.
|
|
167
|
+
*
|
|
168
|
+
* @param {string|null} position The raw stop position text.
|
|
169
|
+
* @return {Array} The normalized position tokens.
|
|
170
|
+
*/
|
|
171
|
+
function splitStopPositionTokens (position) {
|
|
172
|
+
if (position === null) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return position.split(/\s+/).filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Serializes a color stop from its color and normalized position tokens.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} color The normalized stop color.
|
|
183
|
+
* @param {Array} positionTokens The normalized stop positions.
|
|
184
|
+
* @return {string} The serialized color stop.
|
|
185
|
+
*/
|
|
186
|
+
function serializeColorStop (color, positionTokens) {
|
|
187
|
+
if (positionTokens.length === 0) {
|
|
188
|
+
return color;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return color + ' ' + positionTokens.join(' ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Merges consecutive stops with the same color into a single logical stop.
|
|
196
|
+
*
|
|
197
|
+
* @param {Array} group The adjacent parsed stops for one color.
|
|
198
|
+
* @return {object} The merged stop data.
|
|
199
|
+
*/
|
|
200
|
+
function mergeIdenticalStopGroup (group) {
|
|
201
|
+
const firstStop = group[0];
|
|
202
|
+
const lastStop = group[group.length - 1];
|
|
203
|
+
const firstPositionTokens = splitStopPositionTokens(firstStop.position);
|
|
204
|
+
const lastPositionTokens = splitStopPositionTokens(lastStop.position);
|
|
205
|
+
|
|
206
|
+
let positionTokens;
|
|
207
|
+
if (group.length === 1) {
|
|
208
|
+
positionTokens = firstPositionTokens;
|
|
209
|
+
} else {
|
|
210
|
+
const mergedTokens = [];
|
|
211
|
+
const startPosition = firstPositionTokens[0] || null;
|
|
212
|
+
const endPosition = lastPositionTokens[lastPositionTokens.length - 1] || null;
|
|
213
|
+
|
|
214
|
+
if (startPosition !== null) {
|
|
215
|
+
mergedTokens.push(startPosition);
|
|
216
|
+
}
|
|
217
|
+
if (endPosition !== null && endPosition !== startPosition) {
|
|
218
|
+
mergedTokens.push(endPosition);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
positionTokens = mergedTokens;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
color: firstStop.color,
|
|
226
|
+
effectiveEndPosition: lastPositionTokens[lastPositionTokens.length - 1] || null,
|
|
227
|
+
positionTokens
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Removes implied edge positions and rewrites repeated starts as `0`.
|
|
233
|
+
*
|
|
234
|
+
* @param {Array} mergedStops The merged stops to normalize.
|
|
235
|
+
* @return {Array} The serialized normalized stops.
|
|
236
|
+
*/
|
|
237
|
+
function normalizeBoundaryPositionTokens (mergedStops) {
|
|
238
|
+
const result = [];
|
|
239
|
+
let previousEndPosition = null;
|
|
240
|
+
|
|
241
|
+
for (let stopIndex = 0; stopIndex < mergedStops.length; stopIndex++) {
|
|
242
|
+
const stop = mergedStops[stopIndex];
|
|
243
|
+
const isFirstStop = stopIndex === 0;
|
|
244
|
+
const isLastStop = stopIndex === mergedStops.length - 1;
|
|
245
|
+
const positionTokens = [...stop.positionTokens];
|
|
246
|
+
|
|
247
|
+
if (isFirstStop && positionTokens[0] === '0%') {
|
|
248
|
+
positionTokens.shift();
|
|
249
|
+
}
|
|
250
|
+
if (isLastStop && positionTokens[positionTokens.length - 1] === '100%') {
|
|
251
|
+
positionTokens.pop();
|
|
252
|
+
}
|
|
253
|
+
if (positionTokens.length > 0 && positionTokens[0] === previousEndPosition) {
|
|
254
|
+
positionTokens[0] = '0';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
previousEndPosition = stop.effectiveEndPosition;
|
|
258
|
+
result.push(serializeColorStop(stop.color, positionTokens));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Serializes a parsed gradient stop back into normalized CSS text, ensuring a
|
|
266
|
+
* separating space is preserved when a stop position is present.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} stop The raw gradient stop string.
|
|
269
|
+
* @return {string} The normalized gradient stop string.
|
|
270
|
+
*/
|
|
271
|
+
function normalizeColorStop (stop) {
|
|
272
|
+
const parsedStop = parseColorStop(stop);
|
|
273
|
+
const normalizedColor = normalizeStopColorToken(parsedStop.color);
|
|
274
|
+
if (parsedStop.position === null) {
|
|
275
|
+
return normalizedColor;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return normalizedColor + ' ' + parsedStop.position;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Groups consecutive gradient stops that share the same color value into
|
|
283
|
+
* arrays. Each group contains one or more stops with an identical color.
|
|
284
|
+
*
|
|
285
|
+
* @param {Array} stops An array of parsed stop objects with `color` and `position`.
|
|
286
|
+
* @return {Array} An array of groups, each being an array of stop objects with the same color.
|
|
287
|
+
*/
|
|
288
|
+
function groupConsecutiveIdenticalStops (stops) {
|
|
289
|
+
const groups = [];
|
|
290
|
+
let currentGroup = [stops[0]];
|
|
291
|
+
for (let index = 1; index < stops.length; index++) {
|
|
292
|
+
if (stops[index].color === currentGroup[0].color) {
|
|
293
|
+
currentGroup.push(stops[index]);
|
|
294
|
+
} else {
|
|
295
|
+
groups.push(currentGroup);
|
|
296
|
+
currentGroup = [stops[index]];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
groups.push(currentGroup);
|
|
300
|
+
return groups;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Combines groups of identical adjacent color stops into single stops with
|
|
305
|
+
* merged position ranges. Also removes implied 0% at the start and 100%
|
|
306
|
+
* at the end, and replaces a start position with unitless `0` when it
|
|
307
|
+
* matches the previous group's end position.
|
|
308
|
+
*
|
|
309
|
+
* @param {Array} args The gradient stop strings (already split by comma).
|
|
310
|
+
* @return {Array} The optimized gradient stop strings.
|
|
311
|
+
*/
|
|
312
|
+
function combineAdjacentIdenticalStops (args) {
|
|
313
|
+
const stops = args.map((arg) => {
|
|
314
|
+
return parseColorStop(arg);
|
|
315
|
+
});
|
|
316
|
+
const hasPositions = stops.some((stop) => {
|
|
317
|
+
return stop.position !== null;
|
|
318
|
+
});
|
|
319
|
+
if (!hasPositions) {
|
|
320
|
+
return args;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const groups = groupConsecutiveIdenticalStops(stops);
|
|
324
|
+
const mergedStops = groups.map((group) => {
|
|
325
|
+
return mergeIdenticalStopGroup(group);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return normalizeBoundaryPositionTokens(mergedStops);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 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.
|
|
36
333
|
*
|
|
37
334
|
* @param {string} func The gradient function name (e.g. "linear-gradient").
|
|
38
335
|
* @param {string} argsStr The raw comma-separated gradient arguments string.
|
|
@@ -42,6 +339,8 @@ function processGradientArgs (func, argsStr) {
|
|
|
42
339
|
const args = splitGradientArgs(argsStr);
|
|
43
340
|
const functionLower = func.toLowerCase();
|
|
44
341
|
|
|
342
|
+
let directionArgCount = 0;
|
|
343
|
+
|
|
45
344
|
if (functionLower.includes('linear')) {
|
|
46
345
|
if (args.length > 1) {
|
|
47
346
|
const firstDirection = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -49,18 +348,31 @@ function processGradientArgs (func, argsStr) {
|
|
|
49
348
|
args.shift();
|
|
50
349
|
} else if (firstDirection === 'to top') {
|
|
51
350
|
args[0] = '0deg';
|
|
351
|
+
directionArgCount = 1;
|
|
52
352
|
} else if (firstDirection === 'to right') {
|
|
53
353
|
args[0] = '90deg';
|
|
354
|
+
directionArgCount = 1;
|
|
54
355
|
} else if (firstDirection === 'to left') {
|
|
55
356
|
args[0] = '270deg';
|
|
357
|
+
directionArgCount = 1;
|
|
56
358
|
} else if (firstDirection === 'to top right' || firstDirection === 'to right top') {
|
|
57
359
|
args[0] = '45deg';
|
|
360
|
+
directionArgCount = 1;
|
|
58
361
|
} else if (firstDirection === 'to bottom right' || firstDirection === 'to right bottom') {
|
|
59
362
|
args[0] = '135deg';
|
|
363
|
+
directionArgCount = 1;
|
|
60
364
|
} else if (firstDirection === 'to bottom left' || firstDirection === 'to left bottom') {
|
|
61
365
|
args[0] = '225deg';
|
|
366
|
+
directionArgCount = 1;
|
|
62
367
|
} else if (firstDirection === 'to top left' || firstDirection === 'to left top') {
|
|
63
368
|
args[0] = '315deg';
|
|
369
|
+
directionArgCount = 1;
|
|
370
|
+
} else {
|
|
371
|
+
// Check if first arg looks like a direction (angle or "to ..." keyword)
|
|
372
|
+
const looksLikeDirection = /^\d+(\.\d+)?deg$/i.test(firstDirection) || firstDirection.startsWith('to ');
|
|
373
|
+
if (looksLikeDirection) {
|
|
374
|
+
directionArgCount = 1;
|
|
375
|
+
}
|
|
64
376
|
}
|
|
65
377
|
}
|
|
66
378
|
} else if (functionLower.includes('radial')) {
|
|
@@ -68,15 +380,34 @@ function processGradientArgs (func, argsStr) {
|
|
|
68
380
|
const firstShape = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
|
|
69
381
|
if (firstShape === 'ellipse at center' || firstShape === 'circle at center') {
|
|
70
382
|
args.shift();
|
|
383
|
+
} else {
|
|
384
|
+
// Check if first arg is a radial shape/size descriptor
|
|
385
|
+
const looksLikeShape = /\b(circle|ellipse|closest|farthest|at)\b/i.test(firstShape);
|
|
386
|
+
if (looksLikeShape) {
|
|
387
|
+
directionArgCount = 1;
|
|
388
|
+
}
|
|
71
389
|
}
|
|
72
390
|
}
|
|
73
391
|
}
|
|
74
392
|
|
|
75
|
-
|
|
393
|
+
// Extract color stop args (everything after the direction/shape argument)
|
|
394
|
+
const colorStopArgs = args.slice(directionArgCount).map((arg) => {
|
|
395
|
+
return normalizeColorStop(arg);
|
|
396
|
+
});
|
|
397
|
+
if (colorStopArgs.length > 0) {
|
|
398
|
+
const normalizedStops = colorStopArgs.length >= 2 ?
|
|
399
|
+
combineAdjacentIdenticalStops(colorStopArgs) :
|
|
400
|
+
colorStopArgs;
|
|
401
|
+
args.splice(directionArgCount, colorStopArgs.length, ...normalizedStops);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (args.length > directionArgCount) {
|
|
405
|
+
const firstStopIndex = directionArgCount;
|
|
406
|
+
const lastStopIndex = args.length - 1;
|
|
76
407
|
// Remove default 0% stop position from the first gradient stop
|
|
77
|
-
args[
|
|
408
|
+
args[firstStopIndex] = args[firstStopIndex].replace(/^(.*\S)\s+0%$/, '$1');
|
|
78
409
|
// Remove default 100% stop position from the last gradient stop
|
|
79
|
-
args[
|
|
410
|
+
args[lastStopIndex] = args[lastStopIndex].replace(/^(.*\S)\s+100%$/, '$1');
|
|
80
411
|
}
|
|
81
412
|
|
|
82
413
|
return args.join(',');
|
package/src/value/minify.js
CHANGED
|
@@ -651,6 +651,10 @@ function applyPropertyOptimizations (val, property) {
|
|
|
651
651
|
if (normalized) {
|
|
652
652
|
val = normalized;
|
|
653
653
|
}
|
|
654
|
+
// Restore the required separator between an image function and a following
|
|
655
|
+
// background-position when that position is not immediately followed by `/size`.
|
|
656
|
+
val = val.replace(/\)((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?!\/)/gi, ') $1');
|
|
657
|
+
val = val.replace(/\)\s+((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?=\/)/gi, ')$1');
|
|
654
658
|
}
|
|
655
659
|
|
|
656
660
|
if (property === 'border') {
|