postcss-merge-rules 8.0.3 → 8.0.4

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/src/index.js CHANGED
@@ -10,6 +10,7 @@ const {
10
10
  sameVendor,
11
11
  noVendor,
12
12
  } = require('./lib/ensureCompatibility');
13
+ const { isConflictingProp } = require('./lib/propertyRelations.js');
13
14
  /** @import {Declaration, Rule} from 'postcss' */
14
15
  /**
15
16
  * @param {Declaration} a
@@ -105,10 +106,7 @@ function canMerge(
105
106
  return false;
106
107
  }
107
108
 
108
- const parent = sameParent(
109
- /** @type {any} */ (ruleA),
110
- /** @type {any} */ (ruleB)
111
- );
109
+ const parent = sameParent(ruleA, ruleB);
112
110
  if (
113
111
  parent &&
114
112
  ruleA.parent &&
@@ -200,83 +198,6 @@ function ruleLength(...rules) {
200
198
  return rules.map((r) => (r.nodes.length ? String(r) : '')).join('').length;
201
199
  }
202
200
 
203
- /**
204
- * @param {string} prop
205
- * @return {{prefix: string?, base:string?, rest:string[]}}
206
- */
207
- function splitProp(prop) {
208
- // Treat vendor prefixed properties as if they were unprefixed;
209
- // moving them when combined with non-prefixed properties can
210
- // cause issues. e.g. moving -webkit-background-clip when there
211
- // is a background shorthand definition.
212
-
213
- const parts = prop.split('-');
214
- if (prop[0] !== '-') {
215
- return {
216
- prefix: '',
217
- base: parts[0],
218
- rest: parts.slice(1),
219
- };
220
- }
221
- // Don't split css variables
222
- if (prop[1] === '-') {
223
- return {
224
- prefix: null,
225
- base: null,
226
- rest: [prop],
227
- };
228
- }
229
- // Found prefix
230
- return {
231
- prefix: parts[1],
232
- base: parts[2],
233
- rest: parts.slice(3),
234
- };
235
- }
236
-
237
- /**
238
- * @param {string} propA
239
- * @param {string} propB
240
- * @return {boolean}
241
- */
242
- function isConflictingProp(propA, propB) {
243
- if (propA === propB) {
244
- // Same specificity
245
- return true;
246
- }
247
- const a = splitProp(propA);
248
- const b = splitProp(propB);
249
- // Don't resort css variables
250
- if (!a.base && !b.base) {
251
- return true;
252
- }
253
-
254
- // Different base and none is `place`;
255
- if (a.base !== b.base && a.base !== 'place' && b.base !== 'place') {
256
- return false;
257
- }
258
-
259
- // Conflict if rest-count mismatches
260
- if (a.rest.length !== b.rest.length) {
261
- return true;
262
- }
263
-
264
- /* Do not merge conflicting border properties */
265
- if (a.base === 'border') {
266
- const allRestProps = new Set([...a.rest, ...b.rest]);
267
- if (
268
- allRestProps.has('image') ||
269
- allRestProps.has('width') ||
270
- allRestProps.has('color') ||
271
- allRestProps.has('style')
272
- ) {
273
- return true;
274
- }
275
- }
276
- // Conflict if rest parameters are equal (same but unprefixed)
277
- return a.rest.every((s, index) => b.rest[index] === s);
278
- }
279
-
280
201
  /**
281
202
  * @param {Rule} first
282
203
  * @param {Rule} second
@@ -308,11 +229,12 @@ function getNextRule(second) {
308
229
  let nextRule = second.next();
309
230
  if (!nextRule) {
310
231
  // Grab next cousin
311
- /** @type {any} */
312
232
  const parentSibling =
313
- /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
314
- second.parent
315
- ).next();
233
+ /** @type {import('postcss').Container | undefined} */ (
234
+ /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
235
+ second.parent
236
+ ).next()
237
+ );
316
238
  nextRule = parentSibling && parentSibling.nodes && parentSibling.nodes[0];
317
239
  }
318
240
  return nextRule?.type === 'rule' ? nextRule : null;
