@thejaredwilcurt/csslop 0.0.48 → 0.0.50
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 +3 -4
- package/package.json +3 -3
- package/src/declarations/border.js +1 -1
- package/src/declarations/css-wide-keywords.js +37 -1
- package/src/declarations/fold.js +13 -3
- package/src/declarations/process.js +60 -7
- package/src/index.js +12 -7
- package/src/parser/source-search.js +6 -0
- package/src/rules/optimize/at-rules.js +113 -0
- package/src/rules/optimize/declaration-writes.js +70 -0
- package/src/rules/optimize/empty-rules.js +51 -0
- package/src/rules/optimize/expand-nesting.js +236 -0
- package/src/rules/optimize/factor-parents.js +343 -0
- package/src/rules/optimize/index.js +71 -0
- package/src/rules/optimize/merge-declarations.js +199 -0
- package/src/rules/optimize/merge-nested-rules.js +117 -0
- package/src/rules/optimize/merge-selectors.js +252 -0
- package/src/rules/optimize/nest-rules.js +202 -0
- package/src/rules/optimize/override-index.js +264 -0
- package/src/rules/optimize/prune-overridden.js +267 -0
- package/src/rules/optimize/selector-normalization.js +60 -0
- package/src/rules/optimize/specificity.js +280 -0
- package/src/{position-try.js → rules/position-try.js} +1 -1
- package/src/rules/selectors.js +9 -0
- package/src/rules/stringify.js +138 -76
- package/src/utilities.js +9 -8
- package/src/value/border.js +85 -0
- package/src/value/{colors.js → color/conversion.js} +3 -2
- package/src/value/color/functions.js +463 -0
- package/src/value/{light-dark.js → color/light-dark.js} +1 -1
- package/src/value/{color-mix.js → color/mix.js} +3 -3
- package/src/value/{relative-colors.js → color/relative.js} +3 -3
- package/src/value/color/shorten.js +93 -0
- package/src/value/gradients.js +1 -1
- package/src/value/hyphenate-limit-chars.js +72 -0
- package/src/value/initial-keywords.js +128 -0
- package/src/value/lengths.js +55 -0
- package/src/value/math/evaluate.js +36 -0
- package/src/value/math/fold.js +315 -0
- package/src/value/math/format.js +230 -0
- package/src/value/math/functions.js +302 -0
- package/src/value/math/index.js +219 -0
- package/src/value/math/parser.js +301 -0
- package/src/value/math/property-ranges.js +174 -0
- package/src/value/math/rewrite.js +222 -0
- package/src/value/math/tokenizer.js +160 -0
- package/src/value/math/unit-totals.js +194 -0
- package/src/value/minify.js +162 -1678
- package/src/value/numbers.js +28 -0
- package/src/value/positions.js +152 -0
- package/src/value/property-optimizations.js +485 -0
- package/src/value/segments.js +74 -0
- package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
- package/src/value/{svg-markup.js → svg/markup.js} +1 -1
- package/src/value/time.js +75 -0
- package/src/value/urls.js +242 -0
- package/src/value/whitespace.js +88 -0
- package/src/rules/optimize.js +0 -2031
- package/src/value/math-expression.js +0 -1156
- package/src/value/math.js +0 -886
- /package/src/value/{named-colors.js → color/named.js} +0 -0
package/src/value/minify.js
CHANGED
|
@@ -2,1628 +2,105 @@
|
|
|
2
2
|
* @file Minifies CSS declaration values by applying color conversion, math simplification, shorthand compression, and other property-specific optimizations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
findMatchingParenthesis,
|
|
8
|
-
skipParenthesizedGroup,
|
|
9
|
-
skipQuotedString,
|
|
10
|
-
startsUrlToken
|
|
11
|
-
} from '../parser/source-search.js';
|
|
12
|
-
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
13
|
-
import { resolveUnicodeEscape } from '../utilities.js';
|
|
14
|
-
|
|
15
|
-
import { evaluateColorMix } from './color-mix.js';
|
|
16
|
-
import {
|
|
17
|
-
convertLabToHex,
|
|
18
|
-
convertOklabToHex,
|
|
19
|
-
convertOklchToHex,
|
|
20
|
-
hslToRgbChannels,
|
|
21
|
-
hwbToRgbChannels,
|
|
22
|
-
parseHex,
|
|
23
|
-
rgbaToHex,
|
|
24
|
-
shortestColor
|
|
25
|
-
} from './colors.js';
|
|
26
|
-
import { minifyFontShorthand } from './font.js';
|
|
27
|
-
import { minifyGradients } from './gradients.js';
|
|
28
|
-
import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
|
|
29
|
-
import {
|
|
30
|
-
normalizeMathFunctions,
|
|
31
|
-
simplifyStandaloneCalc
|
|
32
|
-
} from './math.js';
|
|
33
|
-
import { namedColors } from './named-colors.js';
|
|
34
|
-
import {
|
|
35
|
-
formatShortestNumber,
|
|
36
|
-
parseNumericParts,
|
|
37
|
-
scaleByPowerOfTen,
|
|
38
|
-
shortenNumericText
|
|
39
|
-
} from './numbers.js';
|
|
40
|
-
import { isQuotesNoneEquivalent } from './quotes.js';
|
|
41
|
-
import {
|
|
42
|
-
evaluateRelativeColor,
|
|
43
|
-
minifyRelativeColorSyntax
|
|
44
|
-
} from './relative-colors.js';
|
|
45
|
-
import {
|
|
46
|
-
collapseShorthandParts,
|
|
47
|
-
convertAbsoluteLengthToPx,
|
|
48
|
-
formatCompactNumber,
|
|
49
|
-
formatResolvedNumber,
|
|
50
|
-
hasSubstitutedParts,
|
|
51
|
-
normalizeScaleComponent,
|
|
52
|
-
parseAlphaString,
|
|
53
|
-
parseAngleToDegrees,
|
|
54
|
-
roundCompactNumber
|
|
55
|
-
} from './shared.js';
|
|
56
|
-
import { minifySvgDataUri } from './svg-data-uri.js';
|
|
57
|
-
import {
|
|
58
|
-
elideRedundantSeparators,
|
|
59
|
-
shortenDimensionNumbers
|
|
60
|
-
} from './tokens.js';
|
|
61
|
-
import { minifyTransformValue } from './transforms.js';
|
|
62
|
-
import { optimizeUnicodeRange } from './unicode-range.js';
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Map of position-area two-keyword values to their single-keyword equivalents.
|
|
66
|
-
* Per CSS spec, `center center` is redundant with `center`, etc.
|
|
67
|
-
*
|
|
68
|
-
* @type {{[key: string]: string}}
|
|
69
|
-
*/
|
|
70
|
-
const POSITION_AREA_SHORTHANDS = {
|
|
71
|
-
'center center': 'center',
|
|
72
|
-
'top center': 'top',
|
|
73
|
-
'bottom center': 'bottom',
|
|
74
|
-
'center top': 'top',
|
|
75
|
-
'center bottom': 'bottom',
|
|
76
|
-
'left center': 'left',
|
|
77
|
-
'right center': 'right'
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
|
|
82
|
-
* color keywords. Hex patterns are ordered longest-first to avoid partial matches.
|
|
83
|
-
* Named colors are sorted longest-first so longer names like `darkslategray` are
|
|
84
|
-
* matched before shorter substrings. A named color only counts as a color keyword
|
|
85
|
-
* when it is a complete identifier, so hyphens on either side disqualify it (this
|
|
86
|
-
* keeps identifiers such as the custom property name in `var(--grey)` intact).
|
|
87
|
-
*
|
|
88
|
-
* @type {RegExp}
|
|
89
|
-
*/
|
|
90
|
-
const COLOR_TOKEN_PATTERN = new RegExp(
|
|
91
|
-
'#[0-9a-fA-F]{8}(?![0-9a-fA-F])|' +
|
|
92
|
-
'#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
|
|
93
|
-
'#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
|
|
94
|
-
'#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
|
|
95
|
-
'(?<![\\w-])(?:' +
|
|
96
|
-
Object.keys(namedColors).sort((a, b) => {
|
|
97
|
-
return b.length - a.length;
|
|
98
|
-
}).join('|') +
|
|
99
|
-
')(?![\\w-])',
|
|
100
|
-
'gi'
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Replaces every hex color and named color keyword in a CSS value segment with
|
|
105
|
-
* the shortest equivalent representation, comparing full hex, shortened hex,
|
|
106
|
-
* and any matching named color keyword.
|
|
107
|
-
*
|
|
108
|
-
* @param {string} segment A CSS value segment (outside strings and urls).
|
|
109
|
-
* @param {boolean} rewritesEqualLengthSpelling Whether a spelling of the same length is worth switching to.
|
|
110
|
-
* @return {string} The segment with all colors shortened to their minimal form.
|
|
111
|
-
*/
|
|
112
|
-
function shortenColorValues (segment, rewritesEqualLengthSpelling = true) {
|
|
113
|
-
// Match "color-mix(" as a whole word, case-insensitive
|
|
114
|
-
const hasColorMix = /\bcolor-mix\(/i.test(segment);
|
|
115
|
-
return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
|
|
116
|
-
let channels;
|
|
117
|
-
if (match.startsWith('#')) {
|
|
118
|
-
channels = parseHex(match);
|
|
119
|
-
} else {
|
|
120
|
-
if (hasColorMix) {
|
|
121
|
-
return match;
|
|
122
|
-
}
|
|
123
|
-
const rgb = namedColors[match.toLowerCase()];
|
|
124
|
-
if (rgb) {
|
|
125
|
-
// transparent has alpha=0, all other named colors have alpha=1
|
|
126
|
-
const alpha = match.toLowerCase() === 'transparent' ? 0 : 1;
|
|
127
|
-
channels = [rgb[0], rgb[1], rgb[2], alpha];
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (!channels) {
|
|
131
|
-
return match;
|
|
132
|
-
}
|
|
133
|
-
const shortest = shortestColor(channels[0], channels[1], channels[2], channels[3]);
|
|
134
|
-
if (!rewritesEqualLengthSpelling && shortest.length >= match.length) {
|
|
135
|
-
return match;
|
|
136
|
-
}
|
|
137
|
-
return shortest;
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* @typedef {object} ValueSegment
|
|
143
|
-
* @property {string} text The segment's slice of the value.
|
|
144
|
-
* @property {boolean} isLiteral Whether the segment is a quoted string or a url() token.
|
|
145
|
-
*/
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Splits a CSS value into literal and syntax segments. A quoted string and a
|
|
149
|
-
* `url()` token are literals: they hold data rather than CSS syntax, so no pass
|
|
150
|
-
* may rewrite what is inside them. Everything between the literals is syntax.
|
|
151
|
-
*
|
|
152
|
-
* @param {string} value The full CSS value string.
|
|
153
|
-
* @return {Array} The value's segments, in order.
|
|
154
|
-
*/
|
|
155
|
-
function splitValueSegments (value) {
|
|
156
|
-
const segments = [];
|
|
157
|
-
let index = 0;
|
|
158
|
-
|
|
159
|
-
while (index < value.length) {
|
|
160
|
-
if (value[index] === '"' || value[index] === '\'') {
|
|
161
|
-
const end = skipQuotedString(value, index);
|
|
162
|
-
segments.push({ text: value.slice(index, end), isLiteral: true });
|
|
163
|
-
index = end;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (startsUrlToken(value, index)) {
|
|
168
|
-
// The `url(` token opens its parenthesized group at its fourth character
|
|
169
|
-
const end = skipParenthesizedGroup(value, index + 3);
|
|
170
|
-
segments.push({ text: value.slice(index, end), isLiteral: true });
|
|
171
|
-
index = end;
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const start = index;
|
|
176
|
-
while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrlToken(value, index)) {
|
|
177
|
-
index++;
|
|
178
|
-
}
|
|
179
|
-
segments.push({ text: value.slice(start, index), isLiteral: false });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return segments;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
|
|
187
|
-
*
|
|
188
|
-
* @param {string} value The full CSS value string.
|
|
189
|
-
* @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
|
|
190
|
-
* @return {string} The value with the replacer applied to all eligible segments.
|
|
191
|
-
*/
|
|
192
|
-
function replaceOutsideStringsAndUrls (value, replacer) {
|
|
193
|
-
return splitValueSegments(value).map((segment) => {
|
|
194
|
-
if (segment.isLiteral) {
|
|
195
|
-
return segment.text;
|
|
196
|
-
}
|
|
197
|
-
return replacer(segment.text);
|
|
198
|
-
}).join('');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Lowercases every hex color token in a CSS value, since uppercase hex digits
|
|
203
|
-
* compress worse and are equivalent to their lowercase form.
|
|
204
|
-
*
|
|
205
|
-
* @param {string} value The CSS value string.
|
|
206
|
-
* @return {string} The value with all hex color tokens lowercased.
|
|
207
|
-
*/
|
|
208
|
-
function lowercaseHexColors (value) {
|
|
209
|
-
return replaceOutsideStringsAndUrls(value, (segment) => {
|
|
210
|
-
// Match hex color tokens of 3 to 8 hex digits
|
|
211
|
-
return segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (hexColor) => {
|
|
212
|
-
return hexColor.toLowerCase();
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Restores the whitespace that a math function requires before a `+` or a `-`
|
|
219
|
-
* operator. Both operators have to be surrounded by whitespace to be read as
|
|
220
|
-
* operators rather than as the sign of the term that follows, and the
|
|
221
|
-
* whitespace before one that trails a closing parenthesis is removed together
|
|
222
|
-
* with the rest of the parenthesis padding.
|
|
223
|
-
*
|
|
224
|
-
* @param {string} value The CSS value string with parenthesis padding removed.
|
|
225
|
-
* @return {string} The value with the operator separator restored.
|
|
226
|
-
*/
|
|
227
|
-
function restoreSpaceBeforeMathOperators (value) {
|
|
228
|
-
return replaceOutsideStringsAndUrls(value, (segment) => {
|
|
229
|
-
// Match a closing parenthesis immediately followed by a `+` or `-` operator
|
|
230
|
-
return segment.replace(/\)(?=[+-])/g, ') ');
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Escapes the characters an unquoted url() token cannot hold, so the path can
|
|
236
|
-
* drop its quotes. A backslash already escaping a character is left alone, so
|
|
237
|
-
* a path that arrived escaped is never escaped a second time.
|
|
238
|
-
*
|
|
239
|
-
* @param {string} path The url path, without surrounding quotes.
|
|
240
|
-
* @return {string} The path with every bare space and parenthesis escaped.
|
|
241
|
-
*/
|
|
242
|
-
function escapeUrlPathCharacters (path) {
|
|
243
|
-
// Match each space and parenthesis that no escaping backslash precedes
|
|
244
|
-
return path.replace(/(?<!\\)([ ()])/g, '\\$1');
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Chooses the shortest valid representation for the path inside a `url(...)`
|
|
249
|
-
* token, weighing an unquoted form — its spaces and parentheses escaped —
|
|
250
|
-
* against a quoted form. Quotation marks can only ever appear in the quoted
|
|
251
|
-
* form, so a path holding one always keeps its wrapper.
|
|
252
|
-
*
|
|
253
|
-
* @param {string} path The resolved url path, without surrounding quotes.
|
|
254
|
-
* @return {string} The shortest valid url() content string.
|
|
255
|
-
*/
|
|
256
|
-
function formatUrlPath (path) {
|
|
257
|
-
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
258
|
-
const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
|
|
259
|
-
// Match a quotation mark, which an unquoted url() token cannot hold
|
|
260
|
-
if (/["']/.test(path)) {
|
|
261
|
-
return quotedForm;
|
|
262
|
-
}
|
|
263
|
-
const unquotedForm = escapeUrlPathCharacters(path);
|
|
264
|
-
// Each escaped character costs one backslash, so dropping the two quotation
|
|
265
|
-
// marks pays off whenever at most one character needs escaping
|
|
266
|
-
if (unquotedForm.length < quotedForm.length) {
|
|
267
|
-
return unquotedForm;
|
|
268
|
-
}
|
|
269
|
-
return quotedForm;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Produces the shortest valid contents for a single `url(...)` token from the
|
|
274
|
-
* raw text between its parentheses, stripping a leading current-directory
|
|
275
|
-
* indicator and normalizing quoting.
|
|
276
|
-
*
|
|
277
|
-
* @param {string} rawContent The trimmed text found between the url parentheses.
|
|
278
|
-
* @return {string} The minified url() content.
|
|
279
|
-
*/
|
|
280
|
-
function minifyUrlContent (rawContent) {
|
|
281
|
-
const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
|
|
282
|
-
let path = rawContent;
|
|
283
|
-
if (wasQuoted) {
|
|
284
|
-
const quote = rawContent[0];
|
|
285
|
-
if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
|
|
286
|
-
path = rawContent.slice(1, -1);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const minifiedSvgDataUri = minifySvgDataUri(path);
|
|
291
|
-
if (minifiedSvgDataUri) {
|
|
292
|
-
return minifiedSvgDataUri;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
|
|
296
|
-
path = path.replace(/^\.\//, '');
|
|
297
|
-
|
|
298
|
-
return formatUrlPath(path);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Builds a marker that cannot appear in a parsed CSS value. URL functions are
|
|
303
|
-
* temporarily replaced with these markers while generic string operations run.
|
|
304
|
-
*
|
|
305
|
-
* @param {number} index The URL token index.
|
|
306
|
-
* @return {string} The marker representing that URL token.
|
|
307
|
-
*/
|
|
308
|
-
function createUrlTokenMarker (index) {
|
|
309
|
-
return '\u0000csslop-url-' + index + '\u0000';
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Replaces URL tokens with stable markers so generic value normalization does
|
|
314
|
-
* not alter data carried inside a URL.
|
|
315
|
-
*
|
|
316
|
-
* @param {string} value The CSS value to protect.
|
|
317
|
-
* @return {object} The protected value and the original URL tokens.
|
|
318
|
-
*/
|
|
319
|
-
function protectUrlTokens (value) {
|
|
320
|
-
const urlTokens = [];
|
|
321
|
-
let protectedValue = '';
|
|
322
|
-
for (const segment of splitValueSegments(value)) {
|
|
323
|
-
const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
|
|
324
|
-
if (isUrlToken) {
|
|
325
|
-
protectedValue += createUrlTokenMarker(urlTokens.length);
|
|
326
|
-
urlTokens.push(segment.text);
|
|
327
|
-
} else {
|
|
328
|
-
protectedValue += segment.text;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return { value: protectedValue, urlTokens };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Restores the URL tokens removed by `protectUrlTokens`.
|
|
336
|
-
*
|
|
337
|
-
* @param {string} value The value containing URL markers.
|
|
338
|
-
* @param {Array} urlTokens The original URL token text.
|
|
339
|
-
* @return {string} The value with URL tokens restored.
|
|
340
|
-
*/
|
|
341
|
-
function restoreUrlTokens (value, urlTokens) {
|
|
342
|
-
let restoredValue = value;
|
|
343
|
-
for (let index = 0; index < urlTokens.length; index++) {
|
|
344
|
-
const marker = createUrlTokenMarker(index);
|
|
345
|
-
restoredValue = restoredValue.split(marker).join(urlTokens[index]);
|
|
346
|
-
}
|
|
347
|
-
return restoredValue;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Removes whitespace after protected URL tokens. A CSS URL token closes itself
|
|
352
|
-
* at `)`, so the following component remains distinguishable without a space.
|
|
353
|
-
*
|
|
354
|
-
* @param {string} value The protected CSS value.
|
|
355
|
-
* @param {Array} urlTokens The original URL token text.
|
|
356
|
-
* @return {string} The protected value without trailing URL spacing.
|
|
357
|
-
*/
|
|
358
|
-
function removeWhitespaceAfterUrlTokens (value, urlTokens) {
|
|
359
|
-
let compactValue = value;
|
|
360
|
-
for (let index = 0; index < urlTokens.length; index++) {
|
|
361
|
-
const marker = createUrlTokenMarker(index);
|
|
362
|
-
const markerIndex = compactValue.indexOf(marker);
|
|
363
|
-
if (markerIndex === -1) {
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
const contentStart = markerIndex + marker.length;
|
|
367
|
-
let nextContentIndex = contentStart;
|
|
368
|
-
while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
|
|
369
|
-
nextContentIndex++;
|
|
370
|
-
}
|
|
371
|
-
compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
|
|
372
|
-
}
|
|
373
|
-
return compactValue;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Removes an explicit default background position directly after a protected
|
|
378
|
-
* URL marker, where the ordinary close-parenthesis optimization cannot see it.
|
|
379
|
-
*
|
|
380
|
-
* @param {string} value The protected background value.
|
|
381
|
-
* @param {Array} urlTokens The original URL token text.
|
|
382
|
-
* @return {string} The value with redundant URL positions removed.
|
|
383
|
-
*/
|
|
384
|
-
function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
|
|
385
|
-
let normalizedValue = value;
|
|
386
|
-
for (let index = 0; index < urlTokens.length; index++) {
|
|
387
|
-
const marker = createUrlTokenMarker(index);
|
|
388
|
-
// Match a URL marker followed by the default 0 0 background-position pair.
|
|
389
|
-
const defaultPositionPattern = new RegExp(
|
|
390
|
-
marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
|
|
391
|
-
'g'
|
|
392
|
-
);
|
|
393
|
-
normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
|
|
394
|
-
}
|
|
395
|
-
return normalizedValue;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Rewrites every `url(...)` token in a CSS value to its shortest valid form,
|
|
400
|
-
* skipping any quoted strings so an embedded `url(` inside a string is ignored.
|
|
401
|
-
*
|
|
402
|
-
* @param {string} value The CSS value string potentially containing url() tokens.
|
|
403
|
-
* @return {string} The value with all url() tokens minified.
|
|
404
|
-
*/
|
|
405
|
-
function minifyUrls (value) {
|
|
406
|
-
let result = '';
|
|
407
|
-
let index = 0;
|
|
408
|
-
|
|
409
|
-
const consumeQuoted = (start) => {
|
|
410
|
-
const quote = value[start];
|
|
411
|
-
let end = start + 1;
|
|
412
|
-
while (end < value.length) {
|
|
413
|
-
if (value[end] === '\\') {
|
|
414
|
-
end += 2;
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
if (value[end] === quote) {
|
|
418
|
-
end++;
|
|
419
|
-
break;
|
|
420
|
-
}
|
|
421
|
-
end++;
|
|
422
|
-
}
|
|
423
|
-
return end;
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
const startsUrl = (start) => {
|
|
427
|
-
return value.slice(start, start + 4).toLowerCase() === 'url(';
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
while (index < value.length) {
|
|
431
|
-
if (value[index] === '"' || value[index] === '\'') {
|
|
432
|
-
const end = consumeQuoted(index);
|
|
433
|
-
result += value.slice(index, end);
|
|
434
|
-
index = end;
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (startsUrl(index)) {
|
|
439
|
-
const closingParenIndex = findMatchingParenthesis(value, index + 3);
|
|
440
|
-
if (closingParenIndex === -1) {
|
|
441
|
-
result += value.slice(index);
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
const inner = value.slice(index + 4, closingParenIndex).trim();
|
|
445
|
-
result += 'url(' + minifyUrlContent(inner) + ')';
|
|
446
|
-
index = closingParenIndex + 1;
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
result += value[index];
|
|
451
|
-
index++;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return result;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
|
|
459
|
-
*
|
|
460
|
-
* @param {string} val The raw CSS value string to normalize.
|
|
461
|
-
* @param {string} property The CSS property name, used for context-aware quote handling.
|
|
462
|
-
* @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
|
|
463
|
-
*/
|
|
464
|
-
function normalizeWhitespaceAndQuotes (val, property) {
|
|
465
|
-
const protectedUrls = protectUrlTokens(val);
|
|
466
|
-
val = protectedUrls.value;
|
|
467
|
-
|
|
468
|
-
// Unescape unicode (skip control characters — they must stay escaped in CSS strings).
|
|
469
|
-
// Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
|
|
470
|
-
if (isUnicodeCharset()) {
|
|
471
|
-
val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
|
|
472
|
-
return resolveUnicodeEscape(hex) ?? match;
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
// Normalize single-quoted strings to double-quoted
|
|
476
|
-
val = val.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
|
|
477
|
-
|
|
478
|
-
// Remove space between string literals
|
|
479
|
-
val = val.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
|
|
480
|
-
|
|
481
|
-
// Whitespace minification
|
|
482
|
-
val = val.replace(/\s*!\s*important/i, '!important');
|
|
483
|
-
// val = val.replace(/\s*([+*/=])\s*/g, '$1');
|
|
484
|
-
// Strip whitespace around commas
|
|
485
|
-
val = val.replace(/\s*([,])\s*/g, '$1');
|
|
486
|
-
// Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
|
|
487
|
-
val = val.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, str, paren) => {
|
|
488
|
-
if (str) {
|
|
489
|
-
return str;
|
|
490
|
-
}
|
|
491
|
-
return paren;
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
// Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
|
|
495
|
-
if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
|
|
496
|
-
// Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
|
|
497
|
-
val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
|
|
498
|
-
// Keep quotes around CSS generic font-family keywords — unquoted they mean something different
|
|
499
|
-
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)) {
|
|
500
|
-
return before + '"' + inner + '"';
|
|
501
|
-
}
|
|
502
|
-
return before + inner;
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
val = removeWhitespaceAfterUrlTokens(val, protectedUrls.urlTokens);
|
|
507
|
-
return restoreUrlTokens(val, protectedUrls.urlTokens);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* The OKLCH chroma value that `100%` resolves to, per CSS Color Level 4.
|
|
512
|
-
*
|
|
513
|
-
* @type {number}
|
|
514
|
-
*/
|
|
515
|
-
const OKLCH_CHROMA_PERCENT_REFERENCE = 0.4;
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Regex matching an `oklch()` function with three space-separated components
|
|
519
|
-
* and an optional slash-delimited alpha. Lightness and chroma accept numbers
|
|
520
|
-
* or percentages, hue accepts a number with an optional CSS angle unit, and
|
|
521
|
-
* every component accepts the `none` keyword.
|
|
522
|
-
*
|
|
523
|
-
* @type {RegExp}
|
|
524
|
-
*/
|
|
525
|
-
const OKLCH_FUNCTION_PATTERN = new RegExp(
|
|
526
|
-
'\\boklch\\(\\s*' +
|
|
527
|
-
'(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
|
|
528
|
-
'(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
|
|
529
|
-
'(none|-?(?:\\d+|\\d*\\.\\d+)(?:deg|grad|rad|turn)?)' +
|
|
530
|
-
'(?:\\s*/\\s*(none|-?(?:\\d+|\\d*\\.\\d+)%?))?' +
|
|
531
|
-
'\\s*\\)',
|
|
532
|
-
'gi'
|
|
533
|
-
);
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Parses an OKLCH lightness or chroma component into its numeric value.
|
|
537
|
-
* Missing components (`none`) resolve to zero, and percentages are scaled
|
|
538
|
-
* against the reference value for that component.
|
|
539
|
-
*
|
|
540
|
-
* @param {string} token The raw component token.
|
|
541
|
-
* @param {number} percentReference The value that `100%` represents for this component.
|
|
542
|
-
* @return {number|null} The numeric component value, or null when unparsable.
|
|
543
|
-
*/
|
|
544
|
-
function parseOklchComponent (token, percentReference) {
|
|
545
|
-
const normalized = token.trim().toLowerCase();
|
|
546
|
-
if (normalized === 'none') {
|
|
547
|
-
return 0;
|
|
548
|
-
}
|
|
549
|
-
const numeric = parseFloat(normalized);
|
|
550
|
-
if (!Number.isFinite(numeric)) {
|
|
551
|
-
return null;
|
|
552
|
-
}
|
|
553
|
-
if (normalized.endsWith('%')) {
|
|
554
|
-
return numeric / 100 * percentReference;
|
|
555
|
-
}
|
|
556
|
-
return numeric;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Parses an OKLCH hue component into degrees, treating `none` as zero.
|
|
561
|
-
*
|
|
562
|
-
* @param {string} token The raw hue token, optionally carrying an angle unit.
|
|
563
|
-
* @return {number|null} The hue in degrees, or null when unparsable.
|
|
564
|
-
*/
|
|
565
|
-
function parseOklchHue (token) {
|
|
566
|
-
const normalized = token.trim().toLowerCase();
|
|
567
|
-
if (normalized === 'none') {
|
|
568
|
-
return 0;
|
|
569
|
-
}
|
|
570
|
-
return parseAngleToDegrees(normalized);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Removes whitespace before a negative numeric channel in Lab-family colors.
|
|
575
|
-
* A preceding numeric token already separates the sign from the prior channel.
|
|
576
|
-
*
|
|
577
|
-
* @param {string} functionName The name of the color function.
|
|
578
|
-
* @param {string} value The minified function arguments.
|
|
579
|
-
* @return {string} The arguments with redundant channel spacing removed.
|
|
580
|
-
*/
|
|
581
|
-
function compactNegativeColorChannelSpacing (functionName, value) {
|
|
582
|
-
const normalizedFunctionName = functionName.toLowerCase();
|
|
583
|
-
if (!['lab', 'lch', 'oklab', 'oklch'].includes(normalizedFunctionName)) {
|
|
584
|
-
return value;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
let result = '';
|
|
588
|
-
let nestingDepth = 0;
|
|
589
|
-
for (let index = 0; index < value.length; index++) {
|
|
590
|
-
const character = value[index];
|
|
591
|
-
if (character === ' ' && nestingDepth === 0) {
|
|
592
|
-
const previousCharacter = result[result.length - 1];
|
|
593
|
-
const nextCharacter = value[index + 1];
|
|
594
|
-
const characterAfterSign = value[index + 2];
|
|
595
|
-
const characterAfterDecimal = value[index + 3];
|
|
596
|
-
const previousEndsNumber = (
|
|
597
|
-
isCssDigit(previousCharacter) ||
|
|
598
|
-
previousCharacter === '%'
|
|
599
|
-
);
|
|
600
|
-
const nextStartsNegativeNumber = (
|
|
601
|
-
nextCharacter === '-' &&
|
|
602
|
-
(
|
|
603
|
-
isCssDigit(characterAfterSign) ||
|
|
604
|
-
(
|
|
605
|
-
characterAfterSign === '.' &&
|
|
606
|
-
isCssDigit(characterAfterDecimal)
|
|
607
|
-
)
|
|
608
|
-
)
|
|
609
|
-
);
|
|
610
|
-
if (previousEndsNumber && nextStartsNegativeNumber) {
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
result += character;
|
|
615
|
-
if (character === '(') {
|
|
616
|
-
nestingDepth++;
|
|
617
|
-
} else if (character === ')') {
|
|
618
|
-
nestingDepth--;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return result;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Checks whether a character is an ASCII decimal digit.
|
|
626
|
-
*
|
|
627
|
-
* @param {string} character The character to inspect.
|
|
628
|
-
* @return {boolean} Whether the character is a digit.
|
|
629
|
-
*/
|
|
630
|
-
function isCssDigit (character) {
|
|
631
|
-
return character >= '0' && character <= '9';
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Converts OKLCH lightness and chroma percentages when doing so is shorter,
|
|
636
|
-
* including the separator each spelling needs before the hue.
|
|
637
|
-
*
|
|
638
|
-
* @param {string} value The normalized arguments of an `oklch()` function.
|
|
639
|
-
* @return {string} The arguments with shorter percentage spellings.
|
|
640
|
-
*/
|
|
641
|
-
function minifyOklchPercentages (value) {
|
|
642
|
-
const slashIndex = value.indexOf('/');
|
|
643
|
-
const componentsText = slashIndex === -1 ? value : value.slice(0, slashIndex);
|
|
644
|
-
const alphaText = slashIndex === -1 ? '' : value.slice(slashIndex + 1);
|
|
645
|
-
const components = componentsText.trim().split(' ');
|
|
646
|
-
if (components.length !== 3) {
|
|
647
|
-
return value;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
let [lightness, chroma, hue] = components;
|
|
651
|
-
if (lightness.endsWith('%')) {
|
|
652
|
-
lightness = formatCompactNumber(Number.parseFloat(lightness) / 100);
|
|
653
|
-
}
|
|
654
|
-
if (chroma.endsWith('%')) {
|
|
655
|
-
const convertedChroma = formatCompactNumber(
|
|
656
|
-
Number.parseFloat(chroma) / 100 * OKLCH_CHROMA_PERCENT_REFERENCE
|
|
657
|
-
);
|
|
658
|
-
const convertedWithHueSeparator = convertedChroma.length + 1;
|
|
659
|
-
if (chroma.length < convertedWithHueSeparator) {
|
|
660
|
-
return [lightness, chroma + hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
|
|
661
|
-
}
|
|
662
|
-
chroma = convertedChroma;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
return [lightness, chroma, hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Converts `oklch()` colors that fall inside the sRGB gamut to their hex
|
|
670
|
-
* equivalent when that is shorter. Out-of-gamut colors have no sRGB
|
|
671
|
-
* representation, so they are left in their native color space.
|
|
672
|
-
*
|
|
673
|
-
* @param {string} value The CSS value string that may contain oklch() colors.
|
|
674
|
-
* @return {string} The value with in-gamut oklch() colors replaced by hex.
|
|
675
|
-
*/
|
|
676
|
-
function convertOklchFunctionsToHex (value) {
|
|
677
|
-
return value.replace(OKLCH_FUNCTION_PATTERN, (match, lightnessToken, chromaToken, hueToken, alphaToken) => {
|
|
678
|
-
const lightness = parseOklchComponent(lightnessToken, 1);
|
|
679
|
-
const chroma = parseOklchComponent(chromaToken, OKLCH_CHROMA_PERCENT_REFERENCE);
|
|
680
|
-
const hue = parseOklchHue(hueToken);
|
|
681
|
-
if (lightness === null || chroma === null || hue === null) {
|
|
682
|
-
return match;
|
|
683
|
-
}
|
|
684
|
-
const alpha = alphaToken?.trim().toLowerCase() === 'none' ? 0 : parseAlphaString(alphaToken);
|
|
685
|
-
const hex = convertOklchToHex(lightness, chroma, hue, alpha);
|
|
686
|
-
if (!hex) {
|
|
687
|
-
return match; // out-of-gamut: keep native oklch form
|
|
688
|
-
}
|
|
689
|
-
if (hex.length < match.length) {
|
|
690
|
-
return hex;
|
|
691
|
-
}
|
|
692
|
-
return match;
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
|
|
698
|
-
* shortest hex equivalents and applies hex shortening.
|
|
699
|
-
*
|
|
700
|
-
* @param {string} val The CSS value string with potential color functions.
|
|
701
|
-
* @return {string} The value with color functions converted to hex where shorter.
|
|
702
|
-
*/
|
|
703
|
-
function convertColorsToHex (val) {
|
|
704
|
-
// Evaluate color-mix() expressions (before space minification to avoid nested-paren issues)
|
|
705
|
-
if (/\bcolor-mix\(/i.test(val)) {
|
|
706
|
-
const result = evaluateColorMix(val);
|
|
707
|
-
if (result) {
|
|
708
|
-
val = result;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Handle color(from ...) relative color syntax (identity case)
|
|
713
|
-
if (/\bcolor\(\s*from\b/i.test(val)) {
|
|
714
|
-
const result = evaluateRelativeColor(val);
|
|
715
|
-
if (result) {
|
|
716
|
-
val = result;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Convert in-gamut lab() (CIE Lab, D50) to hex when it produces a shorter representation
|
|
721
|
-
val = val.replace(/\blab\(\s*(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
|
|
722
|
-
const alpha = parseAlphaString(alphaStr);
|
|
723
|
-
const l = parseFloat(lStr);
|
|
724
|
-
const aNumber = parseFloat(aStr);
|
|
725
|
-
const a = aStr.endsWith('%') ? aNumber * 1.25 : aNumber;
|
|
726
|
-
const bNumber = parseFloat(bStr);
|
|
727
|
-
const b = bStr.endsWith('%') ? bNumber * 1.25 : bNumber;
|
|
728
|
-
const hex = convertLabToHex(l, a, b, alpha);
|
|
729
|
-
if (!hex) {
|
|
730
|
-
return match; // out-of-gamut: keep native lab form
|
|
731
|
-
}
|
|
732
|
-
if (hex.length < match.length) {
|
|
733
|
-
return hex;
|
|
734
|
-
}
|
|
735
|
-
return match;
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
// Convert in-gamut oklch() to hex before precision rounding, so the full authored precision is used
|
|
739
|
-
val = convertOklchFunctionsToHex(val);
|
|
740
|
-
|
|
741
|
-
// Minify whitespace and numeric precision inside wide-gamut and functional color notations
|
|
742
|
-
val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
|
|
743
|
-
// Collapse whitespace to single space
|
|
744
|
-
let minified = inner.replace(/\s+/g, ' ');
|
|
745
|
-
// Remove space after commas
|
|
746
|
-
minified = minified.replace(/, /g, ',');
|
|
747
|
-
// Remove spaces around slash separator (alpha delimiter)
|
|
748
|
-
minified = minified.replace(/ \/ /g, '/');
|
|
749
|
-
// Strip leading zeros from decimal numbers (e.g. 0.5 → .5)
|
|
750
|
-
minified = minified.replace(/\b0+(\.[\d]+)/g, '$1');
|
|
751
|
-
// Strip leading zeros from decimals preceded by a keyword (e.g. srgb 0.5 → srgb .5)
|
|
752
|
-
minified = minified.replace(/([A-Za-z]) 0+(\.[\d]+)/g, '$1 $2');
|
|
753
|
-
// Check if function uses a wide-gamut color space requiring higher numeric precision
|
|
754
|
-
const useWidePrecision = func.toLowerCase() === 'color' && /\b(srgb-linear|xyz-d65|xyz-d50|xyz)\b/i.test(inner);
|
|
755
|
-
// Round numbers with 3+ decimal places, using context-aware precision
|
|
756
|
-
minified = minified.replace(/(^|[\s(,/-])(-?\d*\.\d{3,})/g, (match, before, num) => {
|
|
757
|
-
const isAlpha = before === '/';
|
|
758
|
-
const absoluteValue = Math.abs(parseFloat(num));
|
|
759
|
-
// Check if function is a Lab/LCH color notation with a large channel value (less precision needed)
|
|
760
|
-
const isLargeLabValue = /^(?:lch|lab|oklch|oklab)$/i.test(func) && absoluteValue >= 10;
|
|
761
|
-
let precision;
|
|
762
|
-
if (isAlpha) {
|
|
763
|
-
precision = 3;
|
|
764
|
-
} else if (isLargeLabValue) {
|
|
765
|
-
precision = 1;
|
|
766
|
-
} else if (useWidePrecision) {
|
|
767
|
-
precision = 4;
|
|
768
|
-
} else {
|
|
769
|
-
precision = 3;
|
|
770
|
-
}
|
|
771
|
-
const factor = Math.pow(10, precision);
|
|
772
|
-
const roundedNum = Math.round(parseFloat(num) * factor) / factor;
|
|
773
|
-
// Strip trailing zeros and trailing decimal point from the rounded number
|
|
774
|
-
let rounded = roundedNum.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
|
|
775
|
-
if (rounded.startsWith('0.')) {
|
|
776
|
-
rounded = rounded.substring(1);
|
|
777
|
-
}
|
|
778
|
-
if (rounded.startsWith('-0.')) {
|
|
779
|
-
rounded = '-' + rounded.substring(2);
|
|
780
|
-
}
|
|
781
|
-
return before + rounded;
|
|
782
|
-
});
|
|
783
|
-
// Remove trailing ".0" from numbers so integer channel values stay integer
|
|
784
|
-
// (e.g. display-p3 1.0 0.0 0.0 becomes display-p3 1 0 0)
|
|
785
|
-
minified = minified.replace(/(-?\d*)\.0\b/g, (match, integer) => {
|
|
786
|
-
if (integer === '' || integer === '-' || integer === '-0') {
|
|
787
|
-
return '0';
|
|
788
|
-
}
|
|
789
|
-
return integer;
|
|
790
|
-
});
|
|
791
|
-
minified = compactNegativeColorChannelSpacing(func, minified);
|
|
792
|
-
if (func.toLowerCase() === 'oklch') {
|
|
793
|
-
minified = minifyOklchPercentages(minified);
|
|
794
|
-
}
|
|
795
|
-
return func + '(' + minified.trim() + ')';
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
// Convert in-gamut oklab() to hex when it produces a shorter representation
|
|
799
|
-
// A negative channel may touch the preceding number because its sign separates the CSS number tokens.
|
|
800
|
-
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) => {
|
|
801
|
-
const alpha = parseAlphaString(alphaStr);
|
|
802
|
-
const hex = convertOklabToHex(parseFloat(lStr), parseFloat(aStr), parseFloat(bStr), alpha);
|
|
803
|
-
if (!hex) {
|
|
804
|
-
return match; // out-of-gamut: keep native oklab form
|
|
805
|
-
}
|
|
806
|
-
if (hex.length < match.length) {
|
|
807
|
-
return hex;
|
|
808
|
-
}
|
|
809
|
-
return match;
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
// Handle 'none' keyword in rgb/hsl functions (CSS Color Level 4: treated as 0)
|
|
813
|
-
val = val.replace(/\b(rgba?|hsla?)\([^)]*\)/gi, (match) => {
|
|
814
|
-
return match.replace(/\bnone\b/gi, '0');
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
// hwb() → hex (percent signs optional for whiteness/blackness, values always treated as percentages)
|
|
818
|
-
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) => {
|
|
819
|
-
const [r, g, b] = hwbToRgbChannels(parseFloat(hStr), parseFloat(wStr) / 100, parseFloat(bStr) / 100);
|
|
820
|
-
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
// rgb()/rgba() space syntax → hex, case-insensitive (handles decimals and any alpha)
|
|
824
|
-
val = val.replace(/\brgba?\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, rStr, gStr, bStr, aStr) => {
|
|
825
|
-
const r = Math.round(parseFloat(rStr));
|
|
826
|
-
const g = Math.round(parseFloat(gStr));
|
|
827
|
-
const b = Math.round(parseFloat(bStr));
|
|
828
|
-
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
// hsl()/hsla() space syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
|
|
832
|
-
val = val.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%?\s+((?:\d+|\d*\.\d+))%?(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hStr, sStr, lStr, aStr) => {
|
|
833
|
-
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
834
|
-
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
// rgba() comma syntax → hex, case-insensitive (handles any alpha)
|
|
838
|
-
val = val.replace(/\brgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, rStr, gStr, bStr, aStr) => {
|
|
839
|
-
const r = parseInt(rStr, 10);
|
|
840
|
-
const g = parseInt(gStr, 10);
|
|
841
|
-
const b = parseInt(bStr, 10);
|
|
842
|
-
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
// hsla() comma syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
|
|
846
|
-
val = val.replace(/\bhsla\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, hStr, sStr, lStr, aStr) => {
|
|
847
|
-
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
848
|
-
return rgbaToHex(r, g, b, parseAlphaString(aStr));
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
// hsl()/hsla() comma syntax without alpha → hex, case-insensitive (percent signs optional, values always treated as percentages)
|
|
852
|
-
val = val.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*\)/gi, (match, hStr, sStr, lStr) => {
|
|
853
|
-
const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
|
|
854
|
-
return rgbaToHex(r, g, b, 1);
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
// rgb() comma syntax → hex, case-insensitive
|
|
858
|
-
val = val.replace(/\brgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi, (match, r, g, b) => {
|
|
859
|
-
return rgbaToHex(parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), 1);
|
|
860
|
-
});
|
|
861
|
-
return val;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/**
|
|
865
|
-
* Map from background-position keyword names to their equivalent percent values.
|
|
866
|
-
*
|
|
867
|
-
* @type {{[key: string]: string}}
|
|
868
|
-
*/
|
|
869
|
-
const POSITION_KEYWORD_TO_PERCENT = {
|
|
870
|
-
left: '0',
|
|
871
|
-
center: '50%',
|
|
872
|
-
right: '100%',
|
|
873
|
-
top: '0',
|
|
874
|
-
bottom: '100%'
|
|
875
|
-
};
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Converts background-position keyword values to their shorter percent
|
|
879
|
-
* equivalents when possible. Single Y-axis keywords (top, bottom) and
|
|
880
|
-
* multi-value offset syntax (3 or 4 values) are left unchanged.
|
|
881
|
-
*
|
|
882
|
-
* @param {string} value The background-position value string.
|
|
883
|
-
* @return {string} The value with keywords converted to percents where shorter.
|
|
884
|
-
*/
|
|
885
|
-
function convertBackgroundPositionKeywords (value) {
|
|
886
|
-
// Split on whitespace to determine the number of position parts
|
|
887
|
-
const parts = value.split(/\s+/);
|
|
888
|
-
|
|
889
|
-
// 3 or 4 value syntax uses keyword offsets, retain keywords
|
|
890
|
-
if (parts.length >= 3) {
|
|
891
|
-
return value;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
if (parts.length === 1) {
|
|
895
|
-
const keyword = parts[0].toLowerCase();
|
|
896
|
-
// Y-axis-only keywords (top, bottom) can't be expressed as a single X-axis percent
|
|
897
|
-
const isYAxisOnly = keyword === 'top' || keyword === 'bottom';
|
|
898
|
-
if (isYAxisOnly) {
|
|
899
|
-
return value;
|
|
900
|
-
}
|
|
901
|
-
if (POSITION_KEYWORD_TO_PERCENT[keyword] !== undefined) {
|
|
902
|
-
return POSITION_KEYWORD_TO_PERCENT[keyword];
|
|
903
|
-
}
|
|
904
|
-
return value;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
if (parts.length === 2) {
|
|
908
|
-
const firstKeyword = parts[0].toLowerCase();
|
|
909
|
-
const secondKeyword = parts[1].toLowerCase();
|
|
910
|
-
const firstIsPositionKeyword = POSITION_KEYWORD_TO_PERCENT[firstKeyword] !== undefined;
|
|
911
|
-
const secondIsPositionKeyword = POSITION_KEYWORD_TO_PERCENT[secondKeyword] !== undefined;
|
|
912
|
-
|
|
913
|
-
if (firstIsPositionKeyword && secondIsPositionKeyword) {
|
|
914
|
-
const firstPercent = POSITION_KEYWORD_TO_PERCENT[firstKeyword];
|
|
915
|
-
const secondPercent = POSITION_KEYWORD_TO_PERCENT[secondKeyword];
|
|
916
|
-
// Collapse to single value when Y is center (50%), since a single value defaults Y to 50%
|
|
917
|
-
if (secondPercent === '50%') {
|
|
918
|
-
return firstPercent;
|
|
919
|
-
}
|
|
920
|
-
return firstPercent + ' ' + secondPercent;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
return value;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* The power of ten that separates a time in seconds from the same time in
|
|
929
|
-
* milliseconds, keyed by the lowercased time unit.
|
|
930
|
-
*
|
|
931
|
-
* @type {{[unit: string]: number}}
|
|
932
|
-
*/
|
|
933
|
-
const TIME_UNIT_POWERS = {
|
|
934
|
-
s: 0,
|
|
935
|
-
ms: -3
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Rewrites one time token in whichever of seconds or milliseconds spells it
|
|
940
|
-
* shortest, scientific notation included. Every conversion moves the exponent
|
|
941
|
-
* of the exact digits, so no precision is lost however large the time is.
|
|
942
|
-
* Seconds win a tie, and a zero time becomes `0s`.
|
|
943
|
-
*
|
|
944
|
-
* @param {string} text The text of a dimension token.
|
|
945
|
-
* @return {string} The shortest spelling of the same time.
|
|
946
|
-
*/
|
|
947
|
-
function shortenTimeToken (text) {
|
|
948
|
-
const parts = parseNumericParts(text);
|
|
949
|
-
const writtenUnit = parts?.unit.toLowerCase();
|
|
950
|
-
if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
|
|
951
|
-
return text;
|
|
952
|
-
}
|
|
953
|
-
if (!parts.digits) {
|
|
954
|
-
return '0s';
|
|
955
|
-
}
|
|
956
|
-
const writtenSpelling = shortenNumericText(text, true);
|
|
957
|
-
const secondsSpelling = formatShortestNumber(
|
|
958
|
-
scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
|
|
959
|
-
true
|
|
960
|
-
);
|
|
961
|
-
const millisecondsSpelling = formatShortestNumber(
|
|
962
|
-
scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
|
|
963
|
-
true
|
|
964
|
-
);
|
|
965
|
-
// Seconds are the conventional unit, so they win a tie with milliseconds
|
|
966
|
-
const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
|
|
967
|
-
return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Reports whether rewriting a number keeps its numeric type. A number written
|
|
972
|
-
* with a decimal point or an exponent has the `<number>` type, while one
|
|
973
|
-
* written with digits alone is an `<integer>`. A custom property can be
|
|
974
|
-
* substituted into, or registered with, a grammar that tells the two apart, so
|
|
975
|
-
* `100.0` cannot become `100` there.
|
|
976
|
-
*
|
|
977
|
-
* @param {string} originalNumber The number as it was written.
|
|
978
|
-
* @param {string} rewrittenNumber The number as it would be rewritten.
|
|
979
|
-
* @return {boolean} True when both spellings have the same type.
|
|
980
|
-
*/
|
|
981
|
-
function keepsNumberType (originalNumber, rewrittenNumber) {
|
|
982
|
-
return isIntegerSpelling(originalNumber) === isIntegerSpelling(rewrittenNumber);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* Reports whether a number is spelled as an `<integer>`: digits with an
|
|
987
|
-
* optional sign, and no decimal point or exponent.
|
|
988
|
-
*
|
|
989
|
-
* @param {string} number The number as written.
|
|
990
|
-
* @return {boolean} True when the spelling is an integer.
|
|
991
|
-
*/
|
|
992
|
-
function isIntegerSpelling (number) {
|
|
993
|
-
// An optional sign followed by nothing but digits
|
|
994
|
-
return /^[+-]?\d+$/.test(number);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* Converts each time in a value between seconds and milliseconds, whichever
|
|
999
|
-
* is the shorter string.
|
|
1000
|
-
*
|
|
1001
|
-
* @param {string} value The CSS value string potentially containing time values.
|
|
1002
|
-
* @return {string} The value with each time in its shortest unit.
|
|
1003
|
-
*/
|
|
1004
|
-
function convertTimesToShortestUnit (value) {
|
|
1005
|
-
return tokenizeCssValue(value).map((token) => {
|
|
1006
|
-
if (token.type !== 'dimension') {
|
|
1007
|
-
return token.text;
|
|
1008
|
-
}
|
|
1009
|
-
return shortenTimeToken(token.text);
|
|
1010
|
-
}).join('');
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Matches an absolute CSS length token: an optionally signed number followed by
|
|
1015
|
-
* an absolute length unit. The lookbehind rejects digits that belong to a larger
|
|
1016
|
-
* identifier, such as the custom property name in `var(--size-2in)`, where the
|
|
1017
|
-
* digits and unit do not form a value of their own.
|
|
1018
|
-
*
|
|
1019
|
-
* @type {RegExp}
|
|
1020
|
-
*/
|
|
1021
|
-
const ABSOLUTE_LENGTH_PATTERN = /(?<![\w#.%-])(-?(?:\d+|\d*\.\d+))(pt|pc|in|cm|mm|q)\b/gi;
|
|
5
|
+
import { isCustomProperty } from '../utilities.js';
|
|
1022
6
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
7
|
+
import { convertColorsToHex } from './color/functions.js';
|
|
8
|
+
import { simplifyEquivalentLightDarkFunctions } from './color/light-dark.js';
|
|
9
|
+
import { minifyRelativeColorSyntax } from './color/relative.js';
|
|
10
|
+
import {
|
|
11
|
+
lowercaseHexColors,
|
|
12
|
+
shortenColorValues
|
|
13
|
+
} from './color/shorten.js';
|
|
14
|
+
import { minifyGradients } from './gradients.js';
|
|
15
|
+
import { shortenLengthyInitialKeyword } from './initial-keywords.js';
|
|
16
|
+
import {
|
|
17
|
+
normalizeMathFunctions,
|
|
18
|
+
simplifyStandaloneCalc
|
|
19
|
+
} from './math/index.js';
|
|
20
|
+
import { keepsNumberType } from './numbers.js';
|
|
21
|
+
import { POSITION_AREA_SHORTHANDS } from './positions.js';
|
|
22
|
+
import {
|
|
23
|
+
applyPropertyOptimizations,
|
|
24
|
+
PUNCTUATED_COMPONENT_PROPERTIES
|
|
25
|
+
} from './property-optimizations.js';
|
|
26
|
+
import { isQuotesNoneEquivalent } from './quotes.js';
|
|
27
|
+
import { replaceOutsideStringsAndUrls } from './segments.js';
|
|
28
|
+
import { formatResolvedNumber } from './shared.js';
|
|
29
|
+
import {
|
|
30
|
+
elideRedundantSeparators,
|
|
31
|
+
shortenDimensionNumbers
|
|
32
|
+
} from './tokens.js';
|
|
33
|
+
import { optimizeUnicodeRange } from './unicode-range.js';
|
|
34
|
+
import {
|
|
35
|
+
minifyUrls,
|
|
36
|
+
protectUrlTokens,
|
|
37
|
+
removeWhitespaceAfterUrlTokens,
|
|
38
|
+
restoreUrlTokens
|
|
39
|
+
} from './urls.js';
|
|
40
|
+
import { normalizeWhitespaceAndQuotes } from './whitespace.js';
|
|
1031
41
|
|
|
1032
42
|
/**
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
* the original. Normalizing to px also improves compression by reducing the
|
|
1036
|
-
* number of distinct unit strings in the output.
|
|
43
|
+
* Simplifies the `calc()` expressions that add a zero percentage to a
|
|
44
|
+
* non-zero one, dropping the additive identity term.
|
|
1037
45
|
*
|
|
1038
|
-
* @param {string} value
|
|
1039
|
-
* @return {string} The
|
|
1040
|
-
*/
|
|
1041
|
-
function
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
46
|
+
* @param {string} value The CSS value string.
|
|
47
|
+
* @return {string} The value with zero-percent additive terms removed.
|
|
48
|
+
*/
|
|
49
|
+
function removeZeroPercentTermsFromCalc (value) {
|
|
50
|
+
// Match a calc() whose contents hold no further parentheses
|
|
51
|
+
return value.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
|
|
52
|
+
// Collapse whitespace inside calc expression
|
|
53
|
+
const compactInner = inner.replace(/\s+/g, ' ').trim();
|
|
54
|
+
// Extract all percentage terms from the expression
|
|
55
|
+
const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
|
|
56
|
+
const hasNonZeroPercent = percentTerms.some((term) => {
|
|
57
|
+
return Math.abs(parseFloat(term)) > 0;
|
|
58
|
+
});
|
|
59
|
+
if (!hasNonZeroPercent) {
|
|
60
|
+
return match;
|
|
1051
61
|
}
|
|
1052
|
-
|
|
62
|
+
// Remove trailing "+ 0%" and leading "0% +" additive identity terms
|
|
63
|
+
return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
|
|
1053
64
|
});
|
|
1054
65
|
}
|
|
1055
66
|
|
|
1056
67
|
/**
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
* @type {Set<string>}
|
|
1061
|
-
*/
|
|
1062
|
-
const NUMERIC_INITIAL_PROPERTIES = new Set([
|
|
1063
|
-
'opacity',
|
|
1064
|
-
'z-index',
|
|
1065
|
-
'flex-grow',
|
|
1066
|
-
'flex-shrink',
|
|
1067
|
-
'order',
|
|
1068
|
-
'line-height',
|
|
1069
|
-
'zoom'
|
|
1070
|
-
]);
|
|
1071
|
-
|
|
1072
|
-
/**
|
|
1073
|
-
* Properties whose `initial` value is zero.
|
|
1074
|
-
*
|
|
1075
|
-
* @type {Set<string>}
|
|
1076
|
-
*/
|
|
1077
|
-
const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Properties whose `initial` value is `auto`.
|
|
1081
|
-
*
|
|
1082
|
-
* @type {Set<string>}
|
|
1083
|
-
*/
|
|
1084
|
-
const AUTO_INITIAL_PROPERTIES = new Set([
|
|
1085
|
-
'min-width',
|
|
1086
|
-
'min-height',
|
|
1087
|
-
'hyphenate-limit-chars'
|
|
1088
|
-
]);
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Properties whose initial value is a single keyword longer than the global
|
|
1092
|
-
* `initial` keyword itself. These replacements are applied when serializing
|
|
1093
|
-
* declarations, because shorthand builders reconstruct their value from the
|
|
1094
|
-
* actual longhand keywords, and a CSS-wide keyword such as `initial` is only
|
|
1095
|
-
* valid as a declaration's entire value.
|
|
1096
|
-
*
|
|
1097
|
-
* @type {Map<string, string>}
|
|
1098
|
-
*/
|
|
1099
|
-
const LENGTHY_INITIAL_KEYWORDS = new Map([
|
|
1100
|
-
['background-clip', 'border-box'],
|
|
1101
|
-
['background-origin', 'padding-box'],
|
|
1102
|
-
['border-collapse', 'separate'],
|
|
1103
|
-
['border-color', 'currentcolor'],
|
|
1104
|
-
['border-top-color', 'currentcolor'],
|
|
1105
|
-
['border-right-color', 'currentcolor'],
|
|
1106
|
-
['border-bottom-color', 'currentcolor'],
|
|
1107
|
-
['border-left-color', 'currentcolor'],
|
|
1108
|
-
['box-sizing', 'content-box'],
|
|
1109
|
-
['mask-clip', 'border-box'],
|
|
1110
|
-
['mask-origin', 'border-box'],
|
|
1111
|
-
['vertical-align', 'baseline'],
|
|
1112
|
-
['writing-mode', 'horizontal-tb']
|
|
1113
|
-
]);
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* Properties whose value is a time, where a millisecond amount may be worth
|
|
1117
|
-
* rewriting in seconds.
|
|
1118
|
-
*
|
|
1119
|
-
* @type {Set<string>}
|
|
1120
|
-
*/
|
|
1121
|
-
const TIME_PROPERTIES = new Set([
|
|
1122
|
-
'transition',
|
|
1123
|
-
'transition-duration',
|
|
1124
|
-
'transition-delay',
|
|
1125
|
-
'animation',
|
|
1126
|
-
'animation-duration',
|
|
1127
|
-
'animation-delay'
|
|
1128
|
-
]);
|
|
1129
|
-
|
|
1130
|
-
/**
|
|
1131
|
-
* Properties whose grammar delimits its own components, either with punctuation
|
|
1132
|
-
* (the `/` and `,` of the `background`, `mask`, and `src` layers) or by taking
|
|
1133
|
-
* nothing but functions (`transform`). Their components stay readable without
|
|
1134
|
-
* the whitespace that follows a closing parenthesis, so that whitespace is left
|
|
1135
|
-
* elided and each of them restores only the separators its grammar still needs.
|
|
1136
|
-
*
|
|
1137
|
-
* @type {Set<string>}
|
|
1138
|
-
*/
|
|
1139
|
-
const PUNCTUATED_COMPONENT_PROPERTIES = new Set([
|
|
1140
|
-
'background',
|
|
1141
|
-
'mask',
|
|
1142
|
-
'src',
|
|
1143
|
-
'transform'
|
|
1144
|
-
]);
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Reports whether a property is a custom property. A custom property holds an
|
|
1148
|
-
* arbitrary token stream rather than a typed value, so every one of its tokens,
|
|
1149
|
-
* whitespace included, is part of what it stores.
|
|
1150
|
-
*
|
|
1151
|
-
* @param {string} property The CSS property name.
|
|
1152
|
-
* @return {boolean} Whether the property is a custom property.
|
|
1153
|
-
*/
|
|
1154
|
-
function isCustomProperty (property) {
|
|
1155
|
-
return Boolean(property) && property.startsWith('--');
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
/**
|
|
1159
|
-
* Matches one offset of a position: an edge keyword or a numeric distance.
|
|
1160
|
-
*
|
|
1161
|
-
* @type {string}
|
|
1162
|
-
*/
|
|
1163
|
-
const POSITION_OFFSET_SOURCE = '(?:left|center|right|top|bottom|[+-]?(?:\\d+|\\d*\\.\\d+)(?:%|[a-z]+)?)';
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Matches a whole position component, which is one or two of those offsets.
|
|
1167
|
-
*
|
|
1168
|
-
* @type {string}
|
|
1169
|
-
*/
|
|
1170
|
-
const POSITION_COMPONENT_SOURCE = '(' + POSITION_OFFSET_SOURCE + '(?:\\s+' + POSITION_OFFSET_SOURCE + ')?)';
|
|
1171
|
-
|
|
1172
|
-
/**
|
|
1173
|
-
* Matches everything a `url()` holds up to but not including its own closing
|
|
1174
|
-
* parenthesis. A url is written without nested parentheses, so the first one
|
|
1175
|
-
* that closes it is the end of the token.
|
|
1176
|
-
*
|
|
1177
|
-
* @type {string}
|
|
1178
|
-
*/
|
|
1179
|
-
const URL_TOKEN_BODY_SOURCE = 'url\\([^()]*';
|
|
1180
|
-
|
|
1181
|
-
/**
|
|
1182
|
-
* Matches a close-paren that ends an image function, followed by a separated
|
|
1183
|
-
* position that no slash follows. The parenthesis that ends a `url()` is
|
|
1184
|
-
* skipped, since that one closes a token rather than a function.
|
|
1185
|
-
*
|
|
1186
|
-
* @type {RegExp}
|
|
1187
|
-
*/
|
|
1188
|
-
const SEPARATED_FUNCTION_POSITION_PATTERN = new RegExp('(?<!' + URL_TOKEN_BODY_SOURCE + ')\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?!\\/)', 'gi');
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* Matches a `url()` separated from the position that follows it, which is the
|
|
1192
|
-
* separator a url does not need.
|
|
1193
|
-
*
|
|
1194
|
-
* @type {RegExp}
|
|
1195
|
-
*/
|
|
1196
|
-
const SEPARATED_URL_POSITION_PATTERN = new RegExp('(' + URL_TOKEN_BODY_SOURCE + '\\))\\s+' + POSITION_COMPONENT_SOURCE, 'gi');
|
|
1197
|
-
|
|
1198
|
-
/**
|
|
1199
|
-
* Matches a close-paren separated from a position that a slash follows, where
|
|
1200
|
-
* the slash already delimits the position from the size behind it.
|
|
1201
|
-
*
|
|
1202
|
-
* @type {RegExp}
|
|
1203
|
-
*/
|
|
1204
|
-
const SEPARATED_IMAGE_SIZE_POSITION_PATTERN = new RegExp('\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?=\\/)', 'gi');
|
|
1205
|
-
|
|
1206
|
-
/**
|
|
1207
|
-
* Normalizes the separator between the image of a layer and the position that
|
|
1208
|
-
* follows it. Both `background` and `mask` take a `<position> [ / <size> ]`
|
|
1209
|
-
* component after their image. The closing parenthesis already delimits a
|
|
1210
|
-
* function token from the position, and neither a position that a `/` follows
|
|
1211
|
-
* nor one that follows a `url()` needs a separator either.
|
|
1212
|
-
*
|
|
1213
|
-
* @param {string} value The layered image value, with the parenthesis padding removed.
|
|
1214
|
-
* @return {string} The value with the position separator normalized.
|
|
1215
|
-
*/
|
|
1216
|
-
function normalizeImagePositionSeparator (value) {
|
|
1217
|
-
const joinedToFunction = value.replace(SEPARATED_FUNCTION_POSITION_PATTERN, ')$1');
|
|
1218
|
-
const joinedToUrl = joinedToFunction.replace(SEPARATED_URL_POSITION_PATTERN, '$1$2');
|
|
1219
|
-
return joinedToUrl.replace(SEPARATED_IMAGE_SIZE_POSITION_PATTERN, ')$1');
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
/**
|
|
1223
|
-
* Matches the keywords that state a border line style.
|
|
1224
|
-
*
|
|
1225
|
-
* @type {string}
|
|
1226
|
-
*/
|
|
1227
|
-
const BORDER_STYLE_KEYWORD_SOURCE = 'solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none';
|
|
1228
|
-
|
|
1229
|
-
/**
|
|
1230
|
-
* Matches the ways a border line width is stated: one of its three keywords, a
|
|
1231
|
-
* length, or a math function that resolves to one.
|
|
1232
|
-
*
|
|
1233
|
-
* @type {string}
|
|
1234
|
-
*/
|
|
1235
|
-
const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* Matches a `border` value that states its line style in front of its line
|
|
1239
|
-
* width, with the two captured so they can be swapped.
|
|
1240
|
-
*
|
|
1241
|
-
* @type {RegExp}
|
|
1242
|
-
*/
|
|
1243
|
-
const BORDER_STYLE_BEFORE_WIDTH_PATTERN = new RegExp(
|
|
1244
|
-
'^(' + BORDER_STYLE_KEYWORD_SOURCE + ')\\s+(' + BORDER_WIDTH_SOURCE + ')(?=\\s|$)',
|
|
1245
|
-
'i'
|
|
1246
|
-
);
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* Rewrites a `border` value that leads with its line style into the canonical
|
|
1250
|
-
* width-style-color order. The width has to be recognized before the two are
|
|
1251
|
-
* swapped: `border` states its components in any order, so the component behind
|
|
1252
|
-
* the style may be a color instead, as in `solid #8aadf4 1px`, and that one
|
|
1253
|
-
* belongs behind the width rather than in front of it.
|
|
1254
|
-
*
|
|
1255
|
-
* @param {string} value The `border` value.
|
|
1256
|
-
* @return {string} The value with its width stated before its style.
|
|
1257
|
-
*/
|
|
1258
|
-
function reorderBorderWidthBeforeStyle (value) {
|
|
1259
|
-
return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Matches a trailing `!important` flag, so the components in front of it can be
|
|
1264
|
-
* rewritten without the flag being mistaken for part of the last one.
|
|
68
|
+
* Matches a value that is a single number with an optional unit, capturing
|
|
69
|
+
* the number and the unit.
|
|
1265
70
|
*
|
|
1266
71
|
* @type {RegExp}
|
|
1267
72
|
*/
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
/**
|
|
1271
|
-
* Separates a value from the `!important` flag it may end with.
|
|
1272
|
-
*
|
|
1273
|
-
* @param {string} value The declaration value, possibly flagged important.
|
|
1274
|
-
* @return {object} The components in front of the flag, and the flag itself or an empty string.
|
|
1275
|
-
*/
|
|
1276
|
-
function splitImportantSuffix (value) {
|
|
1277
|
-
const match = value.match(IMPORTANT_SUFFIX_PATTERN);
|
|
1278
|
-
if (!match) {
|
|
1279
|
-
return { components: value, importantSuffix: '' };
|
|
1280
|
-
}
|
|
1281
|
-
return {
|
|
1282
|
-
components: value.slice(0, match.index).trim(),
|
|
1283
|
-
importantSuffix: match[0]
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
/**
|
|
1288
|
-
* Collapses a `hyphenate-limit-chars` value to its shortest equivalent form.
|
|
1289
|
-
* The property states up to three components: the shortest word that may be
|
|
1290
|
-
* hyphenated, then the fewest characters allowed before a hyphen, then the
|
|
1291
|
-
* fewest allowed after it. An unstated "before" component is read as `auto`,
|
|
1292
|
-
* and an unstated "after" component repeats whatever "before" holds, so a
|
|
1293
|
-
* trailing component that only restates the default it stands in for is
|
|
1294
|
-
* redundant.
|
|
1295
|
-
*
|
|
1296
|
-
* @param {string} value The `hyphenate-limit-chars` value.
|
|
1297
|
-
* @return {string} The value with its redundant trailing components dropped.
|
|
1298
|
-
*/
|
|
1299
|
-
function collapseHyphenateLimitChars (value) {
|
|
1300
|
-
const {
|
|
1301
|
-
components,
|
|
1302
|
-
importantSuffix
|
|
1303
|
-
} = splitImportantSuffix(value);
|
|
1304
|
-
const parts = components.split(' ').filter(Boolean);
|
|
1305
|
-
if (hasSubstitutedParts(parts)) {
|
|
1306
|
-
return value;
|
|
1307
|
-
}
|
|
1308
|
-
const isAfterMatchingBefore = (
|
|
1309
|
-
parts.length === 3 &&
|
|
1310
|
-
parts[2].toLowerCase() === parts[1].toLowerCase()
|
|
1311
|
-
);
|
|
1312
|
-
if (isAfterMatchingBefore) {
|
|
1313
|
-
parts.pop();
|
|
1314
|
-
}
|
|
1315
|
-
const isBeforeLeftAtDefault = (
|
|
1316
|
-
parts.length === 2 &&
|
|
1317
|
-
parts[1].toLowerCase() === 'auto'
|
|
1318
|
-
);
|
|
1319
|
-
if (isBeforeLeftAtDefault) {
|
|
1320
|
-
parts.pop();
|
|
1321
|
-
}
|
|
1322
|
-
return parts.join(' ') + importantSuffix;
|
|
1323
|
-
}
|
|
73
|
+
const STANDALONE_NUMBER_PATTERN = /^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i;
|
|
1324
74
|
|
|
1325
75
|
/**
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
76
|
+
* Rounds a value that is a standalone number with an optional unit into the
|
|
77
|
+
* character budget a number is written within. A custom property only takes
|
|
78
|
+
* the rounding when it keeps the number's type, and a tiny dimension whose
|
|
79
|
+
* significant digits rounding would lose keeps its exponent form instead.
|
|
1330
80
|
*
|
|
1331
|
-
* @param {
|
|
1332
|
-
* @
|
|
81
|
+
* @param {string} value The CSS value string.
|
|
82
|
+
* @param {string} property The CSS property name.
|
|
83
|
+
* @return {string} The rounded value, or the value unchanged when it is not a standalone number.
|
|
1333
84
|
*/
|
|
1334
|
-
function
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
// Custom properties hold an arbitrary token stream that JavaScript can read
|
|
1338
|
-
// back, so their keywords are left exactly as written.
|
|
1339
|
-
if (isCustomProperty(property)) {
|
|
1340
|
-
return value;
|
|
1341
|
-
}
|
|
1342
|
-
// A keyword spelling the property's own initial value is shorter as the
|
|
1343
|
-
// global keyword (box-sizing: content-box -> box-sizing: initial)
|
|
1344
|
-
const initialKeyword = LENGTHY_INITIAL_KEYWORDS.get(property?.toLowerCase());
|
|
1345
|
-
if (!initialKeyword || 'initial'.length >= initialKeyword.length) {
|
|
1346
|
-
return value;
|
|
1347
|
-
}
|
|
1348
|
-
// A trailing `!important` rides along inside the value, so the keyword
|
|
1349
|
-
// comparison and the rewrite only concern what sits in front of it.
|
|
1350
|
-
const isImportant = value.endsWith('!important');
|
|
1351
|
-
const keyword = isImportant ? value.slice(0, value.length - '!important'.length) : value;
|
|
1352
|
-
if (keyword.toLowerCase() !== initialKeyword) {
|
|
85
|
+
function roundStandaloneNumber (value, property) {
|
|
86
|
+
const standaloneNumber = value.match(STANDALONE_NUMBER_PATTERN);
|
|
87
|
+
if (!standaloneNumber) {
|
|
1353
88
|
return value;
|
|
1354
89
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
* @param {Array} urlTokens URL tokens currently represented by markers.
|
|
1365
|
-
* @return {string} The value with property-specific optimizations applied.
|
|
1366
|
-
*/
|
|
1367
|
-
function applyPropertyOptimizations (val, property, urlTokens) {
|
|
1368
|
-
if (property === 'font-weight' && isUnicodeCharset()) {
|
|
1369
|
-
// Replace font-weight keyword "bold" with its numeric equivalent
|
|
1370
|
-
val = val.replace(/\bbold\b/gi, '700');
|
|
1371
|
-
// Replace font-weight keyword "normal" with its numeric equivalent
|
|
1372
|
-
val = val.replace(/\bnormal\b/gi, '400');
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
// Convert ms to s for time-related properties when the seconds form is shorter
|
|
1376
|
-
if (TIME_PROPERTIES.has(property)) {
|
|
1377
|
-
val = convertTimesToShortestUnit(val);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// Transition: remove " 0s" duration (transition: all 0s -> transition: all)
|
|
1381
|
-
if (property === 'transition') {
|
|
1382
|
-
// Remove zero-second duration from transition shorthand
|
|
1383
|
-
val = val.replace(/\s+0s/g, ' ');
|
|
1384
|
-
// Remove leading zero-pixel value from transition shorthand
|
|
1385
|
-
val = val.replace(/^0px\s*/, '');
|
|
1386
|
-
// Replace cubic-bezier functions with their equivalent named timing-function keywords
|
|
1387
|
-
val = val.replace(/cubic-bezier\(0,0,1,1\)/g, 'linear');
|
|
1388
|
-
val = val.replace(/cubic-bezier\(\.25,\.1,\.25,1\)/g, 'ease');
|
|
1389
|
-
val = val.replace(/cubic-bezier\(\.42,0,1,1\)/g, 'ease-in');
|
|
1390
|
-
val = val.replace(/cubic-bezier\(0,0,\.58,1\)/g, 'ease-out');
|
|
1391
|
-
val = val.trim();
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
if (property === 'animation') {
|
|
1395
|
-
// Replace steps() functions with their equivalent named timing-function keywords
|
|
1396
|
-
val = val.replace(/steps\(1,start\)/g, 'step-start');
|
|
1397
|
-
val = val.replace(/steps\(1,end\)/g, 'step-end');
|
|
1398
|
-
// Restore space between step-start/step-end keyword and following token
|
|
1399
|
-
// (the parenthesis whitespace stripping removes the space before replacement)
|
|
1400
|
-
val = val.replace(/(step-start|step-end)(?=[a-zA-Z0-9#-])/g, '$1 ');
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// Flex: remove " 0px" from flex shorthand (flex: 0 0 0px -> flex: 0 0)
|
|
1404
|
-
if (property === 'flex') {
|
|
1405
|
-
// Remove trailing zero-pixel basis value
|
|
1406
|
-
val = val.replace(/\s+0px/g, ' ');
|
|
1407
|
-
// Remove leading zero-pixel value
|
|
1408
|
-
val = val.replace(/^0px\s*/, '');
|
|
1409
|
-
// Remove an explicit zero basis: a trailing zero is only a basis when grow
|
|
1410
|
-
// and shrink precede it, and the shorthand already reads an unwritten basis
|
|
1411
|
-
// as 0. In `0 0` the trailing zero is the shrink, which the shorthand
|
|
1412
|
-
// defaults to 1 instead, so dropping it would size the element differently.
|
|
1413
|
-
val = val.replace(/^(\S+\s+\S+)\s+0$/, '$1');
|
|
1414
|
-
// Remove standalone zero-pixel value
|
|
1415
|
-
val = val.replace(/^0px$/, '');
|
|
1416
|
-
val = val.trim();
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// Initial values
|
|
1420
|
-
if (val === 'initial') {
|
|
1421
|
-
if (NUMERIC_INITIAL_PROPERTIES.has(property)) {
|
|
1422
|
-
// Just leaving them or mapping some: opacity: initial -> opacity: 1
|
|
1423
|
-
if (property === 'opacity') {
|
|
1424
|
-
val = '1';
|
|
1425
|
-
}
|
|
1426
|
-
if (property === 'z-index') {
|
|
1427
|
-
val = 'auto';
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
if (ZERO_INITIAL_PROPERTIES.has(property)) {
|
|
1431
|
-
val = '0';
|
|
1432
|
-
}
|
|
1433
|
-
if (AUTO_INITIAL_PROPERTIES.has(property)) {
|
|
1434
|
-
val = 'auto';
|
|
1435
|
-
}
|
|
1436
|
-
// background-color: initial should become #0000 (transparent)
|
|
1437
|
-
if (property === 'background-color') {
|
|
1438
|
-
val = '#0000';
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
if (property === 'background' && val === 'none') {
|
|
1443
|
-
val = '0 0';
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
if (property === 'display') {
|
|
1447
|
-
if (val === 'block flow') {
|
|
1448
|
-
val = 'block';
|
|
1449
|
-
}
|
|
1450
|
-
if (val === 'inline flow-root') {
|
|
1451
|
-
val = 'inline-block';
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
if (property === 'background-repeat') {
|
|
1456
|
-
if (val === 'no-repeat no-repeat') {
|
|
1457
|
-
val = 'no-repeat';
|
|
1458
|
-
}
|
|
1459
|
-
if (val === 'repeat no-repeat') {
|
|
1460
|
-
val = 'repeat-x';
|
|
1461
|
-
}
|
|
1462
|
-
if (val === 'no-repeat repeat') {
|
|
1463
|
-
val = 'repeat-y';
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
if (property === 'background-position') {
|
|
1468
|
-
val = convertBackgroundPositionKeywords(val);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
if (property === 'border') {
|
|
1472
|
-
val = reorderBorderWidthBeforeStyle(val);
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
if (property === 'flex-flow') {
|
|
1476
|
-
// Reorder flex-flow from wrap-direction to direction-wrap (canonical order)
|
|
1477
|
-
val = val.replace(/^(nowrap|wrap|wrap-reverse)\s+(row|row-reverse|column|column-reverse)$/i, '$2 $1');
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
if (property === 'font-family') {
|
|
1481
|
-
// Strip quotes from simple multi-word font family names that don't require quoting
|
|
1482
|
-
val = val.replace(/"([A-Za-z0-9-]+(?: [A-Za-z0-9-]+)+)"/g, '$1');
|
|
1483
|
-
const seenFamilies = new Set();
|
|
1484
|
-
val = val.split(',').map((part) => {
|
|
1485
|
-
return part.trim();
|
|
1486
|
-
}).filter(Boolean).filter((part) => {
|
|
1487
|
-
const lowercaseName = part.toLowerCase();
|
|
1488
|
-
if (seenFamilies.has(lowercaseName)) {
|
|
1489
|
-
return false;
|
|
1490
|
-
}
|
|
1491
|
-
seenFamilies.add(lowercaseName);
|
|
1492
|
-
return true;
|
|
1493
|
-
}).join(',');
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
if (property === 'grid-template-areas') {
|
|
1497
|
-
// Normalize each quoted grid-template-areas row string
|
|
1498
|
-
val = val.replace(/"([^"]*)"/g, (match, inner) => {
|
|
1499
|
-
// Collapse whitespace to single space within grid row
|
|
1500
|
-
let normalized = inner.replace(/\s+/g, ' ').trim();
|
|
1501
|
-
// Collapse consecutive dots (null cell tokens) to a single dot
|
|
1502
|
-
normalized = normalized.replace(/(^| )\.{2,}(?= |$)/g, '$1.');
|
|
1503
|
-
return '"' + normalized + '"';
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// Custom properties hold an arbitrary token stream rather than a typed value,
|
|
1508
|
-
// so a unit-like token in one is not necessarily a length.
|
|
1509
|
-
if (!isCustomProperty(property)) {
|
|
1510
|
-
val = replaceOutsideStringsAndUrls(val, convertAbsoluteLengthsToPx);
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
if (property === 'syntax') {
|
|
1514
|
-
// Remove whitespace around pipe separators in @property syntax descriptors
|
|
1515
|
-
val = val.replace(/\s*\|\s*/g, '|');
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
|
|
1519
|
-
val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
|
|
1520
|
-
|
|
1521
|
-
// Convert display-p3 neutral grays to sRGB (equal channels are identical across gamuts)
|
|
1522
|
-
val = val.replace(/\bcolor\(display-p3\s+([\d.]+)\s+\1\s+\1(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, channelStr, alphaStr) => {
|
|
1523
|
-
const channelValue = parseFloat(channelStr);
|
|
1524
|
-
const r = Math.round(channelValue * 255);
|
|
1525
|
-
return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
|
|
1526
|
-
});
|
|
1527
|
-
|
|
1528
|
-
// Font families may contain color names, but never color values.
|
|
1529
|
-
if (property !== 'font' && property !== 'font-family') {
|
|
1530
|
-
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
1531
|
-
return shortenColorValues(segment);
|
|
1532
|
-
});
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
|
|
1536
|
-
// Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
|
|
1537
|
-
val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
|
|
1538
|
-
}
|
|
1539
|
-
val = restoreSpaceBeforeMathOperators(val);
|
|
1540
|
-
|
|
1541
|
-
if (property === 'font') {
|
|
1542
|
-
val = minifyFontShorthand(val);
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
if (property === 'background' && val !== 'none') {
|
|
1546
|
-
let normalized = val
|
|
1547
|
-
// Remove default "0 0" background-position values
|
|
1548
|
-
.replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
|
|
1549
|
-
// Remove "0 0" background-position after a close-paren (e.g. after url())
|
|
1550
|
-
.replace(/\)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ') ')
|
|
1551
|
-
// Remove default "repeat" background-repeat keyword (excluding compound values like no-repeat)
|
|
1552
|
-
.replace(/(?<!-)\brepeat\b(?!-)/g, ' ')
|
|
1553
|
-
// Remove default "scroll" background-attachment keyword
|
|
1554
|
-
.replace(/\bscroll\b/g, ' ')
|
|
1555
|
-
// Remove default "none" background-image keyword
|
|
1556
|
-
.replace(/\bnone\b/g, ' ')
|
|
1557
|
-
// Collapse whitespace to single space
|
|
1558
|
-
.replace(/\s+/g, ' ')
|
|
1559
|
-
.trim();
|
|
1560
|
-
normalized = removeDefaultBackgroundPositionsAfterUrlTokens(normalized, urlTokens);
|
|
1561
|
-
if (normalized) {
|
|
1562
|
-
val = normalized;
|
|
1563
|
-
}
|
|
1564
|
-
val = normalizeImagePositionSeparator(val);
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
if (property === 'mask') {
|
|
1568
|
-
val = normalizeImagePositionSeparator(val);
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
if (property === 'border') {
|
|
1572
|
-
// Remove default "medium" border-width keyword
|
|
1573
|
-
val = val.replace(/\bmedium\s+/g, '');
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
if (property === 'outline') {
|
|
1577
|
-
// Restore missing space between outline-style and a color keyword when they are adjacent
|
|
1578
|
-
val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)(red|green|olive|tan|transparent)\b/g, '$1 $2');
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
if (property === 'transform') {
|
|
1582
|
-
val = minifyTransformValue(val);
|
|
1583
|
-
// Remove whitespace between consecutive transform functions
|
|
1584
|
-
val = val.replace(/\)\s+(?=[a-z-]+\()/gi, ')');
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
if (property === 'scale') {
|
|
1588
|
-
// Split scale value on whitespace into individual axis components
|
|
1589
|
-
const parts = val.split(/\s+/).filter(Boolean).map(normalizeScaleComponent);
|
|
1590
|
-
if (parts.length === 2 && parts[0] === parts[1]) {
|
|
1591
|
-
val = parts[0];
|
|
1592
|
-
} else if (parts.length === 3 && parts[2] === '1') {
|
|
1593
|
-
if (parts[0] === parts[1]) {
|
|
1594
|
-
val = parts[0];
|
|
1595
|
-
} else {
|
|
1596
|
-
val = parts[0] + ' ' + parts[1];
|
|
1597
|
-
}
|
|
1598
|
-
} else {
|
|
1599
|
-
val = parts.join(' ');
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
// Replace multiple spaces
|
|
1604
|
-
val = val.replace(/\s+/g, ' ');
|
|
1605
|
-
|
|
1606
|
-
// Shorthands: margin, padding, border-width, border-style, border-color, inset
|
|
1607
|
-
// Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
|
|
1608
|
-
if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
|
|
1609
|
-
val = collapseShorthandParts(val.split(' ')).join(' ');
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
if (property === 'hyphenate-limit-chars') {
|
|
1613
|
-
val = collapseHyphenateLimitChars(val);
|
|
90
|
+
const [, rawNumber, rawUnit = ''] = standaloneNumber;
|
|
91
|
+
const isCustom = isCustomProperty(property);
|
|
92
|
+
const scientificValue = isCustom ? value : shortenDimensionNumbers(value, property);
|
|
93
|
+
const roundedNumber = formatResolvedNumber(rawNumber);
|
|
94
|
+
const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
|
|
95
|
+
// Preserve tiny dimensions whose significant digits would otherwise be
|
|
96
|
+
// rounded away before their shorter exponent form can be considered.
|
|
97
|
+
if (losesPrecision && scientificValue !== value) {
|
|
98
|
+
return scientificValue;
|
|
1614
99
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
const segments = val.split('/').map((segment) => {
|
|
1618
|
-
return segment.trim();
|
|
1619
|
-
}).filter(Boolean).map((segment) => {
|
|
1620
|
-
// Split each segment on whitespace and collapse redundant parts
|
|
1621
|
-
return collapseShorthandParts(segment.split(/\s+/)).join(' ');
|
|
1622
|
-
});
|
|
1623
|
-
val = segments.join('/');
|
|
100
|
+
if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
|
|
101
|
+
return roundedNumber + rawUnit;
|
|
1624
102
|
}
|
|
1625
|
-
|
|
1626
|
-
return val;
|
|
103
|
+
return value;
|
|
1627
104
|
}
|
|
1628
105
|
|
|
1629
106
|
/**
|
|
@@ -1642,130 +119,113 @@ function computeMinifiedValue (declaration) {
|
|
|
1642
119
|
if (declaration.property === 'quotes' && isQuotesNoneEquivalent(declaration.value)) {
|
|
1643
120
|
return 'none';
|
|
1644
121
|
}
|
|
1645
|
-
let
|
|
122
|
+
let value = declaration.value;
|
|
1646
123
|
let protectedUrlState = null;
|
|
1647
124
|
|
|
1648
|
-
if (typeof
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
protectedUrlState = protectUrlTokens(
|
|
1653
|
-
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
value = value.trim();
|
|
127
|
+
value = normalizeWhitespaceAndQuotes(value, declaration.property);
|
|
128
|
+
value = minifyUrls(value);
|
|
129
|
+
protectedUrlState = protectUrlTokens(value);
|
|
130
|
+
value = protectedUrlState.value;
|
|
1654
131
|
|
|
1655
132
|
// Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
|
|
1656
133
|
// Collapse spaces around division operator
|
|
1657
|
-
|
|
134
|
+
value = value.replace(/ \/ /g, '/');
|
|
1658
135
|
// Remove whitespace around * and / operators (safe outside calc context)
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
|
|
1664
|
-
// Collapse whitespace inside calc expression
|
|
1665
|
-
const compactInner = inner.replace(/\s+/g, ' ').trim();
|
|
1666
|
-
// Extract all percentage terms from the expression
|
|
1667
|
-
const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
|
|
1668
|
-
const hasNonZeroPercent = percentTerms.some((term) => {
|
|
1669
|
-
return Math.abs(parseFloat(term)) > 0;
|
|
1670
|
-
});
|
|
1671
|
-
if (!hasNonZeroPercent) {
|
|
1672
|
-
return match;
|
|
1673
|
-
}
|
|
1674
|
-
// Remove trailing "+ 0%" and leading "0% +" additive identity terms
|
|
1675
|
-
return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
|
|
1676
|
-
});
|
|
136
|
+
value = value.replace(/\s*([*/])\s*/g, '$1');
|
|
137
|
+
value = normalizeMathFunctions(value, declaration.property, declaration.value || '');
|
|
138
|
+
value = simplifyStandaloneCalc(value, declaration.property);
|
|
139
|
+
value = removeZeroPercentTermsFromCalc(value);
|
|
1677
140
|
|
|
1678
141
|
// Zeros and Decimals
|
|
1679
142
|
if (declaration.property !== 'initial-value') {
|
|
1680
143
|
// Strip units from zero values (0px → 0, 0em → 0, etc.) at a value boundary
|
|
1681
|
-
|
|
1682
|
-
}
|
|
1683
|
-
val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
|
|
1684
|
-
|
|
1685
|
-
// If value is a standalone number with optional unit, round it into the
|
|
1686
|
-
// character budget a number is written within
|
|
1687
|
-
if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
|
|
1688
|
-
const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
|
|
1689
|
-
const isCustom = isCustomProperty(declaration.property);
|
|
1690
|
-
const scientificValue = isCustom ? val : shortenDimensionNumbers(val, declaration.property);
|
|
1691
|
-
const roundedNumber = formatResolvedNumber(rawNumber);
|
|
1692
|
-
const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
|
|
1693
|
-
// Preserve tiny dimensions whose significant digits would otherwise be
|
|
1694
|
-
// rounded away before their shorter exponent form can be considered.
|
|
1695
|
-
if (losesPrecision && scientificValue !== val) {
|
|
1696
|
-
val = scientificValue;
|
|
1697
|
-
} else if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
|
|
1698
|
-
val = roundedNumber + rawUnit;
|
|
1699
|
-
}
|
|
144
|
+
value = value.replace(/(^|\s|,|\()0(?:px|em|rem|vw|vh|cm|mm|in|pt|pc|ex|ch|vmin|vmax)(?=\s|,|$|\)|!)/g, '$10');
|
|
1700
145
|
}
|
|
146
|
+
value = value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
|
|
147
|
+
value = roundStandaloneNumber(value, declaration.property);
|
|
1701
148
|
|
|
1702
|
-
|
|
149
|
+
value = lowercaseHexColors(value);
|
|
1703
150
|
|
|
1704
151
|
if (declaration.property !== 'font' && declaration.property !== 'font-family') {
|
|
1705
|
-
|
|
1706
|
-
|
|
152
|
+
value = convertColorsToHex(value);
|
|
153
|
+
value = replaceOutsideStringsAndUrls(value, (segment) => {
|
|
1707
154
|
return shortenColorValues(segment);
|
|
1708
155
|
});
|
|
1709
156
|
}
|
|
1710
157
|
|
|
1711
158
|
// Collapse light-dark() when both normalized branches are identical
|
|
1712
|
-
|
|
159
|
+
value = simplifyEquivalentLightDarkFunctions(value);
|
|
1713
160
|
|
|
1714
161
|
// Property-specific optimizations
|
|
1715
|
-
|
|
1716
|
-
|
|
162
|
+
value = applyPropertyOptimizations(
|
|
163
|
+
value,
|
|
1717
164
|
declaration.property,
|
|
1718
165
|
protectedUrlState ? protectedUrlState.urlTokens : []
|
|
1719
166
|
);
|
|
1720
167
|
|
|
1721
168
|
// Minify relative color syntax (identity resolution and whitespace collapsing)
|
|
1722
|
-
|
|
169
|
+
value = minifyRelativeColorSyntax(value);
|
|
1723
170
|
}
|
|
1724
171
|
|
|
1725
172
|
// Gradient optimizations
|
|
1726
173
|
// Check if value contains a gradient function
|
|
1727
|
-
if (/gradient\(/.test(
|
|
1728
|
-
|
|
174
|
+
if (/gradient\(/.test(value)) {
|
|
175
|
+
value = minifyGradients(value);
|
|
1729
176
|
}
|
|
1730
177
|
|
|
1731
178
|
if (protectedUrlState) {
|
|
1732
|
-
|
|
1733
|
-
|
|
179
|
+
value = removeWhitespaceAfterUrlTokens(value, protectedUrlState.urlTokens);
|
|
180
|
+
value = restoreUrlTokens(value, protectedUrlState.urlTokens);
|
|
1734
181
|
}
|
|
1735
182
|
|
|
1736
183
|
// Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
|
|
1737
184
|
if (declaration.property === 'unicode-range') {
|
|
1738
|
-
|
|
185
|
+
value = optimizeUnicodeRange(value);
|
|
1739
186
|
}
|
|
1740
187
|
|
|
1741
188
|
// Every earlier pass reads the value with its component separators in place,
|
|
1742
189
|
// so the ones that turned out to be redundant are only dropped at the end.
|
|
1743
190
|
const elidesRedundantSeparators = (
|
|
1744
|
-
typeof
|
|
191
|
+
typeof value === 'string' &&
|
|
1745
192
|
!isCustomProperty(declaration.property)
|
|
1746
193
|
);
|
|
1747
194
|
if (elidesRedundantSeparators) {
|
|
1748
|
-
|
|
195
|
+
value = shortenDimensionNumbers(value, declaration.property);
|
|
1749
196
|
// A property that punctuates its own components already had the whitespace
|
|
1750
197
|
// after its closing parentheses taken out, and only the separators its
|
|
1751
198
|
// grammar still needs put back, so those are the ones left alone here.
|
|
1752
199
|
const elidesAfterSelfEndingTokens = !PUNCTUATED_COMPONENT_PROPERTIES.has(declaration.property);
|
|
1753
|
-
|
|
200
|
+
value = elideRedundantSeparators(value, elidesAfterSelfEndingTokens);
|
|
1754
201
|
}
|
|
1755
202
|
|
|
1756
|
-
return
|
|
203
|
+
return value;
|
|
1757
204
|
}
|
|
1758
205
|
|
|
1759
206
|
/**
|
|
1760
207
|
* Memoizes minified values for the current stylesheet. Minification runs many
|
|
1761
208
|
* passes over the same declarations (deduplication, shorthand assembly,
|
|
1762
209
|
* CSS-wide keyword hoisting, stringification), and the result only depends on
|
|
1763
|
-
* the declaration
|
|
210
|
+
* the declaration's property, its value, and whether it is an assembled
|
|
211
|
+
* shorthand, so each distinct
|
|
1764
212
|
* property/value pair is minified once per stylesheet.
|
|
1765
213
|
*
|
|
1766
|
-
*
|
|
214
|
+
* Values are filed under their property first, and assembled shorthands are
|
|
215
|
+
* kept apart from authored declarations, so a lookup reuses the property and
|
|
216
|
+
* value strings as they are instead of building a combined key on every call.
|
|
217
|
+
*
|
|
218
|
+
* @type {Map<string, Map<string, string>>}
|
|
219
|
+
*/
|
|
220
|
+
const minifiedValuesByProperty = new Map();
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The memoized values of shorthands the minifier assembled itself, filed the
|
|
224
|
+
* same way as `minifiedValuesByProperty`.
|
|
225
|
+
*
|
|
226
|
+
* @type {Map<string, Map<string, string>>}
|
|
1767
227
|
*/
|
|
1768
|
-
const
|
|
228
|
+
const assembledMinifiedValuesByProperty = new Map();
|
|
1769
229
|
|
|
1770
230
|
/**
|
|
1771
231
|
* Discards every memoized value. The minifier calls this at the start of each
|
|
@@ -1773,20 +233,27 @@ const minifiedValueCache = new Map();
|
|
|
1773
233
|
* the cache should not outlive the pass that filled it.
|
|
1774
234
|
*/
|
|
1775
235
|
function clearMinifiedValueCache () {
|
|
1776
|
-
|
|
236
|
+
minifiedValuesByProperty.clear();
|
|
237
|
+
assembledMinifiedValuesByProperty.clear();
|
|
1777
238
|
}
|
|
1778
239
|
|
|
1779
240
|
/**
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
* value, so it safely delimits the parts.
|
|
241
|
+
* Finds the memoized values for a declaration's property, creating the empty
|
|
242
|
+
* collection the first time the property is seen.
|
|
1783
243
|
*
|
|
1784
|
-
* @param {object}
|
|
1785
|
-
* @return {string}
|
|
244
|
+
* @param {object} declaration The CSS declaration object.
|
|
245
|
+
* @return {Map<string, string>} Map of value to its minified form for that property.
|
|
1786
246
|
*/
|
|
1787
|
-
function
|
|
1788
|
-
const
|
|
1789
|
-
|
|
247
|
+
function getMinifiedValuesForProperty (declaration) {
|
|
248
|
+
const valuesByProperty = declaration.isAssembledShorthand ?
|
|
249
|
+
assembledMinifiedValuesByProperty :
|
|
250
|
+
minifiedValuesByProperty;
|
|
251
|
+
let minifiedValues = valuesByProperty.get(declaration.property);
|
|
252
|
+
if (!minifiedValues) {
|
|
253
|
+
minifiedValues = new Map();
|
|
254
|
+
valuesByProperty.set(declaration.property, minifiedValues);
|
|
255
|
+
}
|
|
256
|
+
return minifiedValues;
|
|
1790
257
|
}
|
|
1791
258
|
|
|
1792
259
|
/**
|
|
@@ -1802,16 +269,33 @@ function minifyValue (declaration) {
|
|
|
1802
269
|
if (typeof declaration.value !== 'string') {
|
|
1803
270
|
return computeMinifiedValue(declaration);
|
|
1804
271
|
}
|
|
1805
|
-
const
|
|
1806
|
-
const cachedValue =
|
|
272
|
+
const minifiedValues = getMinifiedValuesForProperty(declaration);
|
|
273
|
+
const cachedValue = minifiedValues.get(declaration.value);
|
|
1807
274
|
if (cachedValue !== undefined) {
|
|
1808
275
|
return cachedValue;
|
|
1809
276
|
}
|
|
1810
277
|
const minifiedValue = computeMinifiedValue(declaration);
|
|
1811
|
-
|
|
278
|
+
minifiedValues.set(declaration.value, minifiedValue);
|
|
1812
279
|
return minifiedValue;
|
|
1813
280
|
}
|
|
1814
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Minifies a declaration's value for output, rewriting its initial keyword to
|
|
284
|
+
* the shorter global `initial` keyword after shorthand assembly.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} declaration The CSS declaration object with property and value fields.
|
|
287
|
+
* @return {string} The minified value with its initial keyword shortened to `initial` when that saves bytes.
|
|
288
|
+
*/
|
|
289
|
+
function minifyValueForOutput (declaration) {
|
|
290
|
+
const value = minifyValue(declaration);
|
|
291
|
+
// Custom properties hold an arbitrary token stream that JavaScript can read
|
|
292
|
+
// back, so their keywords are left exactly as written.
|
|
293
|
+
if (isCustomProperty(declaration.property)) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
return shortenLengthyInitialKeyword(value, declaration.property);
|
|
297
|
+
}
|
|
298
|
+
|
|
1815
299
|
export {
|
|
1816
300
|
clearMinifiedValueCache,
|
|
1817
301
|
minifyValue,
|