@thejaredwilcurt/csslop 0.0.55 → 0.0.57

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.55",
5
+ "version": "0.0.57",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -23,6 +23,127 @@ function removeIEMediaRules (rules) {
23
23
  });
24
24
  }
25
25
 
26
+ /**
27
+ * Matches the media type keyword that opens a normalized `@media` query, such as `screen` in `screen and (width>=768px)`.
28
+ *
29
+ * @type {RegExp}
30
+ */
31
+ const LEADING_MEDIA_TYPE_PATTERN = /^[a-z-]+(?=\s|$)/i;
32
+
33
+ /**
34
+ * Matches one parenthesized media condition, allowing one level of nested parentheses such as `(not (color))`.
35
+ *
36
+ * @type {RegExp}
37
+ */
38
+ const PARENTHESIZED_CONDITION_PATTERN = /\((?:[^()]|\([^()]*\))*\)/g;
39
+
40
+ /**
41
+ * Splits a `@media` query into the media type it names and the conditions it adds, so that two queries can be joined with `and`. Queries that use a list, `not`, or `only` cannot be joined this way.
42
+ *
43
+ * @param {string} media The raw `@media` query string.
44
+ * @return {object|null} The `type` (null when none applies) and `conditions` text, or null when the query cannot be joined.
45
+ */
46
+ function parseJoinableMediaQuery (media) {
47
+ const normalized = normalizeMedia(media);
48
+ const typeText = normalized.match(LEADING_MEDIA_TYPE_PATTERN)?.[0] ?? '';
49
+ const typeName = typeText.toLowerCase();
50
+ if (normalized.includes(',') || typeName === 'not' || typeName === 'only') {
51
+ return null;
52
+ }
53
+ // Drop the `and` that separates the media type from the conditions
54
+ const conditions = normalized.slice(typeText.length).replace(/^\s*and\s*/i, '');
55
+ // Anything left once the parenthesized conditions and `and` joiners are removed is an unsupported operator
56
+ const unparsedText = conditions.replace(PARENTHESIZED_CONDITION_PATTERN, '').replace(/\band\b/gi, '').trim();
57
+ if (unparsedText) {
58
+ return null;
59
+ }
60
+ return {
61
+ // `all` matches every document, so it adds no restriction of its own
62
+ type: typeName && typeName !== 'all' ? typeName : null,
63
+ conditions
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Reports whether two queries name different media types. A document has only one media type, so a block requiring both can never match.
69
+ *
70
+ * @param {object|null} outer The parsed enclosing query, from `parseJoinableMediaQuery`.
71
+ * @param {object|null} inner The parsed nested query, from `parseJoinableMediaQuery`.
72
+ * @return {boolean} True when the two queries name different media types.
73
+ */
74
+ function areExclusiveMediaTypes (outer, inner) {
75
+ return Boolean(outer?.type && inner?.type && outer.type !== inner.type);
76
+ }
77
+
78
+ /**
79
+ * Joins a `@media` block nested directly inside another into the single query that matches the same documents, such as `screen and (width>=768px)`. A media type both queries share is written once.
80
+ *
81
+ * @param {string} outerMedia The raw query of the enclosing `@media` block.
82
+ * @param {string} innerMedia The raw query of the nested `@media` block.
83
+ * @return {string|null} The joined query, or null when the pair must stay nested.
84
+ */
85
+ function joinNestedMediaQueries (outerMedia, innerMedia) {
86
+ const outer = parseJoinableMediaQuery(outerMedia);
87
+ const inner = parseJoinableMediaQuery(innerMedia);
88
+ if (!outer || !inner || areExclusiveMediaTypes(outer, inner)) {
89
+ return null;
90
+ }
91
+ const parts = [outer.type || inner.type, outer.conditions, inner.conditions].filter(Boolean);
92
+ return parts.join(' and ') || 'all';
93
+ }
94
+
95
+ /**
96
+ * Reports whether an entry of a rule list is real content rather than the whitespace between entries.
97
+ *
98
+ * @param {object} entry An entry of a rule list.
99
+ * @return {boolean} True when the entry is not whitespace.
100
+ */
101
+ function isContentEntry (entry) {
102
+ return entry.type !== 'whitespace';
103
+ }
104
+
105
+ /**
106
+ * Simplifies one `@media` block. A block whose only content is a single nested `@media` block is joined into one query, and the simplification repeats on the result. Nested `@media` blocks whose media type the block already excludes are removed.
107
+ *
108
+ * @param {object} rule The `@media` rule node to simplify.
109
+ * @return {object} A new `@media` rule node.
110
+ */
111
+ function simplifyMediaBlock (rule) {
112
+ const children = rule.rules || [];
113
+ const content = children.filter(isContentEntry);
114
+ if (content.length === 1 && content[0].type === 'media') {
115
+ const joinedMedia = joinNestedMediaQueries(rule.media, content[0].media);
116
+ if (joinedMedia !== null) {
117
+ return simplifyMediaBlock({ ...content[0], media: joinedMedia });
118
+ }
119
+ }
120
+ const outerQuery = parseJoinableMediaQuery(rule.media);
121
+ return {
122
+ ...rule,
123
+ rules: children.filter((child) => {
124
+ return !(child.type === 'media' && areExclusiveMediaTypes(outerQuery, parseJoinableMediaQuery(child.media)));
125
+ })
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Simplifies the `@media` blocks in a rule list, dropping any block whose content was removed entirely as dead.
131
+ *
132
+ * @param {Array} rules The AST rule nodes to process.
133
+ * @return {Array} A new array of rules with nested `@media` blocks simplified.
134
+ */
135
+ function simplifyNestedMediaRules (rules) {
136
+ return rules.flatMap((rule) => {
137
+ if (rule.type !== 'media') {
138
+ return [rule];
139
+ }
140
+ const simplified = simplifyMediaBlock(rule);
141
+ const hadContent = (rule.rules || []).some(isContentEntry);
142
+ const isEmptiedByDeadContent = hadContent && !simplified.rules.some(isContentEntry);
143
+ return isEmptiedByDeadContent ? [] : [simplified];
144
+ });
145
+ }
146
+
26
147
  /**
27
148
  * Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
28
149
  *
@@ -126,5 +247,6 @@ export {
126
247
  deduplicateKeyframes,
127
248
  mergeLayerRules,
128
249
  mergeMediaRules,
129
- removeIEMediaRules
250
+ removeIEMediaRules,
251
+ simplifyNestedMediaRules
130
252
  };
@@ -276,6 +276,40 @@ function createWrapperDeclarations (run) {
276
276
  ];
277
277
  }
278
278
 
279
+ /**
280
+ * Collects the run of consecutive sibling rules, starting at a given index,
281
+ * whose selectors can all nest under a candidate parent. Comments never affect
282
+ * the cascade, so they are passed over instead of ending the run.
283
+ *
284
+ * @param {Array} rules The sibling AST entries.
285
+ * @param {number} startIndex The index the run starts at.
286
+ * @param {function(string): (string|null)} matchNestedSelector The matcher built for the candidate parent.
287
+ * @return {object} The `run` of rules paired with their nested selectors, and the `end` index just past the run's last rule.
288
+ */
289
+ function collectSiblingRun (rules, startIndex, matchNestedSelector) {
290
+ const run = [];
291
+ let end = startIndex;
292
+ let lookahead = startIndex;
293
+ while (lookahead < rules.length) {
294
+ const sibling = rules[lookahead];
295
+ if (sibling.type === 'comment') {
296
+ lookahead++;
297
+ continue;
298
+ }
299
+ if (sibling.type !== 'rule' || !sibling.selectors?.length) {
300
+ break;
301
+ }
302
+ const nestedSelectors = nestSelectorsUnderParent(sibling, matchNestedSelector);
303
+ if (!nestedSelectors) {
304
+ break;
305
+ }
306
+ run.push({ sibling, nestedSelectors });
307
+ lookahead++;
308
+ end = lookahead;
309
+ }
310
+ return { end, run };
311
+ }
312
+
279
313
  /**
280
314
  * Groups consecutive sibling rules that share a common leading selector
281
315
  * portion (such as `.foo` in `.foo .a`, `.foo .b`, or `.menu .item` in
@@ -308,20 +342,7 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
308
342
  }
309
343
  const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
310
344
  // Collect the run of consecutive rules whose selectors can all nest under the candidate.
311
- const run = [];
312
- let lookahead = index;
313
- while (lookahead < rules.length) {
314
- const sibling = rules[lookahead];
315
- if (sibling.type !== 'rule' || !sibling.selectors?.length) {
316
- break;
317
- }
318
- const nestedSelectors = nestSelectorsUnderParent(sibling, matchNestedSelector);
319
- if (!nestedSelectors) {
320
- break;
321
- }
322
- run.push({ sibling, nestedSelectors });
323
- lookahead++;
324
- }
345
+ const { end, run } = collectSiblingRun(rules, index, matchNestedSelector);
325
346
  const selectorCount = run.reduce((total, entry) => {
326
347
  return total + entry.nestedSelectors.length;
327
348
  }, 0);
@@ -333,8 +354,11 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
333
354
  const savings = measureWrapperSavings(run, declarations, candidateParent);
334
355
  if (savings > 0 && (!bestGrouping || savings > bestGrouping.savings)) {
335
356
  bestGrouping = {
357
+ comments: rules.slice(index, end).filter((entry) => {
358
+ return entry.type === 'comment';
359
+ }),
336
360
  declarations,
337
- lookahead,
361
+ end,
338
362
  parent: candidateParent,
339
363
  savings
340
364
  };
@@ -349,12 +373,12 @@ function factorCommonParents (rules, allowTrailingCompound = false) {
349
373
  index++;
350
374
  continue;
351
375
  }
352
- result.push({
376
+ result.push(...bestGrouping.comments, {
353
377
  type: 'rule',
354
378
  selectors: [bestGrouping.parent],
355
379
  declarations: bestGrouping.declarations
356
380
  });
357
- index = bestGrouping.lookahead;
381
+ index = bestGrouping.end;
358
382
  }
359
383
  return result;
360
384
  }
@@ -6,7 +6,8 @@ import {
6
6
  deduplicateKeyframes,
7
7
  mergeLayerRules,
8
8
  mergeMediaRules,
9
- removeIEMediaRules
9
+ removeIEMediaRules,
10
+ simplifyNestedMediaRules
10
11
  } from './at-rules.js';
11
12
  import { removeEmptyRules } from './empty-rules.js';
12
13
  import { expandPureNestedRules } from './expand-nesting.js';
@@ -45,6 +46,7 @@ function clearSelectorCaches () {
45
46
  function optimizeRuleList (rules) {
46
47
  let result = expandPureNestedRules(rules);
47
48
  result = removeIEMediaRules(result);
49
+ result = simplifyNestedMediaRules(result);
48
50
  result = mergeLayerRules(result, mergeSelectorRules);
49
51
  result = mergeMediaRules(result, mergeSelectorRules);
50
52
  result = deduplicateKeyframes(result);
@@ -28,13 +28,13 @@ function nestSelectorRemainder (remainder) {
28
28
  if (remainder.startsWith(':')) {
29
29
  return '&' + remainder;
30
30
  }
31
- if (remainder.startsWith(' ')) {
32
- return remainder.slice(1);
33
- }
34
31
  const combinatorMatch = remainder.match(LEADING_COMBINATOR_PATTERN);
35
32
  if (combinatorMatch) {
36
33
  return combinatorMatch[1] + combinatorMatch[2];
37
34
  }
35
+ if (remainder.startsWith(' ')) {
36
+ return remainder.slice(1);
37
+ }
38
38
  return null;
39
39
  }
40
40