mini-figma-code-connect 0.1.1 → 0.1.4

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.
Files changed (61) hide show
  1. package/LICENSE +13 -9
  2. package/README.md +14 -3
  3. package/dist/build.mjs +228 -0
  4. package/dist/figma-mapping.mjs +618 -0
  5. package/dist/generate-registry.mjs +86 -0
  6. package/dist/install-skill.mjs +91 -0
  7. package/dist/main/code.d.ts +1 -0
  8. package/dist/main/code.js +388 -0
  9. package/dist/main/handle.d.ts +55 -0
  10. package/dist/main/handle.js +167 -0
  11. package/dist/main/mapping-table.generated.json +1 -0
  12. package/dist/main/messages.d.ts +71 -0
  13. package/dist/main/messages.js +1 -0
  14. package/dist/main/schema.d.ts +9 -0
  15. package/dist/main/schema.js +39 -0
  16. package/dist/main/validate.d.ts +16 -0
  17. package/dist/main/validate.js +40 -0
  18. package/dist/runtime/define.d.ts +3 -0
  19. package/dist/runtime/define.js +4 -0
  20. package/{src/runtime/index.ts → dist/runtime/index.d.ts} +3 -14
  21. package/dist/runtime/index.js +9 -0
  22. package/dist/runtime/registry.d.ts +5 -0
  23. package/dist/runtime/registry.generated.d.ts +3 -0
  24. package/dist/runtime/registry.generated.js +1 -0
  25. package/{src/runtime/registry.ts → dist/runtime/registry.js} +7 -8
  26. package/dist/runtime/render.d.ts +6 -0
  27. package/dist/runtime/render.js +27 -0
  28. package/dist/runtime/tagged.d.ts +6 -0
  29. package/dist/runtime/tagged.js +45 -0
  30. package/dist/runtime/types.d.ts +107 -0
  31. package/dist/runtime/types.js +7 -0
  32. package/dist/scaffold-manifest.mjs +87 -0
  33. package/dist/scaffold-plugin.mjs +468 -0
  34. package/dist/ui/ui.d.ts +84 -0
  35. package/dist/ui/ui.js +190 -0
  36. package/package.json +23 -18
  37. package/scripts/build-plugin.mjs +0 -132
  38. package/scripts/copy-mapping-table.mjs +0 -19
  39. package/scripts/figma-mapping/ai-generate.mjs +0 -102
  40. package/scripts/figma-mapping/index.mjs +0 -250
  41. package/scripts/figma-mapping/lib.mjs +0 -112
  42. package/scripts/figma-mapping/registry.mjs +0 -12
  43. package/scripts/figma-mapping/scaffold.mjs +0 -153
  44. package/scripts/generate-registry.mjs +0 -94
  45. package/scripts/install-skill.mjs +0 -79
  46. package/scripts/is-cli-entrypoint.mjs +0 -21
  47. package/scripts/scaffold-manifest.mjs +0 -84
  48. package/scripts/scaffold-plugin.mjs +0 -172
  49. package/src/dev/demo.ts +0 -207
  50. package/src/dev/export.ts +0 -28
  51. package/src/main/code.ts +0 -422
  52. package/src/main/handle.ts +0 -207
  53. package/src/main/messages.ts +0 -49
  54. package/src/main/schema.ts +0 -45
  55. package/src/main/validate.ts +0 -49
  56. package/src/runtime/define.ts +0 -6
  57. package/src/runtime/render.ts +0 -26
  58. package/src/runtime/tagged.ts +0 -46
  59. package/src/runtime/types.ts +0 -86
  60. package/src/ui/ui.ts +0 -271
  61. /package/{src → dist}/ui/ui.html +0 -0
