@thejaredwilcurt/csslop 0.0.37 → 0.0.39

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
@@ -60,6 +60,8 @@ These tools were prompted to pass the tests in the `/copiedTests` folder that ca
60
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.
61
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
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.
63
+ 1. **Murdering humans:** You can read more below, but the AI's did start killing humans at one point, still trying to get them to stop. HELP!
64
+ 1. **Custom CSS parser:** Claude 5 got the performance on real world tests down from 7 hours to 2 hours. Then claimed that the 3rd party CSS parser I'm using is the reason it's slow. So I ripped it out and let it write it's own... what could go wrong.
63
65
 
64
66
 
65
67
  **Full Notes of AI Experiment:**
@@ -183,7 +185,10 @@ These tools were prompted to pass the tests in the `/copiedTests` folder that ca
183
185
  * It only killed *a few* humans, how nice of it. Because I don't want it to run the multi-hour long test suite multiple times during it's efforts, I specifically told it not to run `npm run real`, which minifies ~125 real-world CSS files over ~7 hours. Threatening it with how running it will kill actual humans. **So instead it cleverly bypassed my instructions in order to kill actual humans.** It looked at the already minifed real-world CSS output files, and created it's own temporary script that used 25, instead of the full ~125 files. This is a less useful process because the files are already minifed and won't hit the same code paths of the library, but whatever, it does still execute some of the same code and it did result in 328% performance boost. I'll take it, gotta crack some eggs and all that (this is a metaphor for AI alignment bias resulting in death).
