@thejaredwilcurt/csslop 0.0.21 → 0.0.22
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 +1 -1
- package/src/declarations/css-wide-keywords.js +285 -0
- package/src/declarations/process.js +2 -0
- package/src/index.js +5 -2
- 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
|
@@ -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
|
@@ -34,7 +34,10 @@ import {
|
|
|
34
34
|
removeEmptyRules,
|
|
35
35
|
removeOverriddenMultiSelectorProperties
|
|
36
36
|
} from './rules/optimize.js';
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
removeRedundantLayerStatementSemicolon,
|
|
39
|
+
stringifyRule
|
|
40
|
+
} from './rules/stringify.js';
|
|
38
41
|
import { minifyValue } from './value/minify.js';
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -256,7 +259,7 @@ export const minifyCSS = function (input) {
|
|
|
256
259
|
output.push(stringifyRule(rule, context));
|
|
257
260
|
}
|
|
258
261
|
|
|
259
|
-
const mergedOutput = mergeAdjacentRulesWithIdenticalBodies(output);
|
|
262
|
+
const mergedOutput = removeRedundantLayerStatementSemicolon(mergeAdjacentRulesWithIdenticalBodies(output));
|
|
260
263
|
|
|
261
264
|
clearActiveCharset();
|
|
262
265
|
return restoreEscapeSequences(mergedOutput.join(''));
|
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') {
|