@thejaredwilcurt/csslop 0.0.36 → 0.0.37

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
@@ -285,8 +285,9 @@ Two different cases:
285
285
  ## Updating tests
286
286
 
287
287
  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.
288
- 1. After that, delete the `package-lock.json` and `node_modules` folder.
289
- 1. Then run `npm i && npm run copy` to download the latest tests and copy them to this repo.
288
+ 1. Clone down a copy of that repo next to the cloned copy of this one (sibling folders)
289
+ 1. Make sure the `css-minify-tests` clone is on the `main` branch and up-to-date (`git pull origin main`)
290
+ 1. Then run `npm run copy` in the CSSLOP repo to copy the test from `css-minify-tests` to the `copiedTests` folder in this repo.
290
291
  1. `git add -A && git commit -m "Updated tests"`
291
292
  1. Then run `npm t` to see if any tests fail
292
293
  1. If they fail, give an AI this prompt:
@@ -298,9 +299,7 @@ Two different cases:
298
299
  1. `npm run bump`
299
300
  1. `git add -A && git commit -m "Fix newly added tests" && git push`
300
301
  1. Merge the code into `main` on GitHub
301
- 1. Do a new release on GitHub
302
- 1. `git checkout main && git pull origin main && git pull`
303
- 1. `npm run publish`
302
+ 1. Do a new release on GitHub, this will also release to npm
304
303
 
305
304
 
306
305
  ## Code Organization prompt:
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.36",
5
+ "version": "0.0.37",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -36,12 +36,12 @@
36
36
  "eslint-config-tjw-import-x": "^1.0.1",
37
37
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
38
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^64.3.6",
39
+ "eslint-plugin-jsdoc": "^64.3.9",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.12.0",
42
42
  "pretty-ms": "^9.3.1",
43
43
  "real-world-css-libraries": "^1.0.8",
44
- "vite": "^8.2.2"
44
+ "vite": "^8.3.0"
45
45
  },
46
46
  "author": "The Jared Wilcurt",
