@thejaredwilcurt/csslop 0.0.48 → 0.0.50

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.
Files changed (61) hide show
  1. package/README.md +3 -4
  2. package/package.json +3 -3
  3. package/src/declarations/border.js +1 -1
  4. package/src/declarations/css-wide-keywords.js +37 -1
  5. package/src/declarations/fold.js +13 -3
  6. package/src/declarations/process.js +60 -7
  7. package/src/index.js +12 -7
  8. package/src/parser/source-search.js +6 -0
  9. package/src/rules/optimize/at-rules.js +113 -0
  10. package/src/rules/optimize/declaration-writes.js +70 -0
  11. package/src/rules/optimize/empty-rules.js +51 -0
  12. package/src/rules/optimize/expand-nesting.js +236 -0
  13. package/src/rules/optimize/factor-parents.js +343 -0
  14. package/src/rules/optimize/index.js +71 -0
  15. package/src/rules/optimize/merge-declarations.js +199 -0
  16. package/src/rules/optimize/merge-nested-rules.js +117 -0
  17. package/src/rules/optimize/merge-selectors.js +252 -0
  18. package/src/rules/optimize/nest-rules.js +202 -0
  19. package/src/rules/optimize/override-index.js +264 -0
  20. package/src/rules/optimize/prune-overridden.js +267 -0
  21. package/src/rules/optimize/selector-normalization.js +60 -0
  22. package/src/rules/optimize/specificity.js +280 -0
  23. package/src/{position-try.js → rules/position-try.js} +1 -1
  24. package/src/rules/selectors.js +9 -0
  25. package/src/rules/stringify.js +138 -76
  26. package/src/utilities.js +9 -8
  27. package/src/value/border.js +85 -0
  28. package/src/value/{colors.js → color/conversion.js} +3 -2
  29. package/src/value/color/functions.js +463 -0
  30. package/src/value/{light-dark.js → color/light-dark.js} +1 -1
  31. package/src/value/{color-mix.js → color/mix.js} +3 -3
  32. package/src/value/{relative-colors.js → color/relative.js} +3 -3
  33. package/src/value/color/shorten.js +93 -0
  34. package/src/value/gradients.js +1 -1
  35. package/src/value/hyphenate-limit-chars.js +72 -0
  36. package/src/value/initial-keywords.js +128 -0
  37. package/src/value/lengths.js +55 -0
  38. package/src/value/math/evaluate.js +36 -0
  39. package/src/value/math/fold.js +315 -0
  40. package/src/value/math/format.js +230 -0
  41. package/src/value/math/functions.js +302 -0
  42. package/src/value/math/index.js +219 -0
  43. package/src/value/math/parser.js +301 -0
  44. package/src/value/math/property-ranges.js +174 -0
  45. package/src/value/math/rewrite.js +222 -0
  46. package/src/value/math/tokenizer.js +160 -0
  47. package/src/value/math/unit-totals.js +194 -0
  48. package/src/value/minify.js +162 -1678
  49. package/src/value/numbers.js +28 -0
  50. package/src/value/positions.js +152 -0
  51. package/src/value/property-optimizations.js +485 -0
  52. package/src/value/segments.js +74 -0
  53. package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
  54. package/src/value/{svg-markup.js → svg/markup.js} +1 -1
  55. package/src/value/time.js +75 -0
  56. package/src/value/urls.js +242 -0
  57. package/src/value/whitespace.js +88 -0
  58. package/src/rules/optimize.js +0 -2031
  59. package/src/value/math-expression.js +0 -1156
  60. package/src/value/math.js +0 -886
  61. /package/src/value/{named-colors.js → color/named.js} +0 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file Splits a CSS value into the literal segments that hold data, quoted strings and `url()` tokens, and the syntax between them, so rewrites only ever reach the syntax.
