@thejaredwilcurt/csslop 0.0.36 → 0.0.38
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 +4 -5
- package/package.json +6 -5
- package/src/declarations/process.js +44 -14
- package/src/index.js +4 -2
- package/src/rules/lang.js +366 -0
- package/src/rules/optimize.js +77 -10
- package/src/rules/selectors.js +12 -3
- package/src/rules/stringify.js +14 -8
- package/src/value/math.js +266 -11
- package/src/value/minify.js +87 -24
- package/src/value/syntax.js +7 -0
package/README.md
CHANGED
|
@@ -285,8 +285,9 @@ Two different cases:
|
|
|
285
285
|
## Updating tests
|
|
286
286
|
|
|
287
287
|
1. All test changes must occur [upstream](https://github.com/keithamus/css-minify-tests), be written by a human, and be merged in to the `css-minify-tests` repo.
|
|
288
|
-
1.
|
|
289
|
-
1.
|
|
288
|
+
1. Clone down a copy of that repo next to the cloned copy of this one (sibling folders)
|
|
289
|
+
1. Make sure the `css-minify-tests` clone is on the `main` branch and up-to-date (`git pull origin main`)
|
|
290
|
+
1. Then run `npm run copy` in the CSSLOP repo to copy the test from `css-minify-tests` to the `copiedTests` folder in this repo.
|
|
290
291
|
1. `git add -A && git commit -m "Updated tests"`
|
|
291
292
|
1. Then run `npm t` to see if any tests fail
|
|
292
293
|
1. If they fail, give an AI this prompt:
|
|
@@ -298,9 +299,7 @@ Two different cases:
|
|
|
298
299
|
1. `npm run bump`
|
|
299
300
|
1. `git add -A && git commit -m "Fix newly added tests" && git push`
|
|
300
301
|
1. Merge the code into `main` on GitHub
|
|
301
|
-
1. Do a new release on GitHub
|
|
302
|
-
1. `git checkout main && git pull origin main && git pull`
|
|
303
|
-
1. `npm run publish`
|
|
302
|
+
1. Do a new release on GitHub, this will also release to npm
|
|
304
303
|
|
|
305
304
|
|
|
306
305
|
## Code Organization prompt:
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.38",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"copy": "node ./scripts/copyTests.js",
|
|
15
15
|
"test": "node ./tests/index.test.js",
|
|
16
16
|
"real": "node ./tests/realworld.test.js",
|
|
17
|
+
"idem": "node ./tests/idempotency.test.js",
|
|
17
18
|
"lint": "eslint *.js scripts src tests --fix",
|
|
18
19
|
"fix": "npm run lint",
|
|
19
20
|
"bump": "npx --yes -- @jsdevtools/version-bump-prompt patch && npm i",
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
"proto": "proto pin node latest --resolve --tool-native && proto pin npm latest --resolve --tool-native && git status"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@csstools/css-calc": "^3.
|
|
25
|
+
"@csstools/css-calc": "^3.4.0",
|
|
25
26
|
"@node-projects/css-parser": "^5.2.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
@@ -36,12 +37,12 @@
|
|
|
36
37
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
37
38
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
38
39
|
"eslint-plugin-import-x": "^4.17.0",
|
|
39
|
-
"eslint-plugin-jsdoc": "^64.3.
|
|
40
|
+
"eslint-plugin-jsdoc": "^64.3.10",
|
|
40
41
|
"fflate": "^0.8.3",
|
|
41
42
|
"globals": "^17.12.0",
|
|
42
43
|
"pretty-ms": "^9.3.1",
|
|
43
44
|
"real-world-css-libraries": "^1.0.8",
|
|
44
|
-
"vite": "^8.
|
|
45
|
+
"vite": "^8.3.0"
|
|
45
46
|
},
|
|
46
47
|
"author": "The Jared Wilcurt",
|
|
47
48
|
"homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
"devEngines": {
|
|
59
60
|
"runtime": {
|
|
60
61
|
"name": "node",
|
|
61
|
-
"version": "26.8.
|
|
62
|
+
"version": "26.8.2"
|
|
62
63
|
},
|
|
63
64
|
"packageManager": {
|
|
64
65
|
"name": "npm",
|
|
@@ -97,7 +97,8 @@ function keepDeclaration (survivors, declaration) {
|
|
|
97
97
|
} else {
|
|
98
98
|
survivors.positionsByProperty.set(declaration.property, [position]);
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// Custom properties (`--*`) are named by their author, not vendor-prefixed
|
|
101
|
+
if (declaration.property.startsWith('-') && !declaration.property.startsWith('--')) {
|
|
101
102
|
survivors.vendorPrefixedPositions.push(position);
|
|
102
103
|
}
|
|
103
104
|
}
|
|
@@ -137,19 +138,39 @@ function findLastPositionOfProperty (survivors, property) {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
* Finds the surviving vendor-prefixed
|
|
141
|
-
*
|
|
142
|
-
*
|
|
141
|
+
* Finds the surviving vendor-prefixed declarations that set a prefixed form of
|
|
142
|
+
* a property, such as `-webkit-transform` for `transform`, including legacy
|
|
143
|
+
* spellings like `-webkit-box-flex` for `flex`. Only the prefixed declarations
|
|
144
|
+
* are visited, rather than every survivor.
|
|
143
145
|
*
|
|
144
146
|
* @param {object} survivors The surviving-declaration bookkeeping.
|
|
145
147
|
* @param {string} property The unprefixed property name.
|
|
146
|
-
* @return {
|
|
148
|
+
* @return {Array} The positions of the matching declarations, in source order.
|
|
147
149
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
function findVendorPrefixedPositions (survivors, property) {
|
|
151
|
+
return survivors.vendorPrefixedPositions.filter((position) => {
|
|
152
|
+
return survivors.slots[position].property.endsWith(property);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Finds the surviving declaration that last set the unprefixed property a
|
|
158
|
+
* vendor-prefixed declaration spells in prefixed form, such as `transform`
|
|
159
|
+
* for `-moz-transform`.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} survivors The surviving-declaration bookkeeping.
|
|
162
|
+
* @param {string} propertyName The vendor-prefixed property name.
|
|
163
|
+
* @return {number} The position of the matching declaration, or -1 when absent.
|
|
164
|
+
*/
|
|
165
|
+
function findUnprefixedTwinPosition (survivors, propertyName) {
|
|
166
|
+
for (const [property, positions] of survivors.positionsByProperty) {
|
|
167
|
+
const isUnprefixedTwin = (
|
|
168
|
+
!property.startsWith('-') &&
|
|
169
|
+
positions.length &&
|
|
170
|
+
propertyName.endsWith(property)
|
|
171
|
+
);
|
|
172
|
+
if (isUnprefixedTwin) {
|
|
173
|
+
return positions[positions.length - 1];
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
176
|
return -1;
|
|
@@ -183,15 +204,24 @@ function deduplicateDeclarations (declarations) {
|
|
|
183
204
|
|
|
184
205
|
const minifiedValue = minifyValue(declaration);
|
|
185
206
|
|
|
207
|
+
// A vendor-prefixed property that repeats the value of its unprefixed
|
|
208
|
+
// form is dead code for browsers that read the unprefixed one
|
|
209
|
+
if (propertyName.startsWith('-') && !propertyName.startsWith('--')) {
|
|
210
|
+
const twinPosition = findUnprefixedTwinPosition(survivors, propertyName);
|
|
211
|
+
if (twinPosition !== -1 && minifyValue(survivors.slots[twinPosition]) === minifiedValue) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
186
216
|
const previousPosition = findLastPositionOfProperty(survivors, propertyName);
|
|
187
217
|
|
|
188
|
-
// An unprefixed property with the same value also replaces
|
|
189
|
-
let
|
|
218
|
+
// An unprefixed property with the same value also replaces every prefixed form of it
|
|
219
|
+
let prefixedPositions = [];
|
|
190
220
|
if (!propertyName.startsWith('-')) {
|
|
191
|
-
|
|
221
|
+
prefixedPositions = findVendorPrefixedPositions(survivors, propertyName);
|
|
192
222
|
}
|
|
193
223
|
|
|
194
|
-
|
|
224
|
+
for (const prefixedPosition of prefixedPositions) {
|
|
195
225
|
const prefixedValue = minifyValue(survivors.slots[prefixedPosition]);
|
|
196
226
|
if (minifiedValue === prefixedValue) {
|
|
197
227
|
dropDeclaration(survivors, prefixedPosition);
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
preprocessDeclarationBlocks,
|
|
26
26
|
restoreEscapeSequences
|
|
27
27
|
} from './preprocess.js';
|
|
28
|
+
import { combineLanguageSelectors } from './rules/lang.js';
|
|
28
29
|
import {
|
|
29
30
|
deduplicateKeyframes,
|
|
30
31
|
expandPureNestedRules,
|
|
@@ -83,7 +84,8 @@ function splitSelectorList (selectorList) {
|
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* Deduplicates a minified CSS selector list string by splitting at top-level
|
|
86
|
-
* commas, removing duplicate selectors,
|
|
87
|
+
* commas, removing duplicate selectors, combining the selectors that differ
|
|
88
|
+
* only in language-code, and rejoining with commas.
|
|
87
89
|
*
|
|
88
90
|
* @param {string} selectorList The comma-separated selector list string.
|
|
89
91
|
* @return {string} The deduplicated selector list string.
|
|
@@ -98,7 +100,7 @@ function deduplicateSelectorList (selectorList) {
|
|
|
98
100
|
seen.add(selector);
|
|
99
101
|
return true;
|
|
100
102
|
});
|
|
101
|
-
return unique.join(',');
|
|
103
|
+
return combineLanguageSelectors(unique).join(',');
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/**
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Minifies the `:lang()` pseudo-class, by writing each language-code in
|
|
3
|
+
* its shortest valid form and by combining selectors that differ only in the
|
|
4
|
+
* language-code they ask for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
findMatchingCloseParenthesis,
|
|
9
|
+
findNextFunctionCallOutsideStrings
|
|
10
|
+
} from './selectors.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `:lang()` pseudo-class opening, as it is written in minified output.
|
|
14
|
+
* Pseudo-class names are case-insensitive, so the scan that looks for this
|
|
15
|
+
* token ignores case, while everything written back out uses this spelling.
|
|
16
|
+
*
|
|
17
|
+
* @type {string}
|
|
18
|
+
*/
|
|
19
|
+
const LANGUAGE_FUNCTION = ':lang(';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Matches a backslash escape of a character that cannot be read as the start
|
|
23
|
+
* of a hexadecimal code point escape, and captures the character it escapes.
|
|
24
|
+
*
|
|
25
|
+
* @type {RegExp}
|
|
26
|
+
*/
|
|
27
|
+
const SINGLE_CHARACTER_ESCAPE = /\\([^0-9a-fA-F])/g;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Matches a backslash followed by a hexadecimal digit, which starts a code
|
|
31
|
+
* point escape whose meaning cannot be judged without decoding it.
|
|
32
|
+
*
|
|
33
|
+
* @type {RegExp}
|
|
34
|
+
*/
|
|
35
|
+
const CODE_POINT_ESCAPE = /\\[0-9a-fA-F]/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Matches a language-code holding a character that would end the code, or the
|
|
39
|
+
* selector, if the code were written without quotes or escapes.
|
|
40
|
+
*
|
|
41
|
+
* @type {RegExp}
|
|
42
|
+
*/
|
|
43
|
+
const UNWRITABLE_CHARACTER = /[\s"'()\\]/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Matches a language-code whose first characters read as the beginning of a
|
|
47
|
+
* number, which no unquoted identifier may do.
|
|
48
|
+
*
|
|
49
|
+
* @type {RegExp}
|
|
50
|
+
*/
|
|
51
|
+
const NUMERIC_START = /^-?[0-9]/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Matches a single character an identifier may hold as written.
|
|
55
|
+
*
|
|
56
|
+
* @type {RegExp}
|
|
57
|
+
*/
|
|
58
|
+
const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The first code point that is an identifier character in its own right, so
|
|
62
|
+
* that letters beyond ASCII never need escaping.
|
|
63
|
+
*
|
|
64
|
+
* @type {number}
|
|
65
|
+
*/
|
|
66
|
+
const FIRST_NON_ASCII_CODE_POINT = 0x80;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reports whether a character has to be escaped to survive in an unquoted
|
|
70
|
+
* language-code.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} character A single character of a language-code.
|
|
73
|
+
* @return {boolean} True when the character needs a backslash in front of it.
|
|
74
|
+
*/
|
|
75
|
+
function requiresEscape (character) {
|
|
76
|
+
return (
|
|
77
|
+
character.codePointAt(0) < FIRST_NON_ASCII_CODE_POINT &&
|
|
78
|
+
!IDENTIFIER_CHARACTER.test(character)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Splits the argument list of a `:lang()` into its individual language-codes,
|
|
84
|
+
* treating only the commas outside of a quoted code as separators.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} argumentList The text between the parentheses of a `:lang()`.
|
|
87
|
+
* @return {Array} The language-codes, still in the form they were written in.
|
|
88
|
+
*/
|
|
89
|
+
function splitLanguageCodes (argumentList) {
|
|
90
|
+
const codes = [];
|
|
91
|
+
let current = '';
|
|
92
|
+
let openQuote = '';
|
|
93
|
+
for (let index = 0; index < argumentList.length; index++) {
|
|
94
|
+
const character = argumentList[index];
|
|
95
|
+
const nextCharacter = argumentList[index + 1] ?? '';
|
|
96
|
+
if (character === '\\') {
|
|
97
|
+
// An escape carries the character behind it along, whatever it may be
|
|
98
|
+
current += character + nextCharacter;
|
|
99
|
+
index++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (openQuote) {
|
|
103
|
+
current += character;
|
|
104
|
+
if (character === openQuote) {
|
|
105
|
+
openQuote = '';
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (character === '"' || character === '\'') {
|
|
110
|
+
openQuote = character;
|
|
111
|
+
current += character;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (character === ',') {
|
|
115
|
+
codes.push(current);
|
|
116
|
+
current = '';
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
current += character;
|
|
120
|
+
}
|
|
121
|
+
codes.push(current);
|
|
122
|
+
return codes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reads the plain language-code out of one `:lang()` argument, stripping the
|
|
127
|
+
* quotes or escapes that were only there to let it be written. Arguments this
|
|
128
|
+
* minifier cannot read with certainty, such as one holding a code point
|
|
129
|
+
* escape, report no code so they are left exactly as their author wrote them.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} argument One argument of a `:lang()`, as written.
|
|
132
|
+
* @return {string|null} The language-code itself, or null when it cannot be read.
|
|
133
|
+
*/
|
|
134
|
+
function readLanguageCode (argument) {
|
|
135
|
+
const text = argument.trim();
|
|
136
|
+
if (!text || CODE_POINT_ESCAPE.test(text)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const firstCharacter = text[0];
|
|
140
|
+
const isQuoted = (
|
|
141
|
+
(firstCharacter === '"' || firstCharacter === '\'') &&
|
|
142
|
+
text.length > 1 &&
|
|
143
|
+
text.endsWith(firstCharacter)
|
|
144
|
+
);
|
|
145
|
+
let code = text;
|
|
146
|
+
if (isQuoted) {
|
|
147
|
+
code = text.slice(1, -1);
|
|
148
|
+
}
|
|
149
|
+
code = code.replace(SINGLE_CHARACTER_ESCAPE, '$1');
|
|
150
|
+
if (!code || UNWRITABLE_CHARACTER.test(code)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return code;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Writes a language-code in the shortest form a browser still reads as that
|
|
158
|
+
* same code. A code of plain identifier characters needs nothing around it; a
|
|
159
|
+
* code holding others is either escaped character by character or wrapped in
|
|
160
|
+
* quotes once, whichever costs fewer characters.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} code The language-code itself.
|
|
163
|
+
* @return {string} The shortest way to write the code inside a `:lang()`.
|
|
164
|
+
*/
|
|
165
|
+
function formatLanguageCode (code) {
|
|
166
|
+
const quoted = '"' + code + '"';
|
|
167
|
+
// A leading digit can only be escaped as a code point, which is never short
|
|
168
|
+
if (NUMERIC_START.test(code)) {
|
|
169
|
+
return quoted;
|
|
170
|
+
}
|
|
171
|
+
const escaped = [...code].map((character) => {
|
|
172
|
+
if (requiresEscape(character)) {
|
|
173
|
+
return '\\' + character;
|
|
174
|
+
}
|
|
175
|
+
return character;
|
|
176
|
+
}).join('');
|
|
177
|
+
if (escaped.length <= quoted.length) {
|
|
178
|
+
return escaped;
|
|
179
|
+
}
|
|
180
|
+
return quoted;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Rewrites the argument list of one `:lang()`, lowercasing every code because
|
|
185
|
+
* language-codes are matched case-insensitively (one casing throughout a
|
|
186
|
+
* stylesheet compresses better), dropping repeated codes, and writing each
|
|
187
|
+
* remaining code in its shortest form.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} argumentList The text between the parentheses of a `:lang()`.
|
|
190
|
+
* @return {string|null} The minified argument list, or null when an argument could not be read.
|
|
191
|
+
*/
|
|
192
|
+
function minifyLanguageArguments (argumentList) {
|
|
193
|
+
const codes = [];
|
|
194
|
+
for (const argument of splitLanguageCodes(argumentList)) {
|
|
195
|
+
const code = readLanguageCode(argument);
|
|
196
|
+
if (code === null) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
codes.push(code.toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
return [...new Set(codes)].map(formatLanguageCode).join(',');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Locates every `:lang()` of a selector, reporting where each one starts,
|
|
206
|
+
* where its argument list starts, and where it ends.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} selector A minified CSS selector string.
|
|
209
|
+
* @return {Array} One entry per `:lang()`, holding its `startIndex`, `openIndex`, and `closeIndex`.
|
|
210
|
+
*/
|
|
211
|
+
function findLanguageFunctions (selector) {
|
|
212
|
+
const languageFunctions = [];
|
|
213
|
+
let position = 0;
|
|
214
|
+
while (position < selector.length) {
|
|
215
|
+
const startIndex = findNextFunctionCallOutsideStrings(selector, LANGUAGE_FUNCTION, position);
|
|
216
|
+
if (startIndex === -1) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const openIndex = startIndex + LANGUAGE_FUNCTION.length - 1;
|
|
220
|
+
const closeIndex = findMatchingCloseParenthesis(selector, openIndex);
|
|
221
|
+
if (closeIndex === -1) {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
languageFunctions.push({
|
|
225
|
+
closeIndex,
|
|
226
|
+
openIndex,
|
|
227
|
+
startIndex
|
|
228
|
+
});
|
|
229
|
+
position = closeIndex + 1;
|
|
230
|
+
}
|
|
231
|
+
return languageFunctions;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Minifies every `:lang()` a selector holds.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} selector A minified CSS selector string.
|
|
238
|
+
* @return {string} The selector with each `:lang()` written in its shortest form.
|
|
239
|
+
*/
|
|
240
|
+
function minifyLanguageSelector (selector) {
|
|
241
|
+
const languageFunctions = findLanguageFunctions(selector);
|
|
242
|
+
if (!languageFunctions.length) {
|
|
243
|
+
return selector;
|
|
244
|
+
}
|
|
245
|
+
let result = '';
|
|
246
|
+
let index = 0;
|
|
247
|
+
for (const languageFunction of languageFunctions) {
|
|
248
|
+
const {
|
|
249
|
+
closeIndex,
|
|
250
|
+
openIndex,
|
|
251
|
+
startIndex
|
|
252
|
+
} = languageFunction;
|
|
253
|
+
const minifiedArguments = minifyLanguageArguments(selector.slice(openIndex + 1, closeIndex));
|
|
254
|
+
result += selector.slice(index, startIndex);
|
|
255
|
+
if (minifiedArguments === null) {
|
|
256
|
+
result += selector.slice(startIndex, closeIndex + 1);
|
|
257
|
+
} else {
|
|
258
|
+
result += LANGUAGE_FUNCTION + minifiedArguments + ')';
|
|
259
|
+
}
|
|
260
|
+
index = closeIndex + 1;
|
|
261
|
+
}
|
|
262
|
+
return result + selector.slice(index);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reports whether a position in a selector sits inside a functional
|
|
267
|
+
* pseudo-class, such as the `:lang()` of `:not(:lang(en))`.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} selector A minified CSS selector string.
|
|
270
|
+
* @param {number} index The position to judge.
|
|
271
|
+
* @return {boolean} True when an enclosing parenthesis is still open at that position.
|
|
272
|
+
*/
|
|
273
|
+
function isInsideFunction (selector, index) {
|
|
274
|
+
let depth = 0;
|
|
275
|
+
for (const character of selector.slice(0, index)) {
|
|
276
|
+
if (character === '(') {
|
|
277
|
+
depth++;
|
|
278
|
+
} else if (character === ')') {
|
|
279
|
+
depth--;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return depth > 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Splits a selector into the text around its one and only `:lang()` and the
|
|
287
|
+
* language-codes that `:lang()` asks for. A selector holding no `:lang()`, or
|
|
288
|
+
* more than one, has no single list of codes to vary, and a `:lang()` nested
|
|
289
|
+
* in another functional pseudo-class cannot have codes added to it without
|
|
290
|
+
* changing what the pseudo-class it sits in means.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} selector A minified CSS selector string.
|
|
293
|
+
* @return {object|null} The `prefix`, `codes`, and `suffix` of the selector, or null when it has no lone top-level `:lang()`.
|
|
294
|
+
*/
|
|
295
|
+
function splitAroundLanguageCodes (selector) {
|
|
296
|
+
const languageFunctions = findLanguageFunctions(selector);
|
|
297
|
+
if (languageFunctions.length !== 1) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const {
|
|
301
|
+
closeIndex,
|
|
302
|
+
openIndex,
|
|
303
|
+
startIndex
|
|
304
|
+
} = languageFunctions[0];
|
|
305
|
+
if (isInsideFunction(selector, startIndex)) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const codes = splitLanguageCodes(selector.slice(openIndex + 1, closeIndex)).map((code) => {
|
|
309
|
+
return code.trim();
|
|
310
|
+
});
|
|
311
|
+
if (codes.some((code) => {
|
|
312
|
+
return !code;
|
|
313
|
+
})) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
codes,
|
|
318
|
+
prefix: selector.slice(0, startIndex),
|
|
319
|
+
suffix: selector.slice(closeIndex + 1)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Combines the selectors of one rule that are identical but for the
|
|
325
|
+
* language-codes of a single `:lang()`. A `:lang()` takes a comma-separated
|
|
326
|
+
* list of codes and matches an element written in any of them, so the several
|
|
327
|
+
* selectors say as one what they used to say apart, at the same specificity.
|
|
328
|
+
*
|
|
329
|
+
* @param {Array} selectors The rule's minified selector strings.
|
|
330
|
+
* @return {Array} The selector list with language-only variants combined.
|
|
331
|
+
*/
|
|
332
|
+
function combineLanguageSelectors (selectors) {
|
|
333
|
+
const groupsBySurroundings = new Map();
|
|
334
|
+
const result = [];
|
|
335
|
+
for (const selector of selectors) {
|
|
336
|
+
const parts = splitAroundLanguageCodes(selector);
|
|
337
|
+
if (!parts) {
|
|
338
|
+
result.push(selector);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// The prefix and suffix are joined by a character no selector may hold, so
|
|
342
|
+
// that text moving from one side to the other cannot look like a match
|
|
343
|
+
const surroundings = parts.prefix + '\u0000' + parts.suffix;
|
|
344
|
+
const existingGroup = groupsBySurroundings.get(surroundings);
|
|
345
|
+
if (existingGroup) {
|
|
346
|
+
existingGroup.codes.push(...parts.codes);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
groupsBySurroundings.set(surroundings, {
|
|
350
|
+
codes: [...parts.codes],
|
|
351
|
+
parts,
|
|
352
|
+
position: result.length
|
|
353
|
+
});
|
|
354
|
+
result.push(selector);
|
|
355
|
+
}
|
|
356
|
+
for (const group of groupsBySurroundings.values()) {
|
|
357
|
+
const codes = [...new Set(group.codes)];
|
|
358
|
+
result[group.position] = group.parts.prefix + LANGUAGE_FUNCTION + codes.join(',') + ')' + group.parts.suffix;
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export {
|
|
364
|
+
combineLanguageSelectors,
|
|
365
|
+
minifyLanguageSelector
|
|
366
|
+
};
|
package/src/rules/optimize.js
CHANGED
|
@@ -13,13 +13,82 @@ import {
|
|
|
13
13
|
normalizeMedia
|
|
14
14
|
} from './normalize.js';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Reports whether an entry of a rule's body is itself a nested rule rather
|
|
18
|
+
* than a plain declaration.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} entry An entry of a rule's declaration list.
|
|
21
|
+
* @return {boolean} True when the entry is a nested rule.
|
|
22
|
+
*/
|
|
23
|
+
function isNestedRule (entry) {
|
|
24
|
+
return entry.type === 'rule';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Expands the rules nested inside each rule's body, so a chain of wrappers
|
|
29
|
+
* collapses from the inside out and the outermost wrapper is then judged
|
|
30
|
+
* against an already flattened child. A rule's body holds the same kinds of
|
|
31
|
+
* entries a stylesheet does, so the body is expanded by the same rules, and
|
|
32
|
+
* the plain declarations it also holds pass through untouched.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array} rules The AST rule nodes whose bodies may hold nested rules.
|
|
35
|
+
* @return {Array} The rules, each with its body expanded.
|
|
36
|
+
*/
|
|
37
|
+
function expandNestedRuleBodies (rules) {
|
|
38
|
+
return rules.map((rule) => {
|
|
39
|
+
if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
|
|
40
|
+
return rule;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
...rule,
|
|
44
|
+
declarations: expandPureNestedRules(rule.declarations)
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reports whether a nested child selector still means the same thing once it
|
|
51
|
+
* is written out in full under its parent.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} childSelector The selector of a rule nested inside another.
|
|
54
|
+
* @return {boolean} True when the child can be written as a flat selector.
|
|
55
|
+
*/
|
|
56
|
+
function canFlattenChildSelector (childSelector) {
|
|
57
|
+
const child = childSelector.trim();
|
|
58
|
+
if (!child) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
// A `&` anywhere but the very start puts the parent where flattening cannot:
|
|
62
|
+
// `.b &` means the parent follows the child rather than leading it
|
|
63
|
+
return !child.slice(1).includes('&');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Writes a nested child selector out in full, as the selector it stands for
|
|
68
|
+
* when read through its parent.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} parentSelector The selector of the rule the child is nested in.
|
|
71
|
+
* @param {string} childSelector The selector of the nested child rule.
|
|
72
|
+
* @return {string} The flat selector the nested pair stands for.
|
|
73
|
+
*/
|
|
74
|
+
function combineNestedSelector (parentSelector, childSelector) {
|
|
75
|
+
const parent = parentSelector.trim();
|
|
76
|
+
const child = childSelector.trim();
|
|
77
|
+
// A leading `&` names the parent itself, so it is what the parent replaces
|
|
78
|
+
if (child.startsWith('&')) {
|
|
79
|
+
return parent + child.slice(1);
|
|
80
|
+
}
|
|
81
|
+
return parent + ' ' + child;
|
|
82
|
+
}
|
|
83
|
+
|
|
16
84
|
/**
|
|
17
85
|
* Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
|
|
18
86
|
*
|
|
19
|
-
* @param {Array}
|
|
20
|
-
* @return {Array}
|
|
87
|
+
* @param {Array} inputRules The AST rule nodes to process.
|
|
88
|
+
* @return {Array} A new array of rules with pure-nested rules expanded.
|
|
21
89
|
*/
|
|
22
|
-
function expandPureNestedRules (
|
|
90
|
+
function expandPureNestedRules (inputRules) {
|
|
91
|
+
const rules = expandNestedRuleBodies(inputRules);
|
|
23
92
|
const flatSelectors = new Set();
|
|
24
93
|
for (const rule of rules) {
|
|
25
94
|
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
@@ -77,16 +146,14 @@ function expandPureNestedRules (rules) {
|
|
|
77
146
|
canExpand = false;
|
|
78
147
|
break;
|
|
79
148
|
}
|
|
149
|
+
if (!nestedRule.selectors.every(canFlattenChildSelector)) {
|
|
150
|
+
canExpand = false;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
80
153
|
const combinedSelectors = [];
|
|
81
154
|
for (const parentSelector of rule.selectors) {
|
|
82
155
|
for (const childSelector of nestedRule.selectors) {
|
|
83
|
-
const
|
|
84
|
-
let combinedSelector;
|
|
85
|
-
if (trimmedChild.startsWith('&')) {
|
|
86
|
-
combinedSelector = trimmedChild.replace(/^&/, parentSelector.trim());
|
|
87
|
-
} else {
|
|
88
|
-
combinedSelector = parentSelector.trim() + ' ' + trimmedChild;
|
|
89
|
-
}
|
|
156
|
+
const combinedSelector = combineNestedSelector(parentSelector, childSelector);
|
|
90
157
|
combinedSelectors.push(combinedSelector);
|
|
91
158
|
if (flatSelectors.has(combinedSelector)) {
|
|
92
159
|
anyMatch = true;
|
package/src/rules/selectors.js
CHANGED
|
@@ -118,10 +118,11 @@ function findMatchingCloseParenthesis (text, openIndex) {
|
|
|
118
118
|
/**
|
|
119
119
|
* Finds the next occurrence of a pseudo-class function token (e.g. `:is(`)
|
|
120
120
|
* that sits at the top level of a selector, outside any quoted string or
|
|
121
|
-
* attribute selector.
|
|
121
|
+
* attribute selector. Pseudo-class names are case-insensitive, so the token is
|
|
122
|
+
* matched without regard to case and must be given in lowercase.
|
|
122
123
|
*
|
|
123
124
|
* @param {string} text The selector string to scan.
|
|
124
|
-
* @param {string} functionCall The function token to find, including its opening parenthesis.
|
|
125
|
+
* @param {string} functionCall The lowercase function token to find, including its opening parenthesis.
|
|
125
126
|
* @param {number} start The index to start scanning from.
|
|
126
127
|
* @return {number} The index of the next top-level occurrence, or -1 if none remains.
|
|
127
128
|
*/
|
|
@@ -141,7 +142,13 @@ function findNextFunctionCallOutsideStrings (text, functionCall, start) {
|
|
|
141
142
|
index += 2;
|
|
142
143
|
continue;
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
+
// The token's first character is compared before its whole length is read,
|
|
146
|
+
// so only the few positions that could start it are lowercased
|
|
147
|
+
const startsToken = (
|
|
148
|
+
character === functionCall[0] &&
|
|
149
|
+
text.slice(index, index + functionCall.length).toLowerCase() === functionCall
|
|
150
|
+
);
|
|
151
|
+
if (startsToken) {
|
|
145
152
|
return index;
|
|
146
153
|
}
|
|
147
154
|
index++;
|
|
@@ -876,6 +883,8 @@ function flattenNestingParentIsSelector (selector) {
|
|
|
876
883
|
return parts;
|
|
877
884
|
}
|
|
878
885
|
export {
|
|
886
|
+
findMatchingCloseParenthesis,
|
|
887
|
+
findNextFunctionCallOutsideStrings,
|
|
879
888
|
flattenNestingParentIsSelector,
|
|
880
889
|
mergeAdjacentWherePseudoClasses,
|
|
881
890
|
processIsSelector,
|
package/src/rules/stringify.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { processDeclarations } from '../declarations/process.js';
|
|
6
|
-
import {
|
|
6
|
+
import { minifyValueForOutput } from '../value/minify.js';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
collapseCustomPropertyWhitespace,
|
|
10
10
|
processCustomPropertyComments
|
|
11
11
|
} from './custom-properties.js';
|
|
12
|
+
import {
|
|
13
|
+
combineLanguageSelectors,
|
|
14
|
+
minifyLanguageSelector
|
|
15
|
+
} from './lang.js';
|
|
12
16
|
import {
|
|
13
17
|
canUnwrapSupports,
|
|
14
18
|
normalizeLayerNames,
|
|
@@ -37,7 +41,7 @@ function stringifyDeclarations (declarations) {
|
|
|
37
41
|
return declaration.type !== 'whitespace' && declaration.type !== 'comment' && declaration.property;
|
|
38
42
|
})
|
|
39
43
|
.map((declaration) => {
|
|
40
|
-
return [declaration.property, ':',
|
|
44
|
+
return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
|
|
41
45
|
})
|
|
42
46
|
.join(';');
|
|
43
47
|
}
|
|
@@ -251,6 +255,7 @@ function stringifyRule (rule, context) {
|
|
|
251
255
|
// Remove redundant leading "& " nesting selector
|
|
252
256
|
minified = minified.replace(/^& /, '');
|
|
253
257
|
minified = mergeAdjacentWherePseudoClasses(minified);
|
|
258
|
+
minified = minifyLanguageSelector(minified);
|
|
254
259
|
return minified;
|
|
255
260
|
});
|
|
256
261
|
// When this rule is a nesting parent, its whole selector list is treated
|
|
@@ -267,6 +272,7 @@ function stringifyRule (rule, context) {
|
|
|
267
272
|
return processIsSelector(selector, hasSiblingSelectors);
|
|
268
273
|
});
|
|
269
274
|
uniqueSelectors = [...new Set(uniqueSelectors)];
|
|
275
|
+
uniqueSelectors = combineLanguageSelectors(uniqueSelectors);
|
|
270
276
|
output.push(uniqueSelectors.join(','));
|
|
271
277
|
}
|
|
272
278
|
output.push('{');
|
|
@@ -295,7 +301,7 @@ function stringifyRule (rule, context) {
|
|
|
295
301
|
if (property.startsWith('--')) {
|
|
296
302
|
const syntax = context.registeredCustomPropertySyntax.get(property);
|
|
297
303
|
if (syntax === '"<color>"') {
|
|
298
|
-
value =
|
|
304
|
+
value = minifyValueForOutput(declaration);
|
|
299
305
|
} else {
|
|
300
306
|
const rawValue = declaration.rawValue || declaration.value || '';
|
|
301
307
|
const commentProcessedValue = processCustomPropertyComments(rawValue);
|
|
@@ -323,7 +329,7 @@ function stringifyRule (rule, context) {
|
|
|
323
329
|
}
|
|
324
330
|
}
|
|
325
331
|
} else {
|
|
326
|
-
value =
|
|
332
|
+
value = minifyValueForOutput(declaration);
|
|
327
333
|
}
|
|
328
334
|
return [property, ':', value].join('');
|
|
329
335
|
})
|
|
@@ -362,7 +368,7 @@ function stringifyRule (rule, context) {
|
|
|
362
368
|
return item.type !== 'declaration';
|
|
363
369
|
});
|
|
364
370
|
const renderedDeclarations = mediaDeclarations.map((declaration) => {
|
|
365
|
-
return [unescapeIdent(declaration.property), ':',
|
|
371
|
+
return [unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('');
|
|
366
372
|
}).join(';');
|
|
367
373
|
const renderedRules = stringifyChildRules(subRules, context);
|
|
368
374
|
const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
|
|
@@ -440,7 +446,7 @@ function stringifyRule (rule, context) {
|
|
|
440
446
|
return declaration.type !== 'whitespace' && declaration.type !== 'comment';
|
|
441
447
|
})
|
|
442
448
|
?.map((declaration) => {
|
|
443
|
-
return [declaration.property, ':',
|
|
449
|
+
return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
|
|
444
450
|
})
|
|
445
451
|
.join(';') || '';
|
|
446
452
|
output.push(renderedKeyframeDeclarations);
|
|
@@ -558,7 +564,7 @@ function stringifyRule (rule, context) {
|
|
|
558
564
|
return innerDeclaration.type !== 'whitespace' && innerDeclaration.property;
|
|
559
565
|
})
|
|
560
566
|
.map((innerDeclaration) => {
|
|
561
|
-
return [unescapeIdent(innerDeclaration.property), ':',
|
|
567
|
+
return [unescapeIdent(innerDeclaration.property), ':', minifyValueForOutput(innerDeclaration)].join('');
|
|
562
568
|
})
|
|
563
569
|
.join(';');
|
|
564
570
|
if (innerDeclarations) {
|
|
@@ -567,7 +573,7 @@ function stringifyRule (rule, context) {
|
|
|
567
573
|
return [];
|
|
568
574
|
}
|
|
569
575
|
if (declaration.property) {
|
|
570
|
-
return [[unescapeIdent(declaration.property), ':',
|
|
576
|
+
return [[unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('')];
|
|
571
577
|
}
|
|
572
578
|
return [];
|
|
573
579
|
});
|
package/src/value/math.js
CHANGED
|
@@ -10,6 +10,261 @@ import {
|
|
|
10
10
|
roundCompactNumber
|
|
11
11
|
} from './shared.js';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Characters that start a token able to sit directly against a value that
|
|
15
|
+
* precedes them, so no whitespace is needed to keep the two tokens apart:
|
|
16
|
+
* whitespace already separates, and openers like `#`, `!`, or any bracket or
|
|
17
|
+
* punctuation mark their own token's start. Mirrors the tokenizer's notion of
|
|
18
|
+
* tokens marking their own start, with `/` on top since it delimits a size
|
|
19
|
+
* from a position in layered shorthands.
|
|
20
|
+
*
|
|
21
|
+
* @type {Set<string>}
|
|
22
|
+
*/
|
|
23
|
+
const SELF_DELIMITING_FOLLOWERS = new Set([
|
|
24
|
+
' ',
|
|
25
|
+
'\t',
|
|
26
|
+
'\n',
|
|
27
|
+
'\r',
|
|
28
|
+
'\f',
|
|
29
|
+
'#',
|
|
30
|
+
'!',
|
|
31
|
+
'"',
|
|
32
|
+
'\'',
|
|
33
|
+
'(',
|
|
34
|
+
')',
|
|
35
|
+
'[',
|
|
36
|
+
']',
|
|
37
|
+
'{',
|
|
38
|
+
'}',
|
|
39
|
+
',',
|
|
40
|
+
':',
|
|
41
|
+
';',
|
|
42
|
+
'/'
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reports whether a math function's replacement glues itself to the character
|
|
47
|
+
* following the matched function, merging two values into one token. A
|
|
48
|
+
* replacement that keeps its function form ends with a parenthesis, which
|
|
49
|
+
* closes itself; only a bare resolved value needs the whitespace that the
|
|
50
|
+
* parenthesis padding removal took away.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} replacement The string the math function resolved to.
|
|
53
|
+
* @param {string} followingCharacter The character directly after the matched function.
|
|
54
|
+
* @return {boolean} Whether a separating space must be restored.
|
|
55
|
+
*/
|
|
56
|
+
function mergesWithFollowingCharacter (replacement, followingCharacter) {
|
|
57
|
+
if (replacement.endsWith(')') || !followingCharacter) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return !SELF_DELIMITING_FOLLOWERS.has(followingCharacter);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wraps a math-function replacer so a function that dissolves into a bare
|
|
65
|
+
* value does not lose the whitespace separating it from the value behind it.
|
|
66
|
+
* The values were once told apart by where the function's parentheses sat, so
|
|
67
|
+
* when the parentheses go, the separator has to stay.
|
|
68
|
+
*
|
|
69
|
+
* @param {function(string, ...(string|number|undefined)): string} replacer The replacement callback for `String.prototype.replace`.
|
|
70
|
+
* @return {function(string, ...(string|number|undefined)): string} A replacer that keeps the trailing separator intact.
|
|
71
|
+
*/
|
|
72
|
+
function keepSeparatorAfterDissolvedFunction (replacer) {
|
|
73
|
+
return (match, ...rest) => {
|
|
74
|
+
const replacement = replacer(match, ...rest);
|
|
75
|
+
// A replace callback's trailing arguments are the match offset and the full string
|
|
76
|
+
const fullText = rest[rest.length - 1];
|
|
77
|
+
const matchOffset = rest[rest.length - 2];
|
|
78
|
+
const followingCharacter = fullText[matchOffset + match.length];
|
|
79
|
+
if (mergesWithFollowingCharacter(replacement, followingCharacter)) {
|
|
80
|
+
return replacement + ' ';
|
|
81
|
+
}
|
|
82
|
+
return replacement;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The functions whose arguments are a math expression, where `*` and `/` are
|
|
88
|
+
* arithmetic operators rather than characters of some other syntax.
|
|
89
|
+
*
|
|
90
|
+
* @type {Set<string>}
|
|
91
|
+
*/
|
|
92
|
+
const MATH_FUNCTION_NAMES = new Set([
|
|
93
|
+
'abs',
|
|
94
|
+
'calc',
|
|
95
|
+
'clamp',
|
|
96
|
+
'hypot',
|
|
97
|
+
'max',
|
|
98
|
+
'min',
|
|
99
|
+
'mod',
|
|
100
|
+
'rem',
|
|
101
|
+
'round',
|
|
102
|
+
'sign'
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Matches a single character that may appear in a CSS function name.
|
|
107
|
+
*
|
|
108
|
+
* @type {RegExp}
|
|
109
|
+
*/
|
|
110
|
+
const FUNCTION_NAME_CHARACTER = /[a-zA-Z-]/;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Finds where the function name that ends at an opening parenthesis begins.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} value The CSS value string being scanned.
|
|
116
|
+
* @param {number} openIndex The index of the opening parenthesis.
|
|
117
|
+
* @return {number} The index of the function name's first character.
|
|
118
|
+
*/
|
|
119
|
+
function findFunctionNameStart (value, openIndex) {
|
|
120
|
+
let index = openIndex;
|
|
121
|
+
while (index > 0 && FUNCTION_NAME_CHARACTER.test(value[index - 1])) {
|
|
122
|
+
index--;
|
|
123
|
+
}
|
|
124
|
+
return index;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Finds the parenthesis closing the one at the given index.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} value The CSS value string being scanned.
|
|
131
|
+
* @param {number} openIndex The index of the opening parenthesis.
|
|
132
|
+
* @return {number} The index of the matching closing parenthesis, or -1 when it never closes.
|
|
133
|
+
*/
|
|
134
|
+
function findClosingParenthesis (value, openIndex) {
|
|
135
|
+
let depth = 0;
|
|
136
|
+
for (let index = openIndex; index < value.length; index++) {
|
|
137
|
+
if (value[index] === '(') {
|
|
138
|
+
depth++;
|
|
139
|
+
} else if (value[index] === ')') {
|
|
140
|
+
depth--;
|
|
141
|
+
if (depth === 0) {
|
|
142
|
+
return index;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Rewrites the contents of every math function in a value, leaving the rest of
|
|
151
|
+
* the value untouched. A `*` only means multiplication inside these functions,
|
|
152
|
+
* so an arithmetic rewrite may only reach the text between their parentheses.
|
|
153
|
+
* The whole of a math function is handed over at once, so a math function
|
|
154
|
+
* nested in another is rewritten as part of its parent.
|
|
155
|
+
*
|
|
156
|
+
* @param {function(string): string} rewriteExpression Receives a math function's contents and returns its replacement.
|
|
157
|
+
* @param {string} value The CSS value string to rewrite.
|
|
158
|
+
* @return {string} The value with every math function's contents rewritten.
|
|
159
|
+
*/
|
|
160
|
+
function rewriteMathFunctionContents (rewriteExpression, value) {
|
|
161
|
+
let result = '';
|
|
162
|
+
let index = 0;
|
|
163
|
+
while (index < value.length) {
|
|
164
|
+
const openIndex = value.indexOf('(', index);
|
|
165
|
+
if (openIndex === -1) {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const functionName = value.slice(findFunctionNameStart(value, openIndex), openIndex).toLowerCase();
|
|
169
|
+
const closeIndex = findClosingParenthesis(value, openIndex);
|
|
170
|
+
if (!MATH_FUNCTION_NAMES.has(functionName) || closeIndex === -1) {
|
|
171
|
+
result += value.slice(index, openIndex + 1);
|
|
172
|
+
index = openIndex + 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
result += value.slice(index, openIndex + 1);
|
|
176
|
+
result += rewriteExpression(value.slice(openIndex + 1, closeIndex));
|
|
177
|
+
index = closeIndex;
|
|
178
|
+
}
|
|
179
|
+
return result + value.slice(index);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Matches a multiplication by a decimal below one, capturing the digits after
|
|
184
|
+
* its decimal point and the unit written on it. The trailing lookahead keeps
|
|
185
|
+
* the match away from numbers that only begin this way, such as the exponent
|
|
186
|
+
* of `.5e3` or a unit name that is still being read.
|
|
187
|
+
*
|
|
188
|
+
* @type {RegExp}
|
|
189
|
+
*/
|
|
190
|
+
const FRACTIONAL_MULTIPLIER = /\s*\*\s*0*\.(\d+)([a-z%]*)(?![\w.%(])/gi;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The most decimal places a fraction may be built from while the power of ten
|
|
194
|
+
* it needs is still exactly representable as an integer.
|
|
195
|
+
*
|
|
196
|
+
* @type {number}
|
|
197
|
+
*/
|
|
198
|
+
const MAXIMUM_FRACTION_DIGITS = 15;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Computes the greatest common divisor of two non-negative integers, which is
|
|
202
|
+
* what a fraction has to be divided by to be stated in lowest terms.
|
|
203
|
+
*
|
|
204
|
+
* @param {number} first The first integer.
|
|
205
|
+
* @param {number} second The second integer.
|
|
206
|
+
* @return {number} The greatest common divisor of both integers.
|
|
207
|
+
*/
|
|
208
|
+
function greatestCommonDivisor (first, second) {
|
|
209
|
+
let dividend = first;
|
|
210
|
+
let divisor = second;
|
|
211
|
+
while (divisor) {
|
|
212
|
+
const remainder = dividend % divisor;
|
|
213
|
+
dividend = divisor;
|
|
214
|
+
divisor = remainder;
|
|
215
|
+
}
|
|
216
|
+
return dividend;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Converts the digits behind a decimal point into the whole number that
|
|
221
|
+
* dividing by scales a value exactly as multiplying by that decimal does.
|
|
222
|
+
* The digits `25` (of `.25`) state the fraction 25/100, which reduces to 1/4,
|
|
223
|
+
* so the divisor is `4`. A fraction that does not reduce to a numerator of one
|
|
224
|
+
* cannot be stated as a single division, and so has no divisor to give.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} fractionDigits The digits written after the decimal point.
|
|
227
|
+
* @return {number|null} The equivalent whole number divisor, or null when there is none.
|
|
228
|
+
*/
|
|
229
|
+
function reciprocalOfDecimalFraction (fractionDigits) {
|
|
230
|
+
if (fractionDigits.length > MAXIMUM_FRACTION_DIGITS) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const numerator = Number(fractionDigits);
|
|
234
|
+
const denominator = 10 ** fractionDigits.length;
|
|
235
|
+
const commonFactor = greatestCommonDivisor(numerator, denominator);
|
|
236
|
+
if (numerator / commonFactor !== 1) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return denominator / commonFactor;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Rewrites a multiplication by a decimal below one as a division by the whole
|
|
244
|
+
* number that decimal is the reciprocal of, which is never longer to write and
|
|
245
|
+
* usually shorter (`calc(var(--x)*.25)` → `calc(var(--x)/4)`). A unit written
|
|
246
|
+
* on the multiplier moves onto the divisor, since only how the operand is
|
|
247
|
+
* stated changes.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} expression The contents of a math function.
|
|
250
|
+
* @return {string} The expression with reciprocal multipliers stated as divisions.
|
|
251
|
+
*/
|
|
252
|
+
function convertReciprocalMultiplicationToDivision (expression) {
|
|
253
|
+
return expression.replace(FRACTIONAL_MULTIPLIER, (match, fractionDigits, unit) => {
|
|
254
|
+
const divisor = reciprocalOfDecimalFraction(fractionDigits);
|
|
255
|
+
if (divisor === null) {
|
|
256
|
+
return match;
|
|
257
|
+
}
|
|
258
|
+
const division = '/' + divisor + unit;
|
|
259
|
+
// Compared against the shortest the multiplication could have been written
|
|
260
|
+
const multiplication = '*.' + fractionDigits + unit;
|
|
261
|
+
if (division.length > multiplication.length) {
|
|
262
|
+
return match;
|
|
263
|
+
}
|
|
264
|
+
return division;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
13
268
|
/**
|
|
14
269
|
* The units a folded calc() expression leads with, in the order they are
|
|
15
270
|
* written. Every other unit follows them alphabetically.
|
|
@@ -150,33 +405,33 @@ function tryFoldCalcExpression (expression) {
|
|
|
150
405
|
* @return {string} The value with math functions simplified where possible.
|
|
151
406
|
*/
|
|
152
407
|
function normalizeMathFunctions (value, property, originalValue = '') {
|
|
153
|
-
let result = value;
|
|
408
|
+
let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
|
|
154
409
|
|
|
155
410
|
// Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
|
|
156
|
-
result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, (match, inner) => {
|
|
411
|
+
result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
|
|
157
412
|
return inner.trim();
|
|
158
|
-
});
|
|
413
|
+
}));
|
|
159
414
|
// Flatten calc(calc(a) ± b) → calc(a ± b)
|
|
160
|
-
result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, (match, inner, operator, tail) => {
|
|
415
|
+
result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner, operator, tail) => {
|
|
161
416
|
return 'calc(' + inner + ' ' + operator + ' ' + tail + ')';
|
|
162
|
-
});
|
|
417
|
+
}));
|
|
163
418
|
// Unwrap calc(calc(x)) → calc(x)
|
|
164
|
-
result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, (match, inner) => {
|
|
419
|
+
result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
|
|
165
420
|
return 'calc(' + inner + ')';
|
|
166
|
-
});
|
|
421
|
+
}));
|
|
167
422
|
|
|
168
423
|
// Simplify min()/max() expressions using @csstools/css-calc
|
|
169
|
-
result = result.replace(/\b(min|max)\(([^()]+)\)/gi, (match) => {
|
|
424
|
+
result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
|
|
170
425
|
try {
|
|
171
426
|
const simplified = calc(match);
|
|
172
427
|
return typeof simplified === 'string' ? simplified : match;
|
|
173
428
|
} catch {
|
|
174
429
|
return match;
|
|
175
430
|
}
|
|
176
|
-
});
|
|
431
|
+
}));
|
|
177
432
|
|
|
178
433
|
// Simplify calc() expressions using constant folding and @csstools/css-calc
|
|
179
|
-
result = result.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
|
|
434
|
+
result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
|
|
180
435
|
// Collapse whitespace inside calc expression
|
|
181
436
|
const compactInner = inner.replace(/\s+/g, ' ').trim();
|
|
182
437
|
// Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
|
|
@@ -208,7 +463,7 @@ function normalizeMathFunctions (value, property, originalValue = '') {
|
|
|
208
463
|
} catch {
|
|
209
464
|
return match;
|
|
210
465
|
}
|
|
211
|
-
});
|
|
466
|
+
}));
|
|
212
467
|
|
|
213
468
|
// When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
|
|
214
469
|
if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
|
package/src/value/minify.js
CHANGED
|
@@ -165,6 +165,11 @@ function splitValueSegments (value) {
|
|
|
165
165
|
end = consumeQuoted(end);
|
|
166
166
|
continue;
|
|
167
167
|
}
|
|
168
|
+
// An escaped character is url data, never the parenthesis that ends it
|
|
169
|
+
if (value[end] === '\\') {
|
|
170
|
+
end += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
168
173
|
if (value[end] === '(') {
|
|
169
174
|
depth++;
|
|
170
175
|
}
|
|
@@ -250,38 +255,42 @@ function restoreSpaceBeforeMathOperators (value) {
|
|
|
250
255
|
});
|
|
251
256
|
}
|
|
252
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Escapes the characters an unquoted url() token cannot hold, so the path can
|
|
260
|
+
* drop its quotes. A backslash already escaping a character is left alone, so
|
|
261
|
+
* a path that arrived escaped is never escaped a second time.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} path The url path, without surrounding quotes.
|
|
264
|
+
* @return {string} The path with every bare space and parenthesis escaped.
|
|
265
|
+
*/
|
|
266
|
+
function escapeUrlPathCharacters (path) {
|
|
267
|
+
// Match each space and parenthesis that no escaping backslash precedes
|
|
268
|
+
return path.replace(/(?<!\\)([ ()])/g, '\\$1');
|
|
269
|
+
}
|
|
270
|
+
|
|
253
271
|
/**
|
|
254
272
|
* Chooses the shortest valid representation for the path inside a `url(...)`
|
|
255
|
-
* token, weighing an unquoted form
|
|
273
|
+
* token, weighing an unquoted form — its spaces and parentheses escaped —
|
|
274
|
+
* against a quoted form. Quotation marks can only ever appear in the quoted
|
|
275
|
+
* form, so a path holding one always keeps its wrapper.
|
|
256
276
|
*
|
|
257
277
|
* @param {string} path The resolved url path, without surrounding quotes.
|
|
258
278
|
* @return {string} The shortest valid url() content string.
|
|
259
279
|
*/
|
|
260
280
|
function formatUrlPath (path) {
|
|
261
|
-
//
|
|
262
|
-
const
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// A space only needs escaping when no escaping backslash precedes it
|
|
267
|
-
const unescapedSpaceCount = (path.match(/(?<!\\) /g) || []).length;
|
|
268
|
-
const spaceCount = escapedSpaceCount + unescapedSpaceCount;
|
|
269
|
-
|
|
270
|
-
if (hasQuoteForcingCharacters || spaceCount >= 2) {
|
|
271
|
-
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
272
|
-
return '"' + path.replace(/"/g, '\\"') + '"';
|
|
281
|
+
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
282
|
+
const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
|
|
283
|
+
// Match a quotation mark, which an unquoted url() token cannot hold
|
|
284
|
+
if (/["']/.test(path)) {
|
|
285
|
+
return quotedForm;
|
|
273
286
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return path;
|
|
280
|
-
}
|
|
281
|
-
return path.replace(/(?<!\\) /g, '\\ ');
|
|
287
|
+
const unquotedForm = escapeUrlPathCharacters(path);
|
|
288
|
+
// Each escaped character costs one backslash, so dropping the two quotation
|
|
289
|
+
// marks pays off whenever at most one character needs escaping
|
|
290
|
+
if (unquotedForm.length < quotedForm.length) {
|
|
291
|
+
return unquotedForm;
|
|
282
292
|
}
|
|
283
|
-
|
|
284
|
-
return path;
|
|
293
|
+
return quotedForm;
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
/**
|
|
@@ -829,6 +838,26 @@ const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
|
|
|
829
838
|
*/
|
|
830
839
|
const AUTO_INITIAL_PROPERTIES = new Set(['min-width', 'min-height']);
|
|
831
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Properties whose initial value is a single keyword longer than the global
|
|
843
|
+
* `initial` keyword itself. These replacements are applied when serializing
|
|
844
|
+
* declarations, because shorthand builders reconstruct their value from the
|
|
845
|
+
* actual longhand keywords, and a CSS-wide keyword such as `initial` is only
|
|
846
|
+
* valid as a declaration's entire value.
|
|
847
|
+
*
|
|
848
|
+
* @type {Map<string, string>}
|
|
849
|
+
*/
|
|
850
|
+
const LENGTHY_INITIAL_KEYWORDS = new Map([
|
|
851
|
+
['background-clip', 'border-box'],
|
|
852
|
+
['background-origin', 'padding-box'],
|
|
853
|
+
['border-collapse', 'separate'],
|
|
854
|
+
['box-sizing', 'content-box'],
|
|
855
|
+
['mask-clip', 'border-box'],
|
|
856
|
+
['mask-origin', 'border-box'],
|
|
857
|
+
['vertical-align', 'baseline'],
|
|
858
|
+
['writing-mode', 'horizontal-tb']
|
|
859
|
+
]);
|
|
860
|
+
|
|
832
861
|
/**
|
|
833
862
|
* Properties whose value is a time, where a millisecond amount may be worth
|
|
834
863
|
* rewriting in seconds.
|
|
@@ -979,6 +1008,39 @@ function reorderBorderWidthBeforeStyle (value) {
|
|
|
979
1008
|
return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
|
|
980
1009
|
}
|
|
981
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* Minifies a declaration's value for output, rewriting its initial keyword to
|
|
1013
|
+
* the shorter global `initial` keyword after shorthand assembly. Both are
|
|
1014
|
+
* equivalent, but `initial` is shorter than these particular keywords and,
|
|
1015
|
+
* being repeated across properties, gives gzip more opportunities to deduplicate it.
|
|
1016
|
+
*
|
|
1017
|
+
* @param {object} declaration The CSS declaration object with property and value fields.
|
|
1018
|
+
* @return {string} The minified value with its initial keyword shortened to `initial` when that saves bytes.
|
|
1019
|
+
*/
|
|
1020
|
+
function minifyValueForOutput (declaration) {
|
|
1021
|
+
const value = minifyValue(declaration);
|
|
1022
|
+
const property = declaration.property;
|
|
1023
|
+
// Custom properties hold an arbitrary token stream that JavaScript can read
|
|
1024
|
+
// back, so their keywords are left exactly as written.
|
|
1025
|
+
if (isCustomProperty(property)) {
|
|
1026
|
+
return value;
|
|
1027
|
+
}
|
|
1028
|
+
// A keyword spelling the property's own initial value is shorter as the
|
|
1029
|
+
// global keyword (box-sizing: content-box -> box-sizing: initial)
|
|
1030
|
+
const initialKeyword = LENGTHY_INITIAL_KEYWORDS.get(property?.toLowerCase());
|
|
1031
|
+
if (!initialKeyword || 'initial'.length >= initialKeyword.length) {
|
|
1032
|
+
return value;
|
|
1033
|
+
}
|
|
1034
|
+
// A trailing `!important` rides along inside the value, so the keyword
|
|
1035
|
+
// comparison and the rewrite only concern what sits in front of it.
|
|
1036
|
+
const isImportant = value.endsWith('!important');
|
|
1037
|
+
const keyword = isImportant ? value.slice(0, value.length - '!important'.length) : value;
|
|
1038
|
+
if (keyword.toLowerCase() !== initialKeyword) {
|
|
1039
|
+
return value;
|
|
1040
|
+
}
|
|
1041
|
+
return 'initial' + (isImportant ? '!important' : '');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
982
1044
|
/**
|
|
983
1045
|
* Applies property-specific optimizations to a CSS value (transition, flex, font,
|
|
984
1046
|
* background, display, scale, border-radius, shorthand collapsing, etc.).
|
|
@@ -1417,5 +1479,6 @@ function minifyValue (declaration) {
|
|
|
1417
1479
|
|
|
1418
1480
|
export {
|
|
1419
1481
|
clearMinifiedValueCache,
|
|
1420
|
-
minifyValue
|
|
1482
|
+
minifyValue,
|
|
1483
|
+
minifyValueForOutput
|
|
1421
1484
|
};
|
package/src/value/syntax.js
CHANGED
|
@@ -29,6 +29,13 @@ function findMatchingParenthesis (value, openParenIndex) {
|
|
|
29
29
|
continue;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// An escaped character is data rather than a delimiter, even outside a
|
|
33
|
+
// quoted string, as in the `\)` of an unquoted url()
|
|
34
|
+
if (character === '\\') {
|
|
35
|
+
index += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
if (character === '"' || character === '\'') {
|
|
33
40
|
activeQuote = character;
|
|
34
41
|
index++;
|