47
47
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -58,7 +58,7 @@
58
58
  "devEngines": {
59
59
  "runtime": {
60
60
  "name": "node",
61
- "version": "26.8.1"
61
+ "version": "26.8.2"
62
62
  },
63
63
  "packageManager": {
64
64
  "name": "npm",
@@ -97,7 +97,8 @@ function keepDeclaration (survivors, declaration) {
97
97
  } else {
98
98
  survivors.positionsByProperty.set(declaration.property, [position]);
99
99
  }
100
- if (declaration.property.startsWith('-')) {
100
+ // Custom properties (`--*`) are named by their author, not vendor-prefixed
101
+ if (declaration.property.startsWith('-') && !declaration.property.startsWith('--')) {
101
102
  survivors.vendorPrefixedPositions.push(position);
102
103
  }
103
104
  }
@@ -137,19 +138,39 @@ function findLastPositionOfProperty (survivors, property) {
137
138
  }
138
139
 
139
140
  /**
140
- * Finds the surviving vendor-prefixed declaration that last set the prefixed
141
- * form of a property, such as `-webkit-transform` for `transform`. Only the
142
- * prefixed declarations are visited, rather than every survivor.
141
+ * Finds the surviving vendor-prefixed declarations that set a prefixed form of
142
+ * a property, such as `-webkit-transform` for `transform`, including legacy
143
+ * spellings like `-webkit-box-flex` for `flex`. Only the prefixed declarations
144
+ * are visited, rather than every survivor.
143
145
  *
144
146
  * @param {object} survivors The surviving-declaration bookkeeping.
145
147
  * @param {string} property The unprefixed property name.
146
- * @return {number} The position of the matching declaration, or -1 when absent.
148
+ * @return {Array} The positions of the matching declarations, in source order.
147
149
  */
148
- function findLastVendorPrefixedPosition (survivors, property) {
149
- for (let entry = survivors.vendorPrefixedPositions.length - 1; entry >= 0; entry--) {
150
- const position = survivors.vendorPrefixedPositions[entry];
151
- if (survivors.slots[position].property.endsWith(property)) {
152
- return position;
150
+ function findVendorPrefixedPositions (survivors, property) {
151
+ return survivors.vendorPrefixedPositions.filter((position) => {
152
+ return survivors.slots[position].property.endsWith(property);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Finds the surviving declaration that last set the unprefixed property a
158
+ * vendor-prefixed declaration spells in prefixed form, such as `transform`
159
+ * for `-moz-transform`.
160
+ *
161
+ * @param {object} survivors The surviving-declaration bookkeeping.
162
+ * @param {string} propertyName The vendor-prefixed property name.
163
+ * @return {number} The position of the matching declaration, or -1 when absent.
164
+ */
165
+ function findUnprefixedTwinPosition (survivors, propertyName) {
166
+ for (const [property, positions] of survivors.positionsByProperty) {
167
+ const isUnprefixedTwin = (
168
+ !property.startsWith('-') &&
169
+ positions.length &&
170
+ propertyName.endsWith(property)
171
+ );
172
+ if (isUnprefixedTwin) {
173
+ return positions[positions.length - 1];
153
174
  }
154
175
  }
155
176
  return -1;
@@ -183,15 +204,24 @@ function deduplicateDeclarations (declarations) {
183
204
 
184
205
  const minifiedValue = minifyValue(declaration);
185
206
 
207
+ // A vendor-prefixed property that repeats the value of its unprefixed
208
+ // form is dead code for browsers that read the unprefixed one
209
+ if (propertyName.startsWith('-') && !propertyName.startsWith('--')) {
210
+ const twinPosition = findUnprefixedTwinPosition(survivors, propertyName);
211
+ if (twinPosition !== -1 && minifyValue(survivors.slots[twinPosition]) === minifiedValue) {
212
+ continue;
213
+ }
214
+ }
215
+
186
216
  const previousPosition = findLastPositionOfProperty(survivors, propertyName);
187
217
 
188
- // An unprefixed property with the same value also replaces its prefixed form
189
- let prefixedPosition = -1;
218
+ // An unprefixed property with the same value also replaces every prefixed form of it
219
+ let prefixedPositions = [];
190
220
  if (!propertyName.startsWith('-')) {
191
- prefixedPosition = findLastVendorPrefixedPosition(survivors, propertyName);
221
+ prefixedPositions = findVendorPrefixedPositions(survivors, propertyName);
192
222
  }
193
223
 
194
- if (prefixedPosition !== -1) {
224
+ for (const prefixedPosition of prefixedPositions) {
195
225
  const prefixedValue = minifyValue(survivors.slots[prefixedPosition]);
196
226
  if (minifiedValue === prefixedValue) {
197
227
  dropDeclaration(survivors, prefixedPosition);
package/src/value/math.js CHANGED
@@ -10,6 +10,79 @@ import {
10
10
  roundCompactNumber
11
11
  } from './shared.js';
12
12
 
13
+ /**
14
+ * Characters that start a token able to sit directly against a value that
15
+ * precedes them, so no whitespace is needed to keep the two tokens apart:
16
+ * whitespace already separates, and openers like `#`, `!`, or any bracket or
17
+ * punctuation mark their own token's start. Mirrors the tokenizer's notion of
18
+ * tokens marking their own start, with `/` on top since it delimits a size
19
+ * from a position in layered shorthands.
20
+ *
21
+ * @type {Set<string>}
22
+ */
23
+ const SELF_DELIMITING_FOLLOWERS = new Set([
24
+ ' ',
25
+ '\t',
26
+ '\n',
27
+ '\r',
28
+ '\f',
29
+ '#',
30
+ '!',
31
+ '"',
32
+ '\'',
33
+ '(',
34
+ ')',
35
+ '[',
36
+ ']',
37
+ '{',
38
+ '}',
39
+ ',',
40
+ ':',
41
+ ';',
42
+ '/'
43
+ ]);
44
+
45
+ /**
46
+ * Reports whether a math function's replacement glues itself to the character
47
+ * following the matched function, merging two values into one token. A
48
+ * replacement that keeps its function form ends with a parenthesis, which
49
+ * closes itself; only a bare resolved value needs the whitespace that the
50
+ * parenthesis padding removal took away.
51
+ *
52
+ * @param {string} replacement The string the math function resolved to.
53
+ * @param {string} followingCharacter The character directly after the matched function.
54
+ * @return {boolean} Whether a separating space must be restored.
55
+ */
56
+ function mergesWithFollowingCharacter (replacement, followingCharacter) {
57
+ if (replacement.endsWith(')') || !followingCharacter) {
58
+ return false;
59
+ }
60
+ return !SELF_DELIMITING_FOLLOWERS.has(followingCharacter);
61
+ }
62
+
63
+ /**
64
+ * Wraps a math-function replacer so a function that dissolves into a bare
65
+ * value does not lose the whitespace separating it from the value behind it.
66
+ * The values were once told apart by where the function's parentheses sat, so
67
+ * when the parentheses go, the separator has to stay.
68
+ *
69
+ * @param {function(string, ...(string|number|undefined)): string} replacer The replacement callback for `String.prototype.replace`.
70
+ * @return {function(string, ...(string|number|undefined)): string} A replacer that keeps the trailing separator intact.
71
+ */
72
+ function keepSeparatorAfterDissolvedFunction (replacer) {
73
+ return (match, ...rest) => {
74
+ const replacement = replacer(match, ...rest);
75
+ // A replace callback's trailing arguments are the match offset and the full string
76
+ const fullText = rest[rest.length - 1];
77
+ const matchOffset = rest[rest.length - 2];
78
+ const followingCharacter = fullText[matchOffset + match.length];
79
+ if (mergesWithFollowingCharacter(replacement, followingCharacter)) {
80
+ return replacement + ' ';
81
+ }
82
+ return replacement;
83
+ };
84
+ }
85
+
13
86
  /**
14
87
  * The units a folded calc() expression leads with, in the order they are
15
88
  * written. Every other unit follows them alphabetically.
@@ -153,30 +226,30 @@ function normalizeMathFunctions (value, property, originalValue = '') {
153
226
  let result = value;
154
227
 
155
228
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
156
- result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, (match, inner) => {
229
+ result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
157
230
  return inner.trim();
158
- });
231
+ }));
159
232
  // Flatten calc(calc(a) ± b) → calc(a ± b)
160
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, (match, inner, operator, tail) => {
233
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner, operator, tail) => {
161
234
  return 'calc(' + inner + ' ' + operator + ' ' + tail + ')';
162
- });
235
+ }));
163
236
  // Unwrap calc(calc(x)) → calc(x)
164
- result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, (match, inner) => {
237
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
165
238
  return 'calc(' + inner + ')';
166
- });
239
+ }));
167
240
 
168
241
  // Simplify min()/max() expressions using @csstools/css-calc
169
- result = result.replace(/\b(min|max)\(([^()]+)\)/gi, (match) => {
242
+ result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
170
243
  try {
171
244
  const simplified = calc(match);
172
245
  return typeof simplified === 'string' ? simplified : match;
173
246
  } catch {
174
247
  return match;
175
248
  }
176
- });
249
+ }));
177
250
 
178
251
  // Simplify calc() expressions using constant folding and @csstools/css-calc
179
- result = result.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
252
+ result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
180
253
  // Collapse whitespace inside calc expression
181
254
  const compactInner = inner.replace(/\s+/g, ' ').trim();
182
255
  // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
@@ -208,7 +281,7 @@ function normalizeMathFunctions (value, property, originalValue = '') {
208
281
  } catch {
209
282
  return match;
210
283
  }
211
- });
284
+ }));
212
285
 
213
286
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
214
287
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
@@ -165,6 +165,11 @@ function splitValueSegments (value) {
165
165
  end = consumeQuoted(end);
166
166
  continue;
167
167
  }
168
+ // An escaped character is url data, never the parenthesis that ends it
169
+ if (value[end] === '\\') {
170
+ end += 2;
171
+ continue;
172
+ }
168
173
  if (value[end] === '(') {
169
174
  depth++;
170
175
  }
@@ -250,38 +255,42 @@ function restoreSpaceBeforeMathOperators (value) {
250
255
  });
251
256
  }
252
257
 
258
+ /**
259
+ * Escapes the characters an unquoted url() token cannot hold, so the path can
260
+ * drop its quotes. A backslash already escaping a character is left alone, so
261
+ * a path that arrived escaped is never escaped a second time.
262
+ *
263
+ * @param {string} path The url path, without surrounding quotes.
264
+ * @return {string} The path with every bare space and parenthesis escaped.
265
+ */
266
+ function escapeUrlPathCharacters (path) {
267
+ // Match each space and parenthesis that no escaping backslash precedes
268
+ return path.replace(/(?<!\\)([ ()])/g, '\\$1');
269
+ }
270
+
253
271
  /**
254
272
  * Chooses the shortest valid representation for the path inside a `url(...)`
255
- * token, weighing an unquoted form, an escaped single space, and a quoted form.
273
+ * token, weighing an unquoted form — its spaces and parentheses escaped —
274
+ * against a quoted form. Quotation marks can only ever appear in the quoted
275
+ * form, so a path holding one always keeps its wrapper.
256
276
  *
257
277
  * @param {string} path The resolved url path, without surrounding quotes.
258
278
  * @return {string} The shortest valid url() content string.
259
279
  */
260
280
  function formatUrlPath (path) {
261
- // Parentheses and quote characters are invalid inside an unquoted url() token
262
- const hasQuoteForcingCharacters = /[()"']/.test(path);
263
- // An escaped space is already the shortest representation of a space, so it
264
- // is counted apart from unescaped ones to keep from being escaped twice
265
- const escapedSpaceCount = (path.match(/\\ /g) || []).length;
266
- // A space only needs escaping when no escaping backslash precedes it
267
- const unescapedSpaceCount = (path.match(/(?<!\\) /g) || []).length;
268
- const spaceCount = escapedSpaceCount + unescapedSpaceCount;
269
-
270
- if (hasQuoteForcingCharacters || spaceCount >= 2) {
271
- // Escape any embedded double quotes so the double-quoted wrapper stays valid
272
- return '"' + path.replace(/"/g, '\\"') + '"';
281
+ // Escape any embedded double quotes so the double-quoted wrapper stays valid
282
+ const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
283
+ // Match a quotation mark, which an unquoted url() token cannot hold
284
+ if (/["']/.test(path)) {
285
+ return quotedForm;
273
286
  }
274
-
275
- if (spaceCount === 1) {
276
- // A lone space is one byte shorter to escape than to wrap the value in
277
- // quotes, and one that arrived already escaped stays exactly as it is
278
- if (!unescapedSpaceCount) {
279
- return path;
280
- }
281
- return path.replace(/(?<!\\) /g, '\\ ');
287
+ const unquotedForm = escapeUrlPathCharacters(path);
288
+ // Each escaped character costs one backslash, so dropping the two quotation
289
+ // marks pays off whenever at most one character needs escaping
290
+ if (unquotedForm.length < quotedForm.length) {
291
+ return unquotedForm;
282
292
  }
283
-
284
- return path;
293
+ return quotedForm;
285
294
  }
286
295
 
287
296
  /**
@@ -29,6 +29,13 @@ function findMatchingParenthesis (value, openParenIndex) {
29
29
  continue;
30
30
  }
31
31
 
32
+ // An escaped character is data rather than a delimiter, even outside a
33
+ // quoted string, as in the `\)` of an unquoted url()
34
+ if (character === '\\') {
35
+ index += 2;
36
+ continue;
37
+ }
38
+
32
39
  if (character === '"' || character === '\'') {
33
40
  activeQuote = character;
34
41
  index++;