Sign In

@double-coding/pixel-print

Package Overview
Dependencies
Maintainers
2
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@double-coding/pixel-print - npm Package Compare versions

Comparing version
1.0.2
to
1.1.0
+290
templates/skills/pp-fix-partial/SKILL.md
# 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()

{
"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