js-color-hoist.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. #!/usr/bin/env node
  2. /**
  3. * C 批:JS 色板收编
  4. * 将 <script> 块内的硬编码 hex 颜色字符串替换为 BrandColors.* 导入引用。
  5. * 仅在目标文件顶部添加 import 语句。
  6. *
  7. * 替换规则(color-literal 匹配,仅匹配单引号或双引号包裹的 hex):
  8. * '#F97316' / "#F97316" → '$brand.primary'
  9. * '#FB923C' / "#FB923C" → '$brand.primaryLight'
  10. * ...
  11. *
  12. * 用法:
  13. * node scripts/js-color-hoist.js # 全部替换(推荐,但谨慎)
  14. * node scripts/js-color-hoist.js --dry-run # 仅预览不写入
  15. * node scripts/js-color-hoist.js --file path # 只处理指定文件
  16. */
  17. const fs = require('fs')
  18. const path = require('path')
  19. const ROOT = path.resolve(__dirname, '..')
  20. const EXCLUDE_DIRS = new Set(['node_modules', 'dist', 'unpackage', '.git', 'docs'])
  21. // 替换规则(key=hex literal value, value=BrandColors.*)
  22. const JS_RULES = [
  23. { hex: '#F97316', token: 'BrandColors.primary' },
  24. { hex: '#fb923c', token: 'BrandColors.primaryLight' },
  25. { hex: '#FB923C', token: 'BrandColors.primaryLight' },
  26. { hex: '#fff7ed', token: 'BrandColors.primaryPale' },
  27. { hex: '#FFF7ED', token: 'BrandColors.primaryPale' },
  28. { hex: '#ff8c42', token: 'BrandColors.body' },
  29. { hex: '#FF8C42', token: 'BrandColors.body' },
  30. { hex: '#10b981', token: 'BrandColors.success' },
  31. { hex: '#10B981', token: 'BrandColors.success' },
  32. { hex: '#ef4444', token: 'BrandColors.danger' },
  33. { hex: '#EF4444', token: 'BrandColors.danger' },
  34. { hex: '#6366f1', token: 'BrandColors.wisdom' },
  35. { hex: '#6366F1', token: 'BrandColors.wisdom' },
  36. { hex: '#c62828', token: 'BrandColors.dangerDeep' },
  37. { hex: '#C62828', token: 'BrandColors.dangerDeep' },
  38. { hex: '#333333', token: 'BrandColors.textStrong' },
  39. { hex: '#333', token: 'BrandColors.textStrong' },
  40. { hex: '#666666', token: 'BrandColors.textBody' },
  41. { hex: '#666', token: 'BrandColors.textBody' },
  42. { hex: '#999999', token: 'BrandColors.textMuted' },
  43. { hex: '#999', token: 'BrandColors.textMuted' },
  44. { hex: '#fafafa', token: 'BrandColors.fillGhost' },
  45. { hex: '#FAFAFA', token: 'BrandColors.fillGhost' },
  46. { hex: '#f9fafb', token: 'BrandColors.fillGray50' },
  47. { hex: '#F9FAFB', token: 'BrandColors.fillGray50' },
  48. { hex: '#ffffff', token: 'BrandColors.white' },
  49. { hex: '#FFFFFF', token: 'BrandColors.white' },
  50. { hex: '#ffffff', token: 'BrandColors.white' },
  51. ].sort((a, b) => b.hex.length - a.hex.length)
  52. // 优先文件列表(按硬编码色数排序)
  53. const TOP_FILES = [
  54. 'components/ContentSharePoster.vue',
  55. 'pages/mind-detail/emotion-checkin.vue',
  56. 'pages/action-detail/components/PearlDiagram.vue',
  57. 'pages/energy/index.vue',
  58. 'components/ArticleBookshelf.vue',
  59. 'pages/home-pages/parent-index.vue',
  60. 'pages/mind-detail/components/FamilyTianpanCard.vue',
  61. 'pages/promotion/components/SharePoster.vue',
  62. 'components/PageBanner.vue',
  63. 'pages/tianpan/index.vue',
  64. ]
  65. function walk(dir, out = []) {
  66. for (const name of fs.readdirSync(dir)) {
  67. if (EXCLUDE_DIRS.has(name)) continue
  68. const p = path.join(dir, name)
  69. const st = fs.statSync(p)
  70. if (st.isDirectory()) walk(p, out)
  71. else if (name.endsWith('.vue')) out.push(p)
  72. }
  73. return out
  74. }
  75. function hexToToken(hex) {
  76. const escaped = hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  77. const found = JS_RULES.find(r => r.hex.toLowerCase() === hex.toLowerCase())
  78. return found ? found.token : null
  79. }
  80. function replaceJsHex(text, dryRun = false) {
  81. // 匹配 <script> 块
  82. const SCRIPT_RE = /(<script)([^>]*)>([\s\S]*?)(<\/script>)/gi
  83. let result = text
  84. let changed = false
  85. let totalRepl = 0
  86. let needsImport = false
  87. let importAdded = false
  88. const matches = [...text.matchAll(SCRIPT_RE)]
  89. for (const match of matches) {
  90. const body = match[3]
  91. let newBody = body
  92. let blockRepl = 0
  93. for (const rule of JS_RULES) {
  94. const escaped = rule.hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  95. // 匹配单引号或双引号包裹的 hex,且前后不是字母数字
  96. const re = new RegExp(`(['"])${escaped}(?![0-9a-fA-F])\\1`, 'g')
  97. const m = newBody.match(re)
  98. if (m) {
  99. blockRepl += m.length
  100. newBody = newBody.replace(re, `$1${rule.token}$2`) // careful: preserve quote style
  101. }
  102. }
  103. if (blockRepl > 0) {
  104. totalRepl += blockRepl
  105. changed = true
  106. needsImport = true
  107. // 替换 <script> 块
  108. const before = result.slice(0, match.index)
  109. const after = result.slice(SCRIPT_RE.lastIndex)
  110. // 找插入 import 的位置(在 </script> 后面,<template> 前面)
  111. const templateIdx = result.indexOf('<template>')
  112. if (templateIdx !== -1 && !importAdded) {
  113. const importLine = `\nimport { ${[...new Set(JS_RULES.map(r => r.token.split('.')[1]))]
  114. .sort().join(', ')} } from '@/utils/brand-colors.js'\n`
  115. result = before + importLine + result.slice(match.index)
  116. importAdded = true
  117. }
  118. result = result.slice(0, match.index) +
  119. match[1] + match[2] + '>' + newBody + '</script>' +
  120. result.slice(SCRIPT_RE.lastIndex)
  121. SCRIPT_RE.lastIndex = result.indexOf('</script>', match.index) + '</script>'.length
  122. }
  123. }
  124. if (!dryRun && changed) {
  125. fs.writeFileSync(result === text ? text : result, text) // bug guard
  126. return { changed: true, replacements: totalRepl, needsImport }
  127. }
  128. return { changed: false, replacements: totalRepl, needsImport }
  129. }
  130. // 简化版:直接处理整个文件
  131. function processFileSimple(filePath, dryRun = false) {
  132. const src = fs.readFileSync(filePath, 'utf8')
  133. let newSrc = src
  134. let totalRepl = 0
  135. // 收集文件中用到的 token
  136. const usedTokens = new Set()
  137. for (const rule of JS_RULES) {
  138. const escaped = rule.hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  139. const re = new RegExp(`['"]${escaped}(?![0-9a-fA-F])['"]`, 'g')
  140. const m = src.match(re)
  141. if (m) {
  142. totalRepl += m.length
  143. usedTokens.add(rule.token.split('.')[1])
  144. }
  145. }
  146. if (totalRepl === 0) return { changed: false, replacements: 0, needsImport: false }
  147. // 替换 script 块内的 hex
  148. const SCRIPT_RE = /(<script)([^>]*)>([\s\S]*?)(<\/script>)/gi
  149. newSrc = src.replace(SCRIPT_RE, (fullMatch, tagOpen, attrs, body, tagClose) => {
  150. let newBody = body
  151. for (const rule of JS_RULES) {
  152. const escaped = rule.hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  153. const re = new RegExp(`['"]${escaped}(?![0-9a-fA-F])['"]`, 'g')
  154. const m = newBody.match(re)
  155. if (m) {
  156. newBody = newBody.replace(re, `'${rule.token}'`)
  157. }
  158. }
  159. return tagOpen + attrs + '>' + newBody + tagClose
  160. })
  161. // 在 </script> 后添加 import(如果还没加过)
  162. if (usedTokens.size > 0 && !src.includes('brand-colors')) {
  163. const importLine = `\nimport { ${[...usedTokens].sort().join(', ')} } from '@/utils/brand-colors.js'`
  164. newSrc = newSrc.replace(/(<\/script>)/, `$1${importLine}`)
  165. }
  166. if (!dryRun && newSrc !== src) {
  167. fs.writeFileSync(filePath, newSrc, 'utf8')
  168. }
  169. return { changed: newSrc !== src, replacements: totalRepl, needsImport: true }
  170. }
  171. function main() {
  172. const dryRun = process.argv.includes('--dry-run')
  173. const fileArg = process.argv.find(a => a.startsWith('--file='))?.split('=')[1]
  174. const targets = fileArg
  175. ? [path.join(ROOT, fileArg)]
  176. : TOP_FILES.map(f => path.join(ROOT, f))
  177. // 如果没指定文件,跑全量扫描(所有 .vue)
  178. if (!fileArg) {
  179. const allFiles = walk(ROOT)
  180. // 过滤出已处理的 TOP 文件,加上其余全部
  181. const topSet = new Set(TOP_FILES.map(f => path.resolve(ROOT, f)))
  182. const remaining = allFiles.filter(f => !topSet.has(f))
  183. targets.push(...remaining)
  184. }
  185. let grandTotal = 0
  186. const log = []
  187. for (const filePath of targets) {
  188. if (!fs.existsSync(filePath)) {
  189. console.warn(`跳过不存在: ${filePath}`)
  190. continue
  191. }
  192. const rel = path.relative(ROOT, filePath)
  193. const result = processFileSimple(filePath, dryRun)
  194. if (result.changed || result.replacements > 0) {
  195. grandTotal += result.replacements
  196. log.push(`${result.replacements.toString().padStart(4)} ${rel}`)
  197. if (!dryRun) console.log(`✓ ${rel} (${result.replacements} 处)`)
  198. } else {
  199. console.log(`- ${rel} (0 处,跳过)`)
  200. }
  201. }
  202. if (!dryRun) {
  203. console.log(`\n=== JS 色板收编完成 ===`)
  204. console.log(`替换总次数: ${grandTotal}`)
  205. log.sort((a, b) => parseInt(b.split(' ')[0]) - parseInt(a.split(' ')[0]))
  206. console.log('')
  207. for (const line of log) console.log(line)
  208. } else {
  209. console.log('\n[dry-run] 预览替换量:', grandTotal)
  210. }
  211. }
  212. main()