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
@@ -0,0 +1,86 @@
1
+ // scripts/generate-registry.mjs
2
+ import fs from "node:fs";
3
+ import path2 from "node:path";
4
+
5
+ // scripts/is-cli-entrypoint.mjs
6
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ function isCliEntrypoint(importMetaUrl, entryName) {
10
+ if (!process.argv[1]) return false;
11
+ try {
12
+ const argvPath = realpathSync(process.argv[1]);
13
+ const metaPath = realpathSync(fileURLToPath(importMetaUrl));
14
+ if (argvPath !== metaPath) return false;
15
+ if (entryName) {
16
+ return path.basename(argvPath).includes(entryName);
17
+ }
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ // scripts/generate-registry.mjs
25
+ function parseGlob(glob) {
26
+ const starIdx = glob.indexOf("*");
27
+ if (starIdx === -1) throw new Error(`generate-registry: glob \u91CC\u6CA1\u6709 *\uFF1A"${glob}"`);
28
+ const dir = glob.slice(0, starIdx).replace(/\/$/, "");
29
+ const recursive = glob.includes("**");
30
+ return { dir, recursive };
31
+ }
32
+ function findFigmaFiles(dir, recursive) {
33
+ const results = [];
34
+ function walk(d) {
35
+ if (!fs.existsSync(d)) return;
36
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
37
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
38
+ const full = path2.join(d, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (recursive) walk(full);
41
+ } else if (entry.name.endsWith(".figma.ts")) {
42
+ results.push(full);
43
+ }
44
+ }
45
+ }
46
+ walk(dir);
47
+ return results.sort();
48
+ }
49
+ function identifierFor(filePath, index) {
50
+ const base = path2.basename(filePath).replace(/\.figma\.ts$/, "").replace(/[^a-zA-Z0-9]/g, "");
51
+ const safe = base && /^[a-zA-Z_]/.test(base) ? base : `M${base}`;
52
+ return `${safe || "Mapping"}_${index}`;
53
+ }
54
+ function generateRegistry({ cwd, mappingsGlob, outFile, typesImport = "./types" }) {
55
+ const { dir, recursive } = parseGlob(mappingsGlob);
56
+ const files = findFigmaFiles(path2.resolve(cwd, dir), recursive);
57
+ const outDir = path2.dirname(outFile);
58
+ const imports = files.map((f, i) => {
59
+ const id = identifierFor(f, i);
60
+ let rel = path2.relative(outDir, f).replace(/\.ts$/, "");
61
+ if (!rel.startsWith(".")) rel = `./${rel}`;
62
+ rel = rel.split(path2.sep).join("/");
63
+ return { id, importPath: rel };
64
+ });
65
+ const typeLine = typesImport == null ? "" : `import type { Template } from '${typesImport}'
66
+ `;
67
+ const templatesAnn = typesImport == null ? "" : ": Template[]";
68
+ const body = `// \u81EA\u52A8\u751F\u6210\uFF0C\u4E0D\u8981\u624B\u6539 \u2014\u2014 \u7531 scripts/generate-registry.mjs \u626B .figma.ts \u751F\u6210\uFF0C\u6BCF\u6B21 build \u524D\u91CD\u8DD1
69
+ ` + typeLine + imports.map((i) => `import ${i.id} from '${i.importPath}'`).join("\n") + (imports.length ? "\n\n" : "\n") + `export const templates${templatesAnn} = [${imports.map((i) => i.id).join(", ")}]
70
+ `;
71
+ fs.mkdirSync(outDir, { recursive: true });
72
+ fs.writeFileSync(outFile, body);
73
+ return { count: files.length, files };
74
+ }
75
+ if (isCliEntrypoint(import.meta.url, "generate-registry")) {
76
+ const [, , mappingsGlob, outFile] = process.argv;
77
+ if (!mappingsGlob || !outFile) {
78
+ console.error("\u7528\u6CD5: node scripts/generate-registry.mjs <mappingsGlob> <outFile>");
79
+ process.exit(1);
80
+ }
81
+ const result = generateRegistry({ cwd: process.cwd(), mappingsGlob, outFile: path2.resolve(outFile) });
82
+ console.log(`[generate-registry] \u5199\u5165 ${result.count} \u6761\u6620\u5C04\u5230 ${outFile}`);
83
+ }
84
+ export {
85
+ generateRegistry
86
+ };
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ // scripts/install-skill.mjs
4
+ import fs from "node:fs";
5
+ import path2 from "node:path";
6
+
7
+ // scripts/is-cli-entrypoint.mjs
8
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ function isCliEntrypoint(importMetaUrl, entryName) {
12
+ if (!process.argv[1]) return false;
13
+ try {
14
+ const argvPath = realpathSync(process.argv[1]);
15
+ const metaPath = realpathSync(fileURLToPath(importMetaUrl));
16
+ if (argvPath !== metaPath) return false;
17
+ if (entryName) {
18
+ return path.basename(argvPath).includes(entryName);
19
+ }
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ function findPackageRoot(importMetaUrl) {
26
+ let dir = path.dirname(fileURLToPath(importMetaUrl));
27
+ while (true) {
28
+ const pkgPath = path.join(dir, "package.json");
29
+ if (existsSync(pkgPath)) {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
32
+ if (pkg.name === "mini-figma-code-connect") return dir;
33
+ } catch {
34
+ }
35
+ }
36
+ const parent = path.dirname(dir);
37
+ if (parent === dir) {
38
+ throw new Error("[mini-figma-code-connect] \u627E\u4E0D\u5230\u5305\u6839\u76EE\u5F55\uFF08package.json\uFF09");
39
+ }
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ // scripts/install-skill.mjs
45
+ var PACKAGE_ROOT = findPackageRoot(import.meta.url);
46
+ var SKILL_SOURCE = path2.join(PACKAGE_ROOT, ".claude/skills/mini-code-connect");
47
+ var SKILL_NAME = "mini-code-connect";
48
+ function findRepoRoot(cwd) {
49
+ let dir = cwd;
50
+ while (true) {
51
+ if (fs.existsSync(path2.join(dir, ".git"))) return dir;
52
+ const parent = path2.dirname(dir);
53
+ if (parent === dir) return cwd;
54
+ dir = parent;
55
+ }
56
+ }
57
+ function installSkill({ cwd, root, force = false, claudeMirror = true }) {
58
+ const base = root ?? findRepoRoot(cwd);
59
+ const targets = [];
60
+ if (claudeMirror) targets.push(path2.join(base, ".claude/skills", SKILL_NAME));
61
+ if (fs.existsSync(path2.join(base, ".agents/skills"))) {
62
+ targets.push(path2.join(base, ".agents/skills", SKILL_NAME));
63
+ }
64
+ const results = [];
65
+ for (const target of targets) {
66
+ if (fs.existsSync(target) && !force) {
67
+ results.push({ target, skipped: true });
68
+ continue;
69
+ }
70
+ fs.rmSync(target, { recursive: true, force: true });
71
+ fs.cpSync(SKILL_SOURCE, target, { recursive: true });
72
+ results.push({ target, skipped: false });
73
+ }
74
+ return { results, root: base };
75
+ }
76
+ if (isCliEntrypoint(import.meta.url, "install-skill")) {
77
+ const args = process.argv.slice(2);
78
+ const force = args.includes("--force");
79
+ const noClaudeMirror = args.includes("--no-claude-mirror");
80
+ const rootIdx = args.indexOf("--root");
81
+ const root = rootIdx !== -1 ? path2.resolve(args[rootIdx + 1]) : void 0;
82
+ const { results, root: usedRoot } = installSkill({ cwd: process.cwd(), root, force, claudeMirror: !noClaudeMirror });
83
+ console.log(`[install-skill] \u9879\u76EE\u6839\u76EE\u5F55\uFF1A${usedRoot}`);
84
+ for (const r of results) {
85
+ console.log(r.skipped ? `[install-skill] \u5DF2\u5B58\u5728\uFF0C\u8DF3\u8FC7\uFF08\u52A0 --force \u8986\u76D6\uFF09\uFF1A${r.target}` : `[install-skill] \u5DF2\u5199\u5165\uFF1A${r.target}`);
86
+ }
87
+ console.log('\n\u8DD1 /mini-code-connect\uFF08\u6216\u76F4\u63A5\u8BF4"\u5E2E\u6211\u6620\u5C04\u8FD9\u4E2A Figma \u7EC4\u4EF6"\uFF09\u89E6\u53D1 skill\u3002');
88
+ }
89
+ export {
90
+ installSkill
91
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,388 @@
1
+ /**
2
+ * 主线程。三个运行形态,共用同一条流水线:
3
+ *
4
+ * figma.mode === 'codegen' → Dev Mode 右侧 Code 区(真实 Code Connect 出现的位置)
5
+ * figma.mode === 'inspect' → Dev Mode 右侧 Inspect 面板,铺满
6
+ * 其余(Design 模式) → 普通浮层面板
7
+ *
8
+ * 流水线本身刻意跟 figma-code-connect skill 的 6 步对齐:
9
+ * Step 1 归一化输入 —— 拿到 InstanceNode
10
+ * Step 2 任意节点 → 主组件 —— getMainComponentAsync + definitionOwner
11
+ * Step 3 组件 → 属性 schema —— extractSchema
12
+ * Step 4 找代码那一侧 —— registry.lookup(代码组件由模板的 meta.source 声明)
13
+ * Step 5 执行映射 —— template.render(handle)
14
+ * Step 6 自检 —— validate(schema, calls, probeCalls)
15
+ */
16
+ import { extractSchema, definitionOwner } from './schema';
17
+ import { InstanceHandle } from './handle';
18
+ import { lookup } from '../runtime/registry';
19
+ import { renderToString, collectErrors } from '../runtime/render';
20
+ import { validate } from './validate';
21
+ // build 时打包进 dist/code.js —— 装插件的人只有这份产物,摸不到消费方项目的源码,
22
+ // 所以这份映射表得跟着插件一起发,不能指望大家去消费方仓库里找 .figma.ts 文件
23
+ // 消费方的 figma-mapping-table.json 由 scripts/copy-mapping-table.mjs 在 build 前复制到这里
24
+ // (gitignore 掉,不进 git diff)——这份代码本身不该硬编码"数据就在我自己仓库里"这个假设。
25
+ import mappingTable from './mapping-table.generated.json';
26
+ async function mainRefOf(node) {
27
+ const main = await node.getMainComponentAsync();
28
+ if (!main)
29
+ return null;
30
+ const owner = definitionOwner(main);
31
+ return { id: owner.id, key: owner.key, name: owner.name };
32
+ }
33
+ /** 把整棵实例子树的主组件身份预解析好,让 render() 能保持同步。并发,别串行 */
34
+ async function buildMainMap(root) {
35
+ const nodes = [root];
36
+ for (const n of root.findAllWithCriteria({ types: ['INSTANCE'] }))
37
+ nodes.push(n);
38
+ const refs = await Promise.all(nodes.map((n) => mainRefOf(n)));
39
+ const map = new Map();
40
+ nodes.forEach((n, i) => {
41
+ const ref = refs[i];
42
+ if (ref)
43
+ map.set(n.id, ref);
44
+ });
45
+ return map;
46
+ }
47
+ async function analyze(node) {
48
+ const main = await node.getMainComponentAsync();
49
+ if (!main)
50
+ return { ok: false, reason: '读不到主组件(可能是缺失的远端组件)' };
51
+ const schema = await extractSchema(main);
52
+ const template = lookup(schema.componentKey, schema.componentName);
53
+ if (!template) {
54
+ return {
55
+ ok: false,
56
+ schema,
57
+ reason: `还没有 "${schema.componentName}" 的映射。在这个项目的 figma-mappings/ 下新建一个 .figma.ts,` +
58
+ `match.componentName 填 "${schema.componentName}",然后重新跑一遍插件构建(会自动重新扫描收录)。`,
59
+ };
60
+ }
61
+ const mainByNode = await buildMainMap(node);
62
+ const calls = [];
63
+ let sections;
64
+ let imports;
65
+ try {
66
+ const result = template.render(new InstanceHandle(node, { mainByNode, calls, depth: 0 }));
67
+ sections = result.example;
68
+ imports = result.imports ?? [];
69
+ }
70
+ catch (err) {
71
+ return { ok: false, schema, reason: `模板执行抛错:${String(err)}` };
72
+ }
73
+ // 第二遍:探测模式,只为算「模板最多会碰到哪些属性」,避免条件分支被误报未映射
74
+ const probeCalls = [];
75
+ try {
76
+ template.render(new InstanceHandle(node, { mainByNode, calls: probeCalls, depth: 0, probe: true }));
77
+ }
78
+ catch {
79
+ // 探测失败不影响主流程
80
+ }
81
+ const findings = validate(schema, calls, probeCalls);
82
+ for (const m of collectErrors(sections))
83
+ findings.push({ level: 'error', text: m });
84
+ return {
85
+ ok: true,
86
+ schema,
87
+ template: { id: template.id, meta: template.meta },
88
+ sections,
89
+ snippet: renderToString(sections),
90
+ imports,
91
+ findings,
92
+ calls,
93
+ };
94
+ }
95
+ /** 嵌套在别的实例里的实例(比如 Button 内部的图标)交给那个父实例的模板自己处理,不算独立候选 */
96
+ function isNestedInAnotherInstance(node, root) {
97
+ let p = node.parent;
98
+ while (p && p !== root) {
99
+ if (p.type === 'INSTANCE')
100
+ return true;
101
+ p = p.parent;
102
+ }
103
+ return false;
104
+ }
105
+ async function isMapped(node) {
106
+ const ref = await mainRefOf(node);
107
+ return ref !== null && lookup(ref.key, ref.name) !== null;
108
+ }
109
+ /** figma.fileKey 只对私有插件开放(manifest 需 enablePrivatePluginApi),拿不到就老实置 null */
110
+ function figmaUrlFor(componentId) {
111
+ const fileKey = figma.fileKey;
112
+ return fileKey ? `https://www.figma.com/design/${fileKey}/?node-id=${componentId.replace(':', '-')}` : null;
113
+ }
114
+ async function asInstance(node) {
115
+ if (!node)
116
+ return { error: '在画布上选中一个组件实例' };
117
+ if (node.type === 'INSTANCE')
118
+ return node;
119
+ if ('findAllWithCriteria' in node) {
120
+ const all = node.findAllWithCriteria({ types: ['INSTANCE'] });
121
+ const mappedFlags = await Promise.all(all.map((n) => isMapped(n)));
122
+ // 嵌套实例已映射的话,父模板自己 getInstanceSwap 就能渲染出来,不用再单独列一遍;
123
+ // 没映射的嵌套实例(比如还没接的图标)没有别的地方能发现它,照样单独列出来,
124
+ // 不然永远不知道要先去映射它,父模板那个插槽也会一直是空的
125
+ const instances = all
126
+ .map((n, i) => ({ node: n, mapped: mappedFlags[i] }))
127
+ .filter(({ node: n, mapped }) => !(mapped && isNestedInAnotherInstance(n, node)));
128
+ if (instances.length === 1)
129
+ return instances[0].node;
130
+ if (instances.length > 1) {
131
+ return { candidates: instances.map(({ node: n, mapped }) => ({ id: n.id, name: n.name, mapped })) };
132
+ }
133
+ }
134
+ return { error: `选中的是 ${node.type},里面没有组件实例(INSTANCE)` };
135
+ }
136
+ // ───────────────────────── 形态一:Dev Mode Code 区 ─────────────────────────
137
+ if (figma.mode === 'codegen') {
138
+ figma.codegen.on('generate', async ({ node }) => {
139
+ const picked = await asInstance(node);
140
+ if ('error' in picked) {
141
+ return [{ title: 'Simple Code Connect', code: `// ${picked.error}`, language: 'PLAINTEXT' }];
142
+ }
143
+ if ('candidates' in picked) {
144
+ return [
145
+ {
146
+ title: 'Simple Code Connect',
147
+ code: `// 里面有 ${picked.candidates.length} 个组件实例,请单选其中一个:\n${picked.candidates
148
+ .map((c) => `// - ${c.name}`)
149
+ .join('\n')}`,
150
+ language: 'PLAINTEXT',
151
+ },
152
+ ];
153
+ }
154
+ const a = await analyze(picked);
155
+ if (!a.ok) {
156
+ return [{ title: 'Simple Code Connect', code: `// ${a.reason}`, language: 'PLAINTEXT' }];
157
+ }
158
+ const results = [
159
+ {
160
+ title: `React · ${a.template.meta.component}`,
161
+ code: (a.imports.length ? a.imports.join('\n') + '\n\n' : '') + a.snippet,
162
+ language: 'TYPESCRIPT',
163
+ },
164
+ ];
165
+ if (a.findings.length > 0) {
166
+ results.push({
167
+ title: 'Code Connect 自检',
168
+ code: a.findings.map((f) => `[${f.level}] ${f.text}`).join('\n'),
169
+ language: 'PLAINTEXT',
170
+ });
171
+ }
172
+ results.push({
173
+ title: '绑定',
174
+ code: [
175
+ `component: ${a.template.meta.component}`,
176
+ `source: ${a.template.meta.source}`,
177
+ `templateId: ${a.template.id}`,
178
+ `figma: ${a.schema.componentName}`,
179
+ ].join('\n'),
180
+ language: 'PLAINTEXT',
181
+ });
182
+ return results;
183
+ });
184
+ }
185
+ else {
186
+ // ───────────────────── 形态二/三:面板(Inspect 或 Design 浮层)─────────────────────
187
+ figma.showUI(__html__, { width: 480, height: 660, themeColors: true });
188
+ function post(msg) {
189
+ figma.ui.postMessage(msg);
190
+ }
191
+ /**
192
+ * 选区变化会连续触发 run(),而 run() 中间有多个 await。
193
+ * 没有代际校验的话,旧的那次可能在新的之后 post,面板显示上一个实例的结果。
194
+ */
195
+ let generation = 0;
196
+ /** 上一次「容器里多个实例」的候选名单,选完一个之后还留着,供面板画「返回列表」 */
197
+ let lastCandidates = null;
198
+ /** 扫一遍当前页所有实例,按主组件去重,列出全部并标已映射/未映射——对应真实 CC 的 get_code_connect_suggestions */
199
+ async function scanPage() {
200
+ const instances = figma.currentPage.findAllWithCriteria({ types: ['INSTANCE'] });
201
+ const groups = new Map();
202
+ for (const inst of instances) {
203
+ const main = await inst.getMainComponentAsync();
204
+ if (!main)
205
+ continue;
206
+ const owner = definitionOwner(main);
207
+ const template = lookup(owner.key, owner.name);
208
+ const groupKey = owner.key || owner.id;
209
+ const g = groups.get(groupKey);
210
+ if (g) {
211
+ g.count++;
212
+ }
213
+ else {
214
+ groups.set(groupKey, {
215
+ componentName: owner.name,
216
+ count: 1,
217
+ sampleNodeId: inst.id,
218
+ mapped: template !== null,
219
+ codeComponent: template ? template.meta.component : null,
220
+ });
221
+ }
222
+ }
223
+ const list = [...groups.entries()].map(([componentKey, g]) => ({ componentKey, ...g }));
224
+ list.sort((a, b) => Number(a.mapped) - Number(b.mapped) || b.count - a.count);
225
+ const unmappedCount = list.filter((g) => !g.mapped).length;
226
+ post({
227
+ type: 'state',
228
+ state: 'scan',
229
+ message: list.length === 0
230
+ ? '当前页面没有任何组件实例'
231
+ : `共 ${list.length} 个组件,${unmappedCount} 个还没映射`,
232
+ groups: list,
233
+ });
234
+ }
235
+ async function run() {
236
+ const my = ++generation;
237
+ const stale = () => my !== generation;
238
+ const sel = figma.currentPage.selection;
239
+ if (sel.length > 1) {
240
+ post({ type: 'state', state: 'empty', message: `选中了 ${sel.length} 个节点,请单选一个组件实例` });
241
+ return;
242
+ }
243
+ const picked = await asInstance(sel[0] ?? null);
244
+ if ('error' in picked) {
245
+ post({ type: 'state', state: 'empty', message: picked.error, candidates: lastCandidates ?? undefined });
246
+ return;
247
+ }
248
+ if ('candidates' in picked) {
249
+ lastCandidates = picked.candidates;
250
+ post({
251
+ type: 'state',
252
+ state: 'candidates',
253
+ message: `里面有 ${picked.candidates.length} 个组件实例,请单选其中一个`,
254
+ candidates: picked.candidates,
255
+ });
256
+ return;
257
+ }
258
+ const a = await analyze(picked);
259
+ if (stale())
260
+ return;
261
+ if (!a.ok) {
262
+ post({
263
+ type: 'state',
264
+ state: a.schema ? 'unmapped' : 'error',
265
+ message: a.reason,
266
+ schema: a.schema,
267
+ figmaUrl: a.schema ? figmaUrlFor(a.schema.componentId) : undefined,
268
+ candidates: lastCandidates ?? undefined,
269
+ });
270
+ return;
271
+ }
272
+ post({
273
+ type: 'state',
274
+ state: 'ok',
275
+ message: '',
276
+ schema: a.schema,
277
+ template: a.template,
278
+ snippet: a.snippet,
279
+ imports: a.imports,
280
+ sections: a.sections,
281
+ findings: a.findings,
282
+ calls: a.calls,
283
+ candidates: lastCandidates ?? undefined,
284
+ });
285
+ }
286
+ /**
287
+ * Figma 不会 await 这些 handler,也不会处理它们的 rejection。
288
+ * 任何漏出来的 throw 都等于「面板永久停在上一帧」,所以每个入口都得自己兜住。
289
+ */
290
+ async function guard(fn) {
291
+ try {
292
+ await fn();
293
+ }
294
+ catch (err) {
295
+ post({ type: 'state', state: 'error', message: `插件内部错误:${String(err)}` });
296
+ console.error(err);
297
+ }
298
+ }
299
+ figma.ui.onmessage = (msg) => {
300
+ if (msg.type === 'refresh')
301
+ void guard(run);
302
+ else if (msg.type === 'pick') {
303
+ void guard(async () => {
304
+ const node = await figma.getNodeByIdAsync(msg.id);
305
+ if (!node || node.type !== 'INSTANCE') {
306
+ figma.notify('这个实例已经不在画布上了,重新读取一下');
307
+ return;
308
+ }
309
+ figma.currentPage.selection = [node];
310
+ figma.viewport.scrollAndZoomIntoView([node]);
311
+ await run();
312
+ });
313
+ }
314
+ else if (msg.type === 'scan') {
315
+ void guard(scanPage);
316
+ }
317
+ else if (msg.type === 'exportMappingTable') {
318
+ void guard(async () => {
319
+ post({ type: 'download', filename: 'mapping-table.json', json: JSON.stringify(mappingTable, null, 2) });
320
+ });
321
+ }
322
+ else if (msg.type === 'exportAllSchemas') {
323
+ void guard(async () => {
324
+ if (!lastCandidates || lastCandidates.length === 0) {
325
+ figma.notify('没有候选实例可导出,先框选一个里面有多个实例的容器');
326
+ return;
327
+ }
328
+ const schemas = [];
329
+ const seenKeys = new Set();
330
+ for (const c of lastCandidates) {
331
+ const node = await figma.getNodeByIdAsync(c.id);
332
+ if (!node || node.type !== 'INSTANCE')
333
+ continue;
334
+ const main = await node.getMainComponentAsync();
335
+ if (!main)
336
+ continue;
337
+ const schema = await extractSchema(main);
338
+ const dedupeKey = schema.componentKey || schema.componentId;
339
+ if (seenKeys.has(dedupeKey))
340
+ continue;
341
+ seenKeys.add(dedupeKey);
342
+ schemas.push(schema);
343
+ }
344
+ if (schemas.length === 0) {
345
+ figma.notify('没读到任何组件 schema');
346
+ return;
347
+ }
348
+ post({ type: 'download', filename: 'schemas.json', json: JSON.stringify(schemas, null, 2) });
349
+ });
350
+ }
351
+ else if (msg.type === 'backToList') {
352
+ if (lastCandidates) {
353
+ post({
354
+ type: 'state',
355
+ state: 'candidates',
356
+ message: `里面有 ${lastCandidates.length} 个组件实例,请单选其中一个`,
357
+ candidates: lastCandidates,
358
+ });
359
+ }
360
+ }
361
+ else if (msg.type === 'exportSchema') {
362
+ void guard(async () => {
363
+ const picked = await asInstance(figma.currentPage.selection[0] ?? null);
364
+ if ('error' in picked) {
365
+ figma.notify(picked.error);
366
+ return;
367
+ }
368
+ if ('candidates' in picked) {
369
+ figma.notify(`里面有 ${picked.candidates.length} 个组件实例,请先单选其中一个`);
370
+ return;
371
+ }
372
+ const main = await picked.getMainComponentAsync();
373
+ if (!main) {
374
+ figma.notify('读不到主组件');
375
+ return;
376
+ }
377
+ const schema = await extractSchema(main);
378
+ const safeName = schema.componentName.replace(/[^a-zA-Z0-9]+/g, '') || 'Component';
379
+ post({ type: 'download', filename: `${safeName}.schema.json`, json: JSON.stringify(schema, null, 2) });
380
+ });
381
+ }
382
+ };
383
+ figma.on('selectionchange', () => {
384
+ void guard(run);
385
+ });
386
+ // 首次读取由 UI 就绪后主动 send('refresh') 触发 —— 否则这里的 post 可能早于
387
+ // iframe 挂上 onmessage,首帧直接丢掉。
388
+ }
@@ -0,0 +1,55 @@
1
+ import type { AccessorCall, ErrorLike, InstanceLike, TemplateResult, TextLike } from '../runtime/types';
2
+ /** 预解析好的主组件身份 */
3
+ export type MainRef = {
4
+ id: string;
5
+ key: string;
6
+ name: string;
7
+ };
8
+ /**
9
+ * render() 是同步的(跟真实 CC 一致),但 Figma 的 getMainComponentAsync 是异步的。
10
+ * 解法:执行模板前先把整棵实例子树的主组件身份解析好塞进 ctx,句柄层就能保持同步。
11
+ */
12
+ export type RenderCtx = {
13
+ mainByNode: Map<string, MainRef>;
14
+ calls: AccessorCall[];
15
+ depth: number;
16
+ /**
17
+ * 探测模式:所有 getBoolean 一律返回 true。
18
+ * 用来跑第二遍 render,收集「模板最多会碰到哪些属性」——
19
+ * 否则条件分支里的属性(Has Icon 为 false 时的 Icon)会被误报成「未映射」。
20
+ */
21
+ probe?: boolean;
22
+ };
23
+ export declare class ErrorHandle implements ErrorLike {
24
+ readonly message: string;
25
+ readonly type: "ERROR";
26
+ constructor(message: string);
27
+ }
28
+ export declare class InstanceHandle implements InstanceLike {
29
+ private node;
30
+ private ctx;
31
+ readonly type: "INSTANCE";
32
+ constructor(node: InstanceNode, ctx: RenderCtx);
33
+ get name(): string;
34
+ private entryKey;
35
+ /** 保留 type —— 否则 getString 之类无法察觉自己读错了属性类型 */
36
+ private entry;
37
+ private log;
38
+ getString(prop: string): string;
39
+ getBoolean(prop: string): boolean;
40
+ getBoolean<T>(prop: string, mapping: {
41
+ true: T;
42
+ false: T;
43
+ }): T;
44
+ /** 字典查表。命中不了就返回 undefined —— 跟真实 CC 一样静默,靠 validate() 事后揪出来 */
45
+ getEnum<T>(prop: string, mapping: Record<string, T>): T | undefined;
46
+ /** 绑插槽而不是绑图层名:找到把 mainComponent 绑在这个属性上的后代实例 */
47
+ getInstanceSwap(prop: string): InstanceLike | ErrorLike | null;
48
+ findInstance(layerName: string): InstanceLike | ErrorLike;
49
+ findText(layerName: string): TextLike | ErrorLike;
50
+ private template;
51
+ hasCodeConnect(): boolean;
52
+ codeConnectId(): string | null;
53
+ /** 递归求值:子模板的输出直接插进父模板的字面量 */
54
+ executeTemplate(): TemplateResult | null;
55
+ }