@@ -365,66 +287,145 @@ function mergeWithNextRule(
365
287
  }
366
288
 
367
289
  /**
368
- * @param {Declaration[]} intersection
369
- * @param {Declaration[]} firstDecls
370
- * @param {Declaration[]} secondDecls
371
- * @return {Declaration[]}
290
+ * Returns true if hoisting `candidate` cannot reverse the cascade: every
291
+ * declaration that might override it is itself being
292
+ * hoisted, in the same relative order, so declarations move together.
293
+ *
294
+ * @param {Declaration} candidate
295
+ * @param {number} candidateIndex
296
+ * @param {Declaration[]} hoistCandidates
297
+ * @param {Declaration[]} earlierRuleDeclarations
298
+ * @return {boolean}
372
299
  */
373
- function filterIntersections(intersection, firstDecls, secondDecls) {
374
- // Filter out intersections with later conflicts in First
375
- intersection = intersection.filter((decl, intersectIndex) => {
376
- const indexOfDecl = indexOfDeclaration(firstDecls, decl);
377
- const nextConflictInFirst = firstDecls
378
- .slice(indexOfDecl + 1)
379
- .filter((d) => isConflictingProp(d.prop, decl.prop));
380
- if (nextConflictInFirst.length === 0) {
381
- return true;
382
- }
383
- const nextConflictInIntersection = intersection
384
- .slice(intersectIndex + 1)
385
- .filter((d) => isConflictingProp(d.prop, decl.prop));
386
- if (nextConflictInFirst.length !== nextConflictInIntersection.length) {
387
- return false;
388
- }
389
- return nextConflictInFirst.every((d, index) =>
390
- declarationIsEqual(d, nextConflictInIntersection[index])
391
- );
392
- });
300
+ function hoistingPreservesOverrideOrder(
301
+ candidate,
302
+ candidateIndex,
303
+ hoistCandidates,
304
+ earlierRuleDeclarations
305
+ ) {
306
+ const indexInEarlierRule = indexOfDeclaration(
307
+ earlierRuleDeclarations,
308
+ candidate
309
+ );
310
+ const overridesInEarlierRule = earlierRuleDeclarations
311
+ .slice(indexInEarlierRule + 1)
312
+ .filter((d) => isConflictingProp(d.prop, candidate.prop));
313
+ if (overridesInEarlierRule.length === 0) {
314
+ return true;
315
+ }
316
+ const overridesAmongCandidates = hoistCandidates
317
+ .slice(candidateIndex + 1)
318
+ .filter((d) => isConflictingProp(d.prop, candidate.prop));
319
+ if (overridesInEarlierRule.length !== overridesAmongCandidates.length) {
320
+ return false;
321
+ }
322
+ return overridesInEarlierRule.every((d, index) =>
323
+ declarationIsEqual(d, overridesAmongCandidates[index])
324
+ );
325
+ }
326
+
327
+ /**
328
+ * True if the later rule has an unclaimed declaration equal to `candidate`.
329
+ * Records its index in `claimedIndices`, so no other candidate can match the
330
+ * same declaration. Always false when the later rule contains `all`,
331
+ * since `all` resets everything except `direction`/`unicode-bidi`.
332
+ *
333
+ * @param {Declaration} candidate
334
+ * @param {Declaration[]} laterDeclarations
335
+ * @param {Set<number>} claimedIndices
336
+ * @return {boolean}
337
+ */
338
+ function claimMatchInLaterRule(candidate, laterDeclarations, claimedIndices) {
339
+ const matchIndex = laterDeclarations.findIndex(
340
+ (d, index) =>
341
+ !claimedIndices.has(index) && isConflictingProp(d.prop, candidate.prop)
342
+ );
343
+ if (matchIndex === -1) {
344
+ return false;
345
+ }
346
+ if (!declarationIsEqual(laterDeclarations[matchIndex], candidate)) {
347
+ return false;
348
+ }
349
+ if (
350
+ candidate.prop.toLowerCase() !== 'direction' &&
351
+ candidate.prop.toLowerCase() !== 'unicode-bidi' &&
352
+ laterDeclarations.some(
353
+ (declaration) => declaration.prop.toLowerCase() === 'all'
354
+ )
355
+ ) {
356
+ return false;
357
+ }
358
+ claimedIndices.add(matchIndex);
359
+ return true;
360
+ }
393
361
 
394
- // Filter out intersections with previous conflicts in Second
395
- return intersection.filter((decl) => {
396
- const nextConflictIndex = secondDecls.findIndex((d) =>
397
- isConflictingProp(d.prop, decl.prop)
362
+ /**
363
+ * Narrows the declarations shared by two adjacent rules down to those that can
364
+ * safely move into a merged rule, along with the indices of the later rule's
365
+ * declarations they claim.
366
+ *
367
+ * The merged rule is emitted after the earlier rule's remaining declarations,
368
+ * so hoisting a declaration that a later declaration in the earlier rule
369
+ * overrides would reverse the cascade and make the previously dead value
370
+ * effective again, unless that overriding declaration is hoisted too, so both
371
+ * move together and keep their relative order.
372
+ *
373
+ * Dropping a candidate can invalidate one that was only admitted because it
374
+ * travelled with it, so the pass repeats until the surviving set stops
375
+ * shrinking.
376
+ *
377
+ * @param {Declaration[]} hoistCandidates
378
+ * @param {Declaration[]} earlierRuleDeclarations
379
+ * @param {Declaration[]} laterRuleDeclarations
380
+ * @return {{intersection: Declaration[], claimedIndices: Set<number>}}
381
+ */
382
+ function filterRuleIntersections(
383
+ hoistCandidates,
384
+ earlierRuleDeclarations,
385
+ laterRuleDeclarations
386
+ ) {
387
+ let remainingCandidates = hoistCandidates;
388
+ for (;;) {
389
+ // A candidate rejected by the override check never claims a match.
390
+ const claimedIndices = new Set();
391
+ const survivors = remainingCandidates.filter(
392
+ (candidate, candidateIndex) =>
393
+ hoistingPreservesOverrideOrder(
394
+ candidate,
395
+ candidateIndex,
396
+ remainingCandidates,
397
+ earlierRuleDeclarations
398
+ ) &&
399
+ claimMatchInLaterRule(candidate, laterRuleDeclarations, claimedIndices)
398
400
  );
399
- if (nextConflictIndex === -1) {
400
- return false;
401
- }
402
- if (!declarationIsEqual(secondDecls[nextConflictIndex], decl)) {
403
- return false;
404
- }
405
401
  if (
406
- decl.prop.toLowerCase() !== 'direction' &&
407
- decl.prop.toLowerCase() !== 'unicode-bidi' &&
408
- secondDecls.some(
409
- (declaration) => declaration.prop.toLowerCase() === 'all'
410
- )
402
+ survivors.length === remainingCandidates.length ||
403
+ survivors.length === 0
411
404
  ) {
412
- return false;
405
+ return { intersection: survivors, claimedIndices };
413
406
  }
414
- secondDecls.splice(nextConflictIndex, 1);
415
- return true;
416
- });
407
+ remainingCandidates = survivors;
408
+ }
417
409
  }
418
410
 
419
411
  /**
420
412
  * @param {Rule} first
421
413
  * @param {Rule} second
422
414
  * @param {Declaration[]} intersection
415
+ * @param {Set<number>} claimedIndices Positions of the declarations the
416
+ * intersection claimed in `second`, which the merged rule replaces.
423
417
  * @param {WeakSet<Rule>} ruleCache
424
418
  * @param {WeakMap<Rule, RuleMeta>} ruleMeta
425
419
  * @return {Rule}
426
420
  */
427
- function buildMergedRule(first, second, intersection, ruleCache, ruleMeta) {
421
+ function buildMergedRule(
422
+ first,
423
+ second,
424
+ intersection,
425
+ claimedIndices,
426
+ ruleCache,
427
+ ruleMeta
428
+ ) {
428
429
  const receivingBlock = second.clone();
429
430
  const firstSelectors = getMeta(first, ruleMeta).selectors;
430
431
  const secondSelectors = getMeta(second, ruleMeta).selectors;
@@ -462,7 +463,15 @@ function buildMergedRule(first, second, intersection, ruleCache, ruleMeta) {
462
463
  }
463
464
  )
464
465
  );
465
- secondClone.walkDecls(moveDecl((decl) => decl.remove()));
466
+ // Remove exactly the declarations that were claimed: matching by value would
467
+ // also delete a duplicate that re-asserts the value later in the rule, after
468
+ // an overriding declaration that stays behind.
469
+ let laterIndex = 0;
470
+ secondClone.walkDecls((decl) => {
471
+ if (claimedIndices.has(laterIndex++)) {
472
+ decl.remove();
473
+ }
474
+ });
466
475
 
467
476
  // Ensure original rules are flushed for accurate length comparison
468
477
  if (ruleMeta) {
@@ -530,23 +539,35 @@ function partialMerge(
530
539
  ruleCache,
531
540
  ruleMeta
532
541
  );
533
- first = mergedNext.first;
534
- second = mergedNext.second;
542
+ const mergedFirst = mergedNext.first;
543
+ const mergedSecond = mergedNext.second;
535
544
  intersection = mergedNext.intersection;
536
545
 
537
- const metaFirstActual = getMeta(first, ruleMeta);
538
- const metaSecondActual = getMeta(second, ruleMeta);
539
- const firstDecls = [...metaFirstActual.declarations];
540
- const secondDecls = [...metaSecondActual.declarations];
546
+ const metaFirstActual = getMeta(mergedFirst, ruleMeta);
547
+ const metaSecondActual = getMeta(mergedSecond, ruleMeta);
548
+ const earlierRuleDeclarations = [...metaFirstActual.declarations];
549
+ const laterRuleDeclarations = [...metaSecondActual.declarations];
541
550
 
542
- intersection = filterIntersections(intersection, firstDecls, secondDecls);
551
+ const filtered = filterRuleIntersections(
552
+ intersection,
553
+ earlierRuleDeclarations,
554
+ laterRuleDeclarations
555
+ );
556
+ intersection = filtered.intersection;
543
557
 
544
558
  if (intersection.length === 0) {
545
559
  // Nothing to merge
546
- return second;
560
+ return mergedSecond;
547
561
  }
548
562
 
549
- return buildMergedRule(first, second, intersection, ruleCache, ruleMeta);
563
+ return buildMergedRule(
564
+ mergedFirst,
565
+ mergedSecond,
566
+ intersection,
567
+ filtered.claimedIndices,
568
+ ruleCache,
569
+ ruleMeta
570
+ );
550
571
  }
551
572
 
552
573
  /**
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const data = require('../data/propertyGroups.json');
4
+
5
+ const vendorPrefixRegex = /^-\w+-/;
6
+ /**
7
+ * @param {string} prop
8
+ * @return {string}
9
+ */
10
+ function vendorUnprefixed(prop) {
11
+ return prop.replace(vendorPrefixRegex, '');
12
+ }
13
+
14
+ // The generated file is JSON, so its maps arrive as plain objects. Property
15
+ // names come from the stylesheet, and `constructor` or `toString` are things a
16
+ // declaration can be called, so never index those objects directly.
17
+ const knownProperties = new Set(data.properties);
18
+ const aliases = new Map(Object.entries(data.aliases));
19
+ const shorthands = new Map(Object.entries(data.shorthands));
20
+ const logicalGroups = new Map(Object.entries(data.logicalGroups));
21
+ const flowRelative = new Set(data.flowRelative);
22
+
23
+ const ALL = 'all';
24
+
25
+ /**
26
+ * An alias target, or the candidate itself if it's a known property outright.
27
+ *
28
+ * @param {string} candidate
29
+ * @return {string|undefined}
30
+ */
31
+ function lookupProperty(candidate) {
32
+ const alias = aliases.get(candidate);
33
+ if (alias !== undefined) {
34
+ return alias;
35
+ }
36
+ return knownProperties.has(candidate) ? candidate : undefined;
37
+ }
38
+
39
+ /**
40
+ * Resolves a property to the name the generated data knows it by: vendor
41
+ * prefixed spellings collapse onto the property they alias, and a prefix we
42
+ * have no data for is dropped, since moving `-webkit-background-clip` past a
43
+ * `background` shorthand is as unsafe as moving the unprefixed property. Webref
44
+ * lists some prefixed spellings, like `-webkit-user-select`, as properties in
45
+ * their own right with no alias back to the unprefixed one, so the unprefixed
46
+ * spelling is always tried first, not just when the prefixed one is unknown.
47
+ *
48
+ * @param {string} name Lowercased property name.
49
+ * @return {{name: string, known: boolean}}
50
+ */
51
+ function resolveProperty(name) {
52
+ if (name.startsWith('-')) {
53
+ const resolved = lookupProperty(vendorUnprefixed(name));
54
+ if (resolved !== undefined) {
55
+ return { name: resolved, known: true };
56
+ }
57
+ }
58
+ const resolved = lookupProperty(name);
59
+ return resolved !== undefined
60
+ ? { name: resolved, known: true }
61
+ : { name, known: false };
62
+ }
63
+
64
+ /**
65
+ * The longhands a property sets. A longhand sets only itself.
66
+ *
67
+ * @param {string} name
68
+ * @return {string[]}
69
+ */
70
+ function longhandsOf(name) {
71
+ return shorthands.get(name) ?? [name];
72
+ }
73
+
74
+ /**
75
+ * True if two longhands can be the same physical property. Beyond being the
76
+ * same property, that happens between the flow-relative and the physical
77
+ * members of a logical property group, since `margin-inline-start` is
78
+ * `margin-top` under a vertical writing mode. Two physical members, or two
79
+ * flow-relative ones, always address different sides.
80
+ *
81
+ * @param {string} a
82
+ * @param {string} b
83
+ * @return {boolean}
84
+ */
85
+ function isSameLonghand(a, b) {
86
+ if (a === b) {
87
+ return true;
88
+ }
89
+ const group = logicalGroups.get(a);
90
+ return (
91
+ group !== undefined &&
92
+ group === logicalGroups.get(b) &&
93
+ flowRelative.has(a) !== flowRelative.has(b)
94
+ );
95
+ }
96
+
97
+ /**
98
+ * The name-based approximation the plugin relied on before it had property
99
+ * data: two properties interact when they share their leading segment and
100
+ * their remaining segments either match or differ in number. `place` is
101
+ * treated as a wildcard leading segment, since `place-content` expands to
102
+ * `align-content`/`justify-content` and the like. Reached only for vendor
103
+ * extensions no spec describes, such as `-webkit-box-direction`.
104
+ *
105
+ * @param {string} nameA
106
+ * @param {string} nameB
107
+ * @return {boolean}
108
+ */
109
+ function conflictingSegments(nameA, nameB) {
110
+ const a = vendorUnprefixed(nameA).split('-');
111
+ const b = vendorUnprefixed(nameB).split('-');
112
+ if (a[0] !== b[0] && a[0] !== 'place' && b[0] !== 'place') {
113
+ return false;
114
+ }
115
+ if (a.length !== b.length) {
116
+ return true;
117
+ }
118
+ return a.every((segment, index) => b[index] === segment);
119
+ }
120
+
121
+ /**
122
+ * True if declarations of `propA` and `propB` can set the same underlying
123
+ * property, so that reordering them within a rule can change what the rule
124
+ * computes to. The relation is symmetric: a shorthand setting a longhand and a
125
+ * longhand overriding part of a shorthand are the same conflict seen from
126
+ * either end.
127
+ *
128
+ * @param {string} propA
129
+ * @param {string} propB
130
+ * @return {boolean}
131
+ */
132
+ function isConflictingProp(propA, propB) {
133
+ if (propA === propB) {
134
+ return true;
135
+ }
136
+ // Nothing sets a custom property except itself, and custom properties are
137
+ // case-sensitive, so this must run before the names are lowercased below.
138
+ if (propA.startsWith('--') || propB.startsWith('--')) {
139
+ return false;
140
+ }
141
+ const nameA = propA.toLowerCase();
142
+ const nameB = propB.toLowerCase();
143
+ if (nameA === nameB) {
144
+ return true;
145
+ }
146
+ if (nameA === ALL || nameB === ALL) {
147
+ const other = nameA === ALL ? nameB : nameA;
148
+ return other !== 'direction' && other !== 'unicode-bidi';
149
+ }
150
+ const a = resolveProperty(nameA);
151
+ const b = resolveProperty(nameB);
152
+ if (a.name === b.name) {
153
+ return true;
154
+ }
155
+ if (!a.known || !b.known) {
156
+ // A vendor extension the data says nothing about. The shorthand relations
157
+ // that would settle it are exactly what is missing, so fall back to
158
+ // comparing the names.
159
+ return conflictingSegments(a.name, b.name);
160
+ }
161
+ for (const longhandA of longhandsOf(a.name)) {
162
+ for (const longhandB of longhandsOf(b.name)) {
163
+ if (isSameLonghand(longhandA, longhandB)) {
164
+ return true;
165
+ }
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+
171
+ module.exports = { isConflictingProp };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";;AAII,OAAQ,KAAA,YAAY,MAAM,cAAc,CAAA;AAQxC,OAAQ,KAAA,EAAC,WAAW,EAAO,MAAM,SAAS,CAAA;AAmD3C,YAAkB,QAAQ,GAC1B;;;;IAAqB,SAAS,EAAnB,MAAM,EAAE,CACnB;;;;IAA0B,YAAY,EAA3B,WAAW,EAAE,CACxB;;;;IAAoB,KAAK,EAAd,OAAO,CACpB;CAAA,CAAA;AA0kBE,YAAwD,mBAAmB,GAAjE;IAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE,CAAqB;AAC3E,YAAgE,mBAAmB,GAAzE,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC,CAAqB;AACnF,YAAqD,OAAO,GAAlD,mBAAmB,GAAG,mBAAmB,CAAS"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";;AAII,OAAQ,KAAA,YAAY,MAAM,cAAc,CAAA;AASxC,OAAQ,KAAA,EAAC,WAAW,EAAO,MAAM,SAAS,CAAA;AAmD3C,YAAkB,QAAQ,GAC1B;;;;IAAqB,SAAS,EAAnB,MAAM,EAAE,CACnB;;;;IAA0B,YAAY,EAA3B,WAAW,EAAE,CACxB;;;;IAAoB,KAAK,EAAd,OAAO,CACpB;CAAA,CAAA;AA8lBE,YAAwD,mBAAmB,GAAjE;IAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE,CAAqB;AAC3E,YAAgE,mBAAmB,GAAzE,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC,CAAqB;AACnF,YAAqD,OAAO,GAAlD,mBAAmB,GAAG,mBAAmB,CAAS"}
@@ -0,0 +1,17 @@
1
+ declare const _exports: {
2
+ isConflictingProp: typeof isConflictingProp;
3
+ };
4
+ export = _exports;
5
+ /**
6
+ * True if declarations of `propA` and `propB` can set the same underlying
7
+ * property, so that reordering them within a rule can change what the rule
8
+ * computes to. The relation is symmetric: a shorthand setting a longhand and a
9
+ * longhand overriding part of a shorthand are the same conflict seen from
10
+ * either end.
11
+ *
12
+ * @param {string} propA
13
+ * @param {string} propB
14
+ * @return {boolean}
15
+ */
16
+ declare function isConflictingProp(propA: string, propB: string): boolean;
17
+ //# sourceMappingURL=propertyRelations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"propertyRelations.d.ts","sourceRoot":"","sources":["../../src/lib/propertyRelations.js"],"names":[],"mappings":";;;;AAwHA;;;;;;;;;;GAUG;AACH,iBAAS,iBAAiB,CAAC,KAAK,EAJrB,MAIqB,EAAE,KAAK,EAH5B,MAG4B,GAF3B,OAAO,CAuClB"}