@thejaredwilcurt/csslop 0.0.1
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/README.md +213 -0
- package/index.js +5 -0
- package/package.json +48 -0
- package/src/context.js +17 -0
- package/src/declarations/config.js +56 -0
- package/src/declarations/process.js +662 -0
- package/src/index.js +90 -0
- package/src/position-try.js +199 -0
- package/src/preprocess.js +27 -0
- package/src/rules/normalize.js +108 -0
- package/src/rules/optimize.js +375 -0
- package/src/rules/stringify.js +556 -0
- package/src/utilities.js +37 -0
- package/src/value/colors.js +780 -0
- package/src/value/gradients.js +121 -0
- package/src/value/math.js +281 -0
- package/src/value/minify.js +716 -0
- package/src/value/named-colors.js +159 -0
- package/src/value/shared.js +146 -0
- package/src/value/transforms.js +222 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Minifies CSS declaration values by applying color conversion, math simplification, shorthand compression, and other property-specific optimizations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolveUnicodeEscape } from '../utilities.js';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
convertOklabToHex,
|
|
9
|
+
evaluateColorMix,
|
|
10
|
+
evaluateRelativeColor,
|
|
11
|
+
hslToRgbChannels,
|
|
12
|
+
hwbToRgbChannels,
|
|
13
|
+
parseHex,
|
|
14
|
+
rgbaToHex,
|
|
15
|
+
shortestColor
|
|
16
|
+
} from './colors.js';
|
|
17
|
+
import { minifyGradients } from './gradients.js';
|
|
18
|
+
import {
|
|
19
|
+
normalizeMathFunctions,
|
|
20
|
+
simplifyStandaloneCalc
|
|
21
|
+
} from './math.js';
|
|
22
|
+
import { namedColors } from './named-colors.js';
|
|
23
|
+
import {
|
|
24
|
+
collapseShorthandParts,
|
|
25
|
+
normalizeScaleComponent,
|
|
26
|
+
parseAlphaString,
|
|
27
|
+
roundCompactNumber
|
|
28
|
+
} from './shared.js';
|
|
29
|
+
import { minifyTransformValue } from './transforms.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map of position-area two-keyword values to their single-keyword equivalents.
|
|
33
|
+
* Per CSS spec, `center center` is redundant with `center`, etc.
|
|
34
|
+
*
|
|
35
|
+
* @type {{[key: string]: string}}
|
|
36
|
+
*/
|
|
37
|
+
const POSITION_AREA_SHORTHANDS = {
|
|
38
|
+
'center center': 'center',
|
|
39
|
+
'top center': 'top',
|
|
40
|
+
'bottom center': 'bottom',
|
|
41
|
+
'center top': 'top',
|
|
42
|
+
'center bottom': 'bottom',
|
|
43
|
+
'left center': 'left',
|
|
44
|
+
'right center': 'right'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
|
|
49
|
+
* color keywords. Hex patterns are ordered longest-first to avoid partial matches.
|
|
50
|
+
* Named colors are sorted longest-first so longer names like `darkslategray` are
|
|
51
|
+
* matched before shorter substrings.
|
|
52
|
+
*
|
|
53
|
+
* @type {RegExp}
|
|
54
|
+
*/
|
|
55
|
+
const COLOR_TOKEN_PATTERN = new RegExp(
|
|
56
|
+
'#[0-9a-fA-F]{8}(?![0-9a-fA-F])|' +
|
|
57
|
+
'#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
|
|
58
|
+
'#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
|
|
59
|
+
'#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
|
|
60
|
+
'\\b(?:' +
|
|
61
|
+
Object.keys(namedColors).sort((a, b) => {
|
|
62
|
+
return b.length - a.length;
|
|
63
|
+
}).join('|') +
|
|
64
|
+
')\\b',
|
|
65
|
+
'gi'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replaces every hex color and named color keyword in a CSS value segment with
|
|
70
|
+
* the shortest equivalent representation, comparing full hex, shortened hex,
|
|
71
|
+
* and any matching named color keyword.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} segment A CSS value segment (outside strings and urls).
|
|
74
|
+
* @return {string} The segment with all colors shortened to their minimal form.
|
|
75
|
+
*/
|
|
76
|
+
function shortenColorValues (segment) {
|
|
77
|
+
return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
|
|
78
|
+
let channels;
|
|
79
|
+
if (match.startsWith('#')) {
|
|
80
|
+
channels = parseHex(match);
|
|
81
|
+
} else {
|
|
82
|
+
const rgb = namedColors[match.toLowerCase()];
|
|
83
|
+
if (rgb) {
|
|
84
|
+
channels = [rgb[0], rgb[1], rgb[2], 1];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!channels) {
|
|
88
|
+
return match;
|
|
89
|
+
}
|
|
90
|
+
return shortestColor(channels[0], channels[1], channels[2], channels[3]);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} value The full CSS value string.
|
|
98
|
+
* @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
|
|
99
|
+
* @return {string} The value with the replacer applied to all eligible segments.
|
|
100
|
+
*/
|
|
101
|
+
function replaceOutsideStringsAndUrls (value, replacer) {
|
|
102
|
+
let result = '';
|
|
103
|
+
let index = 0;
|
|
104
|
+
|
|
105
|
+
const consumeQuoted = (start) => {
|
|
106
|
+
const quote = value[start];
|
|
107
|
+
let end = start + 1;
|
|
108
|
+
while (end < value.length) {
|
|
109
|
+
if (value[end] === '\\') {
|
|
110
|
+
end += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (value[end] === quote) {
|
|
114
|
+
end++;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
end++;
|
|
118
|
+
}
|
|
119
|
+
return end;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const startsUrl = (start) => {
|
|
123
|
+
return value.slice(start, start + 4).toLowerCase() === 'url(';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
while (index < value.length) {
|
|
127
|
+
if (value[index] === '"' || value[index] === '\'') {
|
|
128
|
+
const end = consumeQuoted(index);
|
|
129
|
+
result += value.slice(index, end);
|
|
130
|
+
index = end;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (startsUrl(index)) {
|
|
135
|
+
let depth = 1;
|
|
136
|
+
let end = index + 4;
|
|
137
|
+
while (end < value.length && depth > 0) {
|
|
138
|
+
if (value[end] === '"' || value[end] === '\'') {
|
|
139
|
+
end = consumeQuoted(end);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (value[end] === '(') {
|
|
143
|
+
depth++;
|
|
144
|
+
}
|
|
145
|
+
if (value[end] === ')') {
|
|
146
|
+
depth--;
|
|
147
|
+
}
|
|
148
|
+
end++;
|
|
149
|
+
}
|
|
150
|
+
result += value.slice(index, end);
|
|
151
|
+
index = end;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const start = index;
|
|
156
|
+
while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrl(index)) {
|
|
157
|
+
index++;
|
|
158
|
+
}
|
|
159
|
+
result += replacer(value.slice(start, index));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} val The raw CSS value string to normalize.
|
|
169
|
+
* @param {string} property The CSS property name, used for context-aware quote handling.
|
|
170
|
+
* @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
|
|
171
|
+
*/
|
|
172
|
+
function normalizeWhitespaceAndQuotes (val, property) {
|
|
173
|
+
// Unescape unicode (skip control characters — they must stay escaped in CSS strings)
|
|
174
|
+
val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
|
|
175
|
+
return resolveUnicodeEscape(hex) ?? match;
|
|
176
|
+
});
|
|
177
|
+
// Normalize single-quoted strings to double-quoted
|
|
178
|
+
val = val.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
|
|
179
|
+
|
|
180
|
+
// Remove space between string literals
|
|
181
|
+
val = val.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
|
|
182
|
+
|
|
183
|
+
// Whitespace minification
|
|
184
|
+
val = val.replace(/\s*!\s*important/i, '!important');
|
|
185
|
+
// val = val.replace(/\s*([+*/=])\s*/g, '$1');
|
|
186
|
+
// Strip whitespace around commas
|
|
187
|
+
val = val.replace(/\s*([,])\s*/g, '$1');
|
|
188
|
+
// Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
|
|
189
|
+
val = val.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, str, paren) => {
|
|
190
|
+
if (str) {
|
|
191
|
+
return str;
|
|
192
|
+
}
|
|
193
|
+
return paren;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
|
|
197
|
+
if (property !== 'content' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
|
|
198
|
+
// Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
|
|
199
|
+
val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
|
|
200
|
+
// Keep quotes around CSS generic font-family keywords — unquoted they mean something different
|
|
201
|
+
if (property === 'font-family' && /^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui|math|emoji|fangsong|ui-serif|ui-sans-serif|ui-monospace|ui-rounded)$/i.test(inner)) {
|
|
202
|
+
return before + '"' + inner + '"';
|
|
203
|
+
}
|
|
204
|
+
return before + inner;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return val;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
|
|
213
|
+
* shortest hex equivalents and applies hex shortening.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} val The CSS value string with potential color functions.
|
|
216
|
+
* @return {string} The value with color functions converted to hex where shorter.
|
|
217
|
+
*/
|
|
218
|
+
function convertColorsToHex (val) {
|
|
219
|
+
// Evaluate color-mix() expressions (before space minification to avoid nested-paren issues)
|
|
220
|
+
if (/\bcolor-mix\(/i.test(val)) {
|
|
221
|
+
const result = evaluateColorMix(val);
|
|
222
|
+
if (result) {
|
|
223
|
+
val = result;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Handle color(from ...) relative color syntax (identity case)
|
|
228
|
+
if (/\bcolor\(\s*from\b/i.test(val)) {
|
|
229
|
+
const result = evaluateRelativeColor(val);
|
|
230
|
+
if (result) {
|
|
231
|
+
val = result;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Minify whitespace and numeric precision inside wide-gamut and functional color notations
|
|
236
|
+
val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
|
|
237
|
+
// Collapse whitespace to single space
|
|
238
|
+
let minified = inner.replace(/\s+/g, ' ');
|
|
239
|
+
// Remove space after commas
|
|
240
|
+
minified = minified.replace(/, /g, ',');
|
|
241
|
+
// Remove spaces around slash separator (alpha delimiter)
|
|
242
|
+
minified = minified.replace(/ \/ /g, '/');
|
|
243
|
+
// Strip leading zeros from decimal numbers (e.g. 0.5 → .5)
|
|
244
|
+
minified = minified.replace(/\b0+(\.[\d]+)/g, '$1');
|
|
245
|
+
// Strip leading zeros from decimals preceded by a keyword (e.g. srgb 0.5 → srgb .5)
|
|
246
|
+
minified = minified.replace(/([A-Za-z]) 0+(\.[\d]+)/g, '$1 $2');
|
|
247
|
+
// Check if function uses a wide-gamut color space requiring higher numeric precision
|
|
248
|
+
const useWidePrecision = func.toLowerCase() === 'color' && /\b(srgb-linear|xyz-d65|xyz-d50|xyz)\b/i.test(inner);
|
|
249
|
+
// Round numbers with 3+ decimal places, using context-aware precision
|
|
250
|
+
minified = minified.replace(/(^|[\s(,/-])(-?\d*\.\d{3,})/g, (match, before, num) => {
|
|
251
|
+
const isAlpha = before === '/';
|
|
252
|
+
const absoluteValue = Math.abs(parseFloat(num));
|
|
253
|
+
// Check if function is a Lab/LCH color notation with a large channel value (less precision needed)
|
|
254
|
+
const isLargeLabValue = /^(?:lch|lab|oklch|oklab)$/i.test(func) && absoluteValue >= 10;
|
|
255
|
+
let precision;
|
|
256
|
+
if (isAlpha) {
|
|
257
|
+
precision = 3;
|
|
258
|
+
} else if (isLargeLabValue) {
|
|
259
|
+
precision = 1;
|
|
260
|
+
} else if (useWidePrecision) {
|
|
261
|
+
precision = 4;
|
|
262
|
+
} else {
|
|
263
|
+
precision = 3;
|
|
264
|
+
}
|
|
265
|
+
const factor = Math.pow(10, precision);
|
|
266
|
+
const roundedNum = Math.round(parseFloat(num) * factor) / factor;
|
|
267
|
+
// Strip trailing zeros and trailing decimal point from the rounded number
|
|
268
|
+
let rounded = roundedNum.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
|
|
269
|
+
if (rounded.startsWith('0.')) {
|
|
270
|
+
rounded = rounded.substring(1);
|
|
271
|
+
}
|
|
272
|
+
if (rounded.startsWith('-0.')) {
|
|
273
|
+
rounded = '-' + rounded.substring(2);
|
|
274
|
+
}
|
|
275
|
+
return before + rounded;
|
|
276
|
+
});
|
|
277
|
+
return func + '(' + minified.trim() + ')';
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Convert in-gamut oklab() to hex when it produces a shorter representation
|
|
281
|
+
val = val.replace(/\boklab\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
|
|
282
|
+
const alpha = parseAlphaString(alphaStr);
|
|
283
|
+
const hex = convertOklabToHex(parseFloat(lStr), parseFloat(aStr), parseFloat(bStr), alpha);
|
|
284
|
+
if (!hex) {
|
|
285
|
+
return match; // out-of-gamut: keep native oklab form
|
|
286
|
+
}
|
|
287
|
+
if (hex.length < match.length) {
|
|
288
|
+
return hex;
|
|
289
|
+
}
|
|
290
|
+
return match;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Handle 'none' keyword in rgb/hsl functions (CSS Color Level 4: treated as 0)
|
|
294
|
+
val = val.replace(/\b(rgba?|hsla?)\([^)]*\)/gi, (match) => {
|
|
295
|
+
return match.replace(/\bnone\b/gi, '0');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// hwb() → hex
|
|
299
|
+
val = val.replace(/\bhwb\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%\s+((?:\d+|\d*\.\d+))%(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hStr, wStr, bStr, aStr) => {
|
|
300
|
+
const [r, g, b] = hwbToRgbChannels(parseFloat(hStr), parseFloat(wStr) / 100, parseFloat(bStr) / 100);
|
|
301
|
+
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// rgb() space syntax → hex (handles decimals and any alpha)
|
|
305
|
+
val = val.replace(/\brgb\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/g, (match, rStr, gStr, bStr, aStr) => {
|
|
306
|
+
const r = Math.round(parseFloat(rStr));
|
|
307
|
+
const g = Math.round(parseFloat(gStr));
|
|
308
|
+
const b = Math.round(parseFloat(bStr));
|
|
309
|
+
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// hsl() space syntax → hex (handles any alpha)
|
|
313
|
+
val = val.replace(/\bhsl\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%\s+((?:\d+|\d*\.\d+))%(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/g, (match, hStr, sStr, lStr, aStr) => {
|
|
314
|
+
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
315
|
+
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// rgba() comma syntax → hex (handles any alpha)
|
|
319
|
+
val = val.replace(/\brgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/g, (match, rStr, gStr, bStr, aStr) => {
|
|
320
|
+
const r = parseInt(rStr, 10);
|
|
321
|
+
const g = parseInt(gStr, 10);
|
|
322
|
+
const b = parseInt(bStr, 10);
|
|
323
|
+
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// hsla() comma syntax → hex (handles any alpha)
|
|
327
|
+
val = val.replace(/\bhsla\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/g, (match, hStr, sStr, lStr, aStr) => {
|
|
328
|
+
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
329
|
+
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// hsl() comma syntax → hex
|
|
333
|
+
val = val.replace(/\bhsl\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*((?:\d+|\d*\.\d+))%\s*\)/g, (match, hStr, sStr, lStr) => {
|
|
334
|
+
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
335
|
+
return rgbaToHex(r, g, b, 1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// rgb() comma syntax → hex
|
|
339
|
+
val = val.replace(/\brgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g, (match, r, g, b) => {
|
|
340
|
+
return rgbaToHex(parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), 1);
|
|
341
|
+
});
|
|
342
|
+
return val;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Applies property-specific optimizations to a CSS value (transition, flex, font,
|
|
347
|
+
* background, display, scale, border-radius, shorthand collapsing, etc.).
|
|
348
|
+
*
|
|
349
|
+
* @param {string} val The CSS value string after generic minification.
|
|
350
|
+
* @param {string} property The CSS property name.
|
|
351
|
+
* @return {string} The value with property-specific optimizations applied.
|
|
352
|
+
*/
|
|
353
|
+
function applyPropertyOptimizations (val, property) {
|
|
354
|
+
if (property === 'font-weight') {
|
|
355
|
+
// Replace font-weight keyword "bold" with its numeric equivalent
|
|
356
|
+
val = val.replace(/\bbold\b/gi, '700');
|
|
357
|
+
// Replace font-weight keyword "normal" with its numeric equivalent
|
|
358
|
+
val = val.replace(/\bnormal\b/gi, '400');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (property === 'transition-duration') {
|
|
362
|
+
// Convert millisecond duration to seconds when the result is shorter (e.g. 200ms → .2s)
|
|
363
|
+
val = val.replace(/^(-?(?:\d+|\d*\.\d+))ms$/i, (match, amount) => {
|
|
364
|
+
return roundCompactNumber(parseFloat(amount) / 1000) + 's';
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Transition: remove " 0s" duration (transition: all 0s -> transition: all)
|
|
369
|
+
if (property === 'transition') {
|
|
370
|
+
// Remove zero-second duration from transition shorthand
|
|
371
|
+
val = val.replace(/\s+0s/g, ' ');
|
|
372
|
+
// Remove leading zero-pixel value from transition shorthand
|
|
373
|
+
val = val.replace(/^0px\s*/, '');
|
|
374
|
+
// Replace cubic-bezier functions with their equivalent named timing-function keywords
|
|
375
|
+
val = val.replace(/cubic-bezier\(0,0,1,1\)/g, 'linear');
|
|
376
|
+
val = val.replace(/cubic-bezier\(\.25,\.1,\.25,1\)/g, 'ease');
|
|
377
|
+
val = val.replace(/cubic-bezier\(\.42,0,1,1\)/g, 'ease-in');
|
|
378
|
+
val = val.replace(/cubic-bezier\(0,0,\.58,1\)/g, 'ease-out');
|
|
379
|
+
val = val.trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (property === 'animation') {
|
|
383
|
+
// Replace steps() functions with their equivalent named timing-function keywords
|
|
384
|
+
val = val.replace(/steps\(1,start\)/g, 'step-start');
|
|
385
|
+
val = val.replace(/steps\(1,end\)/g, 'step-end');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Flex: remove " 0px" from flex shorthand (flex: 0 0 0px -> flex: 0 0)
|
|
389
|
+
if (property === 'flex') {
|
|
390
|
+
// Remove trailing zero-pixel basis value
|
|
391
|
+
val = val.replace(/\s+0px/g, ' ');
|
|
392
|
+
// Remove leading zero-pixel value
|
|
393
|
+
val = val.replace(/^0px\s*/, '');
|
|
394
|
+
// Remove trailing zero
|
|
395
|
+
val = val.replace(/\s+0$/, '');
|
|
396
|
+
// Remove standalone zero-pixel value
|
|
397
|
+
val = val.replace(/^0px$/, '');
|
|
398
|
+
val = val.trim();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Initial values
|
|
402
|
+
if (val === 'initial') {
|
|
403
|
+
if (['opacity', 'z-index', 'flex-grow', 'flex-shrink', 'order', 'line-height', 'zoom'].includes(property)) {
|
|
404
|
+
// Just leaving them or mapping some: opacity: initial -> opacity: 1
|
|
405
|
+
if (property === 'opacity') {
|
|
406
|
+
val = '1';
|
|
407
|
+
}
|
|
408
|
+
if (property === 'z-index') {
|
|
409
|
+
val = 'auto';
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (['margin', 'padding'].includes(property)) {
|
|
413
|
+
val = '0';
|
|
414
|
+
}
|
|
415
|
+
if (['min-width', 'min-height'].includes(property)) {
|
|
416
|
+
val = 'auto';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (property === 'background' && val === 'none') {
|
|
421
|
+
val = '0 0';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (property === 'display') {
|
|
425
|
+
if (val === 'block flow') {
|
|
426
|
+
val = 'block';
|
|
427
|
+
}
|
|
428
|
+
if (val === 'inline flow-root') {
|
|
429
|
+
val = 'inline-block';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (property === 'background-repeat') {
|
|
434
|
+
if (val === 'no-repeat no-repeat') {
|
|
435
|
+
val = 'no-repeat';
|
|
436
|
+
}
|
|
437
|
+
if (val === 'repeat no-repeat') {
|
|
438
|
+
val = 'repeat-x';
|
|
439
|
+
}
|
|
440
|
+
if (val === 'no-repeat repeat') {
|
|
441
|
+
val = 'repeat-y';
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (property === 'background-position') {
|
|
446
|
+
if (val === 'center center') {
|
|
447
|
+
val = '50%';
|
|
448
|
+
}
|
|
449
|
+
if (val === 'left top') {
|
|
450
|
+
val = '0 0';
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Check if border value starts with a style keyword, and reorder to canonical width-style-color order
|
|
455
|
+
if (property === 'border' && /^(?:solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)\s+/i.test(val)) {
|
|
456
|
+
// Reorder border shorthand from style-width-color to width-style-color
|
|
457
|
+
val = val.replace(/^((?:solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none))\s+([^\s]+)\s+(.+)$/i, '$2 $1 $3');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (property === 'flex-flow') {
|
|
461
|
+
// Reorder flex-flow from wrap-direction to direction-wrap (canonical order)
|
|
462
|
+
val = val.replace(/^(nowrap|wrap|wrap-reverse)\s+(row|row-reverse|column|column-reverse)$/i, '$2 $1');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (property === 'font-family') {
|
|
466
|
+
// Strip quotes from simple multi-word font family names that don't require quoting
|
|
467
|
+
val = val.replace(/"([A-Za-z0-9-]+(?: [A-Za-z0-9-]+)+)"/g, '$1');
|
|
468
|
+
const seenFamilies = new Set();
|
|
469
|
+
val = val.split(',').map((part) => {
|
|
470
|
+
return part.trim();
|
|
471
|
+
}).filter(Boolean).filter((part) => {
|
|
472
|
+
const lowercaseName = part.toLowerCase();
|
|
473
|
+
if (seenFamilies.has(lowercaseName)) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
seenFamilies.add(lowercaseName);
|
|
477
|
+
return true;
|
|
478
|
+
}).join(',');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (property === 'grid-template-areas') {
|
|
482
|
+
// Normalize each quoted grid-template-areas row string
|
|
483
|
+
val = val.replace(/"([^"]*)"/g, (match, inner) => {
|
|
484
|
+
// Collapse whitespace to single space within grid row
|
|
485
|
+
let normalized = inner.replace(/\s+/g, ' ').trim();
|
|
486
|
+
// Collapse consecutive dots (null cell tokens) to a single dot
|
|
487
|
+
normalized = normalized.replace(/(^| )\.{2,}(?= |$)/g, '$1.');
|
|
488
|
+
return '"' + normalized + '"';
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (property === 'font-size') {
|
|
493
|
+
// Convert point (pt) font-size values to their pixel (px) equivalent
|
|
494
|
+
val = val.replace(/^(-?(?:\d+|\d*\.\d+))pt$/i, (match, amount) => {
|
|
495
|
+
return roundCompactNumber(parseFloat(amount) * (96 / 72)) + 'px';
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
|
|
500
|
+
val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
|
|
501
|
+
|
|
502
|
+
// Convert display-p3 neutral grays to sRGB (equal channels are identical across gamuts)
|
|
503
|
+
val = val.replace(/\bcolor\(display-p3\s+([\d.]+)\s+\1\s+\1(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, channelStr, alphaStr) => {
|
|
504
|
+
const channelValue = parseFloat(channelStr);
|
|
505
|
+
const r = Math.round(channelValue * 255);
|
|
506
|
+
return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Shorten all color tokens (second pass after property-specific color evaluations)
|
|
510
|
+
val = replaceOutsideStringsAndUrls(val, shortenColorValues);
|
|
511
|
+
|
|
512
|
+
// Remove space before hex colors (second pass after color evaluations)
|
|
513
|
+
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
514
|
+
return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
|
|
515
|
+
});
|
|
516
|
+
if (property !== 'transform' && property !== 'background' && property !== 'src') {
|
|
517
|
+
// Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
|
|
518
|
+
val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (property === 'font') {
|
|
522
|
+
// Split font shorthand on whitespace
|
|
523
|
+
const parts = val.split(/\s+/);
|
|
524
|
+
// Find the font-size part: contains a digit and a recognized CSS length/percentage unit
|
|
525
|
+
const sizeIndex = parts.findIndex((part) => {
|
|
526
|
+
return /\d/.test(part) && /(?:px|em|rem|%|pt|pc|vw|vh|vmin|vmax|ch|ex|cm|mm|in|lh|rlh)/i.test(part);
|
|
527
|
+
});
|
|
528
|
+
if (sizeIndex > 0) {
|
|
529
|
+
val = [...parts.slice(0, sizeIndex).filter((part) => {
|
|
530
|
+
return part !== 'normal' && part !== '400';
|
|
531
|
+
}), ...parts.slice(sizeIndex)].join(' ');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (property === 'background' && val !== 'none') {
|
|
536
|
+
const normalized = val
|
|
537
|
+
// Remove default "0 0" background-position values
|
|
538
|
+
.replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
|
|
539
|
+
// Remove "0 0" background-position after a close-paren (e.g. after url())
|
|
540
|
+
.replace(/\)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ') ')
|
|
541
|
+
// Remove default "repeat" background-repeat keyword (excluding compound values like no-repeat)
|
|
542
|
+
.replace(/(?<!-)\brepeat\b(?!-)/g, ' ')
|
|
543
|
+
// Remove default "scroll" background-attachment keyword
|
|
544
|
+
.replace(/\bscroll\b/g, ' ')
|
|
545
|
+
// Remove default "none" background-image keyword
|
|
546
|
+
.replace(/\bnone\b/g, ' ')
|
|
547
|
+
// Collapse whitespace to single space
|
|
548
|
+
.replace(/\s+/g, ' ')
|
|
549
|
+
.trim();
|
|
550
|
+
if (normalized) {
|
|
551
|
+
val = normalized;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (property === 'border') {
|
|
556
|
+
// Remove default "medium" border-width keyword
|
|
557
|
+
val = val.replace(/\bmedium\s+/g, '');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (property === 'outline') {
|
|
561
|
+
// Restore missing space between outline-style and a color keyword when they are adjacent
|
|
562
|
+
val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)(red|green|olive|tan|transparent)\b/g, '$1 $2');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (property === 'transform') {
|
|
566
|
+
val = minifyTransformValue(val);
|
|
567
|
+
// Remove whitespace between consecutive transform functions
|
|
568
|
+
val = val.replace(/\)\s+(?=[a-z-]+\()/gi, ')');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (property === 'scale') {
|
|
572
|
+
// Split scale value on whitespace into individual axis components
|
|
573
|
+
const parts = val.split(/\s+/).filter(Boolean).map(normalizeScaleComponent);
|
|
574
|
+
if (parts.length === 2 && parts[0] === parts[1]) {
|
|
575
|
+
val = parts[0];
|
|
576
|
+
} else if (parts.length === 3 && parts[2] === '1') {
|
|
577
|
+
if (parts[0] === parts[1]) {
|
|
578
|
+
val = parts[0];
|
|
579
|
+
} else {
|
|
580
|
+
val = parts[0] + ' ' + parts[1];
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
val = parts.join(' ');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Replace multiple spaces
|
|
588
|
+
val = val.replace(/\s+/g, ' ');
|
|
589
|
+
|
|
590
|
+
// Shorthands: margin, padding, border-width, border-style, border-color, inset
|
|
591
|
+
// Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
|
|
592
|
+
if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
|
|
593
|
+
val = collapseShorthandParts(val.split(' ')).join(' ');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (property === 'border-radius') {
|
|
597
|
+
const segments = val.split('/').map((segment) => {
|
|
598
|
+
return segment.trim();
|
|
599
|
+
}).filter(Boolean).map((segment) => {
|
|
600
|
+
// Split each segment on whitespace and collapse redundant parts
|
|
601
|
+
return collapseShorthandParts(segment.split(/\s+/)).join(' ');
|
|
602
|
+
});
|
|
603
|
+
val = segments.join('/');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return val;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Minifies a CSS declaration's value by applying color conversion, math simplification, shorthand compression, gradient optimization, and other property-specific optimizations.
|
|
611
|
+
*
|
|
612
|
+
* @param {object} declaration The CSS declaration object with property and value fields.
|
|
613
|
+
* @return {string} The minified value string.
|
|
614
|
+
*/
|
|
615
|
+
function minifyValue (declaration) {
|
|
616
|
+
if (declaration.property === 'position-area') {
|
|
617
|
+
const shorthand = POSITION_AREA_SHORTHANDS[declaration.value];
|
|
618
|
+
if (shorthand) {
|
|
619
|
+
return shorthand;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
let val = declaration.value;
|
|
623
|
+
|
|
624
|
+
if (typeof val === 'string') {
|
|
625
|
+
val = val.trim();
|
|
626
|
+
val = normalizeWhitespaceAndQuotes(val, declaration.property);
|
|
627
|
+
|
|
628
|
+
// Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
|
|
629
|
+
// Collapse spaces around division operator
|
|
630
|
+
val = val.replace(/ \/ /g, '/');
|
|
631
|
+
// Remove whitespace around * and / operators (safe outside calc context)
|
|
632
|
+
val = val.replace(/\s*([*/])\s*/g, '$1');
|
|
633
|
+
val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
|
|
634
|
+
val = simplifyStandaloneCalc(val);
|
|
635
|
+
// Simplify calc() expressions containing zero-percent additive terms
|
|
636
|
+
val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
|
|
637
|
+
// Collapse whitespace inside calc expression
|
|
638
|
+
const compactInner = inner.replace(/\s+/g, ' ').trim();
|
|
639
|
+
// Extract all percentage terms from the expression
|
|
640
|
+
const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
|
|
641
|
+
const hasNonZeroPercent = percentTerms.some((term) => {
|
|
642
|
+
return Math.abs(parseFloat(term)) > 0;
|
|
643
|
+
});
|
|
644
|
+
if (!hasNonZeroPercent) {
|
|
645
|
+
return match;
|
|
646
|
+
}
|
|
647
|
+
// Remove trailing "+ 0%" and leading "0% +" additive identity terms
|
|
648
|
+
return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Zeros and Decimals
|
|
652
|
+
if (declaration.property !== 'initial-value') {
|
|
653
|
+
// Strip units from zero values (0px → 0, 0em → 0, etc.) at a value boundary
|
|
654
|
+
val = val.replace(/(^|\s|,|\()0(?:px|em|rem|vw|vh|cm|mm|in|pt|pc|ex|ch|vmin|vmax)(?=\s|,|$|\)|!)/g, '$10');
|
|
655
|
+
}
|
|
656
|
+
val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
|
|
657
|
+
|
|
658
|
+
// If value is a standalone number with optional unit, round it compactly
|
|
659
|
+
if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
|
|
660
|
+
const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
|
|
661
|
+
val = roundCompactNumber(rawNumber, 4) + rawUnit;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Remove space before hex colors
|
|
665
|
+
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
666
|
+
segment = segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
|
|
667
|
+
// Lowercase hex color tokens for consistency and shorter output
|
|
668
|
+
segment = segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (m) => {
|
|
669
|
+
return m.toLowerCase();
|
|
670
|
+
});
|
|
671
|
+
return segment;
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Convert color functions to hex equivalents
|
|
675
|
+
val = convertColorsToHex(val);
|
|
676
|
+
|
|
677
|
+
// Shorten all color tokens (hex and named) to their shortest representation
|
|
678
|
+
val = replaceOutsideStringsAndUrls(val, shortenColorValues);
|
|
679
|
+
|
|
680
|
+
// Property-specific optimizations
|
|
681
|
+
val = applyPropertyOptimizations(val, declaration.property);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Gradient optimizations
|
|
685
|
+
// Check if value contains a gradient function
|
|
686
|
+
if (/gradient\(/.test(val)) {
|
|
687
|
+
val = minifyGradients(val);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Unicode range compaction: U+0000-00FF -> U+??
|
|
691
|
+
if (declaration.property === 'unicode-range') {
|
|
692
|
+
val = val.replace(/U\+([0-9a-fA-F]+)-([0-9a-fA-F]+)/gi, (match, startHex, endHex) => {
|
|
693
|
+
const len = Math.max(startHex.length, endHex.length);
|
|
694
|
+
const s = startHex.padStart(len, '0').toUpperCase();
|
|
695
|
+
const e = endHex.padStart(len, '0').toUpperCase();
|
|
696
|
+
let prefixLen = 0;
|
|
697
|
+
while (prefixLen < len && s[prefixLen] === e[prefixLen]) {
|
|
698
|
+
prefixLen++;
|
|
699
|
+
}
|
|
700
|
+
const suffixS = s.slice(prefixLen);
|
|
701
|
+
const suffixE = e.slice(prefixLen);
|
|
702
|
+
// Check if the suffix range spans all values (all-zeros start, all-F end) for wildcard replacement
|
|
703
|
+
if (/^0*$/.test(suffixS) && /^F*$/i.test(suffixE)) {
|
|
704
|
+
const wildcardCount = len - prefixLen;
|
|
705
|
+
// Strip leading zeros from the common prefix
|
|
706
|
+
const prefix = s.slice(0, prefixLen).replace(/^0+/, '');
|
|
707
|
+
return 'U+' + prefix + '?'.repeat(wildcardCount);
|
|
708
|
+
}
|
|
709
|
+
return match;
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return val;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export { minifyValue };
|