postcss-merge-rules 7.0.6 → 7.0.8

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-merge-rules",
3
- "version": "7.0.6",
3
+ "version": "7.0.8",
4
4
  "description": "Merge CSS rules with PostCSS.",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "src/index.js",
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "repository": "cssnano/cssnano",
26
26
  "dependencies": {
27
- "browserslist": "^4.25.1",
27
+ "browserslist": "^4.28.1",
28
28
  "caniuse-api": "^3.0.0",
29
- "postcss-selector-parser": "^7.1.0",
29
+ "postcss-selector-parser": "^7.1.1",
30
30
  "cssnano-utils": "^5.0.1"
31
31
  },
32
32
  "bugs": {
@@ -39,7 +39,7 @@
39
39
  "@types/caniuse-api": "^3.0.6",
40
40
  "postcss": "^8.5.6",
41
41
  "postcss-simple-vars": "^7.0.1",
42
- "postcss-discard-comments": "^7.0.4"
42
+ "postcss-discard-comments": "^7.0.6"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "postcss": "^8.4.32"
package/src/index.js CHANGED
@@ -54,20 +54,51 @@ function sameDeclarationsAndOrder(a, b) {
54
54
  return a.every((d, index) => declarationIsEqual(d, b[index]));
55
55
  }
56
56
 
57
+ /**
58
+ * RuleMeta stores metadata about a `Rule` during the merging process.
59
+ * It tracks selectors and declarations without re-parsing the AST many times.
60
+ *
61
+ * @typedef {Object} RuleMeta
62
+ * @property {string[]} selectors - Array of selector strings for the rule
63
+ * @property {import('postcss').Declaration[]} declarations - Array of declaration nodes for the rule
64
+ * @property {boolean} dirty - Whether the selectors have been modified and need flushing
65
+ */
66
+
57
67
  /**
58
68
  * @param {import('postcss').Rule} ruleA
59
69
  * @param {import('postcss').Rule} ruleB
60
- * @param {string[]=} browsers
61
- * @param {Map<string, boolean>=} compatibilityCache
70
+ * @param {string[]} browsers
71
+ * @param {Map<string, boolean>} compatibilityCache
72
+ * @param {WeakSet<import('postcss').Rule>} ruleCache
73
+ * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
62
74
  * @return {boolean}
63
75
  */
64
- function canMerge(ruleA, ruleB, browsers, compatibilityCache) {
65
- const a = ruleA.selectors;
66
- const b = ruleB.selectors;
76
+ function canMerge(
77
+ ruleA,
78
+ ruleB,
79
+ browsers,
80
+ compatibilityCache,
81
+ ruleCache,
82
+ ruleMeta
83
+ ) {
84
+ const metaA = getMeta(ruleA, ruleMeta);
85
+ const metaB = getMeta(ruleB, ruleMeta);
86
+ const a = metaA.selectors;
87
+ const b = metaB.selectors;
67
88
 
68
89
  const selectors = a.concat(b);
69
90
 
70
- if (!ensureCompatibility(selectors, browsers, compatibilityCache)) {
91
+ if (ruleCache.has(ruleA) && ruleCache.has(ruleB)) {
92
+ // Both already validated
93
+ } else if (ruleCache.has(ruleA)) {
94
+ if (!ensureCompatibility(b, browsers, compatibilityCache)) {
95
+ return false;
96
+ }
97
+ } else if (ruleCache.has(ruleB)) {
98
+ if (!ensureCompatibility(a, browsers, compatibilityCache)) {
99
+ return false;
100
+ }
101
+ } else if (!ensureCompatibility(selectors, browsers, compatibilityCache)) {
71
102
  return false;
72
103
  }
73
104
 
@@ -105,6 +136,51 @@ function isRuleOrAtRule(node) {
105
136
  function isDeclaration(node) {
106
137
  return node.type === 'decl';
107
138
  }
139
+
140
+ /**
141
+ * Retrieves or initializes virtual metadata for a PostCSS rule.
142
+ *
143
+ * This metadata caches selectors and declarations to avoid expensive AST
144
+ * re-parsing, especially for the selectors.
145
+ *
146
+ * @param {import('postcss').Rule} rule The PostCSS rule to get metadata for.
147
+ * @param {WeakMap<import('postcss').Rule, RuleMeta>} [ruleMeta] The metadata cache.
148
+ * @return {RuleMeta} The rule's virtual metadata.
149
+ */
150
+ function getMeta(rule, ruleMeta) {
151
+ if (ruleMeta && rule) {
152
+ let meta = ruleMeta.get(rule);
153
+ if (!meta && rule.nodes) {
154
+ meta = {
155
+ selectors: rule.selectors,
156
+ declarations: rule.nodes.filter(isDeclaration),
157
+ dirty: false,
158
+ };
159
+ ruleMeta.set(rule, meta);
160
+ }
161
+ return meta ?? { selectors: [], declarations: [], dirty: false };
162
+ }
163
+ return {
164
+ selectors: rule?.selectors ?? [],
165
+ declarations: rule?.nodes?.filter(isDeclaration) ?? [],
166
+ dirty: false,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Commits virtual metadata changes back to the actual PostCSS rule.
172
+ *
173
+ * @param {import('postcss').Rule} rule The PostCSS rule to flush.
174
+ * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta The metadata cache.
175
+ */
176
+ function flush(rule, ruleMeta) {
177
+ const meta = ruleMeta.get(rule);
178
+ if (meta && meta.dirty) {
179
+ rule.selector = meta.selectors.join(',');
180
+ meta.dirty = false;
181
+ }
182
+ }
183
+
108
184
  /**
109
185
  * @param {import('postcss').Rule} rule
110
186
  * @return {import('postcss').Declaration[]}
@@ -113,9 +189,6 @@ function getDecls(rule) {
113
189
  return rule.nodes.filter(isDeclaration);
114
190
  }
115
191
 
116
- /** @type {(...rules: import('postcss').Rule[]) => string} */
117
- const joinSelectors = (...rules) => rules.map((s) => s.selector).join();
118
-
119
192
  /**
120
193
  * @param {...import('postcss').Rule} rules
121
194
  * @return {number}
@@ -227,10 +300,26 @@ function mergeParents(first, second) {
227
300
  /**
228
301
  * @param {import('postcss').Rule} first
229
302
  * @param {import('postcss').Rule} second
303
+ * @param {string[]} browsers
304
+ * @param {Map<string, boolean>} compatibilityCache
305
+ * @param {WeakSet<import('postcss').Rule>} ruleCache
306
+ * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
230
307
  * @return {import('postcss').Rule} mergedRule
231
308
  */
232
- function partialMerge(first, second) {
233
- let intersection = intersect(getDecls(first), getDecls(second));
309
+ function partialMerge(
310
+ first,
311
+ second,
312
+ browsers,
313
+ compatibilityCache,
314
+ ruleCache,
315
+ ruleMeta
316
+ ) {
317
+ if (ruleMeta) {
318
+ flush(first, ruleMeta);
319
+ }
320
+ const metaFirst = getMeta(first, ruleMeta);
321
+ const metaSecond = getMeta(second, ruleMeta);
322
+ let intersection = intersect(metaFirst.declarations, metaSecond.declarations);
234
323
  if (intersection.length === 0) {
235
324
  return second;
236
325
  }
@@ -244,8 +333,22 @@ function partialMerge(first, second) {
244
333
  ).next();
245
334
  nextRule = parentSibling && parentSibling.nodes && parentSibling.nodes[0];
246
335
  }
247
- if (nextRule && nextRule.type === 'rule' && canMerge(second, nextRule)) {
248
- let nextIntersection = intersect(getDecls(second), getDecls(nextRule));
336
+ if (
337
+ nextRule?.type === 'rule' &&
338
+ canMerge(
339
+ second,
340
+ nextRule,
341
+ browsers,
342
+ compatibilityCache,
343
+ ruleCache,
344
+ ruleMeta
345
+ )
346
+ ) {
347
+ const metaNext = getMeta(nextRule, ruleMeta);
348
+ let nextIntersection = intersect(
349
+ metaSecond.declarations,
350
+ metaNext.declarations
351
+ );
249
352
  if (nextIntersection.length > intersection.length) {
250
353
  mergeParents(second, nextRule);
251
354
  first = second;
@@ -254,7 +357,11 @@ function partialMerge(first, second) {
254
357
  }
255
358
  }
256
359
 
257
- const firstDecls = getDecls(first);
360
+ const metaFirstActual = getMeta(first, ruleMeta);
361
+ const metaSecondActual = getMeta(second, ruleMeta);
362
+ let firstDecls = [...metaFirstActual.declarations];
363
+ let secondDecls = [...metaSecondActual.declarations];
364
+
258
365
  // Filter out intersections with later conflicts in First
259
366
  intersection = intersection.filter((decl, intersectIndex) => {
260
367
  const indexOfDecl = indexOfDeclaration(firstDecls, decl);
@@ -276,7 +383,6 @@ function partialMerge(first, second) {
276
383
  });
277
384
 
278
385
  // Filter out intersections with previous conflicts in Second
279
- const secondDecls = getDecls(second);
280
386
  intersection = intersection.filter((decl) => {
281
387
  const nextConflictIndex = secondDecls.findIndex((d) =>
282
388
  isConflictingProp(d.prop, decl.prop)
@@ -306,15 +412,18 @@ function partialMerge(first, second) {
306
412
  }
307
413
 
308
414
  const receivingBlock = second.clone();
309
- receivingBlock.selector = joinSelectors(first, second);
415
+ const firstSelectors = metaFirstActual.selectors;
416
+ const secondSelectors = metaSecondActual.selectors;
417
+
418
+ receivingBlock.selector = [...firstSelectors, ...secondSelectors].join();
310
419
  receivingBlock.nodes = [];
311
420
 
312
421
  /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
313
422
  second.parent
314
423
  ).insertBefore(second, receivingBlock);
315
424
 
316
- const firstClone = first.clone();
317
- const secondClone = second.clone();
425
+ const firstClone = first.clone({ selectors: firstSelectors });
426
+ const secondClone = second.clone({ selectors: secondSelectors });
318
427
 
319
428
  /**
320
429
  * @param {function(import('postcss').Declaration):void} callback
@@ -335,6 +444,13 @@ function partialMerge(first, second) {
335
444
  })
336
445
  );
337
446
  secondClone.walkDecls(moveDecl((decl) => decl.remove()));
447
+
448
+ // Ensure original rules are flushed for accurate length comparison
449
+ if (ruleMeta) {
450
+ flush(first, ruleMeta);
451
+ flush(second, ruleMeta);
452
+ }
453
+
338
454
  const merged = ruleLength(firstClone, receivingBlock, secondClone);
339
455
  const original = ruleLength(first, second);
340
456
  if (merged < original) {
@@ -346,8 +462,13 @@ function partialMerge(first, second) {
346
462
  }
347
463
  });
348
464
  if (!secondClone.parent) {
465
+ ruleCache?.add(receivingBlock);
349
466
  return receivingBlock;
350
467
  }
468
+ ruleCache?.add(receivingBlock);
469
+ ruleCache?.add(secondClone);
470
+ ruleMeta?.delete(first);
471
+ ruleMeta?.delete(second);
351
472
  return secondClone;
352
473
  } else {
353
474
  receivingBlock.remove();
@@ -358,53 +479,101 @@ function partialMerge(first, second) {
358
479
  /**
359
480
  * @param {string[]} browsers
360
481
  * @param {Map<string, boolean>} compatibilityCache
361
- * @return {function(import('postcss').Rule)}
482
+ * @param {WeakSet<import('postcss').Rule>} ruleCache
483
+ * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
484
+ * @return {{ merger: function(import('postcss').Rule): void, clean: function(): void }}
362
485
  */
363
- function selectorMerger(browsers, compatibilityCache) {
486
+ function selectorMerger(browsers, compatibilityCache, ruleCache, ruleMeta) {
364
487
  /** @type {import('postcss').Rule | null} */
365
488
  let cache = null;
366
- return function (rule) {
367
- // Prime the cache with the first rule, or alternately ensure that it is
368
- // safe to merge both declarations before continuing
369
- if (!cache || !canMerge(rule, cache, browsers, compatibilityCache)) {
370
- cache = rule;
371
- return;
372
- }
373
- // Ensure that we don't deduplicate the same rule; this is sometimes
374
- // caused by a partial merge
375
- if (cache === rule) {
376
- cache = rule;
377
- return;
378
- }
379
-
380
- // Parents merge: check if the rules have same parents, but not same parent nodes
381
- mergeParents(cache, rule);
382
-
383
- // Merge when declarations are exactly equal
384
- // e.g. h1 { color: red } h2 { color: red }
385
- if (sameDeclarationsAndOrder(getDecls(rule), getDecls(cache))) {
386
- rule.selector = joinSelectors(cache, rule);
387
- cache.remove();
388
- cache = rule;
389
- return;
390
- }
391
- // Merge when both selectors are exactly equal
392
- // e.g. a { color: blue } a { font-weight: bold }
393
- if (cache.selector === rule.selector) {
394
- const cached = getDecls(cache);
395
- rule.walk((node) => {
396
- if (node.type === 'decl' && indexOfDeclaration(cached, node) !== -1) {
397
- node.remove();
398
- return;
489
+ return {
490
+ merger(rule) {
491
+ // Prime the cache with the first rule, or alternately ensure that it is
492
+ // safe to merge both declarations before continuing
493
+ if (
494
+ !cache ||
495
+ !canMerge(
496
+ rule,
497
+ cache,
498
+ browsers,
499
+ compatibilityCache,
500
+ ruleCache,
501
+ ruleMeta
502
+ )
503
+ ) {
504
+ if (cache) {
505
+ flush(cache, ruleMeta);
399
506
  }
400
- /** @type {import('postcss').Rule} */ (cache).append(node);
401
- });
402
- rule.remove();
403
- return;
404
- }
405
- // Partial merge: check if the rule contains a subset of the last; if
406
- // so create a joined selector with the subset, if smaller.
407
- cache = partialMerge(cache, rule);
507
+ cache = rule;
508
+ return;
509
+ }
510
+ // Ensure that we don't deduplicate the same rule; this is sometimes
511
+ // caused by a partial merge
512
+ if (cache === rule) {
513
+ cache = rule;
514
+ return;
515
+ }
516
+
517
+ // Parents merge: check if the rules have same parents, but not same parent nodes
518
+ mergeParents(cache, rule);
519
+
520
+ // Merge when declarations are exactly equal
521
+ // e.g. h1 { color: red } h2 { color: red }
522
+ if (
523
+ sameDeclarationsAndOrder(
524
+ getMeta(rule, ruleMeta).declarations,
525
+ getMeta(cache, ruleMeta).declarations
526
+ )
527
+ ) {
528
+ const metaRule = getMeta(rule, ruleMeta);
529
+ const metaCache = getMeta(cache, ruleMeta);
530
+ metaRule.selectors = [...metaCache.selectors, ...metaRule.selectors];
531
+ metaRule.dirty = true;
532
+ cache.remove();
533
+ ruleMeta?.delete(cache);
534
+ cache = rule;
535
+ ruleCache?.add(rule);
536
+ return;
537
+ }
538
+ // Merge when both selectors are exactly equal
539
+ // e.g. a { color: blue } a { font-weight: bold }
540
+ if (
541
+ getMeta(cache, ruleMeta).selectors.join(',') ===
542
+ getMeta(rule, ruleMeta).selectors.join(',')
543
+ ) {
544
+ const cachedDecls = getMeta(cache, ruleMeta).declarations;
545
+ rule.walk((node) => {
546
+ if (
547
+ node.type === 'decl' &&
548
+ indexOfDeclaration(cachedDecls, node) !== -1
549
+ ) {
550
+ node.remove();
551
+ return;
552
+ }
553
+ /** @type {import('postcss').Rule} */ (cache).append(node);
554
+ });
555
+ getMeta(cache, ruleMeta).declarations = getDecls(cache);
556
+ rule.remove();
557
+ ruleMeta?.delete(rule);
558
+ return;
559
+ }
560
+ // Partial merge: check if the rule contains a subset of the last; if
561
+ // so create a joined selector with the subset, if smaller.
562
+ cache = partialMerge(
563
+ cache,
564
+ rule,
565
+ browsers,
566
+ compatibilityCache,
567
+ ruleCache,
568
+ ruleMeta
569
+ );
570
+ },
571
+ // Flushes any remaining rule in the cache to avoid memory leaks.
572
+ clean() {
573
+ if (cache) {
574
+ flush(cache, ruleMeta);
575
+ }
576
+ },
408
577
  };
409
578
  }
410
579
 
@@ -435,9 +604,21 @@ function pluginCreator(opts = {}) {
435
604
  });
436
605
 
437
606
  const compatibilityCache = new Map();
607
+
608
+ // Use WeakSet and WeakMap to avoid memory leaks because the keys are objects.
609
+ const ruleCache = new WeakSet();
610
+ const ruleMeta = new WeakMap();
611
+
438
612
  return {
439
613
  OnceExit(css) {
440
- css.walkRules(selectorMerger(browsers, compatibilityCache));
614
+ const { merger, clean } = selectorMerger(
615
+ browsers,
616
+ compatibilityCache,
617
+ ruleCache,
618
+ ruleMeta
619
+ );
620
+ css.walkRules(merger);
621
+ clean();
441
622
  },
442
623
  };
443
624
  },
package/types/index.d.ts CHANGED
@@ -11,9 +11,27 @@ export = pluginCreator;
11
11
  */
12
12
  declare function pluginCreator(opts?: Options): import("postcss").Plugin;
13
13
  declare namespace pluginCreator {
14
- export { postcss, AutoprefixerOptions, BrowserslistOptions, Options };
14
+ export { postcss, RuleMeta, AutoprefixerOptions, BrowserslistOptions, Options };
15
15
  }
16
16
  declare var postcss: true;
17
+ /**
18
+ * RuleMeta stores metadata about a `Rule` during the merging process.
19
+ * It tracks selectors and declarations without re-parsing the AST many times.
20
+ */
21
+ type RuleMeta = {
22
+ /**
23
+ * - Array of selector strings for the rule
24
+ */
25
+ selectors: string[];
26
+ /**
27
+ * - Array of declaration nodes for the rule
28
+ */
29
+ declarations: import("postcss").Declaration[];
30
+ /**
31
+ * - Whether the selectors have been modified and need flushing
32
+ */
33
+ dirty: boolean;
34
+ };
17
35
  type AutoprefixerOptions = {
18
36
  overrideBrowserslist?: string | string[];
19
37
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";AA0ZA;;;;GAIG;AAEH;;;;GAIG;AACH,sCAHW,OAAO,GACN,OAAO,SAAS,EAAE,MAAM,CAyBnC;;;;;2BAjCY;IAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE;2BAC5C,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;eACpD,mBAAmB,GAAG,mBAAmB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";AAmkBA;;;;GAIG;AAEH;;;;GAIG;AACH,sCAHW,OAAO,GACN,OAAO,SAAS,EAAE,MAAM,CAqCnC;;;;;;;;;;;;;eApjBa,MAAM,EAAE;;;;kBACR,OAAO,SAAS,EAAE,WAAW,EAAE;;;;WAC/B,OAAO;;2BAqgBR;IAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE;2BAC5C,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;eACpD,mBAAmB,GAAG,mBAAmB"}