@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 +4 -5
- package/package.json +4 -4
- package/src/declarations/process.js +44 -14
- package/src/value/math.js +83 -10
- package/src/value/minify.js +32 -23
- package/src/value/syntax.js +7 -0
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.
|
|
289
|
-
1.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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 {
|
|
148
|
+
* @return {Array} The positions of the matching declarations, in source order.
|
|
147
149
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
189
|
-
let
|
|
218
|
+
// An unprefixed property with the same value also replaces every prefixed form of it
|
|
219
|
+
let prefixedPositions = [];
|
|
190
220
|
if (!propertyName.startsWith('-')) {
|
|
191
|
-
|
|
221
|
+
prefixedPositions = findVendorPrefixedPositions(survivors, propertyName);
|
|
192
222
|
}
|
|
193
223
|
|
|
194
|
-
|
|
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)) {
|
package/src/value/minify.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
262
|
-
const
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
/**
|
package/src/value/syntax.js
CHANGED
|
@@ -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++;
|