@thejaredwilcurt/csslop 0.0.1

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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file Parses and minifies CSS gradient function calls by splitting arguments, normalizing default directions, and removing redundant stop positions.
3
+ */
4
+
5
+ /**
6
+ * Splits a gradient function's argument string at top-level commas, correctly handling nested parentheses.
7
+ *
8
+ * @param {string} argumentString The raw gradient arguments string.
9
+ * @return {Array} An array of trimmed argument strings.
10
+ */
11
+ function splitGradientArgs (argumentString) {
12
+ const parts = [];
13
+ let depth = 0;
14
+ let current = '';
15
+ for (const character of argumentString) {
16
+ if (character === '(') {
17
+ depth++;
18
+ } else if (character === ')') {
19
+ depth--;
20
+ }
21
+ if (character === ',' && depth === 0) {
22
+ parts.push(current.trim());
23
+ current = '';
24
+ } else {
25
+ current += character;
26
+ }
27
+ }
28
+ if (current.trim() || parts.length) {
29
+ parts.push(current.trim());
30
+ }
31
+ return parts;
32
+ }
33
+
34
+ /**
35
+ * Optimizes gradient arguments by removing default direction or shape keywords and trimming redundant 0% or 100% stop positions from the first and last stops.
36
+ *
37
+ * @param {string} func The gradient function name (e.g. "linear-gradient").
38
+ * @param {string} argsStr The raw comma-separated gradient arguments string.
39
+ * @return {string} The optimized gradient arguments string.
40
+ */
41
+ function processGradientArgs (func, argsStr) {
42
+ const args = splitGradientArgs(argsStr);
43
+ const functionLower = func.toLowerCase();
44
+
45
+ if (functionLower.includes('linear')) {
46
+ if (args.length > 1) {
47
+ const firstDirection = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
48
+ if (firstDirection === 'to bottom' || firstDirection === '180deg') {
49
+ args.shift();
50
+ } else if (firstDirection === 'to top') {
51
+ args[0] = '0deg';
52
+ } else if (firstDirection === 'to right') {
53
+ args[0] = '90deg';
54
+ } else if (firstDirection === 'to left') {
55
+ args[0] = '270deg';
56
+ } else if (firstDirection === 'to top right' || firstDirection === 'to right top') {
57
+ args[0] = '45deg';
58
+ } else if (firstDirection === 'to bottom right' || firstDirection === 'to right bottom') {
59
+ args[0] = '135deg';
60
+ } else if (firstDirection === 'to bottom left' || firstDirection === 'to left bottom') {
61
+ args[0] = '225deg';
62
+ } else if (firstDirection === 'to top left' || firstDirection === 'to left top') {
63
+ args[0] = '315deg';
64
+ }
65
+ }
66
+ } else if (functionLower.includes('radial')) {
67
+ if (args.length > 1) {
68
+ const firstShape = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
69
+ if (firstShape === 'ellipse at center' || firstShape === 'circle at center') {
70
+ args.shift();
71
+ }
72
+ }
73
+ }
74
+
75
+ if (args.length > 0) {
76
+ // Remove default 0% stop position from the first gradient stop
77
+ args[0] = args[0].replace(/^(.*\S)\s+0%$/, '$1');
78
+ // Remove default 100% stop position from the last gradient stop
79
+ args[args.length - 1] = args[args.length - 1].replace(/^(.*\S)\s+100%$/, '$1');
80
+ }
81
+
82
+ return args.join(',');
83
+ }
84
+
85
+ /**
86
+ * Finds and minifies all gradient function calls within a CSS value string, applying argument optimization to each one.
87
+ *
88
+ * @param {string} value The CSS value string potentially containing gradient functions.
89
+ * @return {string} The value string with all gradient calls minified.
90
+ */
91
+ function minifyGradients (value) {
92
+ let result = '';
93
+ let position = 0;
94
+ while (position < value.length) {
95
+ const rest = value.slice(position);
96
+ // Match gradient function names: linear-gradient, radial-gradient, conic-gradient, and their repeating- variants
97
+ const gradientMatch = rest.match(/^((?:repeating-)?(?:linear|radial|conic)-gradient)\(/i);
98
+ if (gradientMatch) {
99
+ const func = gradientMatch[1];
100
+ let depth = 1;
101
+ let end = position + func.length + 1;
102
+ while (end < value.length && depth > 0) {
103
+ if (value[end] === '(') {
104
+ depth++;
105
+ } else if (value[end] === ')') {
106
+ depth--;
107
+ }
108
+ end++;
109
+ }
110
+ const argsStr = value.slice(position + func.length + 1, end - 1);
111
+ result += func + '(' + processGradientArgs(func, argsStr) + ')';
112
+ position = end;
113
+ } else {
114
+ result += value[position];
115
+ position++;
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+
121
+ export { minifyGradients };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @file Simplifies CSS math functions (calc, min, max) by folding constant expressions, flattening nested calcs, and converting absolute lengths to pixels.
3
+ */
4
+
5
+ import { calc } from '@csstools/css-calc';
6
+
7
+ import {
8
+ convertAbsoluteLengthToPx,
9
+ formatDimension,
10
+ roundCompactNumber
11
+ } from './shared.js';
12
+
13
+ /**
14
+ * Attempts to simplify a calc() expression by combining like-unit terms and evaluating pure arithmetic, returning the simplified string or null if folding is not possible.
15
+ *
16
+ * @param {string} expression The expression inside calc() to attempt folding.
17
+ * @return {string|null} The simplified expression, or null if it cannot be folded.
18
+ */
19
+ function tryFoldCalcExpression (expression) {
20
+ let expr = expression.trim();
21
+ let previous;
22
+
23
+ do {
24
+ previous = expr;
25
+ // Remove innermost non-nested parentheses (flatten simple grouping)
26
+ expr = expr.replace(/\(([^()]+)\)/g, '$1');
27
+ // Fold: <number> * <number><unit> → computed result in same unit
28
+ expr = expr.replace(/(-?(?:\d*\.\d+|\d+))\s*\*\s*(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)/gi, (match, a, b, unit) => {
29
+ return formatDimension(parseFloat(a) * parseFloat(b), unit);
30
+ });
31
+ // Fold: <number><unit> * <number> → computed result in same unit
32
+ expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)\s*\*\s*(-?(?:\d*\.\d+|\d+))/gi, (match, a, unit, b) => {
33
+ return formatDimension(parseFloat(a) * parseFloat(b), unit);
34
+ });
35
+ // Fold: <number><unit> / <number> → computed result in same unit
36
+ expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q)\s*\/\s*(-?(?:\d*\.\d+|\d+))/gi, (match, value, unit, divisor) => {
37
+ return formatDimension(parseFloat(value) / parseFloat(divisor), unit);
38
+ });
39
+ // Collapse zero-with-unit terms (e.g. 0px, 0%) to plain 0 or remove them
40
+ expr = expr.replace(/(^|[+-])\s*0(?:px|pt|pc|in|cm|mm|q|%)\b/g, (match, sign) => {
41
+ if (sign && sign !== '+') {
42
+ return sign + ' 0';
43
+ }
44
+ return '';
45
+ });
46
+ // Remove additive zero terms
47
+ expr = expr.replace(/\+\s*0\b/g, '');
48
+ // Remove subtractive zero terms
49
+ expr = expr.replace(/-\s*0\b/g, '');
50
+ // Collapse whitespace
51
+ expr = expr.replace(/\s+/g, ' ').trim();
52
+ } while (expr !== previous);
53
+
54
+ // Simplify trivial identity division: 1 / 1 / <dimension> → <dimension>
55
+ if (/^1\s*\/\s*1\s*\/\s*(-?(?:\d*\.\d+|\d+)(?:px|pt|pc|in|cm|mm|q))$/i.test(expr)) {
56
+ return expr.replace(/^1\s*\/\s*1\s*\/\s*/i, '');
57
+ }
58
+
59
+ // Remove all whitespace for validation
60
+ const normalized = expr.replace(/\s+/g, '');
61
+ // Validate that the expression is a simple sequence of signed terms with optional units
62
+ if (!/^[+-]?(?:\d*\.\d+|\d+)(?:[a-z%]+)?(?:[+-](?:\d*\.\d+|\d+)(?:[a-z%]+)?)*$/i.test(normalized)) {
63
+ return null;
64
+ }
65
+
66
+ // Extract each signed term with its optional unit
67
+ const terms = normalized.match(/[+-]?(?:\d*\.\d+|\d+)(?:[a-z%]+)?/gi) || [];
68
+ const totals = new Map();
69
+
70
+ for (const term of terms) {
71
+ // Parse each term into sign, number, and unit parts
72
+ const match = term.match(/^([+-]?)(\d*\.\d+|\d+)([a-z%]+)?$/i);
73
+ if (!match) {
74
+ return null;
75
+ }
76
+ const [, sign, rawNumber, rawUnit = ''] = match;
77
+ let number = parseFloat(rawNumber) * (sign === '-' ? -1 : 1);
78
+ let unit = rawUnit.toLowerCase();
79
+
80
+ if (unit && unit !== '%' && unit !== 'px') {
81
+ const pxValue = convertAbsoluteLengthToPx(number, unit);
82
+ if (pxValue === null) {
83
+ return null;
84
+ }
85
+ number = pxValue;
86
+ unit = 'px';
87
+ }
88
+
89
+ totals.set(unit, (totals.get(unit) || 0) + number);
90
+ }
91
+
92
+ const orderedUnits = ['%', '', 'px', ...[...totals.keys()].filter((unit) => {
93
+ return !['%', '', 'px'].includes(unit);
94
+ }).sort()];
95
+ const outputTerms = [];
96
+
97
+ for (const unit of orderedUnits) {
98
+ if (!totals.has(unit)) {
99
+ continue;
100
+ }
101
+ const value = totals.get(unit);
102
+ if (Math.abs(value) < 1e-12) {
103
+ continue;
104
+ }
105
+ outputTerms.push({ unit, value });
106
+ }
107
+
108
+ if (!outputTerms.length) {
109
+ return '0';
110
+ }
111
+
112
+ if (outputTerms.length === 1) {
113
+ const { unit, value } = outputTerms[0];
114
+ if (unit) {
115
+ return roundCompactNumber(value) + unit;
116
+ }
117
+ return roundCompactNumber(value);
118
+ }
119
+
120
+ const [first, ...rest] = outputTerms;
121
+ let result = roundCompactNumber(first.value) + first.unit;
122
+ for (const term of rest) {
123
+ const sign = term.value < 0 ? '-' : '+';
124
+ result += ' ' + sign + ' ' + roundCompactNumber(Math.abs(term.value)) + term.unit;
125
+ }
126
+ return 'calc(' + result + ')';
127
+ }
128
+
129
+ /**
130
+ * Simplifies calc(), min(), and max() expressions within a CSS value string using the `@csstools`/css-calc library, falling back to the original value on failure.
131
+ *
132
+ * @param {string} value The CSS value string containing math functions to simplify.
133
+ * @param {string} property The CSS property name, used for context-aware simplification.
134
+ * @param {string} originalValue The original unmodified value to fall back to if simplification produces an invalid result.
135
+ * @return {string} The value with math functions simplified where possible.
136
+ */
137
+ function normalizeMathFunctions (value, property, originalValue = '') {
138
+ let result = value;
139
+
140
+ // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
141
+ result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, (match, inner) => {
142
+ return inner.trim();
143
+ });
144
+ // Flatten calc(calc(a) ± b) → calc(a ± b)
145
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*([+-])\s*([^()]+)\s*\)/gi, (match, inner, operator, tail) => {
146
+ return 'calc(' + inner + ' ' + operator + ' ' + tail + ')';
147
+ });
148
+ // Unwrap calc(calc(x)) → calc(x)
149
+ result = result.replace(/calc\(\s*calc\(([^()]+)\)\s*\)/gi, (match, inner) => {
150
+ return 'calc(' + inner + ')';
151
+ });
152
+
153
+ // Simplify min()/max() expressions using @csstools/css-calc
154
+ result = result.replace(/\b(min|max)\(([^()]+)\)/gi, (match) => {
155
+ try {
156
+ const simplified = calc(match);
157
+ return typeof simplified === 'string' ? simplified : match;
158
+ } catch {
159
+ return match;
160
+ }
161
+ });
162
+
163
+ // Simplify calc() expressions using constant folding and @csstools/css-calc
164
+ result = result.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
165
+ // Collapse whitespace inside calc expression
166
+ const compactInner = inner.replace(/\s+/g, ' ').trim();
167
+ // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
168
+ if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(compactInner)) {
169
+ // Remove whitespace around multiplication/division operators
170
+ return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
171
+ }
172
+ // Preserve percent/number division expressions (e.g. 100%/3) — just strip inner spaces
173
+ if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
174
+ // Remove whitespace around division operator
175
+ return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
176
+ }
177
+
178
+ const folded = tryFoldCalcExpression(compactInner);
179
+ if (folded) {
180
+ return folded;
181
+ }
182
+
183
+ try {
184
+ const simplified = calc(match);
185
+ if (typeof simplified !== 'string') {
186
+ return match;
187
+ }
188
+ // If simplified to a bare percentage but original had division, preserve the calc form
189
+ if (/^-?(?:\d+|\d*\.\d+)%$/.test(simplified) && /%\s*\//.test(match)) {
190
+ return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
191
+ }
192
+ return simplified;
193
+ } catch {
194
+ return match;
195
+ }
196
+ });
197
+
198
+ // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
199
+ if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
200
+ // Extract the absolute-length unit from the folded result
201
+ const [, unit] = result.match(/^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i);
202
+ const numeric = parseFloat(result);
203
+ const pxValue = convertAbsoluteLengthToPx(numeric, unit);
204
+ if (pxValue !== null) {
205
+ result = roundCompactNumber(pxValue) + 'px';
206
+ }
207
+ }
208
+
209
+ // Round results with excessive decimal places (4+ digits after the decimal)
210
+ result = result.replace(/(-?(?:\d+|\d*\.\d+)\.\d{4,})([a-z%]+)/gi, (match, number, unit) => {
211
+ return roundCompactNumber(number) + unit;
212
+ });
213
+ return result;
214
+ }
215
+
216
+ /**
217
+ * Simplifies a standalone calc() value by flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
218
+ *
219
+ * @param {string} value The CSS value string that may be a standalone calc() expression.
220
+ * @return {string} The simplified value, or the original value if simplification is not applicable.
221
+ */
222
+ function simplifyStandaloneCalc (value) {
223
+ // Check if value starts with calc( and ends with )
224
+ if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
225
+ return value;
226
+ }
227
+ let inner = value.slice(5, -1).trim();
228
+
229
+ // Preserve percent-times-number expressions, only stripping whitespace around operators
230
+ if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(inner.replace(/\s+/g, ' ').trim())) {
231
+ return 'calc(' + inner.replace(/\s*([*/])\s*/g, '$1') + ')';
232
+ }
233
+
234
+ // If no nested function calls and no multiplication/division involving parens, try flattening
235
+ if (!/[A-Za-z-]+\(/.test(inner) && !/[*/]\s*\(|\)\s*[*/]/.test(inner)) {
236
+ // Remove all parentheses and collapse whitespace for folding
237
+ const flattened = inner.replace(/[()]/g, '').replace(/\s+/g, ' ').trim();
238
+ const foldedFlattened = tryFoldCalcExpression(flattened);
239
+ if (foldedFlattened) {
240
+ return foldedFlattened;
241
+ }
242
+ }
243
+
244
+ let previous;
245
+
246
+ do {
247
+ previous = inner;
248
+ // Fold innermost parenthesized sub-expressions that aren't function calls
249
+ inner = inner.replace(/(^|[^A-Za-z-])\(([^()]+)\)/g, (match, prefix, content) => {
250
+ const trimmed = content.trim();
251
+ const folded = tryFoldCalcExpression(trimmed);
252
+ if (folded) {
253
+ if (folded.startsWith('calc(') && folded.endsWith(')')) {
254
+ return prefix + trimmed;
255
+ }
256
+ return prefix + folded;
257
+ }
258
+ return prefix + trimmed;
259
+ });
260
+ } while (inner !== previous);
261
+
262
+ const folded = tryFoldCalcExpression(inner);
263
+ if (folded) {
264
+ return folded;
265
+ }
266
+
267
+ // Collapse whitespace and check for percent-division expressions
268
+ const compactInner = inner.replace(/\s+/g, ' ').trim();
269
+ // Preserve percent/number division, just strip whitespace around the operator
270
+ if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
271
+ return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
272
+ }
273
+
274
+ // Default: strip whitespace around multiplication/division operators
275
+ return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
276
+ }
277
+
278
+ export {
279
+ normalizeMathFunctions,
280
+ simplifyStandaloneCalc
281
+ };