diff --git a/.claude/skills/design-handoff/defaults/tokens.yml b/.claude/skills/design-handoff/defaults/tokens.yml index 6e7b165..72acfe4 100644 --- a/.claude/skills/design-handoff/defaults/tokens.yml +++ b/.claude/skills/design-handoff/defaults/tokens.yml @@ -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 diff --git a/.claude/skills/design-handoff/scripts/match-tokens.mjs b/.claude/skills/design-handoff/scripts/match-tokens.mjs new file mode 100644 index 0000000..dd66e0a --- /dev/null +++ b/.claude/skills/design-handoff/scripts/match-tokens.mjs @@ -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 + */ + +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 \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(); diff --git a/.claude/skills/design-handoff/scripts/parse-prototype.mjs b/.claude/skills/design-handoff/scripts/parse-prototype.mjs new file mode 100644 index 0000000..cef1fa8 --- /dev/null +++ b/.claude/skills/design-handoff/scripts/parse-prototype.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +/** + * parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件 + * + * 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。 + * 输出 JSON 到 stdout。 + * + * 用法: node parse-prototype.mjs + */ + +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)
... 文本 ... ...
+ // 2)
... 文本 ... ...
+ + // 匹配所有 screen-wrap 块 + const wrapPattern = /]*>([\s\S]*?)<\/div>\s*(?=\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 组件 + // 模式: \n \n + // 或: + const iosFramePattern = /]*>\s*(?:]*>)?\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 '); + } + + 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();