@thejaredwilcurt/csslop 0.0.37 → 0.0.39

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,330 @@
1
+ /**
2
+ * @file Minifies the `:lang()` pseudo-class, by writing each language-code in
3
+ * its shortest valid form and by combining selectors that differ only in the
4
+ * language-code they ask for.
5
+ */
6
+
7
+ import {
8
+ findMatchingParenthesis,
9
+ skipParenthesizedGroup,
10
+ splitTopLevel
11
+ } from '../parser/source-search.js';
12
+
13
+ import { findNextFunctionCallOutsideStrings } from './selectors.js';
14
+
15
+ /**
16
+ * The `:lang()` pseudo-class opening, as it is written in minified output.
17
+ * Pseudo-class names are case-insensitive, so the scan that looks for this
18
+ * token ignores case, while everything written back out uses this spelling.
19
+ *
20
+ * @type {string}
21
+ */
22
+ const LANGUAGE_FUNCTION = ':lang(';
23
+
24
+ /**
25
+ * Matches a backslash escape of a character that cannot be read as the start
26
+ * of a hexadecimal code point escape, and captures the character it escapes.
27
+ *
28
+ * @type {RegExp}
29
+ */
30
+ const SINGLE_CHARACTER_ESCAPE = /\\([^0-9a-fA-F])/g;
31
+
32
+ /**
33
+ * Matches a backslash followed by a hexadecimal digit, which starts a code
34
+ * point escape whose meaning cannot be judged without decoding it.
35
+ *
36
+ * @type {RegExp}
37
+ */
38
+ const CODE_POINT_ESCAPE = /\\[0-9a-fA-F]/;
39
+
40
+ /**
41
+ * Matches a language-code holding a character that would end the code, or the
42
+ * selector, if the code were written without quotes or escapes.
43
+ *
44
+ * @type {RegExp}
45
+ */
46
+ const UNWRITABLE_CHARACTER = /[\s"'()\\]/;
47
+
48
+ /**
49
+ * Matches a language-code whose first characters read as the beginning of a
50
+ * number, which no unquoted identifier may do.
51
+ *
52
+ * @type {RegExp}
53
+ */
54
+ const NUMERIC_START = /^-?[0-9]/;
55
+
56
+ /**
57
+ * Matches a single character an identifier may hold as written.
58
+ *
59
+ * @type {RegExp}
60
+ */
61
+ const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
62
+
63
+ /**
64
+ * The first code point that is an identifier character in its own right, so
65
+ * that letters beyond ASCII never need escaping.
66
+ *
67
+ * @type {number}
68
+ */
69
+ const FIRST_NON_ASCII_CODE_POINT = 0x80;
70
+
71
+ /**
72
+ * Reports whether a character has to be escaped to survive in an unquoted
73
+ * language-code.
74
+ *
75
+ * @param {string} character A single character of a language-code.
76
+ * @return {boolean} True when the character needs a backslash in front of it.
77
+ */
78
+ function requiresEscape (character) {
79
+ return (
80
+ character.codePointAt(0) < FIRST_NON_ASCII_CODE_POINT &&
81
+ !IDENTIFIER_CHARACTER.test(character)
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Reads the plain language-code out of one `:lang()` argument, stripping the
87
+ * quotes or escapes that were only there to let it be written. Arguments this
88
+ * minifier cannot read with certainty, such as one holding a code point
89
+ * escape, report no code so they are left exactly as their author wrote them.
90
+ *
91
+ * @param {string} argument One argument of a `:lang()`, as written.
92
+ * @return {string|null} The language-code itself, or null when it cannot be read.
93
+ */
94
+ function readLanguageCode (argument) {
95
+ const text = argument.trim();
96
+ if (!text || CODE_POINT_ESCAPE.test(text)) {
97
+ return null;
98
+ }
99
+ const firstCharacter = text[0];
100
+ const isQuoted = (
101
+ (firstCharacter === '"' || firstCharacter === '\'') &&
102
+ text.length > 1 &&
103
+ text.endsWith(firstCharacter)
104
+ );
105
+ let code = text;
106
+ if (isQuoted) {
107
+ code = text.slice(1, -1);
108
+ }
109
+ code = code.replace(SINGLE_CHARACTER_ESCAPE, '$1');
110
+ if (!code || UNWRITABLE_CHARACTER.test(code)) {
111
+ return null;
112
+ }
113
+ return code;
114
+ }
115
+
116
+ /**
117
+ * Writes a language-code in the shortest form a browser still reads as that
118
+ * same code. A code of plain identifier characters needs nothing around it; a
119
+ * code holding others is either escaped character by character or wrapped in
120
+ * quotes once, whichever costs fewer characters.
121
+ *
122
+ * @param {string} code The language-code itself.
123
+ * @return {string} The shortest way to write the code inside a `:lang()`.
124
+ */
125
+ function formatLanguageCode (code) {
126
+ const quoted = '"' + code + '"';
127
+ // A leading digit can only be escaped as a code point, which is never short
128
+ if (NUMERIC_START.test(code)) {
129
+ return quoted;
130
+ }
131
+ const escaped = [...code].map((character) => {
132
+ if (requiresEscape(character)) {
133
+ return '\\' + character;
134
+ }
135
+ return character;
136
+ }).join('');
137
+ if (escaped.length <= quoted.length) {
138
+ return escaped;
139
+ }
140
+ return quoted;
141
+ }
142
+
143
+ /**
144
+ * Rewrites the argument list of one `:lang()`, lowercasing every code because
145
+ * language-codes are matched case-insensitively (one casing throughout a
146
+ * stylesheet compresses better), dropping repeated codes, and writing each
147
+ * remaining code in its shortest form.
148
+ *
149
+ * @param {string} argumentList The text between the parentheses of a `:lang()`.
150
+ * @return {string|null} The minified argument list, or null when an argument could not be read.
151
+ */
152
+ function minifyLanguageArguments (argumentList) {
153
+ const codes = [];
154
+ for (const argument of splitTopLevel(argumentList, ',')) {
155
+ const code = readLanguageCode(argument);
156
+ if (code === null) {
157
+ return null;
158
+ }
159
+ codes.push(code.toLowerCase());
160
+ }
161
+ return [...new Set(codes)].map(formatLanguageCode).join(',');
162
+ }
163
+
164
+ /**
165
+ * Locates every `:lang()` of a selector, reporting where each one starts,
166
+ * where its argument list starts, and where it ends.
167
+ *
168
+ * @param {string} selector A minified CSS selector string.
169
+ * @return {Array} One entry per `:lang()`, holding its `startIndex`, `openIndex`, and `closeIndex`.
170
+ */
171
+ function findLanguageFunctions (selector) {
172
+ const languageFunctions = [];
173
+ let position = 0;
174
+ while (position < selector.length) {
175
+ const startIndex = findNextFunctionCallOutsideStrings(selector, LANGUAGE_FUNCTION, position);
176
+ if (startIndex === -1) {
177
+ break;
178
+ }
179
+ const openIndex = startIndex + LANGUAGE_FUNCTION.length - 1;
180
+ const closeIndex = findMatchingParenthesis(selector, openIndex);
181
+ if (closeIndex === -1) {
182
+ break;
183
+ }
184
+ languageFunctions.push({
185
+ closeIndex,
186
+ openIndex,
187
+ startIndex
188
+ });
189
+ position = closeIndex + 1;
190
+ }
191
+ return languageFunctions;
192
+ }
193
+
194
+ /**
195
+ * Minifies every `:lang()` a selector holds.
196
+ *
197
+ * @param {string} selector A minified CSS selector string.
198
+ * @return {string} The selector with each `:lang()` written in its shortest form.
199
+ */
200
+ function minifyLanguageSelector (selector) {
201
+ const languageFunctions = findLanguageFunctions(selector);
202
+ if (!languageFunctions.length) {
203
+ return selector;
204
+ }
205
+ let result = '';
206
+ let index = 0;
207
+ for (const languageFunction of languageFunctions) {
208
+ const {
209
+ closeIndex,
210
+ openIndex,
211
+ startIndex
212
+ } = languageFunction;
213
+ const minifiedArguments = minifyLanguageArguments(selector.slice(openIndex + 1, closeIndex));
214
+ result += selector.slice(index, startIndex);
215
+ if (minifiedArguments === null) {
216
+ result += selector.slice(startIndex, closeIndex + 1);
217
+ } else {
218
+ result += LANGUAGE_FUNCTION + minifiedArguments + ')';
219
+ }
220
+ index = closeIndex + 1;
221
+ }
222
+ return result + selector.slice(index);
223
+ }
224
+
225
+ /**
226
+ * Reports whether a position in a selector sits inside a functional
227
+ * pseudo-class, such as the `:lang()` of `:not(:lang(en))`.
228
+ *
229
+ * @param {string} selector A minified CSS selector string.
230
+ * @param {number} index The position to judge.
231
+ * @return {boolean} True when an enclosing parenthesis is still open at that position.
232
+ */
233
+ function isInsideFunction (selector, index) {
234
+ let position = 0;
235
+ while (position < index) {
236
+ if (selector[position] !== '(') {
237
+ position++;
238
+ continue;
239
+ }
240
+ const afterGroupIndex = skipParenthesizedGroup(selector, position);
241
+ if (afterGroupIndex > index) {
242
+ return true;
243
+ }
244
+ position = afterGroupIndex;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ /**
250
+ * Splits a selector into the text around its one and only `:lang()` and the
251
+ * language-codes that `:lang()` asks for. A selector holding no `:lang()`, or
252
+ * more than one, has no single list of codes to vary, and a `:lang()` nested
253
+ * in another functional pseudo-class cannot have codes added to it without
254
+ * changing what the pseudo-class it sits in means.
255
+ *
256
+ * @param {string} selector A minified CSS selector string.
257
+ * @return {object|null} The `prefix`, `codes`, and `suffix` of the selector, or null when it has no lone top-level `:lang()`.
258
+ */
259
+ function splitAroundLanguageCodes (selector) {
260
+ const languageFunctions = findLanguageFunctions(selector);
261
+ if (languageFunctions.length !== 1) {
262
+ return null;
263
+ }
264
+ const {
265
+ closeIndex,
266
+ openIndex,
267
+ startIndex
268
+ } = languageFunctions[0];
269
+ if (isInsideFunction(selector, startIndex)) {
270
+ return null;
271
+ }
272
+ const codes = splitTopLevel(selector.slice(openIndex + 1, closeIndex), ',').map((code) => {
273
+ return code.trim();
274
+ });
275
+ if (codes.some((code) => {
276
+ return !code;
277
+ })) {
278
+ return null;
279
+ }
280
+ return {
281
+ codes,
282
+ prefix: selector.slice(0, startIndex),
283
+ suffix: selector.slice(closeIndex + 1)
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Combines the selectors of one rule that are identical but for the
289
+ * language-codes of a single `:lang()`. A `:lang()` takes a comma-separated
290
+ * list of codes and matches an element written in any of them, so the several
291
+ * selectors say as one what they used to say apart, at the same specificity.
292
+ *
293
+ * @param {Array} selectors The rule's minified selector strings.
294
+ * @return {Array} The selector list with language-only variants combined.
295
+ */
296
+ function combineLanguageSelectors (selectors) {
297
+ const groupsBySurroundings = new Map();
298
+ const result = [];
299
+ for (const selector of selectors) {
300
+ const parts = splitAroundLanguageCodes(selector);
301
+ if (!parts) {
302
+ result.push(selector);
303
+ continue;
304
+ }
305
+ // The prefix and suffix are joined by a character no selector may hold, so
306
+ // that text moving from one side to the other cannot look like a match
307
+ const surroundings = parts.prefix + '\u0000' + parts.suffix;
308
+ const existingGroup = groupsBySurroundings.get(surroundings);
309
+ if (existingGroup) {
310
+ existingGroup.codes.push(...parts.codes);
311
+ continue;
312
+ }
313
+ groupsBySurroundings.set(surroundings, {
314
+ codes: [...parts.codes],
315
+ parts,
316
+ position: result.length
317
+ });
318
+ result.push(selector);
319
+ }
320
+ for (const group of groupsBySurroundings.values()) {
321
+ const codes = [...new Set(group.codes)];
322
+ result[group.position] = group.parts.prefix + LANGUAGE_FUNCTION + codes.join(',') + ')' + group.parts.suffix;
323
+ }
324
+ return result;
325
+ }
326
+
327
+ export {
328
+ combineLanguageSelectors,
329
+ minifyLanguageSelector
330
+ };
@@ -48,6 +48,85 @@ function unescapeSelector (selector) {
48
48
  });
49
49
  }
50
50
 
51
+ /**
52
+ * Matches a single whitespace character.
53
+ *
54
+ * @type {RegExp}
55
+ */
56
+ const WHITESPACE_CHARACTER = /\s/;
57
+
58
+ /**
59
+ * Collapses every run of whitespace in a selector down to the single space a
60
+ * descendant combinator is written with, and trims the ends. Whitespace held
61
+ * inside a quoted string, or written as an escape, is copied through untouched:
62
+ * there it is part of the value being matched rather than a combinator.
63
+ *
64
+ * @param {string} selector The raw selector string.
65
+ * @return {string} The selector with its combinator whitespace normalized.
66
+ */
67
+ function normalizeSelectorWhitespace (selector) {
68
+ let normalized = '';
69
+ let quoteDelimiter = '';
70
+ let index = 0;
71
+ while (index < selector.length) {
72
+ const character = selector[index];
73
+ if (quoteDelimiter) {
74
+ if (character === '\\') {
75
+ normalized += selector.slice(index, index + 2);
76
+ index += 2;
77
+ continue;
78
+ }
79
+ if (character === quoteDelimiter) {
80
+ quoteDelimiter = '';
81
+ }
82
+ normalized += character;
83
+ index++;
84
+ continue;
85
+ }
86
+ if (character === '"' || character === '\'') {
87
+ quoteDelimiter = character;
88
+ normalized += character;
89
+ index++;
90
+ continue;
91
+ }
92
+ if (character === '\\') {
93
+ normalized += selector.slice(index, index + 2);
94
+ index += 2;
95
+ continue;
96
+ }
97
+ if (WHITESPACE_CHARACTER.test(character)) {
98
+ normalized += ' ';
99
+ while (index < selector.length && WHITESPACE_CHARACTER.test(selector[index])) {
100
+ index++;
101
+ }
102
+ continue;
103
+ }
104
+ normalized += character;
105
+ index++;
106
+ }
107
+ return normalized.trim();
108
+ }
109
+
110
+ /**
111
+ * Rewrites every selector in a stylesheet into its whitespace-normalized form,
112
+ * descending into nested rules and the bodies of at-rules. Running this before
113
+ * any rule is rewritten means each later pass compares, nests, and prints
114
+ * selectors that are already written the one canonical way, so a selector that
115
+ * happens to be spaced out in the source cannot leak that spacing into a
116
+ * selector the minifier builds from it.
117
+ *
118
+ * @param {Array} rules The AST rule nodes to normalize.
119
+ */
120
+ function normalizeRuleSelectors (rules) {
121
+ for (const rule of rules || []) {
122
+ if (rule?.selectors?.length) {
123
+ rule.selectors = rule.selectors.map(normalizeSelectorWhitespace);
124
+ }
125
+ normalizeRuleSelectors(rule?.rules);
126
+ normalizeRuleSelectors(rule?.declarations);
127
+ }
128
+ }
129
+
51
130
  /**
52
131
  * Normalizes a `@layer` cascade layer name list by trimming it and removing the
53
132
  * optional whitespace that may surround the commas separating the layer names.
@@ -126,6 +205,7 @@ export {
126
205
  canUnwrapSupports,
127
206
  normalizeLayerNames,
128
207
  normalizeMedia,
208
+ normalizeRuleSelectors,
129
209
  normalizeSupports,
130
210
  unescapeIdent,
131
211
  unescapeSelector