css2class 2.0.0 → 2.0.1

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.
Files changed (40) hide show
  1. package/.github/workflows/deploy-docs.yml +53 -0
  2. package/.head_config.mjs +68 -0
  3. package/CONFIG.md +38 -1
  4. package/README.md +595 -633
  5. package/bin/class2css.js +32 -4
  6. package/common.css +1 -1
  7. package/configs/typography.config.js +1 -0
  8. package/docs/.vitepress/config.mjs +68 -65
  9. package/docs/guide/cli.md +97 -97
  10. package/docs/guide/config-template.md +16 -1
  11. package/docs/guide/config.md +129 -64
  12. package/docs/guide/faq.md +202 -202
  13. package/docs/guide/getting-started.md +86 -83
  14. package/docs/guide/incremental.md +164 -162
  15. package/docs/guide/rules-reference.md +73 -1
  16. package/docs/index.md +71 -68
  17. package/examples/weapp/README.md +15 -0
  18. package/examples/weapp/class2css.config.js +70 -0
  19. package/examples/weapp/src/placeholder.wxml +0 -0
  20. package/examples/weapp/styles.config.js +201 -0
  21. package/examples/web/README.md +25 -0
  22. package/examples/web/class2css.config.js +70 -0
  23. package/examples/web/demo.html +105 -0
  24. package/examples/web/src/placeholder.html +5 -0
  25. package/examples/web/styles.config.js +201 -0
  26. package/package.json +7 -2
  27. package/src/README.md +99 -0
  28. package/src/core/ConfigManager.js +440 -431
  29. package/src/core/FullScanManager.js +438 -430
  30. package/src/generators/DynamicClassGenerator.js +270 -72
  31. package/src/index.js +1091 -1046
  32. package/src/parsers/ClassParser.js +8 -2
  33. package/src/utils/CssFormatter.js +400 -47
  34. package/src/utils/UnitProcessor.js +4 -3
  35. package/src/watchers/ConfigWatcher.js +413 -413
  36. package/src/watchers/FileWatcher.js +148 -133
  37. package/src/writers/FileWriter.js +444 -302
  38. package/src/writers/UnifiedWriter.js +413 -370
  39. package/class2css.config.js +0 -124
  40. package/styles.config.js +0 -250