@@ -1,250 +0,0 @@
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
- }
@@ -1,112 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,153 +0,0 @@
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
- }
@@ -1,94 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { isCliEntrypoint } from './is-cli-entrypoint.mjs';
4
-
5
- /**
6
- * 递归找 mappingsGlob 目录下所有 *.figma.ts,生成一份 registry.generated.ts。
7
- * 消费方(比如 trex-website)把 .figma.ts 散落放在各自组件旁边时,不需要手动维护
8
- * import 列表——每次 build 前重新扫一遍全量重生成,文件挪了/加了/删了都自动跟上。
9
- *
10
- * 目前只支持简单的 glob 写法:<dir>/**\/*.figma.ts 或 <dir>/*.figma.ts。
11
- */
12
- function parseGlob(glob) {
13
- const starIdx = glob.indexOf('*');
14
- if (starIdx === -1) throw new Error(`generate-registry: glob 里没有 *:"${glob}"`);
15
- const dir = glob.slice(0, starIdx).replace(/\/$/, '');
16
- const recursive = glob.includes('**');
17
- return { dir, recursive };
18
- }
19
-
20
- function findFigmaFiles(dir, recursive) {
21
- const results = [];
22
- function walk(d) {
23
- if (!fs.existsSync(d)) return;
24
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
25
- if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
26
- const full = path.join(d, entry.name);
27
- if (entry.isDirectory()) {
28
- if (recursive) walk(full);
29
- } else if (entry.name.endsWith('.figma.ts')) {
30
- results.push(full);
31
- }
32
- }
33
- }
34
- walk(dir);
35
- return results.sort();
36
- }
37
-
38
- function identifierFor(filePath, index) {
39
- const base = path
40
- .basename(filePath)
41
- .replace(/\.figma\.ts$/, '')
42
- .replace(/[^a-zA-Z0-9]/g, '');
43
- const safe = base && /^[a-zA-Z_]/.test(base) ? base : `M${base}`;
44
- return `${safe || 'Mapping'}_${index}`;
45
- }
46
-
47
- /**
48
- * @param {object} opts
49
- * @param {string} opts.cwd 消费方项目根目录
50
- * @param {string} opts.mappingsGlob 相对 cwd 的 glob,例如 'figma-mappings/**\/*.figma.ts'
51
- * @param {string} opts.outFile 生成文件的绝对路径,import 路径按它所在目录换算相对路径
52
- * @param {string | null} [opts.typesImport] Template 类型的 import 路径;默认 `'./types'`(引擎仓
53
- * 内 typecheck 用)。传 `null` 则不写 type import(消费方 build 产物目录下没有 `./types`,
54
- * 且 esbuild 会擦掉 type-only import,写了反而解析失败)
55
- */
56
- export function generateRegistry({ cwd, mappingsGlob, outFile, typesImport = './types' }) {
57
- const { dir, recursive } = parseGlob(mappingsGlob);
58
- const files = findFigmaFiles(path.resolve(cwd, dir), recursive);
59
-
60
- const outDir = path.dirname(outFile);
61
- const imports = files.map((f, i) => {
62
- const id = identifierFor(f, i);
63
- let rel = path.relative(outDir, f).replace(/\.ts$/, '');
64
- if (!rel.startsWith('.')) rel = `./${rel}`;
65
- // Windows 下 path.relative 会给出反斜杠,ESM import 必须用 /
66
- rel = rel.split(path.sep).join('/');
67
- return { id, importPath: rel };
68
- });
69
-
70
- const typeLine =
71
- typesImport == null ? '' : `import type { Template } from '${typesImport}'\n`;
72
- const templatesAnn = typesImport == null ? '' : ': Template[]';
73
- const body =
74
- `// 自动生成,不要手改 —— 由 scripts/generate-registry.mjs 扫 .figma.ts 生成,每次 build 前重跑\n` +
75
- typeLine +
76
- imports.map((i) => `import ${i.id} from '${i.importPath}'`).join('\n') +
77
- (imports.length ? '\n\n' : '\n') +
78
- `export const templates${templatesAnn} = [${imports.map((i) => i.id).join(', ')}]\n`;
79
-
80
- fs.mkdirSync(outDir, { recursive: true });
81
- fs.writeFileSync(outFile, body);
82
- return { count: files.length, files };
83
- }
84
-
85
- // 允许直接当 CLI 跑:node scripts/generate-registry.mjs <mappingsGlob> <outFile>
86
- if (isCliEntrypoint(import.meta.url)) {
87
- const [, , mappingsGlob, outFile] = process.argv;
88
- if (!mappingsGlob || !outFile) {
89
- console.error('用法: node scripts/generate-registry.mjs <mappingsGlob> <outFile>');
90
- process.exit(1);
91
- }
92
- const result = generateRegistry({ cwd: process.cwd(), mappingsGlob, outFile: path.resolve(outFile) });
93
- console.log(`[generate-registry] 写入 ${result.count} 条映射到 ${outFile}`);
94
- }