postcss-merge-idents 9.0.5 → 9.0.7

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-idents",
3
- "version": "9.0.5",
3
+ "version": "9.0.7",
4
4
  "description": "Merge keyframe and counter style identifiers.",
5
5
  "exports": {
6
6
  ".": {
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@csstools/css-tokenizer": "^4.0.2",
38
- "cssnano-utils": "^8.0.1"
38
+ "cssnano-utils": "^8.2.1"
39
39
  },
40
40
  "bugs": {
41
41
  "url": "https://github.com/cssnano/cssnano/issues"
@@ -44,15 +44,16 @@
44
44
  "node": "^22.22.3 || ^24.15.0 || >=26.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@webref/css": "8.7.5",
48
- "postcss": "^8.5.28"
47
+ "@webref/css": "8.7.6",
48
+ "postcss": "^8.5.29"
49
49
  },
50
50
  "peerDependencies": {
51
- "postcss": "^8.5.28"
51
+ "postcss": "^8.5.29"
52
52
  },
53
53
  "type": "module",
54
54
  "scripts": {
55
55
  "acquire": "node ./script/acquire.js",
56
+ "fuzz": "node ./script/fuzz.js",
56
57
  "test": "node --test"
57
58
  }
58
59
  }
package/src/index.js CHANGED
@@ -1,178 +1,192 @@
1
1
  import cssnanoUtils from 'cssnano-utils';
2
2
  import { LayerRegistry, compareAtRulePriority } from './lib/layerRegistry.js';
3
3
  import {
4
- classifyDeclaration,
5
4
  getBody,
6
- getContainer,
5
+ getConditionContainer,
7
6
  parseAtRuleName,
7
+ VENDOR_PREFIX,
8
8
  } from './lib/grammar.js';
9
- import { rewriteDeclaration } from './lib/valueRewriter.js';
9
+ import { scanDeclarations } from './lib/protectedNames.js';
10
+ import { createReplacement, rewriteDeclaration } from './lib/valueRewriter.js';
10
11
 
11
12
  const { asciiLowerCase } = cssnanoUtils;
12
13
 
13
14
  /**
15
+ * @typedef {Array<{ name: string, copies: AtRuleEntry[] }>} Group names that
16
+ * are defined in the same at-rules and containers with the same body
17
+ *
14
18
  * @typedef {{
15
19
  * rule: import('postcss').AtRule,
16
20
  * body?: string,
17
21
  * layerPriority: number[],
18
22
  * documentIndex: number,
23
+ * family: string,
24
+ * containerId: number,
19
25
  * parsed: { isString: boolean, isReservedName: boolean, key: string, tokenText: string }
20
26
  * }} AtRuleEntry
27
+ */
28
+
29
+ /**
30
+ * @param {AtRuleEntry[]} entries
31
+ * @return {AtRuleEntry}
32
+ */
33
+ function highestPriority(entries) {
34
+ let best = entries[0];
35
+ for (const entry of entries) {
36
+ if (compareAtRulePriority(best, entry) < 0) {
37
+ best = entry;
38
+ }
39
+ }
40
+ return best;
41
+ }
42
+
43
+ /**
44
+ * Every name of a group behaves alike, so the cascade priority does not
45
+ * matter for correctness; the shortest spelling minimizes output size and
46
+ * priority breaks ties.
21
47
  *
22
- * @typedef {{
23
- * entries: AtRuleEntry[],
24
- * resolver?: (key: string) => { text: string, key: string } | undefined,
25
- * definedNames?: Set<string>
26
- * }} FamilyRecord
48
+ * @param {AtRuleEntry[]} entries
49
+ * @return {AtRuleEntry}
27
50
  */
51
+ function shortestSpelling(entries) {
52
+ const shortest = Math.min(
53
+ ...entries.map(({ parsed }) => parsed.tokenText.length)
54
+ );
55
+ return highestPriority(
56
+ entries.filter(({ parsed }) => parsed.tokenText.length === shortest)
57
+ );
58
+ }
28
59
 
