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:
iven
2026-05-17 23:37:59 +08:00
parent b8dce8a42a
commit 35bd60af5b
3 changed files with 1062 additions and 12 deletions

View File

@@ -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

View 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();

View 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();