@thejaredwilcurt/csslop 0.0.19 → 0.0.21

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.19",
5
+ "version": "0.0.21",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -11,8 +11,7 @@
11
11
  "prebuild": "node ./scripts/prebuild.js",
12
12
  "build": "vite build",
13
13
  "postbuild": "node ./scripts/postbuild.js",
14
- "copy": "node ./scripts/copyTestsFromNodeModules.js",
15
- "copy-repo": "node ./scripts/copyTestsFromRepo.js",
14
+ "copy": "node ./scripts/copyTests.js",
16
15
  "test": "node ./tests/index.test.js",
17
16
  "real": "node ./tests/realworld.test.js",
18
17
  "lint": "eslint *.js scripts src tests --fix",
@@ -26,22 +25,21 @@
26
25
  "devDependencies": {
27
26
  "@codemirror/autocomplete": "^6.20.3",
28
27
  "@codemirror/lang-css": "^6.3.1",
29
- "@codemirror/view": "^6.43.7",
28
+ "@codemirror/view": "^6.43.8",
30
29
  "@eslint/js": "^10.0.1",
31
30
  "@stylistic/eslint-plugin": "^5.10.0",
32
31
  "codemirror": "^6.0.2",
33
- "css-minify-tests": "github:keithamus/css-minify-tests",
34
- "eslint": "^10.8.0",
32
+ "eslint": "^10.8.1",
35
33
  "eslint-config-tjw-base": "^5.0.0",
36
34
  "eslint-config-tjw-import-x": "^1.0.1",
37
35
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
36
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^63.3.2",
37
+ "eslint-plugin-jsdoc": "^64.1.0",
40
38
  "fflate": "^0.8.3",
41
- "globals": "^17.8.0",
39
+ "globals": "^17.11.0",
42
40
  "pretty-ms": "^9.3.0",
43
- "real-world-css-libraries": "^1.0.5",
44
- "vite": "^8.2.0"
41
+ "real-world-css-libraries": "^1.0.7",
42
+ "vite": "^8.2.1"
45
43
  },
46
44
  "author": "The Jared Wilcurt",