29
60
  /**
30
- * Collects the entries that may join a cross-name merge group: repeat
31
- * definitions of one name collapse to their highest priority copy first, and
32
- * only when all copies share a body. Reserved names never merge because a
33
- * stylesheet that spells one is not free to take another spelling.
61
+ * Removes repeat definitions of one name that share a body and a condition
62
+ * container, keeping the one that wins the cascade: the kept definition is
63
+ * present whenever a removed one is.
34
64
  *
35
- * @param {Map<string, AtRuleEntry[]>} byName
65
+ * @param {AtRuleEntry[]} copies definitions of one name
36
66
  * @param {Set<import('postcss').AtRule>} removals
37
- * @return {AtRuleEntry[]}
67
+ * @return {AtRuleEntry[]} the definitions that remain
38
68
  */
39
- function collectEligibleEntries(byName, removals) {
40
- /** @type {AtRuleEntry[]} */
41
- const eligible = [];
42
-
43
- for (const list of byName.values()) {
44
- if (list.length === 1) {
45
- if (!list[0].parsed.isReservedName) {
46
- eligible.push(list[0]);
47
- }
48
- continue;
49
- }
50
- const firstBody = getBody(list[0]);
51
- if (!list.every((item) => getBody(item) === firstBody)) {
69
+ function collapseSameNameCopies(copies, removals) {
70
+ if (copies.length < 2) {
71
+ return copies;
72
+ }
73
+ for (const group of Map.groupBy(copies, placementOf).values()) {
74
+ const body = getBody(group[0]);
75
+ if (group.length < 2 || !group.every((copy) => getBody(copy) === body)) {
52
76
  continue;
53
77
  }
54
- let survivor = list[0];
55
- for (let i = 1; i < list.length; i++) {
56
- if (compareAtRulePriority(survivor, list[i]) < 0) {
57
- survivor = list[i];
58
- }
59
- }
60
- for (const item of list) {
61
- if (item !== survivor) {
62
- removals.add(item.rule);
78
+ const survivor = highestPriority(group);
79
+ for (const copy of group) {
80
+ if (copy !== survivor) {
81
+ removals.add(copy.rule);
63
82
  }
64
83
  }
65
- if (!survivor.parsed.isReservedName) {
66
- eligible.push(survivor);
67
- }
68
84
  }
85
+ return copies.filter((copy) => !removals.has(copy.rule));
86
+ }
69
87
 
70
- return eligible;
88
+ /**
89
+ * @param {AtRuleEntry} entry
90
+ * @return {string} the at-rule family and condition container defining it
91
+ */
92
+ function placementOf(entry) {
93
+ return `${entry.family}@${entry.containerId}`;
71
94
  }
72
95
 
73
96
  /**
74
- * @param {AtRuleEntry[]} entries
97
+ * Makes each name of a group of interchangeable names, except the one that
98
+ * wins the cascade, refer to the winner. A name that substituted text may
99
+ * spell keeps its definition and is preferred as the merge target.
100
+ *
101
+ * @param {Group} group
102
+ * @param {Set<string>} protectedNames
103
+ * @param {Map<string, import('./lib/valueRewriter.js').Replacement>} renames
75
104
  * @param {Set<import('postcss').AtRule>} removals
76
- * @return {{ resolver: (key: string) => { text: string, key: string } | undefined, definedNames: Set<string>, hasReplacements: boolean }}
105
+ * @return {void}
77
106
  */
78
- function processAtRuleEntries(entries, removals) {
79
- const definedNames = new Set();
80
- if (entries.length === 0) {
81
- return {
82
- resolver: () => undefined,
83
- definedNames,
84
- hasReplacements: false,
85
- };
86
- }
87
-
88
- for (const entry of entries) {
89
- definedNames.add(entry.parsed.key);
107
+ function mergeInterchangeable(group, protectedNames, renames, removals) {
108
+ const protectedMembers = group.filter(({ name }) => protectedNames.has(name));
109
+ const targetCandidates = (
110
+ protectedMembers.length > 0 ? protectedMembers : group
111
+ ).flatMap(({ copies }) => copies);
112
+ // An identifier reference is shorter than a string and more widely
113
+ // supported as a keyframes name.
114
+ const identifiers = targetCandidates.filter(({ parsed }) => !parsed.isString);
115
+ const target = shortestSpelling(
116
+ identifiers.length > 0 ? identifiers : targetCandidates
117
+ );
118
+ const replacement = createReplacement(target.parsed);
119
+ for (const { name, copies } of group) {
120
+ if (name === target.parsed.key || protectedNames.has(name)) {
121
+ continue;
122
+ }
123
+ renames.set(name, replacement);
124
+ for (const copy of copies) {
125
+ removals.add(copy.rule);
126
+ }
90
127
  }
128
+ }
91
129
 
92
- if (entries.length === 1) {
93
- return {
94
- resolver: () => undefined,
95
- definedNames,
96
- hasReplacements: false,
97
- };
130
+ /**
131
+ * Finds the groups of interchangeable names of one namespace and removes
132
+ * repeat definitions of a single name. Two names are interchangeable when
133
+ * their definitions, in cascade order, sit in the same at-rule families and
134
+ * condition containers with the same bodies: whichever definition a browser
135
+ * picks for one name, it picks the same body for the other. This lets
136
+ * `@-webkit-keyframes` and `@keyframes` pairs merge although their bodies
137
+ * differ. A string and an ident of one value are one name. Bodies are
138
+ * serialized only for names that share where they are defined.
139
+ *
140
+ * @param {AtRuleEntry[]} entries
141
+ * @param {Set<import('postcss').AtRule>} removals
142
+ * @return {Group[]} groups of two or more names
143
+ */
144
+ function findInterchangeableGroups(entries, removals) {
145
+ if (entries.length < 2) {
146
+ return [];
98
147
  }
99
148
 
100
- /** @type {Map<string, AtRuleEntry[]>} */
101
- const byName = new Map();
102
- for (const entry of entries) {
103
- let nameList = byName.get(entry.parsed.key);
104
- if (!nameList) {
105
- nameList = [];
106
- byName.set(entry.parsed.key, nameList);
149
+ /** @type {Group} */
150
+ const candidates = [];
151
+ for (const [name, all] of Map.groupBy(entries, ({ parsed }) => parsed.key)) {
152
+ const copies = collapseSameNameCopies(all, removals);
153
+ if (!copies.some(({ parsed }) => parsed.isReservedName)) {
154
+ candidates.push({ name, copies: copies.toSorted(compareAtRulePriority) });
107
155
  }
108
- nameList.push(entry);
109
156
  }
110
157
 
111
- const eligible = collectEligibleEntries(byName, removals);
112
-
113
- /** @type {Map<string, AtRuleEntry[]>} */
114
- const groupsByBody = new Map();
115
-
116
- for (const item of eligible) {
117
- const groupKey = (item.parsed.isString ? 's:' : 'i:') + getBody(item);
118
- let group = groupsByBody.get(groupKey);
119
- if (!group) {
120
- group = [];
121
- groupsByBody.set(groupKey, group);
158
+ /** @type {Group[]} */
159
+ const groups = [];
160
+ const bySites = Map.groupBy(candidates, ({ copies }) =>
161
+ copies.map(placementOf).join(' ')
162
+ );
163
+ for (const sameSites of bySites.values()) {
164
+ if (sameSites.length < 2) {
165
+ continue;
122
166
  }
123
- group.push(item);
124
- }
125
-
126
- /** @type {Map<string, { text: string, key: string }>} */
127
- const resolved = new Map();
128
-
129
- for (const group of groupsByBody.values()) {
130
- if (group.length > 1) {
131
- let survivor = group[0];
132
- for (let i = 1; i < group.length; i++) {
133
- if (compareAtRulePriority(survivor, group[i]) < 0) {
134
- survivor = group[i];
135
- }
136
- }
137
- for (const item of group) {
138
- if (item !== survivor) {
139
- removals.add(item.rule);
140
- resolved.set(item.parsed.key, {
141
- text: survivor.parsed.tokenText,
142
- key: survivor.parsed.key,
143
- });
144
- }
167
+ const byBodies = Map.groupBy(sameSites, ({ copies }) =>
168
+ JSON.stringify(copies.map(getBody))
169
+ );
170
+ for (const sameBodies of byBodies.values()) {
171
+ if (sameBodies.length > 1) {
172
+ groups.push(sameBodies);
145
173
  }
146
174
  }
147
175
  }
148
-
149
- return {
150
- resolver: (key) => resolved.get(key),
151
- definedNames,
152
- hasReplacements: resolved.size > 0,
153
- };
176
+ return groups;
154
177
  }
155
178
 
156
179
  /**
157
- * Finds or creates the family record of one at-rule name in one container.
158
- *
159
- * @param {Map<import('postcss').Container, Map<string, FamilyRecord>>} scopes
160
- * @param {import('postcss').Container} container
161
- * @param {string} name
162
- * @return {FamilyRecord}
180
+ * @param {string} family lowercase at-rule name
181
+ * @return {'keyframes' | 'counter-style' | undefined}
163
182
  */
164
- function familyRecordFor(scopes, container, name) {
165
- let containerScope = scopes.get(container);
166
- if (!containerScope) {
167
- containerScope = new Map();
168
- scopes.set(container, containerScope);
169
- }
170
- let familyData = containerScope.get(name);
171
- if (!familyData) {
172
- familyData = { entries: [] };
173
- containerScope.set(name, familyData);
174
- }
175
- return familyData;
183
+ function namespaceOf(family) {
184
+ // Only the prefixes whose animation properties the rewriter recognizes
185
+ // qualify; a reference to any other family would be left dangling.
186
+ const unprefixed = family.replace(VENDOR_PREFIX, '');
187
+ return unprefixed === 'keyframes' || unprefixed === 'counter-style'
188
+ ? unprefixed
189
+ : undefined;
176
190
  }
177
191
 
178
192
  /**
@@ -181,79 +195,116 @@ function familyRecordFor(scopes, container, name) {
181
195
  */
182
196
  function mergeAtRules(css) {
183
197
  const layerRegistry = new LayerRegistry();
184
- let keyframesCount = 0;
185
- let counterStyleCount = 0;
198
+ /** @type {Map<'keyframes' | 'counter-style', AtRuleEntry[]>} */
199
+ const namespaces = new Map([
200
+ ['keyframes', []],
201
+ ['counter-style', []],
202
+ ]);
203
+ /** @type {Map<import('postcss').Container, number>} */
204
+ const containerIds = new Map();
205
+ /** @type {import('postcss').AtRule[]} */
206
+ const functionRules = [];
207
+ /** @type {import('postcss').AtRule[]} */
208
+ const parameterRules = [];
209
+ /** @type {import('postcss').AtRule[]} */
210
+ const conditionRules = [];
186
211
  let documentIndex = 0;
187
212
 
188
- /** @type {Map<import('postcss').Container, Map<string, FamilyRecord>>} */
189
- const scopes = new Map();
190
-
191
213
  css.walkAtRules((atRule) => {
192
- const name = asciiLowerCase(atRule.name);
193
- if (name === 'layer') {
214
+ const family = asciiLowerCase(atRule.name);
215
+ if (family === 'layer') {
194
216
  layerRegistry.declareLayerAtRule(atRule);
195
217
  return;
196
218
  }
197
-
198
- const isKeyframes = name.endsWith('keyframes');
199
- const isCounterStyle = name.endsWith('counter-style');
200
- if (!isKeyframes && !isCounterStyle) {
219
+ if (family === 'function') {
220
+ functionRules.push(atRule);
221
+ return;
222
+ }
223
+ // Mixin parameter defaults and @apply arguments are substituted text.
224
+ if (family === 'mixin' || family === 'apply') {
225
+ parameterRules.push(atRule);
201
226
  return;
202
227
  }
203
228
 
204
- if (isKeyframes) {
205
- keyframesCount++;
206
- } else {
207
- counterStyleCount++;
229
+ if (family === 'container' || family === 'when' || family === 'else') {
230
+ conditionRules.push(atRule);
231
+ return;
208
232
  }
209
233
 
210
- const container = getContainer(atRule);
211
- const familyData = familyRecordFor(scopes, container, name);
234
+ const namespace = namespaceOf(family);
235
+ const params = atRule.raws?.between
236
+ ? atRule.params + atRule.raws.between
237
+ : atRule.params;
238
+ const parsed = namespace && parseAtRuleName(params, family);
239
+ // A statement without a block defines nothing.
240
+ if (!namespace || !atRule.nodes || !parsed) {
241
+ return;
242
+ }
243
+ const entries = /** @type {AtRuleEntry[]} */ (namespaces.get(namespace));
212
244
 
213
- const parsed = parseAtRuleName(atRule.params, name);
214
- if (parsed) {
215
- familyData.entries.push({
216
- rule: atRule,
217
- parsed,
218
- layerPriority: layerRegistry.getPriority(atRule),
219
- documentIndex: documentIndex++,
220
- });
245
+ const container = getConditionContainer(atRule);
246
+ let containerId = containerIds.get(container);
247
+ if (containerId === undefined) {
248
+ containerId = containerIds.size;
249
+ containerIds.set(container, containerId);
221
250
  }
251
+ entries.push({
252
+ rule: atRule,
253
+ parsed,
254
+ family,
255
+ containerId,
256
+ layerPriority: layerRegistry.getPriority(atRule),
257
+ documentIndex: documentIndex++,
258
+ });
222
259
  });
223
260
 
224
- if (keyframesCount < 2 && counterStyleCount < 2) {
261
+ if ([...namespaces.values()].every((entries) => entries.length < 2)) {
225
262
  return;
226
263
  }
227
264
 
228
265
  /** @type {Set<import('postcss').AtRule>} */
229
266
  const removals = new Set();
230
- let hasReplacements = false;
231
-
232
- for (const containerScope of scopes.values()) {
233
- for (const familyData of containerScope.values()) {
234
- const result = processAtRuleEntries(familyData.entries, removals);
235
- familyData.resolver = result.resolver;
236
- familyData.definedNames = result.definedNames;
237
- if (result.hasReplacements) {
238
- hasReplacements = true;
239
- }
267
+ const groupsByNamespace = new Map(
268
+ [...namespaces].map(([namespace, entries]) => [
269
+ namespace,
270
+ findInterchangeableGroups(entries, removals),
271
+ ])
272
+ );
273
+ const groupedNames = new Set(
274
+ [...groupsByNamespace.values()].flatMap((groups) =>
275
+ groups.flatMap((group) => group.map(({ name }) => name))
276
+ )
277
+ );
278
+ // The protection scan is needed only to merge names, never to remove
279
+ // repeat definitions of one name.
280
+ const { protectedNames, references } =
281
+ groupedNames.size > 0
282
+ ? scanDeclarations(
283
+ css,
284
+ groupedNames,
285
+ functionRules,
286
+ parameterRules,
287
+ conditionRules
288
+ )
289
+ : { protectedNames: new Set(), references: [] };
290
+ /** @type {Map<string, Map<string, import('./lib/valueRewriter.js').Replacement>>} */
291
+ const renames = new Map();
292
+ for (const [namespace, groups] of groupsByNamespace) {
293
+ /** @type {Map<string, import('./lib/valueRewriter.js').Replacement>} */
294
+ const namespaceRenames = new Map();
295
+ for (const group of groups) {
296
+ mergeInterchangeable(group, protectedNames, namespaceRenames, removals);
240
297
  }
298
+ renames.set(namespace, namespaceRenames);
241
299
  }
242
300
 
243
301
  if (removals.size === 0) {
244
302
  return;
245
303
  }
246
304
 
247
- if (hasReplacements) {
248
- const singleScope =
249
- scopes.size === 1 ? (scopes.values().next().value ?? null) : null;
250
-
251
- css.walkDecls((decl) => {
252
- const classification = classifyDeclaration(decl);
253
- if (classification) {
254
- rewriteDeclaration(decl, classification, scopes, singleScope);
255
- }
256
- });
305
+ // Removing a repeat definition of one name leaves no reference to respell.
306
+ if ([...renames.values()].some((namespaceRenames) => namespaceRenames.size)) {
307
+ rewriteReferences(references, renames);
257
308
  }
258
309
 
259
310
  for (const node of removals) {
@@ -261,6 +312,20 @@ function mergeAtRules(css) {
261
312
  }
262
313
  }
263
314
 
315
+ /**
316
+ * @param {import('./lib/protectedNames.js').Reference[]} references
317
+ * @param {Map<string, Map<string, import('./lib/valueRewriter.js').Replacement>>} renames
318
+ * @return {void}
319
+ */
320
+ function rewriteReferences(references, renames) {
321
+ for (const { decl, classification, value } of references) {
322
+ const namespaceRenames = renames.get(classification.namespace);
323
+ if (namespaceRenames?.size) {
324
+ rewriteDeclaration(decl, classification.kind, namespaceRenames, value);
325
+ }
326
+ }
327
+ }
328
+
264
329
  /**
265
330
  * @return {import('postcss').Plugin}
266
331
  */