feat(skills): 添加 HTML 原型解析 + Token 三层匹配脚本
- parse-prototype.mjs: 括号深度计数法提取 T 对象、内联样式值、screen/组件信息 - match-tokens.mjs: 别名直查→值精确匹配(带 CSS 属性消歧)→色彩模糊匹配(RGB Δ<30) - 修复 tokens.yml radius 段 YAML 格式(unmapped 提升为顶级段)
This commit is contained in:
@@ -253,18 +253,17 @@ radius:
|
||||
scss_var: "$r"
|
||||
elder: "20px"
|
||||
|
||||
# SCSS 变量中有但 tokens.scss 未声明为 CSS 变量的圆角
|
||||
unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
radius_unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
|
||||
sizing:
|
||||
- token: --tk-touch-min
|
||||
|
||||
666
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
666
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
@@ -0,0 +1,666 @@
|
||||
#!/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: 别名直查
|
||||
* @returns {object|null} 匹配结果或 null
|
||||
*/
|
||||
function matchByAlias(key, aliases) {
|
||||
if (!aliases || !aliases.prototype_keys) return null;
|
||||
const alias = aliases.prototype_keys[key];
|
||||
if (!alias) return null;
|
||||
|
||||
const result = {
|
||||
method: 'alias',
|
||||
confidence: 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, 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);
|
||||
|
||||
// ---- 输出 ----
|
||||
const output = {
|
||||
source: parseResult.source || null,
|
||||
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();
|
||||
385
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
385
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件
|
||||
*
|
||||
* 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。
|
||||
* 输出 JSON 到 stdout。
|
||||
*
|
||||
* 用法: node parse-prototype.mjs <html-file-path>
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输出错误 JSON 到 stderr 并以 code 1 退出
|
||||
*/
|
||||
function fail(message) {
|
||||
process.stderr.write(JSON.stringify({ valid: false, error: message }, null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 括号深度计数法:从 source[startPos] 开始(startPos 应指向 '{'),
|
||||
* 提取完整的平衡花括号内容。
|
||||
*
|
||||
* 正确处理:
|
||||
* - 单引号/双引号内的花括号不计数
|
||||
* - 模板字符串(`)内的花括号不计数
|
||||
* - 转义字符(\" \' \\ \x \u)跳过
|
||||
*
|
||||
* 返回包含外层花括号的完整字符串,或在无法平衡时返回 null。
|
||||
*/
|
||||
function extractBalancedBraces(source, startPos) {
|
||||
if (source[startPos] !== '{') return null;
|
||||
|
||||
let depth = 0;
|
||||
let i = startPos;
|
||||
const len = source.length;
|
||||
|
||||
while (i < len) {
|
||||
const ch = source[i];
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
// 字符串字面量:跳到配对的结束引号
|
||||
const quote = ch;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2; // 跳过转义字符
|
||||
continue;
|
||||
}
|
||||
if (source[i] === quote) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
// 模板字符串:处理 ${...} 内的嵌套,以及转义
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '`') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 模板表达式 ${...},需要单独平衡
|
||||
i += 2; // 跳过 ${
|
||||
let tmplDepth = 1;
|
||||
while (i < len && tmplDepth > 0) {
|
||||
const tc = source[i];
|
||||
if (tc === '{') tmplDepth++;
|
||||
else if (tc === '}') tmplDepth--;
|
||||
// 简化:模板表达式内也可以有字符串,但这里不递归处理
|
||||
// 因为 T 对象的值不太可能有如此复杂的嵌套
|
||||
if (tc === "'" || tc === '"') {
|
||||
const tq = tc;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === tq) { i++; break; }
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tc === '`') {
|
||||
// 嵌套模板字符串
|
||||
i++;
|
||||
let nestedTmplDepth = 0;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === '`') { i++; break; }
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 这里简化处理,不递归
|
||||
i += 2;
|
||||
nestedTmplDepth++;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '{') nestedTmplDepth++;
|
||||
if (source[i] === '}') {
|
||||
if (nestedTmplDepth > 0) nestedTmplDepth--;
|
||||
else { i++; break; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && i + 1 < len) {
|
||||
// 注释处理
|
||||
if (source[i + 1] === '/') {
|
||||
// 单行注释
|
||||
while (i < len && source[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] === '*') {
|
||||
// 多行注释
|
||||
i += 2;
|
||||
while (i + 1 < len && !(source[i] === '*' && source[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startPos, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return null; // 未平衡
|
||||
}
|
||||
|
||||
// ─── Step 0: 格式校验 ────────────────────────────────────────
|
||||
|
||||
function validateSource(source) {
|
||||
const hasReact = source.includes('react@') || source.includes('react.production');
|
||||
const hasBabel = source.includes('@babel/standalone') || source.includes('babel.min');
|
||||
const hasToken = /const\s+T\s*=\s*\{/.test(source);
|
||||
|
||||
if (!hasReact) return '文件不包含 React 引用';
|
||||
if (!hasBabel) return '文件不包含 Babel 引用';
|
||||
if (!hasToken) return '文件不包含 T 对象定义 (const T = {)';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Step 1: 提取 T 对象 ─────────────────────────────────────
|
||||
|
||||
function extractTokens(source) {
|
||||
// 匹配 const T = { / const T ={ / const T={ 等各种空格变体
|
||||
const markerPattern = /const\s+T\s*=\s*\{/;
|
||||
const markerMatch = markerPattern.exec(source);
|
||||
if (!markerMatch) return {};
|
||||
|
||||
const braceStart = markerMatch.index + markerMatch[0].length - 1; // 指向 '{'
|
||||
const rawObject = extractBalancedBraces(source, braceStart);
|
||||
|
||||
if (!rawObject) return {};
|
||||
|
||||
// 将 JS 对象字面量转为可求值字符串,用 Function 构造器执行
|
||||
try {
|
||||
const fn = new Function('return (' + rawObject + ')');
|
||||
const tObj = fn();
|
||||
|
||||
// 展平为 "T.key": "value" 格式
|
||||
const flat = {};
|
||||
for (const [key, value] of Object.entries(tObj)) {
|
||||
flat[`T.${key}`] = String(value);
|
||||
}
|
||||
return flat;
|
||||
} catch {
|
||||
// 如果 Function 执行失败,尝试用正则提取简单键值对作为降级方案
|
||||
const fallback = {};
|
||||
const kvPattern = /(\w+)\s*:\s*'([^']*)'/g;
|
||||
const kvPattern2 = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
const kvPatternNum = /(\w+)\s*:\s*(\d+(?:\.\d+)?)/g;
|
||||
|
||||
let match;
|
||||
const innerContent = rawObject.slice(1, -1);
|
||||
while ((match = kvPattern.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPattern2.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPatternNum.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 2: 提取内联样式硬编码值 ─────────────────────────────
|
||||
|
||||
const STYLE_PROPERTIES = ['fontSize', 'padding', 'borderRadius', 'gap', 'margin', 'fontWeight', 'lineHeight', 'width', 'height', 'letterSpacing'];
|
||||
|
||||
function extractInlineStyles(source) {
|
||||
const result = {};
|
||||
|
||||
for (const prop of STYLE_PROPERTIES) {
|
||||
const values = new Set();
|
||||
|
||||
// 匹配 camelCase 属性: fontSize: 16, fontSize: '16px', fontSize: "16px"
|
||||
// 也匹配 object shorthand: { fontSize: 16 }
|
||||
const pattern = new RegExp(
|
||||
prop + '\\s*:\\s*([\'"`]?)([\\w\\s%.,()-]+?)\\1\\s*[,}]',
|
||||
'g'
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
let val = match[2].trim();
|
||||
// 过滤掉变量引用(如 T.pri, T.r 等)
|
||||
if (val.startsWith('T.') || val.startsWith('${')) continue;
|
||||
// 过滤模板字符串插值
|
||||
if (val.includes('${')) continue;
|
||||
// 过滤掉 CSS 变量引用
|
||||
if (val.startsWith('var(')) continue;
|
||||
values.add(val);
|
||||
}
|
||||
|
||||
if (values.size > 0) {
|
||||
// 去重并排序:数值优先(降序),字符串按字母序
|
||||
const sorted = [...values].sort((a, b) => {
|
||||
const numA = parseFloat(a);
|
||||
const numB = parseFloat(b);
|
||||
if (!isNaN(numA) && !isNaN(numB)) return numB - numA;
|
||||
if (!isNaN(numA)) return -1;
|
||||
if (!isNaN(numB)) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
result[prop] = sorted;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Step 3: 提取 screen 信息 ────────────────────────────────
|
||||
|
||||
function extractScreens(source) {
|
||||
const screens = [];
|
||||
|
||||
// 策略:找所有 screen-wrap 块,从中提取 screen-label 文本和组件名
|
||||
// 两种形式:
|
||||
// 1) <div className="screen-wrap"> ... <span className="screen-label">文本</span> ... <IosFrame> ... </div>
|
||||
// 2) <div class="screen-wrap"> ... <span class="screen-label">文本</span> ... <IosFrame> ... </div>
|
||||
|
||||
// 匹配所有 screen-wrap 块
|
||||
const wrapPattern = /<div\s+(?:className|class)="screen-wrap"[^>]*>([\s\S]*?)<\/div>\s*(?=<div\s+(?:className|class)="screen-wrap"|<\/div>\s*<\/div>|\s*$)/g;
|
||||
|
||||
// 更可靠的方式:先找所有 screen-label,再在上下文中找组件
|
||||
// 因为 screen-wrap 的 HTML 嵌套可能很复杂
|
||||
|
||||
// 提取所有 screen-label 文本
|
||||
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
|
||||
const labels = [];
|
||||
let labelMatch;
|
||||
while ((labelMatch = labelPattern.exec(source)) !== null) {
|
||||
labels.push(labelMatch[1].trim());
|
||||
}
|
||||
|
||||
// 提取所有 screen-wrap 内的 IosFrame children 组件
|
||||
// 模式: <IosFrame ...>\n <ComponentName />\n</IosFrame>
|
||||
// 或: <IosFrame ...><ComponentName /></IosFrame>
|
||||
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
|
||||
const components = [];
|
||||
let compMatch;
|
||||
while ((compMatch = iosFramePattern.exec(source)) !== null) {
|
||||
const name = compMatch[1];
|
||||
// 排除 HTML 标签和 React 内置
|
||||
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
|
||||
components.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 将 labels 和 components 配对
|
||||
const count = Math.max(labels.length, components.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
screens.push({
|
||||
label: labels[i] || '',
|
||||
component: components[i] || '',
|
||||
});
|
||||
}
|
||||
|
||||
return screens;
|
||||
}
|
||||
|
||||
// ─── Step 4: 识别组件函数 ─────────────────────────────────────
|
||||
|
||||
function extractComponents(source) {
|
||||
const components = new Set();
|
||||
|
||||
// 匹配 function 声明: function XxxPage() / function Xxx()
|
||||
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
|
||||
let match;
|
||||
while ((match = funcPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
// 匹配 const Xxx = () => / const Xxx = () =>
|
||||
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|[^=])\s*=>/g;
|
||||
while ((match = arrowPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
return [...components].sort((a, b) => {
|
||||
// IosFrame 排到最前面(基础设施组件)
|
||||
if (a === 'IosFrame') return -1;
|
||||
if (b === 'IosFrame') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 主流程 ──────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
fail('用法: node parse-prototype.mjs <html-file-path>');
|
||||
}
|
||||
|
||||
const filePath = resolve(args[0]);
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
fail(`无法读取文件: ${filePath} — ${err.message}`);
|
||||
}
|
||||
|
||||
// Step 0: 格式校验
|
||||
const validationError = validateSource(source);
|
||||
if (validationError) {
|
||||
fail(`格式校验失败: ${validationError}`);
|
||||
}
|
||||
|
||||
// Step 1: 提取 T 对象
|
||||
const tokens = extractTokens(source);
|
||||
|
||||
// Step 2: 提取内联样式
|
||||
const inlineStyles = extractInlineStyles(source);
|
||||
|
||||
// Step 3: 提取 screen 信息
|
||||
const screens = extractScreens(source);
|
||||
|
||||
// Step 4: 识别组件函数
|
||||
const components = extractComponents(source);
|
||||
|
||||
// 组装输出
|
||||
const result = {
|
||||
valid: true,
|
||||
filePath: filePath.replace(/\\/g, '/'),
|
||||
tokens,
|
||||
inlineStyles,
|
||||
screens,
|
||||
components,
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user