@thejaredwilcurt/csslop 0.0.43 → 0.0.45
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 +1 -1
- package/package.json +2 -2
- package/src/declarations/border.js +47 -33
- package/src/declarations/process.js +2 -2
- package/src/index.js +6 -33
- package/src/rules/optimize.js +167 -2
- package/src/rules/selectors.js +41 -0
- package/src/rules/stringify.js +2 -0
- package/src/rules/validate.js +145 -0
- package/src/value/color-mix.js +157 -68
- package/src/value/colors.js +36 -1
- package/src/value/font.js +111 -0
- package/src/value/minify.js +160 -33
- package/src/value/svg-data-uri.js +280 -0
- package/src/value/svg-markup.js +557 -0
- package/src/value/tokens.js +38 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Rejects malformed selector syntax before rules can be merged.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
findMatchingBracket,
|
|
7
|
+
findMatchingParenthesis,
|
|
8
|
+
splitTopLevelCommaList
|
|
9
|
+
} from '../parser/source-search.js';
|
|
10
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads an attribute name, optional matcher, value, and case flag.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} attribute The contents of the brackets.
|
|
16
|
+
* @return {boolean} Whether the attribute has a valid token sequence.
|
|
17
|
+
*/
|
|
18
|
+
function isValidAttribute (attribute) {
|
|
19
|
+
const tokens = tokenizeCssValue(attribute).filter((token) => {
|
|
20
|
+
return token.type !== 'whitespace';
|
|
21
|
+
});
|
|
22
|
+
let index = 0;
|
|
23
|
+
if ((tokens[0]?.type === 'ident' || tokens[0]?.text === '*') && tokens[1]?.text === '|') {
|
|
24
|
+
index = 2;
|
|
25
|
+
} else if (tokens[0]?.text === '|') {
|
|
26
|
+
index = 1;
|
|
27
|
+
}
|
|
28
|
+
if (tokens[index]?.type !== 'ident') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
index++;
|
|
32
|
+
if (index === tokens.length) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (['~', '|', '^', '$', '*'].includes(tokens[index]?.text)) {
|
|
36
|
+
index++;
|
|
37
|
+
}
|
|
38
|
+
if (tokens[index]?.text !== '=') {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
index++;
|
|
42
|
+
if (tokens[index]?.type !== 'ident' && tokens[index]?.type !== 'string') {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
index++;
|
|
46
|
+
if (tokens[index]?.type === 'ident' && ['i', 's'].includes(tokens[index].text.toLowerCase())) {
|
|
47
|
+
index++;
|
|
48
|
+
}
|
|
49
|
+
return index === tokens.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Checks that each language range is a single identifier or quoted string.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} argumentsText The language function's contents.
|
|
56
|
+
* @return {boolean} Whether every argument has valid syntax.
|
|
57
|
+
*/
|
|
58
|
+
function isValidLanguageList (argumentsText) {
|
|
59
|
+
const argumentsList = splitTopLevelCommaList(argumentsText);
|
|
60
|
+
return argumentsList.length > 0 && argumentsList.every((argument) => {
|
|
61
|
+
const tokens = tokenizeCssValue(argument.trim());
|
|
62
|
+
return tokens.length === 1 && (tokens[0].type === 'ident' || tokens[0].type === 'string');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detects invalid tokens without interpreting data inside strings, escapes,
|
|
68
|
+
* or forgiving selector lists as syntax of the surrounding selector.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} selector A single selector.
|
|
71
|
+
* @return {boolean} Whether its checked syntax is valid.
|
|
72
|
+
*/
|
|
73
|
+
function isValidSelector (selector) {
|
|
74
|
+
const tokens = tokenizeCssValue(selector);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
77
|
+
const token = tokens[index];
|
|
78
|
+
if (token.text === '[' || token.type === 'function') {
|
|
79
|
+
const isAttribute = token.text === '[';
|
|
80
|
+
const openingIndex = isAttribute ? offset : offset + token.text.length - 1;
|
|
81
|
+
const closingIndex = isAttribute ?
|
|
82
|
+
findMatchingBracket(selector, openingIndex) :
|
|
83
|
+
findMatchingParenthesis(selector, openingIndex);
|
|
84
|
+
if (closingIndex === -1) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const inner = selector.slice(openingIndex + 1, closingIndex);
|
|
88
|
+
const functionName = token.text.slice(0, -1).toLowerCase();
|
|
89
|
+
if (isAttribute && !isValidAttribute(inner)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (functionName === 'lang' && !isValidLanguageList(inner)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (functionName === 'not' || functionName === 'has') {
|
|
96
|
+
if (!splitTopLevelCommaList(inner).every(isValidSelector)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
while (offset < closingIndex) {
|
|
101
|
+
offset += tokens[index].text.length;
|
|
102
|
+
index++;
|
|
103
|
+
}
|
|
104
|
+
offset += tokens[index].text.length;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (token.text === '|' && tokens[index + 1]?.text === '|') {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (token.text === ':') {
|
|
111
|
+
let followingIndex = index + 1;
|
|
112
|
+
if (tokens[followingIndex]?.text === ':') {
|
|
113
|
+
followingIndex++;
|
|
114
|
+
}
|
|
115
|
+
const following = tokens[followingIndex];
|
|
116
|
+
if (following?.type !== 'ident' && following?.type !== 'function') {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
offset += token.text.length;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Removes an entire unforgiving selector list if any branch is malformed.
|
|
127
|
+
*
|
|
128
|
+
* @param {Array} rules Stylesheet or nested body entries.
|
|
129
|
+
* @return {Array} Entries that cannot poison a valid rule during merging.
|
|
130
|
+
*/
|
|
131
|
+
function removeInvalidSelectorRules (rules) {
|
|
132
|
+
return rules.filter((rule) => {
|
|
133
|
+
return !rule.selectors || rule.selectors.every(isValidSelector);
|
|
134
|
+
}).map((rule) => {
|
|
135
|
+
if (rule.rules) {
|
|
136
|
+
rule.rules = removeInvalidSelectorRules(rule.rules);
|
|
137
|
+
}
|
|
138
|
+
if (rule.declarations) {
|
|
139
|
+
rule.declarations = removeInvalidSelectorRules(rule.declarations);
|
|
140
|
+
}
|
|
141
|
+
return rule;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { removeInvalidSelectorRules };
|
package/src/value/color-mix.js
CHANGED
|
@@ -57,31 +57,104 @@ function formatOklch (L, C, H, alpha) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* Splits the simple, non-nested components of a static color function.
|
|
61
61
|
*
|
|
62
|
-
* @param {string}
|
|
63
|
-
* @return {Array}
|
|
62
|
+
* @param {string} argumentText The text inside a color function's parentheses.
|
|
63
|
+
* @return {Array} The whitespace, comma, and slash-delimited components.
|
|
64
64
|
*/
|
|
65
|
-
function
|
|
65
|
+
function splitSimpleColorComponents (argumentText) {
|
|
66
|
+
// Split CSS color components at whitespace, commas, or the alpha slash.
|
|
67
|
+
return argumentText.trim().split(/[\s,/]+/).filter((part) => {
|
|
68
|
+
return part.length > 0;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Collects the zero-based positions of missing color components.
|
|
74
|
+
*
|
|
75
|
+
* @param {Array} components The parsed color components.
|
|
76
|
+
* @param {number} startIndex The component index represented by the first item.
|
|
77
|
+
* @return {Array} The channel indices whose value is `none`.
|
|
78
|
+
*/
|
|
79
|
+
function collectNoneChannelIndices (components, startIndex) {
|
|
66
80
|
const indices = [];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
|
|
72
|
-
return part.trim();
|
|
73
|
-
}).filter((part) => {
|
|
74
|
-
return part.length > 0;
|
|
75
|
-
});
|
|
76
|
-
parts.forEach((part, index) => {
|
|
77
|
-
if (part.toLowerCase() === 'none') {
|
|
78
|
-
indices.push(index);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
+
for (let index = 0; index < components.length; index++) {
|
|
82
|
+
if (components[index].toLowerCase() === 'none') {
|
|
83
|
+
indices.push(startIndex + index);
|
|
84
|
+
}
|
|
81
85
|
}
|
|
82
86
|
return indices;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Finds the first CSS whitespace character in a string.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text The text to inspect.
|
|
93
|
+
* @return {number} The first whitespace index, or -1 when there is none.
|
|
94
|
+
*/
|
|
95
|
+
function findFirstWhitespaceIndex (text) {
|
|
96
|
+
for (let index = 0; index < text.length; index++) {
|
|
97
|
+
if (' \t\n\r\f'.includes(text[index])) {
|
|
98
|
+
return index;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return -1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detect which channel indices have `none` in a raw static color function.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} rawColorString The raw CSS color function string.
|
|
108
|
+
* @return {Array} An array of zero-based channel indices where `none` was found.
|
|
109
|
+
*/
|
|
110
|
+
function findNoneChannels (rawColorString) {
|
|
111
|
+
const openParenthesisIndex = rawColorString.indexOf('(');
|
|
112
|
+
const closeParenthesisIndex = rawColorString.lastIndexOf(')');
|
|
113
|
+
if (openParenthesisIndex === -1 || closeParenthesisIndex <= openParenthesisIndex) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const functionName = rawColorString.slice(0, openParenthesisIndex).trim().toLowerCase();
|
|
118
|
+
const argumentText = rawColorString.slice(openParenthesisIndex + 1, closeParenthesisIndex).trim();
|
|
119
|
+
if (['rgb', 'rgba', 'hsl', 'hsla', 'hwb'].includes(functionName)) {
|
|
120
|
+
return collectNoneChannelIndices(splitSimpleColorComponents(argumentText), 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (functionName !== 'color') {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const firstComponentEnd = findFirstWhitespaceIndex(argumentText);
|
|
128
|
+
if (firstComponentEnd === -1 || argumentText.slice(0, firstComponentEnd).toLowerCase() !== 'srgb') {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const channelText = argumentText.slice(firstComponentEnd).trim();
|
|
132
|
+
return collectNoneChannelIndices(splitSimpleColorComponents(channelText), 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fills missing interpolation components from the matching component in the
|
|
137
|
+
* other color before either color is premultiplied by alpha.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} firstColor The first parsed color-mix argument.
|
|
140
|
+
* @param {object} secondColor The second parsed color-mix argument.
|
|
141
|
+
* @return {void}
|
|
142
|
+
*/
|
|
143
|
+
function fillMissingColorChannels (firstColor, secondColor) {
|
|
144
|
+
const firstMissingChannels = findNoneChannels(firstColor.raw);
|
|
145
|
+
const secondMissingChannels = findNoneChannels(secondColor.raw);
|
|
146
|
+
for (const channelIndex of firstMissingChannels) {
|
|
147
|
+
if (channelIndex < firstColor.color.length) {
|
|
148
|
+
firstColor.color[channelIndex] = secondColor.color[channelIndex];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const channelIndex of secondMissingChannels) {
|
|
152
|
+
if (channelIndex < secondColor.color.length) {
|
|
153
|
+
secondColor.color[channelIndex] = firstColor.color[channelIndex];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
85
158
|
/**
|
|
86
159
|
* Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
|
|
87
160
|
*
|
|
@@ -208,17 +281,26 @@ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
|
|
|
208
281
|
* @return {string} A hex color string.
|
|
209
282
|
*/
|
|
210
283
|
function mixNColorsSrgb (colors, weights, alphaMultiplier) {
|
|
211
|
-
let
|
|
212
|
-
let
|
|
213
|
-
let
|
|
214
|
-
let
|
|
284
|
+
let premultipliedRed = 0;
|
|
285
|
+
let premultipliedGreen = 0;
|
|
286
|
+
let premultipliedBlue = 0;
|
|
287
|
+
let interpolatedAlpha = 0;
|
|
215
288
|
for (let i = 0; i < colors.length; i++) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
289
|
+
const weightedAlpha = colors[i][3] * weights[i];
|
|
290
|
+
premultipliedRed += colors[i][0] * weightedAlpha;
|
|
291
|
+
premultipliedGreen += colors[i][1] * weightedAlpha;
|
|
292
|
+
premultipliedBlue += colors[i][2] * weightedAlpha;
|
|
293
|
+
interpolatedAlpha += weightedAlpha;
|
|
220
294
|
}
|
|
221
|
-
|
|
295
|
+
if (interpolatedAlpha === 0) {
|
|
296
|
+
return rgbaToHex(0, 0, 0, 0);
|
|
297
|
+
}
|
|
298
|
+
return rgbaToHex(
|
|
299
|
+
Math.round(premultipliedRed / interpolatedAlpha),
|
|
300
|
+
Math.round(premultipliedGreen / interpolatedAlpha),
|
|
301
|
+
Math.round(premultipliedBlue / interpolatedAlpha),
|
|
302
|
+
interpolatedAlpha * alphaMultiplier
|
|
303
|
+
);
|
|
222
304
|
}
|
|
223
305
|
|
|
224
306
|
/**
|
|
@@ -236,16 +318,28 @@ function mixNColorsOklab (colors, weights, alphaMultiplier) {
|
|
|
236
318
|
let L = 0;
|
|
237
319
|
let a = 0;
|
|
238
320
|
let b = 0;
|
|
239
|
-
let
|
|
321
|
+
let interpolatedAlpha = 0;
|
|
240
322
|
for (let i = 0; i < oklabValues.length; i++) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
323
|
+
const weightedAlpha = colors[i][3] * weights[i];
|
|
324
|
+
L += oklabValues[i].L * weightedAlpha;
|
|
325
|
+
a += oklabValues[i].a * weightedAlpha;
|
|
326
|
+
b += oklabValues[i].b * weightedAlpha;
|
|
327
|
+
interpolatedAlpha += weightedAlpha;
|
|
328
|
+
}
|
|
329
|
+
if (interpolatedAlpha === 0) {
|
|
330
|
+
return rgbaToHex(0, 0, 0, 0);
|
|
331
|
+
}
|
|
332
|
+
const rgb = oklabToRgb(
|
|
333
|
+
L / interpolatedAlpha,
|
|
334
|
+
a / interpolatedAlpha,
|
|
335
|
+
b / interpolatedAlpha
|
|
336
|
+
);
|
|
337
|
+
return rgbaToHex(
|
|
338
|
+
rgb[0],
|
|
339
|
+
rgb[1],
|
|
340
|
+
rgb[2],
|
|
341
|
+
interpolatedAlpha * alphaMultiplier
|
|
342
|
+
);
|
|
249
343
|
}
|
|
250
344
|
|
|
251
345
|
/**
|
|
@@ -329,19 +423,7 @@ function evaluateColorMix (expr) {
|
|
|
329
423
|
return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
|
|
330
424
|
}
|
|
331
425
|
|
|
332
|
-
|
|
333
|
-
const nones1 = findNoneChannels(parsed1.raw);
|
|
334
|
-
const nones2 = findNoneChannels(parsed2.raw);
|
|
335
|
-
for (const idx of nones1) {
|
|
336
|
-
if (idx < parsed1.color.length) {
|
|
337
|
-
parsed1.color[idx] = parsed2.color[idx];
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
for (const idx of nones2) {
|
|
341
|
-
if (idx < parsed2.color.length) {
|
|
342
|
-
parsed2.color[idx] = parsed1.color[idx];
|
|
343
|
-
}
|
|
344
|
-
}
|
|
426
|
+
fillMissingColorChannels(parsed1, parsed2);
|
|
345
427
|
|
|
346
428
|
const t1 = p1 / (p1 + p2);
|
|
347
429
|
const t2 = p2 / (p1 + p2);
|
|
@@ -349,37 +431,44 @@ function evaluateColorMix (expr) {
|
|
|
349
431
|
const [r2, g2, b2, a2] = parsed2.color;
|
|
350
432
|
|
|
351
433
|
if (colorSpace === 'srgb') {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
434
|
+
return mixNColorsSrgb(
|
|
435
|
+
[parsed1.color, parsed2.color],
|
|
436
|
+
[t1, t2],
|
|
437
|
+
alphaMultiplier
|
|
438
|
+
);
|
|
357
439
|
}
|
|
358
440
|
|
|
359
441
|
if (colorSpace === 'oklab') {
|
|
360
442
|
const lab1 = rgbToOklab(r1, g1, b1);
|
|
361
443
|
const lab2 = rgbToOklab(r2, g2, b2);
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const rgb = oklabToRgb(L, a, b);
|
|
368
|
-
if (alpha >= 1) {
|
|
369
|
-
return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
|
|
444
|
+
const firstWeightedAlpha = a1 * t1;
|
|
445
|
+
const secondWeightedAlpha = a2 * t2;
|
|
446
|
+
const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
|
|
447
|
+
if (interpolatedAlpha === 0) {
|
|
448
|
+
return rgbaToHex(0, 0, 0, 0);
|
|
370
449
|
}
|
|
371
|
-
|
|
450
|
+
const L = (lab1.L * firstWeightedAlpha + lab2.L * secondWeightedAlpha) / interpolatedAlpha;
|
|
451
|
+
const a = (lab1.a * firstWeightedAlpha + lab2.a * secondWeightedAlpha) / interpolatedAlpha;
|
|
452
|
+
const b = (lab1.b * firstWeightedAlpha + lab2.b * secondWeightedAlpha) / interpolatedAlpha;
|
|
453
|
+
const rgb = oklabToRgb(L, a, b);
|
|
454
|
+
return rgbaToHex(rgb[0], rgb[1], rgb[2], interpolatedAlpha * alphaMultiplier);
|
|
372
455
|
}
|
|
373
456
|
|
|
374
457
|
if (colorSpace === 'oklch') {
|
|
375
458
|
const lch1 = rgbToOklch(r1, g1, b1);
|
|
376
459
|
const lch2 = rgbToOklch(r2, g2, b2);
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
460
|
+
const firstWeightedAlpha = a1 * t1;
|
|
461
|
+
const secondWeightedAlpha = a2 * t2;
|
|
462
|
+
const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
|
|
463
|
+
if (interpolatedAlpha === 0) {
|
|
464
|
+
return rgbaToHex(0, 0, 0, 0);
|
|
465
|
+
}
|
|
466
|
+
const L = (lch1.L * firstWeightedAlpha + lch2.L * secondWeightedAlpha) / interpolatedAlpha;
|
|
467
|
+
const C = (lch1.C * firstWeightedAlpha + lch2.C * secondWeightedAlpha) / interpolatedAlpha;
|
|
468
|
+
const H = interpolateHueShorter(lch1.H, lch2.H, secondWeightedAlpha / interpolatedAlpha);
|
|
469
|
+
const alpha = interpolatedAlpha * alphaMultiplier;
|
|
381
470
|
// In-gamut results have an exact sRGB equivalent, which is always shorter than oklch()
|
|
382
|
-
const hex = convertOklchToHex(L, C, H, alpha
|
|
471
|
+
const hex = convertOklchToHex(L, C, H, alpha);
|
|
383
472
|
if (hex) {
|
|
384
473
|
return hex;
|
|
385
474
|
}
|
package/src/value/colors.js
CHANGED
|
@@ -302,6 +302,29 @@ function parseHex (hex) {
|
|
|
302
302
|
return null;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Matches a static `color(srgb ...)` function. The three color components may
|
|
307
|
+
* be unit-interval numbers or percentages, followed by an optional alpha.
|
|
308
|
+
*
|
|
309
|
+
* @type {RegExp}
|
|
310
|
+
*/
|
|
311
|
+
const SRGB_COLOR_FUNCTION_PATTERN = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Converts one `color(srgb ...)` component to the 0-255 range.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} component A unit-interval number or percentage.
|
|
317
|
+
* @return {number|null} The channel value, or null when invalid.
|
|
318
|
+
*/
|
|
319
|
+
function parseSrgbColorComponent (component) {
|
|
320
|
+
const numericValue = parseFloat(component);
|
|
321
|
+
if (!Number.isFinite(numericValue)) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const normalizedValue = component.endsWith('%') ? numericValue / 100 : numericValue;
|
|
325
|
+
return Math.round(normalizedValue * 255);
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
/**
|
|
306
329
|
* Parse a CSS color string to [r, g, b, a] (r,g,b: 0-255, a: 0-1) or null if unresolvable.
|
|
307
330
|
*
|
|
@@ -326,8 +349,20 @@ function parseColor (colorString) {
|
|
|
326
349
|
return parseHex(normalized);
|
|
327
350
|
}
|
|
328
351
|
|
|
352
|
+
// color(srgb ...) uses unit-interval color channels rather than rgb's 0-255 channels.
|
|
353
|
+
let match = normalized.match(SRGB_COLOR_FUNCTION_PATTERN);
|
|
354
|
+
if (match) {
|
|
355
|
+
const red = parseSrgbColorComponent(match[1]);
|
|
356
|
+
const green = parseSrgbColorComponent(match[2]);
|
|
357
|
+
const blue = parseSrgbColorComponent(match[3]);
|
|
358
|
+
if (red === null || green === null || blue === null) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
return [red, green, blue, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
362
|
+
}
|
|
363
|
+
|
|
329
364
|
// rgb() / rgba() space syntax
|
|
330
|
-
|
|
365
|
+
match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
|
|
331
366
|
if (match) {
|
|
332
367
|
const r = Math.round(parseFloat(match[1]));
|
|
333
368
|
const g = Math.round(parseFloat(match[2]));
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Token-aware font shorthand minification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CSS_WIDE_KEYWORDS } from '../declarations/config.js';
|
|
6
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keywords that would change a quoted family into a generic family.
|
|
10
|
+
*
|
|
11
|
+
* @type {Set<string>}
|
|
12
|
+
*/
|
|
13
|
+
const GENERIC_FAMILY_NAMES = new Set([
|
|
14
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
15
|
+
'math', 'emoji', 'fangsong', 'ui-serif', 'ui-sans-serif', 'ui-monospace',
|
|
16
|
+
'ui-rounded'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Removes family quotes only when their contents form ordinary identifiers.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text A family string token.
|
|
23
|
+
* @return {string} The shortest safe spelling.
|
|
24
|
+
*/
|
|
25
|
+
function shortenFamilyString (text) {
|
|
26
|
+
const inner = text.slice(1, -1);
|
|
27
|
+
if (inner.includes('\\') || inner.trim() !== inner) {
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
const tokens = tokenizeCssValue(inner);
|
|
31
|
+
const isIdentifierList = tokens.length && tokens.every((token) => {
|
|
32
|
+
if (token.type === 'whitespace') {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const keyword = token.text.toLowerCase();
|
|
36
|
+
return token.type === 'ident' && !CSS_WIDE_KEYWORDS.has(keyword) && keyword !== 'default';
|
|
37
|
+
});
|
|
38
|
+
if (!isIdentifierList || GENERIC_FAMILY_NAMES.has(inner.toLowerCase())) {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
return inner;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Locates a size before rewriting optional components. A substituted or
|
|
46
|
+
* calculated prefix is left intact because its component boundaries may not
|
|
47
|
+
* be knowable until the browser resolves it.
|
|
48
|
+
*
|
|
49
|
+
* @param {Array} tokens The font tokens.
|
|
50
|
+
* @return {number} The size index, or -1 if the prefix is ambiguous.
|
|
51
|
+
*/
|
|
52
|
+
function findFontSizeIndex (tokens) {
|
|
53
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
54
|
+
const token = tokens[index];
|
|
55
|
+
if (token.type === 'function') {
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
// An oblique font style may state an angle before the font size.
|
|
59
|
+
const isAngle = token.type === 'dimension' && /(?:deg|grad|rad|turn)$/i.test(token.text);
|
|
60
|
+
if (token.type === 'dimension' && !isAngle) {
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
// Match absolute and relative size keywords, not family-name substrings.
|
|
64
|
+
const isSizeKeyword = token.type === 'ident' &&
|
|
65
|
+
/^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|smaller|larger)$/i.test(token.text);
|
|
66
|
+
if (isSizeKeyword || token.type === 'percentage' || (token.type === 'number' && Number(token.text) === 0)) {
|
|
67
|
+
return index;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalizes optional font components before the size, leaving the family and
|
|
75
|
+
* line height in their respective grammar positions.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} value The font shorthand.
|
|
78
|
+
* @return {string} The minified shorthand.
|
|
79
|
+
*/
|
|
80
|
+
function minifyFontShorthand (value) {
|
|
81
|
+
const tokens = tokenizeCssValue(value);
|
|
82
|
+
const sizeIndex = findFontSizeIndex(tokens);
|
|
83
|
+
if (sizeIndex === -1) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
return tokens.map((token, index) => {
|
|
87
|
+
if (index < sizeIndex) {
|
|
88
|
+
if (token.type === 'ident') {
|
|
89
|
+
const keyword = token.text.toLowerCase();
|
|
90
|
+
if (keyword === 'normal') {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
if (keyword === 'bold') {
|
|
94
|
+
return '700';
|
|
95
|
+
}
|
|
96
|
+
} else if (token.type === 'number' && token.text === '400') {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (index > sizeIndex && token.type === 'string') {
|
|
101
|
+
const family = shortenFamilyString(token.text);
|
|
102
|
+
if (family !== token.text && tokens[index - 1]?.type !== 'whitespace' && tokens[index - 1]?.text !== ',') {
|
|
103
|
+
return ' ' + family;
|
|
104
|
+
}
|
|
105
|
+
return family;
|
|
106
|
+
}
|
|
107
|
+
return token.text;
|
|
108
|
+
}).join('').trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { minifyFontShorthand };
|