@thejaredwilcurt/csslop 0.0.54 → 0.0.55

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.54",
5
+ "version": "0.0.55",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -34,13 +34,13 @@
34
34
  "eslint-config-tjw-base": "^5.0.0",
35
35
  "eslint-config-tjw-import-x": "^1.0.2",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
- "eslint-plugin-import-x": "^4.18.0",
37
+ "eslint-plugin-import-x": "^4.18.1",
38
38
  "eslint-plugin-jsdoc": "^65.4.5",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.14.0",
41
41
  "pretty-ms": "^10.0.0",
42
42
  "real-world-css-libraries": "^1.0.9",
43
- "vite": "^8.3.6"
43
+ "vite": "^8.3.7"
44
44
  },
45
45
  "author": "The Jared Wilcurt",
46
46
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -2,6 +2,8 @@
2
2
  * @file Nests flat rules under a preceding rule whose selector their own selector starts with, so CSS nesting writes the shared part once.
3
3
  */
4
4
 
5
+ import { findTopLevelDelimiter } from '../../parser/source-search.js';
6
+
5
7
  /**
6
8
  * Matches the part of a child selector that follows its parent when that part
7
9
  * starts with an explicit combinator. Optional whitespace, then the combinator
@@ -78,6 +80,46 @@ function isSelectorWhitespace (character) {
78
80
  return (/\s/).test(character);
79
81
  }
80
82
 
83
+ /**
84
+ * The characters that can end a parent selector: the colon of a pseudo-class,
85
+ * the whitespace of a descendant combinator, and the explicit combinators.
86
+ *
87
+ * @type {string}
88
+ */
89
+ const PARENT_BOUNDARY_CHARACTERS = ':>+~ \t\n\r\f';
90
+
91
+ /**
92
+ * Records the parent selector that a boundary character would end, when it
93
+ * ends one. Boundaries are only found at the top level of the selector, so
94
+ * whitespace inside an attribute selector or a pseudo-class argument, and
95
+ * characters escaped with a backslash, never end a parent.
96
+ *
97
+ * @param {string} childSelector The trimmed child selector.
98
+ * @param {number} boundaryIndex The index of the boundary character within the child selector.
99
+ * @param {Set} candidates The set the candidate parent selector is added to.
100
+ */
101
+ function addCandidateParentSelector (childSelector, boundaryIndex, candidates) {
102
+ const character = childSelector[boundaryIndex];
103
+ if (character === ':' || isSelectorWhitespace(character)) {
104
+ // The second colon of a pseudo-element is part of its `::` introducer,
105
+ // not a boundary between a potential parent and nested child.
106
+ if (character === ':' && childSelector[boundaryIndex - 1] === ':') {
107
+ return;
108
+ }
109
+ candidates.add(childSelector.slice(0, boundaryIndex));
110
+ return;
111
+ }
112
+ // A combinator may be separated from its parent by whitespace, which
113
+ // belongs to neither side, so the parent ends where that run begins.
114
+ let boundary = boundaryIndex;
115
+ while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
116
+ boundary--;
117
+ }
118
+ if (boundary > 0) {
119
+ candidates.add(childSelector.slice(0, boundary));
120
+ }
121
+ }
122
+
81
123
  /**
82
124
  * Collects every prefix of a child selector that could act as its nesting
83
125
  * parent. A parent always ends immediately before a pseudo-class colon, a
@@ -91,29 +133,10 @@ function isSelectorWhitespace (character) {
91
133
  */
92
134
  function collectCandidateParentSelectors (childSelector) {
93
135
  const candidates = new Set();
94
- for (let index = 1; index < childSelector.length; index++) {
95
- const character = childSelector[index];
96
- if (character === ':' || isSelectorWhitespace(character)) {
97
- // The second colon of a pseudo-element is part of its `::` introducer,
98
- // not a boundary between a potential parent and nested child.
99
- if (character === ':' && childSelector[index - 1] === ':') {
100
- continue;
101
- }
102
- candidates.add(childSelector.slice(0, index));
103
- continue;
104
- }
105
- if (character !== '>' && character !== '+' && character !== '~') {
106
- continue;
107
- }
108
- // A combinator may be separated from its parent by whitespace, which
109
- // belongs to neither side, so the parent ends where that run begins.
110
- let boundary = index;
111
- while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
112
- boundary--;
113
- }
114
- if (boundary > 0) {
115
- candidates.add(childSelector.slice(0, boundary));
116
- }
136
+ let boundaryIndex = findTopLevelDelimiter(childSelector, PARENT_BOUNDARY_CHARACTERS, 1);
137
+ while (boundaryIndex !== -1) {
138
+ addCandidateParentSelector(childSelector, boundaryIndex, candidates);
139
+ boundaryIndex = findTopLevelDelimiter(childSelector, PARENT_BOUNDARY_CHARACTERS, boundaryIndex + 1);
117
140
  }
118
141
  return candidates;
119
142
  }
@@ -306,6 +306,83 @@ function simplifyNthPseudoClasses (selector) {
306
306
  return minified;
307
307
  }
308
308
 