@@ -51,8 +51,12 @@ class ClassParser {
51
51
  // 智能类名预处理和验证(使用 base class 进行分析)
52
52
  const processedClass = this.preprocessClassName(className, cleanName);
53
53
 
54
+ const isStaticHit = this.userStaticClassSet.has(baseClass);
55
+ // 如果命中静态类定义,则不再把它当成动态类候选,避免同一 selector 重复生成两份规则
56
+ const isDynamicCandidate = baseClass.includes('-') && !isStaticHit;
57
+
54
58
  // 先检查是否是静态类(使用 base class 检查)
55
- if (this.userStaticClassSet.has(baseClass)) {
59
+ if (isStaticHit) {
56
60
  userStaticList.add(processedClass.original); // 保存原始 class 名(如 sm:flex)
57
61
  this.eventBus.emit('parser:static:found', {
58
62
  className: processedClass.original,
@@ -63,7 +67,7 @@ class ClassParser {
63
67
  }
64
68
 
65
69
  // 再检查是否是动态类(使用 base class 检查)
66
- if (baseClass.includes('-')) {
70
+ if (isDynamicCandidate) {
67
71
  classList.add(processedClass.original); // 保存原始 class 名(如 sm:w-100)
68
72
  this.eventBus.emit('parser:dynamic:found', {
69
73
  className: processedClass.original,
@@ -173,6 +177,7 @@ class ClassParser {
173
177
  'text',
174
178
  'font',
175
179
  'leading',
180
+ 'lh',
176
181
  'tracking',
177
182
  'spacing',
178
183
  'top',
@@ -219,6 +224,7 @@ class ClassParser {
219
224
  text: 'font-size',
220
225
  font: 'font-weight',
221
226
  leading: 'line-height',
227
+ lh: 'line-height',
222
228
  tracking: 'letter-spacing',
223
229
  top: 'top',
224
230
  right: 'right',
@@ -3,6 +3,36 @@ class CssFormatter {
3
3
  this.format = format;
4
4
  }
5
5
 
6
+ // 将 states 变体转换为 CSS 伪类选择器片段
7
+ // 例如:['hover','first','odd'] -> ':hover:first-child:nth-child(odd)'
8
+ buildStatePseudoSelectors(stateVariants = []) {
9
+ if (!Array.isArray(stateVariants) || stateVariants.length === 0) {
10
+ return '';
11
+ }
12
+
13
+ const map = {
14
+ hover: 'hover',
15
+ focus: 'focus',
16
+ active: 'active',
17
+ disabled: 'disabled',
18
+ first: 'first-child',
19
+ last: 'last-child',
20
+ odd: 'nth-child(odd)',
21
+ even: 'nth-child(even)',
22
+ };
23
+
24
+ const pseudos = [];
25
+ for (const v of stateVariants) {
26
+ if (!v || typeof v !== 'string') continue;
27
+ const key = v.trim();
28
+ if (!key) continue;
29
+ const mapped = map[key] || key; // 允许用户扩展为标准伪类(如 visited、focus-within 等)
30
+ pseudos.push(mapped);
31
+ }
32
+
33
+ return pseudos.length > 0 ? ':' + pseudos.join(':') : '';
34
+ }
35
+
6
36
  // 转义 CSS 选择器中的特殊字符
7
37
  // 确保生成的 CSS 选择器能正确匹配 HTML/WXML 中的 class 名
8
38
  escapeSelector(selector) {
@@ -31,26 +61,30 @@ class CssFormatter {
31
61
  }
32
62
 
33
63
  // 格式化单个CSS规则
34
- formatRule(selector, properties, format = null) {
64
+ // stateVariants: 状态变体数组(如 ['hover', 'focus']),用于生成伪类选择器
65
+ formatRule(selector, properties, format = null, stateVariants = []) {
35
66
  const targetFormat = format || this.format;
36
67
 
68
+ // 构建带伪类的选择器
69
+ const escapedSelector = this.escapeSelector(selector);
70
+ const pseudoSelectors = this.buildStatePseudoSelectors(stateVariants);
71
+ const fullSelector = escapedSelector + pseudoSelectors;
72
+
37
73
  if (targetFormat === 'compressed') {
38
- return this.formatRuleCompressed(selector, properties);
74
+ return this.formatRuleCompressed(fullSelector, properties);
39
75
  } else if (targetFormat === 'singleLine') {
40
- return this.formatRuleSingleLine(selector, properties);
76
+ return this.formatRuleSingleLine(fullSelector, properties);
41
77
  } else {
42
- return this.formatRuleMultiLine(selector, properties);
78
+ return this.formatRuleMultiLine(fullSelector, properties);
43
79
  }
44
80
  }
45
81
 
46
82
  // 多行格式(默认)
83
+ // 注意:selector 参数已经包含转义和伪类(由 formatRule 处理)
47
84
  formatRuleMultiLine(selector, properties) {
48
- // 转义选择器中的特殊字符
49
- const escapedSelector = this.escapeSelector(selector);
50
-
51
85
  if (Array.isArray(properties)) {
52
86
  // 属性数组格式:[{property: 'margin', value: '10rpx'}, ...]
53
- let css = `\n.${escapedSelector} {\n`;
87
+ let css = `\n.${selector} {\n`;
54
88
  properties.forEach(({ property, value }) => {
55
89
  css += ` ${property}: ${value};\n`;
56
90
  });
@@ -58,36 +92,32 @@ class CssFormatter {
58
92
  return css;
59
93
  } else if (typeof properties === 'string') {
60
94
  // 字符串格式:'margin: 10rpx;'
61
- return `\n.${escapedSelector} {\n ${properties}\n}\n`;
95
+ return `\n.${selector} {\n ${properties}\n}\n`;
62
96
  }
63
97
  return '';
64
98
  }
65
99
 
66
100
  // 单行格式
101
+ // 注意:selector 参数已经包含转义和伪类(由 formatRule 处理)
67
102
  formatRuleSingleLine(selector, properties) {
68
- // 转义选择器中的特殊字符
69
- const escapedSelector = this.escapeSelector(selector);
70
-
71
103
  if (Array.isArray(properties)) {
72
104
  // 属性数组格式
73
105
  const propsStr = properties.map(({ property, value }) => `${property}: ${value}`).join('; ');
74
- return `.${escapedSelector} { ${propsStr}; }\n`;
106
+ return `.${selector} { ${propsStr}; }\n`;
75
107
  } else if (typeof properties === 'string') {
76
108
  // 字符串格式
77
- return `.${escapedSelector} { ${properties}; }\n`;
109
+ return `.${selector} { ${properties}; }\n`;
78
110
  }
79
111
  return '';
80
112
  }
81
113
 
82
114
  // 压缩格式
115
+ // 注意:selector 参数已经包含转义和伪类(由 formatRule 处理)
83
116
  formatRuleCompressed(selector, properties) {
84
- // 转义选择器中的特殊字符
85
- const escapedSelector = this.escapeSelector(selector);
86
-
87
117
  if (Array.isArray(properties)) {
88
118
  // 属性数组格式
89
119
  const propsStr = properties.map(({ property, value }) => `${property}:${value}`).join(';');
90
- return `.${escapedSelector}{${propsStr}}`;
120
+ return `.${selector}{${propsStr}}`;
91
121
  } else if (typeof properties === 'string') {
92
122
  // 字符串格式,移除空格但保留分号(除了最后一个分号)
93
123
  let cleanProps = properties.replace(/\s+/g, ''); // 移除所有空格
@@ -95,7 +125,7 @@ class CssFormatter {
95
125
  cleanProps = cleanProps.replace(/;+$/, ''); // 移除末尾的分号
96
126
  // 确保分号后面没有空格(虽然已经移除了空格,但为了安全)
97
127
  cleanProps = cleanProps.replace(/;+/g, ';'); // 多个分号合并为一个
98
- return `.${escapedSelector}{${cleanProps}}`;
128
+ return `.${selector}{${cleanProps}}`;
99
129
  }
100
130
  return '';
101
131
  }
@@ -118,8 +148,29 @@ class CssFormatter {
118
148
  }
119
149
 
120
150
  // 压缩CSS(去除所有空格、换行、注释)
151
+ // 但保留 CLASS2CSS 分区标记注释(用于 appendDelta 模式)
121
152
  compressCSS(cssString) {
122
- return cssString
153
+ // 先提取并临时替换分区标记,避免被移除
154
+ const partitionMarkers = {
155
+ baseStart: '/* CLASS2CSS:BASE_START */',
156
+ baseEnd: '/* CLASS2CSS:BASE_END */',
157
+ mediaStart: '/* CLASS2CSS:MEDIA_START */',
158
+ mediaEnd: '/* CLASS2CSS:MEDIA_END */',
159
+ deltaStart: '/* CLASS2CSS:DELTA_START */',
160
+ };
161
+
162
+ const placeholders = {};
163
+ let tempCss = cssString;
164
+ for (const [key, marker] of Object.entries(partitionMarkers)) {
165
+ if (tempCss.includes(marker)) {
166
+ const placeholder = `__CLASS2CSS_${key.toUpperCase()}__`;
167
+ placeholders[placeholder] = marker;
168
+ tempCss = tempCss.replace(new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder);
169
+ }
170
+ }
171
+
172
+ // 压缩 CSS(移除注释等)
173
+ let compressed = tempCss
123
174
  .replace(/\/\*[\s\S]*?\*\//g, '') // 移除注释
124
175
  .replace(/\s+/g, ' ') // 多个空格合并为一个
125
176
  .replace(/\s*{\s*/g, '{') // 移除大括号周围的空格
@@ -130,6 +181,13 @@ class CssFormatter {
130
181
  .replace(/;\s*}/g, '}') // 移除结束大括号前的分号(CSS规则结束不需要分号)
131
182
  .replace(/\s+/g, '') // 移除所有剩余空格
132
183
  .trim();
184
+
185
+ // 恢复分区标记
186
+ for (const [placeholder, marker] of Object.entries(placeholders)) {
187
+ compressed = compressed.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker);
188
+ }
189
+
190
+ return compressed;
133
191
  }
134
192
 
135
193
  // 单行CSS(每个规则单行,但规则之间换行)
@@ -191,69 +249,364 @@ class CssFormatter {
191
249
  return '';
192
250
  }
193
251
 
194
- // 对CSS规则进行字母排序
195
- sortCSSRules(cssString) {
252
+ // 规范化响应式规则顺序:
253
+ // - 保证普通规则(base)在前
254
+ // - 保证 @media 块在后(并按 min-width 升序稳定排序)
255
+ // 该步骤不做“字母排序”,只解决媒体查询被覆盖导致的失效问题。
256
+ normalizeResponsiveOrder(cssString) {
196
257
  if (!cssString || typeof cssString !== 'string') {
197
258
  return '';
198
259
  }
199
260
 
200
- // 解析CSS规则:逐字符解析,跟踪大括号嵌套
201
- const rules = [];
202
- let currentRule = '';
261
+ const blocks = [];
262
+ let prefix = '';
263
+ let suffix = '';
264
+
265
+ let i = 0;
203
266
  let braceCount = 0;
204
- let selector = '';
267
+ let blockStart = -1;
268
+ let cursor = 0;
269
+
270
+ while (i < cssString.length) {
271
+ const char = cssString[i];
272
+
273
+ if (char === '{') {
274
+ if (braceCount === 0) {
275
+ const preSelector = cssString.slice(cursor, i);
276
+
277
+ // 尽量把无大括号语句(常见:@import/@charset)保留在 prefix
278
+ const lastSemicolon = preSelector.lastIndexOf(';');
279
+ if (lastSemicolon !== -1) {
280
+ const before = preSelector.slice(0, lastSemicolon + 1);
281
+ if (blocks.length === 0) {
282
+ prefix += before;
283
+ }
284
+ blockStart = cursor + lastSemicolon + 1;
285
+ } else {
286
+ blockStart = cursor;
287
+ }
288
+ }
289
+ braceCount++;
290
+ } else if (char === '}') {
291
+ braceCount--;
292
+ if (braceCount === 0 && blockStart !== -1) {
293
+ const fullRule = cssString.slice(blockStart, i + 1);
294
+ const openBraceIdx = fullRule.indexOf('{');
295
+ const selectorRaw = openBraceIdx === -1 ? '' : fullRule.slice(0, openBraceIdx);
296
+ const selectorTrim = selectorRaw.trim();
297
+ const cleanSelector = selectorTrim.replace(/^\./, '').trim();
298
+
299
+ if (cleanSelector) {
300
+ blocks.push({
301
+ selectorTrim,
302
+ fullRule,
303
+ index: blocks.length,
304
+ });
305
+ }
306
+
307
+ cursor = i + 1;
308
+ blockStart = -1;
309
+ }
310
+ }
311
+
312
+ i++;
313
+ }
314
+
315
+ if (cursor < cssString.length) {
316
+ suffix = cssString.slice(cursor);
317
+ }
318
+
319
+ if (blocks.length === 0) {
320
+ return cssString;
321
+ }
322
+
323
+ const isMediaBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@media');
324
+ const isAtRuleBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@');
325
+
326
+ const parseMinWidth = (selectorTrim) => {
327
+ if (!selectorTrim) return Number.POSITIVE_INFINITY;
328
+ const m = selectorTrim.match(/min-width\s*:\s*([^)]+)\)/i);
329
+ if (!m) return Number.POSITIVE_INFINITY;
330
+ const raw = String(m[1] || '').trim();
331
+ const num = parseFloat(raw);
332
+ return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
333
+ };
334
+
335
+ const baseBlocks = [];
336
+ const mediaBlocks = [];
337
+ const otherAtBlocks = [];
338
+
339
+ for (const b of blocks) {
340
+ if (isMediaBlock(b)) {
341
+ mediaBlocks.push(b);
342
+ } else if (isAtRuleBlock(b)) {
343
+ otherAtBlocks.push(b);
344
+ } else {
345
+ baseBlocks.push(b);
346
+ }
347
+ }
348
+
349
+ // 不改变 base 的原始顺序(稳定)
350
+ baseBlocks.sort((a, b) => a.index - b.index);
351
+ otherAtBlocks.sort((a, b) => a.index - b.index);
352
+
353
+ // media 按 min-width 升序(同断点保持稳定)
354
+ mediaBlocks.sort((a, b) => {
355
+ const wa = parseMinWidth(a.selectorTrim);
356
+ const wb = parseMinWidth(b.selectorTrim);
357
+ if (wa < wb) return -1;
358
+ if (wa > wb) return 1;
359
+ return a.index - b.index;
360
+ });
361
+
362
+ return (
363
+ prefix +
364
+ otherAtBlocks.map((b) => b.fullRule).join('') +
365
+ baseBlocks.map((b) => b.fullRule).join('') +
366
+ mediaBlocks.map((b) => b.fullRule).join('') +
367
+ suffix
368
+ );
369
+ }
370
+
371
+ // 拆分 CSS 为 base 规则和 @media 规则(用于 appendDelta 分区插入)
372
+ // 返回 { baseCss, mediaCss, otherAtRulesPrefix, suffix }
373
+ splitBaseAndMedia(cssString) {
374
+ if (!cssString || typeof cssString !== 'string') {
375
+ return { baseCss: '', mediaCss: '', otherAtRulesPrefix: '', suffix: '' };
376
+ }
377
+
378
+ const blocks = [];
379
+ let prefix = '';
380
+ let suffix = '';
381
+
205
382
  let i = 0;
383
+ let braceCount = 0;
384
+ let blockStart = -1;
385
+ let cursor = 0;
206
386
 
207
387
  while (i < cssString.length) {
208
388
  const char = cssString[i];
209
389
 
210
390
  if (char === '{') {
211
391
  if (braceCount === 0) {
212
- // 找到选择器结束
213
- selector = currentRule.trim();
214
- currentRule = '{';
215
- } else {
216
- currentRule += char;
392
+ const preSelector = cssString.slice(cursor, i);
393
+
394
+ // 尽量把无大括号语句(常见:@import/@charset)保留在 prefix
395
+ const lastSemicolon = preSelector.lastIndexOf(';');
396
+ if (lastSemicolon !== -1) {
397
+ const before = preSelector.slice(0, lastSemicolon + 1);
398
+ if (blocks.length === 0) {
399
+ prefix += before;
400
+ }
401
+ blockStart = cursor + lastSemicolon + 1;
402
+ } else {
403
+ blockStart = cursor;
404
+ }
217
405
  }
218
406
  braceCount++;
219
407
  } else if (char === '}') {
220
- currentRule += char;
221
408
  braceCount--;
409
+ if (braceCount === 0 && blockStart !== -1) {
410
+ const fullRule = cssString.slice(blockStart, i + 1);
411
+ const openBraceIdx = fullRule.indexOf('{');
412
+ const selectorRaw = openBraceIdx === -1 ? '' : fullRule.slice(0, openBraceIdx);
413
+ const selectorTrim = selectorRaw.trim();
414
+
415
+ if (selectorTrim) {
416
+ blocks.push({
417
+ selectorTrim,
418
+ fullRule,
419
+ index: blocks.length,
420
+ });
421
+ }
422
+
423
+ cursor = i + 1;
424
+ blockStart = -1;
425
+ }
426
+ }
427
+
428
+ i++;
429
+ }
430
+
431
+ if (cursor < cssString.length) {
432
+ suffix = cssString.slice(cursor);
433
+ }
434
+
435
+ if (blocks.length === 0) {
436
+ return { baseCss: cssString, mediaCss: '', otherAtRulesPrefix: prefix, suffix };
437
+ }
438
+
439
+ const isMediaBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@media');
440
+ const isAtRuleBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@');
441
+
442
+ const baseBlocks = [];
443
+ const mediaBlocks = [];
444
+ const otherAtBlocks = [];
445
+
446
+ for (const b of blocks) {
447
+ if (isMediaBlock(b)) {
448
+ mediaBlocks.push(b);
449
+ } else if (isAtRuleBlock(b)) {
450
+ otherAtBlocks.push(b);
451
+ } else {
452
+ baseBlocks.push(b);
453
+ }
454
+ }
455
+
456
+ // 保持原始顺序(不排序)
457
+ const baseCss = baseBlocks.map((b) => b.fullRule).join('');
458
+ const mediaCss = mediaBlocks.map((b) => b.fullRule).join('');
459
+ const otherAtRulesPrefix = prefix + otherAtBlocks.map((b) => b.fullRule).join('');
460
+
461
+ return { baseCss, mediaCss, otherAtRulesPrefix, suffix };
462
+ }
463
+
464
+ // 对CSS规则进行字母排序
465
+ sortCSSRules(cssString) {
466
+ if (!cssString || typeof cssString !== 'string') {
467
+ return '';
468
+ }
469
+
470
+ // 解析顶层块(支持 @media 嵌套),并按语义分组排序。
471
+ // 目标:避免 @media 块被排到普通规则前面导致覆盖失效(base 覆盖 media)。
472
+ // 同时尽量保留非块语句(如 @import/@charset)在输出中的存在。
473
+
474
+ const blocks = [];
475
+ let prefix = '';
476
+ let suffix = '';
477
+
478
+ let i = 0;
479
+ let braceCount = 0;
480
+ let blockStart = -1;
481
+ let selectorStart = 0;
482
+ let cursor = 0;
483
+
484
+ while (i < cssString.length) {
485
+ const char = cssString[i];
486
+
487
+ if (char === '{') {
222
488
  if (braceCount === 0) {
223
- // 找到规则结束
489
+ // 顶层块开始:selector 在 cursor..i 之间
490
+ const preSelector = cssString.slice(cursor, i);
491
+
492
+ // 尽量把无大括号的 at-rule 语句(常见:@import/@charset)留在 prefix 中
493
+ // 规则:如果 preSelector 中有分号,将最后一个分号之前的内容视为 prefix
494
+ const lastSemicolon = preSelector.lastIndexOf(';');
495
+ if (lastSemicolon !== -1) {
496
+ const before = preSelector.slice(0, lastSemicolon + 1);
497
+ const after = preSelector.slice(lastSemicolon + 1);
498
+ // prefix 只在第一个块之前累积
499
+ if (blocks.length === 0) {
500
+ prefix += before;
501
+ }
502
+ selectorStart = cursor + lastSemicolon + 1;
503
+ // 把分号后的空白等内容归到块里,避免丢失格式
504
+ blockStart = selectorStart;
505
+ // after 这段会包含在 blockStart.. 中,不需要单独处理
506
+ } else {
507
+ selectorStart = cursor;
508
+ blockStart = selectorStart;
509
+ }
510
+ }
511
+ braceCount++;
512
+ } else if (char === '}') {
513
+ braceCount--;
514
+ if (braceCount === 0 && blockStart !== -1) {
515
+ // 顶层块结束
516
+ const fullRule = cssString.slice(blockStart, i + 1);
517
+ const openBraceIdx = fullRule.indexOf('{');
518
+ const selectorRaw = openBraceIdx === -1 ? '' : fullRule.slice(0, openBraceIdx);
519
+ const selectorTrim = selectorRaw.trim();
520
+
224
521
  // 清理选择器:移除点号前缀用于排序(注意:转义字符不需要处理,排序时保持原样)
225
- const cleanSelector = selector.replace(/^\./, '').trim();
522
+ const cleanSelector = selectorTrim.replace(/^\./, '').trim();
523
+
226
524
  if (cleanSelector) {
227
- rules.push({
525
+ blocks.push({
228
526
  selector: cleanSelector,
229
- fullRule: selector + currentRule,
527
+ selectorTrim,
528
+ fullRule,
529
+ index: blocks.length, // 用于稳定排序
230
530
  });
231
531
  }
232
- currentRule = '';
233
- selector = '';
532
+
533
+ cursor = i + 1;
534
+ blockStart = -1;
535
+ selectorStart = cursor;
234
536
  }
235
- } else {
236
- currentRule += char;
237
537
  }
538
+
238
539
  i++;
239
540
  }
240
541
 
241
- // 如果没有匹配到规则,返回原字符串
242
- if (rules.length === 0) {
542
+ // 保留尾部非块内容(例如末尾注释/换行)
543
+ if (cursor < cssString.length) {
544
+ suffix = cssString.slice(cursor);
545
+ }
546
+
547
+ // 如果没有解析到块,直接返回原字符串
548
+ if (blocks.length === 0) {
243
549
  return cssString;
244
550
  }
245
551
 
246
- // 按选择器名称进行字母排序(不区分大小写)
247
- rules.sort((a, b) => {
552
+ const isMediaBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@media');
553
+ const isAtRuleBlock = (b) => typeof b.selectorTrim === 'string' && b.selectorTrim.startsWith('@');
554
+
555
+ const parseMinWidth = (selectorTrim) => {
556
+ if (!selectorTrim) return Number.POSITIVE_INFINITY;
557
+ // 兼容:@media(min-width:640px) / @media (min-width: 640px)
558
+ const m = selectorTrim.match(/min-width\s*:\s*([^)]+)\)/i);
559
+ if (!m) return Number.POSITIVE_INFINITY;
560
+ const raw = String(m[1] || '').trim();
561
+ const num = parseFloat(raw);
562
+ return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
563
+ };
564
+
565
+ const baseBlocks = [];
566
+ const mediaBlocks = [];
567
+ const otherAtBlocks = [];
568
+
569
+ for (const b of blocks) {
570
+ if (isMediaBlock(b)) {
571
+ mediaBlocks.push(b);
572
+ } else if (isAtRuleBlock(b)) {
573
+ otherAtBlocks.push(b);
574
+ } else {
575
+ baseBlocks.push(b);
576
+ }
577
+ }
578
+
579
+ // base:按选择器字母排序(不区分大小写)
580
+ baseBlocks.sort((a, b) => {
248
581
  const selectorA = a.selector.toLowerCase();
249
582
  const selectorB = b.selector.toLowerCase();
250
583
  if (selectorA < selectorB) return -1;
251
584
  if (selectorA > selectorB) return 1;
252
- return 0;
585
+ return a.index - b.index; // 稳定
586
+ });
587
+
588
+ // media:按 min-width 升序(同断点保持稳定)
589
+ mediaBlocks.sort((a, b) => {
590
+ const wa = parseMinWidth(a.selectorTrim);
591
+ const wb = parseMinWidth(b.selectorTrim);
592
+ if (wa < wb) return -1;
593
+ if (wa > wb) return 1;
594
+ return a.index - b.index;
253
595
  });
254
596
 
255
- // 重新组合CSS规则
256
- return rules.map(rule => rule.fullRule).join('');
597
+ // 组合顺序:
598
+ // - prefix(@import/@charset 等)
599
+ // - 其他 at-rule(保持原顺序)
600
+ // - base 规则(排序后)
601
+ // - @media(最后,保证覆盖方向正确)
602
+ // - suffix
603
+ return (
604
+ prefix +
605
+ otherAtBlocks.map((b) => b.fullRule).join('') +
606
+ baseBlocks.map((b) => b.fullRule).join('') +
607
+ mediaBlocks.map((b) => b.fullRule).join('') +
608
+ suffix
609
+ );
257
610
  }
258
611
  }
259
612
 
@@ -54,7 +54,8 @@ class UnitProcessor {
54
54
  }
55
55
 
56
56
  // 智能解析数值和单位
57
- parseValue(value, property, defaultUnit = null) {
57
+ // options.skipSpecialProcessing: true 时跳过特殊属性处理(如 line-height 的倍数转换),直接附加单位
58
+ parseValue(value, property, defaultUnit = null, options = {}) {
58
59
  if (typeof value !== 'string' && typeof value !== 'number') {
59
60
  return value;
60
61
  }
@@ -78,8 +79,8 @@ class UnitProcessor {
78
79
  return valueStr; // 无法解析,保持原值
79
80
  }
80
81
 
81
- // 处理特殊属性
82
- if (this.isSpecialProperty(property, numericValue)) {
82
+ // 处理特殊属性(skipSpecialProcessing 为 true 时跳过,直接走单位转换)
83
+ if (!options.skipSpecialProcessing && this.isSpecialProperty(property, numericValue)) {
83
84
  return this.processSpecialProperty(property, numericValue);
84
85
  }
85
86