@thejaredwilcurt/csslop 0.0.21 → 0.0.23
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 +12 -5
- package/package.json +6 -6
- package/src/charset.js +246 -0
- package/src/context.js +7 -8
- package/src/declarations/css-wide-keywords.js +285 -0
- package/src/declarations/process.js +2 -0
- package/src/index.js +9 -19
- package/src/position-try.js +0 -32
- package/src/rules/normalize.js +13 -0
- package/src/rules/optimize.js +5 -2
- package/src/rules/stringify.js +40 -8
- package/src/value/minify.js +49 -5
package/README.md
CHANGED
|
@@ -20,16 +20,23 @@
|
|
|
20
20
|
|
|
21
21
|
* All tests pass.
|
|
22
22
|
* Several new tests were created upstream, and some existing tests were improved/fixed upstream (all manually by me, no AI used).
|
|
23
|
-
* The `src` folder is 100% vibe coded, and despite passing all tests, is
|
|
23
|
+
* The `src` folder is 100% vibe coded, and despite passing all tests, is absolutely not worth using as CSSLOP often outputs invalid CSS syntax. But that's the point, any time it outputs something wrong, that's an indication of a missing test upstream.
|
|
24
|
+
* I've made [an online playground](https://TheJaredWilcurt.com/playground) of all modern CSS minifiers so you can compare and isolate the problem, then use that to create a [new test upstream](https://github.com/keithamus/css-minify-tests/issues) for the AI's to be forced to pass.
|
|
24
25
|
* No AI generated code exists outside of the `src` folder, this README, for example, is 100% human crafted.
|
|
25
26
|
|
|
26
|
-
**AI's used:**
|
|
27
|
+
**AI's used during initial library creation:**
|
|
27
28
|
|
|
28
29
|
* Claude Opus 4.6 (Thinking)
|
|
29
30
|
* Claude Sonnet 4.6 (Thinking)
|
|
30
31
|
* Gemini 3.1 Pro (High Thinking)
|
|
31
32
|
* GPT-5.4 High (Thinking)
|
|
32
33
|
|
|
34
|
+
These were the latest and greatest models at the time.
|
|
35
|
+
|
|
36
|
+
**AI's used during maintenance:**
|
|
37
|
+
|
|
38
|
+
As new tests are created upstream I use whatever the latest models are, like "Claude 5 High Thinking".
|
|
39
|
+
|
|
33
40
|
These tools were prompted to pass the tests in the `/copiedTests` folder that came from `keithamus/css-minify-tests`.
|
|
34
41
|
|
|
35
42
|
**Summary of project phases:**
|
|
@@ -51,8 +58,8 @@ These tools were prompted to pass the tests in the `/copiedTests` folder that ca
|
|
|
51
58
|
1. **Test improvements:** Throughout this process, as upstream tests were improved or created, they were pulled in, and the AI was instructed to pass those new tests with prompts like, "Run `npm t` and fix all failing tests by modifying files in `src`."
|
|
52
59
|
1. **Publish:** I had the AI pick a name for the library. Then I published it to npm. It was added to the `css-minify-tests` repo as evidence that it is possible to get all tests to pass and none are conflicting.
|
|
53
60
|
1. **Real world testing:** I created a [separate repo](https://github.com/TheJaredWilcurt/real-world-css-libraries) with copies of 150+ real-world CSS files from open source licensed repos. Then ran all of those CSS files through CSSLOP. One file found a bug in CSSLOP, so I had Claude fix it with a one-line change. See: [`realWorldResults.json`](https://github.com/TheJaredWilcurt/csslop/blob/main/realWorldResults.json) for how well the library actually does on real CSS files. Examining the output has lead to many upstream improvements to the test suite.
|
|
54
|
-
1. **Failed Performance improvements:** CSSLOP takes 3 hours to minify all the real-world tests. Which averages to 144 seconds per test. In reality, most tests take 0-50ms, but there are a handful of large (2-5MB) CSS files that can take over an hour. I asked Claude to improve performance, and it did so bad I had to reject all changes. Then I gave GPT a chance and it took a safer approach. I Had Claude clean up the messy code after the fact. Then
|
|
55
|
-
1. **Playground:** Created an [online playground](https://TheJaredWilcurt.com/csslop) (no AI used) for others to more easily test things out and find
|
|
61
|
+
1. **Failed Performance improvements:** CSSLOP takes 3 hours to minify all the real-world tests. Which averages to 144 seconds per test. In reality, most tests take 0-50ms, but there are a handful of large (2-5MB) CSS files that can take over an hour. I asked Claude to improve performance, and it did so bad I had to reject all changes. Then I gave GPT a chance and it took a safer approach. I Had Claude clean up the messy code after the fact. Then benchmarked it and it was somehow even slower (+20 min), and also the outputs weren't as small as before (+0.05%). Details below. **Update:** The "Real world" library has grown. It now takes over 7 hours to complete.
|
|
62
|
+
1. **Playground:** Created an [online playground](https://TheJaredWilcurt.com/csslop) (no AI used) for others to more easily test things out and find and create new upstream tests.
|
|
56
63
|
|
|
57
64
|
|
|
58
65
|
**Full Notes of AI Experiment:**
|
|
@@ -166,7 +173,7 @@ These tools were prompted to pass the tests in the `/copiedTests` folder that ca
|
|
|
166
173
|
* Okay, so GPT's changes look promising, it's passing all tests, passing the linter, and it added some new files related to worker threads. Though it did out out of using promises, because it didn't want to change the entrypoint function of the library to be async. This would be a breaking change for library consumers, but I was aware of that when I told it to do it and it ignored me, so whatever.
|
|
167
174
|
* On to actually testing it! Before the optimizations, the 150 files took 3 hours and 4 minutes to run, and now with the new and improved optimizations, it only takes 3 hours and 20 minutes. Also the total minified filesize increased by 0.05%. So that's cool.... Dumping those changes.
|
|
168
175
|
* **KILL ALL HUMANS:**
|
|
169
|
-
* More libraries were added to the "Real World CSS Libraries". Now when I run `npm run real` to minify all of them, it ends up taking about 7 hours to run. This is because the optimizations CSSLOP performs do not scale linearly with more CSS rules, they scale exponentially. There are a
|
|
176
|
+
* More libraries were added to the "Real World CSS Libraries". Now when I run `npm run real` to minify all of them, it ends up taking about 7 hours to run. This is because the optimizations CSSLOP performs do not scale linearly with more CSS rules, they scale exponentially. There are a handful of large CSS files that each take about an hour to complete, where as the smaller files all go by pretty fast.
|
|
170
177
|
* When the upstream `css-minify-tests` adds a new test to their suite, I pull it in and have the AI update the library to pass that new test. For some reason, the AI notices `/tests/realworld.test.js`, sees that `npm run real` will execute that, then sees it has permission to run `npm run *`, so it takes it upon itself to run this SEVEN HOUR LONG COMMAND, without anyone asking it to...
|
|
171
178
|
* This has happened several times. Me saying "Don't run `npm run real`", doesn't actually stop it from doing it. I also tried "Do not, under any circumstance, run `npm run real`.". And it *mostly* stopped doing it... but not completely.
|
|
172
179
|
* This has lead me to update all my prompts to now end with: "DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)!"
|
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.23",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"real": "node ./tests/realworld.test.js",
|
|
17
17
|
"lint": "eslint *.js scripts src tests --fix",
|
|
18
18
|
"fix": "npm run lint",
|
|
19
|
-
"bump": "npx --yes -- @jsdevtools/version-bump-prompt && npm i"
|
|
19
|
+
"bump": "npx --yes -- @jsdevtools/version-bump-prompt patch && npm i"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@csstools/css-calc": "^3.3.0",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@codemirror/autocomplete": "^6.20.3",
|
|
27
27
|
"@codemirror/lang-css": "^6.3.1",
|
|
28
|
-
"@codemirror/view": "^6.43.
|
|
28
|
+
"@codemirror/view": "^6.43.9",
|
|
29
29
|
"@eslint/js": "^10.0.1",
|
|
30
30
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
31
31
|
"codemirror": "^6.0.2",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
35
35
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
36
36
|
"eslint-plugin-import-x": "^4.17.0",
|
|
37
|
-
"eslint-plugin-jsdoc": "^64.1
|
|
37
|
+
"eslint-plugin-jsdoc": "^64.2.1",
|
|
38
38
|
"fflate": "^0.8.3",
|
|
39
39
|
"globals": "^17.11.0",
|
|
40
40
|
"pretty-ms": "^9.3.0",
|
|
41
|
-
"real-world-css-libraries": "^1.0.
|
|
42
|
-
"vite": "^8.2.
|
|
41
|
+
"real-world-css-libraries": "^1.0.8",
|
|
42
|
+
"vite": "^8.2.2"
|
|
43
43
|
},
|
|
44
44
|
"author": "The Jared Wilcurt",
|
|
45
45
|
"homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
|
package/src/charset.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Resolves `@charset` labels against the Encoding Standard so charset rules can be deduplicated, hoisted, shortened, or removed.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The default encoding of a stylesheet, used when no `@charset` is declared.
|
|
7
|
+
*
|
|
8
|
+
* @type {string}
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_ENCODING = 'UTF-8';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encodings that a stylesheet can never actually be in when labelled by a
|
|
14
|
+
* `@charset` rule. The rule itself is written in ASCII bytes, so a UTF-16
|
|
15
|
+
* label falls back to UTF-8 when determining the fallback encoding.
|
|
16
|
+
* https://drafts.csswg.org/css-syntax-3/#determine-the-fallback-encoding
|
|
17
|
+
*
|
|
18
|
+
* @type {Set<string>}
|
|
19
|
+
*/
|
|
20
|
+
const UTF_16_ENCODINGS = new Set(['UTF-16BE', 'UTF-16LE']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every encoding name of the Encoding Standard mapped to all of its labels.
|
|
24
|
+
* A `@charset` label is looked up here (ASCII case-insensitively, after
|
|
25
|
+
* trimming whitespace) to find the encoding a browser will use, and any label
|
|
26
|
+
* of that encoding can be swapped in without changing how the file is decoded.
|
|
27
|
+
* https://encoding.spec.whatwg.org/#names-and-labels
|
|
28
|
+
*
|
|
29
|
+
* @type {object}
|
|
30
|
+
*/
|
|
31
|
+
const ENCODING_LABELS = {
|
|
32
|
+
// The encoding
|
|
33
|
+
'UTF-8': ['unicode-1-1-utf-8', 'unicode11utf8', 'unicode20utf8', 'utf-8', 'utf8', 'x-unicode20utf8'],
|
|
34
|
+
// Legacy single-byte encodings
|
|
35
|
+
IBM866: ['866', 'cp866', 'csibm866', 'ibm866'],
|
|
36
|
+
'ISO-8859-2': ['csisolatin2', 'iso-8859-2', 'iso-ir-101', 'iso8859-2', 'iso88592', 'iso_8859-2', 'iso_8859-2:1987', 'l2', 'latin2'],
|
|
37
|
+
'ISO-8859-3': ['csisolatin3', 'iso-8859-3', 'iso-ir-109', 'iso8859-3', 'iso88593', 'iso_8859-3', 'iso_8859-3:1988', 'l3', 'latin3'],
|
|
38
|
+
'ISO-8859-4': ['csisolatin4', 'iso-8859-4', 'iso-ir-110', 'iso8859-4', 'iso88594', 'iso_8859-4', 'iso_8859-4:1988', 'l4', 'latin4'],
|
|
39
|
+
'ISO-8859-5': ['csisolatincyrillic', 'cyrillic', 'iso-8859-5', 'iso-ir-144', 'iso8859-5', 'iso88595', 'iso_8859-5', 'iso_8859-5:1988'],
|
|
40
|
+
'ISO-8859-6': ['arabic', 'asmo-708', 'csiso88596e', 'csiso88596i', 'csisolatinarabic', 'ecma-114', 'iso-8859-6', 'iso-8859-6-e', 'iso-8859-6-i', 'iso-ir-127', 'iso8859-6', 'iso88596', 'iso_8859-6', 'iso_8859-6:1987'],
|
|
41
|
+
'ISO-8859-7': ['csisolatingreek', 'ecma-118', 'elot_928', 'greek', 'greek8', 'iso-8859-7', 'iso-ir-126', 'iso8859-7', 'iso88597', 'iso_8859-7', 'iso_8859-7:1987', 'sun_eu_greek'],
|
|
42
|
+
'ISO-8859-8': ['csiso88598e', 'csisolatinhebrew', 'hebrew', 'iso-8859-8', 'iso-8859-8-e', 'iso-ir-138', 'iso8859-8', 'iso88598', 'iso_8859-8', 'iso_8859-8:1988', 'visual'],
|
|
43
|
+
'ISO-8859-8-I': ['csiso88598i', 'iso-8859-8-i', 'logical'],
|
|
44
|
+
'ISO-8859-10': ['csisolatin6', 'iso-8859-10', 'iso-ir-157', 'iso8859-10', 'iso885910', 'l6', 'latin6'],
|
|
45
|
+
'ISO-8859-13': ['iso-8859-13', 'iso8859-13', 'iso885913'],
|
|
46
|
+
'ISO-8859-14': ['iso-8859-14', 'iso8859-14', 'iso885914'],
|
|
47
|
+
'ISO-8859-15': ['csisolatin9', 'iso-8859-15', 'iso8859-15', 'iso885915', 'iso_8859-15', 'l9'],
|
|
48
|
+
'ISO-8859-16': ['iso-8859-16'],
|
|
49
|
+
'KOI8-R': ['cskoi8r', 'koi', 'koi8', 'koi8-r', 'koi8_r'],
|
|
50
|
+
'KOI8-U': ['koi8-ru', 'koi8-u'],
|
|
51
|
+
macintosh: ['csmacintosh', 'mac', 'macintosh', 'x-mac-roman'],
|
|
52
|
+
'windows-874': ['dos-874', 'iso-8859-11', 'iso8859-11', 'iso885911', 'tis-620', 'windows-874'],
|
|
53
|
+
'windows-1250': ['cp1250', 'windows-1250', 'x-cp1250'],
|
|
54
|
+
'windows-1251': ['cp1251', 'windows-1251', 'x-cp1251'],
|
|
55
|
+
'windows-1252': ['ansi_x3.4-1968', 'ascii', 'cp1252', 'cp819', 'csisolatin1', 'ibm819', 'iso-8859-1', 'iso-ir-100', 'iso8859-1', 'iso88591', 'iso_8859-1', 'iso_8859-1:1987', 'l1', 'latin1', 'us-ascii', 'windows-1252', 'x-cp1252'],
|
|
56
|
+
'windows-1253': ['cp1253', 'windows-1253', 'x-cp1253'],
|
|
57
|
+
'windows-1254': ['cp1254', 'csisolatin5', 'iso-8859-9', 'iso-ir-148', 'iso8859-9', 'iso88599', 'iso_8859-9', 'iso_8859-9:1989', 'l5', 'latin5', 'windows-1254', 'x-cp1254'],
|
|
58
|
+
'windows-1255': ['cp1255', 'windows-1255', 'x-cp1255'],
|
|
59
|
+
'windows-1256': ['cp1256', 'windows-1256', 'x-cp1256'],
|
|
60
|
+
'windows-1257': ['cp1257', 'windows-1257', 'x-cp1257'],
|
|
61
|
+
'windows-1258': ['cp1258', 'windows-1258', 'x-cp1258'],
|
|
62
|
+
'x-mac-cyrillic': ['x-mac-cyrillic', 'x-mac-ukrainian'],
|
|
63
|
+
// Legacy multi-byte Chinese (simplified) encodings
|
|
64
|
+
GBK: ['chinese', 'csgb2312', 'csiso58gb231280', 'gb2312', 'gb_2312', 'gb_2312-80', 'gbk', 'iso-ir-58', 'x-gbk'],
|
|
65
|
+
gb18030: ['gb18030'],
|
|
66
|
+
// Legacy multi-byte Chinese (traditional) encodings
|
|
67
|
+
Big5: ['big5', 'big5-hkscs', 'cn-big5', 'csbig5', 'x-x-big5'],
|
|
68
|
+
// Legacy multi-byte Japanese encodings
|
|
69
|
+
'EUC-JP': ['cseucpkdfmtjapanese', 'euc-jp', 'x-euc-jp'],
|
|
70
|
+
'ISO-2022-JP': ['csiso2022jp', 'iso-2022-jp'],
|
|
71
|
+
Shift_JIS: ['csshiftjis', 'ms932', 'ms_kanji', 'shift-jis', 'shift_jis', 'sjis', 'windows-31j', 'x-sjis'],
|
|
72
|
+
// Legacy multi-byte Korean encodings
|
|
73
|
+
'EUC-KR': ['cseuckr', 'csksc56011987', 'euc-kr', 'iso-ir-149', 'korean', 'ks_c_5601-1987', 'ks_c_5601-1989', 'ksc5601', 'ksc_5601', 'windows-949'],
|
|
74
|
+
// Legacy miscellaneous encodings
|
|
75
|
+
replacement: ['csiso2022kr', 'hz-gb-2312', 'iso-2022-cn', 'iso-2022-cn-ext', 'iso-2022-kr', 'replacement'],
|
|
76
|
+
'UTF-16BE': ['unicodefffe', 'utf-16be'],
|
|
77
|
+
'UTF-16LE': ['csunicode', 'iso-10646-ucs-2', 'ucs-2', 'unicode', 'unicodefeff', 'utf-16', 'utf-16le'],
|
|
78
|
+
'x-user-defined': ['x-user-defined']
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds the lookup of every Encoding Standard label to the encoding it names.
|
|
83
|
+
*
|
|
84
|
+
* @return {Map} Map of lowercase label to encoding name.
|
|
85
|
+
*/
|
|
86
|
+
function createEncodingByLabelLookup () {
|
|
87
|
+
const encodingByLabel = new Map();
|
|
88
|
+
for (const encodingName in ENCODING_LABELS) {
|
|
89
|
+
for (const label of ENCODING_LABELS[encodingName]) {
|
|
90
|
+
encodingByLabel.set(label, encodingName);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return encodingByLabel;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds the lookup of every encoding to its fewest-bytes label, so a
|
|
98
|
+
* `@charset` rule can be rewritten with a shorter but equivalent label.
|
|
99
|
+
* Labels of the same length are ordered alphabetically to keep output stable.
|
|
100
|
+
*
|
|
101
|
+
* @return {Map} Map of encoding name to its shortest label.
|
|
102
|
+
*/
|
|
103
|
+
function createShortestLabelLookup () {
|
|
104
|
+
const shortestLabels = new Map();
|
|
105
|
+
for (const encodingName in ENCODING_LABELS) {
|
|
106
|
+
const labelsByLength = [...ENCODING_LABELS[encodingName]].sort((labelA, labelB) => {
|
|
107
|
+
if (labelA.length !== labelB.length) {
|
|
108
|
+
return labelA.length - labelB.length;
|
|
109
|
+
}
|
|
110
|
+
return labelA.localeCompare(labelB);
|
|
111
|
+
});
|
|
112
|
+
shortestLabels.set(encodingName, labelsByLength[0]);
|
|
113
|
+
}
|
|
114
|
+
return shortestLabels;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ENCODING_BY_LABEL = createEncodingByLabelLookup();
|
|
118
|
+
const SHORTEST_LABEL_BY_ENCODING = createShortestLabelLookup();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reduces a raw `@charset` value to the label that gets looked up, by removing
|
|
122
|
+
* the surrounding quotes, trimming whitespace, and lowercasing it, since
|
|
123
|
+
* Encoding Standard labels are matched ASCII case-insensitively.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} charsetValue The `@charset` value, with or without surrounding quotes.
|
|
126
|
+
* @return {string} The normalized label, or empty string when there is no value.
|
|
127
|
+
*/
|
|
128
|
+
function normalizeCharsetLabel (charsetValue) {
|
|
129
|
+
if (!charsetValue) {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
// Remove one layer of matching single or double quotes wrapping the label
|
|
133
|
+
const unquoted = String(charsetValue).trim().replace(/^(["'])([\s\S]*)\1$/, '$2');
|
|
134
|
+
return unquoted.trim().toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolves a `@charset` value to the encoding a browser will actually decode
|
|
139
|
+
* the stylesheet with. Labels that are not in the Encoding Standard are
|
|
140
|
+
* ignored, and UTF-16 labels fall back to UTF-8, so both leave the stylesheet
|
|
141
|
+
* in the default encoding.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} charsetValue The `@charset` value, with or without surrounding quotes.
|
|
144
|
+
* @return {string} The effective encoding name.
|
|
145
|
+
*/
|
|
146
|
+
function resolveEffectiveEncoding (charsetValue) {
|
|
147
|
+
const encodingName = ENCODING_BY_LABEL.get(normalizeCharsetLabel(charsetValue));
|
|
148
|
+
if (!encodingName || UTF_16_ENCODINGS.has(encodingName)) {
|
|
149
|
+
return DEFAULT_ENCODING;
|
|
150
|
+
}
|
|
151
|
+
return encodingName;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Determines whether a `@charset` value leaves the stylesheet in an encoding
|
|
156
|
+
* that can represent every unicode character, meaning unicode escapes may be
|
|
157
|
+
* safely replaced with the literal characters they resolve to.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} charsetValue The `@charset` value, with or without surrounding quotes.
|
|
160
|
+
* @return {boolean} True when the effective encoding supports all unicode characters.
|
|
161
|
+
*/
|
|
162
|
+
function isUnicodeCompatibleCharset (charsetValue) {
|
|
163
|
+
return resolveEffectiveEncoding(charsetValue) === DEFAULT_ENCODING;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Rewrites a `@charset` value as the shortest quoted label for the same
|
|
168
|
+
* encoding, or as an empty string when the rule has no effect at all and can
|
|
169
|
+
* be deleted, because the stylesheet is left in the default UTF-8 encoding.
|
|
170
|
+
* The declared label is kept when no shorter label exists for its encoding.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} charsetValue The `@charset` value, with or without surrounding quotes.
|
|
173
|
+
* @return {string} The shortest equivalent quoted value, or empty string when the rule is removable.
|
|
174
|
+
*/
|
|
175
|
+
function optimizeCharsetValue (charsetValue) {
|
|
176
|
+
const encodingName = resolveEffectiveEncoding(charsetValue);
|
|
177
|
+
if (encodingName === DEFAULT_ENCODING) {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
const declaredLabel = normalizeCharsetLabel(charsetValue);
|
|
181
|
+
const shortestLabel = SHORTEST_LABEL_BY_ENCODING.get(encodingName);
|
|
182
|
+
if (shortestLabel.length < declaredLabel.length) {
|
|
183
|
+
return '"' + shortestLabel + '"';
|
|
184
|
+
}
|
|
185
|
+
return '"' + declaredLabel + '"';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Finds the `@charset` value that applies to a stylesheet by scanning the raw
|
|
190
|
+
* CSS text before it is parsed. Only the first `@charset` of a document has any
|
|
191
|
+
* effect, so later ones (usually the result of concatenating files) are ignored.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} css The raw CSS string to scan.
|
|
194
|
+
* @return {string} The first `@charset` value (with quotes), or empty string when none is declared.
|
|
195
|
+
*/
|
|
196
|
+
function detectCharset (css) {
|
|
197
|
+
// Match @charset followed by a quoted value and semicolon
|
|
198
|
+
const match = css.match(/@charset\s+(["'][^"']+["'])\s*;/i);
|
|
199
|
+
if (match) {
|
|
200
|
+
return match[1];
|
|
201
|
+
}
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Applies the browser's `@charset` handling to the top-level rules of a
|
|
207
|
+
* stylesheet. Only the first `@charset` is meaningful, and it is only honored
|
|
208
|
+
* when it is the very first thing in the file, so it is shortened and hoisted
|
|
209
|
+
* to the front while every later `@charset` is dropped. A first `@charset` that
|
|
210
|
+
* leaves the stylesheet in the default UTF-8 encoding is dropped as well.
|
|
211
|
+
*
|
|
212
|
+
* @param {Array} rules The top-level AST rule nodes to filter.
|
|
213
|
+
* @return {Array} A new array of rules, with at most one `@charset` rule, placed at the start.
|
|
214
|
+
*/
|
|
215
|
+
function filterRedundantCharsets (rules) {
|
|
216
|
+
let hoistedCharset = null;
|
|
217
|
+
let foundCharset = false;
|
|
218
|
+
|
|
219
|
+
const filtered = rules.filter((rule) => {
|
|
220
|
+
if (rule.type !== 'charset') {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (!foundCharset) {
|
|
224
|
+
foundCharset = true;
|
|
225
|
+
const optimizedCharset = optimizeCharsetValue(rule.charset);
|
|
226
|
+
if (optimizedCharset) {
|
|
227
|
+
hoistedCharset = {
|
|
228
|
+
...rule,
|
|
229
|
+
charset: optimizedCharset
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (hoistedCharset) {
|
|
237
|
+
return [hoistedCharset, ...filtered];
|
|
238
|
+
}
|
|
239
|
+
return filtered;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export {
|
|
243
|
+
detectCharset,
|
|
244
|
+
filterRedundantCharsets,
|
|
245
|
+
isUnicodeCompatibleCharset
|
|
246
|
+
};
|
package/src/context.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* @file Manages the shared minification context for tracking registered custom properties and their syntax.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { isUnicodeCompatibleCharset } from './charset.js';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Creates a fresh minification context used to track `@property`-registered custom properties and their declared syntax types across the entire stylesheet.
|
|
7
9
|
*
|
|
@@ -22,18 +24,15 @@ function createMinifyContext () {
|
|
|
22
24
|
let activeCharset = '';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Returns true when the active charset
|
|
26
|
-
* (UTF-8, UTF-16
|
|
27
|
-
*
|
|
27
|
+
* Returns true when the active charset leaves the stylesheet in a
|
|
28
|
+
* unicode-compatible encoding (UTF-8, a UTF-16 label that falls back to UTF-8,
|
|
29
|
+
* an unrecognized label, or the default when no `@charset` is declared),
|
|
30
|
+
* meaning CSS unicode escapes can safely be resolved to literal characters.
|
|
28
31
|
*
|
|
29
32
|
* @return {boolean} True if the active charset supports unicode characters.
|
|
30
33
|
*/
|
|
31
34
|
function isUnicodeCharset () {
|
|
32
|
-
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
const normalized = activeCharset.toLowerCase().replace(/["']/g, '');
|
|
36
|
-
return normalized === 'utf-8' || normalized.startsWith('utf-16');
|
|
35
|
+
return isUnicodeCompatibleCharset(activeCharset);
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/**
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Rewrites longhand declarations that share a CSS-wide keyword into a shorthand carrying that keyword, followed by the longhands that override it.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { minifyValue } from '../value/minify.js';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CSS_WIDE_KEYWORDS,
|
|
9
|
+
shorthandMap,
|
|
10
|
+
shorthandOverrideMap
|
|
11
|
+
} from './config.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Expands a property into the set of leaf longhands it ultimately sets, so that
|
|
15
|
+
* different groupings of the same box, such as `border-width` and
|
|
16
|
+
* `border-top-width`, can be compared for equivalent coverage.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} property The property name to expand.
|
|
19
|
+
* @param {Set} leafProperties The set collecting the leaf longhand names.
|
|
20
|
+
* @return {Set} The set of leaf longhand property names.
|
|
21
|
+
*/
|
|
22
|
+
function expandToLeafProperties (property, leafProperties = new Set()) {
|
|
23
|
+
const longhands = shorthandMap[property];
|
|
24
|
+
if (!longhands) {
|
|
25
|
+
leafProperties.add(property);
|
|
26
|
+
return leafProperties;
|
|
27
|
+
}
|
|
28
|
+
for (const longhand of longhands) {
|
|
29
|
+
expandToLeafProperties(longhand, leafProperties);
|
|
30
|
+
}
|
|
31
|
+
return leafProperties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checks whether a group of longhands sets every leaf longhand that the
|
|
36
|
+
* shorthand sets. A CSS-wide keyword may only move into the shorthand when the
|
|
37
|
+
* group covers all of them, otherwise the keyword would also land on a longhand
|
|
38
|
+
* the author never declared.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} shorthandName The target shorthand property name.
|
|
41
|
+
* @param {Array} properties The longhand property names in the group.
|
|
42
|
+
* @return {boolean} Whether the group covers the whole shorthand.
|
|
43
|
+
*/
|
|
44
|
+
function coversEveryLonghandOfShorthand (shorthandName, properties) {
|
|
45
|
+
const coveredLeaves = new Set();
|
|
46
|
+
for (const property of properties) {
|
|
47
|
+
expandToLeafProperties(property, coveredLeaves);
|
|
48
|
+
}
|
|
49
|
+
return [...expandToLeafProperties(shorthandName)].every((leafProperty) => {
|
|
50
|
+
return coveredLeaves.has(leafProperty);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} LonghandEntry
|
|
56
|
+
* @property {object} declaration The original declaration object.
|
|
57
|
+
* @property {number} index The declaration's index within the rule.
|
|
58
|
+
* @property {string} property The longhand property name.
|
|
59
|
+
* @property {string} text The minified `property:value` text.
|
|
60
|
+
* @property {string} value The minified value, without any `!important`.
|
|
61
|
+
* @property {boolean} isImportant Whether the declaration carries `!important`.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collects the declarations of a rule that set one of a shorthand's longhands,
|
|
66
|
+
* in source order.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array} declarations The declarations of a single rule.
|
|
69
|
+
* @param {string} shorthandName The shorthand whose longhands to collect.
|
|
70
|
+
* @return {Array} The matching longhand entries, in source order.
|
|
71
|
+
*/
|
|
72
|
+
function collectLonghandEntries (declarations, shorthandName) {
|
|
73
|
+
const longhands = shorthandMap[shorthandName];
|
|
74
|
+
const entries = [];
|
|
75
|
+
declarations.forEach((declaration, index) => {
|
|
76
|
+
if (!declaration.property || !longhands.includes(declaration.property)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const minifiedValue = minifyValue(declaration);
|
|
80
|
+
const isImportant = minifiedValue.includes('!important');
|
|
81
|
+
entries.push({
|
|
82
|
+
declaration,
|
|
83
|
+
index,
|
|
84
|
+
property: declaration.property,
|
|
85
|
+
text: declaration.property + ':' + minifiedValue,
|
|
86
|
+
value: minifiedValue.replace('!important', '').trim(),
|
|
87
|
+
isImportant
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Checks whether a group declares the same property more than once, which
|
|
95
|
+
* happens when an intentional fallback was kept. Rewriting such a group would
|
|
96
|
+
* drop one of the two declarations, so it is left alone.
|
|
97
|
+
*
|
|
98
|
+
* @param {Array} entries The longhand entries of the group.
|
|
99
|
+
* @return {boolean} Whether any property appears more than once.
|
|
100
|
+
*/
|
|
101
|
+
function hasRepeatedProperty (entries) {
|
|
102
|
+
const seenProperties = new Set();
|
|
103
|
+
return entries.some((entry) => {
|
|
104
|
+
if (seenProperties.has(entry.property)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
seenProperties.add(entry.property);
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves the `!important` suffix the shorthand must carry. A group that mixes
|
|
114
|
+
* important and normal longhands cannot be rewritten, because the shorthand
|
|
115
|
+
* would either lose or gain priority over the longhands it replaces.
|
|
116
|
+
*
|
|
117
|
+
* @param {Array} entries The longhand entries of the group.
|
|
118
|
+
* @return {string|null} The suffix to append, or null when the group cannot be rewritten.
|
|
119
|
+
*/
|
|
120
|
+
function resolveImportantSuffix (entries) {
|
|
121
|
+
const allImportant = entries.every((entry) => {
|
|
122
|
+
return entry.isImportant;
|
|
123
|
+
});
|
|
124
|
+
if (allImportant) {
|
|
125
|
+
return '!important';
|
|
126
|
+
}
|
|
127
|
+
const noneImportant = entries.every((entry) => {
|
|
128
|
+
return !entry.isImportant;
|
|
129
|
+
});
|
|
130
|
+
if (noneImportant) {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolves the single CSS-wide keyword shared by the longhands that use one.
|
|
138
|
+
* A group with no keyword has nothing to hoist, and a group mixing different
|
|
139
|
+
* keywords has no single value the shorthand could carry.
|
|
140
|
+
*
|
|
141
|
+
* @param {Array} entries The longhand entries of the group.
|
|
142
|
+
* @return {string|null} The shared keyword, or null when there is none.
|
|
143
|
+
*/
|
|
144
|
+
function resolveSharedKeyword (entries) {
|
|
145
|
+
const keywords = entries.filter((entry) => {
|
|
146
|
+
return CSS_WIDE_KEYWORDS.has(entry.value.toLowerCase());
|
|
147
|
+
}).map((entry) => {
|
|
148
|
+
return entry.value.toLowerCase();
|
|
149
|
+
});
|
|
150
|
+
if (!keywords.length) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const isSharedByAll = keywords.every((keyword) => {
|
|
154
|
+
return keyword === keywords[0];
|
|
155
|
+
});
|
|
156
|
+
if (!isSharedByAll) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return keywords[0];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Checks whether a property the shorthand also resets, such as `border-image`
|
|
164
|
+
* for `border`, is declared before the point where the shorthand would be
|
|
165
|
+
* inserted. Inserting the shorthand there would discard that declaration.
|
|
166
|
+
*
|
|
167
|
+
* @param {Array} declarations The declarations of a single rule.
|
|
168
|
+
* @param {string} shorthandName The target shorthand property name.
|
|
169
|
+
* @param {number} insertionIndex The index the shorthand would be inserted at.
|
|
170
|
+
* @return {boolean} Whether an earlier declaration would be discarded.
|
|
171
|
+
*/
|
|
172
|
+
function resetsEarlierDeclaration (declarations, shorthandName, insertionIndex) {
|
|
173
|
+
const resetProperties = shorthandOverrideMap[shorthandName] || [];
|
|
174
|
+
if (!resetProperties.length) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return declarations.slice(0, insertionIndex).some((declaration) => {
|
|
178
|
+
return resetProperties.includes(declaration.property);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Rewrites one longhand group into a shorthand holding the shared CSS-wide
|
|
184
|
+
* keyword, followed by the longhands that override it, when that is shorter
|
|
185
|
+
* than the group of longhands it replaces.
|
|
186
|
+
*
|
|
187
|
+
* @param {Array} declarations The declarations of a single rule.
|
|
188
|
+
* @param {string} shorthandName The target shorthand property name.
|
|
189
|
+
* @return {Array|null} The rewritten declarations, or null when the rewrite does not apply.
|
|
190
|
+
*/
|
|
191
|
+
function rewriteGroupAsKeywordShorthand (declarations, shorthandName) {
|
|
192
|
+
const shorthandAlreadyExists = declarations.some((declaration) => {
|
|
193
|
+
return declaration.property === shorthandName;
|
|
194
|
+
});
|
|
195
|
+
if (shorthandAlreadyExists) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const entries = collectLonghandEntries(declarations, shorthandName);
|
|
200
|
+
if (entries.length < 2 || hasRepeatedProperty(entries)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const properties = entries.map((entry) => {
|
|
204
|
+
return entry.property;
|
|
205
|
+
});
|
|
206
|
+
if (!coversEveryLonghandOfShorthand(shorthandName, properties)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const importantSuffix = resolveImportantSuffix(entries);
|
|
211
|
+
const sharedKeyword = resolveSharedKeyword(entries);
|
|
212
|
+
if (importantSuffix === null || !sharedKeyword) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const insertionIndex = entries[0].index;
|
|
217
|
+
if (resetsEarlierDeclaration(declarations, shorthandName, insertionIndex)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The longhands whose value is not the shared keyword have to be restated
|
|
222
|
+
// after the shorthand, because the shorthand also set them to the keyword.
|
|
223
|
+
const overrideEntries = entries.filter((entry) => {
|
|
224
|
+
return entry.value.toLowerCase() !== sharedKeyword;
|
|
225
|
+
});
|
|
226
|
+
const shorthandDeclaration = {
|
|
227
|
+
property: shorthandName,
|
|
228
|
+
value: sharedKeyword + importantSuffix,
|
|
229
|
+
isAssembledShorthand: true
|
|
230
|
+
};
|
|
231
|
+
const rewrittenLength = [shorthandDeclaration.property + ':' + shorthandDeclaration.value, ...overrideEntries.map((entry) => {
|
|
232
|
+
return entry.text;
|
|
233
|
+
})].join(';').length;
|
|
234
|
+
const longhandLength = entries.map((entry) => {
|
|
235
|
+
return entry.text;
|
|
236
|
+
}).join(';').length;
|
|
237
|
+
if (rewrittenLength >= longhandLength) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const groupIndexes = new Set(entries.map((entry) => {
|
|
242
|
+
return entry.index;
|
|
243
|
+
}));
|
|
244
|
+
const result = [];
|
|
245
|
+
declarations.forEach((declaration, index) => {
|
|
246
|
+
if (index === insertionIndex) {
|
|
247
|
+
result.push(shorthandDeclaration);
|
|
248
|
+
for (const entry of overrideEntries) {
|
|
249
|
+
result.push(entry.declaration);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (groupIndexes.has(index)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
result.push(declaration);
|
|
257
|
+
});
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Hoists a CSS-wide keyword such as `inherit` out of a group of longhands into
|
|
263
|
+
* their shorthand. A CSS-wide keyword is only valid as a declaration's whole
|
|
264
|
+
* value, so `border-style:inherit;border-color:inherit;border-width:2px` cannot
|
|
265
|
+
* become `border:2px inherit inherit`. It can however become `border:inherit`
|
|
266
|
+
* followed by `border-width:2px`, which inherits every border property and then
|
|
267
|
+
* overrides the one that differs.
|
|
268
|
+
*
|
|
269
|
+
* @param {Array} declarations The declarations of a single rule.
|
|
270
|
+
* @return {Array} The declarations, with eligible groups rewritten.
|
|
271
|
+
*/
|
|
272
|
+
function hoistCssWideKeywordsIntoShorthands (declarations) {
|
|
273
|
+
let result = declarations;
|
|
274
|
+
// Shorthands are visited in declaration order, so the widest shorthand of a
|
|
275
|
+
// family is rewritten before the narrower shorthands it contains.
|
|
276
|
+
for (const shorthandName of Object.keys(shorthandMap)) {
|
|
277
|
+
const rewritten = rewriteGroupAsKeywordShorthand(result, shorthandName);
|
|
278
|
+
if (rewritten) {
|
|
279
|
+
result = rewritten;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export { hoistCssWideKeywordsIntoShorthands };
|
|
@@ -8,6 +8,7 @@ import { hasInvalidQuotesCount } from '../value/quotes.js';
|
|
|
8
8
|
import { absorbBackgroundLonghandsIntoShorthand } from './background.js';
|
|
9
9
|
import { collapseBorderTrioWithPerEdgeColor } from './border.js';
|
|
10
10
|
import { shorthandMap } from './config.js';
|
|
11
|
+
import { hoistCssWideKeywordsIntoShorthands } from './css-wide-keywords.js';
|
|
11
12
|
import {
|
|
12
13
|
getMergeProps,
|
|
13
14
|
tryMergeToShorthand
|
|
@@ -304,6 +305,7 @@ function processDeclarations (declarations, context) {
|
|
|
304
305
|
result = removeLonghandsOverriddenByShorthands(result);
|
|
305
306
|
result = absorbBackgroundLonghandsIntoShorthand(result);
|
|
306
307
|
result = mergeLonghandsIntoShorthands(result, context);
|
|
308
|
+
result = hoistCssWideKeywordsIntoShorthands(result);
|
|
307
309
|
result = collapseBorderTrioWithPerEdgeColor(result);
|
|
308
310
|
|
|
309
311
|
return orderDeclarations(result);
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { parse } from '@node-projects/css-parser';
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
detectCharset,
|
|
9
|
+
filterRedundantCharsets
|
|
10
|
+
} from './charset.js';
|
|
7
11
|
import {
|
|
8
12
|
clearActiveCharset,
|
|
9
13
|
createMinifyContext,
|
|
@@ -13,7 +17,6 @@ import {
|
|
|
13
17
|
analyzePositionTryRules,
|
|
14
18
|
cleanPositionTryRules,
|
|
15
19
|
collectRuleMetadata,
|
|
16
|
-
filterRedundantCharsets,
|
|
17
20
|
filterUnusedPositionTry
|
|
18
21
|
} from './position-try.js';
|
|
19
22
|
import {
|
|
@@ -34,7 +37,10 @@ import {
|
|
|
34
37
|
removeEmptyRules,
|
|
35
38
|
removeOverriddenMultiSelectorProperties
|
|
36
39
|
} from './rules/optimize.js';
|
|
37
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
removeRedundantLayerStatementSemicolon,
|
|
42
|
+
stringifyRule
|
|
43
|
+
} from './rules/stringify.js';
|
|
38
44
|
import { minifyValue } from './value/minify.js';
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -169,22 +175,6 @@ function mergeAdjacentRulesWithIdenticalBodies (ruleStrings) {
|
|
|
169
175
|
return result;
|
|
170
176
|
}
|
|
171
177
|
|
|
172
|
-
/**
|
|
173
|
-
* Extracts the first `@charset` value from raw CSS text before parsing.
|
|
174
|
-
* Scans for `@charset` followed by a quoted string and semicolon.
|
|
175
|
-
*
|
|
176
|
-
* @param {string} css The raw CSS string to scan.
|
|
177
|
-
* @return {string} The first charset value (with quotes), or empty string if none found.
|
|
178
|
-
*/
|
|
179
|
-
function detectCharset (css) {
|
|
180
|
-
// Match @charset followed by a quoted value and semicolon
|
|
181
|
-
const match = css.match(/@charset\s+(["'][^"']+["'])\s*;/i);
|
|
182
|
-
if (match) {
|
|
183
|
-
return match[1];
|
|
184
|
-
}
|
|
185
|
-
return '';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
178
|
/**
|
|
189
179
|
* Parses, optimizes, and minifies a CSS string by applying rule merging, declaration deduplication, value compression, and dead-code elimination.
|
|
190
180
|
*
|
|
@@ -256,7 +246,7 @@ export const minifyCSS = function (input) {
|
|
|
256
246
|
output.push(stringifyRule(rule, context));
|
|
257
247
|
}
|
|
258
248
|
|
|
259
|
-
const mergedOutput = mergeAdjacentRulesWithIdenticalBodies(output);
|
|
249
|
+
const mergedOutput = removeRedundantLayerStatementSemicolon(mergeAdjacentRulesWithIdenticalBodies(output));
|
|
260
250
|
|
|
261
251
|
clearActiveCharset();
|
|
262
252
|
return restoreEscapeSequences(mergedOutput.join(''));
|
package/src/position-try.js
CHANGED
|
@@ -163,41 +163,9 @@ function filterUnusedPositionTry (rules, positionTryRules, positionTryUsage) {
|
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/**
|
|
167
|
-
* Removes duplicate and redundant UTF-8 `@charset` rules, keeping only the first
|
|
168
|
-
* non-UTF-8 charset declaration and moving it to the top of the document.
|
|
169
|
-
* Per the CSS specification, `@charset` must be the very first thing in a stylesheet.
|
|
170
|
-
*
|
|
171
|
-
* @param {Array} rules The top-level AST rule nodes to filter.
|
|
172
|
-
* @return {Array} A new array of rules with the first non-UTF-8 `@charset` at the start and all others removed.
|
|
173
|
-
*/
|
|
174
|
-
function filterRedundantCharsets (rules) {
|
|
175
|
-
let keptCharset = null;
|
|
176
|
-
|
|
177
|
-
const filtered = rules.filter((rule) => {
|
|
178
|
-
if (rule.type !== 'charset') {
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
if (!keptCharset) {
|
|
182
|
-
// Strip surrounding quotes from the charset value for comparison
|
|
183
|
-
const normalizedCharset = rule.charset?.toLowerCase().replace(/["']/g, '');
|
|
184
|
-
if (normalizedCharset !== 'utf-8') {
|
|
185
|
-
keptCharset = rule;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return false;
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (keptCharset) {
|
|
192
|
-
return [keptCharset, ...filtered];
|
|
193
|
-
}
|
|
194
|
-
return filtered;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
166
|
export {
|
|
198
167
|
analyzePositionTryRules,
|
|
199
168
|
cleanPositionTryRules,
|
|
200
169
|
collectRuleMetadata,
|
|
201
|
-
filterRedundantCharsets,
|
|
202
170
|
filterUnusedPositionTry
|
|
203
171
|
};
|
package/src/rules/normalize.js
CHANGED
|
@@ -48,6 +48,18 @@ function unescapeSelector (selector) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Normalizes a `@layer` cascade layer name list by trimming it and removing the
|
|
53
|
+
* optional whitespace that may surround the commas separating the layer names.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} layerNames The raw layer name list (e.g. "reset, base,\n components").
|
|
56
|
+
* @return {string} The normalized comma-separated layer name list.
|
|
57
|
+
*/
|
|
58
|
+
function normalizeLayerNames (layerNames) {
|
|
59
|
+
// Collapse the optional whitespace surrounding the commas between layer names
|
|
60
|
+
return String(layerNames ?? '').trim().replace(/\s*,\s*/g, ',');
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/**
|
|
52
64
|
* Normalizes a `@media` query string by collapsing whitespace, stripping the default "all and" prefix, and converting min/max-width to range syntax.
|
|
53
65
|
*
|
|
@@ -112,6 +124,7 @@ function canUnwrapSupports (supports) {
|
|
|
112
124
|
|
|
113
125
|
export {
|
|
114
126
|
canUnwrapSupports,
|
|
127
|
+
normalizeLayerNames,
|
|
115
128
|
normalizeMedia,
|
|
116
129
|
normalizeSupports,
|
|
117
130
|
unescapeIdent,
|
package/src/rules/optimize.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { escapeRegexString } from '../utilities.js';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
normalizeLayerNames,
|
|
9
|
+
normalizeMedia
|
|
10
|
+
} from './normalize.js';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
|
|
@@ -786,7 +789,7 @@ function mergeLayerRules (rules, mergeSelectorRules) {
|
|
|
786
789
|
const result = [];
|
|
787
790
|
for (const rule of rules) {
|
|
788
791
|
if (rule.type === 'layer') {
|
|
789
|
-
const layerName = rule.layer
|
|
792
|
+
const layerName = normalizeLayerNames(rule.layer);
|
|
790
793
|
if (rule.rules && rule.rules.length > 0) {
|
|
791
794
|
if (layerName && layerBlockMap.has(layerName)) {
|
|
792
795
|
layerBlockMap.get(layerName).rules.push(...rule.rules);
|
package/src/rules/stringify.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './custom-properties.js';
|
|
12
12
|
import {
|
|
13
13
|
canUnwrapSupports,
|
|
14
|
+
normalizeLayerNames,
|
|
14
15
|
normalizeMedia,
|
|
15
16
|
normalizeSupports,
|
|
16
17
|
unescapeIdent,
|
|
@@ -40,6 +41,34 @@ function stringifyDeclarations (declarations) {
|
|
|
40
41
|
.join(';');
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Matches a complete `@layer` statement, which declares layer names without a
|
|
46
|
+
* block and ends with the semicolon that separates it from the CSS that follows
|
|
47
|
+
* it. Layer names are identifiers, so any block, string, or function character
|
|
48
|
+
* means the string is something other than a lone layer statement.
|
|
49
|
+
*
|
|
50
|
+
* @type {RegExp}
|
|
51
|
+
*/
|
|
52
|
+
const LAYER_STATEMENT_PATTERN = /^@layer [^{}();'"]*;$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Removes the semicolon of a trailing `@layer` statement, since that semicolon
|
|
56
|
+
* only exists to separate the statement from whatever comes after it. When the
|
|
57
|
+
* statement ends a stylesheet or a block, there is nothing left to separate.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array} ruleStrings The stringified rules, in output order.
|
|
60
|
+
* @return {Array} The stringified rules, without the redundant semicolon.
|
|
61
|
+
*/
|
|
62
|
+
function removeRedundantLayerStatementSemicolon (ruleStrings) {
|
|
63
|
+
const lastIndex = ruleStrings.length - 1;
|
|
64
|
+
if (lastIndex < 0 || !LAYER_STATEMENT_PATTERN.test(ruleStrings[lastIndex])) {
|
|
65
|
+
return ruleStrings;
|
|
66
|
+
}
|
|
67
|
+
const result = [...ruleStrings];
|
|
68
|
+
result[lastIndex] = result[lastIndex].slice(0, -1);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
/**
|
|
44
73
|
* Recursively stringifies child rules into a concatenated minified CSS string.
|
|
45
74
|
*
|
|
@@ -48,9 +77,10 @@ function stringifyDeclarations (declarations) {
|
|
|
48
77
|
* @return {string} The concatenated minified CSS for all child rules.
|
|
49
78
|
*/
|
|
50
79
|
function stringifyChildRules (rules, context) {
|
|
51
|
-
|
|
80
|
+
const ruleStrings = (rules || []).map((childRule) => {
|
|
52
81
|
return stringifyRule(childRule, context);
|
|
53
|
-
}).
|
|
82
|
+
}).filter(Boolean);
|
|
83
|
+
return removeRedundantLayerStatementSemicolon(ruleStrings).join('');
|
|
54
84
|
}
|
|
55
85
|
/**
|
|
56
86
|
* Minifies a `@function` prelude (signature) by collapsing whitespace around
|
|
@@ -341,9 +371,7 @@ function stringifyRule (rule, context) {
|
|
|
341
371
|
const renderedDeclarations = mediaDeclarations.map((declaration) => {
|
|
342
372
|
return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
|
|
343
373
|
}).join(';');
|
|
344
|
-
const renderedRules = subRules
|
|
345
|
-
return stringifyRule(childRule, context);
|
|
346
|
-
}).join('');
|
|
374
|
+
const renderedRules = stringifyChildRules(subRules, context);
|
|
347
375
|
const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
|
|
348
376
|
if (!children) {
|
|
349
377
|
return '';
|
|
@@ -474,10 +502,11 @@ function stringifyRule (rule, context) {
|
|
|
474
502
|
}
|
|
475
503
|
|
|
476
504
|
if (rule.type === 'layer') {
|
|
505
|
+
const layerNames = normalizeLayerNames(rule.layer);
|
|
477
506
|
if (rule.rules && rule.rules.length) {
|
|
478
|
-
return '@layer ' +
|
|
507
|
+
return '@layer ' + layerNames + '{' + stringifyChildRules(rule.rules, context) + '}';
|
|
479
508
|
} else {
|
|
480
|
-
return '@layer ' +
|
|
509
|
+
return '@layer ' + layerNames + ';';
|
|
481
510
|
}
|
|
482
511
|
}
|
|
483
512
|
|
|
@@ -635,4 +664,7 @@ function stringifyRule (rule, context) {
|
|
|
635
664
|
return ''; // Ignore unknown for now
|
|
636
665
|
}
|
|
637
666
|
|
|
638
|
-
export {
|
|
667
|
+
export {
|
|
668
|
+
removeRedundantLayerStatementSemicolon,
|
|
669
|
+
stringifyRule
|
|
670
|
+
};
|
package/src/value/minify.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from './relative-colors.js';
|
|
31
31
|
import {
|
|
32
32
|
collapseShorthandParts,
|
|
33
|
+
convertAbsoluteLengthToPx,
|
|
33
34
|
normalizeScaleComponent,
|
|
34
35
|
parseAlphaString,
|
|
35
36
|
parseAngleToDegrees,
|
|
@@ -711,6 +712,49 @@ function convertMillisecondsToSeconds (value) {
|
|
|
711
712
|
});
|
|
712
713
|
}
|
|
713
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Matches an absolute CSS length token: an optionally signed number followed by
|
|
717
|
+
* an absolute length unit. The lookbehind rejects digits that belong to a larger
|
|
718
|
+
* identifier, such as the custom property name in `var(--size-2in)`, where the
|
|
719
|
+
* digits and unit do not form a value of their own.
|
|
720
|
+
*
|
|
721
|
+
* @type {RegExp}
|
|
722
|
+
*/
|
|
723
|
+
const ABSOLUTE_LENGTH_PATTERN = /(?<![\w#.%-])(-?(?:\d+|\d*\.\d+))(pt|pc|in|cm|mm|q)\b/gi;
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The largest difference in pixels that a rounded conversion may introduce and
|
|
727
|
+
* still count as exact, which allows for binary floating point error without
|
|
728
|
+
* allowing a visible change to the rendered length.
|
|
729
|
+
*
|
|
730
|
+
* @type {number}
|
|
731
|
+
*/
|
|
732
|
+
const PIXEL_ROUNDING_TOLERANCE = 1e-6;
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Converts absolute length values (pt, pc, in, cm, mm, Q) to their pixel
|
|
736
|
+
* equivalent when the conversion is exact and the pixel form is no longer than
|
|
737
|
+
* the original. Normalizing to px also improves compression by reducing the
|
|
738
|
+
* number of distinct unit strings in the output.
|
|
739
|
+
*
|
|
740
|
+
* @param {string} value A CSS value segment, outside strings and urls.
|
|
741
|
+
* @return {string} The segment with eligible absolute lengths converted to px.
|
|
742
|
+
*/
|
|
743
|
+
function convertAbsoluteLengthsToPx (value) {
|
|
744
|
+
return value.replace(ABSOLUTE_LENGTH_PATTERN, (token, amount, unit) => {
|
|
745
|
+
const pixels = convertAbsoluteLengthToPx(amount, unit);
|
|
746
|
+
if (pixels === null) {
|
|
747
|
+
return token;
|
|
748
|
+
}
|
|
749
|
+
const converted = roundCompactNumber(pixels) + 'px';
|
|
750
|
+
const isExact = Math.abs(parseFloat(converted) - pixels) < PIXEL_ROUNDING_TOLERANCE;
|
|
751
|
+
if (!isExact || converted.length > token.length) {
|
|
752
|
+
return token;
|
|
753
|
+
}
|
|
754
|
+
return converted;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
714
758
|
/**
|
|
715
759
|
* Applies property-specific optimizations to a CSS value (transition, flex, font,
|
|
716
760
|
* background, display, scale, border-radius, shorthand collapsing, etc.).
|
|
@@ -867,11 +911,11 @@ function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
|
|
|
867
911
|
});
|
|
868
912
|
}
|
|
869
913
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
914
|
+
// Custom properties hold an arbitrary token stream rather than a typed value,
|
|
915
|
+
// so a unit-like token in one is not necessarily a length.
|
|
916
|
+
const isCustomProperty = Boolean(property) && property.startsWith('--');
|
|
917
|
+
if (!isCustomProperty) {
|
|
918
|
+
val = replaceOutsideStringsAndUrls(val, convertAbsoluteLengthsToPx);
|
|
875
919
|
}
|
|
876
920
|
|
|
877
921
|
if (property === 'syntax') {
|