3
+ */
4
+
5
+ import {
6
+ skipParenthesizedGroup,
7
+ skipQuotedString,
8
+ startsUrlToken
9
+ } from '../parser/source-search.js';
10
+
11
+ /**
12
+ * @typedef {object} ValueSegment
13
+ * @property {string} text The segment's slice of the value.
14
+ * @property {boolean} isLiteral Whether the segment is a quoted string or a url() token.
15
+ */
16
+
17
+ /**
18
+ * Splits a CSS value into literal and syntax segments. A quoted string and a
19
+ * `url()` token are literals: they hold data rather than CSS syntax, so no pass
20
+ * may rewrite what is inside them. Everything between the literals is syntax.
21
+ *
22
+ * @param {string} value The full CSS value string.
23
+ * @return {Array} The value's segments, in order.
24
+ */
25
+ function splitValueSegments (value) {
26
+ const segments = [];
27
+ let index = 0;
28
+
29
+ while (index < value.length) {
30
+ if (value[index] === '"' || value[index] === '\'') {
31
+ const end = skipQuotedString(value, index);
32
+ segments.push({ text: value.slice(index, end), isLiteral: true });
33
+ index = end;
34
+ continue;
35
+ }
36
+
37
+ if (startsUrlToken(value, index)) {
38
+ // The `url(` token opens its parenthesized group at its fourth character
39
+ const end = skipParenthesizedGroup(value, index + 3);
40
+ segments.push({ text: value.slice(index, end), isLiteral: true });
41
+ index = end;
42
+ continue;
43
+ }
44
+
45
+ const start = index;
46
+ while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrlToken(value, index)) {
47
+ index++;
48
+ }
49
+ segments.push({ text: value.slice(start, index), isLiteral: false });
50
+ }
51
+
52
+ return segments;
53
+ }
54
+
55
+ /**
56
+ * Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
57
+ *
58
+ * @param {string} value The full CSS value string.
59
+ * @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
60
+ * @return {string} The value with the replacer applied to all eligible segments.
61
+ */
62
+ function replaceOutsideStringsAndUrls (value, replacer) {
63
+ return splitValueSegments(value).map((segment) => {
64
+ if (segment.isLiteral) {
65
+ return segment.text;
66
+ }
67
+ return replacer(segment.text);
68
+ }).join('');
69
+ }
70
+
71
+ export {
72
+ replaceOutsideStringsAndUrls,
73
+ splitValueSegments
74
+ };
@@ -2,7 +2,7 @@
2
2
  * @file Decodes, minifies, and safely reserializes SVG data URLs.
3
3
  */
4
4
 
5
- import { minifySvgMarkup } from './svg-markup.js';
5
+ import { minifySvgMarkup } from './markup.js';
6
6
 
7
7
  const SVG_MEDIA_TYPE = 'data:image/svg+xml';
8
8
 
@@ -6,7 +6,7 @@ import {
6
6
  namedColors,
7
7
  parseColor,
8
8
  rgbaToHex
9
- } from './colors.js';
9
+ } from '../color/conversion.js';
10
10
 
