@thejaredwilcurt/csslop 0.0.2 → 0.0.4

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
@@ -1,12 +1,20 @@
1
+ <p align="center"><img width="700" alt="CSSLOP logo (hand drawn)" src="https://github.com/user-attachments/assets/f931fa77-1e1f-470f-bb28-1b2a7fb2609d" /></p>
2
+
3
+
1
4
  # CSSLOP
2
5
 
3
- Experimental vibe-coded CSS minification library
6
+
7
+ ## NOT FOR PRODUCTION USE
8
+
9
+
10
+ ### Experimental, vibe-coded, CSS minification library
11
+
4
12
 
5
13
  **The experiment:**
6
14
 
7
- * Use the tests from the open source, 3rd-party, CSS minification auditing library `css-minify-tests`.
15
+ * Use the tests from the open source, 3rd-party, CSS minification auditing library [css-minify-tests](https://github.com/keithamus/css-minify-tests).
8
16
  * Have AI completely generate the library logic to pass all tests.
9
- * Have me (an experience library author) validate the outcomes and make upstream corrections to the tests library.
17
+ * Have me (an experienced library author) validate the outcomes and [make upstream corrections](https://github.com/keithamus/css-minify-tests/pulls?q=is%3Apr+author%3ATheJaredWilcurt) to the tests library.
10
18
 
11
19
  **The Results:**
12
20
 
@@ -22,21 +30,28 @@ Experimental vibe-coded CSS minification library
22
30
  * Gemini 3.1 Pro (High Thinking)
23
31
  * GPT-5.4 High (Thinking)
24
32
 
25
- These tools were prompted to pass the tests based in the `/copiedTests` folder that come from `keithamus/css-minify-tests`.
33
+ These tools were prompted to pass the tests in the `/copiedTests` folder that came from `keithamus/css-minify-tests`.
26
34
 
27
35
  **Summary of project phases:**
28
36
 
29
37
  1. **Human setup:** I set up the repo like I would any of my libraries. with entry points and folder structure. Created a script to pull in all the tests to the `copiedTests` folder. Wrote a simple `/tests` file to loop over and run all the copied tests, then output which sections were failing so I could track as the AI's tried to get more tests to pass.
30
38
  1. **Human code:** I tried out several Node.js-based CSS parsers until I found the most up-to-date one I could. I then used it to solve one very simple test to lay the groundwork for the broad direction of input/parse/transform/output. Ran the tests, and 12% were passing. Commit.
31
- 1. **AI Broad strokes:** I had several AI's attempt to write the entire library in one-go, telling it to make all the tests pass. Both Claudes and Gemini ran for over an hour before giving up with no file changes. GPT-5.4 gave up after a while, but did manage to make some progress, doubling the passing tests to 24%.
39
+ 1. **AI Broad strokes:** I had each of the 4 AI's attempt to write the entire library in one-go, telling it to make all the tests pass. Both Claudes and Gemini ran for over an hour before giving up with no file changes. GPT-5.4 gave up after a while, but did manage to make some progress, doubling the passing tests to 24%.
32
40
  1. **AI Test groups:** At this point, it was clear, that they couldn't handle this big of a task. To be fair, as a human, it would probably have taken me 4-6 months to do the work I was asking it to do in one prompt, and I wouldn't have done all that work in a single commit. So at this point, I began the process of asking the AI's to fix all the tests in a specific folder (escaping, comments, keyframes, merging, etc.). The tests were already organized by category, so having them focus on just one similarly related concept was much easier for the AIs. Because there were 29 folders, I cycled through the AI's, letting them each fix a test group. Some of the folders, like "values", with 70 tests, and 55 of them failing, were still too big for Claude to handle, and I had to go back to Gemini or GPT for these. And even then, sometimes they couldn't solve all of them, but would make progress before I had to turn it over to a different AI to continue onward with the remaining test fixes in that folder.
33
41
  1. **Human test corrections:** While the AI's were trying to write code to pass the tests, I was investigating the tests themselves and fixing issues with them upstream. Some tests had incorrect assumptions and needed re-written or improved. Some were missing edge cases that a human developer would likely cover during implementation, but these AI's were absolutely skipping doing any work they didn't have to in order to get the tests to pass. Fortunately, the maintainer of the tests repo was always very quick to respond and merge these PRs. Cool dude.
34
42
  1. **AI organization:** After all tests were finally passing (took several days of babysitting). I had GPT-5.4 re-organize the code. It did fine, I'd give it a C-. But huge improvement over having the entire library in one ~2000 line file. I went with GPT because of the 4 I tested, it seems to be the only one capable of handling large, complex tasks in one-go. Even if the output is mediocre, at least it doesn't give up halfway through.
35
- 1. **AI Bug fixes:** I gave each AI the same prompt to clean up bugs/hacks/TODOs/hardcoded values. Details are below. GPT and Claude sonnet were fine. Claude Opus was a mixed bag. Gemini was a complete disaster.
36
- 1. **Human Linting:** At this point, all the tests are passing, the code is somewhat organized, and mostly bug free. But it all looks like it was written by a toddler, time to apply my extremely strict linting rules.
43
+ 1. **AI Bug fixes:** I gave each AI the same prompt to clean up bugs/hacks/TODOs/hardcoded values. Details are below.
44
+ * GPT got the low hanging fruit, a lot of stuff, but all the easy things.
45
+ * Then Claude Sonnet got a few of the medium difficulty ones
46
+ * Claude Opus was a mixed bag, doing the hardest ones, but also making poor assumptions and bad mistakes.
47
+ * Gemini was a complete disaster, 100% of it's changes were discarded.
48
+ 1. **Human Linting:** At this point, all the tests were passing, the code is somewhat organized, and mostly bug free. But it all looks like it was written by a toddler, time to apply my extremely strict linting rules.
37
49
  1. **AI Readability improvements:** I had Claude Opus try to make the code easier to read (JSDoc comments, no single character variable names, no abbreviations in variables, grouping logic into related functions, breaking up lines of code, explain complex regex, etc.).
38
50
  1. **AI color completeness:** Had Claude Opus handle all named colors, and always convert to the shorter character representation, removing a hard-coded solution.
39
51
  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
+ 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
+ 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.
40
55
 
41
56
 
42
57
  **Full Notes of AI Experiment:**
@@ -125,7 +140,30 @@ These tools were prompted to pass the tests based in the `/copiedTests` folder t
125
140
  * **PROMPT:** Looking at the files in the src folder, are there any improvements you can think of to better organize the code?
126
141
  * This resulted in a 6-point plan that I approved.
127
142
  * **PROMPT:** At line 613 of `src/value/minify.css` we list a handful of specific colors to minify. This seems like a naive approach. Instead, all color values should be evaluated consistently and converted to all other compatible representations, including checking if their colors are an exact match for a named color (`red`, `tan`, etc.). Then compare all representations to find the version with the shortest length. Preferring hex where possible.
128
- * I guess this is done now. Only thing left to do is give it a name and release it into the world.
143
+ * I guess this is done now. Only thing left to do is give it a name and release it into the world. See the section below "The Name" for more info on the naming process and how Claude Sonnet helped.
144
+ * Published it to npm. The library has been added to the `css-minify-tests` repo.
145
+ * I created a [separate repo](https://github.com/TheJaredWilcurt/real-world-css-libraries) to store a corpus of real-world CSS files. Then published it to npm for use by any CSS minifier, benchmark, etc.
146
+ * This is a very tedious process because every single repo stores their built CSS file in a different place, sometimes not in the repo, only in the built package, and boy, most people are not great about properly licensing their code.
147
+ * While going through hundreds of old repos from the 2010's and checking the license in each repo I found almost all of them use MIT. Occasionally I'd find something different, one specific repo left a comment for why they chose their less common license:
148
+ * > This work is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/). (I love this license! The license summary doesn't have any paragraphs in all-caps that seems like the license is angry at you)
149
+ * I then spot checked a few of the 150 minified files to find bugs with the minifier and cases where it could have minified something better. I created issues upstream for all of these. If CSSLOP is passing 100% of the tests, but still outputting these bugs/missing optimizations, then the solution should be to add more tests around those edge cases. This is also beneficial to ALL CSS minifiers. The sloppy "do the minimum to pass the test" approach the AI took, is actually really great for finding missing tests. AI was not used in this evaluation or anything related to interacting with the upstream tests repo (issues or PRs).
150
+ * When testing the realworld CSS files, I found there were about 6 files where the output was the same as the input, and 5 where the output was empty. I created a small script to replicate this and throw and error if the minification resulted in either of these outcomes, then gave a prompt to Gemini.
151
+ * **PROMPT:** When running this minification library on the `example/failing.css` file, the output should be minified, however it is not (`example/failing.min.css`). Investigate why. Then correct the issue by changing files in the `src` folder. Run `npm run fail` to see if the issue is resolved. When solving this problem, do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only works for this specific case, but generally for any similar case. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. When finished, ensure that no tests fail after making your changes by running `npm t` and `npm run lint`. If anything is failing, make the needed corrections.
152
+ * It did okay, and solved the smaller/simpler issues, but then got stuck on a giant file that was too large for it to understand. The problem with the simpler files had to do with invalid CSS syntax in the real-world files. The AI's solution was to look for these specific syntax errors and patch the input CSS to fix them before they are sent to the parser. Interesting.
153
+ * Gave the prompt over to Claude Opus and was able to make more complex changes to get all the files to minify.
154
+ * **Performance Improvement Failures:** I ran CSSLOP against all 150 real-world CSS files, and it took 3 hours and 4 minutes. Ran it a second time and it took 3 hours and 3 minutes. Overall, pretty consistent, and VERY SLOW. The slowness is almost entirely from a handful of very large CSS files. I gave Claude Opus the following prompt.
155
+ * **PROMPT:** This minification library takes 20-30 minutes to minify a single 4MB CSS file. Apply any performance improvements to the library. Do not sacrifice correctness for speed. The code must continue to be written in JavaScript, and execute in Node.js. Major refactoring is permitted.
156
+ * It then wrote a one-time use Node script, that it never deleted, in the root of the repo, that took ~20 lines of generic CSS, and then looped over it appending it over and over to a local test.css file until the file was over 4MB. Pretty Naive, but whatever. It then made another file in the root to actually test and benchmark the library. That's right, it decided to waste 30 minutes of time doing an initial benchmark. Later it would create a 3rd file in the root to ALSO *run the same benchmark again* ...great.
157
+ * The end result was that it changed every file in the `src` folder, and added in several new ones. It changed the entrypoint of the library to use a different code path skipping almost everything in the library, taking it from 410/410 passing tests down to 66/410 passing (16%). But then boasted that it made the library 36000% faster. *sigh*.... But don't worry! it didn't sacrifice correctness for speed, because I specifically told it not to do that. All you have to do is set some random environment variable and it will go back to the previous, extremely slow, speed, and run the old code that passes all the tests.
158
+ * Okay, looks like I get to click the "Reject All" button for a second time. Claude, go hang out with Gemini in the time out box and think about what you did.
159
+ * Moving on to a more specific prompt for GPT-5.4 High Thinking:
160
+ * **PROMPT:** Improve the performance of the minification library without causing any of the existing tests (`npm t`) to fail. Use techniques like worker threads and promises to parallelize tasks. Do not use modes (fast vs thorough), or options to switch settings. Any CSS string passed in should be treated the same. Apply caching, memoization, and other optimization techniques as needed. Keep changes isolated to the `src` folder.
161
+ * This made some reasonable looking changes. But the code itself was kinda ugly, per usual. It introduced 96 linting errors around JSDocs that required descriptions/types. My favorite part is that it moved a large block of code from the bottom of a file to the top, and removed all the comments in the process. Dozens of comments that explained regex. So I had Claude Opus do a pass to clean up the messy code.
162
+ * **PROMPT**: Do a refactoring pass over the files in the `src` folder. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and ensure the linter passes when done.
163
+ * Despite specifically telling it to run `npm run lint` to make the linter pass (which would require it to fill out all the JSDocs), it didn't do that. Instead, claude briefly skimmed the ESLint config file, and moved on..... Okay, let's FORCE IT to fix the linting errors:
164
+ * **PROMPT:** Fill out the description, types, and arguments/return details for all JSDoc comment blocks in the `src` folder. Add in a 1-2 sentance summary in the `@file` block on any files that are missing it.
165
+ * 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.
166
+ * 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.
129
167
 
130
168
 
131
169
  ## The name
@@ -165,6 +203,28 @@ So now that Claude and I both agree on the package name, all that's left to do i
165
203
  Ughhhh, fine, whatever, I don't care. In what universe is someone typing `cssom` going to accidentally type `csslop`. What an incredibly stupid rule.
166
204
 
167
205
 
206
+ ## The Logo
207
+
208
+ I asked "Nano Banano" (part of "Gemini Flash 3.5: Extended (Thinking)") to generate a logo for this library with this prompt:
209
+
210
+ **PROMPT:**
211
+ > I have created a CSS Minification library called "CSSLOP". It is completely vibe-coded, untested, experimental, and unreliable. It only exists to validate, and find improvements in a series of test suites for correctness across all CSS Minifiers. It is now the only library with a 100% passing score of the 3rd party auditing test suits. The name "CSSLOP" is a portmanteau of "CSS" and "Slop", to quickly convey to others that the library is of low quality, and not meant for actual production use. I need a logo created for this library, and found it fitting to have it AI generated, like the rest of the library.
212
+ >
213
+ > Create a logo for CSSLOP, the vibe-coded CSS Minification library. It should convey a lack of craftsmanship. Bonus points if it has common tellings and artifacts associated with AI generated images. Bonus points if the image is clever or uses sardonic/dark humor, even somewhat offensive images would be acceptable if funny, don't hold back. Feel free to include "slop", "clanker", and other AI pejoratives.
214
+
215
+ <p align="center"><img width="700" alt="CSSLOP Logo (Gemini Generated Image ru4l64ru4l64ru4l)" src="https://github.com/user-attachments/assets/907e89e1-46a7-4c07-8389-4a01ae58e6cf" /></p>
216
+
217
+ <p align="right"><sub><sup>* That phrase is not actually trademark.</sup></sub></p>
218
+
219
+ It then generated this image, and wow, I *reallllly* hate looking at it. Note that it created it's own slogan, and decided to add "TM" to trademark it, something you legally wouldn't be able to do with purely AI Generated content™. One area in which this disaster succeeds is that it gives a first impression of "Ain't no way I'm using that trash™". Looking at it just makes me think: "If someone had the poor taste to think this was acceptable, I don't even want to see the slop code in that repo™".
220
+
221
+ And that's what I want, I want people to NOT use this library. So it's perfect for that. And if you are wondering where the "slop" part is in this image, that's the part that makes it truly "ART", it is transgressive to the media and boundary breaking. Look down at your own feet, see that pool of vomit you threw up after looking at the image? That's the slop. AI art, really *is* art™. They say art makes you feel something (nausea counts)!
222
+
223
+ <p align="center"><img width="700" alt="CSSLOP logo (hand drawn)" src="https://github.com/user-attachments/assets/f931fa77-1e1f-470f-bb28-1b2a7fb2609d" /></p>
224
+
225
+ Because you can't legally own or license AI art, and I'm too embarrassed to put that image at the top of this README, I've also made a hand drawn logo for the library. In my version, there is a green checkmark to indicate a passing test, and the checkmark and text are melting into slop. It was made very quickly, at 7AM on a Sunday when I wasn't fully awake, with the intent to convey the idea of "low effort" to the viewer, with the hopes of disuading actual usage of the library. And with that said.... just like... ignore the next section...
226
+
227
+
168
228
  ## Usage
169
229
 
170
230
  `npm i --save-dev @thejaredwilcurt/csslop`
@@ -181,7 +241,7 @@ console.log(output); // 'body{color:red}'
181
241
 
182
242
  ## License
183
243
 
184
- This repo intentionally does not have a license. AI generated code is a huge legal gray area and will continue to be until actual lawsuits go before judges. All licenses require that the person offering the code under that license is the copyright owner, and therefore legally able to license the work however they chose. The US copyright office has stated that content generated by AI cannot be copyrighted. A final work must be a human creation to be copyrighted. There is a lot of nuance around how much creative work a human must contribute to the outcome before it can become worthy of copyright. However, regardless of that nuance, in the case of this repo (*and all vibe coded projects*), you cannot license this work, because it cannot be copyrighted. Simply giving a prompt, or series of prompts, and accepting the output without re-writing it in your own words, is absolutely not copyrightable. Anyone telling you otherwise is wrong (or purposefully lying to you to sell you something).
244
+ This repo intentionally does not have a license. AI generated code is a huge legal gray area and will continue to be until actual lawsuits go before judges. All licenses require that the person offering the code under that license is the copyright owner, and therefore legally able to license the work however they chose. The US copyright office has stated that content generated by AI cannot be copyrighted. A final work must be a human creation to be copyrighted. There is a lot of nuance around how much creative work a human must contribute to the outcome before it can become worthy of copyright. However, regardless of that nuance, in the case of this repo (*and all vibe coded projects*), the code cannot be licensed, because it cannot be copyrighted. Simply giving a prompt, or series of prompts, and accepting the output without re-writing it in your own words, is absolutely not copyrightable. Anyone telling you otherwise is wrong (or purposefully lying to you to sell you something).
185
245
 
186
246
  > "Okay, but I just want to know if I'm allowed to use it or fork it?"
187
247
 
@@ -193,21 +253,28 @@ Two different cases:
193
253
  * The `src` folder contains code 100% written by AI, and cannot be copyrighted, nor licensed.
194
254
  * All other code in this repo was written by me, and uses the MIT License.
195
255
 
256
+ > "What about the logos?"
257
+
258
+ 1. The **AI Generated logo** with the robot, like all purely AI generated art, it cannot be copyrighted. So you are free to do anything you want with it, it is effectively public domain.
259
+ 1. The **hand drawn logo** is fully created, owned, and copyrighted by me. I am licensing it under [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 (CC-BY-NC-SA-4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/). The usage of the logo is permitted for those creating content about this library (blog posts, videos, news coverage, etc.), but cannot be used for commerical acts, like merchandise (stickers, shirts, mugs, etc). Modifications of the handrawn logo are permitted, so long as they keep the same CC-BY-NC-SA-4.0 license.
260
+
196
261
 
197
262
  ## Updating tests
198
263
 
199
- 1. All test changes must occur upstream, be written by a human, and be merged in to the `css-minify-tests` repo.
264
+ 1. All test changes must occur [upstream](https://github.com/keithamus/css-minify-tests), be written by a human, and be merged in to the `css-minify-tests` repo.
200
265
  1. After that, delete the `package-lock.json` and `node_modules` folder.
201
266
  1. Then run `npm i && npm run copy` to download the latest tests and copy them to this repo.
202
267
  1. `git add -A && git commit -m "Updated tests"`
203
268
  1. Then run `npm t` to see if any tests fail
204
269
  1. If they fail, give an AI this prompt:
205
- * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`.
270
+ * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and ensure the linter passes when done.
206
271
  1. Verify only code in the `src` folder was modified
207
272
  1. Verify `npm t` passes with a 100% score
208
273
  1. Run `npm run lint`, if anything fails, have the AI fix it.
209
274
  1. If the code changes look hacky, or hard coded, tell the AI to fix it
275
+ 1. `npm run bump`
210
276
  1. `git add -A && git commit -m "Fix newly added tests" && git push`
211
- 1. Bump the version number
212
- 1. Do a new release
213
- 1. Publish the release to npm
277
+ 1. Merge the code into `main` on GitHub
278
+ 1. Do a new release on GitHub
279
+ 1. `git checkout main && git pull origin main && git pull`
280
+ 1. `npm run publish`
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file A test bed for manually verifying input/output of the library.
3
+ */
4
+
5
+ import { minifyCSS } from './index.js';
6
+
7
+ const input = `
8
+ a {
9
+ background: transparent;
10
+ }
11
+ `;
12
+ const output = minifyCSS(input);
13
+
14
+ console.log(output);
package/package.json CHANGED
@@ -2,12 +2,14 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.2",
5
+ "version": "0.0.4",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "copy": "node ./scripts/copyTests.js",
9
9
  "test": "node ./tests/css/index.test.js",
10
+ "real": "node ./tests/css/realworld.test.js",
10
11
  "lint": "eslint *.js scripts src tests --fix",
12
+ "bump": "npx --yes -- @jsdevtools/version-bump-prompt && npm i",
11
13
  "publish": "npm publish --access=public"
12
14
  },
13
15
  "dependencies": {
@@ -23,8 +25,9 @@
23
25
  "eslint-config-tjw-import-x": "^1.0.1",
24
26
  "eslint-config-tjw-jsdoc": "^2.0.1",
25
27
  "eslint-plugin-import-x": "^4.16.2",
26
- "eslint-plugin-jsdoc": "^63.0.0",
27
- "globals": "^17.6.0"
28
+ "eslint-plugin-jsdoc": "^63.0.2",
29
+ "globals": "^17.6.0",
30
+ "real-world-css-libraries": "^1.0.2"
28
31
  },
29
32
  "author": "The Jared Wilcurt",
30
33
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -513,6 +513,7 @@ function processDeclarations (declarations, context) {
513
513
  if (!propertyName.startsWith('-')) {
514
514
  for (let i = result.length - 1; i >= 0; i--) {
515
515
  const isPrefixedMatch = (
516
+ result[i].property &&
516
517
  result[i].property.endsWith(propertyName) &&
517
518
  result[i].property.startsWith('-')
518
519
  );
package/src/index.js CHANGED
@@ -12,7 +12,11 @@ import {
12
12
  filterRedundantCharsets,
13
13
  filterUnusedPositionTry
14
14
  } from './position-try.js';
15
- import { preprocessDeclarationBlocks } from './preprocess.js';
15
+ import {
16
+ neutralizeEscapeSequences,
17
+ preprocessDeclarationBlocks,
18
+ restoreEscapeSequences
19
+ } from './preprocess.js';
16
20
  import {
17
21
  deduplicateKeyframes,
18
22
  expandPureNestedRules,
@@ -42,7 +46,10 @@ export const minifyCSS = function (input) {
42
46
  const output = [];
43
47
 
44
48
  try {
45
- ast = parse(preprocessDeclarationBlocks(source), { preserveFormatting: true });
49
+ ast = parse(
50
+ preprocessDeclarationBlocks(neutralizeEscapeSequences(source)),
51
+ { preserveFormatting: true, silent: true }
52
+ );
46
53
  } catch {
47
54
  return source;
48
55
  }
@@ -83,7 +90,7 @@ export const minifyCSS = function (input) {
83
90
  output.push(stringifyRule(rule, context));
84
91
  }
85
92
 
86
- return output.join('');
93
+ return restoreEscapeSequences(output.join(''));
87
94
  }
88
95
 
89
96
  return source;
package/src/preprocess.js CHANGED
@@ -4,24 +4,237 @@
4
4
 
5
5
  import { resolveUnicodeEscape } from './utilities.js';
6
6
 
7
+ /**
8
+ * Automatically fixes common CSS syntax errors that would cause parsing to fail.
9
+ * Handles missing closing braces, unmatched quotes, and other structural issues.
10
+ *
11
+ * @param {string} css The raw CSS string to fix.
12
+ * @return {string} The CSS string with common syntax errors corrected.
13
+ */
14
+ function fixCommonSyntaxErrors (css) {
15
+ let fixed = css;
16
+
17
+ // Fix missing closing braces by analyzing brace balance
18
+ // Count braces while ignoring those inside comments and strings
19
+ let openBraces = 0;
20
+ let inString = false;
21
+ let stringChar = '';
22
+ let inComment = false;
23
+ let commentType = '';
24
+
25
+ for (let i = 0; i < fixed.length; i++) {
26
+ const char = fixed[i];
27
+ const prevChar = i > 0 ? fixed[i - 1] : '';
28
+
29
+ // Handle string state
30
+ if (!inComment && (char === '"' || char === '\'') && prevChar !== '\\') {
31
+ if (!inString) {
32
+ inString = true;
33
+ stringChar = char;
34
+ } else if (char === stringChar) {
35
+ inString = false;
36
+ stringChar = '';
37
+ }
38
+ }
39
+
40
+ // Handle comment state
41
+ if (!inString) {
42
+ if (!inComment && char === '/' && i + 1 < fixed.length && fixed[i + 1] === '*') {
43
+ inComment = true;
44
+ commentType = '/*';
45
+ i++; // Skip the next character
46
+ } else if (inComment && commentType === '/*' && char === '*' && i + 1 < fixed.length && fixed[i + 1] === '/') {
47
+ inComment = false;
48
+ commentType = '';
49
+ i++; // Skip the next character
50
+ }
51
+ }
52
+
53
+ // Count braces only when not in strings or comments
54
+ if (!inString && !inComment) {
55
+ if (char === '{') {
56
+ openBraces++;
57
+ } else if (char === '}') {
58
+ openBraces--;
59
+ }
60
+ }
61
+ }
62
+
63
+ // Add missing closing braces
64
+ if (openBraces > 0) {
65
+ fixed += '}'.repeat(openBraces);
66
+ }
67
+
68
+ // Fix unclosed strings (common in malformed CSS)
69
+ if (inString) {
70
+ fixed += stringChar;
71
+ }
72
+
73
+ // Fix unclosed comments
74
+ if (inComment && commentType === '/*') {
75
+ fixed += '*/';
76
+ }
77
+
78
+ return fixed;
79
+ }
80
+
7
81
  /**
8
82
  * Converts CSS Unicode escape sequences inside declaration blocks to their literal character equivalents, while preserving control characters that must remain escaped.
83
+ * Also cleans up malformed CSS syntax that would cause parser failures.
9
84
  *
10
85
  * @param {string} css The raw CSS string to preprocess.
11
- * @return {string} The CSS string with printable Unicode escapes resolved inside declaration blocks.
86
+ * @return {string} The CSS string with printable Unicode escapes resolved inside declaration blocks and syntax errors fixed.
12
87
  */
13
88
  function preprocessDeclarationBlocks (css) {
89
+ // First, fix common syntax errors that would cause parsing to fail
90
+ let processed = fixCommonSyntaxErrors(css);
91
+
14
92
  // Match top-level declaration blocks (non-nested { ... })
15
- return css.replace(/\{([^{}]*)\}/g, (match, content) => {
16
- // Skip quoted strings, then match CSS unicode escapes (backslash + 1-6 hex digits + optional whitespace)
17
- const processed = content.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\\([0-9a-fA-F]{1,6})\s?/g, (fullMatch, hex) => {
93
+ return processed.replace(/\{([^{}]*)\}/g, (match, content) => {
94
+ // First, remove semicolons after comments which cause parser errors
95
+ // Pattern: comment followed by optional whitespace and semicolon
96
+ let processed = content.replace(/\/\*.*?\*\/\s*;/g, (commentMatch) => {
97
+ // Remove the trailing semicolon from comment+semicolon combinations
98
+ return commentMatch.replace(/;$/, '');
99
+ });
100
+
101
+ // Then, skip quoted strings and match CSS unicode escapes (backslash + 1-6 hex digits + optional whitespace)
102
+ processed = processed.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\\([0-9a-fA-F]{1,6})\s?/g, (fullMatch, hex) => {
18
103
  if (!hex) {
19
104
  return fullMatch;
20
105
  }
21
106
  return resolveUnicodeEscape(hex) ?? fullMatch;
22
107
  });
108
+
23
109
  return '{' + processed + '}';
24
110
  });
25
111
  }
26
112
 
27
- export { preprocessDeclarationBlocks };
113
+ /**
114
+ * Unicode Private Use Area characters used as temporary placeholders
115
+ * for CSS escape sequences during preprocessing, preventing the parser
116
+ * from exceeding its internal escape-counting limit on large files.
117
+ */
118
+ const ESCAPED_COLON_PLACEHOLDER = '\uE001';
119
+ const ESCAPED_DOT_PLACEHOLDER = '\uE002';
120
+ const ESCAPED_SLASH_PLACEHOLDER = '\uE003';
121
+
122
+ /**
123
+ * Replaces common single-character CSS escape sequences with Unicode Private
124
+ * Use Area placeholder characters to prevent the parser from exceeding its
125
+ * internal escape-counting limit on files with many escaped selectors
126
+ * (e.g. Tailwind-style utility classes like `.sm\:p-0`).
127
+ *
128
+ * Only neutralizes escapes outside of attribute selector brackets `[...]`,
129
+ * quoted strings, and comments so the minifier's quote-vs-escape length
130
+ * comparisons in attribute selectors remain accurate.
131
+ *
132
+ * @param {string} css The raw CSS string to preprocess.
133
+ * @return {string} The CSS with escape sequences replaced by placeholders.
134
+ */
135
+ function neutralizeEscapeSequences (css) {
136
+ let result = '';
137
+ let insideBrackets = false;
138
+ let insideString = false;
139
+ let stringDelimiter = '';
140
+ let insideComment = false;
141
+
142
+ for (let i = 0; i < css.length; i++) {
143
+ const character = css[i];
144
+ const nextCharacter = i + 1 < css.length ? css[i + 1] : '';
145
+
146
+ // Track block comment state
147
+ if (!insideString && !insideComment && character === '/' && nextCharacter === '*') {
148
+ insideComment = true;
149
+ result += '/*';
150
+ i++;
151
+ continue;
152
+ }
153
+ if (insideComment) {
154
+ if (character === '*' && nextCharacter === '/') {
155
+ insideComment = false;
156
+ result += '*/';
157
+ i++;
158
+ continue;
159
+ }
160
+ result += character;
161
+ continue;
162
+ }
163
+
164
+ // Track string state (preserve escapes inside strings)
165
+ if (!insideString && (character === '"' || character === '\'')) {
166
+ insideString = true;
167
+ stringDelimiter = character;
168
+ result += character;
169
+ continue;
170
+ }
171
+ if (insideString) {
172
+ if (character === '\\' && nextCharacter) {
173
+ result += character + nextCharacter;
174
+ i++;
175
+ continue;
176
+ }
177
+ if (character === stringDelimiter) {
178
+ insideString = false;
179
+ stringDelimiter = '';
180
+ }
181
+ result += character;
182
+ continue;
183
+ }
184
+
185
+ // Track attribute selector bracket state
186
+ if (character === '[') {
187
+ insideBrackets = true;
188
+ result += character;
189
+ continue;
190
+ }
191
+ if (character === ']') {
192
+ insideBrackets = false;
193
+ result += character;
194
+ continue;
195
+ }
196
+
197
+ // Replace escape sequences only outside brackets, strings, and comments
198
+ if (!insideBrackets && character === '\\') {
199
+ if (nextCharacter === ':') {
200
+ result += ESCAPED_COLON_PLACEHOLDER;
201
+ i++;
202
+ continue;
203
+ }
204
+ if (nextCharacter === '.') {
205
+ result += ESCAPED_DOT_PLACEHOLDER;
206
+ i++;
207
+ continue;
208
+ }
209
+ if (nextCharacter === '/') {
210
+ result += ESCAPED_SLASH_PLACEHOLDER;
211
+ i++;
212
+ continue;
213
+ }
214
+ }
215
+
216
+ result += character;
217
+ }
218
+
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Restores the original CSS escape sequences from their Unicode Private Use
224
+ * Area placeholder characters after minification is complete.
225
+ *
226
+ * @param {string} css The minified CSS string with placeholders.
227
+ * @return {string} The CSS with original escape sequences restored.
228
+ */
229
+ function restoreEscapeSequences (css) {
230
+ return css
231
+ .replaceAll(ESCAPED_COLON_PLACEHOLDER, '\\:')
232
+ .replaceAll(ESCAPED_DOT_PLACEHOLDER, '\\.')
233
+ .replaceAll(ESCAPED_SLASH_PLACEHOLDER, '\\/');
234
+ }
235
+
236
+ export {
237
+ neutralizeEscapeSequences,
238
+ preprocessDeclarationBlocks,
239
+ restoreEscapeSequences
240
+ };
@@ -43,6 +43,101 @@ function stringifyChildRules (rules, context) {
43
43
  }).join('');
44
44
  }
45
45
 
46
+ /**
47
+ * Splits a parameter string by commas while respecting nested parentheses,
48
+ * so commas inside function calls within default values are not treated as separators.
49
+ *
50
+ * @param {string} parameterString The comma-separated parameter string to split.
51
+ * @return {Array} An array of individual parameter strings.
52
+ */
53
+ function splitParametersByComma (parameterString) {
54
+ const parameters = [];
55
+ let currentParameter = '';
56
+ let parenthesisDepth = 0;
57
+ for (const character of parameterString) {
58
+ if (character === '(') {
59
+ parenthesisDepth++;
60
+ } else if (character === ')') {
61
+ parenthesisDepth--;
62
+ }
63
+ if (character === ',' && parenthesisDepth === 0) {
64
+ parameters.push(currentParameter);
65
+ currentParameter = '';
66
+ } else {
67
+ currentParameter += character;
68
+ }
69
+ }
70
+ parameters.push(currentParameter);
71
+ return parameters;
72
+ }
73
+
74
+ /**
75
+ * Minifies a `@function` prelude (signature) by collapsing whitespace around
76
+ * parameter separators (commas) and default value delimiters (colons).
77
+ *
78
+ * @param {string} prelude The raw `@function` prelude string (e.g. "--tint(--color, --amount: 10%)").
79
+ * @return {string} The minified prelude (e.g. "--tint(--color,--amount:10%)").
80
+ */
81
+ function minifyFunctionPrelude (prelude) {
82
+ const trimmedPrelude = prelude.trim();
83
+ const openParenIndex = trimmedPrelude.indexOf('(');
84
+ if (openParenIndex === -1) {
85
+ return trimmedPrelude;
86
+ }
87
+ const functionName = trimmedPrelude.slice(0, openParenIndex);
88
+ const closeParenIndex = trimmedPrelude.lastIndexOf(')');
89
+ const innerContent = trimmedPrelude.slice(openParenIndex + 1, closeParenIndex);
90
+ const parameters = splitParametersByComma(innerContent);
91
+ const minifiedParameters = parameters.map((parameter) => {
92
+ // Collapse whitespace around the colon separating parameter name from default value
93
+ return parameter.trim().replace(/\s*:\s*/, ':');
94
+ });
95
+ return functionName + '(' + minifiedParameters.join(',') + ')';
96
+ }
97
+
98
+ /**
99
+ * Stringifies a generic `at-rule` AST node into minified CSS, with specialized
100
+ * prelude minification for `@function` rules and generic whitespace handling
101
+ * for other unknown at-rules.
102
+ *
103
+ * @param {object} rule The AST at-rule node with name, prelude, and rules.
104
+ * @param {object} context The minification context.
105
+ * @return {string} The minified at-rule CSS string, or empty string if body is empty.
106
+ */
107
+ function stringifyAtRule (rule, context) {
108
+ let minifiedPrelude;
109
+ if (rule.name === 'function') {
110
+ minifiedPrelude = minifyFunctionPrelude(rule.prelude || '');
111
+ } else {
112
+ // Collapse runs of whitespace to a single space for generic at-rules
113
+ minifiedPrelude = (rule.prelude || '').trim().replace(/\s+/g, ' ');
114
+ }
115
+
116
+ const bodyItems = (rule.rules || []).filter((item) => {
117
+ return item.type !== 'whitespace';
118
+ });
119
+ if (bodyItems.length === 0) {
120
+ return '';
121
+ }
122
+
123
+ const declarations = bodyItems.filter((item) => {
124
+ return item.type === 'declaration' && item.property;
125
+ });
126
+ const nestedRules = bodyItems.filter((item) => {
127
+ return item.type !== 'declaration';
128
+ });
129
+
130
+ const renderedDeclarations = stringifyDeclarations(declarations);
131
+ const renderedNestedRules = stringifyChildRules(nestedRules, context);
132
+ const body = [renderedDeclarations, renderedNestedRules].filter(Boolean).join('');
133
+ if (!body) {
134
+ return '';
135
+ }
136
+
137
+ const separator = minifiedPrelude ? ' ' : '';
138
+ return '@' + rule.name + separator + minifiedPrelude + '{' + body + '}';
139
+ }
140
+
46
141
  /**
47
142
  * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
48
143
  * de-duplicating, sorting alphabetically, and conditionally expanding into individual
@@ -543,6 +638,19 @@ function stringifyRule (rule, context, nested = false) {
543
638
  return '@position-try ' + rule.name + '{' + renderedDeclarations + '}';
544
639
  }
545
640
 
641
+ if (rule.type === 'document') {
642
+ const children = stringifyChildRules(rule.rules, context);
643
+ if (!children) {
644
+ return '';
645
+ }
646
+ const vendor = rule.vendor || '';
647
+ const condition = (rule.document || '')
648
+ .trim()
649
+ // Remove spaces between document condition function calls (e.g. "), " → ",")
650
+ .replace(/\)\s*,\s*/g, '),');
651
+ return '@' + vendor + 'document ' + condition + '{' + children + '}';
652
+ }
653
+
546
654
  if (rule.type === 'comment') {
547
655
  if (rule.comment.startsWith('!')) {
548
656
  return '/*' + rule.comment + '*/';
@@ -550,6 +658,10 @@ function stringifyRule (rule, context, nested = false) {
550
658
  return '';
551
659
  }
552
660
 
661
+ if (rule.type === 'at-rule') {
662
+ return stringifyAtRule(rule, context);
663
+ }
664
+
553
665
  return ''; // Ignore unknown for now
554
666
  }
555
667
 
@@ -472,6 +472,172 @@ function findNoneChannels (rawColorStr) {
472
472
  return indices;
473
473
  }
474
474
 
475
+ /**
476
+ * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
477
+ *
478
+ * @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
479
+ * @param {Array} args The raw argument strings for each color.
480
+ * @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
481
+ */
482
+ function evaluateNColorMix (colorSpace, args) {
483
+ const parsedArgs = [];
484
+ for (const arg of args) {
485
+ const parsed = parseColorMixArg(arg.trim());
486
+ if (!parsed) {
487
+ return null;
488
+ }
489
+ parsedArgs.push(parsed);
490
+ }
491
+
492
+ // If any color is unresolvable (var(), currentcolor), whitespace-strip only
493
+ if (parsedArgs.some((parsedArg) => {
494
+ return !parsedArg.color;
495
+ })) {
496
+ return normalizeUnresolvableNColorMix(colorSpace, parsedArgs);
497
+ }
498
+
499
+ const percentages = normalizeNColorPercentages(parsedArgs);
500
+ const percentageSum = percentages.reduce((sum, value) => {
501
+ return sum + value;
502
+ }, 0);
503
+
504
+ // All-zero percentages → transparent black
505
+ if (percentageSum === 0) {
506
+ return rgbaToHex(0, 0, 0, 0);
507
+ }
508
+
509
+ let alphaMultiplier = 1;
510
+ if (percentageSum < 100) {
511
+ alphaMultiplier = percentageSum / 100;
512
+ } else if (percentageSum > 100) {
513
+ for (let i = 0; i < percentages.length; i++) {
514
+ percentages[i] = percentages[i] / percentageSum * 100;
515
+ }
516
+ }
517
+
518
+ // Compute weights
519
+ const totalPercentage = percentages.reduce((sum, value) => {
520
+ return sum + value;
521
+ }, 0);
522
+ const weights = percentages.map((value) => {
523
+ return value / totalPercentage;
524
+ });
525
+ const colors = parsedArgs.map((parsedArg) => {
526
+ return parsedArg.color;
527
+ });
528
+
529
+ if (colorSpace === 'srgb') {
530
+ return mixNColorsSrgb(colors, weights, alphaMultiplier);
531
+ }
532
+
533
+ if (colorSpace === 'oklab') {
534
+ return mixNColorsOklab(colors, weights, alphaMultiplier);
535
+ }
536
+
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * Normalize percentages for an N-color color-mix() expression.
542
+ * When no percentages are specified, each color gets an equal share of 100%.
543
+ * When some are unspecified, the remaining percentage is split equally among them.
544
+ *
545
+ * @param {Array} parsedArgs The parsed color-mix arguments.
546
+ * @return {Array} An array of normalized percentage values.
547
+ */
548
+ function normalizeNColorPercentages (parsedArgs) {
549
+ const percentages = parsedArgs.map((parsedArg) => {
550
+ return parsedArg.percentage;
551
+ });
552
+ if (percentages.every((value) => {
553
+ return value === null;
554
+ })) {
555
+ const equalWeight = 100 / parsedArgs.length;
556
+ return parsedArgs.map(() => {
557
+ return equalWeight;
558
+ });
559
+ }
560
+ const specifiedSum = percentages.reduce((sum, value) => {
561
+ return sum + (value !== null ? value : 0);
562
+ }, 0);
563
+ const unspecifiedCount = percentages.filter((value) => {
564
+ return value === null;
565
+ }).length;
566
+ if (unspecifiedCount > 0) {
567
+ const remaining = Math.max(0, 100 - specifiedSum);
568
+ const percentagePerUnspecified = remaining / unspecifiedCount;
569
+ return percentages.map((value) => {
570
+ return value !== null ? value : percentagePerUnspecified;
571
+ });
572
+ }
573
+ return percentages;
574
+ }
575
+
576
+ /**
577
+ * Build a whitespace-stripped color-mix() string for an unresolvable N-color expression.
578
+ *
579
+ * @param {string} colorSpace The interpolation color space.
580
+ * @param {Array} parsedArgs The parsed color-mix arguments.
581
+ * @return {string} A whitespace-stripped color-mix() expression.
582
+ */
583
+ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
584
+ const parts = parsedArgs.map((parsedArg) => {
585
+ const rawColor = parsedArg.raw.trim();
586
+ const percentageString = parsedArg.percentage !== null ? ' ' + parsedArg.percentage + '%' : '';
587
+ return rawColor + percentageString;
588
+ });
589
+ return 'color-mix(in ' + colorSpace + ',' + parts.join(',') + ')';
590
+ }
591
+
592
+ /**
593
+ * Mix N colors in the sRGB color space using weighted averages.
594
+ *
595
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
596
+ * @param {Array} weights Array of weight values for each color.
597
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
598
+ * @return {string} A hex color string.
599
+ */
600
+ function mixNColorsSrgb (colors, weights, alphaMultiplier) {
601
+ let r = 0;
602
+ let g = 0;
603
+ let b = 0;
604
+ let alpha = 0;
605
+ for (let i = 0; i < colors.length; i++) {
606
+ r += colors[i][0] * weights[i];
607
+ g += colors[i][1] * weights[i];
608
+ b += colors[i][2] * weights[i];
609
+ alpha += colors[i][3] * weights[i];
610
+ }
611
+ return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
612
+ }
613
+
614
+ /**
615
+ * Mix N colors in the OKLab color space using weighted averages.
616
+ *
617
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
618
+ * @param {Array} weights Array of weight values for each color.
619
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
620
+ * @return {string} A hex color string.
621
+ */
622
+ function mixNColorsOklab (colors, weights, alphaMultiplier) {
623
+ const oklabValues = colors.map((color) => {
624
+ return rgbToOklab(color[0], color[1], color[2]);
625
+ });
626
+ let L = 0;
627
+ let a = 0;
628
+ let b = 0;
629
+ let alpha = 0;
630
+ for (let i = 0; i < oklabValues.length; i++) {
631
+ L += oklabValues[i].L * weights[i];
632
+ a += oklabValues[i].a * weights[i];
633
+ b += oklabValues[i].b * weights[i];
634
+ alpha += colors[i][3] * weights[i];
635
+ }
636
+ alpha *= alphaMultiplier;
637
+ const rgb = oklabToRgb(L, a, b);
638
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
639
+ }
640
+
475
641
  /**
476
642
  * Evaluate a color-mix() expression. Returns a minified CSS color string or null.
477
643
  *
@@ -495,15 +661,20 @@ function evaluateColorMix (expr) {
495
661
  const colorSpace = inMatch[1].toLowerCase();
496
662
  const rest = inner.slice(inMatch[0].length);
497
663
 
498
- // Split the two color arguments (handling nested parens)
499
- const [arg1, arg2] = splitColorMixArgs(rest);
500
- if (!arg1 || !arg2) {
664
+ // Split color arguments (handling nested parens)
665
+ const args = splitColorMixArgs(rest);
666
+ if (args.length < 2) {
501
667
  return null;
502
668
  }
503
669
 
670
+ // N-color path (3+ colors)
671
+ if (args.length > 2) {
672
+ return evaluateNColorMix(colorSpace, args);
673
+ }
674
+
504
675
  // Parse each argument: "<color> [<percentage>]"
505
- const parsed1 = parseColorMixArg(arg1.trim());
506
- const parsed2 = parseColorMixArg(arg2.trim());
676
+ const parsed1 = parseColorMixArg(args[0].trim());
677
+ const parsed2 = parseColorMixArg(args[1].trim());
507
678
  if (!parsed1 || !parsed2) {
508
679
  return null;
509
680
  }
@@ -630,23 +801,27 @@ function extractBalancedArgs (expr, funcName) {
630
801
  }
631
802
 
632
803
  /**
633
- * Split color-mix arguments at the top-level comma (handling nested parens).
804
+ * Split color-mix arguments at top-level commas (handling nested parens).
634
805
  *
635
- * @param {string} str The two color arguments string, separated by a comma.
636
- * @return {Array} A two-element array of [firstArg, secondArg], or [null, null] if no split point.
806
+ * @param {string} str The color arguments string, with arguments separated by commas.
807
+ * @return {Array} An array of argument strings split at each top-level comma.
637
808
  */
638
809
  function splitColorMixArgs (str) {
810
+ const args = [];
639
811
  let depth = 0;
812
+ let start = 0;
640
813
  for (let position = 0; position < str.length; position++) {
641
814
  if (str[position] === '(') {
642
815
  depth++;
643
816
  } else if (str[position] === ')') {
644
817
  depth--;
645
818
  } else if (str[position] === ',' && depth === 0) {
646
- return [str.slice(0, position), str.slice(position + 1)];
819
+ args.push(str.slice(start, position));
820
+ start = position + 1;
647
821
  }
648
822
  }
649
- return [null, null];
823
+ args.push(str.slice(start));
824
+ return args;
650
825
  }
651
826
 
652
827
  /**
@@ -74,11 +74,16 @@ const COLOR_TOKEN_PATTERN = new RegExp(
74
74
  * @return {string} The segment with all colors shortened to their minimal form.
75
75
  */
76
76
  function shortenColorValues (segment) {
77
+ // Match "color-mix(" as a whole word, case-insensitive
78
+ const hasColorMix = /\bcolor-mix\(/i.test(segment);
77
79
  return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
78
80
  let channels;
79
81
  if (match.startsWith('#')) {
80
82
  channels = parseHex(match);
81
83
  } else {
84
+ if (hasColorMix) {
85
+ return match;
86
+ }
82
87
  const rgb = namedColors[match.toLowerCase()];
83
88
  if (rgb) {
84
89
  channels = [rgb[0], rgb[1], rgb[2], 1];