#!/usr/bin/env node /** * match-tokens.mjs — 三层 Token 匹配 * * 接收 parse-prototype.mjs 的输出 JSON 和 tokens.yml 配置, * 对每个原型 Token(别名 / 内联样式值)执行三层匹配, * 输出映射关系到 stdout。 * * 三层匹配算法: * Layer 1: 别名直查(aliases.prototype_keys) * Layer 2: 值精确匹配(带 CSS 属性上下文消歧) * Layer 3: 色彩模糊匹配(RGB 欧几里得距离 < 30) * * 用法: * node match-tokens.mjs */ import yaml from 'js-yaml'; import { readFileSync } from 'fs'; import { resolve } from 'path'; // --------------------------------------------------------------------------- // 工具函数 // --------------------------------------------------------------------------- /** * 解析 hex 颜色字符串为 {r, g, b} * 支持 #RGB / #RRGGBB / RRGGBB */ function hexToRgb(hex) { if (!hex || typeof hex !== 'string') return null; const cleaned = hex.replace(/^#/, '').trim(); if (cleaned.length === 3) { const r = parseInt(cleaned[0] + cleaned[0], 16); const g = parseInt(cleaned[1] + cleaned[1], 16); const b = parseInt(cleaned[2] + cleaned[2], 16); return { r, g, b }; } if (cleaned.length === 6) { const r = parseInt(cleaned.substring(0, 2), 16); const g = parseInt(cleaned.substring(2, 4), 16); const b = parseInt(cleaned.substring(4, 6), 16); if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; return { r, g, b }; } return null; } /** * RGB 欧几里得距离 */ function colorDistance(rgb1, rgb2) { return Math.sqrt( (rgb1.r - rgb2.r) ** 2 + (rgb1.g - rgb2.g) ** 2 + (rgb1.b - rgb2.b) ** 2, ); } /** * 判断字符串是否为颜色值(hex 格式) */ function isHexColor(value) { if (!value || typeof value !== 'string') return false; return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value.trim()); } /** * 归一化数值:去掉 px 后缀,返回数值字符串(保留小数) */ function normalizeNumericValue(value) { if (value == null) return null; const str = String(value).trim(); if (str.endsWith('px')) { return str.slice(0, -2).trim(); } if (str.endsWith('rem') || str.endsWith('em')) { return null; // rem/em 标记为 pending,不参与数值匹配 } return str; } /** * 归一化颜色值:统一为小写 #rrggbb */ function normalizeColor(value) { if (!value) return null; const str = String(value).trim(); const hex = str.startsWith('#') ? str : `#${str}`; const rgb = hexToRgb(hex); if (!rgb) return null; return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`; } /** * CSS 属性到 token 类别的映射 */ const CSS_PROPERTY_CATEGORIES = { // 颜色类 color: 'color', backgroundColor: 'color', 'background-color': 'color', background: 'color', borderColor: 'color', 'border-color': 'color', borderTopColor: 'color', borderBottomColor: 'color', borderLeftColor: 'color', borderRightColor: 'color', outlineColor: 'color', textColor: 'color', fill: 'color', // 间距类 padding: 'spacing', paddingTop: 'spacing', paddingBottom: 'spacing', paddingLeft: 'spacing', paddingRight: 'spacing', paddingVertical: 'spacing', paddingHorizontal: 'spacing', margin: 'spacing', marginTop: 'spacing', marginBottom: 'spacing', marginLeft: 'spacing', marginRight: 'spacing', gap: 'spacing', rowGap: 'spacing', columnGap: 'spacing', top: 'spacing', bottom: 'spacing', left: 'spacing', right: 'spacing', // 排版类 fontSize: 'typography', lineHeight: 'typography', fontWeight: 'typography', letterSpacing: 'typography', // 圆角类 borderRadius: 'radius', borderTopLeftRadius: 'radius', borderTopRightRadius: 'radius', borderBottomLeftRadius: 'radius', borderBottomRightRadius: 'radius', // 尺寸类 width: 'sizing', height: 'sizing', minWidth: 'sizing', minHeight: 'sizing', maxWidth: 'sizing', maxHeight: 'sizing', }; // --------------------------------------------------------------------------- // Token 注册表构建 // --------------------------------------------------------------------------- /** * 从 tokens.yml 构建可查找的 Token 列表 * 返回 { all: [...], byCategory: { color: [...], spacing: [...], ... } } */ function buildTokenRegistry(tokensConfig) { const all = []; const byCategory = {}; function addToken(entry, category) { const record = { token: entry.token || null, scssVar: entry.scss_var || null, value: entry.value || null, category, role: entry.role || null, note: entry.note || null, }; all.push(record); if (!byCategory[category]) byCategory[category] = []; byCategory[category].push(record); } // colors if (Array.isArray(tokensConfig.colors)) { for (const c of tokensConfig.colors) { addToken(c, 'color'); } } // unmapped_scss_variables(颜色类) if (Array.isArray(tokensConfig.unmapped_scss_variables)) { for (const v of tokensConfig.unmapped_scss_variables) { addToken(v, 'color'); } } // typography if (Array.isArray(tokensConfig.typography)) { for (const t of tokensConfig.typography) { addToken(t, 'typography'); } } // structure (line-height 归入 typography) if (Array.isArray(tokensConfig.structure)) { for (const s of tokensConfig.structure) { addToken(s, 'typography'); } } // spacing if (Array.isArray(tokensConfig.spacing)) { for (const s of tokensConfig.spacing) { addToken(s, 'spacing'); } } // radius if (Array.isArray(tokensConfig.radius)) { for (const r of tokensConfig.radius) { if (r && typeof r === 'object') { addToken(r, 'radius'); } } } if (Array.isArray(tokensConfig.radius_unmapped)) { for (const r of tokensConfig.radius_unmapped) { if (r && typeof r === 'object') { addToken(r, 'radius'); } } } // sizing if (Array.isArray(tokensConfig.sizing)) { for (const s of tokensConfig.sizing) { addToken(s, 'sizing'); } } // feedback(归入 sizing 或单独) if (Array.isArray(tokensConfig.feedback)) { for (const f of tokensConfig.feedback) { addToken(f, 'feedback'); } } // tag if (Array.isArray(tokensConfig.tag)) { for (const t of tokensConfig.tag) { addToken(t, 'tag'); } } // shadow_unmapped if (Array.isArray(tokensConfig.shadow_unmapped)) { for (const s of tokensConfig.shadow_unmapped) { addToken(s, 'shadow'); } } return { all, byCategory }; } // --------------------------------------------------------------------------- // 三层匹配 // --------------------------------------------------------------------------- /** * Layer 1: 别名直查(支持值条件映射) * * alias 格式有两种: * 单条: { token: "...", status: "exact_match" } * 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" }, * { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }] * * 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。 */ function matchByAlias(key, prototypeValue, aliases) { if (!aliases || !aliases.prototype_keys) return null; const alias = aliases.prototype_keys[key]; if (!alias) return null; // 数组格式:按值条件匹配 if (Array.isArray(alias)) { const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue); for (const entry of alias) { const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value); if (normalizedInput === normalizedAlias) { const result = { method: 'alias', confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending', token: entry.token || null, scssVar: entry.scss_var || null, }; if (entry.variant) result.variant = entry.variant; if (entry.note) result.note = entry.note; return result; } } // 数组中无值匹配 → 降级到 Layer 2 return null; } // 单条格式(兼容旧格式) const result = { method: 'alias', confidence: null }; // 值校验:如果 alias 有 value 字段,比较值 if (alias.value && prototypeValue != null) { const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value); const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue); if (normAlias !== normProto) { // 值不匹配 → 降级到 Layer 2 return null; } } if (alias.status === 'exact_match') { result.token = alias.token || null; result.scssVar = alias.scss_var || null; result.confidence = 'confirmed'; } else if (alias.status === 'unmatched') { result.token = alias.token || null; result.scssVar = alias.scss_var || null; result.tokenValue = alias.value || null; result.confidence = 'pending'; if (alias.note) result.note = alias.note; if (!result.token && alias.scssVar) { result.note = result.note || `tokens.scss 未声明为 CSS 变量,直接使用 SCSS 变量 ${alias.scssVar}`; } } else if (alias.status === 'approximate') { result.token = alias.token || null; result.scssVar = alias.scss_var || null; result.confidence = 'approximate'; if (alias.note) result.note = alias.note; } return result; } /** * 获取用于值匹配的候选 token 列表 * 根据 CSS 属性确定查找类别 */ function getCandidatesByProperty(propertyName, registry) { const category = CSS_PROPERTY_CATEGORIES[propertyName]; if (!category) { // 未知属性,在所有 token 中搜索 return registry.all; } if (category === 'color') { // color 类同时搜索 colors + unmapped_scss_variables return [ ...(registry.byCategory.color || []), ...(registry.byCategory.shadow || []), // 阴影也含颜色信息 ]; } if (category === 'spacing') { return [ ...(registry.byCategory.spacing || []), ...(registry.byCategory.tag || []), // tag 也有 padding ]; } if (category === 'typography') { return registry.byCategory.typography || []; } if (category === 'radius') { return registry.byCategory.radius || []; } if (category === 'sizing') { return [ ...(registry.byCategory.sizing || []), ...(registry.byCategory.feedback || []), ]; } return registry.byCategory[category] || registry.all; } /** * Layer 2: 值精确匹配(带 CSS 属性上下文消歧) * @param {string} value - 原型中的值 * @param {string|null} cssProperty - 关联的 CSS 属性名(用于消歧) * @param {object} registry - Token 注册表 * @returns {object|null} 匹配结果 */ function matchByValue(value, cssProperty, registry) { if (value == null) return null; const candidates = getCandidatesByProperty(cssProperty, registry); if (candidates.length === 0) return null; // 颜色匹配 if (isHexColor(value)) { const normalizedInput = normalizeColor(value); if (!normalizedInput) return null; for (const candidate of candidates) { if (!candidate.value) continue; if (!isHexColor(candidate.value)) continue; const normalizedCandidate = normalizeColor(candidate.value); if (normalizedCandidate === normalizedInput) { return buildValueMatchResult(candidate, value); } } return null; } // 数值匹配(含 px 后缀) const normalizedInput = normalizeNumericValue(value); if (normalizedInput === null) { // rem/em — 标记为 pending return { token: null, prototypeValue: value, method: 'value_exact', confidence: 'pending', note: 'rem/em 值无法自动匹配,需手动确认', }; } for (const candidate of candidates) { if (!candidate.value) continue; const normalizedCandidate = normalizeNumericValue(candidate.value); if (normalizedCandidate === null) continue; if (normalizedCandidate === normalizedInput) { return buildValueMatchResult(candidate, value); } } // 无单位数值直接比较(如 lineHeight: 1.5) for (const candidate of candidates) { if (!candidate.value) continue; if (String(candidate.value).trim() === String(value).trim()) { return buildValueMatchResult(candidate, value); } } return null; } /** * 构建值匹配结果 */ function buildValueMatchResult(candidate, prototypeValue) { const result = { token: candidate.token || null, scssVar: candidate.scssVar || null, prototypeValue, tokenValue: candidate.value, method: 'value_exact', confidence: candidate.token ? 'confirmed' : 'pending', }; if (candidate.role) result.role = candidate.role; if (!candidate.token && candidate.scssVar) { result.note = `匹配到 SCSS 变量 ${candidate.scssVar},但无对应 CSS Token`; } return result; } /** * Layer 3: 色彩模糊匹配(RGB 欧几里得距离) * @param {string} value - hex 颜色值 * @param {object} registry - Token 注册表 * @param {number} threshold - 距离阈值,默认 30 * @returns {object|null} 最佳近似匹配 */ function matchByColorFuzzy(value, registry, threshold = 30) { const inputRgb = hexToRgb(value); if (!inputRgb) return null; // 在所有颜色类 token 中搜索 const colorCandidates = [ ...(registry.byCategory.color || []), ]; let bestMatch = null; let bestDistance = Infinity; for (const candidate of colorCandidates) { if (!candidate.value) continue; const candidateRgb = hexToRgb(candidate.value); if (!candidateRgb) continue; const dist = colorDistance(inputRgb, candidateRgb); if (dist < bestDistance) { bestDistance = dist; bestMatch = candidate; } } if (bestMatch && bestDistance < threshold) { return { token: bestMatch.token || null, scssVar: bestMatch.scssVar || null, prototypeValue: value, tokenValue: bestMatch.value, method: 'color_fuzzy', confidence: 'approximate', distance: Math.round(bestDistance * 100) / 100, note: `颜色近似匹配(RGB 距离 ${Math.round(bestDistance * 100) / 100})`, }; } return null; } /** * 对单个值执行完整三层匹配 * @param {string} value - 要匹配的值 * @param {string|null} cssProperty - CSS 属性名(消歧用) * @param {object} registry - Token 注册表 * @returns {object} 匹配结果 */ function fullMatchValue(value, cssProperty, registry) { // Layer 2: 值精确匹配 const exact = matchByValue(value, cssProperty, registry); if (exact) return exact; // Layer 3: 颜色模糊匹配(仅对颜色值) if (isHexColor(value)) { const fuzzy = matchByColorFuzzy(value, registry); if (fuzzy) return fuzzy; } // 未匹配 return { token: null, scssVar: null, prototypeValue: value, method: 'none', confidence: 'unmatched', }; } // --------------------------------------------------------------------------- // 主流程 // --------------------------------------------------------------------------- function main() { const args = process.argv.slice(2); if (args.length < 2) { process.stderr.write('用法: node match-tokens.mjs \n'); process.exit(1); } const [parseResultPath, tokensYmlPath] = args; // 读取输入文件 let parseResult; try { const raw = readFileSync(resolve(parseResultPath), 'utf-8'); parseResult = JSON.parse(raw); } catch (err) { process.stderr.write(`无法读取/解析 parse-result JSON: ${err.message}\n`); process.exit(1); } let tokensConfig; try { const raw = readFileSync(resolve(tokensYmlPath), 'utf-8'); tokensConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA }); } catch (err) { process.stderr.write(`无法读取/解析 tokens.yml: ${err.message}\n`); process.exit(1); } // 构建 Token 注册表 const registry = buildTokenRegistry(tokensConfig); const aliases = tokensConfig.aliases || null; // ---- 匹配原型 Token 别名 ---- const matched = {}; const unmatched = []; const prototypeTokens = parseResult.tokens || {}; for (const [key, value] of Object.entries(prototypeTokens)) { // Layer 1: 别名直查 const aliasResult = matchByAlias(key, value, aliases); if (aliasResult) { matched[key] = { ...aliasResult, prototypeValue: value, // 如果 alias 没有覆盖 tokenValue,从 token 注册表中查找 tokenValue: aliasResult.tokenValue || null, }; // 补充 tokenValue if (aliasResult.token && !matched[key].tokenValue) { const found = registry.all.find(t => t.token === aliasResult.token); if (found) matched[key].tokenValue = found.value; } continue; } // 无别名,尝试值匹配(需要推断 CSS 属性上下文) const inferredProperty = inferPropertyFromTokenKey(key); const valueResult = fullMatchValue(value, inferredProperty, registry); if (valueResult.confidence !== 'unmatched') { matched[key] = valueResult; } else { unmatched.push(key); } } // ---- 匹配内联样式值 ---- const inlineTokenMap = {}; const inlineStyles = parseResult.inlineStyles || {}; for (const [cssProperty, values] of Object.entries(inlineStyles)) { for (const rawValue of values) { const mapKey = `${cssProperty}:${rawValue}`; const result = fullMatchValue(rawValue, cssProperty, registry); if (result.confidence !== 'unmatched') { inlineTokenMap[mapKey] = { token: result.token, scssVar: result.scssVar || undefined, tokenValue: result.tokenValue || undefined, confidence: result.confidence, method: result.method, }; if (result.note) inlineTokenMap[mapKey].note = result.note; if (result.distance != null) inlineTokenMap[mapKey].distance = result.distance; } } } // ---- 汇总统计 ---- let confirmed = 0; let pending = 0; let approximate = 0; for (const entry of Object.values(matched)) { if (entry.confidence === 'confirmed') confirmed++; else if (entry.confidence === 'pending') pending++; else if (entry.confidence === 'approximate') approximate++; } for (const entry of Object.values(inlineTokenMap)) { if (entry.confidence === 'confirmed') confirmed++; else if (entry.confidence === 'pending') pending++; else if (entry.confidence === 'approximate') approximate++; } const totalAlias = Object.keys(prototypeTokens).length; const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0); // 检测 variant const variantEntries = Object.values(matched).filter(e => e.variant); const variant = variantEntries.length > 0 ? variantEntries[0].variant : null; // ---- 输出 ---- const output = { source: parseResult.source || null, variant, matched, unmatched, inlineTokenMap, summary: { total: totalAlias + totalInline, aliasTokens: totalAlias, inlineValues: totalInline, confirmed, pending, approximate, unmatched: unmatched.length, }, }; process.stdout.write(JSON.stringify(output, null, 2) + '\n'); } /** * 从 Token 别名 key 推断 CSS 属性(用于无别名时的消歧) */ function inferPropertyFromTokenKey(key) { const lower = key.toLowerCase(); if (lower.includes('pri') || lower.includes('color') || lower.includes('bg') || lower.includes('tx') || lower.includes('bd') || lower.includes('card') || lower.includes('surface') || lower.includes('acc') || lower.includes('dan') || lower.includes('wrn') || lower.includes('wechat')) { return 'color'; } if (lower.includes('font') || lower.includes('text') && !lower.includes('color')) { return 'fontSize'; } if (lower.includes('r') && (lower.includes('sm') || lower.includes('xs') || lower.length <= 3)) { return 'borderRadius'; } if (lower.includes('gap') || lower.includes('pad') || lower.includes('margin')) { return 'padding'; } return null; // 未知,不限制类别 } // 启动 main();