@double-coding/pixel-print
Advanced tools
| # pp-fix-partial Skill | ||
| > 局部 UI 修复:整页已经 D2C 出码但某一小块视觉/交互不对时,只重跑那一块,不重刷整页。利用 `.d2c-cache/` 复用 figma 元数据 + 图片资产,通过 hash 对比防污染。 | ||
| ## 触发条件 | ||
| - 用户说「修复这块」/「这一小块不对」/「重新生成 xxx 区域」/「fix 那个按钮」 | ||
| - 直接 `$pp-fix-partial [figmaUrl?] [目标描述?]` | ||
| - `pp-d2c` / `pp-d2c-rn` 主流程结束后,用户对某个 block 不满意主动触发 | ||
| ## 执行模型 | ||
| **SKILL.md 是 LLM 操作手册,不是可执行代码**。所有 "读 X"/"写 Y"/"调 figma.mjs" 都是给 agent 的操作指令,由 agent 用 Read/Bash/Edit 工具落地。无任何 `partial.run()` 一类的伪代码 API。 | ||
| --- | ||
| ## 步骤 0:确定项目上下文 | ||
| ### 0.1 读 config | ||
| Read 项目根 `pp-d2c.config.json`,拿到: | ||
| - `project.framework`(react / rn)→ 决定用 pp-d2c 还是 pp-d2c-rn 的图层解析规则 | ||
| - `project.styleFormat`(scss / scss-modules / stylesheet / …) → 决定改样式时用哪种语法 | ||
| - `unit.figmaBase / outputBase / scale` → 换算尺寸 | ||
| - `output.dir`(默认 `pages/`)→ 匹配项目里的代码位置 | ||
| - `images.assetsDir / imageBaseUrl` → 补图片资源 | ||
| - rn 分支额外读 `adapter` + `unit.responsive` | ||
| ### 0.2 读 last-page 记录 | ||
| Read `.d2c-cache/last-page.json`,拿最近一次成功出码的元数据: | ||
| ```json | ||
| { | ||
| "figmaUrl": "https://figma.com/design/<fileKey>?node-id=138-1797", | ||
| "fileKey": "<fileKey>", | ||
| "rootNodeId": "138:1797", | ||
| "outputDir": "pages/Italo", | ||
| "outputEntryFile": "pages/Italo/index.jsx", | ||
| "figmaTreeHash": "sha1:abc123...", | ||
| "generatedAt": "2026-08-05T10:23:44Z", | ||
| "framework": "react", | ||
| "styleFormat": "scss" | ||
| } | ||
| ``` | ||
| **文件不存在**:输出错误「未找到 `.d2c-cache/last-page.json`,请先跑一次 pp-d2c / pp-d2c-rn 主流程再回来做局部修复」,终止。 | ||
| ### 0.3 校验 config 与 last-page 一致 | ||
| - `config.project.framework === last-page.framework`?不一致 → warn「项目 framework 已切换,建议重跑整页而非局部修复」,给用户 y/n | ||
| - `config.output.dir` 与 `last-page.outputDir` 前缀匹配?不匹配 → warn 同上 | ||
| --- | ||
| ## 步骤 1:解析用户参数,确定 target | ||
| ### 用户输入的 3 种形态 | ||
| | 形态 | 举例 | 处理 | | ||
| |---|---|---| | ||
| | 明确 figmaUrl | `pp-fix-partial https://figma.com/design/XXX?node-id=138-2050` | 直接用该 URL 里的 `fileKey + nodeId` 作 target | | ||
| | 无参 | `pp-fix-partial` | target = last-page.rootNodeId 的**子节点**;后面弹清单让用户选 | | ||
| | 自然语言描述 | `pp-fix-partial 顶部导航栏` / `修一下卡片` | target = 在 last-page 子树里按 name 做 fuzzy match,列 3-5 个候选让用户选 | | ||
| ### 1.1 形态 A:明确 figmaUrl | ||
| - 解析 fileKey / nodeId(与主 SKILL §4.4 URL 解析规则同款) | ||
| - **强制**:`fileKey` 必须与 `last-page.fileKey` 相等 | ||
| - 不相等 → error「当前 last-page 是 fileKey=X,但你给的 URL 是 fileKey=Y,不是同一份设计稿,不能局部修复,请重跑主流程」,终止 | ||
| - 相等 → target = { fileKey, nodeId, source: 'user-url' } | ||
| ### 1.2 形态 B:无参 | ||
| - 调 figma.mjs 拉 last-page.rootNodeId 的**直接子节点**列表(只到一层子,不递归) | ||
| - 输出候选清单: | ||
| ``` | ||
| 最近实现的页面 pages/Italo/ 有以下子块,选一块要修复的(输编号或名字): | ||
| 1. sub-header-nav (顶部导航,138:1798) | ||
| 2. sub-banner-italo (主视觉横幅,138:1811) | ||
| 3. sub-tab-list (分类 tab,138:1830) | ||
| 4. sub-card-list (卡片列表,138:1900) | ||
| 5. sub-footer (底部,138:2050) | ||
| ``` | ||
| - 等用户选完 → target = { fileKey: last-page.fileKey, nodeId: 选中的, source: 'auto-child' } | ||
| ### 1.3 形态 C:自然语言描述 | ||
| - 拉 last-page 整个子树 name 列表 | ||
| - 对用户描述做 fuzzy match(忽略 sub-/block-/img- 等前缀),取 top 5 | ||
| - 输出候选让用户选(格式同 1.2) | ||
| - 未命中 → 回退到形态 B 完整清单 | ||
| --- | ||
| ## 步骤 2:缓存分层与作废策略 | ||
| ### 2.1 缓存目录约定 | ||
| ``` | ||
| .d2c-cache/ | ||
| ├── figma/ | ||
| │ └── <fileKey>-<nodeId>.json # figma 节点子树 REST 返回,带 { figmaTreeHash, mtime } | ||
| ├── images/ | ||
| │ └── <fileKey>-<nodeId>-<imgId>.png # 切图缓存,与 figma 节点 hash 绑定 | ||
| ├── anchors/ | ||
| │ └── <pageDir 相对 outputDir 的下划线名>.json # 由 pp-strip-nodeid 生成的锚点档案 | ||
| └── last-page.json # 由主 SKILL 生成 | ||
| ``` | ||
| **关键不变式**: | ||
| - 所有缓存文件路径必带 `<fileKey>` 前缀 → 换 fileKey 天然隔离,不会跨稿子污染 | ||
| - 缓存作废三种触发:hash 变了 / mtime 超 7 天 / 用户跑 `pp-d2c clean-cache` | ||
| ### 2.2 hash 对比 | ||
| 调 `bin/figma.mjs fetch-node --file-key <fileKey> --node-id <nodeId>` 拉 target 最新子树 → 算子树 fingerprint hash(递归 SHA1 节点 id + name + style props + children hash): | ||
| ``` | ||
| [fix-partial] fetch target 138:1830 最新元数据... | ||
| [fix-partial] 最新 hash: sha1:def456 | ||
| [fix-partial] 缓存 hash: sha1:abc123 | ||
| [fix-partial] hash 不一致 → invalidate 图片缓存,重新导出 | ||
| ``` | ||
| **hash 一致** → 用缓存(figma JSON + 图片 PNG 都跳过网络); | ||
| **hash 不一致** → 删该 nodeId 对应的所有缓存文件,重拉。 | ||
| ### 2.3 mtime TTL | ||
| 打开缓存文件时:如果 `Date.now() - meta.mtime > 7 * 24 * 3600 * 1000` → 作废重拉。防止 figma 长期没动的稿子静默过期(hash 一致但 figma 侧其实已经删了那个节点的场景很少见,但兜底一下)。 | ||
| ### 2.4 防污染硬规则 | ||
| 1. **禁止**在同一份缓存文件里存"上一次"和"这一次"两份数据 → 每次覆写(单值语义) | ||
| 2. **禁止**跨 fileKey 复用图片 → 图片文件名带 fileKey 前缀,天然隔离 | ||
| 3. **禁止**局部修复期间修改 `.d2c-cache/last-page.json` → 该文件是主 SKILL 独占写入,fix-partial 只读 | ||
| --- | ||
| ## 步骤 3:在项目代码里精确定位 target 对应的 JSX 段 | ||
| ### 3.1 优先走 anchor 档案(推荐路径) | ||
| Read `.d2c-cache/anchors/<page>.json`: | ||
| ```json | ||
| { | ||
| "138:1830": { | ||
| "file": "pages/Italo/blocks/sub-tab-list/index.jsx", | ||
| "start": 12, | ||
| "end": 45, | ||
| "componentName": "TabList" | ||
| }, | ||
| "138:1811": { | ||
| "file": "pages/Italo/blocks/sub-banner-italo/index.jsx", | ||
| "start": 8, | ||
| "end": 62, | ||
| "componentName": "BannerItalo" | ||
| } | ||
| } | ||
| ``` | ||
| 命中 target.nodeId → 直接得到 file + startLine + endLine + componentName。 | ||
| ### 3.2 anchor 档案不存在或未命中时:走 `data-node-id` 反查 | ||
| 老项目、或用户跑过 pp-strip-nodeid 但没留 anchor 档案 → grep `<file>` 找 `data-node-id="138:1830"` / `// node-id:138-1830`: | ||
| ```bash | ||
| grep -rn 'data-node-id="138:1830"' pages/Italo/ | ||
| ``` | ||
| 命中 → 从行号往前找最近的 JSX 元素开始标签,往后找匹配的闭合标签,作为 [startLine, endLine]。 | ||
| ### 3.3 都找不到:让用户选 | ||
| 输出候选组件列表(按 outputDir 下的 `sub-*` 目录名列出): | ||
| ``` | ||
| 未能精确定位 138:1830,请手动选一个组件目录: | ||
| 1. pages/Italo/blocks/sub-header-nav/ | ||
| 2. pages/Italo/blocks/sub-banner-italo/ | ||
| 3. pages/Italo/blocks/sub-tab-list/ ← 建议选这个(name 匹配) | ||
| ... | ||
| ``` | ||
| --- | ||
| ## 步骤 4:出新版本代码 | ||
| ### 4.1 交给主 SKILL 的 sub-agent 流程 | ||
| 调 pp-d2c(h5)/ pp-d2c-rn(rn)主 SKILL 的**§步骤 4「sub-agent 实现单个 block」**规则,但只处理这一个 block: | ||
| - 输入:target.nodeId + 该节点的 figma REST JSON(从缓存或最新拉取) | ||
| - 输出:一份新的 JSX + 样式代码(同套目录约定,写到 `.d2c-tmp/fix-partial-<nodeId>/`,不直接写用户代码) | ||
| - 遵守主 SKILL 的所有前缀规则(§4.0 / §4.3)、图片处理(§4.4)、图层解析(§4.3)、单位换算(§4.5)、rn 分支还要跑 §5.4(rpx)/ §5.5(adapter) | ||
| ### 4.2 视觉验收(强制,复用主 SKILL §6.0) | ||
| 按主 SKILL §6.0 逐叶子对比: | ||
| - 切 target 节点截图 → `.d2c-tmp/screenshots/fix-partial-<nodeId>-figma.png` | ||
| - 渲染新出码 → 截图 → `.d2c-tmp/screenshots/fix-partial-<nodeId>-code.png` | ||
| - 视觉打分:如果 diff 明显 → 回 §4.1 重出,不许直接替换 | ||
| --- | ||
| ## 步骤 5:精确替换用户项目里的旧代码 | ||
| **只替换 target 对应的那一段,其他不动**。 | ||
| ### 5.1 分两种情况 | ||
| - **step 3 走 anchor / grep 定位到了单文件** → 用 Edit 工具直接 `Edit(file, oldStr, newStr)` | ||
| - oldStr = 该文件 [startLine, endLine] 的原文 | ||
| - newStr = 步骤 4 生成的新版本 | ||
| - **step 3 让用户选了一个组件目录** → 该目录整个替换 | ||
| - 备份到 `.d2c-cache/fix-partial-backup/<nodeId>-<timestamp>/`(留 3 份滚动) | ||
| - 用新版本覆写该组件目录的所有文件 | ||
| ### 5.2 anchor 档案联动更新 | ||
| 替换后: | ||
| - 新代码行数会变,原 anchor 的 [start, end] 失效 → 重算 [newStart, newEnd] 写回 `anchors/<page>.json` | ||
| - 其他 nodeId 的 anchor 如果在同文件里且 start > 旧 endLine → 全部按行差平移 | ||
| ### 5.3 图片资产同步 | ||
| 新出码可能引入新图 → 图片文件名带 `<fileKey>-<nodeId>-<idx>` 前缀,与老图并存不会冲突。 | ||
| 老图如果被弃用 → 输出到"提示手动清理"清单,不自动删(避免误删)。 | ||
| --- | ||
| ## 步骤 6:输出修复报告 | ||
| ``` | ||
| ✅ 局部修复完成:sub-tab-list (138:1830) | ||
| 📁 改动文件:pages/Italo/blocks/sub-tab-list/index.jsx (行 12-45) | ||
| 🖼️ 新增图片:static/<fileKey>-138-1830-1.png | ||
| 🗑️ 可能弃用的老图(不自动删,请人工确认): | ||
| · static/<fileKey>-138-1830-0.png | ||
| 💾 缓存复用情况:figma JSON 复用 / 图片 1 张新导 / 1 张复用 | ||
| 📐 anchor 档案已更新:.d2c-cache/anchors/pages_Italo.json | ||
| 👀 请 git diff 复核,视觉验收通过再 commit | ||
| ``` | ||
| --- | ||
| ## 参数 | ||
| | 参数 | 说明 | | ||
| |------|------| | ||
| | `[figmaUrl]` | 明确要修的 figma 节点 URL(fileKey 必须与 last-page 一致) | | ||
| | `[目标描述]` | 自然语言 fuzzy match 子节点 name | | ||
| | `--dry-run` | 只走完步骤 1-4,不动用户代码;输出 diff 给用户看 | | ||
| | `--no-cache` | 强制忽略所有 `.d2c-cache/figma/*` 和 `images/*`,一切重拉 | | ||
| | `--force` | 跳过一致性校验(framework / outputDir 不匹配警告) | | ||
| --- | ||
| ## 禁止项 | ||
| - 禁止跨 fileKey 修复:`user-url.fileKey !== last-page.fileKey` → 直接终止,不给"跨稿子拼接"的口子 | ||
| - 禁止跳过 hash 对比直接用缓存:每次 fix-partial 必须先 fetch target 最新 → 算 hash → 对比缓存 | ||
| - 禁止改动 target 范围外的代码:即使发现同页面其他块也有问题,不管;让用户显式再跑一次 fix-partial | ||
| - 禁止改动 `.d2c-cache/last-page.json`:该文件是主 SKILL 独占写入 | ||
| - 禁止对没跑过主 SKILL 的项目直接使用:必须先有一次 pp-d2c/pp-d2c-rn 成功产出,才能局部修复 | ||
| - 禁止在 anchor 档案缺失且 data-node-id 已剥的项目里"猜"位置:让用户选具体目录,不做启发式硬替换 | ||
| - 禁止把 `sub-` 前缀的图层作为 target 时,只出内部代码不出 sub 组件外壳:target 是 sub- 就完整重新生成 sub 组件(目录 + 组件函数 + 样式);target 不是 sub- 就在最近的 sub 祖先里做局部 patch | ||
| - 禁止把整个 outputDir 备份到 git 追踪路径:备份只落 `.d2c-cache/fix-partial-backup/`,该目录已在 `.gitignore` 里(靠 `.d2c-cache/` 整体忽略) | ||
| - 禁止在 config `project.framework` 与 last-page 不一致时静默继续:必须显式警告 + 等用户 y/n | ||
| - 禁止把 `--no-cache` 当默认行为:缓存复用是本 skill 的核心价值,只有用户明确加 flag 才禁用 | ||
| --- | ||
| ## 与主 SKILL 的分工 | ||
| | 场景 | 用哪个 skill | | ||
| |---|---| | ||
| | 首次实现某页面 | `pp-d2c`(h5)/ `pp-d2c-rn`(rn) | | ||
| | 整页 figma 大改重出 | `pp-d2c` / `pp-d2c-rn`(不用本 skill) | | ||
| | **只有一小块要改** | **`pp-fix-partial`(本 skill)** | | ||
| | 上线前清 `data-node-id` | `pp-strip-nodeid` | | ||
| | 出码后想清缓存 | `npx @double-coding/pixel-print clean-cache` | | ||
| **规则**:凡是「涉及≥ 2 个 sub-block」的修改,退回主 SKILL 重跑;本 skill 只做 1 个 target 节点的局部修复,不解决"多块联动"问题。 |
+148
-115
@@ -146,9 +146,18 @@ #!/usr/bin/env node | ||
| function installFiles(forceSkills = false, skipConfig = false, options = {}) { | ||
| const { skipRn = false } = options | ||
| const { skipRn = false, skipH5 = false } = options | ||
| console.log('\npp-d2c: installing files...\n') | ||
| 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 过来即可 | ||
| const OPT_IN_ONLY = new Set(['pp-style', 'pp-doctor']) | ||
| for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) { | ||
| if (!entry.isDirectory()) continue | ||
| // 按 framework 对称过滤:h5 项目跳 pp-d2c-rn,rn 项目跳 pp-d2c | ||
| // 目的是避免另一分支的主 SKILL 污染当前项目.claude/skills/,让 Claude Code | ||
| // 只看到匹配当前 framework 的主 SKILL(pp-strip-nodeid / pp-fix-partial 等辅助 SKILL 两端通用,继续装) | ||
| if (skipRn && entry.name === 'pp-d2c-rn') continue | ||
| if (skipH5 && entry.name === 'pp-d2c') continue | ||
| if (OPT_IN_ONLY.has(entry.name)) continue | ||
| copyDir(path.join(skillsSrc, entry.name), path.join(skillsDst, entry.name), forceSkills) | ||
@@ -310,84 +319,101 @@ } | ||
| const framework = await pickOrUse('[1/8] 项目框架', p.framework, ['react', 'rn'], 'react') | ||
| // ─── 平铺项目框架 + 方案 ────────────────────────────── | ||
| // 把 H5(React)的样式方案 + RN 的 adapter 预设全部铺开成同一层选项, | ||
| // 用户一次选中就同时敲定 framework / styleFormat(H5)/ adapterCfg(RN), | ||
| // 后续 [2a/2b/2c] 或 [2.1] 子问题不再分开问。 | ||
| const presets = loadAdapterPresets() | ||
| const flatOptions = [] | ||
| // H5 stylesheet 变体:三种预处理语法 × 两种 modules(scss/less/css × 是否 modules) | ||
| for (const syn of ['scss', 'less', 'css']) { | ||
| flatOptions.push({ label: `React / ${syn.toUpperCase()}`, framework: 'react', styleFormat: syn }) | ||
| flatOptions.push({ label: `React / ${syn.toUpperCase()} Modules`, framework: 'react', styleFormat: `${syn}-modules` }) | ||
| } | ||
| flatOptions.push({ label: 'React / Tailwind', framework: 'react', styleFormat: 'tailwind' }) | ||
| flatOptions.push({ label: 'React / Inline Style', framework: 'react', styleFormat: 'inline' }) | ||
| // RN 各 preset,再加"自定义"/"不启用"两个兜底 | ||
| for (const preset of presets) { | ||
| flatOptions.push({ label: `RN / ${preset.name}`, framework: 'rn', adapterKind: 'preset', preset }) | ||
| } | ||
| flatOptions.push({ label: 'RN / 自定义标签映射(后续手填)', framework: 'rn', adapterKind: 'custom' }) | ||
| flatOptions.push({ label: 'RN / 不启用组件映射(保留 RN 原写法)', framework: 'rn', adapterKind: 'off' }) | ||
| // 先问完 framework 再复制 SKILL 文件:react 项目不需要装 rn SKILL,反之亦然 | ||
| installFiles(true, true, { skipRn: framework !== 'rn' }) // init: 强制覆盖 skill 文件;跳过 config 复制 | ||
| // 反推现有 config 对应的平铺项 label(能命中就走"沿用",避免每次 init 都重选) | ||
| let existingLabel = null | ||
| if (p.framework === 'react') { | ||
| const found = flatOptions.find(o => o.framework === 'react' && o.styleFormat === p.styleFormat) | ||
| if (found) existingLabel = found.label | ||
| } else if (p.framework === 'rn') { | ||
| const ad = existing.adapter || {} | ||
| const hasMap = ad.tagMap && Object.keys(ad.tagMap).length > 0 | ||
| if (ad.enabled === false) { | ||
| existingLabel = flatOptions.find(o => o.adapterKind === 'off')?.label | ||
| } else if (!hasMap) { | ||
| existingLabel = flatOptions.find(o => o.adapterKind === 'custom')?.label | ||
| } else { | ||
| // 按 View 目标标签反查匹配 preset | ||
| const viewTarget = ad.tagMap.View | ||
| const hit = flatOptions.find(o => o.preset && o.preset.adapter && o.preset.adapter.tagMap && o.preset.adapter.tagMap.View === viewTarget) | ||
| if (hit) existingLabel = hit.label | ||
| } | ||
| } | ||
| // 样式方案分两个维度问:方式 + (仅 stylesheet 时) 预处理语法 + 是否走 module | ||
| // 最终 styleFormat 落值规范见 SKILL §0「样式方案标识符」 | ||
| let selectedLabel, isReused = false | ||
| const flatLabels = flatOptions.map(o => o.label) | ||
| if (existingLabel) { | ||
| logUseExisting('[1/8] 项目框架 + 方案', existingLabel) | ||
| selectedLabel = existingLabel | ||
| isReused = true | ||
| } else { | ||
| selectedLabel = await select('[1/8] 项目框架 + 方案', flatLabels, flatLabels[0]) | ||
| } | ||
| const selectedOpt = flatOptions.find(o => o.label === selectedLabel) | ||
| const framework = selectedOpt.framework | ||
| // 选完 framework 才复制 SKILL(h5 项目跳 pp-d2c-rn,rn 项目跳 pp-d2c;避免另一分支主 SKILL 污染) | ||
| installFiles(true, true, { skipRn: framework !== 'rn', skipH5: framework === 'rn' }) | ||
| let styleFormat | ||
| let adapterCfg = null // rn 分支下才会填,react 分支保持 null(config 不写 adapter 段) | ||
| let pickedPreset = null // 若走预设分支,记录命中的 preset 对象;供 helper 复制阶段选源 | ||
| let adapterCfg = null | ||
| let pickedPreset = null | ||
| let responsiveCfg = null | ||
| if (framework === 'rn') { | ||
| // rn 分支样式方案写死 StyleSheet.create + 行内 style,不再询问 | ||
| // 理由:styled-components / nativewind 需要额外依赖且 SKILL 侧未落地生成模板,现阶段只支持 stylesheet | ||
| // rn 分支样式方案写死 stylesheet(StyleSheet.create + 行内 style) | ||
| styleFormat = 'stylesheet' | ||
| console.log(' [2/8] 样式方案: \x1b[36mstylesheet\x1b[0m \x1b[90m(rn 分支固定用 StyleSheet.create + 行内 style)\x1b[0m') | ||
| // ─── 【新增】组件框架映射引导 ───────────────────────── | ||
| // adapter 把 RN 原生标签映射到 xtaro / taro / 其他框架 | ||
| // 已有完整 adapter 配置 → 直接沿用,不再询问 | ||
| // 没配置过 → 一层选择:不启用 / xtaro / taro / pure RN / 自定义 | ||
| const existingAdapter = existing.adapter || {} | ||
| const hasExistingMap = existingAdapter.tagMap && Object.keys(existingAdapter.tagMap).length > 0 | ||
| if (hasExistingMap) { | ||
| logUseExisting('[2.1/8] 组件框架映射', `${Object.keys(existingAdapter.tagMap).length} 条(沿用)`) | ||
| // 从平铺项组装 adapterCfg | ||
| // 若是沿用已有 config 且 adapter 存在 → 保留用户可能的手动定制(propMap/importMap) | ||
| if (isReused && existing.adapter && (existing.adapter.tagMap || existing.adapter.enabled === false)) { | ||
| const oldAd = existing.adapter | ||
| adapterCfg = { | ||
| enabled: true, | ||
| tagMap: existingAdapter.tagMap || {}, | ||
| importMap: existingAdapter.importMap || {}, | ||
| propMap: existingAdapter.propMap || {}, | ||
| reactImport: existingAdapter.reactImport || 'react' | ||
| enabled: oldAd.enabled !== false, | ||
| tagMap: oldAd.tagMap || {}, | ||
| importMap: oldAd.importMap || {}, | ||
| propMap: oldAd.propMap || {}, | ||
| reactImport: oldAd.reactImport || 'react' | ||
| } | ||
| // 沿用旧 tagMap/propMap,但 referenceDoc + _presetSource 需要以最新 preset 为准 | ||
| // 老 config 里可能根本没这两字段(v1.0.1 之前 install.js 漏写),必须现在补上, | ||
| // 否则 SKILL §5.5.3c 拿不到手册路径 → 复杂差异处理全部 no-op | ||
| if (existingAdapter.referenceDoc) adapterCfg.referenceDoc = existingAdapter.referenceDoc | ||
| if (existingAdapter._presetSource) adapterCfg._presetSource = existingAdapter._presetSource | ||
| // 兜底回填:按 tagMap 反查匹配的 preset(用 View→? 这条最能定位框架) | ||
| if (!adapterCfg.referenceDoc || !adapterCfg._presetSource) { | ||
| const presets = loadAdapterPresets() | ||
| const viewTarget = adapterCfg.tagMap.View | ||
| const hit = viewTarget | ||
| ? presets.find(p => p.adapter && p.adapter.tagMap && p.adapter.tagMap.View === viewTarget) | ||
| : null | ||
| if (hit) { | ||
| if (!adapterCfg.referenceDoc && hit.referenceDoc) adapterCfg.referenceDoc = hit.referenceDoc | ||
| if (!adapterCfg._presetSource) adapterCfg._presetSource = PRESETS_DIR | ||
| console.log(` → 补齐 adapter.referenceDoc / _presetSource(反查匹配 ${hit.name} 预设)`) | ||
| } else { | ||
| console.log(' ⚠️ 未能反查到匹配的 preset,adapter.referenceDoc / _presetSource 保持缺失;SKILL §5.5.3c 复杂差异处理将 no-op') | ||
| } | ||
| if (oldAd.referenceDoc) adapterCfg.referenceDoc = oldAd.referenceDoc | ||
| if (oldAd._presetSource) adapterCfg._presetSource = oldAd._presetSource | ||
| // 兜底补齐 referenceDoc / _presetSource(v1.0.1 之前 install.js 漏写) | ||
| if (selectedOpt.preset && (!adapterCfg.referenceDoc || !adapterCfg._presetSource)) { | ||
| if (!adapterCfg.referenceDoc && selectedOpt.preset.referenceDoc) adapterCfg.referenceDoc = selectedOpt.preset.referenceDoc | ||
| if (!adapterCfg._presetSource) adapterCfg._presetSource = PRESETS_DIR | ||
| console.log(` → 补齐 adapter.referenceDoc / _presetSource(匹配 ${selectedOpt.preset.name} 预设)`) | ||
| } | ||
| if (selectedOpt.preset) pickedPreset = selectedOpt.preset | ||
| } else if (selectedOpt.adapterKind === 'off') { | ||
| adapterCfg = { enabled: false, tagMap: {}, importMap: {}, propMap: {}, reactImport: 'react' } | ||
| } 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') | ||
| } else { | ||
| // 扫 templates/adapter-presets/ 目录,把 preset.name 与"不启用"/"自定义"平铺在同一层选择 | ||
| const presets = loadAdapterPresets() | ||
| const OFF_LABEL = '不启用(保留 RN 原写法)' | ||
| const CUSTOM_LABEL = '自定义(后续手填 tagMap/importMap/propMap)' | ||
| const choices = [OFF_LABEL, ...presets.map(p => p.name), CUSTOM_LABEL] | ||
| // 默认值:优先取第一个 preset(通常是 xtaro),没预设时用 OFF | ||
| const defaultChoice = presets[0]?.name || OFF_LABEL | ||
| const picked = await select('[2.1/8] 选择组件框架映射', choices, defaultChoice) | ||
| if (picked === OFF_LABEL) { | ||
| adapterCfg = { enabled: false, tagMap: {}, importMap: {}, propMap: {}, reactImport: 'react' } | ||
| } else if (picked === CUSTOM_LABEL) { | ||
| adapterCfg = { enabled: true, tagMap: {}, importMap: {}, propMap: {}, reactImport: 'react' } | ||
| console.log(' → adapter.enabled=true,请后续在 pp-d2c.config.json 手动填 tagMap / importMap / propMap') | ||
| } else { | ||
| const hit = presets.find(p => p.name === picked) | ||
| adapterCfg = { ...hit.adapter } | ||
| // 把 preset 顶层的 referenceDoc + preset 目录绝对路径写入 adapter 段 | ||
| // SKILL §5.5.3c 靠这两字段拼参考手册路径;缺任一 → §5.5.3c 直接 no-op 跳过复杂差异处理 | ||
| if (hit.referenceDoc) adapterCfg.referenceDoc = hit.referenceDoc | ||
| adapterCfg._presetSource = PRESETS_DIR | ||
| pickedPreset = hit | ||
| console.log(` → 已写入 ${hit.name} 预设(${hit.description || '见 templates/adapter-presets/README.md'})`) | ||
| } | ||
| const hit = selectedOpt.preset | ||
| adapterCfg = { ...hit.adapter } | ||
| if (hit.referenceDoc) adapterCfg.referenceDoc = hit.referenceDoc | ||
| adapterCfg._presetSource = PRESETS_DIR | ||
| pickedPreset = hit | ||
| console.log(` → 已写入 ${hit.name} 预设(${hit.description || '见 templates/adapter-presets/README.md'})`) | ||
| } | ||
| // ─── 【新增】响应式 rpx() 包装引导 ────────────────── | ||
| // RN 数值默认是 dp/pt(iOS pt / Android dp),不同屏宽下同一数值物理尺寸不同。 | ||
| // 启用 rpx 包装 → SKILL 在 layout / spacing / borderRadius / fontSize 类属性上调用 rpx(), | ||
| // 由 helper 按 Dimensions.get('window').width / figmaBase 线性缩放。 | ||
| // ─── 响应式 rpx() 包装引导 ───────────────────────── | ||
| const existingUnit = existing.unit || {} | ||
@@ -397,15 +423,8 @@ const existingResp = existingUnit.responsive || {} | ||
| const enableRespYn = await pickOrUse( | ||
| '[2.2/8] 是否启用响应式 rpx() 包装(按屏宽线性缩放尺寸)', | ||
| '[2/8] 是否启用响应式 rpx() 包装(按屏宽线性缩放尺寸)', | ||
| existingRespYn, ['Yes', 'No'], 'Yes' | ||
| ) | ||
| var responsiveCfg = null // 到 config 阶段合并到 unit 段 | ||
| if (enableRespYn === 'Yes') { | ||
| const helperImport = await inputOrUse( | ||
| '[2.3/8] rpx helper import 路径', | ||
| existingResp.helperImport, '@/utils/rpx' | ||
| ) | ||
| const helperName = await inputOrUse( | ||
| '[2.4/8] rpx helper 导出函数名', | ||
| existingResp.helperName, 'rpx' | ||
| ) | ||
| 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') | ||
| responsiveCfg = { enabled: true, helperImport, helperName } | ||
@@ -415,34 +434,6 @@ } else { | ||
| } | ||
| // RN 分支后面 mergeMode 等步骤标号顺延 | ||
| } else { | ||
| // 从现有 styleFormat 反推三个维度的当前值(兼容老 config) | ||
| const existing2a = (() => { | ||
| if (p.styleFormat === 'tailwind') return 'tailwind' | ||
| if (p.styleFormat === 'inline') return 'inline' | ||
| if (p.styleFormat && /^(scss|less|css)(-modules)?$/.test(p.styleFormat)) return 'stylesheet' | ||
| return null | ||
| })() | ||
| const existing2b = (() => { | ||
| if (!p.styleFormat) return null | ||
| const m = p.styleFormat.match(/^(scss|less|css)(-modules)?$/) | ||
| return m ? m[1] : null | ||
| })() | ||
| const existing2c = (() => { | ||
| if (!p.styleFormat) return null | ||
| return /-modules$/.test(p.styleFormat) ? 'Yes' : 'No' | ||
| })() | ||
| const styleMode = await pickOrUse('[2a/8] 样式方式', existing2a, | ||
| ['stylesheet', 'tailwind', 'inline'], 'stylesheet') | ||
| if (styleMode === 'stylesheet') { | ||
| const syntax = await pickOrUse('[2b/8] 预处理语法', existing2b, | ||
| ['scss', 'less', 'css'], 'scss') | ||
| const useModulesYn = await pickOrUse('[2c/8] 是否启用 css-modules', existing2c, | ||
| ['No', 'Yes'], 'No') | ||
| const useModules = useModulesYn === 'Yes' | ||
| styleFormat = useModules ? `${syntax}-modules` : syntax | ||
| } else { | ||
| styleFormat = styleMode | ||
| } | ||
| // react 分支的 styleFormat 已在 [1/8] 里选完,这里只做展示不再交互 | ||
| styleFormat = selectedOpt.styleFormat | ||
| console.log(` [2/8] 样式方案: \x1b[36m${styleFormat}\x1b[0m \x1b[90m(在 [1/8] 里已选定,不再单独询问)\x1b[0m`) | ||
| } | ||
@@ -687,2 +678,41 @@ | ||
| // ─── clean-cache ───────────────────────────────────────────── | ||
| // 递归删除 .d2c-cache/(figma / images / anchors / last-page.json)。 | ||
| // 用途:hash 对比坏了、缓存污染,或用户想彻底重来。 | ||
| // 与主 SKILL "SKILL 结束时的清理动作"分工:主 SKILL 只清 .d2c-tmp/screenshots/, | ||
| // 本命令是 .d2c-cache/ 的显式手动清理入口。 | ||
| function rmDirRecursive(dir) { | ||
| if (!fs.existsSync(dir)) return { removed: false } | ||
| fs.rmSync(dir, { recursive: true, force: true }) | ||
| return { removed: true } | ||
| } | ||
| function runCleanCache() { | ||
| const cacheDir = path.join(CWD, '.d2c-cache') | ||
| console.log('\npp-d2c clean-cache: 清理 .d2c-cache/ 目录\n') | ||
| if (!fs.existsSync(cacheDir)) { | ||
| console.log(` info ${path.relative(CWD, cacheDir) || '.d2c-cache'} 不存在,无需清理\n`) | ||
| return | ||
| } | ||
| // 列一下清理前的内容,让用户知道删了什么 | ||
| const items = [] | ||
| for (const sub of ['figma', 'images', 'anchors']) { | ||
| const p = path.join(cacheDir, sub) | ||
| if (fs.existsSync(p)) { | ||
| const count = fs.readdirSync(p).length | ||
| items.push(` · ${sub}/ (${count} 项)`) | ||
| } | ||
| } | ||
| const lastPage = path.join(cacheDir, 'last-page.json') | ||
| if (fs.existsSync(lastPage)) items.push(' · last-page.json') | ||
| if (items.length > 0) { | ||
| console.log(' 待清理内容:') | ||
| items.forEach(l => console.log(l)) | ||
| } | ||
| rmDirRecursive(cacheDir) | ||
| console.log(`\n ✓ 已清理 ${path.relative(CWD, cacheDir) || '.d2c-cache'} 及其全部内容\n`) | ||
| console.log(' info 下次跑 pp-d2c / pp-d2c-rn 会重新拉 figma 元数据 + 重导图片(不复用旧缓存)\n') | ||
| } | ||
| // ─── 入口 ──────────────────────────────────────────────────── | ||
@@ -695,5 +725,6 @@ | ||
| Usage: | ||
| npx @double-coding/pixel-print init 交互式初始化项目(推荐) | ||
| npx @double-coding/pixel-print install 仅复制模板文件,不进入交互 | ||
| npx @double-coding/pixel-print help 显示本帮助 | ||
| 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 显示本帮助 | ||
| `) | ||
@@ -707,2 +738,4 @@ } | ||
| console.log('done. 运行 npx @double-coding/pixel-print init 完成环境配置。\n') | ||
| } else if (cmd === 'clean-cache') { | ||
| runCleanCache() | ||
| } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { | ||
@@ -709,0 +742,0 @@ printHelp() |
+2
-2
| { | ||
| "name": "@double-coding/pixel-print", | ||
| "version": "1.0.2", | ||
| "description": "PixelPrint —— Figma D2C 工具,一键安装 Claude Code Skill,自动将 Figma 设计稿转换为前端代码(H5 / React Native / xtaro)", | ||
| "version": "1.1.0", | ||
| "description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)", | ||
| "bin": { | ||
@@ -6,0 +6,0 @@ "pp-d2c": "bin/install.js" |
+230
-240
@@ -1,131 +0,188 @@ | ||
| # PixelPrint | ||
| # PixelPrint(像素打印) | ||
| > npm 包名:`@double-coding/pixel-print` | ||
| > npm 包名:`@double-coding/pixel-print` · GitHub:[double-coding-lab/PixelPrint](https://github.com/double-coding-lab/PixelPrint) · License MIT | ||
| > | ||
| > 中文名「像素打印」,寓意像素级还原 —— 把 Figma 每一像素、每一间距、每一个圆角原样"打印"成前端代码。 | ||
| Figma D2C(Design-to-Code)工具 — 把 Figma 设计稿一键还原成可运行的前端代码。 | ||
| 一套让 **Claude Code** 学会「把 Figma 稿子还原成代码」的知识包。装到项目里,把设计稿链接发给 Claude,它自己拆图层、切图、出代码、逐块视觉对比。 | ||
| 通过 Claude Code Skill 协议工作,内置图层命名规范、体检规则、单位换算、资产管理、跨框架 adapter。**H5(React) / React Native / xtaro(携程)** 三端产物一套 SKILL 全覆盖。 | ||
| **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 的人 | 体检规则完整定义 | | ||
| --- | ||
| ## 安装 | ||
| ## 快速开始 | ||
| ### 1. 装到项目 | ||
| ```bash | ||
| # 在你的业务项目根目录下执行 | ||
| cd 你的业务项目 | ||
| npx @double-coding/pixel-print init | ||
| ``` | ||
| `init` 是**交互式引导**,按项目类型问不同题(h5 约 10 题,rn 约 13 题,含 adapter / rpx 响应式设置)。执行完落地以下产物: | ||
| `init` 是交互式引导,共 8-13 题(H5 略少、RN 略多)。1 分钟内答完,自动落地 SKILL + 配置 + 图片资产目录 + Figma Token(存到 `.env`,自动 gitignore)。 | ||
| **5 个 Claude Code SKILL**(`.claude/skills/` 下): | ||
| **init 会问什么**(v1.1.0 起 [1/8] 平铺一层 13 项): | ||
| | SKILL | 作用 | 何时装 | | ||
| ``` | ||
| [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/` | 主 D2C 流程(H5 分支) | framework=react 时装 | | ||
| | `pp-d2c-rn/` | 主 D2C 流程(RN 分支,含 6 大 RN 标签 + adapter) | framework=rn 时装 | | ||
| | `pp-doctor/` | 设计稿体检(命名 / 布局 / 结构 / 资产) | 总是装 | | ||
| | `pp-style/` | 样式还原细节规则 | 总是装 | | ||
| | `pp-strip-nodeid/` | 剥离 `data-node-id` 调试属性 | 总是装 | | ||
| | `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`:项目配置(figma.token / 前缀映射 / 单位换算 / 体检阈值 / adapter) | ||
| - `code-connect/mappings.json`:Figma 组件 → 代码组件映射表(可选,有需要时手工填) | ||
| - `static/`、`pages/`(默认路径,可配置):图片资产目录 / 代码输出目录 | ||
| - (RN 分支)`src/Utils/rpx.ts`:响应式尺寸 helper,SKILL 生成产物时按屏宽线性缩放 | ||
| - `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 | ||
| > **前置依赖**:Claude Code(最新版)+ 一枚 [Figma Personal Access Token](#前置依赖figma-personal-access-token)(File content: Read-only 权限)。SKILL 通过 Figma REST API 拉设计稿,不需要装任何 MCP 插件、不走 OAuth。 | ||
| --- | ||
| ## 快速开始 | ||
| ## 图层命名规范(给设计师看) | ||
| ### 1. 完成 init 交互 | ||
| 完整规范:[`docs/design-guide.md`](./docs/design-guide.md)。速查表: | ||
| ```bash | ||
| $ npx @double-coding/pixel-print init | ||
| | 前缀 | 含义 | 生成效果 | | ||
| |------|------|---------| | ||
| | `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-` | 忽略 | 不生成代码 | | ||
| ─── 阶段一:安装提示 ──────────────────────────────── | ||
| ℹ️ Figma 数据读取走 REST API(不再需要 MCP 插件 + OAuth)。 | ||
| init 阶段只做配置引导,实际可用性会在 Claude 跑 SKILL 步骤 -1 | ||
| 时调 `figma.mjs verify-token` 探针验证 Token 有效性。 | ||
| **修饰前缀可叠加**(选例):`fixed-btn-back-top` / `sub-scrollx-cards` / `end-btn-submit` / `fixed-sub-nav`。 | ||
| ─── 阶段二:交互式配置 ────────────────────────────── | ||
| [1/8] 项目框架: ● react rn | ||
| [2a/8] 样式方式: ● stylesheet tailwind inline # h5 分支才问 | ||
| [2b/8] 预处理语法: ● scss less css # h5 分支才问 | ||
| [2c/8] 是否启用 css-modules: ● No Yes # h5 分支才问 | ||
| [2.1/8] 启用 adapter(把 6 大 RN 标签映射到目标框架)? ● Yes No # rn 分支才问 | ||
| [2.2/8] 选择预设 adapter: ● pure RN xtaro taro 自定义 # rn 分支才问 | ||
| [2.3/8] rpx 响应式包装启用? ● Yes No # rn 分支才问 | ||
| [3/8] 合并模式: ● component flat | ||
| [4/8] 图片输出目录 [static/]: # h5 默认;rn 写死 src/Images/ | ||
| [5/8] 图片 base URL [http://127.0.0.1:8080/]: # h5 才问;rn 走 require + @Images alias | ||
| [6/8] 代码输出目录 [pages/]: # h5 默认;rn 默认 src/pages/ | ||
| **禁止叠加**: | ||
| - `scrollx-` / `scrolly-` × `img-` / `bg-` / `bgc-` / `btn-` / `x-`(语义冲突) | ||
| - `fixed-` × `bg-` / `bgc-` / `x-`(bg/bgc 不生成节点,fixed 无处可挂) | ||
| - `input-` × `bg-` / `bgc-` / `x-` / `img-` / `btn-` | ||
| ─── 阶段三:单位换算规则 ──────────────────────────── | ||
| [单位1/4] 设计稿基准宽度 (px) [375]: | ||
| [单位2/4] 代码使用的单位: ● px vw rem # rn 分支跳过(RN 无单位字符串) | ||
| [单位3/4] 代码 px 基准宽度 [750]: # rn 分支写死 375(scale=1) | ||
| [单位4/4] Figma Personal Access Token []: | ||
| --- | ||
| ## 命令清单 | ||
| ```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 # 帮助 | ||
| ``` | ||
| > **可重复运行**:再次跑 `init` 会**自动沿用 config 里已有的值**,只对缺失字段弹交互。想强制重填某项就删掉 config 对应字段。 | ||
| --- | ||
| ### 2. 把设计稿链接发给 Claude | ||
| ## 常用能力 | ||
| ### 局部 UI 修复(v1.1.0) | ||
| 页面已经出码,某一小块视觉不对,不用整页重跑。让 Claude 走 `pp-fix-partial`: | ||
| ``` | ||
| 把这份设计稿转成代码:https://figma.com/design/abcXyz?node-id=138-1797 | ||
| # 3 种触发形态 | ||
| pp-fix-partial https://figma.com/design/AAA?node-id=138-2050 # 明确 URL | ||
| pp-fix-partial # 不传参:拿最近实现的整页,让你选一个子块 | ||
| pp-fix-partial 顶部导航栏 # 自然语言 fuzzy match | ||
| ``` | ||
| Claude 会自动: | ||
| **利用缓存不污染**: | ||
| - hash 对比 target 子树 → 变了才 invalidate 该 nodeId 的缓存 | ||
| - 图片文件名带 fileKey 前缀 → 换稿子天然隔离 | ||
| - 缓存 mtime 超 7 天自动 TTL 作废 | ||
| 1. 调 `figma.mjs verify-token` 探针确认 Token 可用 | ||
| 2. 跑 doctor 体检(命名规范、布局结构、节点数,可关) | ||
| 3. 拉取图层树,按 `sub-` 前缀拆分 sub-agent 并行生成 | ||
| 4. 通过 Figma REST API 导出图片(带 `use_absolute_bounds=true` 严格按 bbox) | ||
| 5. 逐 sub-block 视觉对比设计稿与生成代码 | ||
| 6. 输出完整可运行的产物: | ||
| - **H5 分支** → React + SCSS(或 less / tailwind / inline,按 styleFormat) | ||
| - **RN 分支** → React Native + StyleSheet;启用 adapter 时 6 大 RN 标签自动映射到目标框架(如 XView / XImage) | ||
| 详见 [`.Knowledge/topics/pp-fix-partial.md`](./.Knowledge/topics/pp-fix-partial.md) 或 SKILL 本身。 | ||
| --- | ||
| ### 剥调试属性 + 存锚点 | ||
| ## 图层命名规范(给设计师看) | ||
| 上线前跑一次,把 `data-node-id="..."` 从产物剥掉,顺手把 nodeId → (file, startLine, endLine) 存到 `.d2c-cache/anchors/`,供后续 `pp-fix-partial` 精确定位: | ||
| 完整规范见 [`docs/design-guide.md`](./docs/design-guide.md)。速查表: | ||
| ```bash | ||
| node .claude/skills/pp-strip-nodeid/strip-node-id.mjs --dry-run # 先预览 | ||
| node .claude/skills/pp-strip-nodeid/strip-node-id.mjs # 确认后清理 | ||
| ``` | ||
| | 前缀 | 含义 | 生成效果 | | ||
| |------|------|---------| | ||
| | `sub-` | 独立模块 | 单独 sub-agent 处理,生成独立组件;**支持嵌套**(最深 3 层) | | ||
| | `block-` | 独立布局块 | HTML/CSS 隔离的容器,不可点击 | | ||
| | `img-` | 整块图片 | 整层导出为图片,**不递归子孙** | | ||
| | `bg-` | 背景图 | 写父元素 `background-image`,**不递归子孙** | | ||
| | `bgc-` | 父级背景与盒级装饰 | 写父元素 fills / strokes / cornerRadius / box-shadow,**不递归子孙** | | ||
| | `btn-` | 可点击区域 | 包裹可点击容器 | | ||
| | `input-` | 输入框(v0.3.4) | 生成 `<input type="text">`,子 TEXT 变 placeholder,子 icon 切图作 background | | ||
| | `scrollx-` / `scrolly-` | 横向 / 纵向滚动 | overflow + 隐藏滚动条,**继续递归子层**(列表项按 `.map()` 处理) | | ||
| | `fixed-` | 视口固定定位(v0.2) | `position: fixed`,依赖 Figma constraints;修饰前缀可叠加 | | ||
| | `end-` | 贴父末端 / 逆向布局(v0.3.2) | 父 auto layout 里贴向末端(纵→贴底 / 横→贴右);修饰前缀可叠加 | | ||
| | `x-` | 忽略 | 完全不生成代码 | | ||
| 加 `--no-anchors` 关掉锚点写入(如果只是纯剥,不打算用局部修复)。 | ||
| **修饰前缀叠加**: | ||
| ### 设计稿体检(Doctor) | ||
| | 组合 | 效果 | | ||
| |------|------| | ||
| | `sub-img-qa` | 独立模块 + 整块图片 | | ||
| | `btn-img-banner` | 可点击 + 整块图片 | | ||
| | `fixed-btn-back-top` | 固定定位 + 可点击按钮 | | ||
| | `sub-scrollx-cards` | 独立模块 + 横向滚动 | | ||
| | `end-btn-submit` | 贴底 + 可点击提交按钮 | | ||
| | `fixed-sub-nav` | 视口固定 + 独立吸顶导航模块 | | ||
| 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` | ||
| - `scrollx-` / `scrolly-` 不能和 `img-` / `bg-` / `bgc-` / `btn-` / `x-` 共存 | ||
| - `scrollx-` + `scrolly-` 不能同时用 | ||
| - `fixed-` 不能和 `bg-` / `bgc-` / `x-` 共存(不生成节点,fixed 无处可挂) | ||
| - `end-` 不能和 `bg-` / `bgc-` / `x-` 共存(同上);`end-` + `fixed-` 同时命中时 fixed 赢,end 失效 | ||
| - `input-` 不能和 `bg-` / `bgc-` / `x-` / `img-` / `btn-` 共存 | ||
| **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)。 | ||
| --- | ||
@@ -135,3 +192,3 @@ | ||
| 完整字段说明见主 SKILL `templates/skills/pp-d2c/SKILL.md` §0(或 pp-d2c-rn §0)。**h5 分支典型配置**: | ||
| 字段完整说明见主 SKILL `templates/skills/pp-d2c/SKILL.md` §0 或 `pp-d2c-rn/SKILL.md` §0。**核心字段**: | ||
@@ -141,37 +198,25 @@ ```jsonc | ||
| "project": { | ||
| "framework": "react", // react | rn | ||
| "styleFormat": "scss" // h5: scss / scss-modules / less / less-modules / css / css-modules / tailwind / inline | ||
| // rn: 固定 stylesheet(nativewind / styled-components 目前仅识别) | ||
| "framework": "react", // react | rn | ||
| "styleFormat": "scss" // h5: scss / scss-modules / less / less-modules / css / css-modules / tailwind / inline | ||
| // rn: 固定 stylesheet | ||
| }, | ||
| "figma": { | ||
| "token": "figd_xxx" // Figma Personal Access Token,用于 REST API | ||
| }, | ||
| "merge": { "mode": "component" }, // component | flat | ||
| "merge": { "mode": "component" }, // component | flat | ||
| "unit": { | ||
| "figmaBase": 375, // 设计稿基准宽度 | ||
| "outputUnit": "px", // h5: px | vw | rem;rn 无单位字符串 | ||
| "outputBase": 750, // h5 默认 750(2 倍图);rn 硬编码为 375(scale=1) | ||
| "scale": 2 // h5 默认 2;rn 硬编码为 1 | ||
| "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 分支写死 "src/Images/" | ||
| "imageBaseUrl": "http://127.0.0.1:8080/", // rn 分支不用(走 require + @Images alias) | ||
| "preserveEffectIds": [] // 例外清单:哪些 nodeId 导出时不带 use_absolute_bounds | ||
| "assetsDir": "static/", // rn 默认 "assets/" | ||
| "imageBaseUrl": "http://127.0.0.1:8080/", // rn 走 require 不用 URL | ||
| "preserveEffectIds": [] | ||
| }, | ||
| "layers": { // 10 类前缀,可自定义(生产建议保持默认) | ||
| "sub": "sub-", "block": "block-", "img": "img-", "bg": "bg-", "bgColor": "bgc-", | ||
| "but": "btn-", "input": "input-", "scrollX": "scrollx-", "scrollY": "scrolly-", | ||
| "fixed": "fixed-", "end": "end-", "ignore": "x-" | ||
| }, | ||
| "output": { "dir": "pages/" }, // rn 默认 "src/pages/" | ||
| "health": { | ||
| "enabled": true, // 是否启用前置体检(rn 默认 false,rn 不接 doctor) | ||
| "blockOnError": true, // 体检 grade=F 时是否阻塞生成 | ||
| "thresholds": { /* 9 项阈值 */ }, | ||
| "rules": {} // 可针对单条规则改 level / 关闭 | ||
| } | ||
| "layers": { /* 12 类前缀映射,生产建议保持默认 */ }, | ||
| "output": { "dir": "pages/" }, // rn 默认 "src/pages/" | ||
| "health": { "enabled": true, "blockOnError": true, /* ... */ } | ||
| } | ||
| ``` | ||
| **RN 分支额外字段** `adapter` 与 `unit.responsive`: | ||
| **RN 分支额外字段** `adapter` + `unit.responsive`: | ||
@@ -182,13 +227,13 @@ ```jsonc | ||
| "responsive": { | ||
| "enabled": true, // rpx() 包装:按屏宽线性缩放尺寸 | ||
| "helperImport": "@/utils/rpx", // rpx helper 的 import 路径 | ||
| "helperName": "rpx" // helper 导出的函数名 | ||
| "enabled": true, | ||
| "helperImport": "@/utils/rpx", | ||
| "helperName": "rpx" | ||
| } | ||
| }, | ||
| "adapter": { | ||
| "enabled": true, // 是否把 6 大 RN 标签换成目标框架标签 | ||
| "tagMap": { "View": "XView", ... }, // View / Text / Image / Pressable / TextInput / ScrollView | ||
| "importMap": { "XView": "@ctrip/xtaro", ... }, | ||
| "propMap": { "Image": { "source": "src" } }, // 纯 prop 改名 | ||
| "referenceDoc": "xtaro.reference.md" // 超改名的复杂差异(值域映射 / 事件签名等)在这份 md 里 | ||
| "enabled": true, | ||
| "tagMap": { "View": "XView", "...": "..." }, | ||
| "importMap": { "XView": "@ctrip/xtaro", "...": "..." }, | ||
| "propMap": { "Image": { "source": "src" } }, | ||
| "referenceDoc": "xtaro.reference.md" | ||
| } | ||
@@ -198,89 +243,31 @@ } | ||
| --- | ||
| > **Token 不入 config**:v1.0.2 起 Figma Token 走 `.env` `FIGMA_TOKEN=...`,`pp-d2c.config.json` 不再存 token 字段。 | ||
| ## 命令清单 | ||
| ```bash | ||
| # 在业务项目根目录下使用 | ||
| npx @double-coding/pixel-print init # 交互式初始化(推荐) | ||
| npx @double-coding/pixel-print install # 仅复制模板文件,不交互 | ||
| npx @double-coding/pixel-print help # 显示帮助 | ||
| ``` | ||
| --- | ||
| ## 前置依赖:Figma Personal Access Token | ||
| ## Figma Personal Access Token | ||
| SKILL 通过 Figma REST API 拉取设计稿元数据 + 导出图片,只需要一枚 Personal Access Token。**不需要装任何 MCP 插件、不走 OAuth**。 | ||
| SKILL 通过 Figma REST API 拉稿子 + 导图,只需要一枚 Personal Access Token。**不需要装任何 MCP 插件、不走 OAuth**。 | ||
| ### 获取 Token | ||
| **获取步骤**: | ||
| 1. 打开 [Figma](https://figma.com) 网页版,右上角头像 → **Settings** | ||
| 2. 左侧栏进入 **Security** → 找到 **Personal access tokens** | ||
| 3. 点 **Generate new token**,填名称(例如 `pp-d2c`),**Scopes** 勾选 `File content: Read-only`(至少) | ||
| 4. 复制生成的 token(格式 `figd_xxxxxxxxxxxxxxxxxxxx`) | ||
| 5. `init` 时粘贴到 `[单位4/4] Figma Personal Access Token []:` 那题,或后续手动填 `pp-d2c.config.json` 的 `figma.token` 字段 | ||
| 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=` | ||
| > **安全提示**:Token 相当于账号密码,不要 commit 到 git。项目 `.gitignore` 已默认忽略 `pp-d2c.config.json`。 | ||
| **探针验证**:Claude 跑 SKILL 步骤 -1 会调 `figma.mjs verify-token`: | ||
| ### 可用性验证 | ||
| 跑 SKILL 时 Claude 会在**步骤 -1** 调 `figma.mjs verify-token` 探针,通过 HTTP GET `https://api.figma.com/v1/me` 验证 token: | ||
| | 探针结果 | 含义 | 处理 | | ||
| |---------|------|------| | ||
| | 200 + 返回用户信息 | Token 有效 | 继续 | | ||
| | 401 / `invalid_token` | Token 已过期 / 拼错 | 按上面步骤重新生成一枚 | | ||
| | 403 | Token 权限不含 File content: Read-only | 重新生成时勾对 scopes | | ||
| | 结果 | 含义 | 处理 | | ||
| |---|---|---| | ||
| | 200 | Token 有效 | 继续 | | ||
| | 401 | Token 已过期/拼错 | 重新生成 | | ||
| | 403 | Scope 不够 | 重新生成时勾 `File content: Read-only` | | ||
| | 网络错误 | 网络不通 api.figma.com | 排查代理/防火墙 | | ||
| --- | ||
| > **安全**:`.env` 默认 gitignore。请勿把 token 写进任何 committed 文件。 | ||
| ## 设计稿体检(Doctor) | ||
| h5 分支 `health.enabled: true` 时(默认),主 SKILL 在生成代码前会自动跑一次设计稿体检: | ||
| - **NAM 系列**:命名规范(NAM001-NAM020,含 v0.3.4 新增 input- 4 条) | ||
| - **LAY 系列**:布局合理性(LAY001-LAY020,含 v0.3.2 新增 end- 4 条) | ||
| - **STR / STY / AST / FEA**:嵌套深度 / 颜色对比度 / 资产体积 / 整体规模 | ||
| 体检完毕输出 grade(A/B/C/D/F)+ 阻塞决策。`grade=F && blockOnError=true` 时会停下来等用户确认。 | ||
| 报告自动写入 `{output.dir}/.d2c-health-{nodeName}-{timestamp}.md`。 | ||
| > **RN 分支不接 doctor**:`pp-d2c-rn` config 默认 `health.enabled: false`,不做体检。理由:rn 分支处于收敛阶段,doctor 规则以 h5 语义为主(如 vw / css-modules),迁移到 rn 需要单独一轮 spec 化。要开也能开,但可能出现 rn 语境下的假阳。 | ||
| 体检规则完整定义见 [`docs/d2c-health-check-spec.md`](./docs/d2c-health-check-spec.md)。 | ||
| --- | ||
| ## RN 分支 adapter(v0.3+) | ||
| RN 分支的核心机制:**内核以 6 大 RN 原生标签描述一切,再通过 config 映射到具体框架标签**。这样一套 SKILL 同时覆盖 pure React Native / Expo / xtaro / taro / 组织内部 RN 组件库。 | ||
| **内置 3 个预设**(`templates/adapter-presets/`): | ||
| | 预设 | 目标 | 映射示意 | | ||
| |---|---|---| | ||
| | `rn` | pure React Native / Expo | 保留原名(identity),`from 'react-native'`;适合"我不做替换"场景 | | ||
| | `xtaro` | 携程 `@ctrip/xtaro` | `View→XView / Text→XText / Image→XImage / Pressable→XView / TextInput→XInput / ScrollView→XScrollView`,`from '@ctrip/xtaro'` | | ||
| | `taro` | Taro `@tarojs/components` | `View→View / Text→Text / Image→Image / Pressable→View / TextInput→Input / ScrollView→ScrollView`,`from '@tarojs/components'` | | ||
| 每个预设 3 件套:`<id>.json`(映射规则)+ `<id>.rpx.ts`(专属屏宽 helper)+ `<id>.reference.md`(超改名的复杂差异手册)。 | ||
| **adapter 分工**: | ||
| | 差异形态 | 承载文件 | SKILL 阶段 | | ||
| |---|---|---| | ||
| | prop 名不同、值和语义一样(如 `Image.source → src`) | `<id>.json` `propMap` | §5.5.3b 声明式改名 | | ||
| | 值域映射(如 `resizeMode='contain' → mode='aspectFit'`) | `<id>.reference.md` §一 | §5.5.3c 查手册 | | ||
| | 布尔取反(如 `editable → disabled` 取反) | `<id>.reference.md` §二 | §5.5.3c | | ||
| | 事件签名转换(如 `onChangeText(text) → onInput(e.detail.value)`) | `<id>.reference.md` §三 | §5.5.3c | | ||
| | 结构变化(如 `ScrollView.horizontal → scrollX + scrollY`) | `<id>.reference.md` §四 | §5.5.3c | | ||
| | 无跨端支持,需删属性 + warn | `<id>.reference.md` §五 | §5.5.3c | | ||
| **加自己的预设**:见 [`templates/adapter-presets/README.md`](./templates/adapter-presets/README.md)。 | ||
| --- | ||
| ## 项目结构 | ||
@@ -290,22 +277,25 @@ | ||
| pixel-print/ | ||
| ├── bin/install.js ← npx 入口(init / install / help) | ||
| ├── 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 ← 加预设的说明 | ||
| │ │ ├── xtaro.{json,rpx.ts,reference.md} ← 携程 xtaro 预设(3 件套) | ||
| │ │ ├── taro.{json,rpx.ts,reference.md} ← Taro (@tarojs/components) 预设 | ||
| │ │ └── rn.{json,rpx.ts,reference.md} ← pure React Native / Expo 预设 | ||
| │ ├── rn-helpers/rpx.ts ← 兜底 rpx helper(用户选"自定义"无预设时用) | ||
| │ ├── 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 ← 主 D2C 流程(h5 分支,~1200 行) | ||
| │ ├── pp-d2c-rn/SKILL.md ← 主 D2C 流程(rn 分支,~2000 行) | ||
| │ ├── pp-doctor/SKILL.md ← 设计稿体检 | ||
| │ ├── pp-style/SKILL.md ← 样式还原细节 | ||
| │ └── pp-strip-nodeid/ ← 剥离 data-node-id 调试属性 | ||
| │ ├── 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/ | ||
| │ ├── design-guide.md ← 给设计师的命名规范指南 | ||
| │ └── d2c-health-check-spec.md ← 体检规则源(含 P0/P1/P2 优先级) | ||
| │ ├── pixel-print-intro.md ← 简介 | ||
| │ ├── pixel-print-architecture.md ← 技术讲解 | ||
| │ ├── design-guide.md ← 给设计师的命名规范 | ||
| │ └── d2c-health-check-spec.md ← 体检规则源 | ||
| └── package.json | ||
@@ -320,38 +310,38 @@ ``` | ||
| |------|------| | ||
| | init 提示"沿用现有配置"但项目里没 config | install.js 已修复(spread merge + 调整读 existing 顺序);老版本升级方法见 SKILL §0 | | ||
| | 切出来的图带紫色画板背景色 / 光晕外扩 | `/v1/images` API 必须带 `use_absolute_bounds=true`(主 SKILL §4.4) | | ||
| | `card-bg.png` 把 bg-bg + bgc-选中框 揉成一张图 | bgc- 嵌在 bg- 子树内是错误结构(doctor NAM013 / 主 SKILL §`bg-` 内嵌 `bgc-` 的处理) | | ||
| | `bg-list.png` 把行程项内容印进背景 | `sub-scrolly-` 必须递归子层,不能整体导出(主 SKILL §`scrollx-/scrolly-` 自检 4 行) | | ||
| | Figma token 过期 / 缺失生成失败 | 自动走 L1→L2→L3 兜底链(主 SKILL §4.4.1) | | ||
| | `position: fixed` 元素跟着祖先滚动 | 祖先链有 `transform` / `filter` / `blur` 导致 fixed 退化(doctor LAY013) | | ||
| | RN 分支产物尺寸都是 ×2 / 视觉偏大 | 早期 SKILL §4.5 h5 残留导致 agent 误 ×2;v1.0.0 起 rn 分支硬编码 scale=1,figma 原值直接进 rpx() | | ||
| | `doctor.run()` 函数找不到 | SKILL.md 是 LLM 操作手册(自然语言),不是可执行代码 —— 任何 `doctor.run({...})` 都是伪代码(主 SKILL 顶部「执行模型说明」) | | ||
| | 切出来的图带画板背景色 / 光晕外扩 | `/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` | | ||
| 更多详见 [`.Knowledge/topics/pp-d2c.md`](./.Knowledge/topics/pp-d2c.md) 的「已知历史 bug 与修订」表。 | ||
| 更多历史 bug 与修订见 [`.Knowledge/topics/pp-d2c.md`](./.Knowledge/topics/pp-d2c.md)。 | ||
| --- | ||
| ## 开发与维护 | ||
| ## 版本历史 | ||
| ### 仓库结构 | ||
| - **本仓库**:D2C 工具源码(SKILL 模板、install.js、文档、规则、adapter 预设) | ||
| - **业务项目**:通过 `npx @double-coding/pixel-print init` 拉取 SKILL 到 `.claude/skills/` | ||
| ### 版本历史 | ||
| | 版本 | 里程碑 | | ||
| |---|---| | ||
| | v0.2.x | 图层前缀体系泛化、doctor 体检、token 兜底链、嵌套 sub-、bgc- 盒级 CSS、CSS-able 自检、fixed- 前缀 | | ||
| | v0.3.0 | Figma MCP → REST API 迁移(figma.mjs);token 探针取代 whoami | | ||
| | v0.3.2 | 新增 `end-` 前缀(贴父末端 / 逆向布局) | | ||
| | v0.3.3 | 页面根容器 `min-height: max(..., 100vh)` 覆写 | | ||
| | v0.3.4 | 新增 `input-` 前缀(生成 `<input type="text">`) | | ||
| | v0.4.0 | rebrand 到 `@double-coding/pixel-print`;RN 分支 + adapter + rpx 响应式 + 参考手册机制 | | ||
| | **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-演化史为什么是今天的样子)。 | ||
| 把 [`docs/design-guide.md`](./docs/design-guide.md) 发给对接设计师。开发对接前**优先**让设计师按规范命名图层,比开发自己改图层名靠谱得多。 | ||
| --- | ||
| ## 开发与维护 | ||
| - **本仓库**:D2C 工具源码(SKILL 模板 / install.js / adapter 预设 / 文档) | ||
| - **业务项目**:通过 `npx @double-coding/pixel-print init` 拉 SKILL 到 `.claude/skills/` | ||
| - **给设计师同步规范**:把 [`docs/design-guide.md`](./docs/design-guide.md) 发过去,让他们按规范命名图层。**开发对接前优先让设计师改**,比开发自己改效率高 10 倍以上。 | ||
| --- | ||
@@ -358,0 +348,0 @@ |
| # pp-strip-nodeid Skill | ||
| > 一键清理 D2C 生成产物里注入的 `data-node-id="..."` 调试锚点。 | ||
| > 一键清理 D2C 生成产物里注入的 `data-node-id="..."` 调试锚点,同时**顺手把 nodeId → 代码位置的映射存到 `.d2c-cache/anchors/`**,供 `pp-fix-partial` 局部修复时精确定位。 | ||
| > | ||
| > 在 D2C 生成阶段,每个 DOM/JSX 节点上会带一个 `data-node-id` 属性用于反查 Figma 节点位置(方便 review 和主 agent 逐 block 验收)。这些属性**只在开发/调试阶段有价值**,上线前必须清理,避免产物体积膨胀 + 调试信息泄露到线上。 | ||
| > 在 D2C 生成阶段,每个 DOM/JSX 节点上会带一个 `data-node-id` 属性用于反查 Figma 节点位置(方便 review 和主 agent 逐 block 验收)。这些属性**只在开发/调试阶段有价值**,上线前必须清理,避免产物体积膨胀 + 调试信息泄露到线上。但直接剥了 nodeId 就意味着"局部修复"再也定位不到 → 所以剥之前先把锚点位置存到 `.d2c-cache/anchors/`,两全其美。 | ||
@@ -35,2 +35,3 @@ ## 触发条件 | ||
| [strip-node-id] extensions : tsx, jsx, ts, js, html, htm | ||
| [strip-node-id] anchors : 写入 .d2c-cache/anchors/ | ||
| [strip-node-id] files scan : 42 | ||
@@ -54,2 +55,12 @@ [strip-node-id] files hit : 8 | ||
| **实际清理时**会: | ||
| 1. 剥前先扫一遍每个 `data-node-id="X"`,把 nodeId → { file, startLine, endLine } 存到 `.d2c-cache/anchors/<pageDirSlug>.json` | ||
| - `pageDirSlug` = scanDir 下第一层目录名(如 `pages/Italo/index.jsx` → slug=`Italo`),scanDir 根直下的文件用 `__root__` | ||
| - 同 nodeId 出现多次时只记第一次(通常不会重复) | ||
| - JSX 元素起始位置 = 从 attr 往前找最近的 `<Tag`,结束位置 = 往后找该标签的 `>` 或 `/>` | ||
| 2. 再剥属性 | ||
| 3. 输出 anchors 写入统计:`anchors written: N 个锚点 → M 个 page 档案` | ||
| **加 `--no-anchors` 关掉**:如果项目里不用 pp-fix-partial(比如只是上线前一次性清理,不打算做局部修复),可以加 `--no-anchors` 跳过 anchor 档案写入,只做剥除。 | ||
| ### 步骤 3:产出摘要 | ||
@@ -61,2 +72,3 @@ | ||
| - 清理属性总数 | ||
| - Anchors 写入的锚点数 / page 档案数 | ||
| - 建议下一步:`git diff` 复核 / 跑 lint / 跑构建 | ||
@@ -71,2 +83,3 @@ | ||
| | `--ext tsx,jsx` | 覆盖扫描扩展名(逗号分隔,不带点;默认 `tsx,jsx,ts,js,html,htm`) | | ||
| | `--no-anchors` | 不写 `.d2c-cache/anchors/` 档案,只做剥除 | | ||
@@ -86,2 +99,18 @@ ## 匹配规则 | ||
| ## Anchors 档案结构 | ||
| `.d2c-cache/anchors/<pageDirSlug>.json`: | ||
| ```json | ||
| { | ||
| "138:1797": { "file": "pages/Italo/index.jsx", "start": 3, "end": 3 }, | ||
| "138:1810": { "file": "pages/Italo/index.jsx", "start": 7, "end": 10 }, | ||
| "138:1900": { "file": "pages/Italo/blocks/sub-card-list/index.jsx", "start": 12, "end": 60 } | ||
| } | ||
| ``` | ||
| nodeId 统一规范化为**冒号形式**(`138:1830`,不是 `138-1830`)作 key,便于 pp-fix-partial 侧与 figma REST 返回值直接对齐。 | ||
| **注意**:anchors 档案不入 git(`.d2c-cache/` 已在 .gitignore),每次剥属性都会重新生成。 | ||
| ## 禁止 | ||
@@ -91,2 +120,4 @@ | ||
| - 禁止对非 D2C 生成目录(例如项目通用 `src/` 根)默认执行:默认只扫 `output.dir`,其他目录必须 `--dir` 显式指定 | ||
| - 禁止在生成阶段跳过 `data-node-id` 注入:这个属性是主 SKILL §6.0 逐 block 视觉验收阶段的反查锚点,生成时必须注入;只在**上线前**用本 skill 清理 | ||
| - 禁止在生成阶段跳过 `data-node-id` 注入:这个属性是主 SKILL §6.0 逐 block 视觉验收阶段的反查锚点 + pp-fix-partial 局部定位的锚点,生成时必须注入;只在**上线前**用本 skill 清理 | ||
| - 禁止在剥属性后手工删除 `.d2c-cache/anchors/`:该目录是 pp-fix-partial 精确定位的关键;要清缓存请走 `npx @double-coding/pixel-print clean-cache`(会一致地清 figma / images / anchors / last-page,不会只清一半) | ||
@@ -10,3 +10,8 @@ #!/usr/bin/env node | ||
| // node strip-node-id.mjs --ext tsx,jsx # 覆盖扫描扩展名(逗号分隔,不带点) | ||
| // node strip-node-id.mjs --no-anchors # 不写 .d2c-cache/anchors/,只做剥除 | ||
| // | ||
| // 剥除前会自动把 nodeId → (file, startLine, endLine) 存到 | ||
| // .d2c-cache/anchors/<pageDirSlug>.json,供 pp-fix-partial 精确定位。 | ||
| // 加 --no-anchors 关掉这行为。 | ||
| // | ||
| // 退出码:0=成功;1=参数错 / 目标目录不存在 / IO 失败 | ||
@@ -22,2 +27,3 @@ | ||
| const dryRun = args['dry-run'] === true | ||
| const writeAnchors = args['no-anchors'] !== true | ||
| const extList = (args.ext || 'tsx,jsx,ts,js,html,htm').split(',').map(s => s.trim().toLowerCase()).filter(Boolean) | ||
@@ -49,2 +55,6 @@ | ||
| const RE = /\s+data-node-id\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\})/g | ||
| // 捕获 nodeId 用的正则(单独一遍扫,便于取值):形如 138:1830 或 138-1830 | ||
| const RE_CAPTURE = /data-node-id\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g | ||
| // JSX 元素起始标签开头(向前找):找 < 后跟大小写字母的位置 | ||
| const RE_TAG_OPEN = /<[A-Za-z][A-Za-z0-9]*/ | ||
@@ -56,2 +66,7 @@ let filesScanned = 0 | ||
| // pageDirSlug → { [nodeId]: { file, start, end } } | ||
| // pageDirSlug = scanDir 下第一层目录名(如 Italo);扫到的文件位于 scanDir/<slug>/... 就归到 <slug> | ||
| // scanDir 根直下的文件归到 slug = '__root__' | ||
| const anchorsByPage = {} | ||
| walk(absDir) | ||
@@ -63,2 +78,3 @@ | ||
| console.log(`[strip-node-id] extensions : ${extList.join(', ')}`) | ||
| console.log(`[strip-node-id] anchors : ${writeAnchors ? '写入 .d2c-cache/anchors/' : '禁用(--no-anchors)'}`) | ||
| console.log(`[strip-node-id] files scan : ${filesScanned}`) | ||
@@ -72,2 +88,15 @@ console.log(`[strip-node-id] files hit : ${filesChanged}`) | ||
| } | ||
| if (writeAnchors && !dryRun) { | ||
| const cacheDir = path.join(CWD, '.d2c-cache/anchors') | ||
| fs.mkdirSync(cacheDir, { recursive: true }) | ||
| const pageCount = Object.keys(anchorsByPage).length | ||
| let anchorCount = 0 | ||
| for (const [slug, anchors] of Object.entries(anchorsByPage)) { | ||
| const dest = path.join(cacheDir, `${slug}.json`) | ||
| fs.writeFileSync(dest, JSON.stringify(anchors, null, 2)) | ||
| anchorCount += Object.keys(anchors).length | ||
| } | ||
| console.log('') | ||
| console.log(`[strip-node-id] anchors written: ${anchorCount} 个锚点 → ${pageCount} 个 page 档案`) | ||
| } | ||
| if (dryRun && hitsTotal > 0) { | ||
@@ -102,2 +131,6 @@ console.log('') | ||
| changedFiles.push(file) | ||
| // 剥前先抽 anchors(基于剥之前的原文,行号才准) | ||
| if (writeAnchors) collectAnchors(file, original) | ||
| if (dryRun) return | ||
@@ -108,2 +141,60 @@ const next = original.replace(RE, '') | ||
| // 抽 anchors:对每个 data-node-id="X",找它所属 JSX 元素的起始行 + 闭合行(或行本身) | ||
| // 简化处理:JSX 元素开始 = 从 attr 位置往前找最近的 < + 大写字母; | ||
| // JSX 元素结束 = 从 attr 位置往后找匹配的 > 或 /> 收尾。 | ||
| // 这个粗粒度定位对"块级 sub-*"够用;深层嵌套内的替换 pp-fix-partial 侧会做二次校验。 | ||
| function collectAnchors(file, source) { | ||
| const relFile = path.relative(CWD, file) | ||
| const slug = deriveSlug(relFile) | ||
| const anchors = anchorsByPage[slug] || (anchorsByPage[slug] = {}) | ||
| RE_CAPTURE.lastIndex = 0 | ||
| let m | ||
| while ((m = RE_CAPTURE.exec(source)) !== null) { | ||
| const rawNodeId = (m[1] || m[2] || m[3] || '').trim() | ||
| if (!rawNodeId) continue | ||
| // 规范化 138-1830 → 138:1830 作 key | ||
| const nodeId = rawNodeId.replace(/-/g, ':') | ||
| const attrIndex = m.index | ||
| // 起始:从 attr 往前找 <Tag(不跨行找 tag 名,但允许跨行找 <) | ||
| let start = attrIndex | ||
| while (start > 0 && source[start] !== '<') start-- | ||
| // 收尾:从 attr 往后找该标签的 > 或 /> | ||
| let end = attrIndex | ||
| let depth = 0 | ||
| while (end < source.length) { | ||
| const ch = source[end] | ||
| if (ch === '<') depth++ | ||
| if (ch === '>' && depth <= 1) { end++; break } | ||
| end++ | ||
| } | ||
| const startLine = source.slice(0, start).split('\n').length | ||
| const endLine = source.slice(0, end).split('\n').length | ||
| // 同 nodeId 出现多次时取第一次 | ||
| if (!anchors[nodeId]) { | ||
| anchors[nodeId] = { | ||
| file: relFile, | ||
| start: startLine, | ||
| end: endLine | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // pages/Italo/blocks/sub-tab-list/index.jsx → 'Italo' | ||
| // pages/index.jsx → '__root__' | ||
| // src/pages/Italo/... → 'Italo' | ||
| function deriveSlug(relFile) { | ||
| const parts = relFile.split(path.sep) | ||
| const scanRel = path.relative(CWD, absDir).split(path.sep).filter(Boolean) | ||
| // 找 relFile 在 scanRel 之后的第一个目录名 | ||
| let i = 0 | ||
| while (i < scanRel.length && i < parts.length && parts[i] === scanRel[i]) i++ | ||
| const first = parts[i] | ||
| if (!first) return '__root__' | ||
| if (i === parts.length - 1) return '__root__' // scanDir 直下的文件 | ||
| return first.replace(/[^A-Za-z0-9]/g, '_') | ||
| } | ||
| function parseArgs(argv) { | ||
@@ -114,2 +205,3 @@ const out = {} | ||
| if (a === '--dry-run' || a === '-n') { out['dry-run'] = true; continue } | ||
| if (a === '--no-anchors') { out['no-anchors'] = true; continue } | ||
| if (a.startsWith('--')) { | ||
@@ -116,0 +208,0 @@ const key = a.slice(2) |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
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.
516184
5.83%26
4%1893
6.47%346
-2.81%