47
45
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -0,0 +1,322 @@
1
+ /**
2
+ * @file Classifies background shorthand tokens and rebuilds the `background` shorthand from its component values.
3
+ */
4
+
5
+ import { minifyValue } from '../value/minify.js';
6
+
7
+ import { shorthandMap } from './config.js';
8
+
9
+ const BACKGROUND_POSITION_KEYWORDS = new Set(['left', 'center', 'right', 'top', 'bottom']);
10
+ const BACKGROUND_REPEAT_KEYWORDS = new Set(['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'space', 'round']);
11
+ const BACKGROUND_ATTACHMENT_KEYWORDS = new Set(['scroll', 'fixed', 'local']);
12
+ const BACKGROUND_BOX_KEYWORDS = new Set(['border-box', 'padding-box', 'content-box']);
13
+
14
+ /**
15
+ * Resolves the background position from a value map. Prefers the combined
16
+ * `background-position` property if present, otherwise combines
17
+ * `background-position-x` and `background-position-y` into a single value.
18
+ *
19
+ * @param {Map} valueMap A map of property names to their minified values.
20
+ * @return {string|null} The resolved position string, or null if no position data is available.
21
+ */
22
+ function resolveBackgroundPosition (valueMap) {
23
+ const position = valueMap.get('background-position');
24
+ if (position) {
25
+ return position;
26
+ }
27
+ const positionX = valueMap.get('background-position-x');
28
+ const positionY = valueMap.get('background-position-y');
29
+ if (positionX && positionY) {
30
+ return positionX + ' ' + positionY;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Determines whether a token is a background image component such as `none`,
37
+ * `url(...)`, or an image-producing function like `linear-gradient(...)`.
38
+ *
39
+ * @param {string} token The token to classify.
40
+ * @return {boolean} Whether the token is a background image token.
41
+ */
42
+ function isBackgroundImageToken (token) {
43
+ if (token === 'none' || token.startsWith('url(')) {
44
+ return true;
45
+ }
46
+ if (!token.endsWith(')')) {
47
+ return false;
48
+ }
49
+ const functionNameMatch = token.match(/^([a-z-]+)\(/i);
50
+ if (!functionNameMatch) {
51
+ return false;
52
+ }
53
+ const functionName = functionNameMatch[1].toLowerCase();
54
+ return !['calc', 'min', 'max', 'clamp', 'var', 'env', 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color'].includes(functionName);
55
+ }
56
+
57
+ /**
58
+ * Determines whether a token can participate in a background-position value.
59
+ *
60
+ * @param {string} token The token to classify.
61
+ * @return {boolean} Whether the token is a valid background-position token.
62
+ */
63
+ function isBackgroundPositionToken (token) {
64
+ const lowercaseToken = token.toLowerCase();
65
+ if (BACKGROUND_POSITION_KEYWORDS.has(lowercaseToken)) {
66
+ return true;
67
+ }
68
+ if (/^[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?$/i.test(token)) {
69
+ return true;
70
+ }
71
+ return /^(?:calc|min|max|clamp|var|env)\(/i.test(token);
72
+ }
73
+
74
+ /**
75
+ * Determines whether a token is a background color component after excluding
76
+ * known image, position, repeat, attachment, and box tokens.
77
+ *
78
+ * @param {string} token The token to classify.
79
+ * @return {boolean} Whether the token is a background color token.
80
+ */
81
+ function isBackgroundColorToken (token) {
82
+ if (token === '/' || isBackgroundImageToken(token) || isBackgroundPositionToken(token)) {
83
+ return false;
84
+ }
85
+ const lowercaseToken = token.toLowerCase();
86
+ if (BACKGROUND_REPEAT_KEYWORDS.has(lowercaseToken) || BACKGROUND_ATTACHMENT_KEYWORDS.has(lowercaseToken) || BACKGROUND_BOX_KEYWORDS.has(lowercaseToken)) {
87
+ return false;
88
+ }
89
+ return /^#/i.test(token) || /^[a-z-]+$/i.test(token) || /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/i.test(token);
90
+ }
91
+
92
+ /**
93
+ * Splits a token like `linear-gradient(...)100%` into separate image and
94
+ * position tokens when the function output is immediately followed by a
95
+ * background-position token.
96
+ *
97
+ * @param {string} token The token to inspect.
98
+ * @return {Array} The original token, or separate image/position tokens.
99
+ */
100
+ function splitAttachedBackgroundImageToken (token) {
101
+ const lastCloseParenthesis = token.lastIndexOf(')');
102
+ if (lastCloseParenthesis === -1 || lastCloseParenthesis === token.length - 1) {
103
+ return [token];
104
+ }
105
+ const imageToken = token.slice(0, lastCloseParenthesis + 1);
106
+ const followingToken = token.slice(lastCloseParenthesis + 1);
107
+ if (!isBackgroundImageToken(imageToken) || !isBackgroundPositionToken(followingToken)) {
108
+ return [token];
109
+ }
110
+ return [imageToken, followingToken];
111
+ }
112
+
113
+ /**
114
+ * Splits a single-layer background shorthand into top-level tokens while
115
+ * respecting nested parentheses and preserving `/` as its own token.
116
+ *
117
+ * @param {string} value The background shorthand value.
118
+ * @return {Array} The extracted top-level tokens.
119
+ */
120
+ function splitBackgroundTokens (value) {
121
+ const tokens = [];
122
+ let currentToken = '';
123
+ let parenthesisDepth = 0;
124
+
125
+ for (const character of value) {
126
+ if (character === '(') {
127
+ parenthesisDepth++;
128
+ } else if (character === ')') {
129
+ parenthesisDepth--;
130
+ }
131
+
132
+ if (parenthesisDepth === 0 && character === '/') {
133
+ if (currentToken) {
134
+ tokens.push(currentToken);
135
+ currentToken = '';
136
+ }
137
+ tokens.push('/');
138
+ continue;
139
+ }
140
+
141
+ if (parenthesisDepth === 0 && /\s/.test(character)) {
142
+ if (currentToken) {
143
+ tokens.push(currentToken);
144
+ currentToken = '';
145
+ }
146
+ continue;
147
+ }
148
+
149
+ currentToken += character;
150
+ }
151
+
152
+ if (currentToken) {
153
+ tokens.push(currentToken);
154
+ }
155
+
156
+ const normalizedTokens = [];
157
+ for (const token of tokens) {
158
+ normalizedTokens.push(...splitAttachedBackgroundImageToken(token));
159
+ }
160
+ return normalizedTokens;
161
+ }
162
+
163
+ /**
164
+ * Extracts the simple image/color base from an existing background shorthand.
165
+ * Returns null for shorthands that already contain size, position, or any
166
+ * token that cannot be safely reconstructed by the background builder.
167
+ *
168
+ * @param {string} value The background shorthand value.
169
+ * @return {Map|null} A component map for safe reconstruction, or null.
170
+ */
171
+ function extractSimpleBackgroundBase (value) {
172
+ const tokens = splitBackgroundTokens(value);
173
+ const componentMap = new Map();
174
+
175
+ for (const token of tokens) {
176
+ if (token === '/') {
177
+ return null;
178
+ }
179
+ if (isBackgroundImageToken(token)) {
180
+ if (componentMap.has('background-image')) {
181
+ return null;
182
+ }
183
+ componentMap.set('background-image', token);
184
+ continue;
185
+ }
186
+ if (isBackgroundColorToken(token)) {
187
+ if (componentMap.has('background-color')) {
188
+ return null;
189
+ }
190
+ componentMap.set('background-color', token);
191
+ continue;
192
+ }
193
+ return null;
194
+ }
195
+
196
+ return componentMap;
197
+ }
198
+
199
+ /**
200
+ * Serializes normalized background components into a minified background
201
+ * shorthand value while omitting default sub-values.
202
+ *
203
+ * @param {Map} valueMap The normalized background component map.
204
+ * @param {string} importantSuffix A trailing `!important` suffix, if needed.
205
+ * @return {string|null} The minified background shorthand, or null.
206
+ */
207
+ function buildBackgroundShorthandValue (valueMap, importantSuffix) {
208
+ const color = valueMap.get('background-color');
209
+ const image = valueMap.get('background-image');
210
+ const repeat = valueMap.get('background-repeat');
211
+ const attachment = valueMap.get('background-attachment');
212
+ const size = valueMap.get('background-size');
213
+ const origin = valueMap.get('background-origin');
214
+ const clip = valueMap.get('background-clip');
215
+ const position = resolveBackgroundPosition(valueMap);
216
+
217
+ const result = [];
218
+ if (color && color !== 'transparent') {
219
+ result.push(color);
220
+ }
221
+ if (image && image !== 'none') {
222
+ result.push(image);
223
+ }
224
+ if (position && position !== '0 0' && position !== '0% 0%') {
225
+ result.push(position);
226
+ }
227
+ if (size && size !== 'auto') {
228
+ if (position && position !== '0 0' && position !== '0% 0%') {
229
+ result.push('/' + size);
230
+ } else {
231
+ result.push('0 0/' + size);
232
+ }
233
+ }
234
+ if (repeat && repeat !== 'repeat') {
235
+ result.push(repeat);
236
+ }
237
+ if (attachment && attachment !== 'scroll') {
238
+ result.push(attachment);
239
+ }
240
+ const hasNonDefaultOrigin = origin && origin !== 'padding-box';
241
+ const hasNonDefaultClip = clip && clip !== 'border-box';
242
+ if (hasNonDefaultOrigin && hasNonDefaultClip) {
243
+ result.push(origin);
244
+ result.push(clip);
245
+ } else if (hasNonDefaultOrigin || hasNonDefaultClip) {
246
+ if (origin) {
247
+ result.push(origin);
248
+ }
249
+ if (clip) {
250
+ result.push(clip);
251
+ }
252
+ }
253
+ if (!result.length) {
254
+ return null;
255
+ }
256
+ return result.join(' ') + importantSuffix;
257
+ }
258
+
259
+ /**
260
+ * Merges later background longhands into an earlier simple background shorthand
261
+ * when their combined value can be reconstructed without changing semantics.
262
+ *
263
+ * @param {Array} declarations The declarations in source order.
264
+ * @return {Array} The updated declarations with absorbed longhands.
265
+ */
266
+ function absorbBackgroundLonghandsIntoShorthand (declarations) {
267
+ const backgroundIndex = declarations.findIndex((declaration) => {
268
+ return declaration.property === 'background';
269
+ });
270
+ if (backgroundIndex === -1) {
271
+ return declarations;
272
+ }
273
+
274
+ const backgroundDeclaration = declarations[backgroundIndex];
275
+ const backgroundValue = minifyValue(backgroundDeclaration);
276
+ const backgroundIsImportant = backgroundValue.includes('!important');
277
+ const simpleBase = extractSimpleBackgroundBase(backgroundValue.replace('!important', '').trim());
278
+ if (!simpleBase) {
279
+ return declarations;
280
+ }
281
+
282
+ const absorbableProperties = new Set(shorthandMap.background.filter((property) => {
283
+ return property !== 'background';
284
+ }));
285
+ const relevantDeclarations = declarations.filter((declaration, index) => {
286
+ return index > backgroundIndex && absorbableProperties.has(declaration.property);
287
+ });
288
+ if (!relevantDeclarations.length) {
289
+ return declarations;
290
+ }
291
+
292
+ const sharesImportance = relevantDeclarations.every((declaration) => {
293
+ return minifyValue(declaration).includes('!important') === backgroundIsImportant;
294
+ });
295
+ if (!sharesImportance) {
296
+ return declarations;
297
+ }
298
+
299
+ for (const declaration of relevantDeclarations) {
300
+ simpleBase.set(declaration.property, minifyValue(declaration).replace('!important', '').trim());
301
+ }
302
+
303
+ const mergedValue = buildBackgroundShorthandValue(simpleBase, backgroundIsImportant ? '!important' : '');
304
+ if (!mergedValue) {
305
+ return declarations;
306
+ }
307
+
308
+ return declarations.flatMap((declaration, index) => {
309
+ if (index === backgroundIndex) {
310
+ return [{ ...declaration, value: mergedValue }];
311
+ }
312
+ if (index > backgroundIndex && absorbableProperties.has(declaration.property)) {
313
+ return [];
314
+ }
315
+ return [declaration];
316
+ });
317
+ }
318
+
319
+ export {
320
+ absorbBackgroundLonghandsIntoShorthand,
321
+ buildBackgroundShorthandValue
322
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @file Rewrites border longhand declarations that cannot be expressed as a single `border` shorthand into the shortest valid shorthand plus override pair.
3
+ */
4
+
5
+ import { minifyValue } from '../value/minify.js';
6
+ import { splitTopLevelComponents } from '../value/syntax.js';
7
+
8
+ import { CSS_WIDE_KEYWORDS } from './config.js';
9
+
10
+ const BORDER_TRIO_PROPERTIES = ['border-width', 'border-style', 'border-color'];
11
+
12
+ /**
13
+ * Finds the index of the last declaration for a property, which is the one that
14
+ * wins the cascade within a rule.
15
+ *
16
+ * @param {Array} declarations The declarations to search.
17
+ * @param {string} property The property name to look for.
18
+ * @return {number} The index of the matching declaration, or -1 when absent.
19
+ */
20
+ function findLastDeclarationIndex (declarations, property) {
21
+ for (let index = declarations.length - 1; index >= 0; index--) {
22
+ if (declarations[index].property === property) {
23
+ return index;
24
+ }
25
+ }
26
+ return -1;
27
+ }
28
+
29
+ /**
30
+ * Rewrites a `border-width` + `border-style` + `border-color` trio whose color
31
+ * differs per edge into a `border` shorthand built from the first color, followed
32
+ * by a `border-color` override that restores the per-edge colors. The rewrite is
33
+ * only applied when the resulting pair is shorter than the three longhands.
34
+ *
35
+ * @param {Array} declarations The declarations of a single rule.
36
+ * @return {Array} The declarations, with the trio rewritten when it is shorter.
37
+ */
38
+ function collapseBorderTrioWithPerEdgeColor (declarations) {
39
+ const trioIndexes = BORDER_TRIO_PROPERTIES.map((property) => {
40
+ return findLastDeclarationIndex(declarations, property);
41
+ });
42
+ const hasWholeTrio = trioIndexes.every((index) => {
43
+ return index !== -1;
44
+ });
45
+ if (!hasWholeTrio) {
46
+ return declarations;
47
+ }
48
+
49
+ const trioValues = trioIndexes.map((index) => {
50
+ return minifyValue(declarations[index]);
51
+ });
52
+ const allImportant = trioValues.every((value) => {
53
+ return value.includes('!important');
54
+ });
55
+ const noneImportant = trioValues.every((value) => {
56
+ return !value.includes('!important');
57
+ });
58
+ if (!allImportant && !noneImportant) {
59
+ return declarations;
60
+ }
61
+ const importantSuffix = allImportant ? '!important' : '';
62
+
63
+ const [width, style, color] = trioValues.map((value) => {
64
+ return value.replace('!important', '').trim();
65
+ });
66
+ const colorComponents = splitTopLevelComponents(color);
67
+ const isSingleComponent = (value) => {
68
+ return splitTopLevelComponents(value).length === 1;
69
+ };
70
+ const canBuildShorthand = (
71
+ isSingleComponent(width) &&
72
+ isSingleComponent(style) &&
73
+ colorComponents.length > 1
74
+ );
75
+ if (!canBuildShorthand) {
76
+ return declarations;
77
+ }
78
+ const usesCssWideKeyword = [width, style, ...colorComponents].some((value) => {
79
+ return CSS_WIDE_KEYWORDS.has(value.toLowerCase());
80
+ });
81
+ if (usesCssWideKeyword) {
82
+ return declarations;
83
+ }
84
+
85
+ const borderValue = [width, style, colorComponents[0]].join(' ') + importantSuffix;
86
+ const colorValue = color + importantSuffix;
87
+ const rewrittenLength = ('border:' + borderValue + ';border-color:' + colorValue).length;
88
+ const longhandLength = (
89
+ 'border-width:' + trioValues[0] +
90
+ ';border-style:' + trioValues[1] +
91
+ ';border-color:' + trioValues[2]
92
+ ).length;
93
+ if (rewrittenLength >= longhandLength) {
94
+ return declarations;
95
+ }
96
+
97
+ const replacedIndexes = new Set(trioIndexes);
98
+ const insertionIndex = Math.min(...trioIndexes);
99
+ return declarations.flatMap((declaration, index) => {
100
+ if (index === insertionIndex) {
101
+ return [
102
+ {
103
+ property: 'border',
104
+ value: borderValue,
105
+ isAssembledShorthand: true
106
+ },
107
+ {
108
+ property: 'border-color',
109
+ value: colorValue
110
+ }
111
+ ];
112
+ }
113
+ if (replacedIndexes.has(index)) {
114
+ return [];
115
+ }
116
+ return [declaration];
117
+ });
118
+ }
119
+
120
+ export { collapseBorderTrioWithPerEdgeColor };
@@ -51,7 +51,43 @@ const shorthandOverrideMap = {
51
51
  mask: ['mask-border', 'mask-border-source', 'mask-border-slice', 'mask-border-width', 'mask-border-outset', 'mask-border-repeat', 'mask-border-mode']
52
52
  };
53
53
 
54
+ /**
55
+ * CSS-wide keywords, which are only valid as the entire value of a declaration
56
+ * and never as an individual component of a shorthand value.
57
+ *
58
+ * @type {Set<string>}
59
+ */
60
+ const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
61
+
62
+ /**
63
+ * Shorthand properties that themselves hold a full width/style/color value for a
64
+ * single edge of a box, so they can only collapse into their parent shorthand
65
+ * when every edge carries the exact same value.
66
+ *
67
+ * @type {Set<string>}
68
+ */
69
+ const EDGE_SHORTHANDS = new Set([
70
+ 'border-top',
71
+ 'border-right',
72
+ 'border-bottom',
73
+ 'border-left',
74
+ 'border-inline-start',
75
+ 'border-inline-end',
76
+ 'border-block-start',
77
+ 'border-block-end'
78
+ ]);
79
+
80
+ /**
81
+ * The four physical edge shorthands that together cover the `border` shorthand.
82
+ *
83
+ * @type {Array}
84
+ */
85
+ const BORDER_EDGE_PROPERTIES = ['border-top', 'border-right', 'border-bottom', 'border-left'];
86
+
54
87
  export {
88
+ BORDER_EDGE_PROPERTIES,
89
+ CSS_WIDE_KEYWORDS,
90
+ EDGE_SHORTHANDS,
55
91
  shorthandMap,
56
92
  shorthandOverrideMap
57
93
  };