11
11
  const COLOR_ATTRIBUTE_NAMES = new Set([
12
12
  'color',
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @file Rewrites CSS times in whichever of seconds or milliseconds spells them shortest.
3
+ */
4
+
5
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
6
+
7
+ import {
8
+ formatShortestNumber,
9
+ parseNumericParts,
10
+ scaleByPowerOfTen,
11
+ shortenNumericText
12
+ } from './numbers.js';
13
+
14
+ /**
15
+ * The power of ten that separates a time in seconds from the same time in
16
+ * milliseconds, keyed by the lowercased time unit.
17
+ *
18
+ * @type {{[unit: string]: number}}
19
+ */
20
+ const TIME_UNIT_POWERS = {
21
+ s: 0,
22
+ ms: -3
23
+ };
24
+
25
+ /**
26
+ * Rewrites one time token in whichever of seconds or milliseconds spells it
27
+ * shortest, scientific notation included. Every conversion moves the exponent
28
+ * of the exact digits, so no precision is lost however large the time is.
29
+ * Seconds win a tie, and a zero time becomes `0s`.
30
+ *
31
+ * @param {string} text The text of a dimension token.
32
+ * @return {string} The shortest spelling of the same time.
33
+ */
34
+ function shortenTimeToken (text) {
35
+ const parts = parseNumericParts(text);
36
+ const writtenUnit = parts?.unit.toLowerCase();
37
+ if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
38
+ return text;
39
+ }
40
+ if (!parts.digits) {
41
+ return '0s';
42
+ }
43
+ const writtenSpelling = shortenNumericText(text, true);
44
+ const secondsSpelling = formatShortestNumber(
45
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
46
+ true
47
+ );
48
+ const millisecondsSpelling = formatShortestNumber(
49
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
50
+ true
51
+ );
52
+ // Seconds are the conventional unit, so they win a tie with milliseconds
53
+ const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
54
+ return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
55
+ }
56
+
57
+ /**
58
+ * Converts each time in a value between seconds and milliseconds, whichever
59
+ * is the shorter string.
60
+ *
61
+ * @param {string} value The CSS value string potentially containing time values.
62
+ * @return {string} The value with each time in its shortest unit.
63
+ */
64
+ function convertTimesToShortestUnit (value) {
65
+ return tokenizeCssValue(value).map((token) => {
66
+ if (token.type !== 'dimension') {
67
+ return token.text;
68
+ }
69
+ return shortenTimeToken(token.text);
70
+ }).join('');
71
+ }
72
+
73
+ export {
74
+ convertTimesToShortestUnit
75
+ };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @file Minifies `url()` tokens, and temporarily replaces them with markers so generic value rewrites cannot alter the data a URL carries.
3
+ */
4
+
5
+ import {
6
+ findMatchingParenthesis,
7
+ startsUrlToken
8
+ } from '../parser/source-search.js';
9
+
10
+ import { splitValueSegments } from './segments.js';
11
+ import { minifySvgDataUri } from './svg/data-uri.js';
12
+
13
+ /**
14
+ * Escapes the characters an unquoted url() token cannot hold, so the path can
15
+ * drop its quotes. A backslash already escaping a character is left alone, so
16
+ * a path that arrived escaped is never escaped a second time.
17
+ *
18
+ * @param {string} path The url path, without surrounding quotes.
19
+ * @return {string} The path with every bare space and parenthesis escaped.
20
+ */
21
+ function escapeUrlPathCharacters (path) {
22
+ // Match each space and parenthesis that no escaping backslash precedes
23
+ return path.replace(/(?<!\\)([ ()])/g, '\\$1');
24
+ }
25
+
26
+ /**
27
+ * Chooses the shortest valid representation for the path inside a `url(...)`
28
+ * token, weighing an unquoted form — its spaces and parentheses escaped —
29
+ * against a quoted form. Quotation marks can only ever appear in the quoted
30
+ * form, so a path holding one always keeps its wrapper.
31
+ *
32
+ * @param {string} path The resolved url path, without surrounding quotes.
33
+ * @return {string} The shortest valid url() content string.
34
+ */
35
+ function formatUrlPath (path) {
36
+ // Escape any embedded double quotes so the double-quoted wrapper stays valid
37
+ const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
38
+ // Match a quotation mark, which an unquoted url() token cannot hold
39
+ if (/["']/.test(path)) {
40
+ return quotedForm;
41
+ }
42
+ const unquotedForm = escapeUrlPathCharacters(path);
43
+ // Each escaped character costs one backslash, so dropping the two quotation
44
+ // marks pays off whenever at most one character needs escaping
45
+ if (unquotedForm.length < quotedForm.length) {
46
+ return unquotedForm;
47
+ }
48
+ return quotedForm;
49
+ }
50
+
51
+ /**
52
+ * Produces the shortest valid contents for a single `url(...)` token from the
53
+ * raw text between its parentheses, stripping a leading current-directory
54
+ * indicator and normalizing quoting.
55
+ *
56
+ * @param {string} rawContent The trimmed text found between the url parentheses.
57
+ * @return {string} The minified url() content.
58
+ */
59
+ function minifyUrlContent (rawContent) {
60
+ const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
61
+ let path = rawContent;
62
+ if (wasQuoted) {
63
+ const quote = rawContent[0];
64
+ if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
65
+ path = rawContent.slice(1, -1);
66
+ }
67
+ }
68
+
69
+ const minifiedSvgDataUri = minifySvgDataUri(path);
70
+ if (minifiedSvgDataUri) {
71
+ return minifiedSvgDataUri;
72
+ }
73
+
74
+ // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
75
+ path = path.replace(/^\.\//, '');
76
+
77
+ return formatUrlPath(path);
78
+ }
79
+
80
+ /**
81
+ * Builds a marker that cannot appear in a parsed CSS value. URL functions are
82
+ * temporarily replaced with these markers while generic string operations run.
83
+ *
84
+ * @param {number} index The URL token index.
85
+ * @return {string} The marker representing that URL token.
86
+ */
87
+ function createUrlTokenMarker (index) {
88
+ return '\u0000csslop-url-' + index + '\u0000';
89
+ }
90
+
91
+ /**
92
+ * Replaces URL tokens with stable markers so generic value normalization does
93
+ * not alter data carried inside a URL.
94
+ *
95
+ * @param {string} value The CSS value to protect.
96
+ * @return {object} The protected value and the original URL tokens.
97
+ */
98
+ function protectUrlTokens (value) {
99
+ const urlTokens = [];
100
+ let protectedValue = '';
101
+ for (const segment of splitValueSegments(value)) {
102
+ const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
103
+ if (isUrlToken) {
104
+ protectedValue += createUrlTokenMarker(urlTokens.length);
105
+ urlTokens.push(segment.text);
106
+ } else {
107
+ protectedValue += segment.text;
108
+ }
109
+ }
110
+ return { value: protectedValue, urlTokens };
111
+ }
112
+
113
+ /**
114
+ * Restores the URL tokens removed by `protectUrlTokens`.
115
+ *
116
+ * @param {string} value The value containing URL markers.
117
+ * @param {Array} urlTokens The original URL token text.
118
+ * @return {string} The value with URL tokens restored.
119
+ */
120
+ function restoreUrlTokens (value, urlTokens) {
121
+ let restoredValue = value;
122
+ for (let index = 0; index < urlTokens.length; index++) {
123
+ const marker = createUrlTokenMarker(index);
124
+ restoredValue = restoredValue.split(marker).join(urlTokens[index]);
125
+ }
126
+ return restoredValue;
127
+ }
128
+
129
+ /**
130
+ * Removes whitespace after protected URL tokens. A CSS URL token closes itself
131
+ * at `)`, so the following component remains distinguishable without a space.
132
+ *
133
+ * @param {string} value The protected CSS value.
134
+ * @param {Array} urlTokens The original URL token text.
135
+ * @return {string} The protected value without trailing URL spacing.
136
+ */
137
+ function removeWhitespaceAfterUrlTokens (value, urlTokens) {
138
+ let compactValue = value;
139
+ for (let index = 0; index < urlTokens.length; index++) {
140
+ const marker = createUrlTokenMarker(index);
141
+ const markerIndex = compactValue.indexOf(marker);
142
+ if (markerIndex === -1) {
143
+ continue;
144
+ }
145
+ const contentStart = markerIndex + marker.length;
146
+ let nextContentIndex = contentStart;
147
+ while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
148
+ nextContentIndex++;
149
+ }
150
+ compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
151
+ }
152
+ return compactValue;
153
+ }
154
+
155
+ /**
156
+ * Removes an explicit default background position directly after a protected
157
+ * URL marker, where the ordinary close-parenthesis optimization cannot see it.
158
+ *
159
+ * @param {string} value The protected background value.
160
+ * @param {Array} urlTokens The original URL token text.
161
+ * @return {string} The value with redundant URL positions removed.
162
+ */
163
+ function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
164
+ let normalizedValue = value;
165
+ for (let index = 0; index < urlTokens.length; index++) {
166
+ const marker = createUrlTokenMarker(index);
167
+ // Match a URL marker followed by the default 0 0 background-position pair.
168
+ const defaultPositionPattern = new RegExp(
169
+ marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
170
+ 'g'
171
+ );
172
+ normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
173
+ }
174
+ return normalizedValue;
175
+ }
176
+
177
+ /**
178
+ * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
179
+ * skipping any quoted strings so an embedded `url(` inside a string is ignored.
180
+ *
181
+ * @param {string} value The CSS value string potentially containing url() tokens.
182
+ * @return {string} The value with all url() tokens minified.
183
+ */
184
+ function minifyUrls (value) {
185
+ let result = '';
186
+ let index = 0;
187
+
188
+ const consumeQuoted = (start) => {
189
+ const quote = value[start];
190
+ let end = start + 1;
191
+ while (end < value.length) {
192
+ if (value[end] === '\\') {
193
+ end += 2;
194
+ continue;
195
+ }
196
+ if (value[end] === quote) {
197
+ end++;
198
+ break;
199
+ }
200
+ end++;
201
+ }
202
+ return end;
203
+ };
204
+
205
+ const startsUrl = (start) => {
206
+ return value.slice(start, start + 4).toLowerCase() === 'url(';
207
+ };
208
+
209
+ while (index < value.length) {
210
+ if (value[index] === '"' || value[index] === '\'') {
211
+ const end = consumeQuoted(index);
212
+ result += value.slice(index, end);
213
+ index = end;
214
+ continue;
215
+ }
216
+
217
+ if (startsUrl(index)) {
218
+ const closingParenIndex = findMatchingParenthesis(value, index + 3);
219
+ if (closingParenIndex === -1) {
220
+ result += value.slice(index);
221
+ break;
222
+ }
223
+ const inner = value.slice(index + 4, closingParenIndex).trim();
224
+ result += 'url(' + minifyUrlContent(inner) + ')';
225
+ index = closingParenIndex + 1;
226
+ continue;
227
+ }
228
+
229
+ result += value[index];
230
+ index++;
231
+ }
232
+
233
+ return result;
234
+ }
235
+
236
+ export {
237
+ minifyUrls,
238
+ protectUrlTokens,
239
+ removeDefaultBackgroundPositionsAfterUrlTokens,
240
+ removeWhitespaceAfterUrlTokens,
241
+ restoreUrlTokens
242
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file Normalizes the whitespace, quotes, and Unicode escapes of a CSS value, and restores the whitespace its math operators cannot do without.
3
+ */
4
+
5
+ import { isUnicodeCharset } from '../context.js';
6
+ import { resolveUnicodeEscape } from '../utilities.js';
7
+
8
+ import { replaceOutsideStringsAndUrls } from './segments.js';
9
+ import {
10
+ protectUrlTokens,
11
+ removeWhitespaceAfterUrlTokens,
12
+ restoreUrlTokens
13
+ } from './urls.js';
14
+
15
+ /**
16
+ * Restores the whitespace that a math function requires before a `+` or a `-`
17
+ * operator. Both operators have to be surrounded by whitespace to be read as
18
+ * operators rather than as the sign of the term that follows, and the
19
+ * whitespace before one that trails a closing parenthesis is removed together
20
+ * with the rest of the parenthesis padding.
21
+ *
22
+ * @param {string} value The CSS value string with parenthesis padding removed.
23
+ * @return {string} The value with the operator separator restored.
24
+ */
25
+ function restoreSpaceBeforeMathOperators (value) {
26
+ return replaceOutsideStringsAndUrls(value, (segment) => {
27
+ // Match a closing parenthesis immediately followed by a `+` or `-` operator
28
+ return segment.replace(/\)(?=[+-])/g, ') ');
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
34
+ *
35
+ * @param {string} value The raw CSS value string to normalize.
36
+ * @param {string} property The CSS property name, used for context-aware quote handling.
37
+ * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
38
+ */
39
+ function normalizeWhitespaceAndQuotes (value, property) {
40
+ const protectedUrls = protectUrlTokens(value);
41
+ value = protectedUrls.value;
42
+
43
+ // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
44
+ // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
45
+ if (isUnicodeCharset()) {
46
+ value = value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
47
+ return resolveUnicodeEscape(hex) ?? match;
48
+ });
49
+ }
50
+ // Normalize single-quoted strings to double-quoted
51
+ value = value.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
52
+
53
+ // Remove space between string literals
54
+ value = value.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
55
+
56
+ // Whitespace minification
57
+ value = value.replace(/\s*!\s*important/i, '!important');
58
+ // value = value.replace(/\s*([+*/=])\s*/g, '$1');
59
+ // Strip whitespace around commas
60
+ value = value.replace(/\s*([,])\s*/g, '$1');
61
+ // Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
62
+ value = value.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, quotedString, parenthesis) => {
63
+ if (quotedString) {
64
+ return quotedString;
65
+ }
66
+ return parenthesis;
67
+ });
68
+
69
+ // Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
70
+ if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
71
+ // Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
72
+ value = value.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
73
+ // Keep quotes around CSS generic font-family keywords — unquoted they mean something different
74
+ if (property === 'font-family' && /^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui|math|emoji|fangsong|ui-serif|ui-sans-serif|ui-monospace|ui-rounded)$/i.test(inner)) {
75
+ return before + '"' + inner + '"';
76
+ }
77
+ return before + inner;
78
+ });
79
+ }
80
+
81
+ value = removeWhitespaceAfterUrlTokens(value, protectedUrls.urlTokens);
82
+ return restoreUrlTokens(value, protectedUrls.urlTokens);
83
+ }
84
+
85
+ export {
86
+ normalizeWhitespaceAndQuotes,
87
+ restoreSpaceBeforeMathOperators
88
+ };