replace-hex-to-token.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. #!/usr/bin/env node
  2. /**
  3. * 批量替换硬编码色值为 SCSS 令牌(CSS style 块内,零视觉风险)
  4. * 用法:
  5. * node scripts/replace-hex-to-token.js --batch gray # 仅灰阶
  6. * node scripts/replace-hex-to-token.js --batch brand # 仅品牌/状态色
  7. * node scripts/replace-hex-to-token.js # 全部
  8. */
  9. const fs = require('fs')
  10. const path = require('path')
  11. const ROOT = path.resolve(__dirname, '..')
  12. const EXCLUDE_DIRS = new Set(['node_modules', 'dist', 'unpackage', '.git', 'docs'])
  13. const ALL_RULES = [
  14. // ---- 灰阶(neutral)----
  15. ['#FFFFFF', '$app-surface'],
  16. ['#ffffff', '$app-surface'],
  17. ['#FFF', '$app-surface'],
  18. ['#fff', '$app-surface'],
  19. ['#333333', '$facade-text-strong'],
  20. ['#333', '$facade-text-strong'],
  21. ['#666666', '$facade-text-body'],
  22. ['#666', '$facade-text-body'],
  23. ['#999999', '$facade-text-muted'],
  24. ['#999', '$facade-text-muted'],
  25. ['#F5F5F5', '$facade-fill-2'],
  26. ['#f5f5f5', '$facade-fill-2'],
  27. ['#F0F0F0', '$facade-fill'],
  28. ['#f0f0f0', '$facade-fill'],
  29. ['#FAFAFA', '$facade-fill-ghost'],
  30. ['#fafafa', '$facade-fill-ghost'],
  31. ['#F9FAFB', '$facade-fill-gray-50'],
  32. ['#f9fafb', '$facade-fill-gray-50'],
  33. ['#BBBBBB', '$facade-text-ghost-light'],
  34. ['#bbbbbb', '$facade-text-ghost-light'],
  35. ['#BBB', '$facade-text-ghost-light'],
  36. ['#bbb', '$facade-text-ghost-light'],
  37. ['#CCCCCC', '$facade-text-ghost'],
  38. ['#cccccc', '$facade-text-ghost'],
  39. ['#CCC', '$facade-text-ghost'],
  40. ['#ccc', '$facade-text-ghost'],
  41. ['#DDDDDD', '$facade-border-soft'],
  42. ['#dddddd', '$facade-border-soft'],
  43. ['#DDD', '$facade-border-soft'],
  44. ['#ddd', '$facade-border-soft'],
  45. ['#EEEEEE', '$facade-hairline-2'],
  46. ['#eeeeee', '$facade-hairline-2'],
  47. ['#EEE', '$facade-hairline-2'],
  48. ['#eee', '$facade-hairline-2'],
  49. // ---- 品牌/状态色(brand/status)----
  50. ['#F97316', '$brand-primary'],
  51. ['#f97316', '$brand-primary'],
  52. ['#FF8C42', '$dim-body'],
  53. ['#ff8c42', '$dim-body'],
  54. ['#FFF7ED', '$uni-primary-pale'],
  55. ['#fff7ed', '$uni-primary-pale'],
  56. ['#FB923C', '$brand-primary-light'],
  57. ['#fb923c', '$brand-primary-light'],
  58. ['#10B981', '$status-success'],
  59. ['#10b981', '$status-success'],
  60. ['#EF4444', '$status-danger'],
  61. ['#ef4444', '$status-danger'],
  62. ['#6366F1', '$dim-wisdom'],
  63. ['#6366f1', '$dim-wisdom'],
  64. ['#C62828', '$status-danger-strong-2'],
  65. ['#c62828', '$status-danger-strong-2'],
  66. ].sort((a, b) => b[0].length - a[0].length)
  67. const GRAY_RULES = ALL_RULES.filter(r => { const i = ALL_RULES.indexOf(r); return i < 30 })
  68. const BRAND_RULES = ALL_RULES.filter(r => { const i = ALL_RULES.indexOf(r); return i >= 30 })
  69. const batch = process.argv.find(a => a.startsWith('--batch='))?.split('=')[1]
  70. const rules = batch === 'brand' ? BRAND_RULES : batch === 'gray' ? GRAY_RULES : ALL_RULES
  71. const label = batch === 'brand' ? '品牌/状态色' : batch === 'gray' ? '灰阶' : '全部'
  72. console.log(`\n批次: ${label} 规则数: ${rules.length}\n`)
  73. function walk(dir, out = []) {
  74. for (const name of fs.readdirSync(dir)) {
  75. if (EXCLUDE_DIRS.has(name)) continue
  76. const p = path.join(dir, name)
  77. const st = fs.statSync(p)
  78. if (st.isDirectory()) walk(p, out)
  79. else if (name.endsWith('.vue')) out.push(p)
  80. }
  81. return out
  82. }
  83. function countReplacements(text, rules) {
  84. let n = 0
  85. for (const [hex] of rules) {
  86. const escaped = hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  87. n += (text.match(new RegExp(escaped + '(?![0-9a-fA-F])', 'g')) || []).length
  88. }
  89. return n
  90. }
  91. function applyRules(text, rules) {
  92. let result = text
  93. for (const [hex, token] of rules) {
  94. const escaped = hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  95. result = result.replace(new RegExp(escaped + '(?![0-9a-fA-F])', 'g'), token)
  96. }
  97. return result
  98. }
  99. function processFile(filePath) {
  100. const src = fs.readFileSync(filePath, 'utf8')
  101. // 按 <style ...> 块拆分,只处理有目标 hex 的块
  102. const STYLE_RE = /(<style)([^>]*)>([\s\S]*?)(<\/style>)/gi
  103. let result = src
  104. let changed = false
  105. let totalRepl = 0
  106. let blocksModified = 0
  107. const matches = [...src.matchAll(STYLE_RE)]
  108. for (const match of matches) {
  109. const fullMatch = match[0]
  110. const tagOpen = match[1] // "<style"
  111. const attrs = match[2] // e.g. ' scoped lang="scss"'
  112. const body = match[3] // CSS content
  113. const tagClose = match[4] // "</style>"
  114. const replCount = countReplacements(body, rules)
  115. if (replCount === 0) continue
  116. const newBody = applyRules(body, rules)
  117. totalRepl += replCount
  118. // 若 <style> 没有 lang="scss",补充上(SCSS 是 CSS 超集,安全)
  119. let newAttrs = attrs
  120. if (!/lang\s*=\s*["']scss["']/i.test(attrs)) {
  121. newAttrs = attrs.trim() ? attrs.trim() + ' lang="scss"' : ' lang="scss"'
  122. }
  123. const newBlock = `${tagOpen}${newAttrs}>${newBody}${tagClose}`
  124. if (newBlock !== fullMatch) {
  125. result = result.split(fullMatch).join(newBlock)
  126. changed = true
  127. blocksModified++
  128. }
  129. }
  130. if (changed) {
  131. fs.writeFileSync(filePath, result, 'utf8')
  132. return { changed: true, replacements: totalRepl, blocks: blocksModified }
  133. }
  134. return { changed: false, replacements: 0, blocks: 0 }
  135. }
  136. function main() {
  137. const files = walk(ROOT)
  138. let totalChanged = 0
  139. let totalReplacements = 0
  140. let totalBlocks = 0
  141. const log = []
  142. for (const file of files) {
  143. const rel = path.relative(ROOT, file)
  144. const result = processFile(file)
  145. if (result.changed) {
  146. totalChanged++
  147. totalReplacements += result.replacements
  148. totalBlocks += result.blocks
  149. log.push(`${result.replacements.toString().padStart(4)} ${rel}`)
  150. }
  151. }
  152. console.log(`\n=== 替换完成 (${label}) ===`)
  153. console.log(`文件变更: ${totalChanged}`)
  154. console.log(`style 块修改: ${totalBlocks}`)
  155. console.log(`替换次数: ${totalReplacements}`)
  156. console.log('')
  157. log.sort((a, b) => parseInt(b.split(' ')[0]) - parseInt(a.split(' ')[0]))
  158. for (const line of log.slice(0, 50)) console.log(line)
  159. if (log.length > 50) console.log(`... 共 ${log.length} 个文件`)
  160. }
  161. main()