@double-coding/pixel-print
Advanced tools
| #!/usr/bin/env node | ||
| // reskin-slice.mjs — pp-d2c-reskin skill 主脚本 | ||
| // | ||
| // 定位:完全独立的切图 skill,只依赖 Node 18+ 内置能力(fetch),不 spawn 任何兄弟 skill 的脚本。 | ||
| // | ||
| // 两种工作模式: | ||
| // 1. 有基线(--base <url> 或读到 .d2c-cache/last-page.json):按基线切图清单去每套 --theme 稿子 | ||
| // 找同名节点切图,报 miss;文件名与基线对齐,便于业务代码写 themeKey→dir 映射。 | ||
| // 2. 无基线(standalone):每套 --theme 独立扫自己图层树,前缀命中就切,不做跨稿匹配。 | ||
| // | ||
| // 前缀规则(与 pp-d2c §4 图层前缀体系对齐): | ||
| // - img / img-* → 整层导出 PNG | ||
| // - bg / bg-* → 背景图 PNG | ||
| // - 裸标签 img / bg 用父节点 name 辅助命名(sub-hero-card > bg → hero-card__bg.png) | ||
| // - 匹配去重按 <parent>||<name> 复合 key(裸标签 + 带子名统一走此规则) | ||
| // - 同名带子名(如 3 个 img-icon 分处不同父)自动加父路径 slug 前缀区分文件名,不再静默丢图 | ||
| // | ||
| // 依赖: pp-d2c.config.json(读 images.assetsDir)、.env FIGMA_TOKEN、Node 18+ | ||
| import fs from 'node:fs' | ||
| import path from 'node:path' | ||
| import { setTimeout as sleep } from 'node:timers/promises' | ||
| const CWD = process.cwd() | ||
| const FIGMA_API = 'https://api.figma.com' | ||
| const MAX_RETRIES = 3 | ||
| // ─── util ─────────────────────────────────────────────────────── | ||
| function die(msg, code = 1) { | ||
| console.error(`[pp-d2c-reskin] ${msg}`) | ||
| process.exit(code) | ||
| } | ||
| function findProjectRoot(startDir = CWD) { | ||
| let dir = path.resolve(startDir) | ||
| while (true) { | ||
| if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir | ||
| const parent = path.dirname(dir) | ||
| if (parent === dir) return null | ||
| dir = parent | ||
| } | ||
| } | ||
| function ensureDir(p) { | ||
| if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }) | ||
| } | ||
| // slug: 用于 --theme name (子目录名 + shell 参数),严格 ASCII 化保稳 | ||
| // 空串兜底为 'theme',主要用于命令行参数场景;文件名场景另用 slugForFilename | ||
| function slugify(s) { | ||
| return String(s) | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, '-') | ||
| .replace(/^-+|-+$/g, '') || 'theme' | ||
| } | ||
| // 图片文件名 slug: 保留 CJK 中日韩表意文字,避免纯中文图层名全部撞成同一个 slug | ||
| // 现代 FS / Metro / Webpack / RN require 都能吃中文文件名,不做 ASCII 化 | ||
| // 空串兜底传入 fallback(通常是 nodeId 冒号形式,不撞车) | ||
| function slugForFilename(s, fallback) { | ||
| const cleaned = String(s) | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9一-鿿]+/g, '-') | ||
| .replace(/^-+|-+$/g, '') | ||
| return cleaned || fallback | ||
| } | ||
| // 极简 .env 解析:KEY=VALUE,支持引号和 # 注释,不做变量插值 | ||
| function parseEnvFile(text) { | ||
| const out = {} | ||
| for (const rawLine of text.split(/\r?\n/)) { | ||
| const line = rawLine.trim() | ||
| if (!line || line.startsWith('#')) continue | ||
| const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/) | ||
| if (!m) continue | ||
| let val = m[2] | ||
| if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { | ||
| val = val.slice(1, -1) | ||
| } | ||
| out[m[1]] = val | ||
| } | ||
| return out | ||
| } | ||
| // token 读取:process.env > 项目根 .env > 兜底 config.figma.token(老项目兼容) | ||
| function loadFigmaToken(projectRoot, config) { | ||
| if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN | ||
| const envPath = path.join(projectRoot, '.env') | ||
| if (fs.existsSync(envPath)) { | ||
| try { | ||
| const parsed = parseEnvFile(fs.readFileSync(envPath, 'utf8')) | ||
| if (parsed.FIGMA_TOKEN) return parsed.FIGMA_TOKEN | ||
| } catch {} | ||
| } | ||
| return config.figma?.token || null | ||
| } | ||
| // figma URL 形态:https://www.figma.com/design/<fileKey>/<name>?node-id=<a>-<b> | ||
| function parseFigmaUrl(u) { | ||
| try { | ||
| const url = new URL(u) | ||
| const m = url.pathname.match(/\/(design|file)\/([A-Za-z0-9]+)/) | ||
| if (!m) return null | ||
| const fileKey = m[2] | ||
| const rawNode = url.searchParams.get('node-id') | ||
| if (!rawNode) return { fileKey, nodeId: null } | ||
| // URL 里 node-id 用 - 分隔(如 138-2050),API 里用 : 分隔(138:2050) | ||
| const nodeId = rawNode.includes(':') ? rawNode : rawNode.replace(/-/, ':') | ||
| return { fileKey, nodeId } | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
| function parseArgs(argv) { | ||
| // dedupeSiblings 默认 false:同父下同名节点(auto-layout 循环卡片)全部切出; | ||
| // 打开 → 同 <parent>||<name> 只切第一个,兼容极少数"循环项刻意重复,切一次即可"场景 | ||
| // | ||
| // outManifest (v1.1.0 pp-d2c Step 1.5 契约): 写切图清单到指定路径,格式: | ||
| // { generatedAt, mode, themes: [{ slug, entries: [{ nodeId, name, parentName, filename, filepath, sliceWidth?, sliceHeight? }] }] } | ||
| const out = { themes: [], dryRun: false, base: null, prefixes: ['img', 'bg'], dedupeSiblings: false, outManifest: null } | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const a = argv[i] | ||
| if (a === '--dry-run' || a === '-n') { out.dryRun = true; continue } | ||
| if (a === '--dedupe-siblings') { out.dedupeSiblings = true; continue } | ||
| if (a === '--base') { out.base = argv[++i]; continue } | ||
| if (a === '--out-manifest') { out.outManifest = argv[++i]; continue } | ||
| if (a === '--prefix') { | ||
| out.prefixes = argv[++i].split(',').map(p => p.replace(/-+$/, '')) | ||
| continue | ||
| } | ||
| if (a === '--theme') { | ||
| const raw = argv[++i] | ||
| const eq = raw.indexOf('=') | ||
| if (eq < 0) die(`--theme 参数格式错: ${raw},应为 <name>=<figmaUrl>`) | ||
| const name = raw.slice(0, eq) | ||
| const url = raw.slice(eq + 1) | ||
| out.themes.push({ name, slug: slugify(name), url }) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
| // ─── Figma REST 封装(内嵌,不 spawn) ───────────────────────── | ||
| async function figmaFetch(pathAndQuery, token) { | ||
| let lastErr | ||
| for (let i = 0; i < MAX_RETRIES; i++) { | ||
| try { | ||
| const res = await fetch(`${FIGMA_API}${pathAndQuery}`, { | ||
| headers: { 'X-Figma-Token': token }, | ||
| }) | ||
| if (res.status === 403 || res.status === 401) { | ||
| throw new Error(`Figma API auth failed (HTTP ${res.status}); token invalid, expired, or lacks permission`) | ||
| } | ||
| if (!res.ok) { | ||
| const body = await res.text() | ||
| throw new Error(`Figma API error HTTP ${res.status}: ${body.slice(0, 200)}`) | ||
| } | ||
| const json = await res.json() | ||
| if (json.err) throw new Error(`Figma API returned err: ${json.err}`) | ||
| return json | ||
| } catch (e) { | ||
| lastErr = e | ||
| if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000) | ||
| } | ||
| } | ||
| throw lastErr | ||
| } | ||
| async function downloadToFile(url, destPath) { | ||
| let lastErr | ||
| for (let i = 0; i < MAX_RETRIES; i++) { | ||
| try { | ||
| const res = await fetch(url) | ||
| if (!res.ok) throw new Error(`download HTTP ${res.status}`) | ||
| const buf = Buffer.from(await res.arrayBuffer()) | ||
| ensureDir(path.dirname(destPath)) | ||
| fs.writeFileSync(destPath, buf) | ||
| return | ||
| } catch (e) { | ||
| lastErr = e | ||
| if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000) | ||
| } | ||
| } | ||
| throw lastErr | ||
| } | ||
| // 拉一个 frame 的子树 JSON | ||
| async function fetchNodeTree(fileKey, nodeId, token) { | ||
| const q = new URLSearchParams({ ids: nodeId }) | ||
| const resp = await figmaFetch(`/v1/files/${fileKey}/nodes?${q.toString()}`, token) | ||
| const doc = resp.nodes?.[nodeId]?.document | ||
| if (!doc) throw new Error(`Figma /v1/files/${fileKey}/nodes 未返回 ${nodeId} 的 document`) | ||
| return doc | ||
| } | ||
| // 单节点导出为 PNG,直接落到指定路径 | ||
| async function exportImageToPath(fileKey, nodeId, token, destPath, scale = 2) { | ||
| const q = new URLSearchParams({ | ||
| ids: nodeId, | ||
| format: 'png', | ||
| scale: String(scale), | ||
| use_absolute_bounds: 'true', | ||
| }) | ||
| const resp = await figmaFetch(`/v1/images/${fileKey}?${q.toString()}`, token) | ||
| const url = resp.images?.[nodeId] | ||
| if (!url) throw new Error(`Figma /v1/images 未返回 ${nodeId} 的 URL`) | ||
| await downloadToFile(url, destPath) | ||
| } | ||
| // 读 PNG 前 24 字节的 IHDR chunk 拿宽高 (纯 Node fs, 无第三方依赖) | ||
| // 用于 v1.1.0 bg 溢出检测: 断言导出的 png 尺寸 ≈ node.absoluteBoundingBox * scale | ||
| function readPngDimensions(pngPath) { | ||
| const fd = fs.openSync(pngPath, 'r') | ||
| try { | ||
| const buf = Buffer.alloc(24) | ||
| fs.readSync(fd, buf, 0, 24, 0) | ||
| // PNG signature: 89 50 4E 47 0D 0A 1A 0A + IHDR chunk (length[4] "IHDR" width[4] height[4]) | ||
| if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) { | ||
| throw new Error('not a PNG file') | ||
| } | ||
| const width = buf.readUInt32BE(16) | ||
| const height = buf.readUInt32BE(20) | ||
| return { width, height } | ||
| } finally { | ||
| fs.closeSync(fd) | ||
| } | ||
| } | ||
| // 断言 png 尺寸 ≈ node bbox * scale (容差 4px 覆盖亚像素舍入) | ||
| // 返回 null = 通过; 返回 string = 违规原因 | ||
| function assertPngSize(pngPath, node, scale) { | ||
| if (!node || !node.absoluteBoundingBox) return null | ||
| const bb = node.absoluteBoundingBox | ||
| const expectedW = Math.round(bb.width * scale) | ||
| const expectedH = Math.round(bb.height * scale) | ||
| let actual | ||
| try { | ||
| actual = readPngDimensions(pngPath) | ||
| } catch (e) { | ||
| return `PNG 读取失败: ${e.message}` | ||
| } | ||
| const dx = Math.abs(actual.width - expectedW) | ||
| const dy = Math.abs(actual.height - expectedH) | ||
| const TOL = 4 | ||
| if (dx > TOL || dy > TOL) { | ||
| return `png ${actual.width}x${actual.height} 与 node bbox ${expectedW}x${expectedH} 相差 dx=${dx} dy=${dy} (兄弟节点溢出到 renderBounds?)` | ||
| } | ||
| return null | ||
| } | ||
| // ─── 前缀匹配 + 图层遍历 ───────────────────────────────────── | ||
| function isSliceName(name, prefixes) { | ||
| for (const p of prefixes) { | ||
| if (name === p) return true | ||
| if (name.startsWith(p + '-')) return true | ||
| } | ||
| return false | ||
| } | ||
| function collectSliceNodes(node, prefixes, out = [], pathStack = [], parentName = null) { | ||
| if (!node) return out | ||
| const name = node.name || '' | ||
| if (isSliceName(name, prefixes)) { | ||
| // renderBounds 是 Figma 出图真实裁剪范围(含描边/投影/子元素溢出); | ||
| // boundingBox 是名义框,遇 mask/clip 时会锁死 → 排查 PNG 尺寸不符预期时看前者 | ||
| const renderBounds = node.absoluteRenderBounds || null | ||
| const boundingBox = node.absoluteBoundingBox || null | ||
| out.push({ | ||
| id: node.id, | ||
| name, | ||
| parentName, | ||
| pathStack: [...pathStack, name], | ||
| renderBounds, | ||
| boundingBox, | ||
| }) | ||
| } | ||
| if (Array.isArray(node.children)) { | ||
| for (const child of node.children) { | ||
| collectSliceNodes(child, prefixes, out, [...pathStack, name], name) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
| // 基础文件名(不带父路径前缀,可能与其他节点撞):裸标签借父 name 拼,带子名去前缀 slug 化 | ||
| // 冲突消解在 resolveFilenameCollisions() 里统一做,这里只出"意图名" | ||
| function baseFilename(name, parentName, nodeId) { | ||
| const idSafe = String(nodeId || '').replace(/:/g, '_') || 'node' | ||
| if (name === 'img' || name === 'bg') { | ||
| const parentStripped = parentName | ||
| ? parentName.replace(/^(img|bg|sub|block|scrollx|scrolly|fixed|end|btn|input|x)-/, '') | ||
| : 'root' | ||
| const parentSlug = slugForFilename(parentStripped, idSafe) | ||
| return `${parentSlug}__${name}` | ||
| } | ||
| const stripped = name.replace(/^(img|bg)-/, '') | ||
| return slugForFilename(stripped, idSafe) | ||
| } | ||
| // 收 pathStack 里除自身外最近的一层祖先 name 做前缀(跳过通用组名"编组"/"Group"及 slice 名自身) | ||
| // 目的:img-icon × 3 分处不同父 Frame → frame-722__icon / frame-726__icon / frame-730__icon | ||
| function parentPrefixSlug(pathStack, nodeId) { | ||
| const idSafe = String(nodeId || '').replace(/:/g, '_') || 'node' | ||
| const GENERIC = new Set(['编组', 'group', 'frame']) | ||
| // pathStack 最后一项是自身 name,倒数第二项才是父;继续往上找到第一个非通用名 | ||
| for (let i = pathStack.length - 2; i >= 0; i--) { | ||
| const raw = String(pathStack[i] || '').toLowerCase().trim() | ||
| if (!raw) continue | ||
| if (GENERIC.has(raw)) continue | ||
| // "Frame 722" 这种带数字的具体名 → 保留;"编组 6" / "Group 12" → 也保留(数字给了区分度) | ||
| const slug = slugForFilename(pathStack[i], idSafe) | ||
| if (slug && slug !== idSafe) return slug | ||
| } | ||
| return idSafe // 全是通用名兜底用 nodeId | ||
| } | ||
| // 冲突消解:同 basename 的 slice 依次加父路径前缀,极端撞名的兜底 nodeId | ||
| // 输入 slices 已经过 matchKey 去重(auto-layout 循环项之类),同 basename 只可能是"真的不同父路径下的同名" | ||
| function resolveFilenameCollisions(slices) { | ||
| const byBase = new Map() | ||
| for (const s of slices) { | ||
| const base = baseFilename(s.name, s.parentName, s.id) | ||
| if (!byBase.has(base)) byBase.set(base, []) | ||
| byBase.get(base).push(s) | ||
| } | ||
| for (const [base, group] of byBase) { | ||
| if (group.length === 1) { | ||
| group[0].filename = base | ||
| continue | ||
| } | ||
| // 撞车:每个都加父路径前缀 | ||
| const usedNames = new Map() | ||
| for (const s of group) { | ||
| const prefix = parentPrefixSlug(s.pathStack, s.id) | ||
| let candidate = `${prefix}__${base}` | ||
| // 极少数二次撞名(两个父路径 slug 又相同)→ nodeId 兜底 | ||
| if (usedNames.has(candidate)) { | ||
| candidate = `${prefix}__${base}__${String(s.id).replace(/:/g, '_')}` | ||
| } | ||
| usedNames.set(candidate, true) | ||
| s.filename = candidate | ||
| } | ||
| } | ||
| return slices | ||
| } | ||
| // matchKey:恒用 <parent>||<name> 复合 key | ||
| // - 裸标签沿用旧语义 | ||
| // - 带子名图层(img-icon / bg-card)也用父辅助 → 3 个同名 img-icon 分处不同父就是 3 个不同 key,都保留 | ||
| // - aligned-to-base 模式下换肤稿若父 Frame 改名会 miss,这是应有的严格性(父路径漂移=对不齐) | ||
| function matchKey(name, parentName) { | ||
| return `${parentName || 'root'}||${name}` | ||
| } | ||
| // ─── 主流程 ──────────────────────────────────────────────────── | ||
| async function main() { | ||
| const args = parseArgs(process.argv.slice(2)) | ||
| const projectRoot = findProjectRoot() | ||
| if (!projectRoot) die('未找到 pp-d2c.config.json,请先在项目根跑 pp-d2c init') | ||
| const config = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pp-d2c.config.json'), 'utf8')) | ||
| const assetsDir = (config.images?.assetsDir || 'static/').replace(/^\//, '') | ||
| const token = loadFigmaToken(projectRoot, config) | ||
| if (!token) die('FIGMA_TOKEN 未配置,请在项目根 .env 写 FIGMA_TOKEN=xxx') | ||
| if (args.themes.length === 0 && !args.dryRun) { | ||
| die('未传 --theme <name>=<figmaUrl>;至少传一套稿子,或加 --dry-run 只扫基线') | ||
| } | ||
| // ─── 基线来源:--base > last-page.json > 无(standalone) ───── | ||
| let baseFileKey = null, baseNodeId = null, baseSource = null | ||
| if (args.base) { | ||
| const parsed = parseFigmaUrl(args.base) | ||
| if (!parsed || !parsed.nodeId) die('--base 必须是含 node-id 的完整 figma URL') | ||
| baseFileKey = parsed.fileKey | ||
| baseNodeId = parsed.nodeId | ||
| baseSource = '--base' | ||
| } else { | ||
| const lastPagePath = path.join(projectRoot, '.d2c-cache/last-page.json') | ||
| if (fs.existsSync(lastPagePath)) { | ||
| try { | ||
| const lp = JSON.parse(fs.readFileSync(lastPagePath, 'utf8')) | ||
| if (lp.fileKey && (lp.rootNodeId || lp.nodeId)) { | ||
| baseFileKey = lp.fileKey | ||
| baseNodeId = lp.rootNodeId || lp.nodeId | ||
| baseSource = 'last-page.json' | ||
| } | ||
| } catch {} | ||
| } | ||
| } | ||
| const hasBase = !!(baseFileKey && baseNodeId) | ||
| console.log(`[pp-d2c-reskin] projectRoot : ${projectRoot}`) | ||
| console.log(`[pp-d2c-reskin] assetsDir : ${assetsDir}`) | ||
| console.log(`[pp-d2c-reskin] base : ${hasBase ? `${baseFileKey} / ${baseNodeId} (${baseSource})` : '无(standalone 模式,每套稿子独立切)'}`) | ||
| console.log(`[pp-d2c-reskin] prefixes : ${args.prefixes.join(' ')}`) | ||
| console.log(`[pp-d2c-reskin] themes : ${args.themes.length} 套`) | ||
| console.log(`[pp-d2c-reskin] mode : ${args.dryRun ? 'dry-run' : 'export'}`) | ||
| console.log('') | ||
| // ─── 有基线:先拉基线子树 → 生成切图清单 ───────────────── | ||
| let uniqSlices = null | ||
| if (hasBase) { | ||
| console.log('[pp-d2c-reskin] 拉基线子树...') | ||
| const baseDoc = await fetchNodeTree(baseFileKey, baseNodeId, token) | ||
| const sliceList = collectSliceNodes(baseDoc, args.prefixes) | ||
| // 同父下同名去重(默认不做,--dedupe-siblings 开启)。跨父同名恒保留(不同 matchKey) | ||
| let dedupedList = sliceList | ||
| if (args.dedupeSiblings) { | ||
| const seenKeys = new Set() | ||
| dedupedList = [] | ||
| for (const s of sliceList) { | ||
| const k = matchKey(s.name, s.parentName) | ||
| if (seenKeys.has(k)) continue | ||
| seenKeys.add(k) | ||
| s._matchKey = k | ||
| dedupedList.push(s) | ||
| } | ||
| } else { | ||
| // 不 dedup 也要挂 _matchKey,给下游对齐用(此时同 matchKey 的多项会共存) | ||
| for (const s of sliceList) s._matchKey = matchKey(s.name, s.parentName) | ||
| } | ||
| // 冲突消解:同 basename 加父路径前缀,再次撞名兜底 nodeId | ||
| uniqSlices = resolveFilenameCollisions(dedupedList) | ||
| console.log(`[pp-d2c-reskin] 基线切图清单:${uniqSlices.length} 项(扫描到 ${sliceList.length}, dedupe-siblings=${args.dedupeSiblings ? 'on' : 'off'})`) | ||
| for (const s of uniqSlices) { | ||
| const rb = s.renderBounds | ||
| const rTag = rb ? `render=${Math.round(rb.width)}x${Math.round(rb.height)}` : 'render=?' | ||
| console.log(` · ${s.name} → ${s.filename}.png (nodeId=${s.id}, ${rTag})`) | ||
| } | ||
| console.log('') | ||
| } | ||
| if (args.dryRun) { | ||
| console.log(hasBase | ||
| ? '[pp-d2c-reskin] dry-run 完成,未拉换肤稿' | ||
| : '[pp-d2c-reskin] dry-run + 无基线:什么都没扫,请传 --theme 或 --base 或 --dry-run --base=<url>') | ||
| return | ||
| } | ||
| // ─── 逐套稿子:切图 + 归子目录 ───────────────────────── | ||
| const reports = [] | ||
| for (const theme of args.themes) { | ||
| console.log(`\n[pp-d2c-reskin] === theme: ${theme.name} (slug=${theme.slug}) ===`) | ||
| const parsed = parseFigmaUrl(theme.url) | ||
| if (!parsed || !parsed.nodeId) { | ||
| console.log(` × URL 解析失败,跳过:${theme.url}`) | ||
| reports.push({ theme: theme.name, error: 'invalid url', hit: 0, miss: hasBase ? uniqSlices.length : 0 }) | ||
| continue | ||
| } | ||
| const { fileKey: themeFileKey, nodeId: themeNodeId } = parsed | ||
| let themeDoc | ||
| try { | ||
| themeDoc = await fetchNodeTree(themeFileKey, themeNodeId, token) | ||
| } catch (e) { | ||
| console.log(` × fetchNodeTree 失败:${e.message}`) | ||
| reports.push({ theme: theme.name, error: e.message, hit: 0, miss: hasBase ? uniqSlices.length : 0 }) | ||
| continue | ||
| } | ||
| const outDirRel = path.join(assetsDir, `theme-${theme.slug}`) | ||
| const outDirAbs = path.join(projectRoot, outDirRel) | ||
| ensureDir(outDirAbs) | ||
| // 决定这套稿子要切哪些位 | ||
| let sliceItems, missNames = [] | ||
| if (hasBase) { | ||
| // 建换肤稿匹配表:同 matchKey 下的多个节点按遍历顺序收成数组 | ||
| // 基线里同结构 3 个 img-icon → 换肤稿也按顺序取对应 3 个,一对一 | ||
| const themeGroups = new Map() | ||
| ;(function walk(n, parentName) { | ||
| if (!n) return | ||
| const name = n.name || '' | ||
| if (name) { | ||
| const key = matchKey(name, parentName) | ||
| if (!themeGroups.has(key)) themeGroups.set(key, []) | ||
| themeGroups.get(key).push(n.id) | ||
| } | ||
| if (Array.isArray(n.children)) n.children.forEach(c => walk(c, name)) | ||
| })(themeDoc, null) | ||
| // 基线里同 matchKey 出现第几次,就取换肤稿数组里第几个 | ||
| const baseCursor = new Map() | ||
| sliceItems = [] | ||
| for (const slice of uniqSlices) { | ||
| const cursor = baseCursor.get(slice._matchKey) || 0 | ||
| baseCursor.set(slice._matchKey, cursor + 1) | ||
| const themeArr = themeGroups.get(slice._matchKey) || [] | ||
| const themeNodeIdInSkin = themeArr[cursor] | ||
| if (!themeNodeIdInSkin) { | ||
| const detail = themeArr.length === 0 | ||
| ? '换肤稿无对应节点' | ||
| : `换肤稿仅 ${themeArr.length} 个同结构节点,基线第 ${cursor + 1} 个无匹配` | ||
| console.log(` ? miss ${slice.name}${slice.parentName ? ` (under ${slice.parentName})` : ''} (${detail})`) | ||
| missNames.push(slice.name) | ||
| continue | ||
| } | ||
| sliceItems.push({ | ||
| name: slice.name, | ||
| parentName: slice.parentName, | ||
| filename: slice.filename, | ||
| nodeId: themeNodeIdInSkin, | ||
| }) | ||
| } | ||
| } else { | ||
| // standalone:直接扫当前稿子;同结构 dedup 受 --dedupe-siblings 控制 | ||
| const selfList = collectSliceNodes(themeDoc, args.prefixes) | ||
| let dedupedSelf = selfList | ||
| if (args.dedupeSiblings) { | ||
| const seenKeys = new Set() | ||
| dedupedSelf = [] | ||
| for (const s of selfList) { | ||
| const key = matchKey(s.name, s.parentName) | ||
| if (seenKeys.has(key)) continue | ||
| seenKeys.add(key) | ||
| dedupedSelf.push(s) | ||
| } | ||
| } | ||
| // 冲突消解后再落名(跨父同名 → 父路径前缀区分) | ||
| const resolvedSelf = resolveFilenameCollisions(dedupedSelf) | ||
| sliceItems = resolvedSelf.map(s => ({ | ||
| name: s.name, | ||
| parentName: s.parentName, | ||
| filename: s.filename, | ||
| nodeId: s.id, | ||
| renderBounds: s.renderBounds, | ||
| boundingBox: s.boundingBox, | ||
| })) | ||
| console.log(` 自扫切图清单:${sliceItems.length} 项(扫描到 ${selfList.length}, dedupe-siblings=${args.dedupeSiblings ? 'on' : 'off'})`) | ||
| } | ||
| // 逐位切图(串行,避免 Figma /v1/images 并发限流) | ||
| const hits = [] | ||
| const manifestEntries = [] | ||
| const sizeWarnings = [] | ||
| for (const item of sliceItems) { | ||
| const destAbs = path.join(outDirAbs, `${item.filename}.png`) | ||
| try { | ||
| await exportImageToPath(themeFileKey, item.nodeId, token, destAbs) | ||
| const destRel = path.relative(projectRoot, destAbs) | ||
| const rb = item.renderBounds | ||
| // aligned-to-base 场景下 item.renderBounds 未透传(用的是换肤稿节点),不打 render 尺寸 | ||
| const rTag = rb ? ` render=${Math.round(rb.width)}x${Math.round(rb.height)}` : '' | ||
| console.log(` · hit ${item.name} → ${destRel} (nodeId=${item.nodeId}${rTag})`) | ||
| hits.push({ name: item.name, path: destRel }) | ||
| // v1.1.0 bg 溢出检测: png 尺寸应 ≈ node bbox × scale | ||
| // 主要针对 bg-* 前缀: 如果 Figma 把兄弟节点溢出烤进 png, png 尺寸会明显大于 bbox | ||
| const pseudoNode = item.boundingBox ? { absoluteBoundingBox: item.boundingBox } : null | ||
| const warn = pseudoNode ? assertPngSize(destAbs, pseudoNode, 2) : null | ||
| if (warn) { | ||
| console.log(` ⚠️ 尺寸告警: ${warn}`) | ||
| sizeWarnings.push({ nodeId: item.nodeId, name: item.name, reason: warn }) | ||
| } | ||
| manifestEntries.push({ | ||
| nodeId: item.nodeId, | ||
| name: item.name, | ||
| parentName: item.parentName || null, | ||
| filename: `${item.filename}.png`, | ||
| filepath: destRel, | ||
| renderWidth: rb ? Math.round(rb.width) : null, | ||
| renderHeight: rb ? Math.round(rb.height) : null, | ||
| bboxWidth: item.boundingBox ? Math.round(item.boundingBox.width) : null, | ||
| bboxHeight: item.boundingBox ? Math.round(item.boundingBox.height) : null, | ||
| sizeWarning: warn || null, | ||
| }) | ||
| } catch (e) { | ||
| console.log(` × err ${item.name} (${e.message})`) | ||
| missNames.push(`${item.name} (${e.message})`) | ||
| } | ||
| } | ||
| reports.push({ | ||
| theme: theme.name, slug: theme.slug, outDir: outDirRel, | ||
| hit: hits.length, miss: missNames.length, missNames, | ||
| mode: hasBase ? 'aligned-to-base' : 'standalone', | ||
| manifestEntries, | ||
| sizeWarnings, | ||
| }) | ||
| console.log(` → ${theme.name}: hit=${hits.length}, ${hasBase ? 'miss' : 'err'}=${missNames.length}`) | ||
| } | ||
| // 汇总 | ||
| console.log('\n[pp-d2c-reskin] ── 汇总 ──') | ||
| // 打时间戳给下游 agent 一个"本次跑的"标识,避免拿旧 PNG 当当前行为的证据 | ||
| const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19) | ||
| console.log(` 产物写入时间: ${stamp}`) | ||
| for (const r of reports) { | ||
| const tag = r.error ? `× ${r.error}` : `✓ hit=${r.hit} ${r.mode === 'standalone' ? 'err' : 'miss'}=${r.miss} [${r.mode}]` | ||
| console.log(` ${r.theme.padEnd(20)} ${tag} ${r.outDir || ''}`) | ||
| if (r.missNames?.length) { | ||
| console.log(` ${r.mode === 'standalone' ? 'errors' : 'missed'}: ${r.missNames.join(', ')}`) | ||
| } | ||
| } | ||
| // v1.1.0: 写清单供 pp-d2c Step 1.5 消费 | ||
| if (args.outManifest) { | ||
| const manifest = { | ||
| generatedAt: stamp, | ||
| mode: hasBase ? 'aligned-to-base' : 'standalone', | ||
| themes: reports | ||
| .filter(r => !r.error) | ||
| .map(r => ({ | ||
| slug: r.slug, | ||
| outDir: r.outDir, | ||
| hit: r.hit, | ||
| miss: r.miss, | ||
| entries: r.manifestEntries || [], | ||
| })), | ||
| } | ||
| const manifestAbs = path.isAbsolute(args.outManifest) | ||
| ? args.outManifest | ||
| : path.resolve(projectRoot, args.outManifest) | ||
| ensureDir(path.dirname(manifestAbs)) | ||
| fs.writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2)) | ||
| console.log(` 切图清单: ${path.relative(projectRoot, manifestAbs)}`) | ||
| } | ||
| } | ||
| main().catch(e => die(e.stack || e.message)) |
| # pp-d2c-reskin Skill | ||
| > 用户给一批 figma 稿子,本 skill 按 `img` / `bg` 前缀规则扫图层树,把命中节点单独切图,归到 `<assetsDir>/theme-<slug>/` 子目录。**两种工作模式**: | ||
| > | ||
| > - **有基线**:先有一套跑过 pp-d2c 的页面(或用 `--base <url>` 显式指定基线稿),skill 按基线的切图清单去每套稿子上找**同名节点**切图,并报告 miss。用于"多套换肤稿对齐同一套代码"场景。 | ||
| > - **无基线(standalone)**:什么参考都没有,skill 对每套稿子**独立扫**自己的图层树,前缀命中就切。用于"美术直接扔几套稿子,你只管按前缀规则切图"场景。 | ||
| ## 触发条件 | ||
| - 用户说:「按图层前缀规则批量切图」「reskin 切图」「多套色版切图」「帮我把这几套稿子的 img/bg 切下来」 | ||
| - 直接 `$pp-d2c-reskin --theme red=<figmaUrl> --theme gold=<figmaUrl> ...` | ||
| **不适用**: | ||
| - 需要生成代码结构 → 用主 `pp-d2c` / `pp-d2c-rn` | ||
| - 单张零散图片压缩 → 用 `pp-image-compress` | ||
| - 页面某一小块重生成 → 用 `pp-fix-partial` | ||
| ## 前置条件 | ||
| 1. 项目根有 `pp-d2c.config.json`(跑过 `npx @double-coding/pixel-print init`,skill 从这里读 `images.assetsDir`) | ||
| 2. `.env` 里配好 `FIGMA_TOKEN`(与主 pp-d2c 复用) | ||
| 3. Node 18+(用内置 fetch) | ||
| 4. **无强制要求**跑过基线;有则用,无则走 standalone | ||
| ## 执行流程 | ||
| ### 步骤 0:识别当前模式 | ||
| skill 启动时按以下优先级确定基线来源: | ||
| 1. 用户传 `--base <figmaUrl>` → 用这个作为基线(**有基线**) | ||
| 2. `.d2c-cache/last-page.json` 存在且完整 → 用主 SKILL 最近实现的整页作为基线(**有基线**) | ||
| 3. 都没有 → **无基线**,走 standalone 模式 | ||
| 启动信息会明确打印当前模式(`base: <fileKey/nodeId (source)>` 或 `base: 无(standalone 模式,每套稿子独立切)`),避免用户以为在跟基线对齐但其实在 standalone。 | ||
| ### 步骤 1(可选):有基线时先跑 dry-run 看清单 | ||
| **有基线时推荐**:先跑 dry-run 让用户确认清单再执行: | ||
| ```bash | ||
| node .claude/skills/pp-d2c-reskin/reskin-slice.mjs --dry-run | ||
| # 或显式指定基线 | ||
| node .claude/skills/pp-d2c-reskin/reskin-slice.mjs --base <baseFigmaUrl> --dry-run | ||
| ``` | ||
| **无基线时可跳过**:standalone 模式没有跨稿清单可预览,直接跑就行。真想预演可以先只传一套稿子看输出。 | ||
| ### 步骤 2:切图 | ||
| 正式跑,加上一套或多套 `--theme`: | ||
| ```bash | ||
| # 有基线:每套稿子对齐基线切图清单 | ||
| node .claude/skills/pp-d2c-reskin/reskin-slice.mjs \ | ||
| --theme red=https://www.figma.com/design/DEF456/xxx?node-id=999-1 \ | ||
| --theme gold=https://www.figma.com/design/DEF456/xxx?node-id=999-2 | ||
| # 无基线:每套稿子独立扫自己的图层树 | ||
| node .claude/skills/pp-d2c-reskin/reskin-slice.mjs \ | ||
| --theme spring=https://www.figma.com/design/AAA/xxx?node-id=1-1 \ | ||
| --theme autumn=https://www.figma.com/design/BBB/xxx?node-id=2-1 | ||
| ``` | ||
| **有基线模式** 对每套稿子: | ||
| 1. 内嵌的 Figma REST 客户端拉换肤稿子树 | ||
| 2. 按 **name 严格匹配**基线切图清单里的每一项 | ||
| 3. 命中 → `GET /v1/images` 拿 CDN URL → 下载到 `<assetsDir>/theme-<slug>/<原文件名>.png` | ||
| 4. 未命中 → 记入 miss 报告(不阻断其它命中) | ||
| **无基线模式** 对每套稿子: | ||
| 1. 内嵌的 Figma REST 客户端拉稿子子树 | ||
| 2. 直接遍历,`img` / `bg` / `img-*` / `bg-*` 前缀命中的节点就是切图位(每套稿子自扫独立清单) | ||
| 3. 逐位导出 → `<assetsDir>/theme-<slug>/<name>.png` | ||
| 4. 切图报错 → 记入 err 列表(不阻断其它) | ||
| ### 步骤 3:产出汇总 | ||
| 脚本会输出每套稿子的模式标签(`[aligned-to-base]` / `[standalone]`)、hit/miss/err 数、miss 节点名列表、输出子目录路径。Agent 建议: | ||
| - **有基线** miss 多 → 让美术检查换肤稿图层命名是否与基线一致 | ||
| - **无基线** hit 数远低于预期 → 让美术检查是否把该切的图层加了 `img` / `bg` 前缀 | ||
| - 业务代码推荐写法:一个 `themeKey → assetsSubDir` 的映射就能切主题(有基线时文件名与基线对齐;无基线时不同稿子文件名可能不一致,以稿内实际图层名为准) | ||
| ## 参数 | ||
| | 参数 | 说明 | | ||
| |------|------| | ||
| | `--theme <name>=<figmaUrl>` | 一套稿子(可重复传多次);name 会 slug 化用作子目录名。至少传一套(除非 `--dry-run`) | | ||
| | `--dry-run` / `-n` | 只扫基线切图清单(有基线时),不拉稿子也不切图 | | ||
| | `--base <figmaUrl>` | 显式指定基线 URL(优先级高于 `last-page.json`) | | ||
| | `--prefix <list>` | 覆盖切图前缀(逗号分隔,不带 `-`;默认 `img,bg`) | | ||
| | `--dedupe-siblings` | 同父下同名节点只切第一个。默认关闭 —— 全都切,同名冲突用父路径前缀区分。**仅在 auto-layout 循环卡片刻意重复、切一次就够的场景才打开** | | ||
| ## 切图清单如何认定 | ||
| **基线切图位** = 基线子树中 name 匹配以下任一形式的所有节点(与 pp-d2c 主 SKILL §4 图层前缀体系对齐): | ||
| - `img` / `img-*` → 整层导出为 PNG(前景图片)。裸 `img` = 整块图片(整层就是图);`img-<name>` = 带语义的图片图层 | ||
| - `bg` / `bg-*` → 背景图片(写父元素 `background-image`)。裸 `bg` = 整块背景;`bg-<name>` = 带语义的背景图层 | ||
| **为什么支持裸标签**:美术在 figma 里给整块背景/整层图片命名时,可能直接叫 `bg` / `img`,不加子命名(尤其是内容语义已经很明显、不需要区分多张图的场景)。skill 兼容两种写法。 | ||
| **裸 `img` / `bg` 的产物文件名**:因为没有子名,skill 会用**父节点 name** 作为文件名基础(如父节点 `sub-hero-card` 下的裸 `bg` → `hero-card__bg.png`),避免多个裸 `bg` 撞名。 | ||
| **同 name 处理(默认全切,加 `--dedupe-siblings` 才去重)**: | ||
| - 跨父同名(3 个 `img-icon` 分处 Frame 722 / 726 / 730)→ **全部切出**,文件名自动加最近具体祖先前缀区分:`frame-722__icon.png` / `frame-726__icon.png` / `frame-730__icon.png` | ||
| - 同父同名(auto-layout 里循环卡片背景之类)→ 默认也全切,但如果确定只需一份,加 `--dedupe-siblings` 只切第一个 | ||
| - 极少数二次撞名(父路径 slug 又相同)→ 再拼 nodeId 兜底,不丢图 | ||
| **不再要求美术回改图层名**;skill 端自动消解冲突。 | ||
| ## 换肤节点匹配规则(仅有基线时执行) | ||
| **只有"有基线"模式才做跨稿匹配**。对每一个基线切图位,在换肤子树里找**同 key** 的对应节点: | ||
| - 匹配 key 恒为 **`<父节点 name>||<name>`**(裸标签、带子名统一走此规则) | ||
| - 同 matchKey 的多个节点(3 个 `img-icon` 各挂不同父)按遍历顺序**一一配对**:基线第 1 个对应换肤稿第 1 个,基线第 2 个对应换肤稿第 2 个 | ||
| - 命中 → 切图落到 `<assetsDir>/theme-<slug>/<原文件名>.png`(文件名由 `resolveFilenameCollisions` 决定,同 basename 会加父路径前缀) | ||
| - 未命中 → 记入 miss 报告并**继续处理下一项**,不中断整套;miss 原因会区分「换肤稿无对应节点」还是「换肤稿仅 N 个同结构节点,基线第 M 个无匹配」 | ||
| **为什么这样匹配**:换肤稿本质是"复制基线稿改颜色",图层树理应保持一致(含父节点命名与循环结构数量)。若换肤稿把**父 Frame 改名了**、或循环卡片数量与基线不一致,skill 会报 miss 让美术回改 —— 比容错匹配可能"切错节点"要好。 | ||
| **无基线模式(standalone)** 不做跨稿匹配 —— 每套稿子按自身图层树独立扫,前缀命中就切,不与其它稿子对齐。 | ||
| ## 输出目录 | ||
| ``` | ||
| <assetsDir>/ | ||
| ├── hero.png ← 基线(主 pp-d2c 产出) | ||
| ├── card-top.png | ||
| ├── cta-button.png | ||
| ├── theme-red/ ← 本 skill 产出 | ||
| │ ├── hero.png | ||
| │ ├── card-top.png | ||
| │ └── cta-button.png | ||
| └── theme-gold/ | ||
| ├── hero.png | ||
| ├── card-top.png | ||
| └── cta-button.png | ||
| ``` | ||
| **有基线模式下文件名与基线严格对齐**,方便业务代码写一个 `themeKey → assetsSubDir` 的映射就能切主题: | ||
| ```js | ||
| // 示例映射 | ||
| const THEME_DIR = { default: '', red: 'theme-red/', gold: 'theme-gold/' } | ||
| const heroSrc = `./assets/${THEME_DIR[themeKey]}hero.png` | ||
| ``` | ||
| **无基线模式下文件名以每套稿子内实际图层名为准**,不同稿子间不保证文件名一致(靠美术自己保持图层命名规范)。 | ||
| ## 排查切图不符预期(给下游 agent 的硬规) | ||
| 跑完 skill 后若发现某张 PNG "少东西 / 尺寸不对 / 内容不符预期",按以下顺序排查,**别自己临时写脚本发挥**: | ||
| 1. **断言 PNG 内容前必须先重跑本 skill** | ||
| 本地 `.png` 是**上一次跑的产物**,设计稿改动后不会自动同步。拿旧 PNG 当"当前行为"的证据是最常见的误诊来源。汇总行会打**产物写入时间**,与设计稿修改时间对比,晚于设计稿改动才是当前状态。 | ||
| 2. **看 `absoluteRenderBounds`,不是 `absoluteBoundingBox`** | ||
| Figma REST 返回**两个** bbox 字段: | ||
| - `absoluteBoundingBox` = 图层名义框(不含描边 / 投影 / 子元素溢出) | ||
| - `absoluteRenderBounds` = **实际渲染范围**(含以上所有),**Figma 出图按此裁剪** | ||
| 本 skill 主 log 每一行 `render=W×H` 就是 renderBounds,直接读它,别 curl API 挑错字段。 | ||
| 3. **同名图层现在会全部切出**(带父路径前缀区分),**不要**建议美术回改图层名 | ||
| 3 个 `img-icon` 分处不同父 Frame → 会看到 `frame-722__icon.png` / `frame-726__icon.png` / `frame-730__icon.png`。若只想切一次(循环卡片背景之类),用 `--dedupe-siblings`。 | ||
| 4. **mask / clip 会锁 renderBounds** | ||
| GROUP 内有 mask RECTANGLE 时,Figma 的 renderBounds 会被锁在 mask 之内,子节点跑出 mask 范围的部分不会出图。这是 Figma 的规则,skill 端**无法绕过**(`/v1/images` 不接自定义 bbox)。修复只能改设计稿: | ||
| - 把 mask 拉大到包住溢出内容,或 | ||
| - 把溢出的子节点(如浮动文字)移出 GROUP,由代码单独渲染 | ||
| 5. **想深入排查**:直接 `curl -H "X-Figma-Token: $FIGMA_TOKEN" "https://api.figma.com/v1/files/<key>/nodes?ids=<a>:<b>"` 拉节点树,先看 `absoluteRenderBounds`,再决定"是设计稿问题"还是"skill 问题"。 | ||
| ## 与其他 skill 的分工 | ||
| | 场景 | 用哪个 | | ||
| |---|---| | ||
| | 首次整页 D2C | `pp-d2c` / `pp-d2c-rn` | | ||
| | 页面某一块单独重跑 | `pp-fix-partial` | | ||
| | 换肤稿子批量切图 | **`pp-d2c-reskin`(本 skill)** | | ||
| | 上线前剥 `data-node-id` | `pp-strip-nodeid` | | ||
| | 零散图片无损压缩 | `pp-image-compress` | | ||
| ## 缓存与幂等 | ||
| - **无缓存**:本 skill 是纯 REST 直连 Figma,每次都拉最新数据。这是刻意选择 —— 换肤场景通常是"美术改稿后重跑",缓存反而让你切到旧图 | ||
| - 已存在的 `theme-<slug>/<name>.png` 会被**覆盖**(每次都从 figma 拉新的);想保护旧文件用 git 复核 | ||
| - 想改成有缓存,自己在 `exportImageToPath` 加一层文件存在检查即可 | ||
| ## 禁止 | ||
| - **有基线时**禁止跳过 dry-run 直接切图:一旦基线清单认错(比如 `last-page.json` 是老的 fileKey)会白切一堆无用图。**无基线时**没有跨稿清单可预览,直接跑即可 | ||
| - 禁止把 `--theme` 的 name 写成含 `/` 或空格的字符串:会被 slug 化成 `-`,可读性差;推荐纯英文小写短名(red / gold / cny-2026) | ||
| - 禁止指望本 skill 生成代码:它只切图,不产任何 `.tsx` / `.jsx`;要代码走主 pp-d2c / pp-d2c-rn | ||
| - 禁止让本 skill 依赖兄弟 skill 的脚本(`pp-d2c/bin/figma.mjs` 等):本 skill 是**独立** REST 客户端,只依赖 `pp-d2c.config.json` 读 `assetsDir` + `.env` 读 `FIGMA_TOKEN`;兄弟 skill 不装也能跑 |
| #!/usr/bin/env node | ||
| // check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.1) | ||
| // 覆盖 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21 | ||
| // v1.2.0 对账升级:loadCache 标注 _inBakedSubtree / _hidden / _templateDup; | ||
| // R02/R06 跳过 baked·隐藏·模板副本 + SCSS &__ 嵌套匹配(lib/cssMatch.mjs)消除假阳性; | ||
| // R17 禁 baked 子孙出 DOM(双重渲染);R18 flex-direction 忠实度;R19 padding 忠实度;R20 绝对定位坐标忠实度。 | ||
| // v1.2.1:_inBakedSubtree 移除 bgc-(bgc- 非 baked,子孙走正常规则暴露误放); | ||
| // 新增 R21 node-id-coverage(应渲染节点必挂 data-node-id,机械强制 §5.1.1 铁律,堵 R18/R19/R20 空 classMap 逃逸)。 | ||
| // | ||
| // 用法: | ||
| // node check-rules.mjs --block <blockDir> --cache-key <fileKey> | ||
| // node check-rules.mjs --merge <pageDir> --cache-key <fileKey> | ||
| // node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06 | ||
| // | ||
| // exit code: | ||
| // 0 — ok=true, 全通过 (可能有 warnings) | ||
| // 1 — ok=false, 有 violations | ||
| // 2 — 环境错误 (cache/产物/config 缺失) | ||
| import path from 'node:path'; | ||
| import { findProjectRoot, loadConfig, loadCache } from './lib/loadCache.mjs'; | ||
| import { loadProduct } from './lib/loadProduct.mjs'; | ||
| import { buildNodeIdToClassName } from './lib/nodeIdToClassName.mjs'; | ||
| import { makeReport, printReport } from './lib/report.mjs'; | ||
| import * as R01 from './rules/R01-fixed-position.mjs'; | ||
| import * as R02 from './rules/R02-fills-image.mjs'; | ||
| import * as R05 from './rules/R05-space-between.mjs'; | ||
| import * as R06 from './rules/R06-text-solid-last.mjs'; | ||
| import * as R08 from './rules/R08-bg-landing-form.mjs'; | ||
| import * as R16 from './rules/R16-no-flatten-text.mjs'; | ||
| import * as R17 from './rules/R17-no-baked-dom.mjs'; | ||
| import * as R18 from './rules/R18-flex-direction.mjs'; | ||
| import * as R19 from './rules/R19-padding.mjs'; | ||
| import * as R20 from './rules/R20-absolute-position.mjs'; | ||
| import * as R21 from './rules/R21-node-id-coverage.mjs'; | ||
| const ALL_RULES = [R01, R02, R05, R06, R08, R16, R17, R18, R19, R20, R21]; | ||
| function parseArgv(argv) { | ||
| const args = { mode: null, dir: null, cacheKey: null, forceSkip: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a === '--block') { args.mode = 'block'; args.dir = argv[++i]; } | ||
| else if (a === '--merge') { args.mode = 'merge'; args.dir = argv[++i]; } | ||
| else if (a === '--cache-key') { args.cacheKey = argv[++i]; } | ||
| else if (a === '--force-skip') { | ||
| args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); | ||
| } else if (a === '-h' || a === '--help') { | ||
| printHelp(); | ||
| process.exit(0); | ||
| } | ||
| } | ||
| return args; | ||
| } | ||
| function printHelp() { | ||
| process.stdout.write(`check-rules.mjs (pp-d2c v1.2.1) | ||
| Usage: | ||
| node check-rules.mjs --block <blockDir> --cache-key <fileKey> | ||
| node check-rules.mjs --merge <pageDir> --cache-key <fileKey> | ||
| node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06 | ||
| Rules covered: R01 R02 R05 R06 R08 R16 R17 R18 R19 R20 R21 | ||
| Exit: 0=ok, 1=violations, 2=env-error | ||
| `); | ||
| } | ||
| function fatal(msg) { | ||
| process.stderr.write(`[check-rules] ERROR: ${msg}\n`); | ||
| process.exit(2); | ||
| } | ||
| function main() { | ||
| const args = parseArgv(process.argv); | ||
| if (!args.mode || !args.dir) fatal('missing --block <dir> or --merge <dir>'); | ||
| if (!args.cacheKey) fatal('missing --cache-key <fileKey>'); | ||
| const productDir = path.resolve(args.dir); | ||
| const product = loadProduct(productDir); | ||
| if (product.error) fatal(product.error); | ||
| if (product.jsx.length === 0 && product.style.length === 0) { | ||
| fatal(`no jsx/style found under ${productDir}`); | ||
| } | ||
| const projectRoot = findProjectRoot(productDir); | ||
| if (!projectRoot) fatal('pp-d2c.config.json not found in ancestors of ' + productDir); | ||
| const config = loadConfig(projectRoot); | ||
| if (!config) fatal('failed to load pp-d2c.config.json at ' + projectRoot); | ||
| const cache = loadCache(projectRoot, args.cacheKey); | ||
| if (cache.error) fatal(cache.error); | ||
| const classMap = buildNodeIdToClassName(product.jsx); | ||
| const checked = []; | ||
| const skipped = []; | ||
| const violations = []; | ||
| const warnings = []; | ||
| for (const rule of ALL_RULES) { | ||
| checked.push(rule.id); | ||
| if (args.forceSkip.includes(rule.id)) { | ||
| skipped.push(rule.id); | ||
| warnings.push({ rule: rule.id, reason: 'skipped via --force-skip' }); | ||
| continue; | ||
| } | ||
| try { | ||
| const hits = rule.check({ cache, product, config, classMap }); | ||
| for (const h of hits) violations.push(h); | ||
| } catch (e) { | ||
| warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` }); | ||
| } | ||
| } | ||
| const report = makeReport({ checked, skipped, violations, warnings }); | ||
| printReport(report); | ||
| process.exit(report.ok ? 0 : 1); | ||
| } | ||
| main(); |
| // cssMatch — 共享 CSS/SCSS 选择器匹配(v1.2.0) | ||
| // | ||
| // 背景:D2C 产物按 config.styleFormat 可能是平铺 CSS(`.page__foo { }`) | ||
| // 或 SCSS 嵌套(`.page { &__foo { } }`)。若规则只用 `\.classname` 平铺正则匹配, | ||
| // 遇到 SCSS `&__foo` / `&-foo` 嵌套写法会整体匹配不到 → 大量假阳性 | ||
| // (test13 事故:R06 47 条假阳性全因产物是 `&__cd-num` 嵌套、正则找的是 `.page__cd-num`)。 | ||
| // | ||
| // 本 lib 提炼自 R01 v1.1.0 补丁,统一供 R01/R02/R06/R18/R19 复用,杜绝各规则各自实现导致的嵌套盲区。 | ||
| // | ||
| // 已知边界:正则按 `{...}` 最近闭合截取规则体,对"同块内既有声明又有嵌套子选择器"的场景 | ||
| // 只截到第一个 `}`。D2C 产物的叶子选择器(承载具体声明)通常无内层嵌套,此边界可接受; | ||
| // 需要更强解析时再升级为词法栈解析。 | ||
| const RE_ESC = /[.*+?^${}()|[\]\\]/g; | ||
| function esc(s) { | ||
| return s.replace(RE_ESC, '\\$&'); | ||
| } | ||
| // className "test13-page__cd-num" → ["__cd-num", "-num"] | ||
| // className "page-fixed-bar" → ["-bar"](无 __) | ||
| // className "foo" → [](无分隔符,无 scss 嵌套形态可推) | ||
| export function deriveScssSuffixes(className) { | ||
| const out = new Set(); | ||
| const idxDouble = className.lastIndexOf('__'); | ||
| if (idxDouble >= 0) out.add(className.slice(idxDouble)); // "__cd-num" | ||
| const idxDash = className.lastIndexOf('-'); | ||
| if (idxDash > 0) out.add('-' + className.slice(idxDash + 1)); // "-num" | ||
| return Array.from(out); | ||
| } | ||
| // 收集某 className 在一段 css 里的所有规则体(平铺 + SCSS &__ / &- 嵌套)。 | ||
| // 返回 [{ body, line }](body = `{ }` 内文本,line = 选择器所在行)。 | ||
| export function collectRuleBodies(css, className) { | ||
| const cn = (className || '').trim(); | ||
| if (!cn) return []; | ||
| const out = []; | ||
| // 1) 平铺完整选择器: .foo { ... } | .parent .foo { ... } | ||
| const directRe = new RegExp(`\\.${esc(cn)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| let m; | ||
| while ((m = directRe.exec(css)) !== null) { | ||
| out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length }); | ||
| } | ||
| // 2) SCSS 嵌套 &__xxx / &-xxx | ||
| for (const suffix of deriveScssSuffixes(cn)) { | ||
| const nestedRe = new RegExp(`&${esc(suffix)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| while ((m = nestedRe.exec(css)) !== null) { | ||
| out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| // 在多个 style 文件里,某 nodeId 的任一 className 是否有规则体命中 propRe。 | ||
| // classes = classMap[nodeId](可能多个);styleFiles = product.style([{ content, rel }])。 | ||
| // 命中返回 { hit:true, rel, line, body };否则 { hit:false, firstRel, firstLine, firstSnippet } | ||
| // (firstX 给出该 className 找到的第一个规则体,便于报错定位)。 | ||
| export function findProperty(styleFiles, classes, propRe) { | ||
| let firstRel = null, firstLine = 0, firstSnippet = ''; | ||
| for (const cls of classes || []) { | ||
| for (const s of styleFiles) { | ||
| const bodies = collectRuleBodies(s.content, cls); | ||
| for (const r of bodies) { | ||
| if (propRe.test(r.body)) { | ||
| return { hit: true, rel: s.rel, line: r.line, body: r.body }; | ||
| } | ||
| if (!firstRel) { | ||
| firstRel = s.rel; | ||
| firstLine = r.line; | ||
| firstSnippet = r.body.slice(0, 200); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { hit: false, firstRel, firstLine, firstSnippet }; | ||
| } | ||
| // 取某 nodeId 第一个 className 的规则体(用于需要读取声明值的规则,如 R19 padding)。 | ||
| // 返回 { body, rel, line } 或 null。 | ||
| export function firstRuleBody(styleFiles, classes) { | ||
| for (const cls of classes || []) { | ||
| for (const s of styleFiles) { | ||
| const bodies = collectRuleBodies(s.content, cls); | ||
| if (bodies.length) return { body: bodies[0].body, rel: s.rel, line: bodies[0].line }; | ||
| } | ||
| } | ||
| return null; | ||
| } |
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| export function findProjectRoot(startDir) { | ||
| let dir = path.resolve(startDir); | ||
| while (true) { | ||
| if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir; | ||
| const parent = path.dirname(dir); | ||
| if (parent === dir) return null; | ||
| dir = parent; | ||
| } | ||
| } | ||
| export function loadConfig(projectRoot) { | ||
| const p = path.join(projectRoot, 'pp-d2c.config.json'); | ||
| if (!fs.existsSync(p)) return null; | ||
| return JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| } | ||
| // "子孙不生成独立 DOM" 的前缀:命中即整体导出 / 忽略,不再向内递归,其子孙不应作为 | ||
| // 独立 DOM 出现,也不应被 R02/R06 逐个溯源。**仅两类**: | ||
| // - bg- / img-(含裸词 bg/img):整体切图,子孙像素**已烤进父层 PNG**(baked,可见) | ||
| // - x-:整体忽略,子孙**被丢弃**(ignored,不可见) | ||
| // **不含 bgc-**:bgc- 是"把盒级 CSS(fills/描边/圆角/阴影) 写到父元素",子孙**没有烤进任何位图**。 | ||
| // 若把 bgc- 也算作 baked,会让误放在 bgc- 下的 TEXT 被静默跳过 R06 + R17 禁 DOM → 静默丢内容。 | ||
| // 故 bgc- 子孙走正常规则:R06 会报"无 className"、R21 会报"不可追溯",把结构错误暴露出来而非吞掉。 | ||
| const NO_RENDER_PREFIXES = ['bg-', 'img-', 'x-']; | ||
| const NO_RENDER_BARE = ['bg', 'img', 'x']; | ||
| export function isNonRecursivePrefix(name) { | ||
| if (!name || typeof name !== 'string') return false; | ||
| const n = name.trim(); | ||
| if (NO_RENDER_PREFIXES.some((p) => n.startsWith(p))) return true; | ||
| if (NO_RENDER_BARE.includes(n)) return true; // 裸词 bg / img / x(与 R16 白名单口径一致) | ||
| return false; | ||
| } | ||
| // 结构签名:捕捉"同构"——同 type + 同层级子结构(深度 3,不含具体文案)。 | ||
| // 用于识别 `.map()` 列表项:≥2 个同签名的容器兄弟 = 列表,非首项是数据副本。 | ||
| function structureSig(node, depth) { | ||
| if (depth <= 0 || !Array.isArray(node.children) || node.children.length === 0) { | ||
| return node.type || '?'; | ||
| } | ||
| return (node.type || '?') + '(' + node.children.map((c) => structureSig(c, depth - 1)).join(',') + ')'; | ||
| } | ||
| // 标记某节点 children 中的"模板重复项":同构容器兄弟里的非首个 → __isDup=true。 | ||
| // 仅对"有自身子结构的容器"生效(叶子如并列 TEXT "20"/"元" 不算列表项,不误标)。 | ||
| function markTemplateDups(node) { | ||
| if (!Array.isArray(node.children) || node.children.length < 2) return; | ||
| const seen = new Map(); // sig -> 已出现 | ||
| for (const c of node.children) { | ||
| if (!c || typeof c !== 'object' || !c.id) continue; | ||
| if (!Array.isArray(c.children) || c.children.length === 0) continue; // 叶子不参与列表判定 | ||
| const sig = structureSig(c, 3); | ||
| if (seen.has(sig)) c.__isDup = true; // 非首个同构兄弟 = 数据副本 | ||
| else seen.set(sig, true); | ||
| } | ||
| } | ||
| export function loadCache(projectRoot, cacheKey) { | ||
| const nodesDir = path.join(projectRoot, '.d2c-cache', cacheKey, 'nodes'); | ||
| if (!fs.existsSync(nodesDir)) { | ||
| return { error: `cache dir not found: ${nodesDir}`, nodes: {} }; | ||
| } | ||
| const nodes = {}; | ||
| const files = fs.readdirSync(nodesDir).filter((f) => f.endsWith('.json')); | ||
| for (const f of files) { | ||
| const raw = fs.readFileSync(path.join(nodesDir, f), 'utf8'); | ||
| let json; | ||
| try { | ||
| json = JSON.parse(raw); | ||
| } catch (e) { | ||
| continue; | ||
| } | ||
| // 自上而下遍历:跟踪 parent / "是否处于整体切图子树" / "是否隐藏" 状态,直接标注节点对象 | ||
| // (nodes[id] 存的是同一对象引用,标注即对全局生效)。 | ||
| walk(json, nodes, null, false, null, false, false); | ||
| } | ||
| return { nodes }; | ||
| } | ||
| function walk(node, acc, parentRealId, inBaked, bakedBy, hidden, templateDup) { | ||
| if (!node || typeof node !== 'object') return; | ||
| let childParentId = parentRealId; | ||
| let childInBaked = inBaked; | ||
| let childBakedBy = bakedBy; | ||
| let childHidden = hidden; | ||
| let childTemplateDup = templateDup; | ||
| if (node.id && node.type) { | ||
| // 节点自身:继承祖先传下来的 baked 状态(前缀节点自身不算 baked,它是切图/忽略目标) | ||
| node._parentId = parentRealId; | ||
| node._inBakedSubtree = inBaked; | ||
| node._bakedBy = inBaked ? bakedBy : null; | ||
| // 隐藏传播:自身 visible===false 或任一祖先隐藏 → 该节点不渲染,对账应整体跳过 | ||
| node._hidden = hidden || node.visible === false; | ||
| // 模板重复项:自身被父标为 __isDup(非首个同构兄弟),或祖先已是副本 → 整棵子树是数据副本 | ||
| node._templateDup = templateDup || node.__isDup === true; | ||
| acc[node.id] = node; | ||
| childParentId = node.id; | ||
| childHidden = node._hidden; | ||
| childTemplateDup = node._templateDup; | ||
| // 自身若是非递归前缀,则其"子孙"进入 baked 子树(自身不进) | ||
| if (!inBaked && isNonRecursivePrefix(node.name)) { | ||
| childInBaked = true; | ||
| childBakedBy = node.id; | ||
| } | ||
| } | ||
| // 进入 children 前,标记本层的模板重复项(同构容器兄弟的非首个) | ||
| markTemplateDups(node); | ||
| if (Array.isArray(node.children)) { | ||
| for (const c of node.children) walk(c, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| } | ||
| if (node.document) walk(node.document, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| if (node.node) walk(node.node, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| if (node.nodes && typeof node.nodes === 'object' && !Array.isArray(node.nodes)) { | ||
| for (const k of Object.keys(node.nodes)) walk(node.nodes[k], acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| } | ||
| } |
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| const STYLE_EXTS = ['.scss', '.less', '.css', '.module.scss', '.module.less', '.module.css']; | ||
| const JSX_EXTS = ['.jsx', '.tsx']; | ||
| export function loadProduct(dir) { | ||
| const absDir = path.resolve(dir); | ||
| if (!fs.existsSync(absDir)) { | ||
| return { error: `product dir not found: ${absDir}`, jsx: [], style: [] }; | ||
| } | ||
| const jsx = []; | ||
| const style = []; | ||
| walk(absDir, absDir, jsx, style); | ||
| return { root: absDir, jsx, style }; | ||
| } | ||
| function walk(root, dir, jsx, style) { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| for (const e of entries) { | ||
| const full = path.join(dir, e.name); | ||
| if (e.isDirectory()) { | ||
| if (e.name === 'node_modules' || e.name.startsWith('.')) continue; | ||
| walk(root, full, jsx, style); | ||
| continue; | ||
| } | ||
| const ext = getExt(e.name); | ||
| const rel = path.relative(root, full); | ||
| if (JSX_EXTS.includes(ext)) { | ||
| jsx.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') }); | ||
| } else if (STYLE_EXTS.includes(ext)) { | ||
| style.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') }); | ||
| } | ||
| } | ||
| } | ||
| function getExt(name) { | ||
| if (name.endsWith('.module.scss')) return '.module.scss'; | ||
| if (name.endsWith('.module.less')) return '.module.less'; | ||
| if (name.endsWith('.module.css')) return '.module.css'; | ||
| const i = name.lastIndexOf('.'); | ||
| return i < 0 ? '' : name.slice(i); | ||
| } |
| // 从 jsx 里 grep 出 data-node-id={"X:Y"} 或 data-node-id="X:Y" 对应的 className={styles.foo} / className="foo" | ||
| // 建立 nodeId -> [className, ...] 的 map | ||
| export function buildNodeIdToClassName(jsxFiles) { | ||
| const map = new Map(); // nodeId -> Set<className> | ||
| for (const { content } of jsxFiles) { | ||
| scanFile(content, map); | ||
| } | ||
| const out = {}; | ||
| for (const [k, v] of map) out[k] = Array.from(v); | ||
| return out; | ||
| } | ||
| function scanFile(src, map) { | ||
| // 简易 JSX 标签匹配 (足够 v1.0.0): 逐行 or 逐标签扫 | ||
| // 用 <XXX ... data-node-id="..." ... /> 或 <XXX ... />...</XXX> | ||
| // 允许 attrs 之间任意换行 (className 和 data-node-id 顺序不定) | ||
| const tagRe = /<[A-Za-z][A-Za-z0-9-]*\b([^<>]*?)\/?>/gs; | ||
| let m; | ||
| while ((m = tagRe.exec(src)) !== null) { | ||
| const attrs = m[1]; | ||
| if (!attrs) continue; | ||
| const nodeId = pickAttr(attrs, 'data-node-id'); | ||
| if (!nodeId) continue; | ||
| const cls = pickClassName(attrs); | ||
| if (!cls || cls.length === 0) continue; | ||
| if (!map.has(nodeId)) map.set(nodeId, new Set()); | ||
| for (const c of cls) map.get(nodeId).add(c); | ||
| } | ||
| } | ||
| function pickAttr(attrs, name) { | ||
| // name="value" 或 name={"value"} 或 name={'value'} | ||
| const re1 = new RegExp(`\\b${name}="([^"]+)"`); | ||
| const m1 = attrs.match(re1); | ||
| if (m1) return m1[1]; | ||
| const re2 = new RegExp(`\\b${name}=\\{['"]([^'"]+)['"]\\}`); | ||
| const m2 = attrs.match(re2); | ||
| if (m2) return m2[1]; | ||
| return null; | ||
| } | ||
| function pickClassName(attrs) { | ||
| // className="a b c" | ||
| const m1 = attrs.match(/\bclassName="([^"]+)"/); | ||
| if (m1) return m1[1].split(/\s+/).filter(Boolean); | ||
| // className={styles.foo} | ||
| const m2 = attrs.match(/\bclassName=\{styles\.([A-Za-z_][A-Za-z0-9_]*)\}/); | ||
| if (m2) return [m2[1]]; | ||
| // className={`${styles.foo} ${styles.bar}`} — 简易匹配 | ||
| const m3 = attrs.match(/\bclassName=\{`([^`]+)`\}/); | ||
| if (m3) { | ||
| const names = []; | ||
| const inner = m3[1]; | ||
| const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g; | ||
| let mm; | ||
| while ((mm = partRe.exec(inner)) !== null) names.push(mm[1]); | ||
| return names; | ||
| } | ||
| // className={clsx(styles.foo, styles.bar)} / className={classNames(...)} | ||
| const m4 = attrs.match(/\bclassName=\{(?:clsx|classNames|cx)\(([^)]+)\)\}/); | ||
| if (m4) { | ||
| const names = []; | ||
| const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g; | ||
| let mm; | ||
| while ((mm = partRe.exec(m4[1])) !== null) names.push(mm[1]); | ||
| return names; | ||
| } | ||
| return null; | ||
| } |
| export function makeReport({ checked, skipped, violations, warnings }) { | ||
| const failed = Array.from(new Set(violations.map((v) => v.rule))); | ||
| const passed = checked.filter((r) => !failed.includes(r) && !skipped.includes(r)); | ||
| return { | ||
| ok: violations.length === 0, | ||
| checked, | ||
| skipped, | ||
| passed, | ||
| failed, | ||
| violations, | ||
| warnings, | ||
| }; | ||
| } | ||
| export function printReport(report) { | ||
| process.stdout.write(JSON.stringify(report, null, 2) + '\n'); | ||
| } |
| // R01 fixed-position | ||
| // 触发: node.name.startsWith('fixed-') | ||
| // 期望: 产物 CSS 中该 node 对应类名规则内含 position: fixed | ||
| // | ||
| // v1.1.0 修复 (test12 事故): | ||
| // - 只走 classMap[nodeId] 反查, 禁止再走 name 派生兜底 | ||
| // - SCSS 支持 &__foo / &-foo 嵌套语法, 通过 selector 后缀匹配 | ||
| // - classMap 空 → 真报 R01 (jsx 缺 data-node-id 或未绑 className) | ||
| // v1.2.0: 嵌套匹配逻辑提炼到 lib/cssMatch.mjs 共享;跳过隐藏节点。 | ||
| import { findProperty } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R01'; | ||
| export const name = 'fixed-position'; | ||
| export function check({ cache, product, classMap }) { | ||
| const violations = []; | ||
| const prefix = 'fixed-'; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.name || !node.name.startsWith(prefix)) continue; | ||
| if (node._hidden) continue; // 隐藏节点不渲染,不校验 | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: `jsx 里 data-node-id="${nodeId}" 元素绑定 className, 才能在 CSS 中反查 position: fixed`, | ||
| actual: '产物 jsx 未找到 data-node-id + className 映射; 若产物无该 nodeId 视为漏画', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| const found = findProperty(product.style, classes, /position\s*:\s*fixed/i); | ||
| if (!found.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: 'css 含 position: fixed', | ||
| actual: 'css 未含 position: fixed (可能只有 relative / static / 无规则)', | ||
| file: found.firstRel || '(missing in style)', | ||
| line: found.firstLine || 0, | ||
| snippet: found.firstSnippet || '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } |
| // R02 fills-image | ||
| // 触发: node.fills[].some(f => f.type === 'IMAGE' && f.visible !== false) | ||
| // 期望: | ||
| // - assets.txt 中有该 nodeId 的切图记录 (fileName) | ||
| // - 产物 CSS (或 jsx <img>) 引用该切图 | ||
| // 排斥: 节点前缀是 x- → 忽略 | ||
| // v1.2.0: 跳过 baked 子树与隐藏节点;CSS url 匹配走 lib/cssMatch.mjs(修 &__ 嵌套盲区)。 | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R02'; | ||
| export const name = 'fills-image'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const ignorePrefix = 'x-'; | ||
| // 读 assets.txt (在 product root) | ||
| const assetsPath = path.join(product.root, 'assets.txt'); | ||
| const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : ''; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!Array.isArray(node.fills) || node.fills.length === 0) continue; | ||
| const hasImage = node.fills.some((f) => f && f.type === 'IMAGE' && f.visible !== false); | ||
| if (!hasImage) continue; | ||
| if (node.name && node.name.startsWith(ignorePrefix)) continue; | ||
| // 处于 bg-/img- 整体切图子树(像素已烤进父层 PNG)或 x- 忽略子树内 → 不应逐个溯源。 | ||
| // 跳过,消除对账假阳性(v1.2.0;v1.2.1 起不含 bgc-)。这类子孙的"禁 DOM"约束交由 R17。 | ||
| if (node._inBakedSubtree) continue; | ||
| if (node._hidden) continue; // 隐藏节点不渲染,不校验 | ||
| if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项 | ||
| // 检查 assets.txt 里是否提到此 nodeId | ||
| const inAssets = assetsText.includes(nodeId); | ||
| // 检查产物是否 (a) jsx 有 <img src=... nodeId 相关> 或 (b) style 有 url(...对应文件) | ||
| const productMention = mentionsNodeIdAsset(product, nodeId, classMap); | ||
| if (!inAssets && !productMention.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: 'assets.txt 有此 nodeId 切图记录 且 产物引用该切图', | ||
| actual: 'assets.txt 未记录 且 产物中未找到该 nodeId 相关 <img> / background url', | ||
| file: '(missing)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| if (!productMention.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: '产物 jsx 或 CSS 引用该 nodeId 切图', | ||
| actual: 'assets.txt 已记录但产物未引用', | ||
| file: '(missing in product)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function mentionsNodeIdAsset(product, nodeId, classMap) { | ||
| // 简易: nodeId 直接串在产物 (jsx / style) 里就算 hit; | ||
| // 或该 nodeId 对应 className 的 css 规则里含 url( | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| for (const j of product.jsx) { | ||
| if (j.content.includes(nodeId) || j.content.includes(idNorm)) return { hit: true }; | ||
| } | ||
| const classes = classMap[nodeId] || []; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| for (const r of collectRuleBodies(s.content, cls)) { | ||
| if (/url\(/i.test(r.body) || /background-image\s*:/i.test(r.body)) return { hit: true }; | ||
| } | ||
| } | ||
| } | ||
| for (const s of product.style) { | ||
| if (s.content.includes(nodeId) || s.content.includes(idNorm)) return { hit: true }; | ||
| } | ||
| return { hit: false }; | ||
| } |
| // R05 space-between | ||
| // 触发: primaryAxisAlignItems === 'SPACE_BETWEEN' (Figma AutoLayout) | ||
| // 期望: 对应 CSS 类含 justify-content: space-between | ||
| // 反向 warning: 类内含 margin-*:auto / justify-content:flex-* / gap:auto 之类模拟法 | ||
| export const id = 'R05'; | ||
| export const name = 'space-between'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.primaryAxisAlignItems !== 'SPACE_BETWEEN') continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: '产物 jsx 里 data-node-id 缺失或无 className', | ||
| actual: '产物中无对应 className', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| let ok = false; | ||
| let hitFile = null; | ||
| let hitLine = 0; | ||
| let hitSnippet = ''; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| const rules = collectRules(s.content, cls); | ||
| for (const r of rules) { | ||
| if (/justify-content\s*:\s*space-between/i.test(r.body)) { | ||
| ok = true; | ||
| break; | ||
| } | ||
| if (!hitFile) { | ||
| hitFile = s.rel; | ||
| hitLine = r.line; | ||
| hitSnippet = r.snippet; | ||
| } | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (!ok) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: 'css 含 justify-content: space-between', | ||
| actual: 'css 未含 justify-content: space-between (可能用 margin/flex-end 模拟)', | ||
| file: hitFile || '(missing in style)', | ||
| line: hitLine, | ||
| snippet: hitSnippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function collectRules(css, className) { | ||
| const re = new RegExp(`\\.${escapeRegex(className)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| const out = []; | ||
| let m; | ||
| while ((m = re.exec(css)) !== null) { | ||
| const line = css.slice(0, m.index).split('\n').length; | ||
| out.push({ line, body: m[1], snippet: m[0].slice(0, 200) }); | ||
| } | ||
| return out; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| // R06 text-solid-last | ||
| // 触发: TEXT 节点,fills 数组非空,末位可见 fill 是 SOLID | ||
| // 期望: 对应 CSS 类含 color: #HEX (与 SOLID.color 匹配) | ||
| // 排斥: 末位可见 fill 是 GRADIENT/IMAGE → 归 R04 判定,不在此处 | ||
| // v1.2.0: 跳过 baked 子树 TEXT 与隐藏节点;SCSS 嵌套匹配走 lib/cssMatch.mjs(修 &__ 盲区假阳性)。 | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R06'; | ||
| export const name = 'text-solid-last'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.type !== 'TEXT') continue; | ||
| if (!Array.isArray(node.fills) || node.fills.length === 0) continue; | ||
| // 处于 bg-/img- 整体切图子树(文字像素已烤进父层 PNG)或 x- 忽略子树内的 TEXT → | ||
| // 不作为独立 DOM 渲染,无需校验字色。跳过(v1.2.0;v1.2.1 起不含 bgc-)。禁 DOM 交由 R17。 | ||
| if (node._inBakedSubtree) continue; | ||
| if (node._hidden) continue; // 隐藏 TEXT 不渲染,不校验 | ||
| if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项 | ||
| const lastVisible = pickLastVisibleFill(node.fills); | ||
| if (!lastVisible) continue; // 全 invisible → 走默认 (略过) | ||
| if (lastVisible.type !== 'SOLID') continue; // 走 R04 | ||
| const expectedHex = rgbaToHex(lastVisible.color); | ||
| if (!expectedHex) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| // 不可追溯(无 className/data-node-id)→ 交由 R21 node-id-coverage 统一报"应挂 id", | ||
| // R06 只负责"可追溯 TEXT 的字色是否取对",避免与 R21 双报同一节点(v1.2.1)。 | ||
| if (classes.length === 0) continue; | ||
| let ok = false; | ||
| let hitFile = null; | ||
| let hitLine = 0; | ||
| let hitSnippet = ''; | ||
| let actualColor = null; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| const rules = collectRuleBodies(s.content, cls); | ||
| for (const r of rules) { | ||
| const colorMatch = r.body.match(/(?:^|[^-\w])color\s*:\s*(#[0-9a-fA-F]{3,8})/); | ||
| if (colorMatch) { | ||
| const found = normalizeHex(colorMatch[1]); | ||
| if (found === expectedHex) { ok = true; break; } | ||
| if (!actualColor) actualColor = found; | ||
| } | ||
| if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); } | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (!ok) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `css 含 color: ${expectedHex} (源自 fills 末位可见 SOLID)`, | ||
| actual: actualColor ? `css color: ${actualColor} (与 SOLID 不符)` : 'css 未含 color', | ||
| file: hitFile || '(missing in style)', | ||
| line: hitLine, | ||
| snippet: hitSnippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function pickLastVisibleFill(fills) { | ||
| for (let i = fills.length - 1; i >= 0; i--) { | ||
| const f = fills[i]; | ||
| if (f && f.visible !== false) return f; | ||
| } | ||
| return null; | ||
| } | ||
| function rgbaToHex(color) { | ||
| if (!color) return null; | ||
| const r = Math.round((color.r || 0) * 255); | ||
| const g = Math.round((color.g || 0) * 255); | ||
| const b = Math.round((color.b || 0) * 255); | ||
| return ('#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')).toLowerCase(); | ||
| } | ||
| function normalizeHex(hex) { | ||
| let h = hex.toLowerCase(); | ||
| if (h.length === 4) { | ||
| h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; | ||
| } else if (h.length === 9) { | ||
| h = h.slice(0, 7); // 忽略 alpha | ||
| } | ||
| return h; | ||
| } | ||
| // R08 bg-landing-form | ||
| // 触发: node.name.startsWith('bg-') 或 name === 'bg' | ||
| // 反向扫产物,禁止: | ||
| // - jsx: <img ... src=".../bg-..." ... /> 或 <img ... src=".../bg.<ext>" ... /> | ||
| // - jsx: style={{ ... background... }} | ||
| // - jsx: className="...-bg..." 里空 div (无 children) 且挂 bg | ||
| // - scss: ::before / ::after { ... background-image ... } | ||
| export const id = 'R08'; | ||
| export const name = 'bg-landing-form'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const bgPrefix = 'bg-'; | ||
| // 先看 cache 有没有 bg- 节点 — 用于生成 nodeId 提示 | ||
| const bgNodes = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.name) continue; | ||
| if (node.name.startsWith(bgPrefix) || node.name === 'bg') { | ||
| bgNodes.push({ nodeId, name: node.name }); | ||
| } | ||
| } | ||
| if (bgNodes.length === 0) return violations; | ||
| // jsx 反向扫 | ||
| for (const j of product.jsx) { | ||
| const lines = j.content.split('\n'); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const line = lines[i]; | ||
| // <img src=".../bg-XXX" or .../bg.<ext> | ||
| if (/<img\b[^>]*src=[^>]*bg-/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg-..."> 应用父容器 background-image 代替', bgNodes[0])); | ||
| } else if (/<img\b[^>]*src=[^>]*\/bg\.[a-z]{2,5}["'`)]/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg.<ext>"> 应用父容器 background-image 代替', bgNodes[0])); | ||
| } | ||
| // inline style background | ||
| if (/style=\{\{[^}]*background/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), 'inline style 挂 background 应用 className + scss background', bgNodes[0])); | ||
| } | ||
| } | ||
| // 空 div 挂 bg-* (整个 <div /> 或空标签,自闭合 or 只有空白 children) | ||
| // 简易匹配: <div className={styles.bg...} data-node-id=... /> | ||
| const emptyBgDivRe = /<div\b[^>]*className=\{styles\.(bg[A-Za-z0-9_]*)\}[^>]*\/>/g; | ||
| let m; | ||
| while ((m = emptyBgDivRe.exec(j.content)) !== null) { | ||
| const idx = j.content.slice(0, m.index).split('\n').length; | ||
| violations.push(mkV(id, j.rel, idx, m[0], '空 <div className={styles.bg...} /> 应作为父容器 background 或用父节点直接挂', bgNodes[0])); | ||
| } | ||
| } | ||
| // scss 反向扫 ::before / ::after 挂 background-image | ||
| for (const s of product.style) { | ||
| // 简易: 检索 ::before { ... background-image ... } 块 | ||
| const pseudoRe = /::(before|after)\s*\{([\s\S]*?)\}/g; | ||
| let m; | ||
| while ((m = pseudoRe.exec(s.content)) !== null) { | ||
| if (/background-image\s*:/i.test(m[2]) || /background\s*:[^;]*url\(/i.test(m[2])) { | ||
| const idx = s.content.slice(0, m.index).split('\n').length; | ||
| violations.push(mkV(id, s.rel, idx, m[0].slice(0, 200), `::${m[1]} 挂 background-image 应改为父节点直接 background`, bgNodes[0])); | ||
| } | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function mkV(rule, file, line, snippet, expected, bgNode) { | ||
| return { | ||
| rule, | ||
| nodeId: bgNode ? bgNode.nodeId : '(bg node in cache)', | ||
| name: bgNode ? bgNode.name : '(bg node)', | ||
| type: 'JSX/SCSS', | ||
| expected, | ||
| actual: snippet, | ||
| file, | ||
| line, | ||
| snippet, | ||
| }; | ||
| } |
| // R16 no-flatten-text | ||
| // 触发: GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT,且节点 name 前缀不在白名单 | ||
| // 白名单: img- / bg- (含裸词 img / bg,全等或以 xxx- 开头) | ||
| // 反查: 产物 jsx 中出现 `<img ... data-node-id="<该节点>" ... />` → 违规 | ||
| // | ||
| // 语义: 禁止用整体切图替代含 TEXT 的容器;否则 TEXT 无障碍缺失、无法本地化、按钮不可点击 | ||
| // 排斥: img-/bg- 节点自身就是拿来切图/挂 background 的,天然免疫 | ||
| export const id = 'R16'; | ||
| export const name = 'no-flatten-text'; | ||
| const WHITELIST_PREFIXES = ['img-', 'bg-']; | ||
| const WHITELIST_BARE = ['img', 'bg']; | ||
| const CONTAINER_TYPES = new Set(['GROUP', 'FRAME', 'COMPONENT', 'INSTANCE']); | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.type || !CONTAINER_TYPES.has(node.type)) continue; | ||
| if (!node.name) continue; | ||
| if (isWhitelisted(node.name)) continue; | ||
| if (!subtreeHasText(node, cache.nodes)) continue; | ||
| const hits = findImgReferencingNode(product.jsx, nodeId); | ||
| for (const hit of hits) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: `不得对含 TEXT 的 ${node.type}(前缀非 img-/bg-)整体切图;应按 §4.3 前缀规则拆解 TEXT / btn / img / bg 子节点`, | ||
| actual: `产物 jsx 出现 <img data-node-id="${nodeId}">,意味着该容器被整体烤成位图`, | ||
| file: hit.file, | ||
| line: hit.line, | ||
| snippet: hit.snippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function isWhitelisted(nodeName) { | ||
| const name = nodeName.trim(); | ||
| if (WHITELIST_BARE.includes(name)) return true; | ||
| for (const p of WHITELIST_PREFIXES) { | ||
| if (name.startsWith(p) && name.length > p.length) return true; | ||
| } | ||
| return false; | ||
| } | ||
| // 递归判定:子树内是否存在 TEXT 节点 | ||
| // 通过 cache.nodes 反查 children(避免 node 对象子引用不全) | ||
| function subtreeHasText(root, allNodes) { | ||
| const stack = [root]; | ||
| const visited = new Set(); | ||
| while (stack.length) { | ||
| const cur = stack.pop(); | ||
| if (!cur || !cur.id || visited.has(cur.id)) continue; | ||
| visited.add(cur.id); | ||
| if (cur.type === 'TEXT') return true; | ||
| if (Array.isArray(cur.children)) { | ||
| for (const child of cur.children) { | ||
| if (child && child.id && allNodes[child.id]) { | ||
| stack.push(allNodes[child.id]); | ||
| } else { | ||
| stack.push(child); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| // 在 jsx 里搜 <img ... data-node-id="<nodeId>" ... /> | ||
| // 允许属性顺序任意;跨行也扫(松散匹配到 </img> 或 />) | ||
| function findImgReferencingNode(jsxFiles, nodeId) { | ||
| const hits = []; | ||
| const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| // 匹配 <img ...(可跨行)... data-node-id="<nodeId>" ...(可跨行)... /> | ||
| // 或反向:data-node-id 在前、闭合在后 | ||
| const re = new RegExp(`<img\\b[^>]*?data-node-id=["']${escaped}["'][^>]*?/?>`, 'gs'); | ||
| for (const j of jsxFiles) { | ||
| let m; | ||
| while ((m = re.exec(j.content)) !== null) { | ||
| const before = j.content.slice(0, m.index); | ||
| const line = before.split('\n').length; | ||
| hits.push({ | ||
| file: j.rel, | ||
| line, | ||
| snippet: m[0].length > 200 ? m[0].slice(0, 200) + '...' : m[0], | ||
| }); | ||
| } | ||
| } | ||
| return hits; | ||
| } |
| // R17 no-baked-dom(v1.2.0 对账新增) | ||
| // 触发: 节点处于 bg-/bgc-/img-/x- 整体切图子树内(_inBakedSubtree=true) | ||
| // 期望: 该节点的像素已烤进父层切图(或被 x- 整体忽略),产物中【不得】再有其 data-node-id 元素 | ||
| // 违反: 产物 JSX 出现 data-node-id="<nodeId>" → 双重渲染(文字/图叠一遍,典型 test13 title-text/subtitle 既进 main.png 又出 DOM) | ||
| // | ||
| // 与 R02/R06 的分工: R02/R06 跳过 baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底。 | ||
| export const id = 'R17'; | ||
| export const name = 'no-baked-dom'; | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node._inBakedSubtree) continue; | ||
| if (node._templateDup) continue; // 数据副本,代表项报过即可,避免重复 | ||
| // x- 忽略子树里本就不该出现,bg-/bgc-/img- 烤进切图更不该;隐藏与否都不应出 DOM | ||
| const hit = findDomNode(product, nodeId); | ||
| if (hit) { | ||
| const bakedByNode = node._bakedBy ? cache.nodes[node._bakedBy] : null; | ||
| const bakedByName = (bakedByNode && bakedByNode.name) || node._bakedBy || '?'; | ||
| const isIgnored = /^x[-]?/.test(String(bakedByName).trim()) || String(bakedByName).trim() === 'x'; | ||
| const kind = isIgnored | ||
| ? `处于 x- 忽略子树内(bakedBy=${bakedByName}),该内容被整体忽略,不该渲染` | ||
| : `处于整体切图子树内(bakedBy=${bakedByName}),像素已烤进父层 PNG`; | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `节点${kind},产物不应有其 data-node-id 元素`, | ||
| actual: `产物 ${hit.rel} 出现 data-node-id="${nodeId}"(${isIgnored ? '被忽略内容却出 DOM' : '双重渲染:切图一份 + DOM 一份'})`, | ||
| file: hit.rel, | ||
| line: hit.line, | ||
| snippet: hit.snippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function findDomNode(product, nodeId) { | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`); | ||
| for (const j of product.jsx) { | ||
| const m = j.content.match(re); | ||
| if (m) { | ||
| const line = j.content.slice(0, m.index).split('\n').length; | ||
| const lineText = j.content.split('\n')[line - 1] || ''; | ||
| return { rel: j.rel, line, snippet: lineText.trim().slice(0, 200) }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| // R18 flex-direction(v1.2.0 对账新增) | ||
| // 触发: autolayout 容器(layoutMode === 'HORIZONTAL' | 'VERTICAL') | ||
| // 期望: VERTICAL → CSS 含 flex-direction: column;HORIZONTAL → 不得写 flex-direction: column(row 为 flex 默认,可省) | ||
| // 违反: 方向写反(典型 test13 small-card-top:Figma VERTICAL 却写 flex-direction: row) | ||
| // 跳过: baked / hidden / templateData 副本 / 无 className(不可追溯,交由 §5.1 data-node-id 铁律在生成侧兜底) | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R18'; | ||
| export const name = 'flex-direction'; | ||
| export function check({ cache, product, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| const lm = node.layoutMode; | ||
| if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; // 不可追溯:本条不报,由 §5.1 生成铁律保证挂 id | ||
| const body = firstBodyWithFlex(product.style, classes); | ||
| if (!body) continue; // 无 display:flex 规则体(可能非 flex 实现),不在本条判定 | ||
| const dir = extractFlexDirection(body); | ||
| if (lm === 'VERTICAL') { | ||
| if (dir !== 'column') { | ||
| violations.push(mk(nodeId, node, 'flex-direction: column(Figma layoutMode=VERTICAL)', dir ? `flex-direction: ${dir}` : '未写 flex-direction(默认 row,纵向布局会横排)')); | ||
| } | ||
| } else { | ||
| // HORIZONTAL | ||
| if (dir === 'column') { | ||
| violations.push(mk(nodeId, node, 'flex-direction: row 或省略(Figma layoutMode=HORIZONTAL)', 'flex-direction: column(方向写反)')); | ||
| } | ||
| } | ||
| } | ||
| return violations; | ||
| function mk(nodeId, node, expected, actual) { | ||
| return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' }; | ||
| } | ||
| } | ||
| // 找该 nodeId 第一个含 display:flex 的规则体 | ||
| function firstBodyWithFlex(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| for (const r of collectRuleBodies(s.content, cls)) { | ||
| if (/display\s*:\s*flex/i.test(r.body)) return r.body; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function extractFlexDirection(body) { | ||
| const m = body.match(/flex-direction\s*:\s*([a-z-]+)/i); | ||
| return m ? m[1].toLowerCase() : null; | ||
| } |
| // R19 padding(v1.2.0 对账新增) | ||
| // 触发: autolayout 容器且 Figma 声明了 padding(paddingTop/Right/Bottom/Left 任一非 0),或产物写了 padding | ||
| // 期望: CSS padding 四值 ≈ Figma paddingT/R/B/L × scale(容差 2px) | ||
| // 违反: | ||
| // - Figma pad0 但产物写了非 0 padding(凭空捏造,典型 test13 small-card-top: Figma 四边 0 却写 padding:0 12px) | ||
| // - Figma 有 padding 但产物缺失或数值对不上 | ||
| // 跳过: baked / hidden / templateDup 副本 / 无 className | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R19'; | ||
| export const name = 'padding'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const scale = (config && config.unit && config.unit.scale) || 2; | ||
| const TOL = 2; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| const lm = node.layoutMode; | ||
| if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; // padding 仅在 autolayout 容器有意义 | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; | ||
| const body = firstBody(product.style, classes); | ||
| if (!body) continue; | ||
| // Figma 期望值(未声明视为 0) | ||
| const fig = [ | ||
| Math.round((node.paddingTop || 0) * scale), | ||
| Math.round((node.paddingRight || 0) * scale), | ||
| Math.round((node.paddingBottom || 0) * scale), | ||
| Math.round((node.paddingLeft || 0) * scale), | ||
| ]; | ||
| const figAllZero = fig.every((v) => v === 0); | ||
| const css = extractPadding(body); // null | [t,r,b,l] | ||
| if (!css) { | ||
| // 产物未写 padding:Figma 也 0 → OK;Figma 有 padding → 缺失违规 | ||
| if (!figAllZero) { | ||
| violations.push(mk(nodeId, node, `padding: ${fig.join('px ')}px(Figma ×${scale})`, '产物未写 padding')); | ||
| } | ||
| continue; | ||
| } | ||
| // 逐边比对 | ||
| const bad = css.some((v, i) => Math.abs(v - fig[i]) > TOL); | ||
| if (bad) { | ||
| const reason = figAllZero ? '(Figma 四边 padding 均为 0,产物凭空加了 padding)' : ''; | ||
| violations.push(mk(nodeId, node, `padding ≈ [${fig.join(', ')}]px(Figma ×${scale})`, `产物 padding = [${css.join(', ')}]px${reason}`)); | ||
| } | ||
| } | ||
| return violations; | ||
| function mk(nodeId, node, expected, actual) { | ||
| return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' }; | ||
| } | ||
| } | ||
| function firstBody(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| const b = collectRuleBodies(s.content, cls); | ||
| if (b.length) return b[0].body; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // 解析 CSS padding 简写 / 拆分为 [top,right,bottom,left](px)。取规则体里"最后一次" padding 声明。 | ||
| // 只认 px 值;含非 px(%/auto/var)→ 放弃比对返回 null(不误报)。 | ||
| function extractPadding(body) { | ||
| // longhand 优先覆盖 shorthand:先取 shorthand,再用 longhand 覆盖对应边 | ||
| let vals = null; | ||
| const sh = lastMatch(body, /(?:^|[^-\w])padding\s*:\s*([^;}]+)/gi); | ||
| if (sh) { | ||
| const parts = sh.trim().split(/\s+/); | ||
| const nums = []; | ||
| for (const p of parts) { | ||
| const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/); | ||
| if (!mm) return null; // 含 %/auto/var/calc 等非 px → 放弃比对,不误报 | ||
| if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0(如 rem 缺写)→ 放弃 | ||
| nums.push(parseFloat(mm[1])); // 无单位 0 或带 px → 取数值 | ||
| } | ||
| if (nums.length === 1) vals = [nums[0], nums[0], nums[0], nums[0]]; | ||
| else if (nums.length === 2) vals = [nums[0], nums[1], nums[0], nums[1]]; | ||
| else if (nums.length === 3) vals = [nums[0], nums[1], nums[2], nums[1]]; | ||
| else if (nums.length >= 4) vals = [nums[0], nums[1], nums[2], nums[3]]; | ||
| } | ||
| const lh = { | ||
| 0: lastMatch(body, /padding-top\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 1: lastMatch(body, /padding-right\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 2: lastMatch(body, /padding-bottom\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 3: lastMatch(body, /padding-left\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| }; | ||
| const hasLh = Object.values(lh).some((x) => x != null); | ||
| if (!vals && !hasLh) return null; | ||
| if (!vals) vals = [0, 0, 0, 0]; | ||
| for (const i of [0, 1, 2, 3]) if (lh[i] != null) vals[i] = parseFloat(lh[i]); | ||
| return vals.map((v) => Math.round(v)); | ||
| } | ||
| function lastMatch(body, re) { | ||
| let m, last = null; | ||
| while ((m = re.exec(body)) !== null) last = m[1]; | ||
| return last; | ||
| } |
| // R20 absolute-position(v1.2.0 对账新增) | ||
| // 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位) | ||
| // 期望: CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px) | ||
| // 违反: 坐标靠猜(典型 test13 img-huochepiao:真值 left≈-10/top≈-13 溢出到背景上,产物却写 top:40/left:40) | ||
| // 跳过: baked / hidden / templateDup / 无 className / 父无 bbox | ||
| // | ||
| // 核心哲学: 能从 bbox 精确算出的坐标,禁止靠猜 + "需人工核对" 兜底(§6.0.2 已封该逃逸口)。 | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R20'; | ||
| export const name = 'absolute-position'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const scale = (config && config.unit && config.unit.scale) || 2; | ||
| const TOL = 4; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.layoutPositioning !== 'ABSOLUTE') continue; | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| // fixed- 前缀走 constraints 视口定位(R01 域),不是 (子bbox−父bbox) 相对定位,跳过 | ||
| if (node.name && node.name.startsWith('fixed-')) continue; | ||
| const parent = node._parentId ? cache.nodes[node._parentId] : null; | ||
| const nb = node.absoluteBoundingBox; | ||
| const pb = parent && parent.absoluteBoundingBox; | ||
| if (!nb || !pb) continue; // 缺 bbox 无法精确计算,不误报 | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; // 不可追溯,交由 §5.1 生成铁律 | ||
| const body = firstBody(product.style, classes); | ||
| if (!body) continue; | ||
| const expLeft = Math.round((nb.x - pb.x) * scale); | ||
| const expTop = Math.round((nb.y - pb.y) * scale); | ||
| // inset 简写兜底:inset: <top> <right> <bottom> <left> | inset: 0(四边) | ||
| const inset = extractInset(body); | ||
| const cssTop = extractPos(body, 'top', inset ? inset[0] : null); | ||
| const cssLeft = extractPos(body, 'left', inset ? inset[3] : null); | ||
| // 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。 | ||
| // 期望非 0 却缺失(丢了真实偏移)、或写了值但对不上(如 huochepiao 40 vs -13)→ 报。 | ||
| const problems = []; | ||
| if (cssTop == null) { | ||
| if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop}px,丢了真实偏移)`); | ||
| } else if (Math.abs(cssTop - expTop) > TOL) problems.push(`top=${cssTop}px 应 ${expTop}px`); | ||
| if (cssLeft == null) { | ||
| if (Math.abs(expLeft) > TOL) problems.push(`缺 left(应 ${expLeft}px,丢了真实偏移)`); | ||
| } else if (Math.abs(cssLeft - expLeft) > TOL) problems.push(`left=${cssLeft}px 应 ${expLeft}px`); | ||
| if (problems.length) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `top≈${expTop}px left≈${expLeft}px((子bbox−父bbox)×${scale},父=${node._parentId})`, | ||
| actual: problems.join(';'), | ||
| file: '(style)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function firstBody(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| const b = collectRuleBodies(s.content, cls); | ||
| if (b.length) return b[0].body; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // 取 top/left 值:兼容带 px 与无单位 0(`top: 0`);无显式声明时回落 inset 值。 | ||
| function extractPos(body, prop, insetVal) { | ||
| const re = new RegExp(`(?:^|[^-\\w])${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)(px)?\\b`, 'i'); | ||
| const m = body.match(re); | ||
| if (m) { | ||
| const num = parseFloat(m[1]); | ||
| if (m[2] || num === 0) return num; // 有 px,或无单位的 0 | ||
| return null; // 无单位的非 0(%/未知)→ 不比对 | ||
| } | ||
| return insetVal; // 回落 inset | ||
| } | ||
| // 解析 inset 简写为 [top,right,bottom,left](px 或无单位 0);含非纯数值 → null | ||
| function extractInset(body) { | ||
| const m = body.match(/(?:^|[^-\w])inset\s*:\s*([^;}]+)/i); | ||
| if (!m) return null; | ||
| const parts = m[1].trim().split(/\s+/); | ||
| const nums = []; | ||
| for (const p of parts) { | ||
| const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/); | ||
| if (!mm) return null; | ||
| if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0 放弃 | ||
| nums.push(parseFloat(mm[1])); | ||
| } | ||
| if (nums.length === 1) return [nums[0], nums[0], nums[0], nums[0]]; | ||
| if (nums.length === 2) return [nums[0], nums[1], nums[0], nums[1]]; | ||
| if (nums.length === 3) return [nums[0], nums[1], nums[2], nums[1]]; | ||
| if (nums.length >= 4) return [nums[0], nums[1], nums[2], nums[3]]; | ||
| return null; | ||
| } |
| // R21 node-id-coverage(v1.2.1 对账新增) | ||
| // 触发: "应生成独立 DOM"的节点,产物 JSX 里找不到其 data-node-id | ||
| // 目的: 让 §5.1.1「data-node-id 全覆盖铁律」机械强制。没有 node-id,R06/R18/R19/R20 全都 | ||
| // 绑定不到产物 → 遇空 classMap 只能 continue,bug 静默逃逸(典型 test13 small-card-top)。 | ||
| // R21 正是"不可追溯"本身的硬拦截:应渲染却无 node-id = 违规。 | ||
| // | ||
| // "应生成独立 DOM"的节点(满足任一): | ||
| // - TEXT 节点 | ||
| // - autolayout 容器(layoutMode ∈ {HORIZONTAL, VERTICAL}) | ||
| // - layoutPositioning === 'ABSOLUTE'(需 R20 校验坐标) | ||
| // - name 前缀 img- / btn- / input-(生成 <img>/<button>/<input>) | ||
| // 排斥: | ||
| // - _inBakedSubtree(bg-/img- 整体切图 或 x- 忽略子树,本就不出 DOM) | ||
| // - _hidden(不渲染) | ||
| // - _templateDup(.map() 数据副本,只需代表项挂 id) | ||
| // - name 前缀 bg- / bgc- / x-(自身不生成独立 DOM:bg/bgc 挂父,x 忽略) | ||
| // | ||
| // .map() 模板项:产物用代表项(variant a)nodeId 挂 data-node-id;R21 对代表项校验, | ||
| // 副本已被 _templateDup 跳过。 | ||
| export const id = 'R21'; | ||
| export const name = 'node-id-coverage'; | ||
| const NO_OWN_DOM_PREFIXES = ['bg-', 'bgc-', 'x-']; | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const nm = (node.name || '').trim(); | ||
| if (NO_OWN_DOM_PREFIXES.some((p) => nm.startsWith(p)) || nm === 'bg' || nm === 'bgc' || nm === 'x') continue; | ||
| if (!shouldRender(node, nm)) continue; | ||
| if (!hasDomNode(product, nodeId)) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `应生成 DOM 的节点必须挂 data-node-id="${nodeId}"(§5.1.1 铁律;.map() 模板挂代表项 id),否则 R06/R18/R19/R20 无法绑定校验`, | ||
| actual: '产物 JSX 中找不到该 nodeId 的 data-node-id(不可追溯,可能漏画或漏挂 id)', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function shouldRender(node, nm) { | ||
| if (node.type === 'TEXT') return true; | ||
| if (node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL') return true; | ||
| if (node.layoutPositioning === 'ABSOLUTE') return true; | ||
| if (nm.startsWith('img-') || nm.startsWith('btn-') || nm.startsWith('input-')) return true; | ||
| return false; | ||
| } | ||
| function hasDomNode(product, nodeId) { | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`); | ||
| for (const j of product.jsx) { | ||
| if (re.test(j.content)) return true; | ||
| } | ||
| return false; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| #!/usr/bin/env node | ||
| // slugify.mjs — pp-d2c v1.1.0 内置 slug 工具 | ||
| // | ||
| // 用法: | ||
| // node slugify.mjs "<name>" → 输出 slug | ||
| // node slugify.mjs "<name>" --fallback "<nodeId>" → 失败时用 page-<nodeId-safe> 兜底 | ||
| // | ||
| // slug 规则: | ||
| // 1. ASCII: 保留 [a-z0-9], 其余 → '-', trim, lowercase, 连续 '-' 压成一个 | ||
| // 2. 中文 → pinyin 简易转换 (内置常用字表, 缺则跳过) | ||
| // 3. 结果为空 or 仅含 '-' → 返回 fallback 或空串 | ||
| // | ||
| // 中文覆盖: 前 500 常用字 + Figma 常见图层词(活动、页面、按钮、导航等) | ||
| // 不追求 100% 覆盖,只作 v1.1.0 默认 slug 兜底;用户可显式指定 slug 覆盖脚本结果 | ||
| const CJK_TO_PINYIN = { | ||
| // 数字 / 单位 | ||
| '零': 'ling', '一': 'yi', '二': 'er', '三': 'san', '四': 'si', '五': 'wu', | ||
| '六': 'liu', '七': 'qi', '八': 'ba', '九': 'jiu', '十': 'shi', | ||
| '百': 'bai', '千': 'qian', '万': 'wan', '亿': 'yi', | ||
| // 常用字 (Figma 图层高频词) | ||
| '完': 'wan', '整': 'zheng', '版': 'ban', '页': 'ye', '面': 'mian', | ||
| '中': 'zhong', '国': 'guo', '秋': 'qiu', '春': 'chun', '夏': 'xia', '冬': 'dong', | ||
| '节': 'jie', '日': 'ri', '月': 'yue', '年': 'nian', '季': 'ji', | ||
| '亚': 'ya', '洲': 'zhou', '欧': 'ou', '美': 'mei', '非': 'fei', '大': 'da', | ||
| '洋': 'yang', '海': 'hai', '陆': 'lu', '岛': 'dao', | ||
| '首': 'shou', '尾': 'wei', '前': 'qian', '后': 'hou', '左': 'zuo', '右': 'you', | ||
| '上': 'shang', '下': 'xia', '内': 'nei', '外': 'wai', | ||
| '状': 'zhuang', '态': 'tai', '栏': 'lan', '条': 'tiao', '框': 'kuang', | ||
| '按': 'an', '钮': 'niu', '键': 'jian', '标': 'biao', '题': 'ti', '副': 'fu', | ||
| '图': 'tu', '片': 'pian', '像': 'xiang', '标': 'biao', '识': 'shi', | ||
| '文': 'wen', '字': 'zi', '本': 'ben', '段': 'duan', | ||
| '导': 'dao', '航': 'hang', '菜': 'cai', '单': 'dan', '侧': 'ce', '边': 'bian', | ||
| '底': 'di', '部': 'bu', '顶': 'ding', | ||
| '活': 'huo', '动': 'dong', '优': 'you', '惠': 'hui', '券': 'quan', '卡': 'ka', | ||
| '福': 'fu', '利': 'li', '专': 'zhuan', '享': 'xiang', | ||
| '领': 'ling', '取': 'qu', '立': 'li', '即': 'ji', '抢': 'qiang', | ||
| '预': 'yu', '约': 'yue', '订': 'ding', '购': 'gou', '买': 'mai', | ||
| '火': 'huo', '车': 'che', '票': 'piao', '飞': 'fei', '机': 'ji', '船': 'chuan', | ||
| '酒': 'jiu', '店': 'dian', '住': 'zhu', '宿': 'su', | ||
| '开': 'kai', '售': 'shou', '关': 'guan', '闭': 'bi', '结': 'jie', '束': 'shu', | ||
| '时': 'shi', '间': 'jian', '倒': 'dao', '计': 'ji', | ||
| '新': 'xin', '旧': 'jiu', '老': 'lao', '客': 'ke', '户': 'hu', | ||
| '会': 'hui', '员': 'yuan', | ||
| '首': 'shou', '页': 'ye', '主': 'zhu', '要': 'yao', | ||
| '服': 'fu', '务': 'wu', '中': 'zhong', '心': 'xin', | ||
| '登': 'deng', '录': 'lu', '注': 'zhu', '册': 'ce', '账': 'zhang', '号': 'hao', | ||
| '密': 'mi', '码': 'ma', '手': 'shou', '机': 'ji', | ||
| '搜': 'sou', '索': 'suo', '查': 'cha', '询': 'xun', | ||
| '折': 'zhe', '扣': 'kou', '价': 'jia', '元': 'yuan', '角': 'jiao', '分': 'fen', | ||
| '免': 'mian', '费': 'fei', | ||
| '收': 'shou', '藏': 'cang', '分': 'fen', '享': 'xiang', | ||
| '设': 'she', '置': 'zhi', '编': 'bian', '辑': 'ji', '删': 'shan', '除': 'chu', | ||
| '保': 'bao', '存': 'cun', '确': 'que', '认': 'ren', | ||
| '成': 'cheng', '功': 'gong', '失': 'shi', '败': 'bai', '错': 'cuo', '误': 'wu', | ||
| '返': 'fan', '回': 'hui', '进': 'jin', '入': 'ru', '出': 'chu', | ||
| '快': 'kuai', '慢': 'man', '高': 'gao', '低': 'di', | ||
| '弹': 'tan', '窗': 'chuang', '浮': 'fu', '动': 'dong', | ||
| '空': 'kong', '白': 'bai', '黑': 'hei', '红': 'hong', '橙': 'cheng', '黄': 'huang', | ||
| '绿': 'lv', '蓝': 'lan', '紫': 'zi', '灰': 'hui', | ||
| '预': 'yu', '警': 'jing', '提': 'ti', '示': 'shi', | ||
| }; | ||
| export function slugify(input, fallbackNodeId = null) { | ||
| if (!input || typeof input !== 'string') { | ||
| return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : ''; | ||
| } | ||
| // 1. 中文转 pinyin (逐字, 缺字直接丢弃) | ||
| let s = ''; | ||
| for (const ch of input) { | ||
| if (CJK_TO_PINYIN[ch]) { | ||
| s += CJK_TO_PINYIN[ch]; | ||
| } else { | ||
| s += ch; | ||
| } | ||
| } | ||
| // 2. ASCII 化: 保留 [A-Za-z0-9], 其余变 '-' | ||
| s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); | ||
| // 3. 结果空 or 只有 '-' → fallback | ||
| if (!s || /^-+$/.test(s)) { | ||
| return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : ''; | ||
| } | ||
| return s; | ||
| } | ||
| function nodeIdFallback(nodeId) { | ||
| const safe = String(nodeId).replace(/[^A-Za-z0-9]+/g, '_'); | ||
| return `page-${safe}`; | ||
| } | ||
| // CLI | ||
| import { fileURLToPath } from 'node:url'; | ||
| import path from 'node:path'; | ||
| const __filename = fileURLToPath(import.meta.url); | ||
| if (path.resolve(process.argv[1] || '') === __filename) { | ||
| const argv = process.argv.slice(2); | ||
| let name = null; | ||
| let fallback = null; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| if (argv[i] === '--fallback') fallback = argv[++i]; | ||
| else if (!name) name = argv[i]; | ||
| } | ||
| if (!name) { | ||
| process.stderr.write('Usage: slugify.mjs "<name>" [--fallback "<nodeId>"]\n'); | ||
| process.exit(2); | ||
| } | ||
| const out = slugify(name, fallback); | ||
| process.stdout.write(out + '\n'); | ||
| } |
| # R01 - fixed-position | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无(与 R14 fixed-z-index 是"父规则-补充规则"关系,不排斥) | ||
| ## 触发条件 | ||
| - **cache**: `node.name.startsWith('fixed-')` | ||
| - **命中信号**: 图层名以 `fixed-` 开头(如 `fixed-状态栏`、`fixed-topbar`、`fixed-底部bar`) | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 该节点对应的 `<div>` / `<section>` 必须有 `className` 且带 `data-node-id` | ||
| **SCSS 端**: | ||
| - 对应 className 规则内 **必须含** `position: fixed;` | ||
| - 同时按 `constraints` 推 `top` / `left` / `right` / `bottom`: | ||
| - `constraints.vertical === 'TOP'` → `top: 0;` | ||
| - `constraints.vertical === 'BOTTOM'` → `bottom: 0;` | ||
| - `constraints.horizontal === 'LEFT'` → `left: 0;` | ||
| - `constraints.horizontal === 'RIGHT'` → `right: 0;` | ||
| **反例扫描**: | ||
| - `.<className> { position: relative/static/absolute; ... }` 而不是 `fixed` | ||
| - 缺 `position` 属性 | ||
| - 只在父容器上写 `position: relative`,自身没写 `fixed` | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 */ | ||
| .topbar { | ||
| position: relative; | ||
| width: 750px; | ||
| height: 236px; | ||
| } | ||
| /* ❌ 错法 (缺 position) */ | ||
| .topbar { | ||
| width: 750px; | ||
| height: 236px; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <div className={styles.topbar} data-node-id="211:32"> | ||
| {/* ... */} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .topbar { | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| width: 750px; | ||
| height: 236px; | ||
| z-index: 100; // 见 R14 | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 页面滚动时该组件跟随滚动,不再"钉"在视口顶/底/左/右 | ||
| - **典型事故**: v0.3.20 test1 事故 — `fixed-状态栏` 只写了 `position: relative`,滚动后状态栏消失 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 | ||
| - rules/R14-fixed-z-index.md |
| # R02 - fills-image | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: | ||
| - 节点前缀是 `x-` → 忽略 | ||
| - 节点是 `btn-` 子层的 `bgc-` → 归 R09 | ||
| - 节点已被 R11 判 mask-vector-css-able → R02 优先切图 | ||
| ## 触发条件 | ||
| - **cache**: `Array.isArray(node.fills) && node.fills.some(f => f.type === 'IMAGE' && f.visible !== false)` | ||
| - **命中信号**: 该节点有可见的 IMAGE 填充 | ||
| ## 期望产物 | ||
| **assets.txt 端**: | ||
| - 必须记录该 `nodeId` 的切图文件名(如 `bg-body.png`) | ||
| **JSX 端** (二选一): | ||
| - (a) `<img src="${ASSET_PREFIX}xxx.png" data-node-id="{id}" />` (前景图) | ||
| - (b) 父容器 `<div className={styles.foo} data-node-id="{id}"></div>`(后台图,配合 SCSS background) | ||
| **SCSS 端** (方式 b 时): | ||
| - `.foo { background-image: url("...xxx.png"); background-size: cover|100% 100%; }` | ||
| **反例扫描**: | ||
| - assets.txt 缺记录 → violation | ||
| - assets.txt 有记录但产物没引用 → violation | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法 1: 该切图不切,凭空 gradient */} | ||
| <div style={{ background: "linear-gradient(180deg, #fee 0%, #fdb 100%)" }} /> | ||
| {/* ❌ 错法 2: 该切图不切,写 solid color */} | ||
| <div className={styles.bgBody} /> | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法 2 对应 */ | ||
| .bgBody { | ||
| background-color: #f0e0d0; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| **前景图**: | ||
| ```jsx | ||
| <img className={styles.heroImg} | ||
| src={`${ASSET_PREFIX}hero.png`} | ||
| data-node-id="211:126" | ||
| alt="" /> | ||
| ``` | ||
| **背景图(推荐用父容器)**: | ||
| ```jsx | ||
| <div className={styles.bgBody} data-node-id="211:37"> | ||
| {/* children */} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .bgBody { | ||
| width: 750px; | ||
| height: 1200px; | ||
| background-image: url("../../static/test1/bg-body.png"); | ||
| background-size: 100% 100%; | ||
| background-repeat: no-repeat; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: agent 凭空搓 gradient/solid color 代替真图,视觉严重跑偏 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — `bg-body` (211:37) 没切图,agent 编造 `background-color` | ||
| - v0.3.21 test8 事故 — 多张 `image XX` 未切,agent 用文字堆叠模拟 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 | ||
| - rules/R08-bg-landing-form.md (bg- 前缀的落地形态) | ||
| - rules/R09-btn-bgc-取值.md (btn 内 bgc 的取值) |
| # R03 - implicit-image | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (语义判断,脚本难) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 节点有 `img-` / `bg-` / `bgc-` / `x-` / `input-` / `sub-` / `block-` / `btn-` / `fixed-` / `end-` / `scrollx-` / `scrolly-` 前缀 → 不适用 | ||
| - 子树中有 TEXT / INSTANCE / COMPONENT 节点 → 不适用 (需交互或文字,不该切) | ||
| - 子树中有以 btn- / input- / sub- / block- 命名的子节点 → 不适用 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.name` **无**上述任何前缀 | ||
| 2. 整棵子树的节点 `type` 全在 `['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE', 'STAR', 'REGULAR_POLYGON', 'LINE']` | ||
| 3. 子树中**无** `TEXT` / `INSTANCE` / `COMPONENT` 类型 | ||
| 4. 子树中**无** `btn-` / `input-` / `sub-` / `block-` 前缀命名的子节点 | ||
| ## 期望产物 | ||
| **assets.txt 端**: | ||
| - 必须为该节点整体切一张图,记录 `nodeId → filename` | ||
| **JSX 端**: | ||
| - `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />` | ||
| **SCSS 端**: | ||
| - 只写尺寸和定位,**不要**展开成一堆 vector 元素 | ||
| **反例扫描**: | ||
| - agent 不切图,展开成 20+ 个 `<div>` + `<svg>` + CSS 渐变叠加 | ||
| - SCSS 里出现大量 `.icon-part-1 { ... } .icon-part-2 { ... }` 类的重复子元素规则 | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法: 该整体切图但没识别,展开成 vector CSS 堆 */} | ||
| <div className={styles.iconWrap} data-node-id="123:45"> | ||
| <div className={styles.iconLayer1} /> | ||
| <div className={styles.iconLayer2} /> | ||
| <svg><path d="M..." /></svg> | ||
| <div className={styles.iconLayer3} /> | ||
| </div> | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <img className={styles.iconWrap} | ||
| src={`${ASSET_PREFIX}icon-wrap.png`} | ||
| data-node-id="123:45" | ||
| alt="" /> | ||
| ``` | ||
| ```scss | ||
| .iconWrap { | ||
| width: 120px; | ||
| height: 120px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 该切图没切,变成一堆 vector/CSS 堆叠,几何形状不对、无法还原设计 | ||
| - **典型事故**: | ||
| - v0.3.20 test2 事故 — `renwuInviteIcon` (装饰性 icon)没切,agent 用 4 层 div 叠 shadow 模拟 | ||
| ## Rule-Scan 识别提示 | ||
| - 看 cache 里 `node.children` 递归,统计子树的 `type` 分布 | ||
| - 只要出现 `TEXT` 就绝对不是 R03 | ||
| - 只要出现 `btn-` / `input-` / `sub-` / `block-` 前缀的子节点(说明是复合结构,不是纯装饰)也不是 R03 | ||
| - 常见误判:装饰性圆点、装饰花纹被误识别为需要展开的元素 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 (R3 隐式切图) | ||
| - rules/R11-mask-vector-css-able.md (相似判断,更严格) |
| # R04 - text-gradient | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 末位可见 fill 是 SOLID → 归 R06 | ||
| - 末位可见 fill 是 IMAGE 且节点不是 TEXT → 归 R02 | ||
| ## 触发条件 | ||
| - **cache**: `node.type === 'TEXT'` | ||
| - **fills 末位可见** (从 `fills.length-1` 倒序找第一个 `visible !== false`) 的类型是: | ||
| - `GRADIENT_LINEAR` / `GRADIENT_RADIAL` / `GRADIENT_ANGULAR` / `GRADIENT_DIAMOND` | ||
| - `IMAGE` | ||
| ## 期望产物 | ||
| **JSX 端** (强制): | ||
| ```jsx | ||
| <div className={styles.title} data-node-id="{id}"> | ||
| <span>2026</span> | ||
| </div> | ||
| ``` | ||
| **SCSS 端** (强制): | ||
| ```scss | ||
| .title { | ||
| span { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| } | ||
| ``` | ||
| **关键点**: | ||
| - `<span>` 是**必须的**(background-clip 需要行内元素承载) | ||
| - `background` 后必须紧跟 `background-clip: text` + `color: transparent` | ||
| - 缺任何一项都会失效(要么整块背景、要么无渐变) | ||
| ## Handle → CSS angle 转换表 | ||
| Figma `gradientHandlePositions` 有 3 个点:`P0=起点, P1=终点, P2=侧向控制点`。 | ||
| | 常见 handle (P0→P1) | CSS angle | 视觉方向 | | ||
| |---|---|---| | ||
| | `[0.5, 0] → [0.5, 1]` | `180deg` | 从上到下 | | ||
| | `[0.5, 1] → [0.5, 0]` | `0deg` | 从下到上 | | ||
| | `[0, 0.5] → [1, 0.5]` | `90deg` | 从左到右 | | ||
| | `[1, 0.5] → [0, 0.5]` | `270deg` | 从右到左 | | ||
| | `[0, 0] → [1, 1]` | `135deg` | 左上到右下 | | ||
| | `[1, 0] → [0, 1]` | `225deg` | 右上到左下 | | ||
| **通用公式**: | ||
| ``` | ||
| angle_rad = atan2(P1.x - P0.x, P0.y - P1.y) // 注意 y 反向 | ||
| angle_deg = angle_rad * 180 / π | ||
| // 结果映射到 CSS 0..360 | ||
| ``` | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 1: 凭空搓 solid color 代替 gradient */ | ||
| .title { color: #FFDBAA; } | ||
| /* ❌ 错法 2: 写 background 但没 background-clip */ | ||
| .title { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| /* 缺 background-clip: text 和 color: transparent → 整个块被染色,文字看不见 */ | ||
| } | ||
| /* ❌ 错法 3: 没包 <span>,直接给 .title 挂 background-clip → Safari 部分场景失效 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| **GRADIENT_LINEAR**: | ||
| ```jsx | ||
| <div className={styles.title2026} data-node-id="211:411 > 211:91"> | ||
| <span>2026</span> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .title2026 { | ||
| font-size: 48px; | ||
| font-weight: bold; | ||
| span { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| } | ||
| ``` | ||
| **IMAGE fill**: | ||
| ```jsx | ||
| <div className={styles.brandText} data-node-id="{id}"> | ||
| <span>品牌名</span> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .brandText span { | ||
| background: url("../../static/xxx/brand-fill.png") center / cover; | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 文字渐变/图案填充丢失,变成纯色或整块背景 | ||
| - **典型事故**: | ||
| - v0.3.21 test8 事故 — `2026 (TEXT)` 应该白到金渐变,agent 写成 `color: white` | ||
| ## Rule-Scan 识别提示 | ||
| - 只判 TEXT 节点 | ||
| - fills 数组末位可见类型判断: | ||
| ```js | ||
| const lastVisible = fills.slice().reverse().find(f => f && f.visible !== false); | ||
| if (lastVisible && (lastVisible.type.startsWith('GRADIENT') || lastVisible.type === 'IMAGE')) → 命中 R04 | ||
| ``` | ||
| - 输出 context 里必须带 `fills_last_type` / `fills_last_stops` / `fills_last_handles`,UI sub-agent 直接照做 | ||
| ## 相关 | ||
| - SKILL.md §4.1.1 TEXT 多层 fills 处理 | ||
| - rules/R06-text-solid-last.md (末位 SOLID 的对应规则) |
| # R05 - space-between | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无 | ||
| ## 触发条件 | ||
| - **cache**: `node.primaryAxisAlignItems === 'SPACE_BETWEEN'` | ||
| - **命中信号**: Figma AutoLayout 主轴对齐 = SPACE_BETWEEN (常见于 topbar 左右两端布局、卡片 header 主题+图标) | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 父容器 flex 布局(结构不用变,子元素两个及以上) | ||
| **SCSS 端** (强制): | ||
| ```scss | ||
| .container { | ||
| display: flex; | ||
| flex-direction: row; // 或 column,看 layoutMode | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| } | ||
| ``` | ||
| **反例扫描** (warning 而非 violation): | ||
| - `margin-left: auto` / `margin-right: auto` 模拟推开 | ||
| - `justify-content: flex-end` + 手动 padding | ||
| - `gap: auto`(不合法但 agent 常用) | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 1: 用 margin auto */ | ||
| .topbar { | ||
| display: flex; | ||
| .navBack { margin-right: auto; } | ||
| .navShare { } | ||
| } | ||
| /* ❌ 错法 2: 用 flex-end 单侧对齐 */ | ||
| .topbar { | ||
| display: flex; | ||
| justify-content: flex-end; | ||
| padding-left: 500px; /* 硬 padding 顶开左边元素 */ | ||
| } | ||
| /* ❌ 错法 3: 用 gap auto */ | ||
| .topbar { | ||
| display: flex; | ||
| gap: auto; /* 不合法 */ | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .topbar { | ||
| display: flex; | ||
| flex-direction: row; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| padding: 0 20px; | ||
| width: 750px; | ||
| height: 118px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 布局在不同宽度设备上错位(margin auto 只在 flex 里工作,固定 padding 无响应) | ||
| - **典型事故**: | ||
| - test8 topbar — 左右两端图标应 SPACE_BETWEEN,agent 写成 margin-right: auto,设备变宽后错位 | ||
| ## Rule-Scan 识别提示 | ||
| - `node.primaryAxisAlignItems === 'SPACE_BETWEEN'` 是**唯一触发** | ||
| - 有些 AutoLayout 主轴是 vertical (`node.layoutMode === 'VERTICAL'`),需 `flex-direction: column` | ||
| ## 相关 | ||
| - SKILL.md §4.3 硬规则 |
| # R06 - text-solid-last | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: | ||
| - 末位可见 fill 是 GRADIENT/IMAGE → 归 R04 | ||
| - fills 全部 invisible → 用默认 `#000000` | ||
| ## 触发条件 | ||
| - **cache**: `node.type === 'TEXT' && Array.isArray(node.fills) && fills.length > 0` | ||
| - **fills 末位可见** (`fills.slice().reverse().find(f => f && f.visible !== false)`) 类型是 `SOLID` | ||
| ## 期望产物 | ||
| **SCSS 端**: | ||
| - 对应 CSS 类含 `color: #RRGGBB;`,值来自末位可见 SOLID | ||
| - HEX 大小写不敏感;alpha 通道另写 `opacity` | ||
| **取色算法**: | ||
| ```js | ||
| const solid = pickLastVisibleFill(node.fills); | ||
| const { r, g, b } = solid.color; // 0..1 | ||
| const hex = '#' + [r,g,b].map(n => Math.round(n*255).toString(16).padStart(2,'0')).join(''); | ||
| // 例: { r: 0, g: 0.4, b: 0.6 } → #006699 | ||
| ``` | ||
| **反例扫描**: | ||
| - 取到 fills[0] 或中间层的色(不是末位可见) | ||
| - 编造的色(cache 里找不到 SOLID.color 对应值) | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* 若 cache 里 fills = [{SOLID, #FFFFFF, visible:true}, {SOLID, #00679D, visible:true}] | ||
| 末位可见 = #00679D,应写: | ||
| ✅ color: #00679D | ||
| ❌ color: #FFFFFF (取到 fills[0]) */ | ||
| /* 若 cache 里 fills = [{SOLID, #00679D, visible:false}, {SOLID, #FF0000, visible:true}] | ||
| 末位可见 = #FF0000,应写: | ||
| ✅ color: #FF0000 | ||
| ❌ color: #00679D (取到 fills[0] 忽略 visible) */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <span className={styles.priceLabel} data-node-id="211:50"> | ||
| 提前下预约单开售自动抢 | ||
| </span> | ||
| ``` | ||
| ```scss | ||
| .priceLabel { | ||
| color: #00679D; // 来自 cache node.fills 末位可见 SOLID | ||
| font-size: 28px; | ||
| line-height: 1.4; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 文字颜色错误,与设计稿不符 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — TEXT 节点 fills 有两层可见,agent 取到 fills[0] (#FFF),漏了末位 (#00679D) | ||
| ## Rule-Scan 识别提示 | ||
| - 优先看末位 (`fills.length - 1`)倒序遍历 | ||
| - 跳过 `visible === false` 的层 | ||
| - 若 fills 全部 invisible → 走默认 `#000000` (不常见,但要覆盖) | ||
| ## 相关 | ||
| - SKILL.md §4.1.1 TEXT 多层 fills 处理 | ||
| - rules/R04-text-gradient.md (末位是 GRADIENT/IMAGE 的对应规则) | ||
| - rules/R10-no-fake-solid-color.md (核对色源,避免幻觉色) |
| # R07 - multi-fills | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - fills 只有 1 层可见 → 不适用 | ||
| - fills 全部同类型 SOLID → 属 R06 单层判定 | ||
| - 节点是 TEXT + 末位 GRADIENT/IMAGE → 归 R04 | ||
| ## 触发条件 | ||
| - **cache**: `Array.isArray(node.fills) && fills.filter(f => f && f.visible !== false).length >= 2` | ||
| - **且**: 多层 fills 类型混合 (`SOLID + IMAGE`、`SOLID + GRADIENT`、多个 IMAGE 叠加等) | ||
| ## 期望产物 | ||
| **核心原则**: 每层 fills 都要落地,不能只取其一。 | ||
| **JSX/SCSS 组合方式**: | ||
| 1. **SOLID + IMAGE 叠加** (底色 + 图案): | ||
| ```scss | ||
| .box { | ||
| background-color: #FF6600; // SOLID 底色 | ||
| background-image: url("...pattern.png"); // IMAGE 上层 | ||
| background-blend-mode: normal; // 或 multiply / overlay | ||
| } | ||
| ``` | ||
| 2. **SOLID + GRADIENT** (底色 + 渐变): | ||
| ```scss | ||
| .box { | ||
| background: | ||
| linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 100%), // GRADIENT 上层 | ||
| #FF6600; // SOLID 底色 | ||
| } | ||
| ``` | ||
| 3. **多层 GRADIENT / IMAGE**: | ||
| - CSS `background` 简写多层,后写的在下方 | ||
| - **Figma fills 数组顺序**: 索引小的在下,索引大的在上;转 CSS 时**颠倒** | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma fills = [{SOLID, #FF6600, visible:true}, {IMAGE, pattern.png, visible:true}] | ||
| agent 常错法: */ | ||
| /* ❌ 只写 SOLID 忽略 IMAGE */ | ||
| .box { background-color: #FF6600; } | ||
| /* ❌ 只写 IMAGE 忽略 SOLID */ | ||
| .box { background-image: url("...pattern.png"); } | ||
| /* ❌ 层次颠倒 (SOLID 在上盖住 IMAGE) */ | ||
| .box { | ||
| background-image: url("...pattern.png"); | ||
| background-color: #FF6600; | ||
| } | ||
| /* 上例其实 CSS 顺序不重要,但要用 background 简写才可控叠加 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .orangeCard { | ||
| background: | ||
| url("../../static/xxx/pattern.png") center / cover no-repeat, | ||
| #FF6600; | ||
| width: 750px; | ||
| height: 200px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 底色或图案丢失,视觉与设计不符 | ||
| - **典型事故**: | ||
| - v0.3.19 test8 事故 — `quanItem__btn` fills = [SOLID 金色, IMAGE 光斑],agent 只写 SOLID,光斑丢失 | ||
| ## Rule-Scan 识别提示 | ||
| - 统计 `visible !== false` 的 fills 数量,≥2 才触发 | ||
| - 输出 context 里必须列 **每一层** 的 type + 主要参数(color/imageRef/gradientStops),UI sub-agent 照做 | ||
| ## 相关 | ||
| - SKILL.md §4.3 CSS 翻译表 | ||
| - rules/R09-btn-bgc-取值.md (btn 内 bgc 层的特殊处理) |
| # R08 - bg-landing-form | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无(反向匹配,自成一体) | ||
| ## 触发条件 | ||
| - **cache**: `node.name.startsWith('bg-')` 或 `node.name === 'bg'` | ||
| - **命中信号**: 图层名以 `bg-` 开头(如 `bg-body`、`bg-header`、`bg-card`)或裸 `bg` | ||
| ## 期望产物 | ||
| **核心原则**: `bg-` 前缀节点表示"该节点是父容器的背景",**必须以父容器的 `background-image` 方式落地**。 | ||
| **JSX 端** (强制): | ||
| - **不**为 bg 节点单独渲染 `<div>` 或 `<img>` | ||
| - **父容器** 直接挂 `className` 并有 `data-node-id="{bgNodeId}"` (代表这层背景由 bg 节点提供) | ||
| **SCSS 端**: | ||
| - **父容器类**含 `background-image: url("...bg-xxx.png")` + `background-size: cover|100% 100%` | ||
| **禁止的落地形态** (反向扫描): | ||
| | 错法 | 表现 | 检测正则 | | ||
| |---|---|---| | ||
| | `<img src="bg-xxx.png">` | 把 bg 当前景图 | `/<img[^>]*src=[^>]*bg-/` | | ||
| | `<img src=".../bg.png">` (裸 bg) | 同上 | `/<img[^>]*src=[^>]*\/bg\.[a-z]+/` | | ||
| | `style={{ background: ... }}` | inline style | `/style=\{\{[^}]*background/` | | ||
| | `.foo::before { background-image }` | 伪元素挂 bg | `/::(before\|after)\s*\{[^}]*background-image/` | | ||
| | `<div className={styles.bg} />` (空 div) | 空 div 挂 bg | `/<div[^>]*className=\{styles\.bg[^}]*\}[^>]*\/>/` | | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法 1: bg 用 <img> */} | ||
| <img src={`${ASSET_PREFIX}bg-body.png`} className={styles.bgBody} /> | ||
| {/* ❌ 错法 2: inline style */} | ||
| <div style={{ backgroundImage: `url(${ASSET_PREFIX}bg-body.png)` }} /> | ||
| {/* ❌ 错法 3: 空 div 挂 bg */} | ||
| <div className={styles.bgBody} data-node-id="211:37" /> | ||
| {/* 然后在其他 div 里塞内容,分开的父子关系 */} | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法 4: 伪元素 */ | ||
| .page::before { | ||
| content: ""; | ||
| background-image: url("...bg-body.png"); | ||
| position: absolute; | ||
| inset: 0; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <div className={styles.page} data-node-id="211:31"> | ||
| {/* 父容器 page 承担 bg-body 的背景, 无需为 bg-body 单开元素 */} | ||
| <div className={styles.topbar} data-node-id="211:32">...</div> | ||
| <div className={styles.content}>...</div> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .page { | ||
| background-image: url("../../static/test1/bg-body.png"); | ||
| background-size: 100% 100%; | ||
| background-repeat: no-repeat; | ||
| width: 750px; | ||
| min-height: 100vh; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - `<img>` 挂 bg → 图片和内容 z-index 冲突,内容被压在图后 | ||
| - 伪元素挂 bg → 父容器 `position: relative` 忘配时定位错 | ||
| - inline style → 无缓存、无响应式,后期难维护 | ||
| - **典型事故**: | ||
| - v0.3.9 test8 事故 — `bg-body` 用 `<img>` + `position: absolute`,内容层被盖住 | ||
| ## Rule-Scan 识别提示 | ||
| - 触发只看图层名(`bg-` 前缀 or 裸 `bg`) | ||
| - 判定产物形态需 Read jsx/scss,对照上表 5 种禁止形态 | ||
| - **警惕**: 有些 agent 会把 bg 分层到子块内(不是 page 而是 sub- 或 block-),这时候 bg 应挂到那个子块的父容器 | ||
| ## 相关 | ||
| - SKILL.md §4.3 硬规则第 4 条 | ||
| - rules/R02-fills-image.md (fills IMAGE 的通用落地) |
| # R09 - btn-bgc-取值 | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - `bgc-` 层的 fills 只有单层 SOLID → 直接按 CSS `background-color` 取,不算 R09 | ||
| - `bgc-` 层的 fills 是 IMAGE → 归 R02 | ||
| - btn 前缀节点无 `bgc-` 子层 → 不适用 | ||
| ## 触发条件 | ||
| - **cache**: 存在 `node.name.startsWith('btn-')` 节点 | ||
| - **且**: 该节点 children 里有 `child.name.startsWith('bgc-')` | ||
| - **且**: `bgc-` 层的 `fills` 是 `GRADIENT_*`(或多层含 GRADIENT) | ||
| ## 期望产物 | ||
| **核心原则**: `btn-` 的**父容器** CSS `background` 应取自 `bgc-` 子层的**真实 fills**,不是编造。 | ||
| **JSX 端**: | ||
| - 按分层结构 `<div className={styles.btn}>` 承担 bgc 背景,内部再放文字/图标 | ||
| **SCSS 端**: | ||
| - `.btn { background: linear-gradient(...); }`(gradient 参数按 bgc-*.fills) | ||
| **反例扫描**: | ||
| - agent 编造渐变色(cache 里 bgc- 明明是 A→B,产物写成 C→D) | ||
| - agent 编造 solid color 代替 gradient | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma: | ||
| btn-primary | ||
| └─ bgc-primary fills = [ GRADIENT: #864500 → #6D3600 ] | ||
| └─ text "购买" | ||
| agent 常错法: */ | ||
| /* ❌ 错法 1: 凭空搓 solid color */ | ||
| .btnPrimary { background-color: #864500; } | ||
| /* ❌ 错法 2: 颜色对了但类型错(SOLID 代 GRADIENT) */ | ||
| .btnPrimary { background: #864500; } | ||
| /* ❌ 错法 3: 编造与 bgc 无关的渐变 */ | ||
| .btnPrimary { background: linear-gradient(180deg, #FF0 0%, #F00 100%); } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <button className={styles.btnPrimary} data-node-id="211:446"> | ||
| <span>购买</span> | ||
| </button> | ||
| ``` | ||
| ```scss | ||
| .btnPrimary { | ||
| background: linear-gradient(180deg, #864500 0%, #6D3600 100%); // 取自 bgc-primary.fills | ||
| border: none; | ||
| border-radius: 44px; | ||
| padding: 22px 60px; | ||
| color: #FFFFFF; | ||
| font-size: 32px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 按钮颜色错、无渐变 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — `btn-购买` 内 `bgc-` 渐变 #864500→#6D3600,agent 只写 SOLID #864500 | ||
| - v0.3.19 test8 事故 — `quanItem__btn` 内 `bgc-` 光效渐变,agent 编造成完全不同的黄色 | ||
| ## Rule-Scan 识别提示 | ||
| - 先按 `btn-` 前缀找到按钮节点 | ||
| - 递归 children 找 `bgc-` 前缀节点(通常是第 1 或第 2 层) | ||
| - 读该 bgc 节点的 `fills` (**不是** btn 节点自身的 fills) | ||
| - 输出 context 里必须含: | ||
| ```json | ||
| { | ||
| "btn_nodeId": "211:446", | ||
| "bgc_nodeId": "211:447", | ||
| "bgc_fills": [ { "type": "GRADIENT_LINEAR", "gradientStops": [...], "gradientHandlePositions": [...] } ] | ||
| } | ||
| ``` | ||
| ## 相关 | ||
| - SKILL.md §4.3 CSS 翻译表 (fills GRADIENT) | ||
| - rules/R07-multi-fills.md | ||
| - rules/R10-no-fake-solid-color.md |
| # R10 - no-fake-solid-color | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (需交叉核对 cache 与产物) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - R06 已判定过的 TEXT color 值 → 不重复扫 | ||
| - R07/R09 已判定过的 background 色 → 不重复扫 | ||
| ## 触发条件 | ||
| - **产物**: SCSS 中出现 `color: #XXX` / `background-color: #XXX` / `background: #XXX` 之类的 SOLID 色 | ||
| - **cache 侧**: 该 CSS 规则对应的 `nodeId` 的所有 fills 中,**找不到**任何 SOLID 色的 HEX 与该产物色匹配 | ||
| - **意味着**: agent 幻觉搓色 | ||
| ## 期望产物 | ||
| **核心原则**: 产物里出现的每一个 `#RRGGBB` 都必须在 cache 里能找到 fills 源头。 | ||
| **判定算法**: | ||
| 1. Read 产物所有 .scss / .css 文件 | ||
| 2. 提取所有 `#RRGGBB` (或 `rgba(...)` 已知色) | ||
| 3. 对每个色,反查:该规则挂在哪个 `nodeId` 的 className 下 | ||
| 4. Read 该 nodeId 的 cache,遍历 fills: | ||
| - 有匹配 SOLID.color → OK | ||
| - 全部 GRADIENT/IMAGE → 走 R04/R07/R09,不属 R10 | ||
| - 找不到匹配 → **R10 命中(幻觉色)** | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma nodeId=211:32 fills = [] (无填充,靠父容器) | ||
| agent 幻觉搓: */ | ||
| .topbar { background-color: #F5F5F5; } /* ❌ cache 里找不到 #F5F5F5 */ | ||
| /* Figma nodeId=211:411 fills = [{SOLID, #003366, visible:true}] | ||
| agent 幻觉搓: */ | ||
| .title { color: #0066CC; } /* ❌ #0066CC ≠ #003366 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| **从 cache 精确取色**: | ||
| ```js | ||
| // UI sub-agent 生 SCSS 前,遍历本 block 每个 TEXT/RECT 节点: | ||
| const solid = pickSolidColor(node.fills); // 取第一个可见 SOLID | ||
| if (!solid) return; // 无 SOLID 就不写色 | ||
| const hex = rgbaToHex(solid.color); | ||
| // 输出: color: {hex}; | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 颜色与设计稿不符,agent 常"猜"一个相近色 | ||
| - **典型事故**: | ||
| - v0.3.15 test8 事故 — 卡片背景 agent 写 #FFF7E5,cache 里 fills=[] 也没父背景,纯幻觉 | ||
| - 多起 v0.3.x 事故 — 按钮 hover / disabled 状态色被 agent 编造 | ||
| ## Rule-Scan 识别提示 | ||
| - 只对产物已 Read 后判定;主要靠"反向核对" | ||
| - 检出 `#RRGGBB` 后,反查对应 className → nodeId → cache fills | ||
| - 若 cache 里 fills=[] 但产物有色 → 强命中 | ||
| - 若 cache 里 fills 全 IMAGE/GRADIENT 但产物有 SOLID 色 → 强命中 | ||
| - **豁免**: 系统默认色如 `color: inherit` / `color: transparent` / `background: none`,不算 R10 | ||
| ## 相关 | ||
| - rules/R06-text-solid-last.md (TEXT SOLID 精确取色) | ||
| - rules/R07-multi-fills.md (多层 fills 取色) | ||
| - rules/R09-btn-bgc-取值.md (btn 背景取色) |
| # R11 - mask-vector-css-able | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 节点已按 R02 (fills=IMAGE) 切图 → 不重复判 | ||
| - 节点已按 R03 (implicit-image) 切图 → 不重复判 | ||
| - 简单矩形 / 圆角矩形 / 圆形 / 椭圆 → CSS 可表达,不切 | ||
| ## 触发条件 | ||
| - **cache**: 节点或其子树含以下之一: | ||
| - `type === 'BOOLEAN_OPERATION'`(布尔运算,如 UNION/INTERSECT/SUBTRACT) | ||
| - 多层 `type === 'VECTOR'` 叠加 | ||
| - `node.isMask === true` 与其他节点组合 | ||
| - 复杂 SVG path(非矩形/圆形) | ||
| - **且**: 该结构**不能仅用 CSS** 表达(如 `border-radius` + `background` 组合) | ||
| ## 期望产物 | ||
| **核心原则**: CSS 表达不了的复合几何 → 必须切图。 | ||
| **JSX 端**: | ||
| - `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />` | ||
| **SCSS 端**: | ||
| - 只写尺寸,**不**尝试用 CSS `mask` / 多层 `clip-path` / SVG path 还原 | ||
| **反例扫描**: | ||
| - SCSS 里出现 `mask-image` / `-webkit-mask` (虽然合法,但兼容性差) | ||
| - SCSS 里出现多层 `clip-path` 试图组合 | ||
| - JSX 里 inline `<svg>` 复杂 path | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Figma 结构: BOOLEAN_OPERATION SUBTRACT (圆环减去中间空心) */} | ||
| {/* ❌ 错法 1: agent 用 mask */} | ||
| <div className={styles.ring} /> | ||
| ``` | ||
| ```scss | ||
| .ring { | ||
| width: 200px; | ||
| height: 200px; | ||
| background: #FF6600; | ||
| mask-image: radial-gradient(circle, transparent 60px, black 61px); | ||
| -webkit-mask-image: radial-gradient(circle, transparent 60px, black 61px); | ||
| } | ||
| ``` | ||
| ```jsx | ||
| {/* ❌ 错法 2: agent 用 clip-path */} | ||
| <div className={styles.ring} style={{ clipPath: "..." }} /> | ||
| {/* ❌ 错法 3: agent 用内联 SVG (性能差, 无缓存) */} | ||
| <svg> | ||
| <path d="M100,100 L200,200 ..." fill="#FF6600" /> | ||
| </svg> | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <img className={styles.ring} | ||
| src={`${ASSET_PREFIX}ring.png`} | ||
| data-node-id="{id}" | ||
| alt="" /> | ||
| ``` | ||
| ```scss | ||
| .ring { | ||
| width: 200px; | ||
| height: 200px; | ||
| } | ||
| ``` | ||
| ## CSS 可表达 vs 不可表达速查 | ||
| **可 CSS 表达(不切图)**: | ||
| - 纯色矩形 / 圆角矩形 → `background-color` + `border-radius` | ||
| - 单色圆形 / 椭圆 → `border-radius: 50%` | ||
| - 单向阴影 → `box-shadow` | ||
| - 单一渐变(线性/径向) → `background: linear-gradient(...)` | ||
| - 单层 border → `border` | ||
| **不可 CSS 表达(必须切图)**: | ||
| - 布尔运算(subtract/intersect/exclude) | ||
| - 多层 vector 叠加(如 icon 组合) | ||
| - 复杂 SVG path(非规则几何) | ||
| - mask 与其他 fills 组合 | ||
| - 特殊纹理 / 光效 | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - `mask-image` 在旧浏览器失效 | ||
| - 内联 `<svg>` 破坏组件树、无缓存 | ||
| - 复杂 `clip-path` 兼容性差 | ||
| - **典型事故**: | ||
| - v0.3.9 test8 事故 — 圆环装饰用 `mask-image`,某些手机不显示 | ||
| ## Rule-Scan 识别提示 | ||
| - 首先看 cache 里的 `type` 分布:出现 `BOOLEAN_OPERATION` → 强命中 | ||
| - 出现多个 `VECTOR` 叠加 → 命中 | ||
| - 判断"CSS 可表达"的临界:如果 shape 是圆/椭圆/圆角矩形,不命中 | ||
| - 输出 context 里列出复合几何的形态描述 | ||
| ## 相关 | ||
| - SKILL.md §4.4.pre.b 子树结构禁切规则 (v0.3.9) | ||
| - rules/R02-fills-image.md | ||
| - rules/R03-implicit-image.md |
| # R12 - flat-mode-naming | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - `pp-d2c.config.json` 的 `merge.mode !== 'flat'` → 不适用 | ||
| - 项目使用 CSS Modules 且每个 block 独立 scss 文件 → 天然隔离,不适用 | ||
| ## 触发条件 | ||
| - **config**: `merge.mode === 'flat'`(所有 block 产物合并到一个文件) | ||
| - **命中信号**: 多个 block 产出相同 className(如 `.title` / `.button` / `.card`),合并后互相覆盖 | ||
| ## 期望产物 | ||
| **核心原则**: flat 模式下,className 必须带 **block 语义前缀** 才不冲突。 | ||
| **命名规范**: | ||
| | 场景 | 好类名 | 坏类名 | | ||
| |---|---|---| | ||
| | topbar 的 title | `.topbarTitle` | `.title` | | ||
| | card 的 title | `.cardTitle` | `.title` | | ||
| | footer 的 button | `.footerButton` | `.button` | | ||
| | 通用容器 | `.<block>Container` | `.container` | | ||
| **JSX 端**: | ||
| - `<div className={styles.topbarTitle}>`,不是 `<div className={styles.title}>` | ||
| **SCSS 端**: | ||
| - 类选择器全都含 block 前缀 | ||
| - **不用** BEM 双下划线(`.topbar__title`)反而增加复杂度;直接驼峰 camelCase 拼 | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Block 1: topbar */} | ||
| <div className={styles.title}>标题</div> | ||
| {/* Block 2: card */} | ||
| <div className={styles.title}>卡片标题</div> | ||
| {/* 合并后 `.title` 只保留最后一个规则,前面的样式被覆盖 */} | ||
| ``` | ||
| ```scss | ||
| /* Block 1 生成 */ | ||
| .title { font-size: 32px; color: #003366; } | ||
| /* Block 2 生成 */ | ||
| .title { font-size: 24px; color: #666; } | ||
| /* flat 合并:第二个 .title 覆盖第一个,topbar 的样式丢失 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* Block 1: topbar */} | ||
| <div className={styles.topbarTitle}>标题</div> | ||
| {/* Block 2: card */} | ||
| <div className={styles.cardTitle}>卡片标题</div> | ||
| ``` | ||
| ```scss | ||
| .topbarTitle { font-size: 32px; color: #003366; } | ||
| .cardTitle { font-size: 24px; color: #666; } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 类名冲突,后 block 的样式盖住前 block,视觉错乱 | ||
| - **典型事故**: | ||
| - v0.3.16 test8 事故 — 3 个 block 都用 `.title`,合并后只剩 footer 样式 | ||
| ## Rule-Scan 识别提示 | ||
| - Read `pp-d2c.config.json`,若 `merge.mode !== 'flat'` → 跳过 R12 判定 | ||
| - 遍历产物 SCSS,检索**同名 selector**是否出现多次 | ||
| - 或提前扫每个 block 的类名列表,交叉比对 | ||
| ## 相关 | ||
| - SKILL.md §5 合并策略 | ||
| - pp-d2c.config.json `merge.mode` |
| # R13 - unit-scale | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (未来可升,现阶段需 config 解析) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: `pp-d2c.config.json` 的 `unit.figmaBase === unit.outputBase` → scale=1,不适用 | ||
| ## 触发条件 | ||
| - **config**: `unit.scale !== 1`(常见 Figma 375 base 输出到 750 px,scale=2) | ||
| - **命中信号**: 产物 CSS 中的 `px` 数字与 Figma 原始尺寸相同(意味着没换算) | ||
| ## 期望产物 | ||
| **换算公式**: | ||
| ``` | ||
| outputPx = figmaPx * (unit.outputBase / unit.figmaBase) | ||
| ``` | ||
| **举例**(figmaBase=375, outputBase=750, scale=2): | ||
| | Figma 尺寸 | 输出尺寸 | | ||
| |---|---| | ||
| | `x=20, y=44` | `20 * 2 = 40px, 44 * 2 = 88px` | | ||
| | `width=335, height=118` | `670px × 236px` | | ||
| | `fontSize=16` | `32px` | | ||
| | `borderRadius=8` | `16px` | | ||
| | `padding: 12 20` | `24px 40px` | | ||
| **SCSS 端**: | ||
| - 所有 `px` 值都是换算后的输出值,不含 Figma 原值 | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma cache: node.width = 335, node.absoluteBoundingBox.height = 118 | ||
| config: figmaBase=375, outputBase=750, scale=2 | ||
| 期望输出: width=670px, height=236px | ||
| agent 错法: */ | ||
| .card { | ||
| width: 335px; /* ❌ Figma 原值,未换算 */ | ||
| height: 118px; /* ❌ */ | ||
| padding: 12px; /* ❌ */ | ||
| font-size: 16px; /* ❌ */ | ||
| border-radius: 8px; /* ❌ */ | ||
| } | ||
| /* 期望 */ | ||
| .card { | ||
| width: 670px; | ||
| height: 236px; | ||
| padding: 24px; | ||
| font-size: 32px; | ||
| border-radius: 16px; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| **UI sub-agent 生 SCSS 前应用换算**: | ||
| ```js | ||
| const config = readConfig(); | ||
| const scale = config.unit.outputBase / config.unit.figmaBase; | ||
| function toPx(figmaPx) { | ||
| return `${Math.round(figmaPx * scale)}px`; | ||
| } | ||
| // 使用 | ||
| .card { | ||
| width: ${toPx(node.width)}; | ||
| height: ${toPx(node.height)}; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - 375 base 输出到 750 屏幕 → 元素显示只有一半大小 | ||
| - 或应用 `rem` 换算时,基础字号错乱 | ||
| - **典型事故**: | ||
| - v0.3.8 test1 事故 — agent 忘换算,整个页面在真机上缩小一半 | ||
| ## Rule-Scan 识别提示 | ||
| - Read `pp-d2c.config.json`,取 `unit.figmaBase` / `unit.outputBase` | ||
| - scale = outputBase / figmaBase | ||
| - 若 scale === 1 → 不适用 | ||
| - 命中判定: | ||
| - Read 产物 SCSS,提取所有 `\d+px` | ||
| - 反查每个 px 值对应的 nodeId,读 cache 的 `absoluteBoundingBox.width/height` / `x/y` | ||
| - 若产物 px === Figma px 且 scale != 1 → 命中 R13 | ||
| ## 相关 | ||
| - SKILL.md §4.5 单位换算 | ||
| - pp-d2c.config.json `unit.figmaBase` / `unit.outputBase` | ||
| - rules/R10-no-fake-solid-color.md |
| # R14 - fixed-z-index | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: 页面只有 1 个 `fixed-` 节点 → 无冲突,不判 z-index | ||
| ## 触发条件 | ||
| - **cache**: 存在**多个** `node.name.startsWith('fixed-')` 节点(通常是状态栏 + 底部 bar + 悬浮按钮) | ||
| - **命中信号**: 多层 fixed 节点在 Figma 里有明确 z 顺序(通常按图层顺序),产物 z-index 缺失或未递增 | ||
| ## 期望产物 | ||
| **核心原则**: 多个 fixed 元素必须有递增 z-index,视觉层级依 Figma 图层顺序。 | ||
| **SCSS 端**: | ||
| ```scss | ||
| .fixedStatusBar { position: fixed; top: 0; z-index: 100; } | ||
| .fixedTopbar { position: fixed; top: 118px; z-index: 90; } | ||
| .fixedFloatingBtn { position: fixed; bottom: 40px; right: 40px; z-index: 200; } | ||
| ``` | ||
| **约定**: | ||
| - 底部 bar / 悬浮按钮 → z-index ≥ 100 | ||
| - 顶部固定 bar → z-index 递减(status > topbar > sub-nav) | ||
| - **不要**都用 z-index: 1 或都不写 | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma 有 3 个 fixed 节点: | ||
| - fixed-状态栏 | ||
| - fixed-顶部bar | ||
| - fixed-悬浮按钮 | ||
| 期望层级: 状态栏 > 悬浮按钮 > 顶部bar | ||
| agent 错法 1: 全部不写 z-index */ | ||
| .fixedStatusBar { position: fixed; top: 0; } | ||
| .fixedTopbar { position: fixed; top: 118px; } | ||
| .fixedFloatingBtn { position: fixed; bottom: 40px; } | ||
| /* → z-index 默认为 auto,层级不确定,可能互相覆盖 */ | ||
| /* agent 错法 2: 全部 z-index 相同 */ | ||
| .fixedStatusBar { z-index: 1; } | ||
| .fixedTopbar { z-index: 1; } | ||
| .fixedFloatingBtn { z-index: 1; } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .fixedStatusBar { | ||
| position: fixed; | ||
| top: 0; | ||
| z-index: 100; | ||
| } | ||
| .fixedTopbar { | ||
| position: fixed; | ||
| top: 118px; | ||
| z-index: 90; | ||
| } | ||
| .fixedFloatingBtn { | ||
| position: fixed; | ||
| bottom: 40px; | ||
| right: 40px; | ||
| z-index: 200; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: fixed 元素互相覆盖、位置错乱 | ||
| - **典型事故**: | ||
| - v0.3.19 test8 事故 — 底部 fixed 悬浮按钮被上滑的内容盖住(z-index 未设) | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 收集 `fixed-` 前缀节点 | ||
| - ≥2 → 判定 R14 | ||
| - Figma 图层顺序(`children` 数组索引)对应视觉 z:索引大的在上 | ||
| - 输出 context 里列出各 fixed 节点及推荐 z-index 值 | ||
| ## 相关 | ||
| - rules/R01-fixed-position.md | ||
| - SKILL.md §4.3 硬规则第 1 条 (fixed-) |
| # R15 - 同构 map 渲染 | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 同层 <3 个同构节点 → 不强制 map | ||
| - 每个节点有明显不同的交互 / 命名 / 结构差异 → 保留独立元素 | ||
| ## 触发条件 | ||
| - **cache**: 同一父节点下有 **≥3 个** 结构同构的子节点 | ||
| - **同构判定**: | ||
| - 相同 `type` | ||
| - 相同 `children` 结构(层级 + 类型分布相同) | ||
| - 相同 `name` 前缀(如 `item-1`, `item-2`, `item-3` 或 `card_1`, `card_2`) | ||
| - **命中信号**: agent 展开成 3+ 份重复 JSX + SCSS | ||
| ## 期望产物 | ||
| **JSX 端** (强制): | ||
| ```jsx | ||
| {[ | ||
| { title: '...', desc: '...' }, | ||
| { title: '...', desc: '...' }, | ||
| { title: '...', desc: '...' }, | ||
| ].map((item, i) => ( | ||
| <div key={i} className={styles.card}> | ||
| <div className={styles.cardTitle}>{item.title}</div> | ||
| <div className={styles.cardDesc}>{item.desc}</div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| **SCSS 端** (强制): | ||
| - 同构节点**只写一份** `.card { ... }` 规则 | ||
| - 通过 `:nth-child(n)` 处理个别差异(通常无差异,不需要) | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Figma: 同层 3 个 item-* 节点 */} | ||
| {/* ❌ 错法: 展开 3 份重复 JSX */} | ||
| <div className={styles.item1}> | ||
| <div className={styles.item1Title}>标题 1</div> | ||
| <div className={styles.item1Desc}>描述 1</div> | ||
| </div> | ||
| <div className={styles.item2}> | ||
| <div className={styles.item2Title}>标题 2</div> | ||
| <div className={styles.item2Desc}>描述 2</div> | ||
| </div> | ||
| <div className={styles.item3}> | ||
| <div className={styles.item3Title}>标题 3</div> | ||
| <div className={styles.item3Desc}>描述 3</div> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法: 展开 3 份重复 SCSS */ | ||
| .item1 { ... } | ||
| .item1Title { ... } | ||
| .item2 { ... } | ||
| .item2Title { ... } | ||
| .item3 { ... } | ||
| .item3Title { ... } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| const CARDS = [ | ||
| { title: '预约票', desc: '开售自动抢' }, | ||
| { title: '优惠券', desc: '限时领取' }, | ||
| { title: '会员权益', desc: '专享特惠' }, | ||
| ]; | ||
| <div className={styles.cardList}> | ||
| {CARDS.map((card, i) => ( | ||
| <div key={i} className={styles.card}> | ||
| <div className={styles.cardTitle}>{card.title}</div> | ||
| <div className={styles.cardDesc}>{card.desc}</div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .cardList { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 20px; | ||
| } | ||
| .card { | ||
| padding: 30px; | ||
| background: #FFFFFF; | ||
| border-radius: 16px; | ||
| } | ||
| .cardTitle { font-size: 32px; font-weight: bold; } | ||
| .cardDesc { font-size: 24px; color: #666; } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - 代码冗长 3 倍以上,可维护性差 | ||
| - 后续加/删一项需要多处改 | ||
| - SCSS 类名膨胀,增大产物体积 | ||
| - **典型事故**: | ||
| - v0.3.14 test8 事故 — 6 个卡片各 15 行 JSX + 30 行 SCSS,合计 270 行,合并成 map 后仅 40 行 | ||
| ## Rule-Scan 识别提示 | ||
| - 找同层 ≥3 个子节点 | ||
| - 判断"同构": | ||
| 1. `type` 相同 | ||
| 2. 子结构签名相同(如 `[TEXT, TEXT, VECTOR]`) | ||
| 3. 名字前缀相同(可选) | ||
| - 输出 context 里列出: | ||
| - 同构节点 nodeId 列表 | ||
| - 每个节点的可提取内容差异(如 title / imageUrl) | ||
| ## 相关 | ||
| - rules/R12-flat-mode-naming.md |
| # R16 - no-flatten-text | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底,建议 sub-agent 生成前也自查一遍) | ||
| - **排斥条件**: | ||
| - 节点 name **裸词** `img` / `bg` → 免疫 | ||
| - 节点 name 以 **`img-`** / **`bg-`** 开头(且后有字符)→ 免疫 | ||
| - 节点 `type` 不在 `{GROUP, FRAME, COMPONENT, INSTANCE}` → 不适用 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.type` ∈ `{GROUP, FRAME, COMPONENT, INSTANCE}` | ||
| 2. `node.name` 前缀 **非** `img-` / `bg-` / 裸词 `img` / `bg` | ||
| 3. 该节点子树(递归 children)中 **存在** 至少一个 `TEXT` 类型节点 | ||
| 4. 产物 jsx 中出现 `<img ... data-node-id="<该节点 nodeId>" ... />`(无论跨行/属性顺序) | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 不允许 `<img data-node-id="<该 nodeId>">` | ||
| - 应按 §4.3 前缀规则递归拆解子树: | ||
| - TEXT 子节点 → `<span>` / `<p>` 展开 | ||
| - `btn-` 子节点 → `<button>` + CSS 化 | ||
| - `img-` 子节点 → `<img>` 引用切图 | ||
| - `bg-` 子节点 → 挂父容器 `background-image` | ||
| - 无前缀装饰性 vector 子树 → 走 R03 隐式切图 | ||
| **assets.txt 端**: | ||
| - 不允许出现该 nodeId 的"整体切图"行 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ Frame 745 含 TEXT "北京时间18:00开抢" + btn,被整体烤成 png */} | ||
| <img | ||
| className={styles.couponBig} | ||
| src={`${ASSET}frame-745.png`} | ||
| data-node-id="211:171" | ||
| alt="" | ||
| /> | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { | ||
| "id": "211:171", | ||
| "name": "Frame 745", | ||
| "type": "FRAME", | ||
| "children": [ | ||
| { "id": "211:174", "name": "coupon-big-bg", "type": "RECTANGLE" }, | ||
| { "id": "211:198", "name": "折扣数字", "type": "TEXT" }, // ← 触发 R16 | ||
| { "id": "211:212", "name": "btn-q", "type": "GROUP" } | ||
| ] | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ 拆解子树;bg-* 挂父 background;TEXT 用 <span>;btn-* 用 <button> */} | ||
| <div className={styles.couponBig} data-node-id="211:171"> | ||
| {/* 211:174 bg-* → 父容器 background-image,此处不生成 DOM */} | ||
| <span className={styles.couponBigNum} data-node-id="211:198">1折</span> | ||
| <button className={styles.couponBigBtn} type="button" data-node-id="211:212"> | ||
| 立即抢 | ||
| </button> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .couponBig { | ||
| position: relative; | ||
| width: 718px; | ||
| height: 300px; | ||
| background: url('#{$asset-prefix}coupon-big-bg.png') no-repeat center / 100% 100%; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:TEXT 无障碍缺失(屏幕阅读器读不到)、无法本地化(换语言换不了)、按钮不可点击、体验不可修改 | ||
| - **典型事故**:v1.0.0 test12 事故 — Frame 745/744/762/img-title-quan + sub-MAIN 五处被整体烤成 png,91 条 R02/R06 违规被 agent 用 `[整体切图兜底]` 标签自签豁免 | ||
| ## 与其他规则的关系 | ||
| - **R03 implicit-image**:R03 自身已排斥子树含 TEXT 的情况;R16 是"哪怕 agent 无视 R03 的排斥条件,最终也拦得住"的兜底 | ||
| - **R02 fills-image**:R02 只管 fills 含 IMAGE 的节点必须落切图;R16 管 fills 不含 IMAGE 但被违规整体切图的情况 | ||
| - **§6.0.2 兜底防线 N=0**:R16 输出的 violation 一律不许被 `[整体切图兜底]` 豁免(该标签已废除) | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `type ∈ {GROUP, FRAME, COMPONENT, INSTANCE}` 且 `name` 非白名单的节点 | ||
| - 递归 `children` 判断子树是否含 `TEXT` | ||
| - 是 → 在 `rule-hits.json` 里给该节点标记 `rule: 'R16'` + `expected: '禁止整体切图;按前缀拆解子树'` | ||
| - UI sub-agent 生成 jsx 时若准备对该节点写 `<img>` → 强制回退到子树递归 | ||
| ## 相关 | ||
| - SKILL.md §4.3 图层解析规则(前缀白名单来源) | ||
| - SKILL.md §6.0.2 兜底防线(配合本规则彻底废除"整体切图兜底"路径) | ||
| - rules/R02-fills-image.md | ||
| - rules/R03-implicit-image.md | ||
| - rules/R06-text-solid-last.md |
| # R17 - no-baked-dom(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - 节点 `_inBakedSubtree !== true`(不在整体切图子树内)→ 不适用 | ||
| - 节点 `_templateDup === true`(`.map()` 数据副本)→ 跳过(代表项报过即可) | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. 节点处于 `bg-` / `img-`(整体切图,含裸词 `bg`/`img`)或 `x-`(整体忽略)前缀节点的**子树内**(`loadCache.mjs` 标注 `_inBakedSubtree === true`,`_bakedBy` 指向那个前缀节点)。**不含 `bgc-`**:bgc- 是盒级 CSS 写父、非切图,其子孙走正常规则(见 R21) | ||
| 2. 产物 JSX 中出现该节点的 `data-node-id="<nodeId>"` 元素(跨行 / 属性顺序任意) | ||
| 命中 → 违规(双重渲染)。 | ||
| ## 期望产物 | ||
| - 整体切图子树内的节点,其像素**已经在父层切图 PNG 里**(`bg-`/`bgc-`/`img-`),或被 `x-` 整体忽略 | ||
| - 产物中**不得**再有其 `data-node-id` 元素——它不该作为独立 DOM 出现 | ||
| - 与 R02/R06 分工:R02/R06 **跳过** baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ bg-main(211:39) 整体切成 main.png,title/subtitle 文字像素已在 PNG 里 */} | ||
| <div className="page__main-bg" data-node-id="211:39" /> | ||
| {/* ↓ 但又把 bg-main 子孙的 title-text/subtitle 生成成 DOM → 文字渲染两遍、叠字 */} | ||
| <div className="page__title-row" data-node-id="211:410"> | ||
| <span className="page__title-text" data-node-id="211:83">中秋火车票开售预测</span> | ||
| </div> | ||
| <p className="page__subtitle" data-node-id="211:84">官方尚未给出明确开售时间...</p> | ||
| ``` | ||
| 对应 cache(211:83 / 211:84 是 bg-main 211:39 的子孙): | ||
| ```json | ||
| { "id": "211:39", "name": "bg-main", "type": "GROUP", | ||
| "children": [ { "id": "211:410", "children": [ { "id": "211:83", "type": "TEXT" } ] }, | ||
| { "id": "211:82", "children": [ { "id": "211:84", "type": "TEXT" } ] } ] } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ bg-main 整体切图 → 只保留背景层,子孙文字不再出 DOM(已在 main.png 里) */} | ||
| <div className="page__main-bg" data-node-id="211:39" /> | ||
| {/* title-text / subtitle 不生成 —— 它们的像素在 main.png 中 */} | ||
| ``` | ||
| 若文案需要动态替换 → 见 §4.3「含 TEXT 容器 压平 vs 拆」裁决树:改走**拆结构**(去掉 bg- 前缀,文字出 DOM,背景单独切成不含文字的图),而非"既烤又留"。 | ||
| ## 违反后果 | ||
| - **产物表现**:文字/图叠一遍(切图里一份 + DOM 一份),视觉重影、错位 | ||
| - **典型事故**:v1.1.0 test13 — bg-main 的 title-text/subtitle/韩国贴纸既进 main.png 又出 DOM,用户手工删除 DOM 才修正 | ||
| ## 与其他规则的关系 | ||
| - **R16 no-flatten-text**:R16 管"不该压平的容器被整体切图";R17 管"该压平的容器压平后子孙又出 DOM"。二者是同一矛盾(压平 vs 拆)的两面 | ||
| - **R02/R06**:跳过 `_inBakedSubtree` 节点(不误报"缺 url/缺 color"),"禁 DOM" 交由 R17 | ||
| - **§6.0.2 兜底防线 N=0**:R17 违规一律不许豁免,回滚 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache,找 `_inBakedSubtree === true` 的节点 | ||
| - 检查产物 JSX 是否有其 `data-node-id` | ||
| - 有 → 标记 `rule: 'R17'` + `expected: '整体切图子树内节点禁止出 DOM(像素已在父层切图)'` | ||
| ## 相关 | ||
| - SKILL.md §4.3「含 TEXT 容器 压平 vs 拆」裁决树 | ||
| - SKILL.md §6.0.2 兜底防线 | ||
| - rules/R16-no-flatten-text.md | ||
| - bin/lib/loadCache.mjs(`_inBakedSubtree` 标注) |
| # R18 - flex-direction(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(非 autolayout 容器)→ 不适用 | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空(不可追溯)→ 不报,由 §5.1.1 data-node-id 铁律在生成侧兜底 | ||
| - 该节点 CSS 规则体无 `display: flex` → 不判定(可能非 flex 实现) | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.layoutMode` ∈ `{HORIZONTAL, VERTICAL}` | ||
| 2. 节点有 `data-node-id` + className 映射,且 CSS 规则体含 `display: flex` | ||
| 3. CSS 的 `flex-direction` 与 Figma `layoutMode` **不符**: | ||
| - `layoutMode === 'VERTICAL'` 但 CSS **非** `flex-direction: column`(含缺省,默认 row)→ 违规 | ||
| - `layoutMode === 'HORIZONTAL'` 但 CSS **是** `flex-direction: column`(方向写反)→ 违规 | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| | Figma `layoutMode` | CSS `flex-direction` | | ||
| |--------------------|----------------------| | ||
| | `VERTICAL` | `column`(**必须显式写**,否则默认 row 会横排) | | ||
| | `HORIZONTAL` | `row` 或省略(row 是 flex 默认,可不写) | | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ small-card-top 对应 Figma Frame 764 layoutMode=VERTICAL,却写成 row */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| flex-direction: row; /* ← 应为 column,价格与描述该竖排却横排 */ | ||
| align-items: center; | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:221", "name": "Frame 764", "type": "FRAME", "layoutMode": "VERTICAL" } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ VERTICAL → 显式 column */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: center; | ||
| } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:子元素排列方向反了(竖排变横排 / 横排变竖排),整块布局错乱 | ||
| - **典型事故**:v1.1.0 test13 — small-card-top(Figma VERTICAL)写成 `flex-direction: row`,价格与描述横排;且该节点在 `.map()` 模板里没挂 data-node-id,逃过校验直接上线 | ||
| ## 与其他规则的关系 | ||
| - **§5.1.1 data-node-id 全覆盖铁律**:R18 靠 data-node-id 绑定;`.map()` 模板必须挂代表项(variant a)id,否则 R18 无法校验(正是 test13 逃逸根因) | ||
| - **lib/cssMatch.mjs**:R18 用它匹配 SCSS `&__foo` 嵌套写法,避免"产物嵌套、正则找平铺"的假阴性 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `layoutMode ∈ {HORIZONTAL, VERTICAL}` 的容器 | ||
| - 提示 UI sub-agent:VERTICAL 必写 `flex-direction: column`;勿凭视觉猜方向 | ||
| - 标记 `rule: 'R18'` + `expected: 'VERTICAL→column / HORIZONTAL→row'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级(子视角 layoutMode↔flex-direction) | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/cssMatch.mjs | ||
| - rules/R19-padding.md(同为 autolayout 容器忠实度) |
| # R19 - padding(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(padding 仅在 autolayout 容器有意义)→ 不适用 | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底 | ||
| - CSS padding 含非 px 值(%/auto/var/calc)→ 放弃比对(不误报) | ||
| ## 触发条件 | ||
| 设 Figma 期望 padding = `[paddingTop, paddingRight, paddingBottom, paddingLeft] × scale`,容差 2px。 | ||
| **任一命中**: | ||
| 1. Figma 四边 padding 均为 0,但产物写了非 0 padding → **凭空捏造** | ||
| 2. Figma 有 padding,但产物**未写** padding → 漏写 | ||
| 3. 产物 padding 某边与 Figma×scale **相差 > 2px** → 数值错 | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| - CSS padding 四值 ≈ `Figma paddingT/R/B/L × scale` | ||
| - Figma pad0 → 产物**不写** padding(或显式 0) | ||
| - 支持 shorthand(`padding: 0 82px 10px 82px`)与 longhand(`padding-left` 等),longhand 覆盖 shorthand 对应边 | ||
| - **无单位 0 合法**(`padding: 0 12px` 里的 `0` 按 0px 处理) | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ small-card-top 对应 Figma Frame 764 四边 padding 全 0,却凭空加了 0 12px */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| padding: 0 12px; /* ← Figma pad0,凭空捏造 */ | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:221", "layoutMode": "VERTICAL", | ||
| "paddingTop": 0, "paddingRight": 0, "paddingBottom": 0, "paddingLeft": 0 } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ Figma pad0 → 不写 padding */ | ||
| .page { | ||
| &__small-card-top { display: flex; flex-direction: column; } | ||
| } | ||
| /* ✅ Figma pad=[0,41,5,41],scale=2 → padding: 0 82px 10px 82px */ | ||
| .page { | ||
| &__btn-q { display: flex; padding: 0 82px 10px 82px; } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:内容框内边距多/少,元素错位、挤压或留白异常 | ||
| - **典型事故**:v1.1.0 test13 — small-card-top(Figma pad0)凭空加 `padding: 0 12px`;btn-q(Figma pad=[0,41,5,41])漏写 padding | ||
| ## 与其他规则的关系 | ||
| - **R18 flex-direction**:同为 autolayout 容器忠实度,成对使用 | ||
| - **R13 unit-scale**:R19 的期望值走 `Figma padding × scale`,与 R13 换算口径一致 | ||
| - **§5.1.1**:靠 data-node-id 绑定,模板项挂代表项 id | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 autolayout 容器,读 `paddingTop/Right/Bottom/Left` | ||
| - 提示 UI sub-agent:padding 从 Figma 读,勿凭视觉估;Figma 0 就别写 | ||
| - 标记 `rule: 'R19'` + `expected: 'padding = Figma四边×scale'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级(父视角 padding) | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/cssMatch.mjs | ||
| - rules/R18-flex-direction.md |
| # R20 - absolute-position(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutPositioning !== 'ABSOLUTE'`(未脱离父顺流)→ 不适用 | ||
| - `name` 以 `fixed-` 开头 → 跳过(走 constraints 视口定位,R01 域,非 bbox 相对定位) | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底 | ||
| - 节点或父节点缺 `absoluteBoundingBox` → 无法精确计算,不误报 | ||
| ## 触发条件 | ||
| 设 `scale` = `config.unit.scale`(默认 2),容差 4px,父 = `cache.nodes[node._parentId]`: | ||
| - 期望 `left = (node.bbox.x − parent.bbox.x) × scale` | ||
| - 期望 `top = (node.bbox.y − parent.bbox.y) × scale` | ||
| **任一命中**: | ||
| 1. 产物写了 `top`/`left` 但与期望**相差 > 4px** → 坐标错(典型:靠猜) | ||
| 2. 期望值**非 0**(|exp| > 4)但产物**缺** `top`/`left` → 丢了真实偏移 | ||
| 命中 → 违规。 | ||
| > **容忍**:期望值 ≈ 0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,不报(避免噪声)。支持 `top: 0` 无单位零与 `inset` 简写。 | ||
| ## 期望产物 | ||
| - ABSOLUTE 子节点:`position: absolute` + `top`/`left` = `(子bbox − 父bbox) × scale` | ||
| - 父容器加 `position: relative` | ||
| - **能从 bbox 精确算出的坐标,禁止靠猜 + 「需人工核对」兜底**(§6.0.2 已封该逃逸口) | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ img-huochepiao 相对父 Frame 760:真值 left=-5/top=-6.5(×2=-10/-13,溢出到背景上) | ||
| 产物却写正值 40/40,方向完全反 */ | ||
| .page { | ||
| &__screen-piao { | ||
| position: absolute; | ||
| top: 40px; /* ← 应 -13px */ | ||
| left: 40px; /* ← 应 -10px */ | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:440", "name": "img-huochepiao", "layoutPositioning": "ABSOLUTE", | ||
| "absoluteBoundingBox": { "x": -4016, "y": 248 }, | ||
| "_parentId": "211:102" } // 父 Frame 760 bbox.x=-4011, y=254.5 | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ left=(-4016−(-4011))×2=-10;top=(248−254.5)×2=-13 */ | ||
| .page { | ||
| &__screen { position: relative; } /* 父加 relative */ | ||
| &__screen-piao { | ||
| position: absolute; | ||
| top: -13px; | ||
| left: -10px; | ||
| } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:绝对定位元素位置错乱(本该溢出到背景上的贴纸缩进容器内 / 偏移丢失贴边错位) | ||
| - **典型事故**:v1.1.0 test13 — img-huochepiao 坐标靠猜写 40/40(应 -10/-13),agent 用「需人工核对」兜底交付 | ||
| ## 与其他规则的关系 | ||
| - **R01 fixed-position**:`fixed-` 前缀走 constraints 视口定位,由 R01 管;R20 只管非 fixed 的 `layoutPositioning: ABSOLUTE` | ||
| - **§6.0.2**:R20 覆盖的坐标是"可机械计算量",禁用「需人工核对」豁免 | ||
| - **§5.1.1**:靠 data-node-id 绑定 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `layoutPositioning === 'ABSOLUTE'` 且非 `fixed-` 的节点 | ||
| - 提示 UI sub-agent:top/left 用 `(子bbox − 父bbox) × scale` 算,勿目测;父加 `position: relative` | ||
| - 标记 `rule: 'R20'` + `expected: 'top/left=(子bbox−父bbox)×scale'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级第 0 条(`layoutPositioning: ABSOLUTE`) | ||
| - SKILL.md §6.0.2(禁「需人工核对」用于可计算量) | ||
| - rules/R01-fixed-position.md | ||
| - bin/lib/loadCache.mjs(`_parentId` 供父 bbox 查询) |
| # R21 - node-id-coverage(v1.2.1 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅(优先级最高——节点无 node-id 则其余绑定类规则全失效) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `_inBakedSubtree`(bg-/img- 整体切图 或 x- 忽略子树,本就不出独立 DOM)→ 跳过 | ||
| - `_hidden`(不渲染)→ 跳过 | ||
| - `_templateDup`(`.map()` 数据副本,只需代表项挂 id)→ 跳过 | ||
| - `name` 前缀 `bg-` / `bgc-` / `x-`(自身不生成独立 DOM:bg/bgc 挂父,x 忽略)→ 跳过 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. 节点"应生成独立 DOM"(满足任一): | ||
| - `type === 'TEXT'` | ||
| - autolayout 容器:`layoutMode ∈ {HORIZONTAL, VERTICAL}` | ||
| - `layoutPositioning === 'ABSOLUTE'`(需 R20 校验坐标) | ||
| - `name` 前缀 `img-` / `btn-` / `input-`(生成 `<img>`/`<button>`/`<input>`) | ||
| 2. 产物 JSX 中**找不到**该节点的 `data-node-id="<nodeId>"`(跨行/属性顺序任意) | ||
| 命中 → 违规(不可追溯)。 | ||
| ## 期望产物 | ||
| - 凡承载 Figma 语义、会渲染成 DOM 的节点,产物元素必须带 `data-node-id="<nodeId>"` | ||
| - `.map()` 模板项:用**代表项(列表第一个同构兄弟 = variant a)**的 nodeId 挂在模板元素上;副本(variant b/c)已被 `_templateDup` 跳过,只校验代表项 | ||
| - 详见 SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| ## 为什么必须机械强制 | ||
| 没有 node-id,R06(字色)/R18(flex 方向)/R19(padding)/R20(绝对坐标)在 `classMap[nodeId]` 为空时只能 `continue` → **对账规则集体失灵**。若只把"挂 id"写成文档铁律,agent 仍可漏挂 → bug 静默逃逸(典型 test13:`.small-card-top` 无 data-node-id,flex 方向反 + 幻觉 padding 直接上线,R18/R19 绑定不上没拦住)。R21 把"应渲染却无 id"本身变成硬违规,从机制上堵死这条逃逸。 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ .map() 模板:容器与文字都没挂 data-node-id → 逃出 R18/R19/R06 校验 */} | ||
| {CARDS.map((c) => ( | ||
| <div className="page__small-card" key={c.key}> | ||
| <div className="page__small-card-top"> {/* 无 data-node-id */} | ||
| <span className="page__small-card-title">{c.title}</span> {/* 无 data-node-id */} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ 模板挂代表项(variant a)的 nodeId;副本由 _templateDup 跳过 */} | ||
| {CARDS.map((c) => ( | ||
| <div className="page__small-card" data-node-id="211:218" key={c.key}> | ||
| <div className="page__small-card-top" data-node-id="211:221"> | ||
| <span className="page__small-card-title" data-node-id="211:227">{c.title}</span> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:节点不可追溯 → 对账规则绑定不上 → 布局/字色/坐标错误无人拦截,静默上线 | ||
| - **典型事故**:v1.1.0 test13 — `.map()` 模板的 small-card-top / 文字节点全无 data-node-id,R18/R19 无法校验,flex 方向反 + 幻觉 padding 逃逸 | ||
| ## 与其他规则的关系 | ||
| - **§5.1.1 data-node-id 全覆盖铁律**:R21 是该铁律的机械执行体 | ||
| - **R06**:R06 只校验"可追溯 TEXT 的字色";"TEXT 不可追溯"由 R21 统一报,二者不双报同一节点 | ||
| - **R18/R19/R20**:都靠 data-node-id 绑定;R21 保证绑定前提成立 | ||
| - **_templateDup**:R21 只校验代表项,副本跳过——与"模板挂代表项 id"策略配套 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里"应渲染"节点(TEXT / autolayout 容器 / ABSOLUTE / img-·btn-·input-) | ||
| - 排除 baked/hidden/templateDup 与 bg-/bgc-/x- 前缀 | ||
| - 提示 UI sub-agent:每个这类节点必挂 data-node-id,模板挂代表项 id | ||
| - 标记 `rule: 'R21'` + `expected: '应渲染节点必挂 data-node-id'` | ||
| ## 相关 | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/loadCache.mjs(`_inBakedSubtree`/`_hidden`/`_templateDup` 标注) | ||
| - rules/R18-flex-direction.md / R19-padding.md / R20-absolute-position.md(依赖 R21 保证的可追溯前提) |
| # pp-d2c 规则库 | ||
| > pp-d2c skill 硬性规则的原始定义。当 rules/*.md 内容与 SKILL.md 冲突时以 rules/ 为准。 | ||
| ## 内置前缀常量表(硬编码,不可配置) | ||
| Figma 图层名前缀是**内置常量**,写死在 skill 里,不再从 config 读取。所有规则(SKILL / rules/ / check-rules.mjs / Rule-Scan sub-agent / UI sub-agent)一律用下表值: | ||
| | 前缀 | 语义 | | ||
| |---|---| | ||
| | `sub-` | 分块边界(sub-agent 派发单元) | | ||
| | `block-` | 独立布局块(命名空间隔离) | | ||
| | `img-` | 图片内容(生成 `<img>`,不递归) | | ||
| | `bg-` | 背景图(挂父容器 background-image,自身不生成 DOM) | | ||
| | `bgc-` | 背景纯色(全套盒级 CSS 写父,自身不生成 DOM) | | ||
| | `btn-` | 可点击区域(永远 CSS 化) | | ||
| | `scrollx-` | 横向滚动容器 | | ||
| | `scrolly-` | 纵向滚动容器 | | ||
| | `fixed-` | 视口固定定位(修饰前缀) | | ||
| | `end-` | 逆向布局(贴父末端,修饰前缀) | | ||
| | `input-` | 输入框(生成 `<input type="text">`,不递归) | | ||
| | `x-` | 忽略(跳过整层,优先级最高) | | ||
| **pp-d2c.config.json 里不再有 `layers` 段**——之前的 `layers.sub` / `layers.bg` / `layers.but` 等映射都已删除。 | ||
| ## 索引表 | ||
| | ID | 名称 | 判定归属 | 一句话触发条件 | | ||
| |---|---|---|---| | ||
| | R01 | fixed-position | 硬防线 | `name.startsWith('fixed-')` | | ||
| | R02 | fills-image | 硬防线 | `fills[].some(f => f.type === 'IMAGE' && f.visible !== false)` | | ||
| | R03 | implicit-image | 软防线 | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 | | ||
| | R04 | text-gradient | 软防线 | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` | | ||
| | R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` | | ||
| | R06 | text-solid-last | 硬防线 | `TEXT` 节点,fills 末位可见 = `SOLID` | | ||
| | R07 | multi-fills | 软防线 | fills 数组多层可见(≥2),且不全是 SOLID | | ||
| | R08 | bg-landing-form | 硬防线 | `name.startsWith('bg-')` 或 `name === 'bg'`,产物落地形态错 | | ||
| | R09 | btn-bgc-取值 | 软防线 | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE | | ||
| | R10 | no-fake-solid-color | 软防线 | 产物 CSS 出现 `color: #XXX`,但 cache 里对应节点找不到源头 | | ||
| | R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector,CSS 表达不了 → 应切图 | | ||
| | R12 | flat-mode-naming | 软防线 | `merge.mode === 'flat'` 下类名跨 block 冲突 | | ||
| | R13 | unit-scale | 软防线 | Figma px → 产物 px 未换算(应 `outputBase / figmaBase`) | | ||
| | R14 | fixed-z-index | 软防线 | 多个 `fixed-` 节点,z-index 未递增 | | ||
| | R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点,展开成重复代码而非 `.map()` | | ||
| | R16 | no-flatten-text | 硬防线 | GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT 且前缀非 `img-`/`bg-`,产物 jsx 出现 `<img data-node-id="该节点">` | | ||
| | R17 | no-baked-dom | 硬防线 | 节点 `_inBakedSubtree`(处于 bg-/bgc-/img-/x- 整体切图子树内),产物却有其 `data-node-id`(双重渲染) | | ||
| | R18 | flex-direction | 硬防线 | autolayout 容器 `layoutMode` 与产物 `flex-direction` 不符(VERTICAL 却非 column / HORIZONTAL 却 column) | | ||
| | R19 | padding | 硬防线 | autolayout 容器 padding 与 `Figma paddingT/R/B/L × scale` 不符(凭空加 / 漏写 / 数值错) | | ||
| | R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale | | ||
| | R21 | node-id-coverage | 硬防线 | 应渲染节点(TEXT/autolayout 容器/ABSOLUTE/img-·btn-·input-)在产物 JSX 里找不到 data-node-id | | ||
| ## 判定归属说明 | ||
| **硬防线** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R05 / R06 / R08 / R16 / R17 / R18 / R19 / R20 / R21。 | ||
| **软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R03 / R04 / R07 / R09 / R10 / R11 / R12 / R13 / R14 / R15。 | ||
| **v1.2.0 对账基座**: R02 / R06 / R17 / R18 / R19 / R20 依赖 `bin/lib/loadCache.mjs` 标注的 `_inBakedSubtree`(整体切图子树)/`_hidden`(隐藏)/`_templateDup`(`.map()` 数据副本),以及 `bin/lib/cssMatch.mjs` 的 SCSS `&__foo` 嵌套匹配。这些标注把"整体切图子树 / 隐藏 / 列表副本 / 嵌套写法"四类假阳性从根源清除,使硬防线报数即真值,校验从"黑名单抽查"升级为"以 cache 为真值逐节点对账"。 | ||
| ## 使用方式 | ||
| ### Rule-Scan sub-agent | ||
| 派发时的完整 prompt: | ||
| ``` | ||
| 你是 Rule-Scan sub-agent, 只做规则识别, 不写 UI 代码. | ||
| 任务: | ||
| 1. Read templates/skills/pp-d2c/rules/*.md (全部 19 条) | ||
| 2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON | ||
| 3. 对本 block 的每个节点, 判断命中了哪些规则 | ||
| 4. 输出 rule-hits.json (schema 见附) | ||
| 规则命中判定原则: | ||
| - 硬防线规则 (R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21): 你也扫,即使 check-rules.mjs 会兜底 | ||
| - 软防线规则 (R03/R04/R07/R09-R15): 你是唯一识别方 | ||
| - 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列 | ||
| - 优先级 (由高到低): R21 > R16 > R17 > R02 > R01 > R05 > R11 > R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10(R21 最高:节点不可追溯则其余绑定类规则无从谈起) | ||
| 输出要求: | ||
| - 每个 hit 包含 nodeId / rule / trigger 描述 / expected 描述 / context (关键 JSON 字段抽样) | ||
| - 输出 JSON, 不带 markdown 代码块围栏, 不加解释文字 | ||
| - 落盘到 blocks/{sub}/rule-hits.json | ||
| 禁止: | ||
| - 不允许写 JSX / SCSS | ||
| - 不允许改 cache 文件 | ||
| - 不允许基于"设计意图猜测"命中规则; 只按 rules/*.md "触发条件" 字面判定 | ||
| ``` | ||
| ### UI sub-agent | ||
| - Read `blocks/{sub}/rule-hits.json` 里涉及的 R0X.md,按"期望产物"落地 | ||
| - 生完 JSX + SCSS 后跑: | ||
| ```bash | ||
| node .claude/skills/pp-d2c/bin/check-rules.mjs \ | ||
| --block blocks/{sub}/ \ | ||
| --cache-key <fileKey> | ||
| ``` | ||
| - exit 0 继续 / exit 1 按 violations 回滚重做 / exit 2 报环境错 | ||
| - `assets.txt` 追加"rule-hits 消费证明"块(格式见下) | ||
| **rule-hits 消费证明格式**: | ||
| ``` | ||
| ## rule-hits 消费证明 (v1.0.0) | ||
| - 输入 rule-hits 条数: N | ||
| - 处理到位条数: M (M == N 时 ✅) | ||
| - 处理列表: | ||
| - { nodeId, rule: "R0X", 落地类型: "css 属性" | "span 包裹" | "切图挂父" | ... } | ||
| - 遗漏补捕: K 条 | ||
| - [遗漏补捕] R0X {nodeId} "{name}": Rule-Scan 未识别, 自动补齐落地 = {做了什么} | ||
| - check-rules.mjs 通过: ✅ / ❌ + violations 列表 | ||
| ``` | ||
| ### check-rules.mjs | ||
| - **硬编码 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20 逻辑**,rules/*.md 是设计文档,不是执行文档 | ||
| - 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...` | ||
| - 详细 CLI 见 `templates/skills/pp-d2c/bin/check-rules.mjs --help` | ||
| ## 排斥关系图 | ||
| ``` | ||
| R02 (fills-image) ─┬─► R11 (mask-vector-css-able): 已切图不再判 CSS-able | ||
| └─► R09 (btn-bgc): btn 内 fills IMAGE 走 R09 优先 | ||
| R01 (fixed-position) ── R14 (fixed-z-index): 多个 fixed 才判 R14 | ||
| R06 (text-solid-last) ─┬─► R04 (text-gradient): 末位是 GRADIENT/IMAGE 归 R04 | ||
| └─► R10 (no-fake-solid-color): R06 命中即已核对色源 | ||
| R05 (space-between) ── (无排斥) | ||
| R08 (bg-landing-form) ── (无排斥,反向匹配所以自成一体) | ||
| R03 (implicit-image) ── R11 (mask-vector-css-able): R03 覆盖后者的常见形态 | ||
| R12 (flat-mode-naming) ── (无排斥,只影响 merge.mode='flat') | ||
| R13 (unit-scale) ── (无排斥,单位) | ||
| R15 (同构 map) ── (无排斥,结构) | ||
| R16 (no-flatten-text) ── 最高优先级; 命中 R16 时 R02/R06 若源自同一"整体切图"违规则视为衍生, 不重复报 | ||
| R17 (no-baked-dom) ── 与 R16 配套(压平 vs 拆两面); R02/R06 跳过 _inBakedSubtree 节点(不报"缺 url/color"), "禁 DOM" 交 R17 正向兜底 | ||
| R18 (flex-direction) ─┬─ 与 R19 成对(autolayout 容器忠实度); 靠 data-node-id 绑定, 模板项挂代表项 id | ||
| R19 (padding) ────────┘ | ||
| R20 (absolute-position) ── 排斥 fixed-(那走 R01/constraints); 只管非 fixed 的 layoutPositioning:ABSOLUTE | ||
| R21 (node-id-coverage) ── 最高优先级; 节点无 data-node-id 则 R06/R18/R19/R20 全绑定不上, 先补 id 再谈其余; 排斥 baked/hidden/templateDup 与 bg-/bgc-/x-(不生成独立 DOM) | ||
| ``` | ||
| ## 版本 | ||
| - **v1.2.1** `_inBakedSubtree` 移除 bgc-(bgc- 盒级 CSS 写父、非切图,子孙误放 TEXT 应被 R06/R21 暴露而非静默吞); 新增 **R21 node-id-coverage**(应渲染节点漏挂 data-node-id 即 exit 1,机械强制 §5.1.1 铁律,堵 R18/R19/R20 遇空 classMap 静默 continue 的逃逸); §6.0.2 禁生成流程用 `--force-skip` | ||
| - **v1.2.0** 校验范式从"黑名单抽查"→"以 cache 为真值逐节点对账": loadCache 标注 `_inBakedSubtree`/`_hidden`/`_templateDup` + cssMatch 共享 SCSS 嵌套匹配(R02/R06 假阳性根源清除); 新增 R17 no-baked-dom / R18 flex-direction / R19 padding / R20 absolute-position 四条对账规则 | ||
| - v1.1.0 新增 R16 no-flatten-text 硬防线 | ||
| - v1.0.0 首次引入 rules/ 目录 + check-rules.mjs | ||
| - v0.3.21 之前:硬规则文字散落在 SKILL.md §4.3 等章节 | ||
| ## 相关 | ||
| - `templates/skills/pp-d2c/SKILL.md` — 主流程 | ||
| - `templates/skills/pp-d2c/bin/check-rules.mjs` — 硬防线脚本 | ||
| - `.Knowledge/req-docs/pp-d2c-rule-scan_技术方案.md` — 本轮技术方案 | ||
| - `.Knowledge/req-docs/pp-d2c-rule-scan_需求澄清.md` — 需求澄清 |
| #!/usr/bin/env python3 | ||
| """pp-image-compress: 无损压缩指定文件夹下的 PNG / JPEG 图片。 | ||
| 用法: | ||
| python compress.py <folder> # 压缩到 <folder>/compressed/ | ||
| python compress.py <folder> --dry-run # 预览命中文件与预估节省 | ||
| python compress.py <folder> --recursive | ||
| python compress.py <folder> --overwrite # 直接原地覆盖(不推荐) | ||
| python compress.py <folder> --out <dir> # 自定义输出目录 | ||
| 无损策略: | ||
| - PNG : Pillow save(optimize=True, compress_level=9) 真·位级无损 | ||
| - JPEG: Pillow save(quality='keep', optimize=True, progressive=True) | ||
| 保留原有量化表, 不重新有损编码; 仅重排熵编码, 是 JPEG 层面的无损 | ||
| 依赖: pip install Pillow | ||
| """ | ||
| from __future__ import annotations | ||
| import argparse | ||
| import io | ||
| import sys | ||
| from pathlib import Path | ||
| try: | ||
| from PIL import Image, ImageFile | ||
| except ImportError: | ||
| print("[pp-image-compress] 缺少依赖 Pillow, 请先运行: pip install Pillow", file=sys.stderr) | ||
| sys.exit(2) | ||
| ImageFile.LOAD_TRUNCATED_IMAGES = False | ||
| PNG_EXTS = {".png"} | ||
| JPEG_EXTS = {".jpg", ".jpeg"} | ||
| SUPPORTED = PNG_EXTS | JPEG_EXTS | ||
| def human(n: int) -> str: | ||
| step = 1024.0 | ||
| for unit in ("B", "KB", "MB", "GB"): | ||
| if abs(n) < step: | ||
| return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}{unit}" | ||
| n /= step | ||
| return f"{n:.1f}TB" | ||
| def compress_png(src: Path) -> bytes: | ||
| with Image.open(src) as im: | ||
| im.load() | ||
| buf = io.BytesIO() | ||
| save_kwargs = {"format": "PNG", "optimize": True, "compress_level": 9} | ||
| if "icc_profile" in im.info: | ||
| save_kwargs["icc_profile"] = im.info["icc_profile"] | ||
| im.save(buf, **save_kwargs) | ||
| return buf.getvalue() | ||
| def compress_jpeg(src: Path) -> bytes: | ||
| with Image.open(src) as im: | ||
| im.load() | ||
| buf = io.BytesIO() | ||
| save_kwargs = { | ||
| "format": "JPEG", | ||
| "quality": "keep", | ||
| "optimize": True, | ||
| "progressive": True, | ||
| } | ||
| if "icc_profile" in im.info: | ||
| save_kwargs["icc_profile"] = im.info["icc_profile"] | ||
| if "exif" in im.info: | ||
| save_kwargs["exif"] = im.info["exif"] | ||
| im.save(buf, **save_kwargs) | ||
| return buf.getvalue() | ||
| def gather(folder: Path, recursive: bool) -> list[Path]: | ||
| it = folder.rglob("*") if recursive else folder.iterdir() | ||
| files: list[Path] = [] | ||
| for p in it: | ||
| if not p.is_file(): | ||
| continue | ||
| if p.suffix.lower() not in SUPPORTED: | ||
| continue | ||
| parts = set(p.relative_to(folder).parts) | ||
| if "compressed" in parts or ".backup" in parts: | ||
| continue | ||
| files.append(p) | ||
| return sorted(files) | ||
| def process_one(src: Path) -> bytes | None: | ||
| ext = src.suffix.lower() | ||
| try: | ||
| if ext in PNG_EXTS: | ||
| return compress_png(src) | ||
| if ext in JPEG_EXTS: | ||
| return compress_jpeg(src) | ||
| except Exception as e: | ||
| print(f" ! 跳过 {src}: {e}", file=sys.stderr) | ||
| return None | ||
| def main() -> int: | ||
| ap = argparse.ArgumentParser(description="Lossless PNG/JPEG compression (Pillow-only).") | ||
| ap.add_argument("folder", help="目标图片文件夹") | ||
| ap.add_argument("--dry-run", "-n", action="store_true", help="仅预览, 不写盘") | ||
| ap.add_argument("--recursive", "-r", action="store_true", help="递归子目录") | ||
| ap.add_argument("--overwrite", action="store_true", help="原地覆盖(不推荐, 不与 --out 兼用)") | ||
| ap.add_argument("--out", default=None, help="自定义输出目录 (默认 <folder>/compressed)") | ||
| args = ap.parse_args() | ||
| folder = Path(args.folder).expanduser().resolve() | ||
| if not folder.is_dir(): | ||
| print(f"[pp-image-compress] 目录不存在: {folder}", file=sys.stderr) | ||
| return 2 | ||
| if args.overwrite and args.out: | ||
| print("[pp-image-compress] --overwrite 与 --out 互斥", file=sys.stderr) | ||
| return 2 | ||
| out_dir = None | ||
| if not args.overwrite: | ||
| out_dir = Path(args.out).expanduser().resolve() if args.out else (folder / "compressed") | ||
| files = gather(folder, args.recursive) | ||
| print(f"[pp-image-compress] folder : {folder}") | ||
| print(f"[pp-image-compress] recursive : {args.recursive}") | ||
| print(f"[pp-image-compress] mode : {'dry-run' if args.dry_run else ('overwrite' if args.overwrite else 'copy-out')}") | ||
| if out_dir: | ||
| print(f"[pp-image-compress] output : {out_dir}") | ||
| print(f"[pp-image-compress] candidates : {len(files)}") | ||
| if not files: | ||
| return 0 | ||
| total_before = 0 | ||
| total_after = 0 | ||
| hit = 0 | ||
| skipped_no_gain = 0 | ||
| for src in files: | ||
| rel = src.relative_to(folder) | ||
| before = src.stat().st_size | ||
| data = process_one(src) | ||
| if data is None: | ||
| continue | ||
| after = len(data) | ||
| total_before += before | ||
| # 只有真的变小才算命中; JPEG quality=keep 有时会略大, 那种就保持原图 | ||
| if after >= before: | ||
| total_after += before | ||
| skipped_no_gain += 1 | ||
| print(f" = {rel} {human(before)} → {human(after)} (no gain, 保留原图)") | ||
| if not args.dry_run and not args.overwrite: | ||
| # copy-out 模式下, 无收益的也复制一份原图, 保证 compressed/ 是完整可替换的副本 | ||
| dst = out_dir / rel | ||
| dst.parent.mkdir(parents=True, exist_ok=True) | ||
| dst.write_bytes(src.read_bytes()) | ||
| continue | ||
| total_after += after | ||
| hit += 1 | ||
| saved = before - after | ||
| pct = saved * 100.0 / before | ||
| print(f" · {rel} {human(before)} → {human(after)} -{human(saved)} ({pct:.1f}%)") | ||
| if args.dry_run: | ||
| continue | ||
| if args.overwrite: | ||
| src.write_bytes(data) | ||
| else: | ||
| dst = out_dir / rel | ||
| dst.parent.mkdir(parents=True, exist_ok=True) | ||
| dst.write_bytes(data) | ||
| saved_total = total_before - total_after | ||
| pct_total = (saved_total * 100.0 / total_before) if total_before else 0.0 | ||
| print("") | ||
| print(f"[pp-image-compress] processed : {len(files)}") | ||
| print(f"[pp-image-compress] compressed : {hit}") | ||
| print(f"[pp-image-compress] no-gain : {skipped_no_gain}") | ||
| print(f"[pp-image-compress] before : {human(total_before)}") | ||
| print(f"[pp-image-compress] after : {human(total_after)}") | ||
| print(f"[pp-image-compress] saved : {human(saved_total)} ({pct_total:.1f}%)") | ||
| if args.dry_run: | ||
| print("[pp-image-compress] dry-run 完成, 未写盘") | ||
| return 0 | ||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
| # pp-image-compress Skill | ||
| > 无损压缩指定文件夹下的 PNG / JPEG 图片,输出到 `<folder>/compressed/` 子目录,原图不动。**纯 Python + Pillow 方案**,零系统依赖。 | ||
| ## 触发条件 | ||
| - 用户输入图片文件夹路径 + 明确要求"压缩图片 / 无损压缩 / 优化图片体积" | ||
| - 用户说:「帮我压一下这个文件夹的图片」「无损压缩这批图」「优化 assets 体积」 | ||
| - 直接 `$pp-image-compress <folder>` | ||
| ## 前置依赖 | ||
| ```bash | ||
| pip install Pillow | ||
| ``` | ||
| 未安装会退出码 2 并提示。 | ||
| ## 执行流程 | ||
| ### 步骤 0:确认目标文件夹 | ||
| 从用户输入拿到图片目录的绝对/相对路径。**默认只扫顶层**,需要递归时加 `--recursive`。 | ||
| **禁止**在没有明确路径时假设默认目录(不同于 pp-strip-nodeid,这个 skill 没有配置文件兜底)。 | ||
| ### 步骤 1:先跑 dry-run 预览 | ||
| **必须**先跑 dry-run,让用户看清将处理多少文件、预计节省多少体积: | ||
| ```bash | ||
| python .claude/skills/pp-image-compress/compress.py <folder> --dry-run | ||
| ``` | ||
| 输出示例: | ||
| ``` | ||
| [pp-image-compress] folder : /abs/path/to/imgs | ||
| [pp-image-compress] recursive : False | ||
| [pp-image-compress] mode : dry-run | ||
| [pp-image-compress] output : /abs/path/to/imgs/compressed | ||
| [pp-image-compress] candidates : 12 | ||
| · hero.png 482.3KB → 361.7KB -120.6KB (25.0%) | ||
| · icon.png 8.2KB → 6.1KB -2.1KB (25.6%) | ||
| = photo.jpg 1.2MB → 1.2MB (no gain, 保留原图) | ||
| ... | ||
| [pp-image-compress] processed : 12 | ||
| [pp-image-compress] compressed : 10 | ||
| [pp-image-compress] no-gain : 2 | ||
| [pp-image-compress] before : 8.4MB | ||
| [pp-image-compress] after : 6.1MB | ||
| [pp-image-compress] saved : 2.3MB (27.4%) | ||
| [pp-image-compress] dry-run 完成, 未写盘 | ||
| ``` | ||
| ### 步骤 2:用户确认后实际写盘 | ||
| 用户确认无误后,去掉 `--dry-run` 重跑: | ||
| ```bash | ||
| python .claude/skills/pp-image-compress/compress.py <folder> | ||
| ``` | ||
| **默认行为**:写入 `<folder>/compressed/`,保持相对子路径。原图保持原样不动。 | ||
| **同名保留**:文件名与相对路径完全保持一致,方便一键替换原目录(`cp -r compressed/. .`)。 | ||
| ### 步骤 3:产出摘要 | ||
| 脚本自身会打印:候选数、命中数、无收益数、压缩前后总体积、节省百分比。Agent 侧只需转述关键指标并建议下一步(例如"确认无误后可用 `cp -r <folder>/compressed/. <folder>/` 覆盖原图,或用 git 复核 diff")。 | ||
| ## 参数 | ||
| | 参数 | 说明 | | ||
| |------|------| | ||
| | `<folder>` | **必填**。目标图片文件夹(绝对或相对路径) | | ||
| | `--dry-run` / `-n` | 只预览,不写盘 | | ||
| | `--recursive` / `-r` | 递归处理子目录 | | ||
| | `--out <dir>` | 自定义输出目录(默认 `<folder>/compressed/`) | | ||
| | `--overwrite` | 原地覆盖原图(危险,与 `--out` 互斥;仅在原图已有别的备份时使用) | | ||
| ## 无损策略说明 | ||
| - **PNG**:`Pillow.save(optimize=True, compress_level=9)` — deflate 最高档 + 熵优化,**像素级完全无损**,仅重排压缩流。ICC profile 保留。 | ||
| - **JPEG**:`Pillow.save(quality='keep', optimize=True, progressive=True)` — 保留原图量化表,**不重编码**,仅重排 Huffman 熵编码为渐进式。这是 JPEG 层面的无损(像素一致性依赖原有量化表)。ICC / EXIF 保留。 | ||
| **收益预期**: | ||
| - PNG:典型 5%–30%(已经 optipng 过的会没收益) | ||
| - JPEG:典型 2%–10%(相机原片通常收益更大,已优化过的接近 0) | ||
| **无收益保护**:某些图片压缩后反而变大(罕见但存在),脚本自动保留原图字节,输出 `no gain, 保留原图`。copy-out 模式下 compressed/ 里放的仍是原图字节,保证目录整体可替换。 | ||
| ## 支持范围 | ||
| - ✅ `.png` — Pillow 处理 | ||
| - ✅ `.jpg` / `.jpeg` — Pillow 处理 | ||
| - ❌ 其他扩展名(`.webp` / `.gif` / `.svg`)当前版本不处理,遇到自动跳过 | ||
| 自动跳过:`compressed/`、`.backup/` 子目录(避免重复处理)。 | ||
| ## 禁止 | ||
| - 禁止跳过 dry-run 直接写盘:即使默认输出到 `compressed/` 不改原图,也必须先给用户看命中列表与预估节省,避免对着 `~/Downloads` 或错误目录跑几千张图 | ||
| - 禁止用 `--overwrite` 作为默认路径:这个参数只在用户明确说"原地压缩 / 覆盖 / 我有别的备份"时才允许,其它情况下必须走默认的 `compressed/` 输出 | ||
| - 禁止对 `node_modules/`、`.git/`、`dist/`、`build/` 里的图片跑压缩:调用前 Agent 需要判断路径合理性,用户若给了工程根目录就要反问确认 | ||
| - 禁止修改 quality 参数为具体数字(例如 `quality=85`):那会把 JPEG 从"无损重编码"变成"有损重编码",违反 skill 语义。真需要有损压缩请另开 skill |
+0
-29
@@ -699,32 +699,3 @@ #!/usr/bin/env node | ||
| }, | ||
| layers: { | ||
| sub: 'sub-', | ||
| block: 'block-', | ||
| img: 'img-', | ||
| bg: 'bg-', | ||
| bgColor: 'bgc-', | ||
| but: 'btn-', | ||
| scrollX: 'scrollx-', | ||
| scrollY: 'scrolly-', | ||
| fixed: 'fixed-', | ||
| end: 'end-', | ||
| input: 'input-', | ||
| ignore: 'x-', | ||
| ...(existing.layers || {}) | ||
| }, | ||
| output: { dir: outputDir }, | ||
| // rn 项目默认关闭 doctor(不接卫星 SKILL);react 项目保留完整 health 段 | ||
| health: framework === 'rn' | ||
| ? (existing.health || { enabled: false }) | ||
| : (existing.health || { | ||
| enabled: true, | ||
| blockOnError: true, | ||
| report: { markdown: true, json: true, dir: '' }, | ||
| thresholds: { | ||
| maxDepth: 6, subBlockMin: 3, subBlockMax: 20, totalNodesMax: 1500, | ||
| hiddenRatioMax: 0.2, paddingAsymmetryMax: 32, | ||
| bgSizeMin: 0.8, bgSizeMax: 1.2, colorDeltaEMin: 3 | ||
| }, | ||
| rules: {} | ||
| }), | ||
| // 【新增】rn 分支写 adapter 段;react 项目不写 | ||
@@ -731,0 +702,0 @@ ...(framework === 'rn' ? { adapter: adapterCfg } : {}) |
+1
-1
| { | ||
| "name": "@double-coding/pixel-print", | ||
| "version": "1.2.7", | ||
| "version": "1.3.0", | ||
| "description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)", | ||
@@ -5,0 +5,0 @@ "bin": { |
@@ -21,38 +21,5 @@ { | ||
| }, | ||
| "layers": { | ||
| "sub": "sub-", | ||
| "block": "block-", | ||
| "img": "img-", | ||
| "bg": "bg-", | ||
| "bgColor": "bgc-", | ||
| "but": "btn-", | ||
| "scrollX": "scrollx-", | ||
| "scrollY": "scrolly-", | ||
| "ignore": "x-" | ||
| }, | ||
| "output": { | ||
| "dir": "pages/" | ||
| }, | ||
| "health": { | ||
| "enabled": true, | ||
| "blockOnError": true, | ||
| "report": { | ||
| "markdown": true, | ||
| "json": true, | ||
| "dir": "" | ||
| }, | ||
| "thresholds": { | ||
| "maxDepth": 6, | ||
| "subBlockMin": 3, | ||
| "subBlockMax": 20, | ||
| "totalNodesMax": 1500, | ||
| "hiddenRatioMax": 0.2, | ||
| "paddingAsymmetryMax": 32, | ||
| "bgSizeMin": 0.8, | ||
| "bgSizeMax": 1.2, | ||
| "colorDeltaEMin": 3 | ||
| }, | ||
| "rules": {} | ||
| } | ||
| } | ||
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
798606
32.37%70
169.23%4082
97.77%18
50%6
50%