mini-figma-code-connect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/mini-code-connect/SKILL.md +100 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/figma-mapping.config.example.json +11 -0
- package/package.json +70 -0
- package/scripts/build-plugin.mjs +132 -0
- package/scripts/copy-mapping-table.mjs +19 -0
- package/scripts/figma-mapping/ai-generate.mjs +102 -0
- package/scripts/figma-mapping/index.mjs +250 -0
- package/scripts/figma-mapping/lib.mjs +112 -0
- package/scripts/figma-mapping/registry.mjs +12 -0
- package/scripts/figma-mapping/scaffold.mjs +153 -0
- package/scripts/generate-registry.mjs +94 -0
- package/scripts/install-skill.mjs +79 -0
- package/scripts/is-cli-entrypoint.mjs +21 -0
- package/scripts/scaffold-manifest.mjs +84 -0
- package/scripts/scaffold-plugin.mjs +172 -0
- package/src/dev/demo.ts +207 -0
- package/src/dev/export.ts +28 -0
- package/src/main/code.ts +422 -0
- package/src/main/handle.ts +207 -0
- package/src/main/messages.ts +49 -0
- package/src/main/schema.ts +45 -0
- package/src/main/validate.ts +49 -0
- package/src/runtime/define.ts +6 -0
- package/src/runtime/index.ts +21 -0
- package/src/runtime/registry.ts +14 -0
- package/src/runtime/render.ts +26 -0
- package/src/runtime/tagged.ts +46 -0
- package/src/runtime/types.ts +86 -0
- package/src/ui/ui.html +114 -0
- package/src/ui/ui.ts +271 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// 引擎自带的格式示例,不依赖任何具体消费方的真实数据——这个 CLI 可能在任何装了
|
|
4
|
+
// mini-figma-code-connect 的项目里跑,不能假设某份 .figma.ts 一定存在。
|
|
5
|
+
const EXAMPLE_TEMPLATE = `import { defineTemplate, code } from 'mini-figma-code-connect'
|
|
6
|
+
|
|
7
|
+
export default defineTemplate({
|
|
8
|
+
meta: {
|
|
9
|
+
url: 'https://www.figma.com/design/FILE_KEY/Design-System?node-id=NODE_ID',
|
|
10
|
+
source: '../path/to/components/button.tsx',
|
|
11
|
+
component: 'Button',
|
|
12
|
+
},
|
|
13
|
+
id: 'button',
|
|
14
|
+
match: { componentName: 'Button' },
|
|
15
|
+
|
|
16
|
+
render(instance) {
|
|
17
|
+
const label = instance.getString('Label')
|
|
18
|
+
const disabled = instance.getBoolean('Disabled')
|
|
19
|
+
const size = instance.getEnum('Size', { Large: 'large', Medium: 'medium', Small: 'small' })
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
example: code\`<Button size="\${size}"\${disabled ? code\` disabled\` : ''}>\${label}</Button>\`,
|
|
23
|
+
imports: ['import { Button } from "@/components/button"'],
|
|
24
|
+
id: 'button',
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function buildPrompt({ schema, candidates, importPaths }) {
|
|
31
|
+
const candidateBlocks = candidates
|
|
32
|
+
.map(
|
|
33
|
+
(c) => `### 候选文件: ${c.relPath}
|
|
34
|
+
\`\`\`
|
|
35
|
+
${c.source}
|
|
36
|
+
\`\`\`
|
|
37
|
+
`,
|
|
38
|
+
)
|
|
39
|
+
.join('\n');
|
|
40
|
+
|
|
41
|
+
return `你是 Figma Code Connect 映射生成器。任务:判断下面这个 Figma 组件该接到哪个候选代码组件,如果有合适的就生成映射文件内容。
|
|
42
|
+
|
|
43
|
+
## Figma 组件 schema
|
|
44
|
+
\`\`\`json
|
|
45
|
+
${JSON.stringify(schema, null, 2)}
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
## 候选代码组件(按路径排列,可能一个都不合适)
|
|
49
|
+
${candidateBlocks}
|
|
50
|
+
|
|
51
|
+
## 目标文件格式(这是一份已有的示例,照这个格式和 accessor API 写,不要用别的格式)
|
|
52
|
+
\`\`\`ts
|
|
53
|
+
${EXAMPLE_TEMPLATE}
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
## import 路径换算规则
|
|
57
|
+
候选文件路径前缀 → 生成文件里 imports 用的 specifier:
|
|
58
|
+
${JSON.stringify(importPaths, null, 2)}
|
|
59
|
+
|
|
60
|
+
## 要求
|
|
61
|
+
1. 从候选文件里选一个最合适的(组件名、Props 语义都要对得上,不是字符串像不像)。一个都不合适就选 NONE。
|
|
62
|
+
2. Figma 属性名可能是中文、可能跟代码 prop 不是字面一致(比如"状态"里的 disabled/hover/loading 需要拆成 disabled/loading 两个 prop,hover/pressed 是 CSS 状态不是 prop,不能硬塞)。VARIANT 的字典要覆盖 schema 里给的每一个选项,配不上真实值的必须显式留 'TODO' 不要瞎编一个能编译但语义错的值。
|
|
63
|
+
3. 不确定的判断(比如没有精确对应的 variant 值该猜哪个),照样做出选择,但在生成文件顶部注释里明确写出"这是猜的,需要人工确认",别不声不响。
|
|
64
|
+
4. 严禁调用任何工具/命令,严禁读写文件系统 —— 你现在没有这些权限,只需要基于上面给的信息直接生成文本。
|
|
65
|
+
5. 严禁编造代码里不存在的 prop 名。
|
|
66
|
+
|
|
67
|
+
## 输出格式(严格遵守,不要输出其它任何文字)
|
|
68
|
+
第一行:\`MATCH: <候选文件的 relPath>\` 或 \`MATCH: NONE\`
|
|
69
|
+
如果 MATCH 不是 NONE,第二行开始是一条分隔线 \`---\`,之后是完整的 .figma.ts 文件内容(不要用 markdown 代码块包裹,直接是可以原样写入文件的 TypeScript 源码)。
|
|
70
|
+
如果 MATCH 是 NONE,第二行开始简短说明为什么没有合适的候选(一两句话)。`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 调本机 claude CLI 无头模式做真判断,禁掉所有工具权限强制纯文本生成,不给它碰文件系统的机会 */
|
|
74
|
+
export function aiGenerateMapping({ schema, candidates, importPaths }) {
|
|
75
|
+
const prompt = buildPrompt({ schema, candidates, importPaths });
|
|
76
|
+
|
|
77
|
+
const raw = execFileSync(
|
|
78
|
+
'claude',
|
|
79
|
+
[
|
|
80
|
+
'-p',
|
|
81
|
+
prompt,
|
|
82
|
+
'--output-format',
|
|
83
|
+
'text',
|
|
84
|
+
'--disallowedTools',
|
|
85
|
+
'Read,Write,Edit,Bash,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit',
|
|
86
|
+
],
|
|
87
|
+
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const firstLineEnd = raw.indexOf('\n');
|
|
91
|
+
const firstLine = (firstLineEnd === -1 ? raw : raw.slice(0, firstLineEnd)).trim();
|
|
92
|
+
const rest = firstLineEnd === -1 ? '' : raw.slice(firstLineEnd + 1);
|
|
93
|
+
|
|
94
|
+
const m = firstLine.match(/^MATCH:\s*(.+)$/);
|
|
95
|
+
if (!m || m[1].trim() === 'NONE') {
|
|
96
|
+
return { matched: false, reason: rest.trim() || firstLine };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const relPath = m[1].trim();
|
|
100
|
+
const content = rest.replace(/^---\s*\n?/, '');
|
|
101
|
+
return { matched: true, relPath, content };
|
|
102
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline/promises';
|
|
5
|
+
import { stdin, stdout } from 'node:process';
|
|
6
|
+
import { ROOT, readConfig, listCandidateFiles, extractComponentInfo, scoreCandidate, MAPPINGS_DIR } from './lib.mjs';
|
|
7
|
+
import { buildMatchedScaffold, toImportSpecifier } from './scaffold.mjs';
|
|
8
|
+
import { refreshRegistry } from './registry.mjs';
|
|
9
|
+
import { aiGenerateMapping } from './ai-generate.mjs';
|
|
10
|
+
|
|
11
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
12
|
+
const ask = (q) => rl.question(q);
|
|
13
|
+
|
|
14
|
+
function printHelp() {
|
|
15
|
+
console.log(`用法:
|
|
16
|
+
node scripts/figma-mapping/index.mjs <schema.json> [--ai]
|
|
17
|
+
|
|
18
|
+
schema.json 从插件面板拿:
|
|
19
|
+
- 单个组件:"导出 schema.json"按钮(未映射状态下才有)
|
|
20
|
+
- 一整批:候选列表里"导出全部 schema.json"按钮,会是一个 schema 数组,脚本挨个跑完
|
|
21
|
+
|
|
22
|
+
不带 --ai:本地正则打分排候选,你自己挑、自己判断要不要接哪个 prop。
|
|
23
|
+
带 --ai:调本机 claude CLI(无头模式,禁掉所有工具权限)读 schema + 候选源码,直接做真判断、
|
|
24
|
+
生成完整映射内容,你只需要确认要不要采纳——判断力比正则强,但也可能判断错,一样要看一眼。
|
|
25
|
+
|
|
26
|
+
两种模式都会把 .figma.ts 生成到当前目录(运行这个命令的项目根目录)的 figma-mappings/,并重新生成
|
|
27
|
+
registry.generated.ts(自动扫全部 .figma.ts,不需要手动加 import)。`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rankFiles(files, schema) {
|
|
31
|
+
return files
|
|
32
|
+
.map((f) => {
|
|
33
|
+
const info = extractComponentInfo(f);
|
|
34
|
+
const s = scoreCandidate(schema, info);
|
|
35
|
+
return { file: f, info, ...s };
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => b.score - a.score);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main(schemaPath, useAi) {
|
|
41
|
+
if (!schemaPath || !fs.existsSync(schemaPath)) {
|
|
42
|
+
console.error(`找不到 schema 文件: ${schemaPath}\n先在插件面板里对着未映射的实例点"导出 schema.json"。`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
|
47
|
+
const queue = Array.isArray(parsed) ? parsed : [parsed];
|
|
48
|
+
console.log(`\n共 ${queue.length} 个 Figma 组件待处理。${useAi ? '(AI 模式)' : ''}\n`);
|
|
49
|
+
|
|
50
|
+
await processQueue(queue, useAi);
|
|
51
|
+
rl.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function processQueue(queue, useAi) {
|
|
55
|
+
if (queue.length === 0) {
|
|
56
|
+
console.log('\n全部处理完。');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const [schema, ...rest] = queue;
|
|
60
|
+
console.log(`\n────────── [还剩 ${queue.length} 个] ──────────`);
|
|
61
|
+
console.log(`目标 Figma 组件: ${schema.componentName}`);
|
|
62
|
+
console.log(`属性: ${schema.properties.map((p) => `${p.name}(${p.type})`).join(', ') || '(无)'}\n`);
|
|
63
|
+
|
|
64
|
+
const allFiles = listCandidateFiles();
|
|
65
|
+
if (useAi) {
|
|
66
|
+
return aiFlow(schema, rankFiles(allFiles, schema), rest);
|
|
67
|
+
}
|
|
68
|
+
await pickLoop(schema, rankFiles(allFiles, schema), allFiles, rest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function aiFlow(schema, ranked, rest) {
|
|
72
|
+
const top = ranked.slice(0, 6);
|
|
73
|
+
if (top.length === 0) {
|
|
74
|
+
console.log('没有候选文件,跳过这个组件。');
|
|
75
|
+
return processQueue(rest, true);
|
|
76
|
+
}
|
|
77
|
+
console.log(`把这 ${top.length} 个候选连同源码交给 claude 判断:`);
|
|
78
|
+
top.forEach((r) => console.log(` - ${r.file}`));
|
|
79
|
+
console.log('\n调用中(可能要几十秒)...\n');
|
|
80
|
+
|
|
81
|
+
const config = readConfig();
|
|
82
|
+
const candidates = top.map((r) => ({ relPath: r.file, source: r.info.source }));
|
|
83
|
+
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = aiGenerateMapping({ schema, candidates, importPaths: config.importPaths });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.log(`调用 claude CLI 失败:${err.stderr || err.stdout || err.message}\n`);
|
|
89
|
+
const fallback = (await ask('要不要退回本地正则打分手动挑一个?(y/N): ')).trim().toLowerCase();
|
|
90
|
+
if (fallback === 'y') {
|
|
91
|
+
const allFiles = listCandidateFiles();
|
|
92
|
+
return pickLoop(schema, ranked, allFiles, rest, true);
|
|
93
|
+
}
|
|
94
|
+
return processQueue(rest, true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!result.matched) {
|
|
98
|
+
console.log(`AI 判断没有合适的候选:${result.reason}\n`);
|
|
99
|
+
const fallback = (await ask('要不要退回本地正则打分手动挑一个?(y/N): ')).trim().toLowerCase();
|
|
100
|
+
if (fallback === 'y') {
|
|
101
|
+
const allFiles = listCandidateFiles();
|
|
102
|
+
return pickLoop(schema, ranked, allFiles, rest, true);
|
|
103
|
+
}
|
|
104
|
+
return processQueue(rest, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`AI 选了: ${result.relPath}\n`);
|
|
108
|
+
console.log('─'.repeat(60));
|
|
109
|
+
console.log(result.content);
|
|
110
|
+
console.log('─'.repeat(60));
|
|
111
|
+
|
|
112
|
+
const confirm = (await ask('\n确认采纳这份生成结果吗?输入 "good" 确认,其他任意键放弃: ')).trim();
|
|
113
|
+
if (confirm !== 'good') {
|
|
114
|
+
console.log('放弃,跳过这个组件。\n');
|
|
115
|
+
return processQueue(rest, true);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return writeMappingFile(schema, result.content, rest, true);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function pickLoop(schema, ranked, allFiles, rest, useAi) {
|
|
122
|
+
const top = ranked.slice(0, 8);
|
|
123
|
+
if (top.length === 0) {
|
|
124
|
+
console.log('没有候选文件了,跳过这个组件。');
|
|
125
|
+
return processQueue(rest, useAi);
|
|
126
|
+
}
|
|
127
|
+
console.log('候选排名(分数越高越像):\n');
|
|
128
|
+
top.forEach((r, i) => {
|
|
129
|
+
const reason = `名字相似度 ${r.nameScore.toFixed(2)} · 属性重合 ${r.overlapCount}/${schema.properties.length}`;
|
|
130
|
+
console.log(` ${i + 1}. ${r.file} [${r.score.toFixed(2)}] (${reason})`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const answer = (
|
|
134
|
+
await ask('\n输入序号选一个 / 输入关键字重新筛选文件 / "m" 手动输入路径 / "s" 跳过这个组件 / "q" 全部退出: ')
|
|
135
|
+
).trim();
|
|
136
|
+
|
|
137
|
+
if (answer === 'q') return;
|
|
138
|
+
if (answer === 's') return processQueue(rest, useAi);
|
|
139
|
+
|
|
140
|
+
if (answer === 'm') {
|
|
141
|
+
const rel = (await ask('输入路径(相对项目根目录,或绝对路径): ')).trim();
|
|
142
|
+
if (!fs.existsSync(path.resolve(ROOT, rel))) {
|
|
143
|
+
console.log('文件不存在,重来。\n');
|
|
144
|
+
return pickLoop(schema, ranked, allFiles, rest, useAi);
|
|
145
|
+
}
|
|
146
|
+
return confirmAndGenerate(schema, path.relative(ROOT, path.resolve(ROOT, rel)), rest, useAi);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const idx = Number(answer);
|
|
150
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= top.length) {
|
|
151
|
+
return confirmAndGenerate(schema, top[idx - 1].file, rest, useAi);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const filtered = allFiles.filter((f) => f.toLowerCase().includes(answer.toLowerCase()));
|
|
155
|
+
if (filtered.length === 0) {
|
|
156
|
+
console.log('没匹配到文件,重来。\n');
|
|
157
|
+
return pickLoop(schema, ranked, allFiles, rest, useAi);
|
|
158
|
+
}
|
|
159
|
+
console.log('');
|
|
160
|
+
return pickLoop(schema, rankFiles(filtered, schema), allFiles, rest, useAi);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function confirmAndGenerate(schema, relPath, rest, useAi) {
|
|
164
|
+
const info = extractComponentInfo(relPath);
|
|
165
|
+
const componentName =
|
|
166
|
+
info.names.find((n) => n.toLowerCase() === path.basename(relPath, '.ts').toLowerCase()) ??
|
|
167
|
+
info.names[0] ??
|
|
168
|
+
path.basename(relPath, '.ts');
|
|
169
|
+
|
|
170
|
+
console.log(`\n选中: ${relPath}`);
|
|
171
|
+
console.log(`识别到的导出: ${info.names.join(', ') || '(没找到 export 的组件名,会用文件名兜底)'}`);
|
|
172
|
+
console.log(`默认取: ${componentName}(不对的话下一步可以改)`);
|
|
173
|
+
if (info.propsBlocks.length) {
|
|
174
|
+
console.log(`Props 定义:`);
|
|
175
|
+
for (const b of info.propsBlocks) {
|
|
176
|
+
const names = [...b.body.matchAll(/^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\??:/gm)].map((m) => m[1]);
|
|
177
|
+
console.log(` ${b.typeName} { ${names.join(', ')} }`);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
console.log('没找到 Props type/interface(可能是内联类型,生成后自己核对)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const nameInput = (await ask(`\n代码组件名 [回车用 "${componentName}"]: `)).trim();
|
|
184
|
+
const finalName = nameInput || componentName;
|
|
185
|
+
|
|
186
|
+
const confirm = (await ask(`\n确认生成映射吗?输入 "good" 确认,其他任意键放弃: `)).trim();
|
|
187
|
+
if (confirm !== 'good') {
|
|
188
|
+
console.log('放弃,回到列表。\n');
|
|
189
|
+
const allFiles = listCandidateFiles();
|
|
190
|
+
return pickLoop(schema, rankFiles(allFiles, schema), allFiles, rest, useAi);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const config = readConfig();
|
|
194
|
+
const importSpecifier = toImportSpecifier(relPath, config.importPaths);
|
|
195
|
+
const propsBlock = info.propsBlocks[0];
|
|
196
|
+
const content = buildMatchedScaffold({
|
|
197
|
+
schema,
|
|
198
|
+
componentFile: relPath,
|
|
199
|
+
componentName: finalName,
|
|
200
|
+
importSpecifier,
|
|
201
|
+
propsBlock,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return writeMappingFile(schema, content, rest, useAi);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 两条路径(本地打分 / AI 判断)共用的落盘逻辑:确认保存路径、硬性保护、写文件、接 registry */
|
|
208
|
+
async function writeMappingFile(schema, content, rest, useAi) {
|
|
209
|
+
const safeName = schema.componentName.replace(/[^a-zA-Z0-9]+/g, '') || 'Component';
|
|
210
|
+
const defaultOut = path.relative(ROOT, path.join(MAPPINGS_DIR, `${safeName}.figma.ts`));
|
|
211
|
+
const outInput = (await ask(`保存到哪 [回车用 "${defaultOut}"]: `)).trim();
|
|
212
|
+
const outRel = outInput || defaultOut;
|
|
213
|
+
const outAbs = path.resolve(ROOT, outRel);
|
|
214
|
+
|
|
215
|
+
// 硬性保护:绝不允许写到当前项目(ROOT = 运行这个命令时所在的目录)以外
|
|
216
|
+
if (!outAbs.startsWith(ROOT + path.sep)) {
|
|
217
|
+
console.log(`拒绝写入:${outAbs} 在项目根目录之外,这个脚本只允许往 ${ROOT} 里写。跳过这个组件。\n`);
|
|
218
|
+
return processQueue(rest, useAi);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (fs.existsSync(outAbs)) {
|
|
222
|
+
const overwrite = (await ask(`${outRel} 已存在,覆盖?(y/N): `)).trim().toLowerCase();
|
|
223
|
+
if (overwrite !== 'y') {
|
|
224
|
+
console.log('取消写入,跳过这个组件。\n');
|
|
225
|
+
return processQueue(rest, useAi);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
230
|
+
fs.writeFileSync(outAbs, content);
|
|
231
|
+
console.log(`\n已写入 ${outRel}`);
|
|
232
|
+
|
|
233
|
+
const { count } = refreshRegistry({ cwd: ROOT });
|
|
234
|
+
console.log(`registry.generated.ts 已重新生成(${count} 条映射)`);
|
|
235
|
+
|
|
236
|
+
return processQueue(rest, useAi);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const args = process.argv.slice(2);
|
|
240
|
+
const useAiFlag = args.includes('--ai');
|
|
241
|
+
const schemaPath = args.find((a) => !a.startsWith('--'));
|
|
242
|
+
|
|
243
|
+
if (!schemaPath) {
|
|
244
|
+
printHelp();
|
|
245
|
+
rl.close();
|
|
246
|
+
} else {
|
|
247
|
+
await main(schemaPath, useAiFlag);
|
|
248
|
+
console.log(`\n全部跑完,回到项目里跑一遍类型检查 + 插件构建(比如 rexy 是 "pnpm run figma:build"),确认编得过,再回 Figma 重新读取验证。`);
|
|
249
|
+
rl.close();
|
|
250
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// ROOT 是运行这个 CLI 时所在的目录——消费方(比如 trex-website/apps/rexy)在自己项目里
|
|
5
|
+
// 跑 `figma-mapping`,ROOT 就是那个项目,.figma.ts 和 config 都读写在消费方自己那边,
|
|
6
|
+
// 不是这个引擎包安装到哪 ROOT 就是哪(那是 import.meta.url 的算法,这里故意不用)。
|
|
7
|
+
export const ROOT = process.cwd();
|
|
8
|
+
// 文件名特意不叫 figma.config.json —— 官方 Figma Code Connect CLI 也认这个文件名,
|
|
9
|
+
// 消费方项目里可能已经有一份官方的(trex-website/apps/rexy 就有),撞名会互相踩。
|
|
10
|
+
export const CONFIG_PATH = path.join(ROOT, 'figma-mapping.config.json');
|
|
11
|
+
export const MAPPINGS_DIR = path.join(ROOT, 'figma-mappings');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* figma-mapping.config.json 放在消费方项目根目录,内容都是项目内相对路径,可以直接提交 git——
|
|
15
|
+
* 不再需要"公共配置 + 每人一份本地绝对路径覆盖"那一套,因为 CLI 现在就是在消费方项目里跑的,
|
|
16
|
+
* 不存在"扫另一个仓库"的绝对路径问题了。
|
|
17
|
+
*/
|
|
18
|
+
export function readConfig() {
|
|
19
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
20
|
+
console.warn(
|
|
21
|
+
`[figma-mapping] 当前目录(${ROOT})没有 figma-mapping.config.json —— 扫不到任何候选组件目录。\n` +
|
|
22
|
+
`抄引擎包里的 figma-mapping.config.example.json,在这个项目根目录建一份,改成自己的组件目录。`,
|
|
23
|
+
);
|
|
24
|
+
return { paths: {}, importPaths: {} };
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')).codeConnect;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function globToRegExp(glob) {
|
|
30
|
+
let re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
31
|
+
// "dir/**/pattern" 得能匹配零层子目录("dir/pattern" 本身),不能死要求斜杠两边都有内容
|
|
32
|
+
re = re.replace(/\/\*\*\//g, '/(?:.*/)?');
|
|
33
|
+
re = re.replace(/\*\*/g, '.*');
|
|
34
|
+
re = re.replace(/\*/g, '[^/]*');
|
|
35
|
+
return new RegExp(`^${re}$`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 候选代码组件从 figma.config.json 的 paths 里声明的目录扫,不是随便全仓库翻 */
|
|
39
|
+
export function listCandidateFiles() {
|
|
40
|
+
const { paths } = readConfig();
|
|
41
|
+
const dirs = Object.values(paths ?? {});
|
|
42
|
+
const results = [];
|
|
43
|
+
|
|
44
|
+
function walk(dir) {
|
|
45
|
+
if (!fs.existsSync(dir)) return;
|
|
46
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
47
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
48
|
+
const full = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
walk(full);
|
|
51
|
+
} else if (/\.tsx?$/.test(entry.name) && !/\.figma\.tsx?$/.test(entry.name)) {
|
|
52
|
+
results.push(path.relative(ROOT, full));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// path.resolve 而不是 path.join —— dirs 里可能是绝对路径(比如指向另一个仓库只读扫描),
|
|
58
|
+
// join 会把绝对路径当成普通片段拼接在 ROOT 后面,resolve 才会正确地整个替换掉
|
|
59
|
+
for (const d of dirs) walk(path.resolve(ROOT, d));
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 正则抠出组件名 + Props 类型定义源码,够打分和展示用,不追求 AST 级精确 */
|
|
64
|
+
export function extractComponentInfo(relPath) {
|
|
65
|
+
const source = fs.readFileSync(path.join(ROOT, relPath), 'utf8');
|
|
66
|
+
|
|
67
|
+
const nameMatches = [
|
|
68
|
+
...source.matchAll(/export\s+(?:declare\s+)?(?:const|function|class)\s+([A-Z][A-Za-z0-9]*)/g),
|
|
69
|
+
].map((m) => m[1]);
|
|
70
|
+
|
|
71
|
+
const propsBlocks = [
|
|
72
|
+
...source.matchAll(/(?:interface|type)\s+([A-Za-z0-9]*Props)\b[^{]*\{([\s\S]*?)\n\}/g),
|
|
73
|
+
].map((m) => ({ typeName: m[1], body: m[2] }));
|
|
74
|
+
|
|
75
|
+
const propNames = new Set();
|
|
76
|
+
for (const block of propsBlocks) {
|
|
77
|
+
for (const m of block.body.matchAll(/^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\??:/gm)) {
|
|
78
|
+
propNames.add(m[1]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { relPath, names: nameMatches, propsBlocks, propNames: [...propNames], source };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalize(s) {
|
|
86
|
+
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tokenOverlap(a, b) {
|
|
90
|
+
const na = normalize(a);
|
|
91
|
+
const nb = normalize(b);
|
|
92
|
+
if (!na || !nb) return 0;
|
|
93
|
+
if (na === nb) return 1;
|
|
94
|
+
if (na.includes(nb) || nb.includes(na)) return 0.7;
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 打分:组件名相似度权重高,属性名重合度加分 */
|
|
99
|
+
export function scoreCandidate(schema, info) {
|
|
100
|
+
const fileBase = path.basename(info.relPath).replace(/\.tsx?$/, '');
|
|
101
|
+
const nameCandidates = [...info.names, fileBase];
|
|
102
|
+
const nameScore = Math.max(0, ...nameCandidates.map((n) => tokenOverlap(schema.componentName, n)));
|
|
103
|
+
|
|
104
|
+
const schemaProps = schema.properties.map((p) => normalize(p.name)).filter(Boolean);
|
|
105
|
+
const codeProps = info.propNames.map(normalize);
|
|
106
|
+
const overlapCount = schemaProps.filter((p) => codeProps.some((c) => c === p || c.includes(p) || p.includes(c)))
|
|
107
|
+
.length;
|
|
108
|
+
const propScore = schemaProps.length ? overlapCount / schemaProps.length : 0;
|
|
109
|
+
|
|
110
|
+
const score = nameScore * 0.6 + propScore * 0.4;
|
|
111
|
+
return { score, nameScore, propScore, overlapCount, fileBase };
|
|
112
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { generateRegistry } from '../generate-registry.mjs';
|
|
2
|
+
import { REGISTRY_GENERATED } from '../build-plugin.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* registry.ts 的 templates 数组由 scripts/generate-registry.mjs 按 glob 自动生成,不需要手动加
|
|
6
|
+
* import。输出文件固定写进引擎包自己的 src/runtime/registry.generated.ts(跟 buildPlugin() 用的
|
|
7
|
+
* 是同一个路径,build-plugin.mjs 里解释过为什么——多个消费方会互相覆盖,目前接受这个简化)。
|
|
8
|
+
*/
|
|
9
|
+
export function refreshRegistry({ cwd, mappingsGlob = 'figma-mappings/**/*.figma.ts' }) {
|
|
10
|
+
const result = generateRegistry({ cwd, mappingsGlob, outFile: REGISTRY_GENERATED });
|
|
11
|
+
return { count: result.count };
|
|
12
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
function normalize(s) {
|
|
2
|
+
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** 从 Props 类型源码里找某个 prop 声明行,抠出它的联合类型字面量(给 VARIANT 自动配值用) */
|
|
6
|
+
function findUnionLiterals(propsBody, propName) {
|
|
7
|
+
const line = propsBody.split('\n').find((l) => new RegExp(`^\\s*${propName}\\??:`).test(l));
|
|
8
|
+
if (!line) return null;
|
|
9
|
+
const literals = [...line.matchAll(/"([^"]+)"|'([^']+)'/g)].map((m) => m[1] ?? m[2]);
|
|
10
|
+
return literals.length ? literals : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 在 code prop 名单里,找跟某个 schema property 名字最像的一个 */
|
|
14
|
+
function bestPropMatch(schemaPropName, codePropNames) {
|
|
15
|
+
const target = normalize(schemaPropName);
|
|
16
|
+
if (!target) return null; // 中文/符号属性名会被 normalize 削成空串,空串是任何字符串的子串,不能拿来配
|
|
17
|
+
let best = null;
|
|
18
|
+
for (const name of codePropNames) {
|
|
19
|
+
const n = normalize(name);
|
|
20
|
+
if (n === target || n.includes(target) || target.includes(n)) {
|
|
21
|
+
if (!best || n.length < normalize(best).length) best = name;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return best;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function varName(p, i, seen) {
|
|
28
|
+
const cleaned = p.name.replace(/[^a-zA-Z0-9]/g, '');
|
|
29
|
+
const base = cleaned || `prop${i + 1}`;
|
|
30
|
+
const camel = base[0].toLowerCase() + base.slice(1);
|
|
31
|
+
let name = /^[0-9]/.test(camel) ? `_${camel}` : camel;
|
|
32
|
+
// 中文属性名剥完 ascii 后可能撞车(比如"左icon"和"右icon"都只剩"icon"),撞了就按顺序加序号
|
|
33
|
+
if (seen.has(name)) {
|
|
34
|
+
let n = 2;
|
|
35
|
+
while (seen.has(`${name}${n}`)) n++;
|
|
36
|
+
name = `${name}${n}`;
|
|
37
|
+
}
|
|
38
|
+
seen.add(name);
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function idFrom(name) {
|
|
43
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'template';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function propLine(p, i, codePropNames, propsBody, seen, claimedCodeProps) {
|
|
47
|
+
const v = varName(p, i, seen);
|
|
48
|
+
const guess = bestPropMatch(p.name, codePropNames);
|
|
49
|
+
// 一个代码 prop 已经被别的 Figma 属性占用了,就不能再配第二次(比如"左icon"/"右icon"都只剩"icon",
|
|
50
|
+
// 不能俩都配到同一个 leftIcon 上——留 TODO 比生成一个明摆着错的重复 attr 更诚实)
|
|
51
|
+
const matched = guess && !claimedCodeProps.has(guess) ? guess : null;
|
|
52
|
+
if (matched) claimedCodeProps.add(matched);
|
|
53
|
+
|
|
54
|
+
switch (p.type) {
|
|
55
|
+
case 'TEXT':
|
|
56
|
+
return { line: `const ${v} = instance.getString('${p.name}')`, jsxAttr: matched ? `${matched}={${v}}` : null };
|
|
57
|
+
case 'BOOLEAN':
|
|
58
|
+
return { line: `const ${v} = instance.getBoolean('${p.name}')`, jsxAttr: matched ? `${matched}={${v}}` : null };
|
|
59
|
+
case 'VARIANT': {
|
|
60
|
+
const literals = matched && propsBody ? findUnionLiterals(propsBody, matched) : null;
|
|
61
|
+
const opts = p.variantOptions ?? [];
|
|
62
|
+
const mapping = opts
|
|
63
|
+
.map((o) => {
|
|
64
|
+
const guess = literals?.find((l) => normalize(l) === normalize(o)) ?? literals?.[0];
|
|
65
|
+
const value = guess ? JSON.stringify(guess) : `'TODO'`;
|
|
66
|
+
return ` ${JSON.stringify(o)}: ${value},`;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
return {
|
|
70
|
+
line: `const ${v} = instance.getEnum('${p.name}', {\n${mapping}\n })`,
|
|
71
|
+
jsxAttr: matched ? `${matched}="\${${v}}"` : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
case 'INSTANCE_SWAP':
|
|
75
|
+
return {
|
|
76
|
+
line:
|
|
77
|
+
`const ${v} = instance.getInstanceSwap('${p.name}')\n` +
|
|
78
|
+
` let ${v}Code\n` +
|
|
79
|
+
` if (${v} && ${v}.type === 'INSTANCE') {\n ${v}Code = ${v}.executeTemplate()?.example\n }`,
|
|
80
|
+
jsxAttr: matched ? `${matched}={\${${v}Code}}` : null,
|
|
81
|
+
};
|
|
82
|
+
default:
|
|
83
|
+
return { line: `// TODO: 未知属性类型 ${p.type} — ${p.name}`, jsxAttr: null };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 生成一份接到 mini-figma-code-connect 运行时的 .figma.ts —— 落进消费方项目的 figma-mappings/。
|
|
89
|
+
* import/source/component 用匹配到的真实文件与 figma-mapping.config.json 的 importPaths 换算出来的 specifier;
|
|
90
|
+
* VARIANT 字典尽量从 Props 联合类型里自动配对真实值,配不上的标 TODO。
|
|
91
|
+
*/
|
|
92
|
+
export function buildMatchedScaffold({ schema, componentFile, componentName, importSpecifier, propsBlock }) {
|
|
93
|
+
const propsBody = propsBlock?.body ?? '';
|
|
94
|
+
const codePropNames = propsBlock ? [...propsBody.matchAll(/^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\??:/gm)].map((m) => m[1]) : [];
|
|
95
|
+
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const claimedCodeProps = new Set();
|
|
98
|
+
const parts = schema.properties.map((p, i) => propLine(p, i, codePropNames, propsBody, seen, claimedCodeProps));
|
|
99
|
+
const lines = parts.map((p) => ` ${p.line}`).join('\n\n');
|
|
100
|
+
const jsxAttrs = parts
|
|
101
|
+
.map((p) => p.jsxAttr)
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((a) => `\n ${a}`)
|
|
104
|
+
.join('');
|
|
105
|
+
const unmatchedCount = schema.properties.length - parts.filter((p) => p.jsxAttr).length;
|
|
106
|
+
const id = idFrom(schema.componentName);
|
|
107
|
+
|
|
108
|
+
return `// url=https://www.figma.com/design/REPLACE_FILE_KEY/Design-System?node-id=REPLACE
|
|
109
|
+
// source=${componentFile}
|
|
110
|
+
// component=${componentName}
|
|
111
|
+
import { defineTemplate } from '../runtime/define'
|
|
112
|
+
import { code } from '../runtime/tagged'
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 由 figma-mapping CLI 生成:Figma "${schema.componentName}" ↔ 代码 ${componentName}(${componentFile})
|
|
116
|
+
* ${unmatchedCount > 0 ? `${unmatchedCount} 个属性没自动配上对应的代码 prop,人工确认` : '每个属性都自动配上了对应的代码 prop,仍建议人工确认一遍'}
|
|
117
|
+
*/
|
|
118
|
+
export default defineTemplate({
|
|
119
|
+
meta: {
|
|
120
|
+
url: 'https://www.figma.com/design/REPLACE_FILE_KEY/Design-System?node-id=REPLACE',
|
|
121
|
+
source: '${componentFile}',
|
|
122
|
+
component: '${componentName}',
|
|
123
|
+
},
|
|
124
|
+
id: '${id}',
|
|
125
|
+
match: { componentName: '${schema.componentName}' },
|
|
126
|
+
|
|
127
|
+
render(instance) {
|
|
128
|
+
${lines || ' // 这个组件没有可读的属性'}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
example: code\`
|
|
132
|
+
<${componentName}${jsxAttrs}
|
|
133
|
+
/>
|
|
134
|
+
\`,
|
|
135
|
+
imports: ['import { ${componentName} } from "${importSpecifier}"'],
|
|
136
|
+
id: '${id}',
|
|
137
|
+
metadata: { nestable: true },
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function toImportSpecifier(relPath, importPaths) {
|
|
145
|
+
for (const [glob, spec] of Object.entries(importPaths ?? {})) {
|
|
146
|
+
const prefix = glob.replace(/\*$/, '');
|
|
147
|
+
if (relPath.startsWith(prefix)) {
|
|
148
|
+
const rest = relPath.slice(prefix.length).replace(/\.tsx?$/, '');
|
|
149
|
+
return spec.endsWith('*') ? spec.replace(/\*$/, rest) : spec;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return './' + relPath.replace(/\.tsx?$/, '');
|
|
153
|
+
}
|