postcss-merge-idents 9.0.3 → 9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-merge-idents",
3
- "version": "9.0.3",
3
+ "version": "9.0.4",
4
4
  "description": "Merge keyframe and counter style identifiers.",
5
5
  "exports": {
6
6
  ".": {
@@ -34,8 +34,8 @@
34
34
  "url": "git+https://github.com/cssnano/cssnano.git"
35
35
  },
36
36
  "dependencies": {
37
- "cssnano-utils": "^7.0.3",
38
- "postcss-value-parser": "^4.2.0"
37
+ "@csstools/css-tokenizer": "^4.0.2",
38
+ "cssnano-utils": "^8.0.0"
39
39
  },
40
40
  "bugs": {
41
41
  "url": "https://github.com/cssnano/cssnano/issues"
@@ -44,6 +44,7 @@
44
44
  "node": "^22.22.3 || ^24.15.0 || >=26.0"
45
45
  },
46
46
  "devDependencies": {
47
+ "@webref/css": "8.7.5",
47
48
  "postcss": "^8.5.28"
48
49
  },
49
50
  "peerDependencies": {
@@ -51,6 +52,7 @@
51
52
  },
52
53
  "type": "module",
53
54
  "scripts": {
55
+ "acquire": "node ./script/acquire.mjs",
54
56
  "test": "node --test"
55
57
  }
56
58
  }
@@ -0,0 +1,58 @@
1
+ {
2
+ "cssWideKeywords": [
3
+ "inherit",
4
+ "initial",
5
+ "revert",
6
+ "revert-layer",
7
+ "revert-rule",
8
+ "unset"
9
+ ],
10
+ "keyframes": {
11
+ "shorthandKeywords": [
12
+ "alternate",
13
+ "alternate-reverse",
14
+ "auto",
15
+ "backwards",
16
+ "both",
17
+ "ease",
18
+ "ease-in",
19
+ "ease-in-out",
20
+ "ease-out",
21
+ "forwards",
22
+ "infinite",
23
+ "linear",
24
+ "none",
25
+ "normal",
26
+ "paused",
27
+ "reverse",
28
+ "running",
29
+ "step-end",
30
+ "step-start"
31
+ ]
32
+ },
33
+ "counterStyle": {
34
+ "keywords": [
35
+ "additive",
36
+ "alphabetic",
37
+ "auto",
38
+ "bullets",
39
+ "cyclic",
40
+ "extends",
41
+ "fixed",
42
+ "inside",
43
+ "none",
44
+ "numbers",
45
+ "numeric",
46
+ "outside",
47
+ "spell-out",
48
+ "symbolic",
49
+ "words"
50
+ ],
51
+ "functions": {
52
+ "counter()": [1],
53
+ "counters()": [2],
54
+ "target-counter()": [2],
55
+ "target-counters()": [3]
56
+ }
57
+ }
58
+ }
package/src/index.js CHANGED
@@ -1,130 +1,263 @@
1
- import valueParser from 'postcss-value-parser';
2
1
  import cssnanoUtils from 'cssnano-utils';
2
+ import { LayerRegistry, compareAtRulePriority } from './lib/layerRegistry.js';
3
+ import {
4
+ classifyDeclaration,
5
+ getBody,
6
+ getContainer,
7
+ parseAtRuleName,
8
+ } from './lib/grammar.js';
9
+ import { rewriteDeclaration } from './lib/valueRewriter.js';
10
+
11
+ const { asciiLowerCase } = cssnanoUtils;
12
+
13
+ /**
14
+ * @typedef {{
15
+ * rule: import('postcss').AtRule,
16
+ * body?: string,
17
+ * layerPriority: number[],
18
+ * documentIndex: number,
19
+ * parsed: { isString: boolean, isReservedName: boolean, key: string, tokenText: string }
20
+ * }} AtRuleEntry
21
+ *
22
+ * @typedef {{
23
+ * entries: AtRuleEntry[],
24
+ * resolver?: (key: string) => { text: string, key: string } | undefined,
25
+ * definedNames?: Set<string>
26
+ * }} FamilyRecord
27
+ */
28
+
29
+ /**
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.
34
+ *
35
+ * @param {Map<string, AtRuleEntry[]>} byName
36
+ * @param {Set<import('postcss').AtRule>} removals
37
+ * @return {AtRuleEntry[]}
38
+ */
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)) {
52
+ continue;
53
+ }
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);
63
+ }
64
+ }
65
+ if (!survivor.parsed.isReservedName) {
66
+ eligible.push(survivor);
67
+ }
68
+ }
69
+
70
+ return eligible;
71
+ }
3
72
 
4
- const { sameParent } = cssnanoUtils;
5
- const keyframesRegex = /keyframes/i;
6
- const animationRegex = /animation/i;
7
- const counterStyleRegex = /counter-style/i;
8
- const listStyleSystemRegex = /(list-style|system)/i;
9
73
  /**
10
- * @param {Record<string, string>} obj
11
- * @return {(key: string) => string}
74
+ * @param {AtRuleEntry[]} entries
75
+ * @param {Set<import('postcss').AtRule>} removals
76
+ * @return {{ resolver: (key: string) => { text: string, key: string } | undefined, definedNames: Set<string>, hasReplacements: boolean }}
12
77
  */
