@double-coding/pixel-print
Advanced tools
+278
-99
@@ -10,22 +10,79 @@ #!/usr/bin/env node | ||
| const CONFIG_PATH = path.join(CWD, 'pp-d2c.config.json') | ||
| const MAPPINGS_PATH = path.join(CWD, 'code-connect/mappings.json') | ||
| // ─── argv 解析 ──────────────────────────────────────────────── | ||
| // 支持 --key value / --key=value 两种写法;bool 短开关(--yes)也接受但目前未使用。 | ||
| // 未知参数直接忽略,不报错(留给上游 pipeline 传扩展参数)。返回 { flag: value } 对象, | ||
| // 已知参数在 CLI_ARG_MAP 里显式列举,避免出现拼写错误默默失效。 | ||
| const CLI_ARG_MAP = { | ||
| 'framework': 'framework', // react | rn | ||
| 'style-format': 'styleFormat', // scss / scss-modules / less / less-modules / css / css-modules / tailwind / inline | ||
| 'adapter-preset': 'adapterPreset', // rn / taro / xtaro / off / custom | ||
| 'merge-mode': 'mergeMode', // flat | component | ||
| 'figma-token': 'figmaToken', // Figma Personal Access Token | ||
| 'figma-base': 'figmaBase', // 设计稿基准宽度(px) | ||
| 'output-unit': 'outputUnit', // px | vw | rem (仅 react) | ||
| 'output-base': 'outputBase', // 输出基准宽度 | ||
| 'responsive': 'responsive', // on | off (仅 rn) | ||
| 'assets-dir': 'assetsDir', // 图片输出目录 | ||
| 'image-base-url': 'imageBaseUrl', // 图片 base URL (仅 react) | ||
| 'output-dir': 'outputDir', // 代码输出目录 | ||
| 'rpx-helper-import': 'rpxHelperImport', // rpx helper import 路径 | ||
| 'rpx-helper-name': 'rpxHelperName', // rpx helper 导出函数名 | ||
| } | ||
| function parseArgs(argv) { | ||
| const out = {} | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const a = argv[i] | ||
| if (!a.startsWith('--')) continue | ||
| let key, value | ||
| const eq = a.indexOf('=') | ||
| if (eq > -1) { | ||
| key = a.slice(2, eq) | ||
| value = a.slice(eq + 1) | ||
| } else { | ||
| key = a.slice(2) | ||
| const next = argv[i + 1] | ||
| // 下一个 token 如果不是 -- 开头就当作值消费掉 | ||
| if (next !== undefined && !next.startsWith('--')) { | ||
| value = next | ||
| i++ | ||
| } else { | ||
| value = '' | ||
| } | ||
| } | ||
| const mapped = CLI_ARG_MAP[key] | ||
| if (mapped) out[mapped] = value | ||
| // 未知的 --xxx 静默忽略 | ||
| } | ||
| return out | ||
| } | ||
| // ─── 文件操作 ──────────────────────────────────────────────── | ||
| function copyFile(src, dest) { | ||
| // 复制统计:silent=true 时不逐文件打印,只累加计数,由上层统一汇总。 | ||
| // installFiles 用 silent 模式,把原来 5-10 行 overwrite/copy 日志收成一行"复制 N 个 skill 文件"。 | ||
| // 独立场景(如 install 命令)仍走 silent=false,逐行日志便于排查。 | ||
| const _copyStats = { copy: 0, overwrite: 0, skip: 0 } | ||
| function resetCopyStats() { _copyStats.copy = 0; _copyStats.overwrite = 0; _copyStats.skip = 0 } | ||
| function copyFile(src, dest, silent = false) { | ||
| const destDir = path.dirname(dest) | ||
| if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }) | ||
| if (fs.existsSync(dest)) { | ||
| console.log(` skip ${path.relative(CWD, dest)} (already exists)`) | ||
| _copyStats.skip++ | ||
| if (!silent) console.log(` skip ${path.relative(CWD, dest)} (already exists)`) | ||
| return | ||
| } | ||
| fs.copyFileSync(src, dest) | ||
| console.log(` copy ${path.relative(CWD, dest)}`) | ||
| _copyStats.copy++ | ||
| if (!silent) console.log(` copy ${path.relative(CWD, dest)}`) | ||
| } | ||
| function copyFileForce(src, dest) { | ||
| function copyFileForce(src, dest, silent = false) { | ||
| const destDir = path.dirname(dest) | ||
| if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }) | ||
| fs.copyFileSync(src, dest) | ||
| console.log(` overwrite ${path.relative(CWD, dest)}`) | ||
| _copyStats.overwrite++ | ||
| if (!silent) console.log(` overwrite ${path.relative(CWD, dest)}`) | ||
| } | ||
@@ -105,3 +162,3 @@ | ||
| function copyDir(srcDir, destDir, force = false) { | ||
| function copyDir(srcDir, destDir, force = false, silent = false) { | ||
| for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { | ||
@@ -111,5 +168,5 @@ const src = path.join(srcDir, entry.name) | ||
| if (entry.isDirectory()) { | ||
| copyDir(src, dest, force) | ||
| copyDir(src, dest, force, silent) | ||
| } else { | ||
| force ? copyFileForce(src, dest) : copyFile(src, dest) | ||
| force ? copyFileForce(src, dest, silent) : copyFile(src, dest, silent) | ||
| } | ||
@@ -149,10 +206,12 @@ } | ||
| function installFiles(forceSkills = false, skipConfig = false, options = {}) { | ||
| const { skipRn = false, skipH5 = false } = options | ||
| console.log('\npp-d2c: installing files...\n') | ||
| const { skipRn = false, skipH5 = false, silent = false } = options | ||
| if (!silent) console.log('\npp-d2c: installing files...\n') | ||
| resetCopyStats() | ||
| const skillsSrc = path.join(TEMPLATES_DIR, 'skills') | ||
| const skillsDst = path.join(CWD, '.claude/skills') | ||
| // pp-style 是 pp-d2c 的规则速查手册,pp-doctor 是静态体检 skill;两者当前无独立触发入口、 | ||
| // 没有工具调用能力、内容与主 SKILL 重复,默认不落到用户项目。需要时把它们从 | ||
| // pp 仓 templates/skills/ 手工 cp 过来即可 | ||
| // 没有工具调用能力、内容与主 SKILL 重复,**后期准备丢弃**,默认不落到用户项目。 | ||
| // 保留在 templates/skills/ 只为过渡期兼容,不建议引导用户手动启用。 | ||
| const OPT_IN_ONLY = new Set(['pp-style', 'pp-doctor']) | ||
| const installedSkills = [] | ||
| for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) { | ||
@@ -166,21 +225,47 @@ if (!entry.isDirectory()) continue | ||
| if (OPT_IN_ONLY.has(entry.name)) continue | ||
| copyDir(path.join(skillsSrc, entry.name), path.join(skillsDst, entry.name), forceSkills) | ||
| copyDir(path.join(skillsSrc, entry.name), path.join(skillsDst, entry.name), forceSkills, silent) | ||
| installedSkills.push(entry.name) | ||
| } | ||
| if (!skipConfig) { | ||
| copyFile(path.join(TEMPLATES_DIR, 'pp-d2c.config.json'), CONFIG_PATH) | ||
| copyFile(path.join(TEMPLATES_DIR, 'pp-d2c.config.json'), CONFIG_PATH, silent) | ||
| } | ||
| copyFile(path.join(TEMPLATES_DIR, 'code-connect/mappings.json'), MAPPINGS_PATH) | ||
| console.log('') | ||
| if (silent) { | ||
| // 汇总一行:动词按主要动作挑(overwrite > copy > skip),文件总数 + skill 列表 | ||
| const total = _copyStats.copy + _copyStats.overwrite + _copyStats.skip | ||
| const verb = _copyStats.overwrite > 0 ? '刷新' : _copyStats.copy > 0 ? '安装' : '跳过' | ||
| console.log(` ${verb} ${installedSkills.length} 个 skill(${installedSkills.join(' / ')}),共 ${total} 个文件到 .claude/skills/`) | ||
| } | ||
| if (!silent) console.log('') | ||
| } | ||
| // ─── 选择器(方向键 + 回车) ───────────────────────────────── | ||
| // choices 支持两种形态: | ||
| // 1) 字符串数组 ['a', 'b'] —— label 与 value 同源,无 hint | ||
| // 2) 对象数组 [{ value:'flat', hint:'单文件合并…(默认)' }] —— 值和展示文案解耦,hint 用灰色显示在选项右侧 | ||
| // 返回值永远是选中项的 value(字符串数组模式下 value === 字符串本身) | ||
| function normalizeChoices(choices) { | ||
| return choices.map(c => typeof c === 'string' ? { value: c, hint: '' } : { value: c.value, hint: c.hint || '' }) | ||
| } | ||
| function select(label, choices, defaultVal) { | ||
| return new Promise(resolve => { | ||
| let idx = Math.max(0, choices.indexOf(defaultVal)) | ||
| const norm = normalizeChoices(choices) | ||
| let idx = Math.max(0, norm.findIndex(c => c.value === defaultVal)) | ||
| if (idx < 0) idx = 0 | ||
| let rendered = false | ||
| // 多行渲染:label 一行 + 每个选项一行。上一版单行平铺遇到长选项会终端硬 wrap, | ||
| // 只清 \r\x1b[K 清不到 wrap 出来的行 → 方向键切换时堆多行。改成回到起点用 \x1b[0J 清到底。 | ||
| const totalLines = choices.length + 1 | ||
| const totalLines = norm.length + 1 | ||
| // 计算 value 列宽度,让 hint 对齐 | ||
| const maxValueLen = norm.reduce((m, c) => Math.max(m, c.value.length), 0) | ||
| function renderLine(c, selected) { | ||
| const marker = selected ? ' \x1b[36m●' : ' ' | ||
| const valueColored = selected ? `\x1b[36m${c.value}\x1b[0m` : c.value | ||
| const pad = ' '.repeat(maxValueLen - c.value.length + 2) | ||
| const hint = c.hint ? `\x1b[90m${c.hint}\x1b[0m` : '' | ||
| return `${marker} ${valueColored}${pad}${hint}\n` | ||
| } | ||
| function render() { | ||
@@ -192,5 +277,4 @@ if (rendered) { | ||
| process.stdout.write(` ${label}:\n`) | ||
| for (let i = 0; i < choices.length; i++) { | ||
| if (i === idx) process.stdout.write(` \x1b[36m● ${choices[i]}\x1b[0m\n`) | ||
| else process.stdout.write(` ${choices[i]}\n`) | ||
| for (let i = 0; i < norm.length; i++) { | ||
| process.stdout.write(renderLine(norm[i], i === idx)) | ||
| } | ||
@@ -205,6 +289,6 @@ rendered = true | ||
| if (key === '\x1b[D' || key === '\x1b[A') { // ← ↑ | ||
| idx = (idx - 1 + choices.length) % choices.length | ||
| idx = (idx - 1 + norm.length) % norm.length | ||
| render() | ||
| } else if (key === '\x1b[C' || key === '\x1b[B') { // → ↓ | ||
| idx = (idx + 1) % choices.length | ||
| idx = (idx + 1) % norm.length | ||
| render() | ||
@@ -215,6 +299,8 @@ } else if (key === '\r' || key === '\n') { | ||
| process.stdin.pause() | ||
| // 确认后擦掉多行菜单,换成单行 "label: 选中值" | ||
| // 确认后擦掉多行菜单,换成单行 "label: 选中值 灰色hint" | ||
| process.stdout.write(`\x1b[${totalLines}A\x1b[0J`) | ||
| process.stdout.write(` ${label}: \x1b[36m${choices[idx]}\x1b[0m\n`) | ||
| resolve(choices[idx]) | ||
| const chosen = norm[idx] | ||
| const hintTail = chosen.hint ? ` \x1b[90m${chosen.hint}\x1b[0m` : '' | ||
| process.stdout.write(` ${label}: \x1b[36m${chosen.value}\x1b[0m${hintTail}\n`) | ||
| resolve(chosen.value) | ||
| } | ||
@@ -254,4 +340,16 @@ } | ||
| async function pickOrUse(label, currentVal, choices, defaultVal) { | ||
| if (hasValue(currentVal) && choices.includes(currentVal)) { | ||
| function logUseCli(label, value) { | ||
| process.stdout.write(` ${label}: \x1b[36m${value}\x1b[0m \x1b[90m(命令行参数)\x1b[0m\n`) | ||
| } | ||
| // 三个 or-use 系列统一 CLI > existing > 交互 的优先级。 | ||
| // cliVal 是从 argv 解析出来的字符串,undefined 表示用户没在命令行传这项;传了但等于空字符串 | ||
| // 视为"用户想清空",按 hasValue 规则由后续逻辑决定是否落到默认值(具体分支各自处理)。 | ||
| async function pickOrUse(label, currentVal, choices, defaultVal, cliVal) { | ||
| const values = choices.map(c => typeof c === 'string' ? c : c.value) | ||
| if (hasValue(cliVal) && values.includes(cliVal)) { | ||
| logUseCli(label, cliVal) | ||
| return cliVal | ||
| } | ||
| if (hasValue(currentVal) && values.includes(currentVal)) { | ||
| logUseExisting(label, currentVal) | ||
@@ -263,3 +361,7 @@ return currentVal | ||
| async function inputOrUse(label, currentVal, defaultVal) { | ||
| async function inputOrUse(label, currentVal, defaultVal, cliVal) { | ||
| if (hasValue(cliVal)) { | ||
| logUseCli(label, cliVal) | ||
| return cliVal | ||
| } | ||
| if (hasValue(currentVal)) { | ||
@@ -272,3 +374,7 @@ logUseExisting(label, currentVal) | ||
| async function inputIntOrUse(label, currentVal, defaultVal) { | ||
| async function inputIntOrUse(label, currentVal, defaultVal, cliVal) { | ||
| if (hasValue(cliVal) && Number.isFinite(Number(cliVal))) { | ||
| logUseCli(label, cliVal) | ||
| return Number(cliVal) | ||
| } | ||
| if (hasValue(currentVal) && Number.isFinite(Number(currentVal))) { | ||
@@ -293,3 +399,7 @@ logUseExisting(label, currentVal) | ||
| const preset = JSON.parse(fs.readFileSync(path.join(PRESETS_DIR, entry), 'utf8')) | ||
| if (preset && preset.name && preset.adapter) list.push(preset) | ||
| // _sourceFile 记录 JSON 文件名(含扩展名),供 CLI --adapter-preset 反查 preset id 使用 | ||
| if (preset && preset.name && preset.adapter) { | ||
| preset._sourceFile = entry | ||
| list.push(preset) | ||
| } | ||
| } catch (e) { | ||
@@ -304,3 +414,3 @@ console.warn(` ⚠️ adapter-presets/${entry} 解析失败,跳过: ${e.message}`) | ||
| async function runInit() { | ||
| async function runInit(cliArgs = {}) { | ||
| // 先读现有 config(init 自己生成 config,不能让 installFiles 提前复制 templates 模板污染 existing) | ||
@@ -319,13 +429,16 @@ let existing = {} | ||
| console.log('─── 阶段一:Figma Personal Access Token 说明 ─────────\n') | ||
| console.log(' ⚠️ v0.3 起本 SKILL 完全走 Figma REST API,不再依赖 MCP。') | ||
| console.log(' 你只需要一个 Figma Personal Access Token 即可运行(在后续阶段三输入)。\n') | ||
| console.log(' Token 生成路径:') | ||
| console.log(' 1. 打开 https://www.figma.com/ 登录') | ||
| console.log(' 2. 右上角头像 → Settings → Security') | ||
| console.log(' 3. 找到 Personal access tokens,点击 "Generate new token"') | ||
| console.log(' 4. 权限勾选 "File content: Read-only" 即可') | ||
| console.log(' 5. 复制 token 后不要关闭窗口(离开后无法再次查看)\n') | ||
| // 检查已有 token(env / .env / 旧 config / CLI --figma-token),有的话就跳过完整劝导,只留一行提示 | ||
| const preexistingToken = process.env.FIGMA_TOKEN || readEnvFile().map.FIGMA_TOKEN || fig.token || cliArgs.figmaToken | ||
| if (preexistingToken) { | ||
| console.log('─── 阶段一:Figma Token ─────────────────────────────\n') | ||
| console.log(' ✓ 检测到已配置的 FIGMA_TOKEN,阶段三会沿用(如需换 token,重跑并传 --figma-token)\n') | ||
| } else { | ||
| console.log('─── 阶段一:Figma Token 生成指南 ────────────────────\n') | ||
| console.log(' v0.3 起完全走 Figma REST API,只需一个 Personal Access Token(阶段三输入):') | ||
| console.log(' 1. 打开 https://www.figma.com/ 登录 → 头像 → Settings → Security') | ||
| console.log(' 2. Personal access tokens → Generate new token') | ||
| console.log(' 3. 权限勾选 "File content: Read-only",复制 token(离开无法再看)\n') | ||
| } | ||
| console.log('─── 阶段二:交互式配置 ──────────────────────────────\n') | ||
| console.log('─── 阶段二:交互式配置 ──────────────────────────────\n') | ||
@@ -347,7 +460,30 @@ // ─── 平铺项目框架 + 方案 ────────────────────────────── | ||
| for (const preset of presets) { | ||
| flatOptions.push({ label: `RN / ${preset.name}`, framework: 'rn', adapterKind: 'preset', preset }) | ||
| // preset.json 里 name 形如 "pure React Native / Expo",这里取文件名做 CLI 标识(rn / taro / xtaro), | ||
| // 避免中文 / 特殊字符出现在 --adapter-preset 值里 | ||
| const presetId = path.basename(preset._sourceFile || '', '.json') || preset.name.toLowerCase() | ||
| flatOptions.push({ label: `RN / ${preset.name}`, framework: 'rn', adapterKind: 'preset', preset, presetId }) | ||
| } | ||
| flatOptions.push({ label: 'RN / 自定义标签映射(后续手填)', framework: 'rn', adapterKind: 'custom' }) | ||
| flatOptions.push({ label: 'RN / 不启用组件映射(保留 RN 原写法)', framework: 'rn', adapterKind: 'off' }) | ||
| flatOptions.push({ label: 'RN / 自定义标签映射(后续手填)', framework: 'rn', adapterKind: 'custom', presetId: 'custom' }) | ||
| flatOptions.push({ label: 'RN / 不启用组件映射(保留 RN 原写法)', framework: 'rn', adapterKind: 'off', presetId: 'off' }) | ||
| // CLI 参数反查:优先级最高 | ||
| // --framework=react --style-format=scss → 命中 React / SCSS | ||
| // --framework=rn --adapter-preset=xtaro → 命中 RN / 携程 xtaro | ||
| let cliLabel = null | ||
| if (hasValue(cliArgs.framework)) { | ||
| if (cliArgs.framework === 'react') { | ||
| const sf = cliArgs.styleFormat | ||
| const hit = hasValue(sf) ? flatOptions.find(o => o.framework === 'react' && o.styleFormat === sf) : null | ||
| if (hit) cliLabel = hit.label | ||
| else if (hasValue(sf)) console.warn(` ⚠️ --style-format=${sf} 在 React 分支下没有匹配预设,忽略 CLI 命中,回退交互`) | ||
| } else if (cliArgs.framework === 'rn') { | ||
| const pid = cliArgs.adapterPreset | ||
| const hit = hasValue(pid) ? flatOptions.find(o => o.framework === 'rn' && o.presetId === pid) : null | ||
| if (hit) cliLabel = hit.label | ||
| else if (hasValue(pid)) console.warn(` ⚠️ --adapter-preset=${pid} 无法匹配已加载的 RN 预设(可选值:${flatOptions.filter(o => o.framework === 'rn').map(o => o.presetId).join(' / ')}),忽略 CLI 命中,回退交互`) | ||
| } else { | ||
| console.warn(` ⚠️ --framework=${cliArgs.framework} 无效(可选 react / rn),忽略 CLI 命中,回退交互`) | ||
| } | ||
| } | ||
| // 反推现有 config 对应的平铺项 label(能命中就走"沿用",避免每次 init 都重选) | ||
@@ -375,8 +511,13 @@ let existingLabel = null | ||
| const flatLabels = flatOptions.map(o => o.label) | ||
| if (existingLabel) { | ||
| logUseExisting('[1/8] 项目框架 + 方案', existingLabel) | ||
| if (cliLabel) { | ||
| logUseCli('[1/6] 项目框架 + 方案', cliLabel) | ||
| selectedLabel = cliLabel | ||
| // CLI 命中时 isReused 保持 false,让 rn 分支走"重新按 preset 组装 adapterCfg"分支, | ||
| // 而不是走"沿用旧 config.adapter"分支——用户显式换 preset 就是要覆盖旧映射 | ||
| } else if (existingLabel) { | ||
| logUseExisting('[1/6] 项目框架 + 方案', existingLabel) | ||
| selectedLabel = existingLabel | ||
| isReused = true | ||
| } else { | ||
| selectedLabel = await select('[1/8] 项目框架 + 方案', flatLabels, flatLabels[0]) | ||
| selectedLabel = await select('[1/6] 项目框架 + 方案', flatLabels, flatLabels[0]) | ||
| } | ||
@@ -387,3 +528,3 @@ const selectedOpt = flatOptions.find(o => o.label === selectedLabel) | ||
| // 选完 framework 才复制 SKILL(h5 项目跳 pp-d2c-rn,rn 项目跳 pp-d2c;避免另一分支主 SKILL 污染) | ||
| installFiles(true, true, { skipRn: framework !== 'rn', skipH5: framework === 'rn' }) | ||
| installFiles(true, true, { skipRn: framework !== 'rn', skipH5: framework === 'rn', silent: true }) | ||
@@ -422,4 +563,19 @@ let styleFormat | ||
| } else if (selectedOpt.adapterKind === 'custom') { | ||
| adapterCfg = { enabled: true, tagMap: {}, importMap: {}, propMap: {}, reactImport: 'react' } | ||
| console.log(' → adapter.enabled=true,请后续在 pp-d2c.config.json 手动填 tagMap / importMap / propMap') | ||
| // 给 6 键 tagMap 骨架 + 空 importMap / propMap,让用户手动填目标标签名 / import 源 / prop 重命名。 | ||
| // 空字符串比缺键更好——config schema 保持稳定,pp-d2c-rn SKILL 检测到空串会报错提示补齐,不会当成 preset 命中。 | ||
| adapterCfg = { | ||
| enabled: true, | ||
| tagMap: { | ||
| View: '', | ||
| Text: '', | ||
| Image: '', | ||
| Pressable: '', | ||
| TextInput: '', | ||
| ScrollView: '' | ||
| }, | ||
| importMap: {}, | ||
| propMap: {}, | ||
| reactImport: 'react' | ||
| } | ||
| console.log(' → adapter.enabled=true,已生成 6 键空 tagMap 骨架,请在 pp-d2c.config.json 里填入目标标签 / importMap / propMap') | ||
| } else { | ||
@@ -438,9 +594,11 @@ const hit = selectedOpt.preset | ||
| const existingRespYn = existingResp.enabled === true ? 'Yes' : existingResp.enabled === false ? 'No' : null | ||
| // --responsive on|off 映射到 Yes/No,其他值(包括空字符串)当作未传 | ||
| const cliRespYn = cliArgs.responsive === 'on' ? 'Yes' : cliArgs.responsive === 'off' ? 'No' : undefined | ||
| const enableRespYn = await pickOrUse( | ||
| '[2/8] 是否启用响应式 rpx() 包装(按屏宽线性缩放尺寸)', | ||
| existingRespYn, ['Yes', 'No'], 'Yes' | ||
| '[2/6] 是否启用响应式 rpx() 包装(按屏宽线性缩放尺寸)', | ||
| existingRespYn, ['Yes', 'No'], 'Yes', cliRespYn | ||
| ) | ||
| if (enableRespYn === 'Yes') { | ||
| const helperImport = await inputOrUse('[2.1/8] rpx helper import 路径', existingResp.helperImport, '@/utils/rpx') | ||
| const helperName = await inputOrUse('[2.2/8] rpx helper 导出函数名', existingResp.helperName, 'rpx') | ||
| const helperImport = await inputOrUse('[2.1/6] rpx helper import 路径', existingResp.helperImport, '@/utils/rpx', cliArgs.rpxHelperImport) | ||
| const helperName = await inputOrUse('[2.2/6] rpx helper 导出函数名', existingResp.helperName, 'rpx', cliArgs.rpxHelperName) | ||
| responsiveCfg = { enabled: true, helperImport, helperName } | ||
@@ -451,8 +609,11 @@ } else { | ||
| } else { | ||
| // react 分支的 styleFormat 已在 [1/8] 里选完,这里只做展示不再交互 | ||
| // react 分支的 styleFormat 已在 [1/6] 里选完,这里只做展示不再交互 | ||
| styleFormat = selectedOpt.styleFormat | ||
| console.log(` [2/8] 样式方案: \x1b[36m${styleFormat}\x1b[0m \x1b[90m(在 [1/8] 里已选定,不再单独询问)\x1b[0m`) | ||
| console.log(` [2/6] 样式方案: \x1b[36m${styleFormat}\x1b[0m \x1b[90m(在 [1/6] 里已选定,不再单独询问)\x1b[0m`) | ||
| } | ||
| const mergeMode = await pickOrUse('[3/8] 合并模式', m.mode, ['component', 'flat'], 'component') | ||
| const mergeMode = await pickOrUse('[3/6] 合并模式', m.mode, [ | ||
| { value: 'flat', hint: '单文件合并,所有子 block 展开到主文件(默认)' }, | ||
| { value: 'component', hint: '组件化拆分,每个子 block 独立目录' } | ||
| ], 'flat', cliArgs.mergeMode) | ||
@@ -470,3 +631,3 @@ // rn / react 分支的默认值分叉: | ||
| // 强制写死会绑架用户,保留输入让用户能改。imageBaseUrl 才是真正的 rn 特化项(走 require 不走 URL)。 | ||
| const assetsDir = await inputOrUse('[4/8] 图片输出目录', img.assetsDir, defaultAssetsDir) | ||
| const assetsDir = await inputOrUse('[4/6] 图片输出目录', img.assetsDir, defaultAssetsDir, cliArgs.assetsDir) | ||
@@ -477,8 +638,8 @@ // rn 分支:图片 base URL 固定为空,不再询问 | ||
| imageBaseUrl = '' | ||
| console.log(` [5/8] 图片 base URL: \x1b[36m(空)\x1b[0m \x1b[90m(rn 分支走 require 引用,不用远程 URL)\x1b[0m`) | ||
| console.log(` [5/6] 图片 base URL: \x1b[36m(空)\x1b[0m \x1b[90m(rn 分支走 require 引用,不用远程 URL)\x1b[0m`) | ||
| } else { | ||
| imageBaseUrl = await inputOrUse('[5/8] 图片 base URL', img.imageBaseUrl, defaultImageBaseUrl) | ||
| imageBaseUrl = await inputOrUse('[5/6] 图片 base URL', img.imageBaseUrl, defaultImageBaseUrl, cliArgs.imageBaseUrl) | ||
| } | ||
| const outputDir = await inputOrUse('[6/8] 代码输出目录', out.dir, defaultOutputDir) | ||
| const outputDir = await inputOrUse('[6/6] 代码输出目录', out.dir, defaultOutputDir, cliArgs.outputDir) | ||
@@ -490,3 +651,3 @@ console.log('\n─── 阶段三:单位换算规则 ────────────────────────────\n') | ||
| isRn ? '[单位1/2] 设计稿基准宽度 (px)' : '[单位1/4] 设计稿基准宽度 (px)', | ||
| u.figmaBase, 375 | ||
| u.figmaBase, 375, cliArgs.figmaBase | ||
| ) | ||
@@ -504,13 +665,13 @@ | ||
| } else { | ||
| outputUnit = await pickOrUse('[单位2/4] 代码使用的单位', u.outputUnit, ['px', 'vw', 'rem'], 'px') | ||
| outputUnit = await pickOrUse('[单位2/4] 代码使用的单位', u.outputUnit, ['px', 'vw', 'rem'], 'px', cliArgs.outputUnit) | ||
| if (outputUnit === 'px') { | ||
| outputBase = await inputIntOrUse('[单位3/4] 代码 px 基准宽度(如 postcss px2vw 基于 750 则填 750)', u.outputBase, figmaBase * 2) | ||
| outputBase = await inputIntOrUse('[单位3/4] 代码 px 基准宽度(如 postcss px2vw 基于 750 则填 750)', u.outputBase, figmaBase * 2, cliArgs.outputBase) | ||
| scale = outputBase / figmaBase | ||
| console.log(` → 换算倍数:×${scale}(Figma ${figmaBase}px → 代码 ${figmaBase * scale}px)`) | ||
| } else if (outputUnit === 'vw') { | ||
| outputBase = await inputIntOrUse('[单位3/4] vw 基准宽度(100vw 对应多少 px)', u.outputBase, figmaBase) | ||
| outputBase = await inputIntOrUse('[单位3/4] vw 基准宽度(100vw 对应多少 px)', u.outputBase, figmaBase, cliArgs.outputBase) | ||
| scale = outputBase / figmaBase | ||
| console.log(` → 换算:Figma ${figmaBase}px → ${(figmaBase * scale / outputBase * 100).toFixed(3)}vw`) | ||
| } else { | ||
| outputBase = await inputIntOrUse('[单位3/4] rem 基准(1rem = 多少 px)', u.outputBase, 16) | ||
| outputBase = await inputIntOrUse('[单位3/4] rem 基准(1rem = 多少 px)', u.outputBase, 16, cliArgs.outputBase) | ||
| scale = 1 | ||
@@ -529,3 +690,3 @@ console.log(` → 换算:Figma 值 / ${outputBase} rem`) | ||
| : '[单位4/4] Figma Personal Access Token(存到项目根 .env,回车跳过)', | ||
| defaultToken, '' | ||
| defaultToken, '', cliArgs.figmaToken | ||
| ) | ||
@@ -670,29 +831,23 @@ | ||
| console.log('\n─── 阶段四:mappings.json ───────────────────────────\n') | ||
| if (fs.existsSync(MAPPINGS_PATH)) { | ||
| let existingMappings = null | ||
| try { existingMappings = JSON.parse(fs.readFileSync(MAPPINGS_PATH, 'utf8')) } catch {} | ||
| const hasComponents = existingMappings && Array.isArray(existingMappings.components) && existingMappings.components.length > 0 | ||
| if (hasComponents) { | ||
| console.log(` skip mappings.json (已有 ${existingMappings.components.length} 条映射,沿用现有配置)`) | ||
| } else { | ||
| fs.writeFileSync(MAPPINGS_PATH, JSON.stringify({ components: [] }, null, 2)) | ||
| console.log(' ✓ mappings.json 已重置为空模板(原文件无有效映射)') | ||
| console.log('\n─── 阶段四:追加 .gitignore ─────────────────────────\n') | ||
| ensureGitignoreEntries() | ||
| console.log('\n─── 完成 ────────────────────────────────────────────\n') | ||
| console.log(` ✓ pp-d2c.config.json framework=${framework} · merge=${mergeMode} · unit=${outputUnit}(base ${outputBase}) · out=${outputDir}`) | ||
| if (framework === 'rn') { | ||
| // adapter 摘要:preset 用 preset name,custom 提示 6 键待填,off 说明关闭 | ||
| const ad = config.adapter | ||
| let adapterLine | ||
| if (ad.enabled === false) adapterLine = 'off(保留 RN 原写法)' | ||
| else if (pickedPreset) adapterLine = `${pickedPreset.name}` | ||
| else adapterLine = '自定义(config.adapter.tagMap 6 键待填)' | ||
| console.log(` ✓ adapter ${adapterLine}`) | ||
| if (responsiveCfg && responsiveCfg.enabled) { | ||
| console.log(` ✓ 响应式 rpx() ${responsiveCfg.helperImport} · ${responsiveCfg.helperName}()`) | ||
| } | ||
| } else { | ||
| fs.mkdirSync(path.dirname(MAPPINGS_PATH), { recursive: true }) | ||
| fs.writeFileSync(MAPPINGS_PATH, JSON.stringify({ components: [] }, null, 2)) | ||
| console.log(' ✓ mappings.json 已初始化') | ||
| } | ||
| console.log('\n─── 阶段五:追加 .gitignore ─────────────────────────\n') | ||
| ensureGitignoreEntries() | ||
| console.log('\n─────────────────────────────────────────────────────') | ||
| console.log(' ✓ v0.3 起完全走 Figma REST API,无需 MCP;确保项目根 .env 里 FIGMA_TOKEN 已配置即可。') | ||
| console.log(' ✓ pp-d2c.config.json 已配置') | ||
| console.log(' ✓ code-connect/mappings.json 已就绪') | ||
| console.log(' ✓ .gitignore 已追加 .d2c-cache/ / .d2c-tmp/') | ||
| console.log('\n 把设计稿链接发给 Claude 即可开始生成:') | ||
| console.log(' 把这份设计稿转成代码:https://figma.com/design/xxx?node-id=1-2\n') | ||
| console.log(` ✓ .env FIGMA_TOKEN ${figmaToken ? '已写入' : '未配置(切图前手动补上)'}`) | ||
| console.log(` ✓ .gitignore .d2c-cache/ · .d2c-tmp/ · .env`) | ||
| console.log('\n 把设计稿链接发给 Claude 即可开始生成,例:') | ||
| console.log(' 把这份设计稿转成代码:https://figma.com/design/xxx?node-id=1-2\n') | ||
| } | ||
@@ -742,2 +897,3 @@ | ||
| const cmd = process.argv[2] | ||
| const rest = process.argv.slice(3) | ||
@@ -747,6 +903,28 @@ function printHelp() { | ||
| Usage: | ||
| npx @double-coding/pixel-print init 交互式初始化项目(推荐) | ||
| npx @double-coding/pixel-print install 仅复制模板文件,不进入交互 | ||
| npx @double-coding/pixel-print clean-cache 清理 .d2c-cache/(figma / images / anchors / last-page.json) | ||
| npx @double-coding/pixel-print help 显示本帮助 | ||
| npx @double-coding/pixel-print init [options] 交互式初始化项目(推荐) | ||
| npx @double-coding/pixel-print install 仅复制模板文件,不进入交互 | ||
| npx @double-coding/pixel-print clean-cache 清理 .d2c-cache/(figma / images / anchors / last-page.json) | ||
| npx @double-coding/pixel-print help 显示本帮助 | ||
| init 支持的 CLI 快捷参数(未传的项照常进入交互;CLI > 现有 config > 交互输入): | ||
| --framework <react|rn> 项目框架 | ||
| --style-format <scss|scss-modules|less|less-modules|css|css-modules|tailwind|inline> | ||
| React 分支样式方案(rn 忽略,固定 stylesheet) | ||
| --adapter-preset <rn|taro|xtaro|off|custom> | ||
| RN 组件映射预设(react 忽略) | ||
| --merge-mode <flat|component> 合并模式(默认 flat) | ||
| --figma-token <TOKEN> Figma Personal Access Token(写入 .env) | ||
| --figma-base <PX> 设计稿基准宽度,默认 375 | ||
| --output-unit <px|vw|rem> 代码单位(仅 react) | ||
| --output-base <PX> 输出基准宽度 | ||
| --responsive <on|off> 是否启用 rpx 响应式包装(仅 rn) | ||
| --assets-dir <PATH> 图片输出目录 | ||
| --image-base-url <URL> 图片 base URL(仅 react) | ||
| --output-dir <PATH> 代码输出目录 | ||
| --rpx-helper-import <PATH> rpx helper import 路径(仅 rn + responsive) | ||
| --rpx-helper-name <NAME> rpx helper 导出函数名(仅 rn + responsive) | ||
| 例: | ||
| npx @double-coding/pixel-print init --framework rn --adapter-preset xtaro --merge-mode flat | ||
| npx @double-coding/pixel-print init --framework react --style-format scss --output-unit vw --output-base 750 | ||
| `) | ||
@@ -756,3 +934,4 @@ } | ||
| if (cmd === 'init') { | ||
| runInit().catch(err => { console.error(err); process.exit(1) }) | ||
| const cliArgs = parseArgs(rest) | ||
| runInit(cliArgs).catch(err => { console.error(err); process.exit(1) }) | ||
| } else if (cmd === 'install') { | ||
@@ -759,0 +938,0 @@ installFiles() |
+1
-1
| { | ||
| "name": "@double-coding/pixel-print", | ||
| "version": "1.1.0", | ||
| "version": "1.2.0", | ||
| "description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)", | ||
@@ -5,0 +5,0 @@ "bin": { |
+32
-298
| # PixelPrint(像素打印) | ||
| > 把 Figma 稿子丢给 Claude,喝杯咖啡的时间拿到可运行的代码 + 视觉对比截图。 | ||
| > | ||
| > npm 包名:`@double-coding/pixel-print` · GitHub:[double-coding-lab/PixelPrint](https://github.com/double-coding-lab/PixelPrint) · License MIT | ||
| > | ||
| > 中文名「像素打印」,寓意像素级还原 —— 把 Figma 每一像素、每一间距、每一个圆角原样"打印"成前端代码。 | ||
| 一套让 **Claude Code** 学会「把 Figma 稿子还原成代码」的知识包。装到项目里,把设计稿链接发给 Claude,它自己拆图层、切图、出代码、逐块视觉对比。 | ||
| **PixelPrint 是一套让 Claude Code 学会「把 Figma 稿子还原成代码」的知识包**。装到项目里,把设计稿链接发给 Claude,它自己拆图层、切图、出代码、逐块视觉对比。 | ||
| **H5(React)** / **React Native** / **RN 系跨端(xtaro / taro / 自定义)** 三端产物一套 SKILL 全覆盖。走 Figma 原生 REST API,不装 MCP 插件、不走 OAuth。 | ||
| ## 文档导航 | ||
| | 文档 | 面向 | 用来做什么 | | ||
| |---|---|---| | ||
| | **本文 README** | 已经决定用的开发者 | 参数、配置、命令、故障排查速查 | | ||
| | [`docs/pixel-print-intro.md`](./docs/pixel-print-intro.md) | 不了解 PixelPrint 的人 | 3 分钟看懂"这是什么、能做什么" | | ||
| | [`docs/pixel-print-architecture.md`](./docs/pixel-print-architecture.md) | 维护者/贡献者 | 架构、执行模型、缓存、adapter、演化史 | | ||
| | [`docs/design-guide.md`](./docs/design-guide.md) | **设计师** | 图层命名规范(命名对了,开发省 10 倍时间) | | ||
| | [`docs/d2c-health-check-spec.md`](./docs/d2c-health-check-spec.md) | 想调 doctor 的人 | 体检规则完整定义 | | ||
| --- | ||
@@ -32,315 +22,59 @@ | ||
| `init` 是交互式引导,共 8-13 题(H5 略少、RN 略多)。1 分钟内答完,自动落地 SKILL + 配置 + 图片资产目录 + Figma Token(存到 `.env`,自动 gitignore)。 | ||
| 交互式引导 6 题,1 分钟内答完。 | ||
| **init 会问什么**(v1.1.0 起 [1/8] 平铺一层 13 项): | ||
| **一键式装法**(推荐给已知配置的场景,零交互): | ||
| ``` | ||
| [1/8] 项目框架 + 方案: | ||
| ● React / SCSS | ||
| React / SCSS Modules | ||
| React / LESS / LESS Modules / CSS / CSS Modules | ||
| React / Tailwind / Inline Style | ||
| RN / pure React Native / Expo | ||
| RN / Taro (@tarojs/components) | ||
| RN / 携程 xtaro | ||
| RN / 自定义标签映射(后续手填) | ||
| RN / 不启用组件映射(保留 RN 原写法) | ||
| [2/8] RN 分支才问:是否启用响应式 rpx() 包装 | ||
| [3/8] 合并模式:component / flat | ||
| [4/8] 图片输出目录 [默认 static/,rn 默认 assets/] | ||
| [5/8] H5 才问:图片 base URL [默认 http://127.0.0.1:8080/] | ||
| [6/8] 代码输出目录 [默认 pages/,rn 默认 src/pages/] | ||
| 阶段三:单位换算(设计稿基准宽度 / 单位 / 输出基准 / Figma Token) | ||
| ``` | ||
| > **可重复运行**:再次跑 `init` 会**自动沿用现有 config 里的值**,只对缺失字段弹交互。想改某项就删掉 config 对应字段后重跑。 | ||
| ### 2. 把设计稿链接发给 Claude | ||
| ``` | ||
| 把这份稿子转成代码:https://figma.com/design/AAA?node-id=138-1797 | ||
| ``` | ||
| Claude 会自动: | ||
| 1. 探活 Figma Token → 2. 跑 doctor 体检(可关) → 3. 拉图层树,按 `sub-` 前缀并行分派 sub-agent → 4. 切图(REST API 严格 bbox)→ 5. 逐 sub-block 视觉对比 → 6. 出完整可运行产物 + 交付清单。 | ||
| --- | ||
| ## 装完之后长什么样 | ||
| **SKILL**(`.claude/skills/`,按 framework 分): | ||
| | SKILL | 作用 | 何时落地 | | ||
| |---|---|---| | ||
| | `pp-d2c/` | H5 主 D2C 流程 | framework=react 时 | | ||
| | `pp-d2c-rn/` | RN 主 D2C 流程(6 大 RN 内核标签 + adapter) | framework=rn 时 | | ||
| | `pp-strip-nodeid/` | 剥离 `data-node-id` 调试属性 + 生成 anchor 档案 | 总是装 | | ||
| | `pp-fix-partial/` | **局部 UI 修复**(v1.1.0+) | 总是装 | | ||
| | `pp-doctor/` `pp-style/` | 体检 / 样式速查 | opt-in(需手工 cp 过来) | | ||
| **配置与资产**: | ||
| - `pp-d2c.config.json` — 项目配置(前缀映射 / 单位换算 / 图片路径 / 体检阈值 / adapter);**已默认 gitignore** | ||
| - `.env` — 存 `FIGMA_TOKEN`;**已默认 gitignore** | ||
| - `.d2c-cache/` — 跨会话缓存(figma JSON / 切图 / anchor / last-page.json);**已默认 gitignore** | ||
| - `code-connect/mappings.json` — Figma 组件 → 代码组件映射表(可选) | ||
| - (RN 分支)`src/utils/rpx.ts` — 响应式尺寸 helper | ||
| --- | ||
| ## 图层命名规范(给设计师看) | ||
| 完整规范:[`docs/design-guide.md`](./docs/design-guide.md)。速查表: | ||
| | 前缀 | 含义 | 生成效果 | | ||
| |------|------|---------| | ||
| | `sub-` | 独立模块 | 单独 sub-agent,生成独立组件;支持嵌套(最深 3 层) | | ||
| | `block-` | 独立布局块 | HTML/CSS 隔离容器,不可点击 | | ||
| | `img-` | 整块图片 | 整层导出为 PNG,不递归子孙 | | ||
| | `bg-` | 背景图 | 写父元素 `background-image`,不递归子孙 | | ||
| | `bgc-` | 盒级装饰 | 写父元素 fills / strokes / cornerRadius / effects,不递归 | | ||
| | `btn-` | 可点击 | H5:`<button>`;RN:`<Pressable>` | | ||
| | `input-` | 输入框 | 生成 `<input>` / `<TextInput>`,子 TEXT 变 placeholder | | ||
| | `scrollx-` / `scrolly-` | 横向 / 纵向滚动 | overflow + 隐藏滚动条,**继续递归子层** | | ||
| | `fixed-` | 视口固定 | `position: fixed`,读 Figma constraints | | ||
| | `end-` | 贴父末端 | auto-layout 里贴向末端(纵→贴底 / 横→贴右) | | ||
| | `x-` | 忽略 | 不生成代码 | | ||
| **修饰前缀可叠加**(选例):`fixed-btn-back-top` / `sub-scrollx-cards` / `end-btn-submit` / `fixed-sub-nav`。 | ||
| **禁止叠加**: | ||
| - `scrollx-` / `scrolly-` × `img-` / `bg-` / `bgc-` / `btn-` / `x-`(语义冲突) | ||
| - `fixed-` × `bg-` / `bgc-` / `x-`(bg/bgc 不生成节点,fixed 无处可挂) | ||
| - `input-` × `bg-` / `bgc-` / `x-` / `img-` / `btn-` | ||
| --- | ||
| ## 命令清单 | ||
| ```bash | ||
| # 在业务项目根目录使用 | ||
| npx @double-coding/pixel-print init # 交互式初始化(推荐) | ||
| npx @double-coding/pixel-print install # 仅复制模板文件,不交互 | ||
| npx @double-coding/pixel-print clean-cache # 清 .d2c-cache/(figma / images / anchors / last-page.json) | ||
| npx @double-coding/pixel-print help # 帮助 | ||
| ``` | ||
| # 携程 xtaro 一键 | ||
| npx @double-coding/pixel-print init \ | ||
| --framework rn --adapter-preset xtaro --merge-mode flat \ | ||
| --figma-base 375 --responsive on \ | ||
| --rpx-helper-import "@ctrip/xtaro" --rpx-helper-name xrpx \ | ||
| --assets-dir assets/ --output-dir src/pages/ \ | ||
| --figma-token figd_你的token | ||
| --- | ||
| ## 常用能力 | ||
| ### 局部 UI 修复(v1.1.0) | ||
| 页面已经出码,某一小块视觉不对,不用整页重跑。让 Claude 走 `pp-fix-partial`: | ||
| # React + SCSS 一键 | ||
| npx @double-coding/pixel-print init \ | ||
| --framework react --style-format scss --merge-mode flat \ | ||
| --figma-base 375 --output-unit vw --output-base 375 \ | ||
| --output-dir pages/ --figma-token figd_你的token | ||
| ``` | ||
| # 3 种触发形态 | ||
| pp-fix-partial https://figma.com/design/AAA?node-id=138-2050 # 明确 URL | ||
| pp-fix-partial # 不传参:拿最近实现的整页,让你选一个子块 | ||
| pp-fix-partial 顶部导航栏 # 自然语言 fuzzy match | ||
| ``` | ||
| **利用缓存不污染**: | ||
| - hash 对比 target 子树 → 变了才 invalidate 该 nodeId 的缓存 | ||
| - 图片文件名带 fileKey 前缀 → 换稿子天然隔离 | ||
| - 缓存 mtime 超 7 天自动 TTL 作废 | ||
| 完整参数表见 [`docs/pixel-print-guide.md §6`](./docs/pixel-print-guide.md#6-cli-快捷参数速查)。 | ||
| 详见 [`.Knowledge/topics/pp-fix-partial.md`](./.Knowledge/topics/pp-fix-partial.md) 或 SKILL 本身。 | ||
| ### 2. 让设计师按规范命名图层 | ||
| ### 剥调试属性 + 存锚点 | ||
| 把 [`docs/design-guide.md`](./docs/design-guide.md) 发给对接设计师。他花 20 分钟改图层名,你后面省 10 倍时间。 | ||
| 上线前跑一次,把 `data-node-id="..."` 从产物剥掉,顺手把 nodeId → (file, startLine, endLine) 存到 `.d2c-cache/anchors/`,供后续 `pp-fix-partial` 精确定位: | ||
| ### 3. 让 Claude 干活 | ||
| ```bash | ||
| node .claude/skills/pp-strip-nodeid/strip-node-id.mjs --dry-run # 先预览 | ||
| node .claude/skills/pp-strip-nodeid/strip-node-id.mjs # 确认后清理 | ||
| ``` | ||
| 加 `--no-anchors` 关掉锚点写入(如果只是纯剥,不打算用局部修复)。 | ||
| ### 设计稿体检(Doctor) | ||
| H5 分支 `health.enabled: true` 时(默认),主 SKILL 生成代码前会跑一次体检: | ||
| - **NAM** 命名规范 · **LAY** 布局合理 · **STR** 嵌套深度 · **STY** 颜色/字号 · **AST** 资产体积 · **FEA** 整体规模 | ||
| - 输出 grade(A/B/C/D/F)+ 阻塞决策;`grade=F && blockOnError=true` 会停下来等确认 | ||
| - 报告落到 `{output.dir}/.d2c-health-{nodeName}-{timestamp}.md` | ||
| **RN 分支不默认接 doctor**(规则以 H5 语义为主,RN 语境会假阳)。 | ||
| ### RN Adapter(v0.4+) | ||
| RN 分支的核心机制:**内核用 6 大 RN 原生标签描述一切**(`View / Text / Image / Pressable / TextInput / ScrollView`),`§5.5` 阶段读 config 换标签。这样一套 SKILL 覆盖 pure RN / Expo / xtaro / taro / 自定义。 | ||
| 内置 3 个预设: | ||
| | 预设 | 目标 | 映射示意 | | ||
| |---|---|---| | ||
| | `rn` | pure RN / Expo | 保留原名(identity),`from 'react-native'` | | ||
| | `xtaro` | 携程 `@ctrip/xtaro` | `View→XView / TextInput→XInput / ScrollView→XScrollView`,`from '@ctrip/xtaro'` | | ||
| | `taro` | Taro `@tarojs/components` | `TextInput→Input / Pressable→View`,`from '@tarojs/components'` | | ||
| 每个预设 3 件套:`<id>.json`(映射规则)+ `<id>.rpx.ts`(专属屏宽 helper)+ `<id>.reference.md`(超改名的复杂差异手册)。 | ||
| **加自己的预设**:见 [`templates/adapter-presets/README.md`](./templates/adapter-presets/README.md)。 | ||
| --- | ||
| ## 配置文件 `pp-d2c.config.json` | ||
| 字段完整说明见主 SKILL `templates/skills/pp-d2c/SKILL.md` §0 或 `pp-d2c-rn/SKILL.md` §0。**核心字段**: | ||
| ```jsonc | ||
| { | ||
| "project": { | ||
| "framework": "react", // react | rn | ||
| "styleFormat": "scss" // h5: scss / scss-modules / less / less-modules / css / css-modules / tailwind / inline | ||
| // rn: 固定 stylesheet | ||
| }, | ||
| "merge": { "mode": "component" }, // component | flat | ||
| "unit": { | ||
| "figmaBase": 375, // 设计稿基准宽度 | ||
| "outputUnit": "px", // h5: px | vw | rem;rn 无单位字符串 | ||
| "outputBase": 750, // h5 默认 2 倍图;rn 固定 = figmaBase | ||
| "scale": 2 // h5 默认 2;rn 固定 1 | ||
| }, | ||
| "images": { | ||
| "assetsDir": "static/", // rn 默认 "assets/" | ||
| "imageBaseUrl": "http://127.0.0.1:8080/", // rn 走 require 不用 URL | ||
| "preserveEffectIds": [] | ||
| }, | ||
| "layers": { /* 12 类前缀映射,生产建议保持默认 */ }, | ||
| "output": { "dir": "pages/" }, // rn 默认 "src/pages/" | ||
| "health": { "enabled": true, "blockOnError": true, /* ... */ } | ||
| } | ||
| 把这份稿子转成代码:https://figma.com/design/xxx?node-id=1-2 | ||
| ``` | ||
| **RN 分支额外字段** `adapter` + `unit.responsive`: | ||
| Claude 自动:探活 Token → 跑体检 → 拆图层 → 派 sub-agent 并行出码 → 切图 → 逐块视觉对比 → 交付。 | ||
| ```jsonc | ||
| { | ||
| "unit": { | ||
| "responsive": { | ||
| "enabled": true, | ||
| "helperImport": "@/utils/rpx", | ||
| "helperName": "rpx" | ||
| } | ||
| }, | ||
| "adapter": { | ||
| "enabled": true, | ||
| "tagMap": { "View": "XView", "...": "..." }, | ||
| "importMap": { "XView": "@ctrip/xtaro", "...": "..." }, | ||
| "propMap": { "Image": { "source": "src" } }, | ||
| "referenceDoc": "xtaro.reference.md" | ||
| } | ||
| } | ||
| ``` | ||
| > **Token 不入 config**:v1.0.2 起 Figma Token 走 `.env` `FIGMA_TOKEN=...`,`pp-d2c.config.json` 不再存 token 字段。 | ||
| --- | ||
| ## Figma Personal Access Token | ||
| ## 想了解更多 | ||
| SKILL 通过 Figma REST API 拉稿子 + 导图,只需要一枚 Personal Access Token。**不需要装任何 MCP 插件、不走 OAuth**。 | ||
| 📖 **详细文档:[`docs/pixel-print-guide.md`](./docs/pixel-print-guide.md)** — 包含架构说明、init 交互实录(3 种模式)、CLI 参数、配置字段、Token 说明、故障排查、版本历史、效果图。 | ||
| **获取步骤**: | ||
| 🎨 **给设计师看:[`docs/design-guide.md`](./docs/design-guide.md)** — 图层命名规范速查(sub- / img- / bg- / fixed- / …)。 | ||
| 1. 打开 [figma.com](https://figma.com) 登录,右上头像 → **Settings** | ||
| 2. 左侧 **Security** → **Personal access tokens** → **Generate new token** | ||
| 3. 名称随意(如 `pp-d2c`),**Scopes** 至少勾 `File content: Read-only` | ||
| 4. 复制 token(格式 `figd_xxx...`),不要关窗口(离开无法再看) | ||
| 5. `init` 时粘贴到 Token 那题,或后续手动写到项目根 `.env` 的 `FIGMA_TOKEN=` | ||
| **探针验证**:Claude 跑 SKILL 步骤 -1 会调 `figma.mjs verify-token`: | ||
| | 结果 | 含义 | 处理 | | ||
| |---|---|---| | ||
| | 200 | Token 有效 | 继续 | | ||
| | 401 | Token 已过期/拼错 | 重新生成 | | ||
| | 403 | Scope 不够 | 重新生成时勾 `File content: Read-only` | | ||
| | 网络错误 | 网络不通 api.figma.com | 排查代理/防火墙 | | ||
| > **安全**:`.env` 默认 gitignore。请勿把 token 写进任何 committed 文件。 | ||
| --- | ||
| ## 项目结构 | ||
| ## 命令清单 | ||
| ```bash | ||
| npx @double-coding/pixel-print init [options] # 交互式初始化(推荐) | ||
| npx @double-coding/pixel-print install # 仅复制模板文件,不交互 | ||
| npx @double-coding/pixel-print clean-cache # 清 .d2c-cache/(figma / images / anchors / last-page.json) | ||
| npx @double-coding/pixel-print help # 完整帮助 + 参数示例 | ||
| ``` | ||
| pixel-print/ | ||
| ├── bin/install.js ← npx 入口(init / install / clean-cache / help) | ||
| ├── templates/ | ||
| │ ├── pp-d2c.config.json ← h5 分支配置模板 | ||
| │ ├── pp-d2c.rn.config.json ← rn 分支配置模板 | ||
| │ ├── code-connect/mappings.json ← Figma 组件映射模板(可选) | ||
| │ ├── adapter-presets/ ← RN adapter 预设目录 | ||
| │ │ ├── README.md ← 加预设的说明 | ||
| │ │ ├── rn.{json,rpx.ts,reference.md} ← pure RN | ||
| │ │ ├── taro.{json,rpx.ts,reference.md} ← Taro | ||
| │ │ └── xtaro.{json,rpx.ts,reference.md} ← 携程 xtaro | ||
| │ ├── rn-helpers/rpx.ts ← 兜底 rpx helper | ||
| │ └── skills/ | ||
| │ ├── pp-d2c/SKILL.md ← H5 主流程(~1700 行)+ bin/figma.mjs | ||
| │ ├── pp-d2c-rn/SKILL.md ← RN 主流程(~2200 行)+ bin/figma.mjs | ||
| │ ├── pp-strip-nodeid/ ← 剥属性 + 存锚点档案 | ||
| │ ├── pp-fix-partial/ ← 局部 UI 修复(v1.1.0) | ||
| │ ├── pp-doctor/ ← opt-in | ||
| │ └── pp-style/ ← opt-in | ||
| ├── docs/ | ||
| │ ├── pixel-print-intro.md ← 简介 | ||
| │ ├── pixel-print-architecture.md ← 技术讲解 | ||
| │ ├── design-guide.md ← 给设计师的命名规范 | ||
| │ └── d2c-health-check-spec.md ← 体检规则源 | ||
| └── package.json | ||
| ``` | ||
| --- | ||
| ## 故障排查 | ||
| | 现象 | 入口 | | ||
| |------|------| | ||
| | 切出来的图带画板背景色 / 光晕外扩 | `/v1/images` 必须带 `use_absolute_bounds=true`(主 SKILL §4.4) | | ||
| | `card-bg.png` 把 `bg-bg` + `bgc-选中框` 揉成一张 | bgc- 嵌在 bg- 子树是错误结构(doctor NAM013) | | ||
| | `bg-list.png` 把列表项内容印进背景 | `sub-scrolly-` 必须递归子层不能整体导出(主 SKILL §4.4 自检 4 行) | | ||
| | Figma token 过期 / 失败 | 走 verify-token 探针;失败终止,用户重生 token 后重跑 | | ||
| | `position: fixed` 元素跟着祖先滚动 | 祖先链有 `transform` / `filter` / `blur`(doctor LAY013) | | ||
| | RN 产物尺寸 ×2 视觉偏大 | 早期 h5 残留;v1.0.0 起 rn 硬编码 `scale=1` | | ||
| | `doctor.run()` 函数找不到 | SKILL.md 是 LLM 操作手册,不是可执行代码(见 [architecture.md §3](./docs/pixel-print-architecture.md#3-核心抽象skill-是-llm-操作手册不是可执行代码)) | | ||
| | 局部修复找不到 target | 先确认 `.d2c-cache/last-page.json` 存在;不存在说明还没跑过整页主 SKILL | | ||
| | 缓存出问题 / 想重来 | `npx @double-coding/pixel-print clean-cache` | | ||
| 更多历史 bug 与修订见 [`.Knowledge/topics/pp-d2c.md`](./.Knowledge/topics/pp-d2c.md)。 | ||
| --- | ||
| ## 版本历史 | ||
| | 版本 | 里程碑 | | ||
| |---|---| | ||
| | **v1.1.0** | **新增 `pp-fix-partial` 局部修复 skill + `.d2c-cache/last-page.json` + `pp-strip-nodeid` 存 anchor 档案 + `clean-cache` 命令 + init [1/8] 平铺一层** | | ||
| | v1.0.3 | RN 页面根强制 ScrollView 骨架 + fixed 分层贴屏 + bg- 铺满用 Figma 事实尺寸 | | ||
| | v1.0.2 | Token 迁到 `.env`;`sub-` FIXED 高度 → `min-height` 防塌陷;冗余嵌套 autoLayout 属性向内层下穿 | | ||
| | **v1.0.0** | **首个稳定版**;GitHub 上线 `double-coding-lab/PixelPrint`;`font-` 前缀移除 | | ||
| | v0.4.0 | rebrand 到 `@double-coding/pixel-print`;RN 分支独立 + adapter 机制 + rpx 响应式包装 + reference.md 手册机制 | | ||
| | v0.3.x | Figma MCP → REST API 迁移(figma.mjs);token 探针取代 whoami;新增 `end-` / `input-` 前缀;页面根 `min-height: max(..., 100vh)` | | ||
| | v0.2.x | 图层前缀体系泛化、doctor 体检、token 兜底链、嵌套 sub-、bgc- 盒级 CSS、CSS-able 自检、`fixed-` 前缀 | | ||
| 架构决策 + 每个变化的触发原因见 [`docs/pixel-print-architecture.md §12`](./docs/pixel-print-architecture.md#12-演化史为什么是今天的样子)。 | ||
| --- | ||
| ## 开发与维护 | ||
| - **本仓库**:D2C 工具源码(SKILL 模板 / install.js / adapter 预设 / 文档) | ||
| - **业务项目**:通过 `npx @double-coding/pixel-print init` 拉 SKILL 到 `.claude/skills/` | ||
| - **给设计师同步规范**:把 [`docs/design-guide.md`](./docs/design-guide.md) 发过去,让他们按规范命名图层。**开发对接前优先让设计师改**,比开发自己改效率高 10 倍以上。 | ||
| --- | ||
| ## License | ||
| MIT © double-coding-lab |
@@ -9,3 +9,3 @@ { | ||
| "merge": { | ||
| "mode": "component" | ||
| "mode": "flat" | ||
| }, | ||
@@ -12,0 +12,0 @@ "unit": { |
@@ -9,3 +9,3 @@ { | ||
| "merge": { | ||
| "mode": "component" | ||
| "mode": "flat" | ||
| }, | ||
@@ -12,0 +12,0 @@ "unit": { |
| # pp-d2c 命名与实现规则 | ||
| > **v0.3.6(2026-08-07)**:新增 §四a「父容器盒级装饰兜底(默认开)」;§八 追加「TEXT 多层 fills 处理」(多个可见 SOLID 取末位);配套 §十二禁止项 4 条。 | ||
| 设计稿还原时的**命名规则、图层解析规则、图片/字体/单位/框架规则**参考手册。 | ||
@@ -71,3 +73,3 @@ 无执行流程,直接按需查阅对应章节。 | ||
| | `bgc-` | 背景色/盒级装饰 | fills/strokes/cornerRadius/effects 写入**父元素** CSS,自身不生成 HTML | | ||
| | `btn-` | 可点击区域 | 在内容外包一层可点击容器 | | ||
| | `btn-` | 可点击区域 | 在内容外包一层可点击容器;自身若命中「父容器盒级装饰兜底」(§四a)→ 直接把 fills/strokes/cornerRadius/effects 写到自己的容器 CSS,**不建 `bg-` 子层** | | ||
| | `scrollx-` | 横向滚动容器 | `overflow-x: auto` + 隐藏滚动条,**继续递归子层** | | ||
@@ -88,5 +90,6 @@ | `scrolly-` | 纵向滚动容器 | `overflow-y: auto` + 隐藏滚动条,**继续递归子层** | | ||
| 7. 无内容前缀 → 走兜底规则 | ||
| 8. 有 `btn-` → 把渲染结果包裹在可点击容器内 | ||
| 9. 有 `scrollx-` / `scrolly-` → 给当前容器加 overflow 样式(不新增 wrapper) | ||
| 10. 有 `fixed-` → 在最终容器上加 `position: fixed` + constraints 推断定位值 | ||
| 8. **父容器盒级装饰兜底**(§四a):任意会生成容器的节点(含 `btn-` / `sub-` / `block-` / 无前缀 FRAME/GROUP),若命中 §四a 条件 → 把节点自身的 fills/strokes/cornerRadius/effects **写到自己的容器 CSS**(不是父元素),不切图、不要求 `bg-` 子层 | ||
| 9. 有 `btn-` → 把渲染结果包裹在可点击容器内 | ||
| 10. 有 `scrollx-` / `scrolly-` → 给当前容器加 overflow 样式(不新增 wrapper) | ||
| 11. 有 `fixed-` → 在最终容器上加 `position: fixed` + constraints 推断定位值 | ||
@@ -172,2 +175,61 @@ --- | ||
| ## 四a、父容器盒级装饰兜底(默认开,v0.3.6 新增) | ||
| **目的**:任何普通容器(FRAME / GROUP / COMPONENT)自身带背景色、渐变、圆角、投影时,直接把这些属性写到自己的容器 CSS 上;**不需要**在下面单独建 `bg-*` 子图层再切图。这条规则针对"设计师自然命名 + Figma 原生装饰"的常见场景,`btn-` 是最典型代表。 | ||
| ### 命中条件(全部满足才算命中) | ||
| | 检查项 | 条件 | | ||
| |-------|------| | ||
| | 前缀 | 节点自身**不含** `img-` / `bg-` / `x-` 前缀(`btn-` / `sub-` / `block-` / `fixed-` / `scrollx-` / `scrolly-` / `end-` / `input-` / 无前缀都算) | | ||
| | fills | 允许:空 / 单层或多层 SOLID / 单层 GRADIENT_LINEAR / GRADIENT_RADIAL;**不允许**:任何一层是 IMAGE | | ||
| | strokes | 空 / 单层 SOLID(gradient stroke 允许,但按 bgc- 规则降级为 `box-shadow`) | | ||
| | effects | 空 / 全部是 DROP_SHADOW / INNER_SHADOW / LAYER_BLUR / BACKGROUND_BLUR | | ||
| | cornerRadius / rectangleCornerRadii | 任意值(含 0) | | ||
| | 子树 | **无嵌套形状**:不含 BOOLEAN_OPERATION / VECTOR / MASK / ELLIPSE 复合形状节点;不含内层 `img-` / `bg-` 命中的位图节点(TEXT / 普通嵌套 FRAME / 兄弟 `bgc-` 都不算破坏纯净度) | | ||
| 命中后:**不切图**,把节点自身的 fills / strokes / cornerRadius / effects 按 §四「bgc- 规则」的映射表直接写到**当前节点生成的容器 CSS 上**(注意:`bgc-` 是写到"父元素",本规则是写到"自身容器",区别只在于装饰是节点自己的还是子节点的)。 | ||
| 不命中(例如 fills 含 IMAGE,或子树含 vector 复合形状):正常走「必须切图」的分支(`img-`/`bg-` 前缀 → §三;无前缀 → §二兜底为 `<img>`)。 | ||
| ### 反向自检 3 行(sub-agent 处理此类节点前必须输出) | ||
| ``` | ||
| · 节点前缀:{prefixes}(非 img-/bg-/x- 才可能命中) | ||
| · fills/strokes/effects/子树是否纯净?{是/否}(对照上方 5 项条件,逐一勾选) | ||
| · 走 CSS 还是切图?{CSS / 切图}(命中 → CSS;不命中 → 切图,走 §三/§二) | ||
| ``` | ||
| 任意一项与实际不符即停下重做。 | ||
| ### 与 `bg-` 子层规则的兼容性 | ||
| - **允许旧命名**:设计师主动建 `bg-*` 子层是**旧规范**,仍然完全合法;此时子层按 §三 切图,父容器只需 flex 布局 + 定尺寸 | ||
| - **默认打开新规则**:设计师**没建** `bg-*` 子层,节点自身直接带背景/渐变/投影 → 按本规则走 | ||
| - **doctor NAM024(v0.3.6 新增)**:命中本规则的父容器下若仍存在 `bg-*` 子层 → warn(冗余告警,建议移除子层,改由父容器 CSS 表达) | ||
| ### 案例(对照) | ||
| **旧规范**(依然合法): | ||
| ``` | ||
| btn-qukankan (FRAME, fills=[]) ← 自己没背景 | ||
| └── bg-btn-qukankan (RECTANGLE, fills=[渐变]) ← 单独子层切图 or CSS | ||
| └── TEXT "去看看" | ||
| ``` | ||
| **新规范默认场景**(本规则命中): | ||
| ``` | ||
| btn-qukankan (FRAME, fills=[GRADIENT_LINEAR], cornerRadius=8) ← 自己带渐变+圆角 | ||
| └── TEXT "去看看" | ||
| → CSS: | ||
| .btn-qukankan { | ||
| background-image: linear-gradient(...); | ||
| border-radius: 8px; | ||
| display: flex; align-items: center; justify-content: center; | ||
| } | ||
| ``` | ||
| --- | ||
| ## 五、fixed- 规则 | ||
@@ -294,2 +356,27 @@ | ||
| ### TEXT 多层 fills 处理(v0.3.6 新增) | ||
| Figma 里同一个 TEXT 节点可以叠多层 fills(设计师改颜色时忘记删旧层是常见情况)。取字色规则: | ||
| | fills 情形 | 取值 | | ||
| |-----------|------| | ||
| | 单层 SOLID | 直接取 | | ||
| | 多层 SOLID,且都 `visible !== false` | **按 fills[] 顺序取最末位**(Figma 渲染顺序:后写的覆盖先写的,视觉上看到的就是最末位) | | ||
| | 多层 SOLID,有些 `visible === false` | **跳过所有 visible:false**,再按上一条取"剩下的最末位" | | ||
| | 单层 GRADIENT | 按 `background-clip: text` + `color: transparent` 处理(不适用 RN,RN 降级为末位近似 SOLID + QA 告警) | | ||
| | 多层混合(SOLID + GRADIENT) | 按 Figma 渲染顺序合成;若 GRADIENT 在最上层 → `background-clip: text`;SOLID 在最上层 → 取 SOLID 色 | | ||
| | fills 为空 | 用 Figma 默认黑 `#000` + QA 告警 | | ||
| **反向自检 1 行**(sub-agent 生成 TEXT 前必须输出): | ||
| ``` | ||
| · TEXT 字色:{finalColor}(fills 有 {N} 层可见 SOLID,取末位;#492b0d 覆盖为 #ffffff 这类要按 Figma 视觉走) | ||
| ``` | ||
| **doctor NAM025(v0.3.6 新增)**:TEXT 节点的 `fills` 有 ≥2 个可见 SOLID → info 提示:按渲染顺序取末位,防止取错色。 | ||
| **典型案例**:`136:45728`("去看看")fills = `[#492b0d, #ffffff]` → 取 `#ffffff`,而不是 `#492b0d`。 | ||
| --- | ||
| Bold / Heavy 统一使用固定 CDN,不下载到本地: | ||
@@ -393,2 +480,5 @@ | ||
| - 禁止在多个 block 样式里各自重复 `@font-face`(集中到页面根样式声明一次) | ||
| - 禁止 TEXT 节点有多层可见 SOLID fills 时直接取 `fills[0]`(必须按 §八「TEXT 多层 fills 处理」取末位) | ||
| - 禁止父容器命中 §四a 时仍强行要求设计师建 `bg-*` 子层(默认打开新规则,两种命名都合法) | ||
| - 禁止 `img-` / `bg-` / 裸词 `img` / 裸词 `bg` 命中时跳过 REST API 调用直接复用同名文件(必须先查 images.json 的 md5,不一致或不存在就重切;见 pp-d2c §4.4) | ||
| - 禁止用相对路径下载图片(`-o` 必须是绝对路径) |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
630725
22.19%2064
9.03%80
-76.88%12
20%