@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 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 almost certainly not worth using. Though if you do, and you find issues, you can isolate the problem and [compare against the correct output of real minifiers](https://TheJaredWilcurt.com/playground), 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.
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 bench marked it and it was somehow even slower (+20 min), and also the outputs weren't as small as before (+0.05%). Details below.
55
- 1. **Playground:** Created an [online playground](https://TheJaredWilcurt.com/csslop) (no AI used) for others to more easily test things out and find issues that can be fixed upstream.
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 handfull of large CSS files that each take about an hour to complete, where as the smaller files all go by pretty fast.
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.21",
5
+ "version": "0.0.22",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -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 { stringifyRule } from './rules/stringify.js';
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(''));
@@ -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,
@@ -4,7 +4,10 @@
4
4
 
5
5
  import { escapeRegexString } from '../utilities.js';
6
6
 
7
- import { normalizeMedia } from './normalize.js';
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);
@@ -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
- return (rules || []).map((childRule) => {
80
+ const ruleStrings = (rules || []).map((childRule) => {
52
81
  return stringifyRule(childRule, context);
53
- }).join('');
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.map((childRule) => {
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 ' + (rule.layer || '') + '{' + stringifyChildRules(rule.rules, context) + '}';
507
+ return '@layer ' + layerNames + '{' + stringifyChildRules(rule.rules, context) + '}';
479
508
  } else {
480
- return '@layer ' + rule.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 { stringifyRule };
667
+ export {
668
+ removeRedundantLayerStatementSemicolon,
669
+ stringifyRule
670
+ };
@@ -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
- if (property === 'font-size') {
871
- // Convert point (pt) font-size values to their pixel (px) equivalent
872
- val = val.replace(/^(-?(?:\d+|\d*\.\d+))pt$/i, (match, amount) => {
873
- return roundCompactNumber(parseFloat(amount) * (96 / 72)) + 'px';
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') {