@thejaredwilcurt/csslop 0.0.17 → 0.0.18

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/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.17",
5
+ "version": "0.0.18",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -27,7 +27,7 @@
27
27
  "devDependencies": {
28
28
  "@codemirror/autocomplete": "^6.20.3",
29
29
  "@codemirror/lang-css": "^6.3.1",
30
- "@codemirror/view": "^6.43.6",
30
+ "@codemirror/view": "^6.43.7",
31
31
  "@eslint/js": "^10.0.1",
32
32
  "@stylistic/eslint-plugin": "^5.10.0",
33
33
  "codemirror": "^6.0.2",
@@ -37,9 +37,9 @@
37
37
  "eslint-config-tjw-import-x": "^1.0.1",
38
38
  "eslint-config-tjw-jsdoc": "^2.0.1",
39
39
  "eslint-plugin-import-x": "^4.17.0",
40
- "eslint-plugin-jsdoc": "^63.2.2",
40
+ "eslint-plugin-jsdoc": "^63.3.2",
41
41
  "fflate": "^0.8.3",
42
- "globals": "^17.7.0",
42
+ "globals": "^17.8.0",
43
43
  "pretty-ms": "^9.3.0",
44
44
  "real-world-css-libraries": "^1.0.5",
45
45
  "vite": "^8.1.2"
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Custom property value whitespace and comment processing for CSS minification.
3
+ */
4
+
5
+ /**
6
+ * Removes spaces after commas only inside parenthesized groups (function
7
+ * calls like `var()`, `calc()`), leaving top-level comma spacing intact.
8
+ *
9
+ * @param {string} value The whitespace-collapsed custom property value.
10
+ * @return {string} The value with post-comma spaces removed inside function calls only.
11
+ */
12
+ function removeSpacesAfterCommasInsideFunctions (value) {
13
+ let result = '';
14
+ let parenthesisDepth = 0;
15
+ for (let index = 0; index < value.length; index++) {
16
+ const character = value[index];
17
+ if (character === '(') {
18
+ parenthesisDepth++;
19
+ }
20
+ if (character === ')') {
21
+ parenthesisDepth--;
22
+ }
23
+ if (character === ',' && parenthesisDepth > 0) {
24
+ result += ',';
25
+ // Skip whitespace after the comma inside function calls
26
+ while (index + 1 < value.length && value[index + 1] === ' ') {
27
+ index++;
28
+ }
29
+ } else {
30
+ result += character;
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+
36
+ /**
37
+ * Strips leading zeros from decimal numbers in a custom property value
38
+ * (e.g. `0.5` becomes `.5`, `-0.02em` becomes `-.02em`).
39
+ *
40
+ * @param {string} value The custom property value string.
41
+ * @return {string} The value with leading zeros removed from decimals.
42
+ */
43
+ function stripLeadingZerosFromDecimals (value) {
44
+ // Match a boundary (start, whitespace, comma, open-paren), optional sign, then leading zeros before a decimal
45
+ return value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3');
46
+ }
47
+
48
+ /**
49
+ * Processes CSS comments within a custom property value. If the value
50
+ * consists entirely of a comment, the comment is removed (producing an
51
+ * empty value). If comments appear between other tokens, their content
52
+ * is stripped but empty comment delimiters are kept as zero-width
53
+ * token separators to preserve the token sequence.
54
+ *
55
+ * @param {string} value The raw custom property value string.
56
+ * @return {string} The value with comments processed.
57
+ */
58
+ function processCustomPropertyComments (value) {
59
+ // Match values that are entirely a comment (with optional surrounding whitespace)
60
+ const commentOnlyPattern = /^\s*\/\*.*?\*\/\s*$/s;
61
+ if (commentOnlyPattern.test(value)) {
62
+ return '';
63
+ }
64
+ // Strip comment content but keep empty markers as token separators
65
+ return value.replace(/\/\*.*?\*\//g, '/**/');
66
+ }
67
+
68
+ /**
69
+ * Collapses whitespace in a custom property value while preserving
70
+ * token boundaries. Each whitespace sequence is reduced to a single
71
+ * space, spaces after commas inside function calls are removed, and
72
+ * leading zeros on decimal numbers are stripped.
73
+ *
74
+ * @param {string} value The raw custom property value string.
75
+ * @return {string} The minified custom property value.
76
+ */
77
+ function collapseCustomPropertyWhitespace (value) {
78
+ // Collapse all whitespace sequences (newlines, tabs, multiple spaces) to a single space
79
+ let collapsed = value.replace(/\s+/g, ' ');
80
+ // Remove spaces after commas only inside function calls (e.g. var(--bar, 1.5) → var(--bar,1.5))
81
+ collapsed = removeSpacesAfterCommasInsideFunctions(collapsed);
82
+ // Strip leading zeros from decimals (e.g. 0.5 → .5, -0.02em → -.02em)
83
+ collapsed = stripLeadingZerosFromDecimals(collapsed);
84
+ return collapsed;
85
+ }
86
+ export {
87
+ collapseCustomPropertyWhitespace,
88
+ processCustomPropertyComments
89
+ };
@@ -0,0 +1,304 @@
1
+ /**
2
+ * @file Selector minification utilities for CSS rule stringification.
3
+ */
4
+
5
+ /**
6
+ * Splits a parameter string by commas while respecting nested parentheses,
7
+ * so commas inside function calls within default values are not treated as separators.
8
+ *
9
+ * @param {string} parameterString The comma-separated parameter string to split.
10
+ * @return {Array} An array of individual parameter strings.
11
+ */
12
+ function splitParametersByComma (parameterString) {
13
+ const parameters = [];
14
+ let currentParameter = '';
15
+ let parenthesisDepth = 0;
16
+ for (const character of parameterString) {
17
+ if (character === '(') {
18
+ parenthesisDepth++;
19
+ } else if (character === ')') {
20
+ parenthesisDepth--;
21
+ }
22
+ if (character === ',' && parenthesisDepth === 0) {
23
+ parameters.push(currentParameter);
24
+ currentParameter = '';
25
+ } else {
26
+ currentParameter += character;
27
+ }
28
+ }
29
+ parameters.push(currentParameter);
30
+ return parameters;
31
+ }
32
+
33
+ /**
34
+ * Finds the index of the closing parenthesis that matches the opening
35
+ * parenthesis at the given position in the string.
36
+ *
37
+ * @param {string} text The string to search within.
38
+ * @param {number} openIndex The index of the opening parenthesis.
39
+ * @return {number} The index of the matching closing parenthesis, or -1 if not found.
40
+ */
41
+ function findMatchingCloseParenthesis (text, openIndex) {
42
+ let depth = 0;
43
+ for (let index = openIndex; index < text.length; index++) {
44
+ if (text[index] === '(') {
45
+ depth++;
46
+ } else if (text[index] === ')') {
47
+ depth--;
48
+ if (depth === 0) {
49
+ return index;
50
+ }
51
+ }
52
+ }
53
+ return -1;
54
+ }
55
+
56
+ /**
57
+ * Extracts the type selector or universal selector from the beginning of a
58
+ * compound selector string, if one is present. A type selector is a bare
59
+ * element name (e.g. `div`, `a`); the universal selector is `*`.
60
+ *
61
+ * @param {string} compoundSelector A single compound CSS selector string.
62
+ * @return {string|null} The type or universal selector, or null if none is present.
63
+ */
64
+ function extractTypeSelector (compoundSelector) {
65
+ // Match universal selector (*) or type selector (letter followed by alphanumeric chars or hyphens)
66
+ const match = compoundSelector.match(/^(\*|[a-zA-Z][a-zA-Z0-9-]*)/);
67
+ if (match) {
68
+ return match[0];
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Merges two simple/compound selectors into a single compound selector,
75
+ * ensuring any type or universal selector appears first. Returns null when
76
+ * merging is invalid because both sides contain a type or universal selector.
77
+ *
78
+ * @param {string} left The first selector to merge.
79
+ * @param {string} right The second selector to merge.
80
+ * @return {string|null} The merged compound selector, or null if the merge is invalid.
81
+ */
82
+ function mergeCompoundSelectors (left, right) {
83
+ const leftTypeSelector = extractTypeSelector(left);
84
+ const rightTypeSelector = extractTypeSelector(right);
85
+ if (leftTypeSelector && rightTypeSelector) {
86
+ return null;
87
+ }
88
+ // When the right side has a type selector, it must come first in the compound
89
+ if (rightTypeSelector) {
90
+ return right + left;
91
+ }
92
+ return left + right;
93
+ }
94
+
95
+ /**
96
+ * Builds the cartesian product of two selector lists by merging every
97
+ * combination of left and right selectors into compound selectors.
98
+ * Returns null if any combination produces an invalid merge.
99
+ *
100
+ * @param {Array} leftParts Selectors from the first `:where()`.
101
+ * @param {Array} rightParts Selectors from the second `:where()`.
102
+ * @return {Array|null} The array of merged compound selectors, or null if any merge is invalid.
103
+ */
104
+ function buildWhereCartesianProduct (leftParts, rightParts) {
105
+ const products = [];
106
+ for (const leftSelector of leftParts) {
107
+ for (const rightSelector of rightParts) {
108
+ const merged = mergeCompoundSelectors(leftSelector.trim(), rightSelector.trim());
109
+ if (merged === null) {
110
+ return null;
111
+ }
112
+ products.push(merged);
113
+ }
114
+ }
115
+ return products;
116
+ }
117
+
118
+ /**
119
+ * Scans a selector string for adjacent `:where(A):where(B)` patterns and
120
+ * merges them into a single `:where(AB)` (or `:where()` with the cartesian
121
+ * product of their selector lists) when the merged form is strictly shorter.
122
+ * Type selectors are correctly repositioned to the front of each merged
123
+ * compound, and merges that would produce invalid compound selectors (two
124
+ * type selectors) are skipped.
125
+ *
126
+ * @param {string} selector A minified CSS selector string.
127
+ * @return {string} The selector with beneficial adjacent `:where()` merges applied.
128
+ */
129
+ function mergeAdjacentWherePseudoClasses (selector) {
130
+ let result = selector;
131
+ let position = 0;
132
+ while (position < result.length) {
133
+ const whereIndex = result.indexOf(':where(', position);
134
+ if (whereIndex === -1) {
135
+ break;
136
+ }
137
+ // Index of the '(' in the first ':where('
138
+ const firstOpenParenthesis = whereIndex + 6;
139
+ const firstCloseParenthesis = findMatchingCloseParenthesis(result, firstOpenParenthesis);
140
+ if (firstCloseParenthesis === -1) {
141
+ break;
142
+ }
143
+ const adjacentStart = firstCloseParenthesis + 1;
144
+ const adjacentWhereTag = ':where(';
145
+ if (result.slice(adjacentStart, adjacentStart + adjacentWhereTag.length) !== adjacentWhereTag) {
146
+ position = firstCloseParenthesis + 1;
147
+ continue;
148
+ }
149
+ // Index of the '(' in the second ':where('
150
+ const secondOpenParenthesis = adjacentStart + 6;
151
+ const secondCloseParenthesis = findMatchingCloseParenthesis(result, secondOpenParenthesis);
152
+ if (secondCloseParenthesis === -1) {
153
+ break;
154
+ }
155
+ const firstInnerContent = result.slice(firstOpenParenthesis + 1, firstCloseParenthesis);
156
+ const secondInnerContent = result.slice(secondOpenParenthesis + 1, secondCloseParenthesis);
157
+ const leftParts = splitParametersByComma(firstInnerContent);
158
+ const rightParts = splitParametersByComma(secondInnerContent);
159
+ const mergedParts = buildWhereCartesianProduct(leftParts, rightParts);
160
+ if (mergedParts === null) {
161
+ position = firstCloseParenthesis + 1;
162
+ continue;
163
+ }
164
+ const originalFragment = result.slice(whereIndex, secondCloseParenthesis + 1);
165
+ const mergedFragment = ':where(' + mergedParts.join(',') + ')';
166
+ if (mergedFragment.length < originalFragment.length) {
167
+ result = result.slice(0, whereIndex) + mergedFragment + result.slice(secondCloseParenthesis + 1);
168
+ // Don't advance position; the merged result may be adjacent to another :where()
169
+ } else {
170
+ position = firstCloseParenthesis + 1;
171
+ }
172
+ }
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
178
+ * de-duplicating, sorting alphabetically, and conditionally expanding into individual
179
+ * selectors when all parts are simple type/universal selectors with no modifications.
180
+ *
181
+ * @param {string} selector A minified CSS selector string.
182
+ * @return {Array} An array of one or more processed selector strings.
183
+ */
184
+ function processIsSelector (selector) {
185
+ // Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
186
+ selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
187
+ selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
188
+ // Only process bare :is() selectors (where :is() is the entire selector)
189
+ if (!selector.startsWith(':is(')) {
190
+ return [selector];
191
+ }
192
+ let depth = 0;
193
+ let closingIndex = -1;
194
+ for (let index = 4; index < selector.length; index++) {
195
+ if (selector[index] === '(') {
196
+ depth++;
197
+ } else if (selector[index] === ')') {
198
+ if (depth === 0) {
199
+ closingIndex = index;
200
+ break;
201
+ }
202
+ depth--;
203
+ }
204
+ }
205
+ if (closingIndex !== selector.length - 1) {
206
+ return [selector];
207
+ }
208
+ const content = selector.slice(4, -1);
209
+ let parts = [];
210
+ let currentPart = '';
211
+ let parenDepth = 0;
212
+ for (const character of content) {
213
+ if (character === '(') {
214
+ parenDepth++;
215
+ } else if (character === ')') {
216
+ parenDepth--;
217
+ }
218
+ if (character === ',' && parenDepth === 0) {
219
+ parts.push(currentPart);
220
+ currentPart = '';
221
+ } else {
222
+ currentPart += character;
223
+ }
224
+ }
225
+ parts.push(currentPart);
226
+ const originalCount = parts.length;
227
+ // Replace :link + :visited with :any-link
228
+ const hasLink = parts.includes(':link');
229
+ const hasVisited = parts.includes(':visited');
230
+ if (hasLink && hasVisited) {
231
+ parts = parts.filter((part) => {
232
+ return part !== ':link' && part !== ':visited';
233
+ });
234
+ if (!parts.includes(':any-link')) {
235
+ parts.push(':any-link');
236
+ }
237
+ }
238
+ // De-duplicate
239
+ parts = [...new Set(parts)];
240
+ // Sort alphabetically
241
+ parts.sort();
242
+ // Unwrap :is() with a single selector
243
+ if (parts.length === 1) {
244
+ return parts;
245
+ }
246
+ // Expand if all parts are simple type/universal selectors and no dedup/replacement occurred
247
+ const allSimple = parts.every((part) => {
248
+ return /^[a-z*][a-z0-9-]*$/i.test(part);
249
+ });
250
+ if (allSimple && parts.length === originalCount) {
251
+ return parts;
252
+ }
253
+ return [':is(' + parts.join(',') + ')'];
254
+ }
255
+
256
+ /**
257
+ * Flattens a top-level `:is()` selector into its individual parts when the rule
258
+ * acts as a nesting parent. A nesting parent's entire selector list is treated
259
+ * as `:is()` when computing the specificity of its nested children, so lifting
260
+ * the parts out of an inner `:is()` does not change specificity. The `:is()` is
261
+ * kept when any part contains a pseudo (`:`), since such selectors may be
262
+ * unsupported and rely on `:is()` for forgiving parsing.
263
+ *
264
+ * @param {string} selector A minified CSS selector string.
265
+ * @return {Array} The flattened selector parts, or the original selector.
266
+ */
267
+ function flattenNestingParentIsSelector (selector) {
268
+ if (!selector.startsWith(':is(')) {
269
+ return [selector];
270
+ }
271
+ let depth = 0;
272
+ let closingIndex = -1;
273
+ for (let index = 4; index < selector.length; index++) {
274
+ if (selector[index] === '(') {
275
+ depth++;
276
+ } else if (selector[index] === ')') {
277
+ if (depth === 0) {
278
+ closingIndex = index;
279
+ break;
280
+ }
281
+ depth--;
282
+ }
283
+ }
284
+ // The :is() must span the entire selector to be safely liftable
285
+ if (closingIndex !== selector.length - 1) {
286
+ return [selector];
287
+ }
288
+ const parts = splitParametersByComma(selector.slice(4, -1)).map((part) => {
289
+ return part.trim();
290
+ });
291
+ const hasPotentiallyUnsupportedPart = parts.some((part) => {
292
+ return part.includes(':');
293
+ });
294
+ if (hasPotentiallyUnsupportedPart) {
295
+ return [selector];
296
+ }
297
+ return parts;
298
+ }
299
+ export {
300
+ flattenNestingParentIsSelector,
301
+ mergeAdjacentWherePseudoClasses,
302
+ processIsSelector,
303
+ splitParametersByComma
304
+ };