Files
hms/.claude/skills/design-handoff/scripts/match-tokens.mjs
iven 6151fde7c4 fix(skills): 修复 design-handoff 医生端原型输出质量
- match-tokens: alias 增加值条件映射,支持 doctor/patient variant 自动检测
- parse-prototype: 支持 IosFrame label prop 提取(JSX 属性模式)
- interaction-rules: login-cta 增加 exclude_patterns 避免"立即关注"误报
- tokens.yml: T.pri/T.priL/T.priD alias 改为数组格式,按值匹配 variant

验证: 患者端 T.pri→--tk-pri 确认,医生端 T.pri→--tk-pri.doctor 确认
2026-05-18 01:00:20 +08:00

707 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <parse-result.json> <tokens.yml>
*/
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 <parse-result.json> <tokens.yml>\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();