309
+ /**
310
+ * Matches an attribute selector's brackets: the attribute name, the matcher
311
+ * (such as `=` or `^=`), the value, and the optional case-sensitivity flag `i`
312
+ * or `s`. The value is a double-quoted string, a single-quoted string, or a run
313
+ * of unquoted characters in which escapes may stand in for spaces.
314
+ *
315
+ * @type {RegExp}
316
+ */
317
+ const ATTRIBUTE_SELECTOR_PATTERN = /\[\s*([^\s=\]]+?)\s*([~|^$*]?=)\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:\\.|[^\s"'\\\]])+)(?:\s*([is]))?\s*\]/gi;
318
+
319
+ /**
320
+ * Matches a value that can be written without quotes: an identifier made of
321
+ * letters, digits, hyphens, underscores, and escapes. It cannot start with a
322
+ * digit, or with a hyphen followed by a digit.
323
+ *
324
+ * @type {RegExp}
325
+ */
326
+ const UNQUOTED_ATTRIBUTE_VALUE_PATTERN = /^(?:-?[a-zA-Z_]|\\.)(?:[\w-]|\\.)*$/;
327
+
328
+ /**
329
+ * Writes the text of a quoted attribute value without its quotes, escaping the
330
+ * characters that would otherwise end an unquoted identifier.
331
+ *
332
+ * @param {string} content The value's text, without its quotes.
333
+ * @return {string|null} The unquoted value, or null when it cannot be written without quotes.
334
+ */
335
+ function escapeAttributeValue (content) {
336
+ if (content.includes('\\')) {
337
+ return null;
338
+ }
339
+ // Escape special characters that require quoting (spaces, #, ., :, /, ;)
340
+ const escaped = content.replace(/([ #.:/;])/g, '\\$1');
341
+ if (!UNQUOTED_ATTRIBUTE_VALUE_PATTERN.test(escaped)) {
342
+ return null;
343
+ }
344
+ return escaped;
345
+ }
346
+
347
+ /**
348
+ * Writes an attribute value with double quotes, removing the escapes that only
349
+ * an unquoted value needs. A value that already has quotes keeps them, unless
350
+ * a single-quoted value holds a double quote that would have to be escaped.
351
+ *
352
+ * @param {string} rawValue The value as written, with or without quotes.
353
+ * @return {string} The value wrapped in double quotes.
354
+ */
355
+ function quoteAttributeValue (rawValue) {
356
+ if (rawValue[0] === '"' || rawValue[0] === '\'') {
357
+ const content = rawValue.slice(1, -1);
358
+ return content.includes('"') ? rawValue : '"' + content + '"';
359
+ }
360
+ // Unescape the characters that only an unquoted value needs escaped
361
+ return '"' + rawValue.replace(/\\([ #.:/;])/g, '$1') + '"';
362
+ }
363
+
364
+ /**
365
+ * Writes an attribute value and its case-sensitivity flag in whichever form is
366
+ * shorter. A value written with quotes stays quoted on a tie, and a value written
367
+ * without quotes stays unquoted. A flag after an unquoted value needs a space,
368
+ * but a flag after a quoted value does not.
369
+ *
370
+ * @param {string} rawValue The value as written, including its quotes when it has them.
371
+ * @param {string|null} flag The flag as written, or null when there is none.
372
+ * @return {string} The value followed by the flag, in its shortest form.
373
+ */
374
+ function formatAttributeValue (rawValue, flag) {
375
+ const isQuoted = rawValue[0] === '"' || rawValue[0] === '\'';
376
+ const quotedForm = quoteAttributeValue(rawValue) + (flag || '');
377
+ const unquotedValue = isQuoted ? escapeAttributeValue(rawValue.slice(1, -1)) : rawValue;
378
+ if (unquotedValue === null) {
379
+ return quotedForm;
380
+ }
381
+ const unquotedForm = unquotedValue + (flag ? ' ' + flag : '');
382
+ const useUnquoted = isQuoted ? unquotedForm.length < quotedForm.length : unquotedForm.length <= quotedForm.length;
383
+ return useUnquoted ? unquotedForm : quotedForm;
384
+ }
385
+
309
386
  /**
310
387
  * Writes each attribute selector's value quoted or escaped, whichever is
311
388
  * shorter, and removes the whitespace inside its brackets.
@@ -314,35 +391,9 @@ function simplifyNthPseudoClasses (selector) {
314
391
  * @return {string} The selector with minified attribute selectors.
315
392
  */
316
393
  function minifyAttributeSelectors (selector) {
317
- let minified = selector;
318
- // Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
319
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
320
- // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
321
- let escaped = value.replace(/([ #.:/;])/g, '\\$1');
322
- if (escaped.length < value.length + 2) {
323
- return '[' + attribute + '=' + escaped + ']';
324
- }
325
- return '[' + attribute + '="' + value + '"]';
326
- });
327
- // Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
328
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
329
- // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
330
- let escaped = value.replace(/([ #.:/;])/g, '\\$1');
331
- if (escaped.length < value.length + 2) {
332
- return '[' + attribute + '=' + escaped + ']';
333
- }
334
- return '[' + attribute + '="' + value + '"]';
335
- });
336
- // Minify unquoted attribute selectors: quote when unescaping produces a shorter result
337
- minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
338
- // Unescape special characters (spaces, #, ., :, /, ;) and compare with quoted form
339
- let unescaped = value.replace(/\\([ #.:/;])/g, '$1');
340
- if (unescaped.length + 2 < value.length) {
341
- return '[' + attribute + '="' + unescaped + '"]';
342
- }
343
- return '[' + attribute + '=' + value + ']';
394
+ return selector.replace(ATTRIBUTE_SELECTOR_PATTERN, (match, attribute, matcher, rawValue, flag) => {
395
+ return '[' + attribute + matcher + formatAttributeValue(rawValue, flag) + ']';
344
396
  });
345
- return minified;
346
397
  }
347
398
 
348
399
  /**