@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Splits a CSS value into the literal segments that hold data, quoted strings and `url()` tokens, and the syntax between them, so rewrites only ever reach the syntax.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
skipParenthesizedGroup,
|
|
7
|
+
skipQuotedString,
|
|
8
|
+
startsUrlToken
|
|
9
|
+
} from '../parser/source-search.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} ValueSegment
|
|
13
|
+
* @property {string} text The segment's slice of the value.
|
|
14
|
+
* @property {boolean} isLiteral Whether the segment is a quoted string or a url() token.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Splits a CSS value into literal and syntax segments. A quoted string and a
|
|
19
|
+
* `url()` token are literals: they hold data rather than CSS syntax, so no pass
|
|
20
|
+
* may rewrite what is inside them. Everything between the literals is syntax.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} value The full CSS value string.
|
|
23
|
+
* @return {Array} The value's segments, in order.
|
|
24
|
+
*/
|
|
25
|
+
function splitValueSegments (value) {
|
|
26
|
+
const segments = [];
|
|
27
|
+
let index = 0;
|
|
28
|
+
|
|
29
|
+
while (index < value.length) {
|
|
30
|
+
if (value[index] === '"' || value[index] === '\'') {
|
|
31
|
+
const end = skipQuotedString(value, index);
|
|
32
|
+
segments.push({ text: value.slice(index, end), isLiteral: true });
|
|
33
|
+
index = end;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (startsUrlToken(value, index)) {
|
|
38
|
+
// The `url(` token opens its parenthesized group at its fourth character
|
|
39
|
+
const end = skipParenthesizedGroup(value, index + 3);
|
|
40
|
+
segments.push({ text: value.slice(index, end), isLiteral: true });
|
|
41
|
+
index = end;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const start = index;
|
|
46
|
+
while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrlToken(value, index)) {
|
|
47
|
+
index++;
|
|
48
|
+
}
|
|
49
|
+
segments.push({ text: value.slice(start, index), isLiteral: false });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return segments;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} value The full CSS value string.
|
|
59
|
+
* @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
|
|
60
|
+
* @return {string} The value with the replacer applied to all eligible segments.
|
|
61
|
+
*/
|
|
62
|
+
function replaceOutsideStringsAndUrls (value, replacer) {
|
|
63
|
+
return splitValueSegments(value).map((segment) => {
|
|
64
|
+
if (segment.isLiteral) {
|
|
65
|
+
return segment.text;
|
|
66
|
+
}
|
|
67
|
+
return replacer(segment.text);
|
|
68
|
+
}).join('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
replaceOutsideStringsAndUrls,
|
|
73
|
+
splitValueSegments
|
|
74
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Rewrites CSS times in whichever of seconds or milliseconds spells them shortest.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
formatShortestNumber,
|
|
9
|
+
parseNumericParts,
|
|
10
|
+
scaleByPowerOfTen,
|
|
11
|
+
shortenNumericText
|
|
12
|
+
} from './numbers.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The power of ten that separates a time in seconds from the same time in
|
|
16
|
+
* milliseconds, keyed by the lowercased time unit.
|
|
17
|
+
*
|
|
18
|
+
* @type {{[unit: string]: number}}
|
|
19
|
+
*/
|
|
20
|
+
const TIME_UNIT_POWERS = {
|
|
21
|
+
s: 0,
|
|
22
|
+
ms: -3
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rewrites one time token in whichever of seconds or milliseconds spells it
|
|
27
|
+
* shortest, scientific notation included. Every conversion moves the exponent
|
|
28
|
+
* of the exact digits, so no precision is lost however large the time is.
|
|
29
|
+
* Seconds win a tie, and a zero time becomes `0s`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} text The text of a dimension token.
|
|
32
|
+
* @return {string} The shortest spelling of the same time.
|
|
33
|
+
*/
|
|
34
|
+
function shortenTimeToken (text) {
|
|
35
|
+
const parts = parseNumericParts(text);
|
|
36
|
+
const writtenUnit = parts?.unit.toLowerCase();
|
|
37
|
+
if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
if (!parts.digits) {
|
|
41
|
+
return '0s';
|
|
42
|
+
}
|
|
43
|
+
const writtenSpelling = shortenNumericText(text, true);
|
|
44
|
+
const secondsSpelling = formatShortestNumber(
|
|
45
|
+
scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
|
|
46
|
+
true
|
|
47
|
+
);
|
|
48
|
+
const millisecondsSpelling = formatShortestNumber(
|
|
49
|
+
scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
|
|
50
|
+
true
|
|
51
|
+
);
|
|
52
|
+
// Seconds are the conventional unit, so they win a tie with milliseconds
|
|
53
|
+
const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
|
|
54
|
+
return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Converts each time in a value between seconds and milliseconds, whichever
|
|
59
|
+
* is the shorter string.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} value The CSS value string potentially containing time values.
|
|
62
|
+
* @return {string} The value with each time in its shortest unit.
|
|
63
|
+
*/
|
|
64
|
+
function convertTimesToShortestUnit (value) {
|
|
65
|
+
return tokenizeCssValue(value).map((token) => {
|
|
66
|
+
if (token.type !== 'dimension') {
|
|
67
|
+
return token.text;
|
|
68
|
+
}
|
|
69
|
+
return shortenTimeToken(token.text);
|
|
70
|
+
}).join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
convertTimesToShortestUnit
|
|
75
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Minifies `url()` tokens, and temporarily replaces them with markers so generic value rewrites cannot alter the data a URL carries.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
findMatchingParenthesis,
|
|
7
|
+
startsUrlToken
|
|
8
|
+
} from '../parser/source-search.js';
|
|
9
|
+
|
|
10
|
+
import { splitValueSegments } from './segments.js';
|
|
11
|
+
import { minifySvgDataUri } from './svg/data-uri.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Escapes the characters an unquoted url() token cannot hold, so the path can
|
|
15
|
+
* drop its quotes. A backslash already escaping a character is left alone, so
|
|
16
|
+
* a path that arrived escaped is never escaped a second time.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} path The url path, without surrounding quotes.
|
|
19
|
+
* @return {string} The path with every bare space and parenthesis escaped.
|
|
20
|
+
*/
|
|
21
|
+
function escapeUrlPathCharacters (path) {
|
|
22
|
+
// Match each space and parenthesis that no escaping backslash precedes
|
|
23
|
+
return path.replace(/(?<!\\)([ ()])/g, '\\$1');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Chooses the shortest valid representation for the path inside a `url(...)`
|
|
28
|
+
* token, weighing an unquoted form — its spaces and parentheses escaped —
|
|
29
|
+
* against a quoted form. Quotation marks can only ever appear in the quoted
|
|
30
|
+
* form, so a path holding one always keeps its wrapper.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} path The resolved url path, without surrounding quotes.
|
|
33
|
+
* @return {string} The shortest valid url() content string.
|
|
34
|
+
*/
|
|
35
|
+
function formatUrlPath (path) {
|
|
36
|
+
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
37
|
+
const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
|
|
38
|
+
// Match a quotation mark, which an unquoted url() token cannot hold
|
|
39
|
+
if (/["']/.test(path)) {
|
|
40
|
+
return quotedForm;
|
|
41
|
+
}
|
|
42
|
+
const unquotedForm = escapeUrlPathCharacters(path);
|
|
43
|
+
// Each escaped character costs one backslash, so dropping the two quotation
|
|
44
|
+
// marks pays off whenever at most one character needs escaping
|
|
45
|
+
if (unquotedForm.length < quotedForm.length) {
|
|
46
|
+
return unquotedForm;
|
|
47
|
+
}
|
|
48
|
+
return quotedForm;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Produces the shortest valid contents for a single `url(...)` token from the
|
|
53
|
+
* raw text between its parentheses, stripping a leading current-directory
|
|
54
|
+
* indicator and normalizing quoting.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} rawContent The trimmed text found between the url parentheses.
|
|
57
|
+
* @return {string} The minified url() content.
|
|
58
|
+
*/
|
|
59
|
+
function minifyUrlContent (rawContent) {
|
|
60
|
+
const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
|
|
61
|
+
let path = rawContent;
|
|
62
|
+
if (wasQuoted) {
|
|
63
|
+
const quote = rawContent[0];
|
|
64
|
+
if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
|
|
65
|
+
path = rawContent.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const minifiedSvgDataUri = minifySvgDataUri(path);
|
|
70
|
+
if (minifiedSvgDataUri) {
|
|
71
|
+
return minifiedSvgDataUri;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
|
|
75
|
+
path = path.replace(/^\.\//, '');
|
|
76
|
+
|
|
77
|
+
return formatUrlPath(path);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a marker that cannot appear in a parsed CSS value. URL functions are
|
|
82
|
+
* temporarily replaced with these markers while generic string operations run.
|
|
83
|
+
*
|
|
84
|
+
* @param {number} index The URL token index.
|
|
85
|
+
* @return {string} The marker representing that URL token.
|
|
86
|
+
*/
|
|
87
|
+
function createUrlTokenMarker (index) {
|
|
88
|
+
return '\u0000csslop-url-' + index + '\u0000';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replaces URL tokens with stable markers so generic value normalization does
|
|
93
|
+
* not alter data carried inside a URL.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} value The CSS value to protect.
|
|
96
|
+
* @return {object} The protected value and the original URL tokens.
|
|
97
|
+
*/
|
|
98
|
+
function protectUrlTokens (value) {
|
|
99
|
+
const urlTokens = [];
|
|
100
|
+
let protectedValue = '';
|
|
101
|
+
for (const segment of splitValueSegments(value)) {
|
|
102
|
+
const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
|
|
103
|
+
if (isUrlToken) {
|
|
104
|
+
protectedValue += createUrlTokenMarker(urlTokens.length);
|
|
105
|
+
urlTokens.push(segment.text);
|
|
106
|
+
} else {
|
|
107
|
+
protectedValue += segment.text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { value: protectedValue, urlTokens };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Restores the URL tokens removed by `protectUrlTokens`.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} value The value containing URL markers.
|
|
117
|
+
* @param {Array} urlTokens The original URL token text.
|
|
118
|
+
* @return {string} The value with URL tokens restored.
|
|
119
|
+
*/
|
|
120
|
+
function restoreUrlTokens (value, urlTokens) {
|
|
121
|
+
let restoredValue = value;
|
|
122
|
+
for (let index = 0; index < urlTokens.length; index++) {
|
|
123
|
+
const marker = createUrlTokenMarker(index);
|
|
124
|
+
restoredValue = restoredValue.split(marker).join(urlTokens[index]);
|
|
125
|
+
}
|
|
126
|
+
return restoredValue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Removes whitespace after protected URL tokens. A CSS URL token closes itself
|
|
131
|
+
* at `)`, so the following component remains distinguishable without a space.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} value The protected CSS value.
|
|
134
|
+
* @param {Array} urlTokens The original URL token text.
|
|
135
|
+
* @return {string} The protected value without trailing URL spacing.
|
|
136
|
+
*/
|
|
137
|
+
function removeWhitespaceAfterUrlTokens (value, urlTokens) {
|
|
138
|
+
let compactValue = value;
|
|
139
|
+
for (let index = 0; index < urlTokens.length; index++) {
|
|
140
|
+
const marker = createUrlTokenMarker(index);
|
|
141
|
+
const markerIndex = compactValue.indexOf(marker);
|
|
142
|
+
if (markerIndex === -1) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const contentStart = markerIndex + marker.length;
|
|
146
|
+
let nextContentIndex = contentStart;
|
|
147
|
+
while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
|
|
148
|
+
nextContentIndex++;
|
|
149
|
+
}
|
|
150
|
+
compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
|
|
151
|
+
}
|
|
152
|
+
return compactValue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Removes an explicit default background position directly after a protected
|
|
157
|
+
* URL marker, where the ordinary close-parenthesis optimization cannot see it.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} value The protected background value.
|
|
160
|
+
* @param {Array} urlTokens The original URL token text.
|
|
161
|
+
* @return {string} The value with redundant URL positions removed.
|
|
162
|
+
*/
|
|
163
|
+
function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
|
|
164
|
+
let normalizedValue = value;
|
|
165
|
+
for (let index = 0; index < urlTokens.length; index++) {
|
|
166
|
+
const marker = createUrlTokenMarker(index);
|
|
167
|
+
// Match a URL marker followed by the default 0 0 background-position pair.
|
|
168
|
+
const defaultPositionPattern = new RegExp(
|
|
169
|
+
marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
|
|
170
|
+
'g'
|
|
171
|
+
);
|
|
172
|
+
normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
|
|
173
|
+
}
|
|
174
|
+
return normalizedValue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rewrites every `url(...)` token in a CSS value to its shortest valid form,
|
|
179
|
+
* skipping any quoted strings so an embedded `url(` inside a string is ignored.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} value The CSS value string potentially containing url() tokens.
|
|
182
|
+
* @return {string} The value with all url() tokens minified.
|
|
183
|
+
*/
|
|
184
|
+
function minifyUrls (value) {
|
|
185
|
+
let result = '';
|
|
186
|
+
let index = 0;
|
|
187
|
+
|
|
188
|
+
const consumeQuoted = (start) => {
|
|
189
|
+
const quote = value[start];
|
|
190
|
+
let end = start + 1;
|
|
191
|
+
while (end < value.length) {
|
|
192
|
+
if (value[end] === '\\') {
|
|
193
|
+
end += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (value[end] === quote) {
|
|
197
|
+
end++;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
end++;
|
|
201
|
+
}
|
|
202
|
+
return end;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const startsUrl = (start) => {
|
|
206
|
+
return value.slice(start, start + 4).toLowerCase() === 'url(';
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
while (index < value.length) {
|
|
210
|
+
if (value[index] === '"' || value[index] === '\'') {
|
|
211
|
+
const end = consumeQuoted(index);
|
|
212
|
+
result += value.slice(index, end);
|
|
213
|
+
index = end;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (startsUrl(index)) {
|
|
218
|
+
const closingParenIndex = findMatchingParenthesis(value, index + 3);
|
|
219
|
+
if (closingParenIndex === -1) {
|
|
220
|
+
result += value.slice(index);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
const inner = value.slice(index + 4, closingParenIndex).trim();
|
|
224
|
+
result += 'url(' + minifyUrlContent(inner) + ')';
|
|
225
|
+
index = closingParenIndex + 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
result += value[index];
|
|
230
|
+
index++;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export {
|
|
237
|
+
minifyUrls,
|
|
238
|
+
protectUrlTokens,
|
|
239
|
+
removeDefaultBackgroundPositionsAfterUrlTokens,
|
|
240
|
+
removeWhitespaceAfterUrlTokens,
|
|
241
|
+
restoreUrlTokens
|
|
242
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Normalizes the whitespace, quotes, and Unicode escapes of a CSS value, and restores the whitespace its math operators cannot do without.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isUnicodeCharset } from '../context.js';
|
|
6
|
+
import { resolveUnicodeEscape } from '../utilities.js';
|
|
7
|
+
|
|
8
|
+
import { replaceOutsideStringsAndUrls } from './segments.js';
|
|
9
|
+
import {
|
|
10
|
+
protectUrlTokens,
|
|
11
|
+
removeWhitespaceAfterUrlTokens,
|
|
12
|
+
restoreUrlTokens
|
|
13
|
+
} from './urls.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Restores the whitespace that a math function requires before a `+` or a `-`
|
|
17
|
+
* operator. Both operators have to be surrounded by whitespace to be read as
|
|
18
|
+
* operators rather than as the sign of the term that follows, and the
|
|
19
|
+
* whitespace before one that trails a closing parenthesis is removed together
|
|
20
|
+
* with the rest of the parenthesis padding.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} value The CSS value string with parenthesis padding removed.
|
|
23
|
+
* @return {string} The value with the operator separator restored.
|
|
24
|
+
*/
|
|
25
|
+
function restoreSpaceBeforeMathOperators (value) {
|
|
26
|
+
return replaceOutsideStringsAndUrls(value, (segment) => {
|
|
27
|
+
// Match a closing parenthesis immediately followed by a `+` or `-` operator
|
|
28
|
+
return segment.replace(/\)(?=[+-])/g, ') ');
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} value The raw CSS value string to normalize.
|
|
36
|
+
* @param {string} property The CSS property name, used for context-aware quote handling.
|
|
37
|
+
* @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
|
|
38
|
+
*/
|
|
39
|
+
function normalizeWhitespaceAndQuotes (value, property) {
|
|
40
|
+
const protectedUrls = protectUrlTokens(value);
|
|
41
|
+
value = protectedUrls.value;
|
|
42
|
+
|
|
43
|
+
// Unescape unicode (skip control characters — they must stay escaped in CSS strings).
|
|
44
|
+
// Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
|
|
45
|
+
if (isUnicodeCharset()) {
|
|
46
|
+
value = value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
|
|
47
|
+
return resolveUnicodeEscape(hex) ?? match;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Normalize single-quoted strings to double-quoted
|
|
51
|
+
value = value.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
|
|
52
|
+
|
|
53
|
+
// Remove space between string literals
|
|
54
|
+
value = value.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
|
|
55
|
+
|
|
56
|
+
// Whitespace minification
|
|
57
|
+
value = value.replace(/\s*!\s*important/i, '!important');
|
|
58
|
+
// value = value.replace(/\s*([+*/=])\s*/g, '$1');
|
|
59
|
+
// Strip whitespace around commas
|
|
60
|
+
value = value.replace(/\s*([,])\s*/g, '$1');
|
|
61
|
+
// Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
|
|
62
|
+
value = value.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, quotedString, parenthesis) => {
|
|
63
|
+
if (quotedString) {
|
|
64
|
+
return quotedString;
|
|
65
|
+
}
|
|
66
|
+
return parenthesis;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
|
|
70
|
+
if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
|
|
71
|
+
// Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
|
|
72
|
+
value = value.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
|
|
73
|
+
// Keep quotes around CSS generic font-family keywords — unquoted they mean something different
|
|
74
|
+
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)) {
|
|
75
|
+
return before + '"' + inner + '"';
|
|
76
|
+
}
|
|
77
|
+
return before + inner;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
value = removeWhitespaceAfterUrlTokens(value, protectedUrls.urlTokens);
|
|
82
|
+
return restoreUrlTokens(value, protectedUrls.urlTokens);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
normalizeWhitespaceAndQuotes,
|
|
87
|
+
restoreSpaceBeforeMathOperators
|
|
88
|
+
};
|