@thejaredwilcurt/csslop 0.0.16 → 0.0.17

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,336 @@
1
+ /**
2
+ * @file Evaluates and minifies CSS relative color syntax.
3
+ */
4
+
5
+ import {
6
+ parseColor,
7
+ rgbaToHex,
8
+ shortestColor
9
+ } from './colors.js';
10
+ import { parseAlphaString } from './shared.js';
11
+
12
+ /**
13
+ * Handle color(from ...) relative color syntax for simple identity cases.
14
+ *
15
+ * @param {string} expr The color(from ...) expression string.
16
+ * @return {string|null} A hex color string if the relative color is a simple identity transform, or null otherwise.
17
+ */
18
+ function evaluateRelativeColor (expr) {
19
+ // Match: color(from <base-color> srgb r g b [/ <alpha>]) identity transform pattern
20
+ const match = expr.match(/^color\(\s*from\s+(.+?)\s+srgb\s+r\s+g\s+b(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
21
+ if (!match) {
22
+ return null;
23
+ }
24
+ const baseColor = parseColor(match[1]);
25
+ if (!baseColor) {
26
+ return null;
27
+ }
28
+ const alpha = parseAlphaString(match[2], baseColor[3]);
29
+ return rgbaToHex(baseColor[0], baseColor[1], baseColor[2], alpha);
30
+ }
31
+
32
+ /**
33
+ * Canonical channel keyword order for each relative-color function. When the
34
+ * channel expressions in a `<func>(from <color> ...)` match this order exactly,
35
+ * the function reproduces the base color unchanged (an identity transform).
36
+ *
37
+ * @type {{[key: string]: Array<string>}}
38
+ */
39
+ const RELATIVE_COLOR_CHANNELS = {
40
+ rgb: ['r', 'g', 'b'],
41
+ rgba: ['r', 'g', 'b'],
42
+ hsl: ['h', 's', 'l'],
43
+ hsla: ['h', 's', 'l'],
44
+ hwb: ['h', 'w', 'b'],
45
+ lab: ['l', 'a', 'b'],
46
+ oklab: ['l', 'a', 'b'],
47
+ lch: ['l', 'c', 'h'],
48
+ oklch: ['l', 'c', 'h']
49
+ };
50
+
51
+ /**
52
+ * Finds the closing parenthesis matching the opening one at the given index,
53
+ * accounting for nested parentheses.
54
+ *
55
+ * @param {string} value The string being scanned.
56
+ * @param {number} openIndex Index of the opening parenthesis.
57
+ * @return {number} Index of the matching close parenthesis, or -1.
58
+ */
59
+ function findClosingParenthesis (value, openIndex) {
60
+ let depth = 1;
61
+ let index = openIndex + 1;
62
+ while (index < value.length) {
63
+ const character = value[index];
64
+ if (character === '(') {
65
+ depth++;
66
+ } else if (character === ')') {
67
+ depth--;
68
+ if (depth === 0) {
69
+ return index;
70
+ }
71
+ }
72
+ index++;
73
+ }
74
+ return -1;
75
+ }
76
+
77
+ /**
78
+ * Splits a string on top-level whitespace while treating each parenthesized
79
+ * group as part of a single token, keeping nested function arguments intact.
80
+ *
81
+ * @param {string} text The text to split.
82
+ * @return {Array} The list of top-level tokens.
83
+ */
84
+ function splitTopLevelWhitespace (text) {
85
+ const tokens = [];
86
+ let current = '';
87
+ let depth = 0;
88
+ for (let index = 0; index < text.length; index++) {
89
+ const character = text[index];
90
+ if (character === '(') {
91
+ depth++;
92
+ current += character;
93
+ } else if (character === ')') {
94
+ if (depth > 0) {
95
+ depth--;
96
+ }
97
+ current += character;
98
+ // Match any single whitespace character at the top level
99
+ } else if (depth === 0 && /\s/.test(character)) {
100
+ if (current) {
101
+ tokens.push(current);
102
+ current = '';
103
+ }
104
+ } else {
105
+ current += character;
106
+ }
107
+ }
108
+ if (current) {
109
+ tokens.push(current);
110
+ }
111
+ return tokens;
112
+ }
113
+
114
+ /**
115
+ * Splits a relative-color body into its channel portion and its optional alpha
116
+ * portion at the top-level `/` separator.
117
+ *
118
+ * @param {string} body The body of a relative color, after the base color.
119
+ * @return {Array} A two-element array of [channelsPart, alphaPart|null].
120
+ */
121
+ function splitRelativeAlpha (body) {
122
+ let depth = 0;
123
+ for (let index = 0; index < body.length; index++) {
124
+ const character = body[index];
125
+ if (character === '(') {
126
+ depth++;
127
+ } else if (character === ')') {
128
+ if (depth > 0) {
129
+ depth--;
130
+ }
131
+ } else if (character === '/' && depth === 0) {
132
+ return [body.slice(0, index).trim(), body.slice(index + 1).trim()];
133
+ }
134
+ }
135
+ return [body.trim(), null];
136
+ }
137
+
138
+ /**
139
+ * Removes redundant leading zeros from a plain numeric token (e.g. `0.6` → `.6`).
140
+ *
141
+ * @param {string} token A candidate numeric token.
142
+ * @return {string} The token with redundant leading zeros stripped.
143
+ */
144
+ function stripLeadingZeroFromNumber (token) {
145
+ // Match an optional sign, redundant leading zeros, then a decimal fraction
146
+ const match = token.match(/^(-?)0*(\.\d+)$/);
147
+ if (match) {
148
+ return match[1] + match[2];
149
+ }
150
+ return token;
151
+ }
152
+
153
+ /**
154
+ * Determines whether a channel expression is a single value that can safely
155
+ * leave a surrounding calc() wrapper: a channel keyword or a plain number.
156
+ *
157
+ * @param {string} token The simplified channel expression.
158
+ * @return {boolean} Whether the token can stand alone without calc().
159
+ */
160
+ function isBareChannelToken (token) {
161
+ // A run of letters (a channel keyword) or a plain signed number
162
+ return (/^[a-z]+$/i).test(token) || (/^-?(?:\d*\.\d+|\d+)$/).test(token);
163
+ }
164
+
165
+ /**
166
+ * Simplifies a single relative-color channel expression by resolving arithmetic
167
+ * identities inside calc() and unwrapping calc() when it holds a lone value.
168
+ *
169
+ * @param {string} channel The raw channel expression.
170
+ * @return {string} The simplified channel expression.
171
+ */
172
+ function simplifyRelativeChannel (channel) {
173
+ const calcMatch = channel.match(/^calc\((.*)\)$/is);
174
+ if (!calcMatch) {
175
+ return channel;
176
+ }
177
+ let inner = calcMatch[1].trim();
178
+ let previous;
179
+ do {
180
+ previous = inner;
181
+ // Drop multiply-by-one on the right, e.g. "s*1" → "s"
182
+ inner = inner.replace(/\*\s*1(?![\d.])/g, '');
183
+ // Drop multiply-by-one on the left, e.g. "1*s" → "s"
184
+ inner = inner.replace(/(?<![\d.])1\s*\*/g, '');
185
+ // Drop divide-by-one, e.g. "l/1" → "l"
186
+ inner = inner.replace(/\/\s*1(?![\d.])/g, '');
187
+ // Drop an additive or subtractive zero, e.g. "l + 0" or "l - 0" → "l"
188
+ inner = inner.replace(/\s*[+-]\s*0(?![\d.])/g, '');
189
+ // Drop a leading additive zero, e.g. "0 + l" → "l"
190
+ inner = inner.replace(/^0\s*\+\s*/g, '');
191
+ inner = inner.trim();
192
+ } while (inner !== previous);
193
+ if (isBareChannelToken(inner)) {
194
+ return inner;
195
+ }
196
+ return 'calc(' + inner + ')';
197
+ }
198
+
199
+ /**
200
+ * Determines whether simplified channel expressions match the canonical channel
201
+ * keyword order, indicating an identity transform.
202
+ *
203
+ * @param {Array} channels The simplified channel expressions.
204
+ * @param {Array} canonical The canonical channel keyword order.
205
+ * @return {boolean} Whether the channels are an identity pass-through.
206
+ */
207
+ function channelsMatchCanonical (channels, canonical) {
208
+ if (channels.length !== canonical.length) {
209
+ return false;
210
+ }
211
+ for (let index = 0; index < channels.length; index++) {
212
+ if (channels[index].toLowerCase() !== canonical[index]) {
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Resolves an identity relative color to its shortest concrete representation
221
+ * when the base color can be parsed to concrete channel values.
222
+ *
223
+ * @param {string} baseColor The base color token.
224
+ * @param {string|null} alpha The alpha token, or null when absent.
225
+ * @return {string|null} The shortest color string, or null when unresolvable.
226
+ */
227
+ function resolveRelativeIdentity (baseColor, alpha) {
228
+ const parsed = parseColor(baseColor);
229
+ if (!parsed) {
230
+ return null;
231
+ }
232
+ let alphaValue = parsed[3];
233
+ if (alpha !== null) {
234
+ alphaValue = alpha.endsWith('%') ? parseFloat(alpha) / 100 : parseFloat(alpha);
235
+ if (Number.isNaN(alphaValue)) {
236
+ return null;
237
+ }
238
+ }
239
+ return shortestColor(parsed[0], parsed[1], parsed[2], alphaValue);
240
+ }
241
+
242
+ /**
243
+ * Joins the parts of a relative color back together, omitting whitespace after a
244
+ * closing parenthesis (which already delimits adjacent tokens) and appending the
245
+ * alpha value after a `/` when present.
246
+ *
247
+ * @param {Array} parts The ordered parts, starting with the `from` keyword.
248
+ * @param {string|null} alpha The alpha token, or null when absent.
249
+ * @return {string} The joined relative-color body.
250
+ */
251
+ function joinRelativeColorParts (parts, alpha) {
252
+ let result = parts[0];
253
+ for (let index = 1; index < parts.length; index++) {
254
+ if (result.endsWith(')')) {
255
+ result += parts[index];
256
+ } else {
257
+ result += ' ' + parts[index];
258
+ }
259
+ }
260
+ if (alpha !== null) {
261
+ result += '/' + alpha;
262
+ }
263
+ return result;
264
+ }
265
+
266
+ /**
267
+ * Rewrites a single relative-color function body to its shortest form, resolving
268
+ * identity transforms to a concrete color and otherwise minifying whitespace and
269
+ * numeric tokens.
270
+ *
271
+ * @param {string} functionName The lowercased color function name.
272
+ * @param {string} inner The text between the function parentheses.
273
+ * @return {string} The minified relative-color function string.
274
+ */
275
+ function rewriteRelativeColor (functionName, inner) {
276
+ // Strip the leading `from` keyword, allowing it to abut the base color
277
+ const body = inner.replace(/^\s*from\b\s*/i, '').trim();
278
+ const [channelsPart, alphaRaw] = splitRelativeAlpha(body);
279
+ const tokens = splitTopLevelWhitespace(channelsPart);
280
+ if (tokens.length < 2) {
281
+ return functionName + '(' + inner + ')';
282
+ }
283
+ const baseColor = tokens[0];
284
+ const channels = tokens.slice(1).map(simplifyRelativeChannel);
285
+ const alpha = alphaRaw === null ? null : stripLeadingZeroFromNumber(alphaRaw);
286
+
287
+ const canonicalChannels = RELATIVE_COLOR_CHANNELS[functionName];
288
+ if (canonicalChannels && channelsMatchCanonical(channels, canonicalChannels)) {
289
+ const identity = resolveRelativeIdentity(baseColor, alpha);
290
+ if (identity) {
291
+ return identity;
292
+ }
293
+ }
294
+
295
+ const parts = ['from', baseColor, ...channels];
296
+ return functionName + '(' + joinRelativeColorParts(parts, alpha) + ')';
297
+ }
298
+
299
+ /**
300
+ * Minifies every relative-color function (`<func>(from ...)`) in a CSS value,
301
+ * resolving identity transforms and collapsing redundant whitespace. The
302
+ * `color(from ...)` form is intentionally left to `evaluateRelativeColor`.
303
+ *
304
+ * @param {string} value The CSS value string potentially containing relative colors.
305
+ * @return {string} The value with relative colors minified.
306
+ */
307
+ function minifyRelativeColorSyntax (value) {
308
+ let result = '';
309
+ let index = 0;
310
+ while (index < value.length) {
311
+ // Match a relative-color function name immediately followed by "(from"
312
+ const match = value.slice(index).match(/^(rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*from\b/i);
313
+ const previousCharacter = index > 0 ? value[index - 1] : '';
314
+ // Skip matches that are part of a longer identifier (e.g. the "lch" in "oklch")
315
+ const precededByIdentifier = (/[a-z0-9-]/i).test(previousCharacter);
316
+ if (match && !precededByIdentifier) {
317
+ const functionName = match[1].toLowerCase();
318
+ const openParenIndex = index + match[1].length;
319
+ const closingParenIndex = findClosingParenthesis(value, openParenIndex);
320
+ if (closingParenIndex !== -1) {
321
+ const inner = value.slice(openParenIndex + 1, closingParenIndex);
322
+ result += rewriteRelativeColor(functionName, inner);
323
+ index = closingParenIndex + 1;
324
+ continue;
325
+ }
326
+ }
327
+ result += value[index];
328
+ index++;
329
+ }
330
+ return result;
331
+ }
332
+
333
+ export {
334
+ evaluateRelativeColor,
335
+ minifyRelativeColorSyntax
336
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file Provides syntax-aware CSS string scanning utilities.
3
+ */
4
+
5
+ /**
6
+ * Finds the closing parenthesis for an opening parenthesis while respecting
7
+ * nested parentheses and quoted strings.
8
+ *
9
+ * @param {string} value The CSS text being scanned.
10
+ * @param {number} openParenIndex The index of the opening `(` character.
11
+ * @return {number} The closing `)` index, or -1 if unmatched.
12
+ */
13
+ function findMatchingParenthesis (value, openParenIndex) {
14
+ let depth = 1;
15
+ let index = openParenIndex + 1;
16
+ let activeQuote = '';
17
+
18
+ while (index < value.length) {
19
+ const character = value[index];
20
+ if (activeQuote) {
21
+ if (character === '\\') {
22
+ index += 2;
23
+ continue;
24
+ }
25
+ if (character === activeQuote) {
26
+ activeQuote = '';
27
+ }
28
+ index++;
29
+ continue;
30
+ }
31
+
32
+ if (character === '"' || character === '\'') {
33
+ activeQuote = character;
34
+ index++;
35
+ continue;
36
+ }
37
+
38
+ if (character === '(') {
39
+ depth++;
40
+ index++;
41
+ continue;
42
+ }
43
+
44
+ if (character === ')') {
45
+ depth--;
46
+ if (depth === 0) {
47
+ return index;
48
+ }
49
+ }
50
+ index++;
51
+ }
52
+
53
+ return -1;
54
+ }
55
+
56
+ export { findMatchingParenthesis };