#!/usr/bin/env node /** * 别名构建:把上游各库的历史图标名映射到本仓库的 canonical 名。 * * 为什么需要别名层(而不是把名字改成上游的样子): * Element UI 的 283 个图标名是**图标字体**时代的名字,画的是「线」不是「形」—— * 它们不携带形状数据,只携带一个码点。我们要的是可渲染的 SVG, * 所以必须为每个历史名字**指定**它对应哪个真实图标。 * 这一步是显式映射,不做模糊匹配:猜错了用户看到的就是错的图标, * 而"看起来差不多"在静默处最危险(历史教训:6 次"文档声称完成但代码缺失")。 * * 三类映射: * identity 名字本身就在 registry 里(同名直通) * stripped 去掉 -solid/-outline/-round/- 后缀后命中 * semantic 需要人工指定(历史名与新名不同词)——见 SEMANTIC 表 * * 产物:.design_library/kole-ui/icons/aliases.json * 用法:node tools/build-icon-aliases.mjs */ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ICON_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons'); const REGISTRY = join(ICON_DIR, 'registry.json'); const OUT = join(ICON_DIR, 'aliases.json'); /* Element UI 历史名清单:由 tools/fetch-icon-sources.mjs 的取证阶段产出, 这里作为输入读入(离线:文件已在仓库里)。 */ const EL_NAMES = join(ICON_DIR, 'element-ui-names.txt'); function die(msg) { console.error('FAIL: ' + msg); process.exit(1); } function atomicWrite(file, content) { mkdirSync(dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, content.replace(/\r\n/g, '\n'), 'utf8'); renameSync(tmp, file); } if (!existsSync(REGISTRY)) die(`缺 ${REGISTRY} —— 先跑 node tools/build-icons.mjs`); const registry = JSON.parse(readFileSync(REGISTRY, 'utf8')).icons; const names = new Set(Object.keys(registry)); const strip = (n) => n.replace(/-(solid|outline|round|filled)$/, '').replace(/-[0-9]+$/, ''); /** * 语义映射表:历史名 → canonical 名。逐条要能说清"为什么是它"。 * * 未列入的名字由下面的自动规则处理(同名 / 去后缀)。 * 规则覆盖不到的会进 `unresolved` 并被 verify 门禁拦下 —— 不允许静默丢弃。 */ const SEMANTIC = { /* Element UI 的后台菜单系列(s-*):每个都是"某类管理台入口", TDesign 用 view-* 与 setting-* 系列表达同一批语义。 */ 's-check': 'check-circle', 's-claim': 'file-copy', 's-comment': 'chat', 's-cooperation': 'usergroup', 's-custom': 'setting', 's-data': 'chart-bar', 's-finance': 'money', 's-flag': 'flag', 's-fold': 'folder', 's-goods': 'goods', 's-grid': 'grid', 's-help': 'help-circle', 's-home': 'home', 's-management': 'view-organization', 's-marketing': 'present', 's-open': 'unlock', 's-operation': 'control-platform', 's-opportunity': 'chart-bubble', 's-order': 'order', 's-platform': 'app', 's-promotion': 'loudspeaker', 's-release': 'send', 's-shop': 'shop', 's-ticket': 'ticket', 's-tools': 'tools', 's-unfold': 'menu-unfold', /* 单点语义 */ 'attract': 'star', 'bangzhu': 'help-circle', 'bank-card': 'creditcard', 'c-scale-to-original': 'fullscreen', 'close-notification': 'mute-notification', 'cloudy-and-sunny': 'cloudy-day', 'date': 'calendar', 'discover': 'compass', 'heavy-rain': 'rain-heavy', 'light-rain': 'rain-light', 'mobile-phone': 'mobile', 'news': 'article', 'picture-outline-round': 'image', 'platform-eleme': 'app', 'potato-strips': 'food', 'receiving': 'download', 'scissors': 'cut', 'star-off': 'star', 'star-on': 'star-filled', 'success': 'check-circle', 'table-lamp': 'lightbulb', 'tableware': 'fork-spoon', 'thumb': 'thumb-up', 'truck': 'undertake-delivery', 'turn-off-microphone': 'sound-mute', 'upload2': 'upload', 'water-cup': 'coffee', /* Element UI 的 -left/-right 是被解析器截下的碎片(原为 d-arrow-left 等), 不是真实图标名,显式标注为"忽略"而不是映射到某个猜的图标。 */ '-left': null, '-right': null, }; /* 移动端既有 9 名字形表 → 新图标集(保持既有 4 端行为不破,见 ICON-SPEC §六)。 注意:check / close / star / plus / minus / more **本身已是 canonical 名且语义一致**, 不需要别名条目 —— 直接命中真实图标即可(更精确,也不会遮蔽真图标)。 只有旧名与新集用词不同的三个才需要映射。 */ const MOBILE_LEGACY = { warn: 'warning', /* 旧名 warn,新集是 warning */ info: 'info-circle', /* 旧名 info 画的是 ⓘ(圆圈 i),对应 info-circle */ arrow: 'chevron-right', /* 旧名 arrow 画的是 ›(右箭头) */ }; const aliases = {}; const ignored = []; const problems = []; /* ① Element UI 历史名 */ if (!existsSync(EL_NAMES)) die(`缺 ${EL_NAMES}`); const elNames = readFileSync(EL_NAMES, 'utf8') .split(/\r?\n/) .map((s) => s.trim()) /* 该文件带 `#` 头注释(来源/许可/提取命令),跳过 */ .filter((s) => s && s.length > 1 && !s.startsWith('#')); for (const n of elNames) { if (Object.prototype.hasOwnProperty.call(SEMANTIC, n)) { const target = SEMANTIC[n]; if (target === null) { ignored.push(n); continue; } if (!names.has(target)) { problems.push(`SEMANTIC['${n}'] → '${target}' 不存在`); continue; } if (target !== n) aliases[n] = target; continue; } if (names.has(n)) continue; /* 同名直通,不需要别名条目 */ const s = strip(n); if (names.has(s)) { aliases[n] = s; continue; } const cands = [...names].filter((x) => strip(x) === s).sort((a, b) => a.length - b.length); if (cands.length) { aliases[n] = cands[0]; continue; } problems.push(`无法解析 ${n}`); } /* ② 移动端历史名。 同一条铁律:如果某个旧名本身就是真实图标名,就**不许**再给它挂别名 —— 否则 `plus` 这种真图标会被旧语义改写(门禁会当场拦下,见文件末尾自检)。 */ for (const [from, to] of Object.entries(MOBILE_LEGACY)) { if (!names.has(to)) { problems.push(`MOBILE_LEGACY['${from}'] → '${to}' 不存在`); continue; } if (from === to) continue; if (names.has(from)) { problems.push(`MOBILE_LEGACY['${from}'] 与真实图标同名,不许遮蔽`); continue; } aliases[from] = to; } /* ③ 常见同义词(开发者直觉命名 → canonical) */ const SYNONYMS = { 'chevron-left': 'chevron-left', 'angle-left': 'chevron-left', 'caret-left': 'caret-left', 'x': 'close', 'cross': 'close', 'cancel': 'close', 'trash': 'delete', 'bin': 'delete', 'garbage': 'delete', 'pencil': 'edit', 'write': 'edit', 'magnifier': 'search', 'magnify': 'search', 'find': 'search', 'cog': 'setting', 'gear': 'setting', 'settings': 'setting', 'warning': 'warning', 'alert': 'warning', 'caution': 'warning', 'danger': 'warning', 'info': 'info-circle', 'information': 'info-circle', 'ok': 'check', 'tick': 'check', 'done': 'check', 'plus-circle': 'add-circle', 'minus-circle': 'minus-circle', 'person': 'user', 'account': 'user', 'profile': 'user', 'picture': 'image', 'photo': 'image', 'email': 'mail', 'envelope': 'mail', 'home': 'home', 'house': 'home', 'refresh': 'refresh', 'reload': 'refresh', 'sync': 'refresh', 'eye': 'view', 'eye-off': 'hide', 'lock': 'lock', 'unlock': 'unlock', 'clock': 'time', 'time': 'time', 'schedule': 'calendar', 'map-pin': 'location', 'pin-location': 'location', 'loading': 'loading', 'spinner': 'loading', 'star-o': 'star', 'star-solid': 'star-filled', 'heart-o': 'heart', 'heart-solid': 'heart-filled', 'thumb-up': 'thumb-up', 'like': 'thumb-up', 'cart': 'cart', 'shopping-cart': 'cart', 'money': 'money', 'currency': 'money', 'yuan': 'money', 'list': 'list', 'menu': 'menu', 'hamburger': 'menu', 'grid': 'grid', 'apps': 'grid', 'dashboard': 'dashboard', 'chart': 'chart', 'graph': 'chart', 'statistics': 'chart', 'folder-open': 'folder-open', 'directory': 'folder', 'file-text': 'file', 'document': 'file', 'download': 'download', 'upload': 'upload', 'share': 'share', 'print': 'print', 'copy': 'copy', 'duplicate': 'copy', 'filter': 'filter', 'funnel': 'filter', 'sort': 'sort', 'order': 'sort', 'more': 'more', 'more-horizontal': 'more', 'ellipsis': 'more', 'external-link': 'link', 'link': 'link', 'chain': 'link', 'save': 'save', 'floppy': 'save', 'print': 'print', 'printer': 'printer', 'calendar': 'calendar', 'date': 'calendar', 'bell': 'bell', 'notification': 'notification', 'message': 'message', 'chat': 'chat', 'comment': 'chat', 'user-group': 'usergroup', 'users': 'usergroup', 'team': 'usergroup', 'shield': 'shield-error', 'security': 'shield-error', 'key': 'key', 'password': 'lock', 'play': 'play', 'pause': 'pause', 'stop': 'stop', 'video': 'video', 'music': 'music', 'audio': 'sound', 'camera': 'camera', 'image-plus': 'image-add', 'cloud': 'cloud', 'server': 'server', 'database': 'server', 'code': 'code', 'terminal': 'terminal', 'command': 'terminal', 'bug': 'bug', 'debug': 'bug', 'terminal': 'terminal', 'console': 'terminal', 'wifi': 'wifi', 'signal': 'signal', 'battery': 'battery', 'power': 'poweroff', 'mobile': 'mobile', 'phone': 'phone', 'tablet': 'tablet', 'desktop': 'desktop', 'monitor': 'monitor', 'laptop': 'laptop', 'keyboard': 'keyboard', 'mouse': 'mouse', 'printer': 'printer', 'folder-plus': 'folder-add', 'file-plus': 'file-add', 'cart-plus': 'cart-add', 'user-plus': 'user-add', 'arrow-up-circle': 'arrow-up-circle', 'arrow-down-circle': 'arrow-down-circle', 'chevron-up': 'chevron-up', 'chevron-down': 'chevron-down', 'chevron-right': 'chevron-right', 'chevron-left': 'chevron-left', 'caret-up': 'caret-up', 'caret-down': 'caret-down', 'expand-more': 'chevron-down', 'expand-less': 'chevron-up', 'arrow-back': 'back', 'arrow-forward': 'arrow-right', 'reply': 'back', 'undo': 'back', 'redo': 'forward', 'fullscreen': 'fullscreen', 'full-screen': 'fullscreen', 'maximize': 'fullscreen', 'minimize': 'fullscreen', 'zoom-in': 'zoom-in', 'zoom-out': 'zoom-out', 'help': 'help-circle', 'question-circle': 'help-circle', 'support': 'help-circle', 'exclamation': 'warning', 'exclamation-circle': 'error-circle', 'check-circle-o': 'check-circle', 'check-circle-filled': 'check-circle-filled', 'close-circle-o': 'close-circle', 'plus-square': 'add-rectangle', 'minus-square': 'minus-rectangle', 'check-square': 'check-rectangle', 'square': 'rectangle', 'circle': 'circle', 'triangle-up': 'arrow-up', 'triangle-down': 'arrow-down', 'sort-asc': 'arrow-up', 'sort-desc': 'arrow-down', 'sort-ascending': 'arrow-up', 'unsorted': 'sort', 'sortable': 'sort', 'loading-spinner': 'loading', 'spinner-loading': 'loading', 'drag': 'move', 'move': 'move', 'hand': 'move', 'crop': 'crop', 'scissors': 'cut', 'cut': 'cut', 'paste': 'file-copy', 'clipboard': 'file-copy', 'select-all': 'check-rectangle', 'select': 'check', 'indeterminate': 'minus-rectangle', 'star-half': 'star', 'rate': 'star', 'gift': 'present', 'present': 'present', 'coupon': 'coupon', 'ticket': 'ticket', 'price': 'price-tag', 'tag': 'tag', 'flag': 'flag', 'bookmark': 'bookmark', 'collection': 'collection', 'wallet': 'wallet', 'bank': 'bank', 'credit-card': 'creditcard', 'order': 'order', 'goods': 'goods', 'shop': 'shop', 'store': 'store', 'delivery': 'undertake-delivery', 'truck': 'undertake-delivery', 'shipping': 'undertake-delivery', 'box': 'box', 'package': 'box', 'archive': 'folder', 'receive': 'download', 'send': 'send', 'publish': 'send', 'announcement': 'loudspeaker', 'speaker': 'loudspeaker', 'broadcast': 'loudspeaker', 'megaphone': 'loudspeaker', 'promotion': 'loudspeaker', 'news': 'article', 'article': 'article', 'blog': 'article', 'book': 'book', 'notebook': 'notebook', 'note': 'file', 'reading': 'book-open', 'book-open': 'book-open', 'graduation': 'education', 'school': 'education', 'education': 'education', 'workspace': 'app', 'application': 'app', 'platform': 'app', 'component': 'component', 'widget': 'widget', 'module': 'widget', 'plugin': 'extension', 'extension': 'extension', 'puzzle': 'extension', 'integration': 'api', 'api': 'api', 'interface': 'api', 'webhook': 'link', 'endpoint': 'api', 'log': 'history', 'history': 'history', 'audit': 'history', 'version': 'history', 'release': 'send', 'deploy': 'cloud-upload', 'backup': 'cloud-download', 'restore': 'refresh', 'migration': 'transform', 'transfer': 'transform', 'import': 'upload', 'export': 'download', 'template': 'file-copy', 'snippet': 'code', 'form': 'file', 'survey': 'file', 'workflow': 'flowchart', 'flow': 'flowchart', 'process': 'flowchart', 'pipeline': 'flowchart', 'automation': 'control-platform', 'report': 'chart-bar', 'analytics': 'chart-bar', 'insight': 'chart-bar', 'metric': 'chart-line', 'kpi': 'chart-line', 'trend': 'chart-line', 'pie': 'chart-pie', 'donut': 'chart-ring', 'funnel-chart': 'chart-bubble', 'scatter': 'chart-scatter', 'radar-chart': 'chart-radar', 'gauge-chart': 'chart-radar', 'heatmap': 'chart-bar', 'table-chart': 'chart-bar', 'pivot': 'table', 'model': 'widget', 'dataset': 'data-base', 'etl': 'flowchart', 'ai': 'ai', 'robot': 'robot', 'assistant': 'chat-bubble', 'prompt': 'ai-edit', 'generate': 'ai', 'magic': 'magic', }; for (const [from, to] of Object.entries(SYNONYMS)) { if (from === to) continue; /* 同名不需要条目 */ if (!names.has(to)) continue; /* 目标不存在则跳过(由下面的统计报出) */ if (!names.has(from)) aliases[from] = to; /* 只有当它不是真实图标名时才做别名 */ } const missingSyn = Object.entries(SYNONYMS).filter(([f, t]) => f !== t && !names.has(t)).map(([f, t]) => `${f}→${t}`); if (missingSyn.length) console.log(` 注意:${missingSyn.length} 条同义词的目标不存在(已跳过):${missingSyn.slice(0, 8).join(', ')}`); /* 被真图标挡下的同义词:记下来,不是错误但要知道有这么回事 */ const shadowedSyn = Object.entries(SYNONYMS).filter(([f, t]) => f !== t && names.has(f)).map(([f, t]) => `${f}(真图标)→${t}(未采用)`); if (shadowedSyn.length) { console.log(` 注意:${shadowedSyn.length} 条同义词因「键本身已是真实图标」而未采用(真图标优先):`); console.log(` ${shadowedSyn.slice(0, 10).map((x) => x.split('(')[0]).join(', ')}${shadowedSyn.length > 10 ? ' …' : ''}`); } /* 自检 */ if (problems.length) { console.error('无法解析的名字:'); problems.forEach((p) => console.error(' ' + p)); die(`${problems.length} 个名字没有落点 —— 别名层不许静默丢弃`); } for (const [a, t] of Object.entries(aliases)) { if (!names.has(t)) die(`别名 ${a} → ${t} 指向不存在的图标`); if (names.has(a) && a !== t) die(`别名 ${a} 与真实图标同名,会遮蔽真实图标`); } const payload = { schemaVersion: 1, generatedBy: 'tools/build-icon-aliases.mjs', note: '别名只改查找路径,不产生新图标。identity(同名)不在此表中。', ignored: ignored.sort(), stats: { total: Object.keys(aliases).length, elementUI: elNames.length, mobileLegacy: Object.keys(MOBILE_LEGACY).length }, aliases, }; atomicWrite(OUT, JSON.stringify(payload, null, 2)); console.log('图标别名构建'); console.log(` 别名总数 ${Object.keys(aliases).length}`); console.log(` Element UI 历史名 ${elNames.length}(其中 ${ignored.length} 个标记为忽略:${ignored.join(', ')})`); console.log(` 移动端历史名 ${Object.keys(MOBILE_LEGACY).length}`); console.log(` 写入 ${OUT}(${(JSON.stringify(payload).length / 1024).toFixed(0)}KB)`); console.log('OK');