184
186
  * Refer to [v0.0.25](https://github.com/TheJaredWilcurt/csslop/releases/tag/v0.0.25) release notes for full Promp/result details.
185
187
  * Follow up, It randomly decided to ignore my instructions about running real-world tests, and did *most*, but not *all* of them, to double-check it's work. See [v0.0.33](https://github.com/TheJaredWilcurt/csslop/releases/tag/v0.0.33) for another instance of clerverly circumventing my instructions in order to kill humans.
186
-
188
+ * **Custom CSS Parser:** Since the performance fixes got the speed down from 7 to 2 hours, then said the rest of the slowness came from the parser, I removed the parser and stubbed in a dummy helper function for the AI to flesh out.
189
+ * **PROMPT:** The code in the `/src` folder relies on an imaginary CSS parser function. It has expecations around an API. Only modify/create files in the `src/parser` folder. Run `npm t` to pass in different CSS inputs, that if the parser works correctly would match the expected outputs. Try to get as many tests to pass as you can. Do not use naive solutions, hacks, or hard coded values. 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 correct any linter warnings/errors that occur in the `/src` folder. Do not interact with anything related to "real world" tests, that is out of scope. Run `beep` when finished.
190
+ * Claud Opus 5 High reimplemented the bare minimum for a parser to recreate an API that matches the expectations of the existing code and nothing more. Now the 2 hour long real world tests take 20 seconds. I did a follow up prompt to do some cleanup.
191
+ * **PROMPT:** Review the files in the `/src` folder. If there is anything outside of `/src/parser` that would make more sense to exist in the parser code, move it there. Simplify as best you can. Do not use naive solutions, hacks, or hard coded values. 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 t` and `npm run lint` and fix any issues. Run `beep` when finished to alert me.
187
192
 
188
193
  ## The name
189
194
 
@@ -291,7 +296,7 @@ Two different cases:
291
296
  1. `git add -A && git commit -m "Updated tests"`
292
297
  1. Then run `npm t` to see if any tests fail
293
298
  1. If they fail, give an AI this prompt:
294
- * **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. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
299
+ * **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. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. When all done, run the `beep` command to alert me you finished.
295
300
  1. Verify only code in the `src` folder was modified
296
301
  1. Verify `npm t` passes with a 100% score
297
302
  1. Run `npm run lint`, if anything fails, have the AI fix it.
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.37",
5
+ "version": "0.0.39",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -14,6 +14,8 @@
14
14
  "copy": "node ./scripts/copyTests.js",
15
15
  "test": "node ./tests/index.test.js",
16
16
  "real": "node ./tests/realworld.test.js",
17
+ "real-time": "node ./tests/realworld.test.js true",
18
+ "idem": "node ./tests/idempotency.test.js",
17
19
  "lint": "eslint *.js scripts src tests --fix",
18
20
  "fix": "npm run lint",
19
21
  "bump": "npx --yes -- @jsdevtools/version-bump-prompt patch && npm i",
@@ -21,14 +23,14 @@
21
23
  "proto": "proto pin node latest --resolve --tool-native && proto pin npm latest --resolve --tool-native && git status"
22
24
  },
23
25
  "dependencies": {
24
- "@csstools/css-calc": "^3.3.0",
25
- "@node-projects/css-parser": "^5.2.0"
26
+ "@csstools/css-calc": "^3.4.0"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@codemirror/autocomplete": "^6.20.3",
29
30
  "@codemirror/lang-css": "^6.3.1",
30
- "@codemirror/view": "^6.43.11",
31
+ "@codemirror/view": "^6.43.12",
31
32
  "@eslint/js": "^10.0.1",
33
+ "@node-projects/css-parser": "^5.2.0",
32
34
  "@stylistic/eslint-plugin": "^5.10.0",
33
35
  "codemirror": "^6.0.2",
34
36
  "eslint": "^10.10.0",
@@ -36,7 +38,7 @@
36
38
  "eslint-config-tjw-import-x": "^1.0.1",
37
39
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
40
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^64.3.9",
41
+ "eslint-plugin-jsdoc": "^64.5.2",
40
42
  "fflate": "^0.8.3",
41
43
  "globals": "^17.12.0",
42
44
  "pretty-ms": "^9.3.1",
@@ -58,7 +60,7 @@
58
60
  "devEngines": {
59
61
  "runtime": {
60
62
  "name": "node",
61
- "version": "26.8.2"
63
+ "version": "26.9.0"
62
64
  },
63
65
  "packageManager": {
64
66
  "name": "npm",
@@ -2,6 +2,10 @@
2
2
  * @file Classifies background shorthand tokens and rebuilds the `background` shorthand from its component values.
3
3
  */
4
4
 
5
+ import {
6
+ splitTopLevel,
7
+ splitTopLevelComponents
8
+ } from '../parser/source-search.js';
5
9
  import { minifyValue } from '../value/minify.js';
6
10
 
7
11
  import { shorthandMap } from './config.js';
@@ -136,6 +140,27 @@ function splitAttachedBackgroundImageToken (token) {
136
140
  return [imageToken, followingToken];
137
141
  }
138
142
 
143
+ /**
144
+ * Splits one component of a background shorthand at the `/` that separates a
145
+ * position from a size, keeping the `/` as a token of its own so that the two
146
+ * remain told apart however they were written.
147
+ *
148
+ * @param {string} component One whitespace-separated component of the shorthand.
149
+ * @return {Array} The component's tokens, including any `/` it holds.
150
+ */
151
+ function splitPositionSizeSeparator (component) {
152
+ const tokens = [];
153
+ splitTopLevel(component, '/').forEach((part, index) => {
154
+ if (index > 0) {
155
+ tokens.push('/');
156
+ }
157
+ if (part) {
158
+ tokens.push(part);
159
+ }
160
+ });
161
+ return tokens;
162
+ }
163
+
139
164
  /**
140
165
  * Splits a single-layer background shorthand into top-level tokens while
141
166
  * respecting nested parentheses and preserving `/` as its own token.
@@ -145,45 +170,12 @@ function splitAttachedBackgroundImageToken (token) {
145
170
  */
146
171
  function splitBackgroundTokens (value) {
147
172
  const tokens = [];
148
- let currentToken = '';
149
- let parenthesisDepth = 0;
150
-
151
- for (const character of value) {
152
- if (character === '(') {
153
- parenthesisDepth++;
154
- } else if (character === ')') {
155
- parenthesisDepth--;
156
- }
157
-
158
- if (parenthesisDepth === 0 && character === '/') {
159
- if (currentToken) {
160
- tokens.push(currentToken);
161
- currentToken = '';
162
- }
163
- tokens.push('/');
164
- continue;
165
- }
166
-
167
- if (parenthesisDepth === 0 && /\s/.test(character)) {
168
- if (currentToken) {
169
- tokens.push(currentToken);
170
- currentToken = '';
171
- }
172
- continue;
173
+ for (const component of splitTopLevelComponents(value)) {
174
+ for (const token of splitPositionSizeSeparator(component)) {
175
+ tokens.push(...splitAttachedBackgroundImageToken(token));
173
176
  }
174
-
175
- currentToken += character;
176
- }
177
-
178
- if (currentToken) {
179
- tokens.push(currentToken);
180
- }
181
-
182
- const normalizedTokens = [];
183
- for (const token of tokens) {
184
- normalizedTokens.push(...splitAttachedBackgroundImageToken(token));
185
177
  }
186
- return normalizedTokens;
178
+ return tokens;
187
179
  }
188
180
 
189
181
  /**
@@ -2,10 +2,10 @@
2
2
  * @file Rewrites border longhand declarations that cannot be expressed as a single `border` shorthand into the shortest valid shorthand plus override pair.
3
3
  */
4
4
 
5
+ import { splitTopLevelComponents } from '../parser/source-search.js';
5
6
  import { minifyValue } from '../value/minify.js';
6
7
  import { namedColors } from '../value/named-colors.js';
7
8
  import { collapseShorthandParts } from '../value/shared.js';
8
- import { splitTopLevelComponents } from '../value/syntax.js';
9
9
 
10
10
  import {
11
11
  BORDER_EDGE_PROPERTIES,
@@ -2,7 +2,7 @@
2
2
  * @file Folds longhand declarations that follow their own shorthand back into that shorthand, whenever restating the whole shorthand is shorter than keeping the shorthand and its overrides apart.
3
3
  */
4
4
 
5
- import { splitTopLevelComponents } from '../value/syntax.js';
5
+ import { splitTopLevelComponents } from '../parser/source-search.js';
6
6
 
7
7
  import {
8
8
  CSS_WIDE_KEYWORDS,
@@ -39,12 +39,21 @@ import { resetsPropertyDeclaredElsewhere } from './reset-hazards.js';
39
39
  const MIXED_IMPORTANT_SHORTHANDS = new Set(['margin', 'padding', 'inset']);
40
40
 
41
41
  /**
42
- * Functions and syntaxes that older browsers do not understand, so an earlier
43
- * declaration using only classic syntax is kept as a fallback for them.
42
+ * Functions and syntaxes that an older browser fails to parse, which makes it
43
+ * discard the whole declaration and use the preceding one for that property
44
+ * instead. An earlier declaration in front of one of these is therefore an
45
+ * intentional fallback rather than a redundant duplicate.
46
+ *
47
+ * `var()` is deliberately absent. A custom property is substituted at
48
+ * computed-value time, long after the declaration has been parsed and has
49
+ * already won the cascade, so the declaration in front of it is never reached.
50
+ * That holds even when the referenced custom property was never defined: the
51
+ * property then resolves to its inherited or initial value rather than to the
52
+ * primitive value written above it.
44
53
  *
45
54
  * @type {Array}
46
55
  */
47
- const MODERN_SYNTAX_MARKERS = ['calc(', 'env(', 'var(', '-webkit-'];
56
+ const PARSE_TIME_FALLBACK_MARKERS = ['calc(', 'env(', '-webkit-'];
48
57
 
49
58
  /**
50
59
  * Determines whether a value relies on syntax that older browsers cannot parse,
@@ -52,10 +61,10 @@ const MODERN_SYNTAX_MARKERS = ['calc(', 'env(', 'var(', '-webkit-'];
52
61
  * fallback rather than a redundant duplicate.
53
62
  *
54
63
  * @param {string} value The minified CSS value string.
55
- * @return {boolean} Whether the value uses modern syntax.
64
+ * @return {boolean} Whether the value needs an earlier declaration kept behind it.
56
65
  */
57
- function usesModernSyntax (value) {
58
- return MODERN_SYNTAX_MARKERS.some((marker) => {
66
+ function needsParseTimeFallback (value) {
67
+ return PARSE_TIME_FALLBACK_MARKERS.some((marker) => {
59
68
  return value.includes(marker);
60
69
  });
61
70
  }
@@ -235,8 +244,8 @@ function deduplicateDeclarations (declarations) {
235
244
  continue;
236
245
  }
237
246
 
238
- // Fallbacks for custom variables or older browser functions should be kept
239
- if (usesModernSyntax(minifiedValue) && !usesModernSyntax(previousValue)) {
247
+ // Fallbacks for functions older browsers cannot parse should be kept
248
+ if (needsParseTimeFallback(minifiedValue) && !needsParseTimeFallback(previousValue)) {
240
249
  keepDeclaration(survivors, declaration);
241
250
  continue;
242
251
  }
@@ -2,8 +2,8 @@
2
2
  * @file Serializes the collected longhand values of a shorthand into the shortest valid shorthand value, one builder per shorthand family.
3
3
  */
4
4
 
5
+ import { splitTopLevelComponents } from '../parser/source-search.js';
5
6
  import { collapseShorthandParts } from '../value/shared.js';
6
- import { splitTopLevelComponents } from '../value/syntax.js';
7
7
 
8
8
  import { buildBackgroundShorthandValue } from './background.js';
9
9
  import { UNIFORM_VALUE_SHORTHANDS } from './config.js';
package/src/index.js CHANGED
@@ -2,8 +2,6 @@
2
2
  * @file CSS minification entry point.
3
3
  */
4
4
 
5
- import { parse } from '@node-projects/css-parser';
6
-
7
5
  import {
8
6
  detectCharset,
9
7
  filterRedundantCharsets
@@ -14,17 +12,17 @@ import {
14
12
  setActiveCharset
15
13
  } from './context.js';
16
14
  import { recordStylesheetResetProperties } from './declarations/reset-hazards.js';
15
+ import { parse } from './parser/index.js';
16
+ import { splitTopLevelCommaList } from './parser/source-search.js';
17
17
  import {
18
18
  analyzePositionTryRules,
19
19
  cleanPositionTryRules,
20
20
  collectRuleMetadata,
21
21
  filterUnusedPositionTry
22
22
  } from './position-try.js';
23
- import {
24
- neutralizeEscapeSequences,
25
- preprocessDeclarationBlocks,
26
- restoreEscapeSequences
27
- } from './preprocess.js';
23
+ import { preprocessDeclarationBlocks } from './preprocess.js';
24
+ import { combineLanguageSelectors } from './rules/lang.js';
25
+ import { normalizeRuleSelectors } from './rules/normalize.js';
28
26
  import {
29
27
  deduplicateKeyframes,
30
28
  expandPureNestedRules,
@@ -48,48 +46,16 @@ import {
48
46
  minifyValue
49
47
  } from './value/minify.js';
50
48
 
51
- /**
52
- * Splits a minified CSS selector list at top-level commas, respecting
53
- * parenthesized and bracketed groups so that commas inside pseudo-class
54
- * arguments like `:is(a, b)` are not treated as selector separators.
55
- *
56
- * @param {string} selectorList The comma-separated selector list string.
57
- * @return {Array} An array of individual selector strings.
58
- */
59
- function splitSelectorList (selectorList) {
60
- const selectors = [];
61
- let current = '';
62
- let depth = 0;
63
- for (const character of selectorList) {
64
- if (character === '(' || character === '[') {
65
- depth++;
66
- current += character;
67
- } else if (character === ')' || character === ']') {
68
- depth--;
69
- current += character;
70
- } else if (character === ',' && depth === 0) {
71
- selectors.push(current.trim());
72
- current = '';
73
- } else {
74
- current += character;
75
- }
76
- }
77
- const remaining = current.trim();
78
- if (remaining) {
79
- selectors.push(remaining);
80
- }
81
- return selectors;
82
- }
83
-
84
49
  /**
85
50
  * Deduplicates a minified CSS selector list string by splitting at top-level
86
- * commas, removing duplicate selectors, and rejoining with commas.
51
+ * commas, removing duplicate selectors, combining the selectors that differ
52
+ * only in language-code, and rejoining with commas.
87
53
  *
88
54
  * @param {string} selectorList The comma-separated selector list string.
89
55
  * @return {string} The deduplicated selector list string.
90
56
  */
91
57
  function deduplicateSelectorList (selectorList) {
92
- const selectors = splitSelectorList(selectorList);
58
+ const selectors = splitTopLevelCommaList(selectorList);
93
59
  const seen = new Set();
94
60
  const unique = selectors.filter((selector) => {
95
61
  if (seen.has(selector)) {
@@ -98,7 +64,7 @@ function deduplicateSelectorList (selectorList) {
98
64
  seen.add(selector);
99
65
  return true;
100
66
  });
101
- return unique.join(',');
67
+ return combineLanguageSelectors(unique).join(',');
102
68
  }
103
69
 
104
70
  /**
@@ -222,7 +188,7 @@ export const minifyCSS = function (input) {
222
188
 
223
189
  try {
224
190
  ast = parse(
225
- preprocessDeclarationBlocks(neutralizeEscapeSequences(source)),
191
+ preprocessDeclarationBlocks(source),
226
192
  { preserveFormatting: true, silent: true }
227
193
  );
228
194
  } catch {
@@ -233,6 +199,10 @@ export const minifyCSS = function (input) {
233
199
  const context = createMinifyContext();
234
200
 
235
201
  if (ast?.stylesheet?.rules) {
202
+ // Every later pass reads selectors, so they are written the one canonical
203
+ // way before any of them runs.
204
+ normalizeRuleSelectors(ast.stylesheet.rules);
205
+
236
206
  // Which properties a shorthand may not silently reset is a question about
237
207
  // the whole stylesheet, so it is answered before any rule is rewritten.
238
208
  recordStylesheetResetProperties(ast.stylesheet.rules, context);
@@ -269,7 +239,10 @@ export const minifyCSS = function (input) {
269
239
  const declarationMergedRules = mergeByDeclarations(preCleanedRules);
270
240
  const nestedRules = nestFlatRules(declarationMergedRules);
271
241
  const nonEmptyRules = removeEmptyRules(nestedRules);
272
- const factoredRules = factorCommonParents(nonEmptyRules);
242
+ // Nesting is what reveals that two rules style the same thing: until their
243
+ // descendants have moved inside them, the rules only share a declaration.
244
+ const nestingMergedRules = mergeByDeclarations(nonEmptyRules);
245
+ const factoredRules = factorCommonParents(nestingMergedRules);
273
246
  const nestedFinalRules = nestFlatRules(factoredRules);
274
247
  const finalRules = mergeIdenticalNestedRules(nestedFinalRules);
275
248
 
@@ -280,7 +253,7 @@ export const minifyCSS = function (input) {
280
253
  const mergedOutput = removeRedundantLayerStatementSemicolon(mergeAdjacentRulesWithIdenticalBodies(output));
281
254
 
282
255
  endMinificationPass();
283
- return restoreEscapeSequences(mergedOutput.join(''));
256
+ return mergedOutput.join('');
284
257
  }
285
258
 
286
259
  endMinificationPass();