13
- function canonical(obj) {
14
- // Prevent potential infinite loops
15
- let stack = 50;
16
-
17
- /**
18
- * @param {string} key
19
- * @return {string}
20
- */
21
- return function recurse(key) {
22
- if (Object.hasOwn(obj, key) && obj[key] !== key && stack) {
23
- stack--;
24
-
25
- return recurse(obj[key]);
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);
90
+ }
91
+
92
+ if (entries.length === 1) {
93
+ return {
94
+ resolver: () => undefined,
95
+ definedNames,
96
+ hasReplacements: false,
97
+ };
98
+ }
99
+
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);
26
107
  }
108
+ nameList.push(entry);
109
+ }
27
110
 
28
- stack = 50;
111
+ const eligible = collectEligibleEntries(byName, removals);
29
112
 
30
- return key;
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);
122
+ }
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
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return {
150
+ resolver: (key) => resolved.get(key),
151
+ definedNames,
152
+ hasReplacements: resolved.size > 0,
31
153
  };
32
154
  }
33
155
 
156
+ /**
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}
163
+ */
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;
176
+ }
177
+
34
178
  /**
35
179
  * @param {import('postcss').Root} css
36
180
  * @return {void}
37
181
  */
38
182
  function mergeAtRules(css) {
39
- const pairs = [
40
- {
41
- atrule: keyframesRegex,
42
- decl: animationRegex,
43
- /** @type {import('postcss').AtRule[]} */
44
- cache: [],
45
- replacements: {},
46
- /** @type {import('postcss').Declaration[]} */
47
- decls: [],
48
- /** @type {import('postcss').AtRule[]} */
49
- removals: [],
50
- },
51
- {
52
- atrule: counterStyleRegex,
53
- decl: listStyleSystemRegex,
54
- cache: [],
55
- replacements: {},
56
- decls: [],
57
- removals: [],
58
- },
59
- ];
183
+ const layerRegistry = new LayerRegistry();
184
+ let keyframesCount = 0;
185
+ let counterStyleCount = 0;
186
+ let documentIndex = 0;
60
187
 
61
- /**
62
- * @type {{atrule: RegExp, decl: RegExp, replacements: Record<string, string>, removals: import('postcss').AtRule[], cache: import('postcss').AtRule[], decls: import('postcss').Declaration[]} | undefined}
63
- */
64
- let relevant;
188
+ /** @type {Map<import('postcss').Container, Map<string, FamilyRecord>>} */
189
+ const scopes = new Map();
65
190
 
66
- css.walk((node) => {
67
- if (node.type === 'atrule') {
68
- relevant = pairs.find((pair) =>
69
- pair.atrule.test(node.name.toLowerCase())
70
- );
191
+ css.walkAtRules((atRule) => {
192
+ const name = asciiLowerCase(atRule.name);
193
+ if (name === 'layer') {
194
+ layerRegistry.declareLayerAtRule(atRule);
195
+ return;
196
+ }
71
197
 
72
- if (!relevant) {
73
- return;
74
- }
198
+ const isKeyframes = name.endsWith('keyframes');
199
+ const isCounterStyle = name.endsWith('counter-style');
200
+ if (!isKeyframes && !isCounterStyle) {
201
+ return;
202
+ }
75
203
 
76
- if (relevant.cache.length < 1) {
77
- relevant.cache.push(node);
78
- return;
79
- } else {
80
- const toString = node.nodes ? node.nodes.toString() : '';
81
-
82
- for (const cached of relevant.cache) {
83
- const cachedStringContent = cached.nodes
84
- ? cached.nodes.toString()
85
- : '';
86
- if (
87
- cached.name.toLowerCase() === node.name.toLowerCase() &&
88
- sameParent(cached, node) &&
89
- cachedStringContent === toString
90
- ) {
91
- relevant.removals.push(cached);
92
- relevant.replacements[cached.params] = node.params;
93
- }
94
- }
204
+ if (isKeyframes) {
205
+ keyframesCount++;
206
+ } else {
207
+ counterStyleCount++;
208
+ }
95
209
 
96
- relevant.cache.push(node);
210
+ const container = getContainer(atRule);
211
+ const familyData = familyRecordFor(scopes, container, name);
97
212
 
98
- return;
99
- }
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
+ });
100
221
  }
222
+ });
223
+
224
+ if (keyframesCount < 2 && counterStyleCount < 2) {
225
+ return;
226
+ }
101
227
 
102
- if (node.type === 'decl') {
103
- relevant = pairs.find((pair) => pair.decl.test(node.prop.toLowerCase()));
228
+ /** @type {Set<import('postcss').AtRule>} */
229
+ const removals = new Set();
230
+ let hasReplacements = false;
104
231
 
105
- if (!relevant) {
106
- return;
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;
107
239
  }
108
-
109
- relevant.decls.push(node);
110
240
  }
111
- });
241
+ }
112
242
 
113
- for (const pair of pairs) {
114
- const canon = canonical(pair.replacements);
115
-
116
- for (const decl of pair.decls) {
117
- decl.value = valueParser(decl.value)
118
- .walk((node) => {
119
- if (node.type === 'word') {
120
- node.value = canon(node.value);
121
- }
122
- })
123
- .toString();
124
- }
125
- for (const cached of pair.removals) {
126
- cached.remove();
127
- }
243
+ if (removals.size === 0) {
244
+ return;
245
+ }
246
+
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
+ });
257
+ }
258
+
259
+ for (const node of removals) {
260
+ node.remove();
128
261
  }
129
262
  }
130
263