mini-figma-code-connect 0.1.1 → 0.1.3

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 +10 -2
  3. package/dist/build.mjs +222 -0
  4. package/dist/figma-mapping.mjs +611 -0
  5. package/dist/generate-registry.mjs +79 -0
  6. package/dist/install-skill.mjs +85 -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 +80 -0
  33. package/dist/scaffold-plugin.mjs +462 -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
package/src/main/code.ts DELETED
@@ -1,422 +0,0 @@
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, type MainRef, type RenderCtx } from './handle'
18
- import { lookup } from '../runtime/registry'
19
- import { renderToString, collectErrors } from '../runtime/render'
20
- import { validate, type Finding } from './validate'
21
- import type { AccessorCall, ComponentSchema, ResultSection, TemplateMeta } from '../runtime/types'
22
- import type { FromUi, ToUi } from './messages'
23
- // build 时打包进 dist/code.js —— 装插件的人只有这份产物,摸不到消费方项目的源码,
24
- // 所以这份映射表得跟着插件一起发,不能指望大家去消费方仓库里找 .figma.ts 文件
25
- // 消费方的 figma-mapping-table.json 由 scripts/copy-mapping-table.mjs 在 build 前复制到这里
26
- // (gitignore 掉,不进 git diff)——这份代码本身不该硬编码"数据就在我自己仓库里"这个假设。
27
- import mappingTable from './mapping-table.generated.json'
28
-
29
- // ───────────────────────── 共用流水线 ─────────────────────────
30
-
31
- type Analysis =
32
- | { ok: false; reason: string; schema?: ComponentSchema }
33
- | {
34
- ok: true
35
- schema: ComponentSchema
36
- template: { id: string; meta: TemplateMeta }
37
- sections: ResultSection[]
38
- snippet: string
39
- imports: string[]
40
- findings: Finding[]
41
- calls: AccessorCall[]
42
- }
43
-
44
- async function mainRefOf(node: InstanceNode): Promise<MainRef | null> {
45
- const main = await node.getMainComponentAsync()
46
- if (!main) return null
47
- const owner = definitionOwner(main)
48
- return { id: owner.id, key: owner.key, name: owner.name }
49
- }
50
-
51
- /** 把整棵实例子树的主组件身份预解析好,让 render() 能保持同步。并发,别串行 */
52
- async function buildMainMap(root: InstanceNode): Promise<Map<string, MainRef>> {
53
- const nodes: InstanceNode[] = [root]
54
- for (const n of root.findAllWithCriteria({ types: ['INSTANCE'] })) nodes.push(n)
55
- const refs = await Promise.all(nodes.map((n) => mainRefOf(n)))
56
- const map = new Map<string, MainRef>()
57
- nodes.forEach((n, i) => {
58
- const ref = refs[i]
59
- if (ref) map.set(n.id, ref)
60
- })
61
- return map
62
- }
63
-
64
- async function analyze(node: InstanceNode): Promise<Analysis> {
65
- const main = await node.getMainComponentAsync()
66
- if (!main) return { ok: false, reason: '读不到主组件(可能是缺失的远端组件)' }
67
-
68
- const schema = await extractSchema(main)
69
- const template = lookup(schema.componentKey, schema.componentName)
70
- if (!template) {
71
- return {
72
- ok: false,
73
- schema,
74
- reason:
75
- `还没有 "${schema.componentName}" 的映射。在这个项目的 figma-mappings/ 下新建一个 .figma.ts,` +
76
- `match.componentName 填 "${schema.componentName}",然后重新跑一遍插件构建(会自动重新扫描收录)。`,
77
- }
78
- }
79
-
80
- const mainByNode = await buildMainMap(node)
81
- const calls: AccessorCall[] = []
82
-
83
- let sections: ResultSection[]
84
- let imports: string[]
85
- try {
86
- const result = template.render(new InstanceHandle(node, { mainByNode, calls, depth: 0 }))
87
- sections = result.example
88
- imports = result.imports ?? []
89
- } catch (err) {
90
- return { ok: false, schema, reason: `模板执行抛错:${String(err)}` }
91
- }
92
-
93
- // 第二遍:探测模式,只为算「模板最多会碰到哪些属性」,避免条件分支被误报未映射
94
- const probeCalls: AccessorCall[] = []
95
- try {
96
- template.render(new InstanceHandle(node, { mainByNode, calls: probeCalls, depth: 0, probe: true }))
97
- } catch {
98
- // 探测失败不影响主流程
99
- }
100
-
101
- const findings = validate(schema, calls, probeCalls)
102
- for (const m of collectErrors(sections)) findings.push({ level: 'error', text: m })
103
-
104
- return {
105
- ok: true,
106
- schema,
107
- template: { id: template.id, meta: template.meta },
108
- sections,
109
- snippet: renderToString(sections),
110
- imports,
111
- findings,
112
- calls,
113
- }
114
- }
115
-
116
- type Candidate = { id: string; name: string; mapped: boolean }
117
- type PickResult = InstanceNode | { error: string } | { candidates: Candidate[] }
118
-
119
- /** 嵌套在别的实例里的实例(比如 Button 内部的图标)交给那个父实例的模板自己处理,不算独立候选 */
120
- function isNestedInAnotherInstance(node: InstanceNode, root: BaseNode): boolean {
121
- let p: BaseNode | null = node.parent
122
- while (p && p !== root) {
123
- if (p.type === 'INSTANCE') return true
124
- p = p.parent
125
- }
126
- return false
127
- }
128
-
129
- async function isMapped(node: InstanceNode): Promise<boolean> {
130
- const ref = await mainRefOf(node)
131
- return ref !== null && lookup(ref.key, ref.name) !== null
132
- }
133
-
134
- /** figma.fileKey 只对私有插件开放(manifest 需 enablePrivatePluginApi),拿不到就老实置 null */
135
- function figmaUrlFor(componentId: string): string | null {
136
- const fileKey = figma.fileKey
137
- return fileKey ? `https://www.figma.com/design/${fileKey}/?node-id=${componentId.replace(':', '-')}` : null
138
- }
139
-
140
- async function asInstance(node: BaseNode | null): Promise<PickResult> {
141
- if (!node) return { error: '在画布上选中一个组件实例' }
142
- if (node.type === 'INSTANCE') return node
143
- if ('findAllWithCriteria' in node) {
144
- const all = (node as ChildrenMixin).findAllWithCriteria({ types: ['INSTANCE'] }) as InstanceNode[]
145
- const mappedFlags = await Promise.all(all.map((n) => isMapped(n)))
146
- // 嵌套实例已映射的话,父模板自己 getInstanceSwap 就能渲染出来,不用再单独列一遍;
147
- // 没映射的嵌套实例(比如还没接的图标)没有别的地方能发现它,照样单独列出来,
148
- // 不然永远不知道要先去映射它,父模板那个插槽也会一直是空的
149
- const instances = all
150
- .map((n, i) => ({ node: n, mapped: mappedFlags[i] }))
151
- .filter(({ node: n, mapped }) => !(mapped && isNestedInAnotherInstance(n, node)))
152
- if (instances.length === 1) return instances[0].node
153
- if (instances.length > 1) {
154
- return { candidates: instances.map(({ node: n, mapped }) => ({ id: n.id, name: n.name, mapped })) }
155
- }
156
- }
157
- return { error: `选中的是 ${node.type},里面没有组件实例(INSTANCE)` }
158
- }
159
-
160
- // ───────────────────────── 形态一:Dev Mode Code 区 ─────────────────────────
161
-
162
- if (figma.mode === 'codegen') {
163
- figma.codegen.on('generate', async ({ node }) => {
164
- const picked = await asInstance(node)
165
- if ('error' in picked) {
166
- return [{ title: 'Simple Code Connect', code: `// ${picked.error}`, language: 'PLAINTEXT' }]
167
- }
168
- if ('candidates' in picked) {
169
- return [
170
- {
171
- title: 'Simple Code Connect',
172
- code: `// 里面有 ${picked.candidates.length} 个组件实例,请单选其中一个:\n${picked.candidates
173
- .map((c) => `// - ${c.name}`)
174
- .join('\n')}`,
175
- language: 'PLAINTEXT',
176
- },
177
- ]
178
- }
179
-
180
- const a = await analyze(picked)
181
- if (!a.ok) {
182
- return [{ title: 'Simple Code Connect', code: `// ${a.reason}`, language: 'PLAINTEXT' }]
183
- }
184
-
185
- const results: CodegenResult[] = [
186
- {
187
- title: `React · ${a.template.meta.component}`,
188
- code: (a.imports.length ? a.imports.join('\n') + '\n\n' : '') + a.snippet,
189
- language: 'TYPESCRIPT',
190
- },
191
- ]
192
- if (a.findings.length > 0) {
193
- results.push({
194
- title: 'Code Connect 自检',
195
- code: a.findings.map((f) => `[${f.level}] ${f.text}`).join('\n'),
196
- language: 'PLAINTEXT',
197
- })
198
- }
199
- results.push({
200
- title: '绑定',
201
- code: [
202
- `component: ${a.template.meta.component}`,
203
- `source: ${a.template.meta.source}`,
204
- `templateId: ${a.template.id}`,
205
- `figma: ${a.schema.componentName}`,
206
- ].join('\n'),
207
- language: 'PLAINTEXT',
208
- })
209
- return results
210
- })
211
- } else {
212
- // ───────────────────── 形态二/三:面板(Inspect 或 Design 浮层)─────────────────────
213
-
214
- figma.showUI(__html__, { width: 480, height: 660, themeColors: true })
215
-
216
- function post(msg: ToUi): void {
217
- figma.ui.postMessage(msg)
218
- }
219
-
220
- /**
221
- * 选区变化会连续触发 run(),而 run() 中间有多个 await。
222
- * 没有代际校验的话,旧的那次可能在新的之后 post,面板显示上一个实例的结果。
223
- */
224
- let generation = 0
225
-
226
- /** 上一次「容器里多个实例」的候选名单,选完一个之后还留着,供面板画「返回列表」 */
227
- let lastCandidates: Candidate[] | null = null
228
-
229
- /** 扫一遍当前页所有实例,按主组件去重,列出全部并标已映射/未映射——对应真实 CC 的 get_code_connect_suggestions */
230
- async function scanPage(): Promise<void> {
231
- const instances = figma.currentPage.findAllWithCriteria({ types: ['INSTANCE'] })
232
- const groups = new Map<
233
- string,
234
- { componentName: string; count: number; sampleNodeId: string; mapped: boolean; codeComponent: string | null }
235
- >()
236
-
237
- for (const inst of instances) {
238
- const main = await inst.getMainComponentAsync()
239
- if (!main) continue
240
- const owner = definitionOwner(main)
241
- const template = lookup(owner.key, owner.name)
242
-
243
- const groupKey = owner.key || owner.id
244
- const g = groups.get(groupKey)
245
- if (g) {
246
- g.count++
247
- } else {
248
- groups.set(groupKey, {
249
- componentName: owner.name,
250
- count: 1,
251
- sampleNodeId: inst.id,
252
- mapped: template !== null,
253
- codeComponent: template ? template.meta.component : null,
254
- })
255
- }
256
- }
257
-
258
- const list = [...groups.entries()].map(([componentKey, g]) => ({ componentKey, ...g }))
259
- list.sort((a, b) => Number(a.mapped) - Number(b.mapped) || b.count - a.count)
260
- const unmappedCount = list.filter((g) => !g.mapped).length
261
- post({
262
- type: 'state',
263
- state: 'scan',
264
- message:
265
- list.length === 0
266
- ? '当前页面没有任何组件实例'
267
- : `共 ${list.length} 个组件,${unmappedCount} 个还没映射`,
268
- groups: list,
269
- })
270
- }
271
-
272
- async function run(): Promise<void> {
273
- const my = ++generation
274
- const stale = () => my !== generation
275
-
276
- const sel = figma.currentPage.selection
277
- if (sel.length > 1) {
278
- post({ type: 'state', state: 'empty', message: `选中了 ${sel.length} 个节点,请单选一个组件实例` })
279
- return
280
- }
281
- const picked = await asInstance(sel[0] ?? null)
282
- if ('error' in picked) {
283
- post({ type: 'state', state: 'empty', message: picked.error, candidates: lastCandidates ?? undefined })
284
- return
285
- }
286
- if ('candidates' in picked) {
287
- lastCandidates = picked.candidates
288
- post({
289
- type: 'state',
290
- state: 'candidates',
291
- message: `里面有 ${picked.candidates.length} 个组件实例,请单选其中一个`,
292
- candidates: picked.candidates,
293
- })
294
- return
295
- }
296
-
297
- const a = await analyze(picked)
298
- if (stale()) return
299
-
300
- if (!a.ok) {
301
- post({
302
- type: 'state',
303
- state: a.schema ? 'unmapped' : 'error',
304
- message: a.reason,
305
- schema: a.schema,
306
- figmaUrl: a.schema ? figmaUrlFor(a.schema.componentId) : undefined,
307
- candidates: lastCandidates ?? undefined,
308
- })
309
- return
310
- }
311
-
312
- post({
313
- type: 'state',
314
- state: 'ok',
315
- message: '',
316
- schema: a.schema,
317
- template: a.template,
318
- snippet: a.snippet,
319
- imports: a.imports,
320
- sections: a.sections,
321
- findings: a.findings,
322
- calls: a.calls,
323
- candidates: lastCandidates ?? undefined,
324
- })
325
- }
326
-
327
- /**
328
- * Figma 不会 await 这些 handler,也不会处理它们的 rejection。
329
- * 任何漏出来的 throw 都等于「面板永久停在上一帧」,所以每个入口都得自己兜住。
330
- */
331
- async function guard(fn: () => Promise<void>): Promise<void> {
332
- try {
333
- await fn()
334
- } catch (err) {
335
- post({ type: 'state', state: 'error', message: `插件内部错误:${String(err)}` })
336
- console.error(err)
337
- }
338
- }
339
-
340
- figma.ui.onmessage = (msg: FromUi) => {
341
- if (msg.type === 'refresh') void guard(run)
342
- else if (msg.type === 'pick') {
343
- void guard(async () => {
344
- const node = await figma.getNodeByIdAsync(msg.id)
345
- if (!node || node.type !== 'INSTANCE') {
346
- figma.notify('这个实例已经不在画布上了,重新读取一下')
347
- return
348
- }
349
- figma.currentPage.selection = [node]
350
- figma.viewport.scrollAndZoomIntoView([node])
351
- await run()
352
- })
353
- } else if (msg.type === 'scan') {
354
- void guard(scanPage)
355
- } else if (msg.type === 'exportMappingTable') {
356
- void guard(async () => {
357
- post({ type: 'download', filename: 'mapping-table.json', json: JSON.stringify(mappingTable, null, 2) })
358
- })
359
- } else if (msg.type === 'exportAllSchemas') {
360
- void guard(async () => {
361
- if (!lastCandidates || lastCandidates.length === 0) {
362
- figma.notify('没有候选实例可导出,先框选一个里面有多个实例的容器')
363
- return
364
- }
365
- const schemas: ComponentSchema[] = []
366
- const seenKeys = new Set<string>()
367
- for (const c of lastCandidates) {
368
- const node = await figma.getNodeByIdAsync(c.id)
369
- if (!node || node.type !== 'INSTANCE') continue
370
- const main = await (node as InstanceNode).getMainComponentAsync()
371
- if (!main) continue
372
- const schema = await extractSchema(main)
373
- const dedupeKey = schema.componentKey || schema.componentId
374
- if (seenKeys.has(dedupeKey)) continue
375
- seenKeys.add(dedupeKey)
376
- schemas.push(schema)
377
- }
378
- if (schemas.length === 0) {
379
- figma.notify('没读到任何组件 schema')
380
- return
381
- }
382
- post({ type: 'download', filename: 'schemas.json', json: JSON.stringify(schemas, null, 2) })
383
- })
384
- } else if (msg.type === 'backToList') {
385
- if (lastCandidates) {
386
- post({
387
- type: 'state',
388
- state: 'candidates',
389
- message: `里面有 ${lastCandidates.length} 个组件实例,请单选其中一个`,
390
- candidates: lastCandidates,
391
- })
392
- }
393
- } else if (msg.type === 'exportSchema') {
394
- void guard(async () => {
395
- const picked = await asInstance(figma.currentPage.selection[0] ?? null)
396
- if ('error' in picked) {
397
- figma.notify(picked.error)
398
- return
399
- }
400
- if ('candidates' in picked) {
401
- figma.notify(`里面有 ${picked.candidates.length} 个组件实例,请先单选其中一个`)
402
- return
403
- }
404
- const main = await picked.getMainComponentAsync()
405
- if (!main) {
406
- figma.notify('读不到主组件')
407
- return
408
- }
409
- const schema = await extractSchema(main)
410
- const safeName = schema.componentName.replace(/[^a-zA-Z0-9]+/g, '') || 'Component'
411
- post({ type: 'download', filename: `${safeName}.schema.json`, json: JSON.stringify(schema, null, 2) })
412
- })
413
- }
414
- }
415
-
416
- figma.on('selectionchange', () => {
417
- void guard(run)
418
- })
419
-
420
- // 首次读取由 UI 就绪后主动 send('refresh') 触发 —— 否则这里的 post 可能早于
421
- // iframe 挂上 onmessage,首帧直接丢掉。
422
- }
@@ -1,207 +0,0 @@
1
- import { lookup } from '../runtime/registry'
2
- import type {
3
- AccessorCall,
4
- ErrorLike,
5
- InstanceLike,
6
- Template,
7
- TemplateResult,
8
- TextLike,
9
- } from '../runtime/types'
10
-
11
- /** 预解析好的主组件身份 */
12
- export type MainRef = { id: string; key: string; name: string }
13
-
14
- /**
15
- * render() 是同步的(跟真实 CC 一致),但 Figma 的 getMainComponentAsync 是异步的。
16
- * 解法:执行模板前先把整棵实例子树的主组件身份解析好塞进 ctx,句柄层就能保持同步。
17
- */
18
- export type RenderCtx = {
19
- mainByNode: Map<string, MainRef>
20
- calls: AccessorCall[]
21
- depth: number
22
- /**
23
- * 探测模式:所有 getBoolean 一律返回 true。
24
- * 用来跑第二遍 render,收集「模板最多会碰到哪些属性」——
25
- * 否则条件分支里的属性(Has Icon 为 false 时的 Icon)会被误报成「未映射」。
26
- */
27
- probe?: boolean
28
- }
29
-
30
- export class ErrorHandle implements ErrorLike {
31
- readonly type = 'ERROR' as const
32
- constructor(readonly message: string) {}
33
- }
34
-
35
- class TextHandle implements TextLike {
36
- readonly type = 'TEXT' as const
37
- constructor(private node: TextNode) {}
38
- get name(): string {
39
- return this.node.name
40
- }
41
- get textContent(): string {
42
- return this.node.characters
43
- }
44
- }
45
-
46
- export class InstanceHandle implements InstanceLike {
47
- readonly type = 'INSTANCE' as const
48
-
49
- constructor(private node: InstanceNode, private ctx: RenderCtx) {}
50
-
51
- get name(): string {
52
- return this.node.name
53
- }
54
-
55
- // ── 属性查找:key 带 "#id" 后缀,按显示名找回来 ──
56
- private entryKey(prop: string): string | undefined {
57
- const props = this.node.componentProperties
58
- if (Object.prototype.hasOwnProperty.call(props, prop)) return prop
59
- return Object.keys(props).find((k) => k.split('#')[0] === prop)
60
- }
61
-
62
- /** 保留 type —— 否则 getString 之类无法察觉自己读错了属性类型 */
63
- private entry(prop: string): { type: ComponentPropertyType; value: string | boolean } | undefined {
64
- const k = this.entryKey(prop)
65
- if (k === undefined) return undefined
66
- return this.node.componentProperties[k]
67
- }
68
-
69
- private log(c: Omit<AccessorCall, 'depth'>): void {
70
- this.ctx.calls.push({ ...c, depth: this.ctx.depth })
71
- }
72
-
73
- getString(prop: string): string {
74
- const e = this.entry(prop)
75
- if (!e) {
76
- this.log({ prop, method: 'getString', ok: false, note: '属性不存在' })
77
- return ''
78
- }
79
- if (e.type === 'INSTANCE_SWAP') {
80
- // INSTANCE_SWAP 的 value 是个节点 id,直接当字符串吐进代码里毫无意义
81
- this.log({
82
- prop,
83
- method: 'getString',
84
- ok: false,
85
- note: '这是 INSTANCE_SWAP 属性,应该用 getInstanceSwap()',
86
- })
87
- return ''
88
- }
89
- this.log({ prop, method: 'getString', ok: true })
90
- return String(e.value)
91
- }
92
-
93
- getBoolean(prop: string): boolean
94
- getBoolean<T>(prop: string, mapping: { true: T; false: T }): T
95
- getBoolean(prop: string, mapping?: { true: unknown; false: unknown }): unknown {
96
- const e = this.entry(prop)
97
- if (!e) {
98
- this.log({ prop, method: 'getBoolean', ok: false, note: '属性不存在' })
99
- return mapping ? mapping.false : false
100
- }
101
- // 布尔常被做成 VARIANT,选项写作 "True"/"False"(Figma UI 的默认命名),
102
- // 所以必须大小写不敏感,否则 Disabled 永远读成 false
103
- const v = this.ctx.probe ? true : e.value === true || String(e.value).toLowerCase() === 'true'
104
- this.log({ prop, method: 'getBoolean', ok: true })
105
- return mapping ? (v ? mapping.true : mapping.false) : v
106
- }
107
-
108
- /** 字典查表。命中不了就返回 undefined —— 跟真实 CC 一样静默,靠 validate() 事后揪出来 */
109
- getEnum<T>(prop: string, mapping: Record<string, T>): T | undefined {
110
- const keys = Object.keys(mapping)
111
- const e = this.entry(prop)
112
- if (!e) {
113
- this.log({ prop, method: 'getEnum', mappingKeys: keys, ok: false, note: '属性不存在' })
114
- return undefined
115
- }
116
- const raw = String(e.value)
117
- const hit = Object.prototype.hasOwnProperty.call(mapping, raw)
118
- this.log({
119
- prop,
120
- method: 'getEnum',
121
- mappingKeys: keys,
122
- ok: hit,
123
- note: hit ? undefined : `当前值 "${raw}" 不在字典里,返回了 undefined`,
124
- })
125
- return hit ? mapping[raw] : undefined
126
- }
127
-
128
- /** 绑插槽而不是绑图层名:找到把 mainComponent 绑在这个属性上的后代实例 */
129
- getInstanceSwap(prop: string): InstanceLike | ErrorLike | null {
130
- const key = this.entryKey(prop)
131
- if (key === undefined) {
132
- this.log({ prop, method: 'getInstanceSwap', ok: false, note: '属性不存在' })
133
- return null
134
- }
135
- const hit = this.node.findOne((n) => {
136
- if (n.type !== 'INSTANCE') return false
137
- const refs = (n as InstanceNode).componentPropertyReferences
138
- return !!refs && refs.mainComponent === key
139
- }) as InstanceNode | null
140
-
141
- if (!hit) {
142
- this.log({ prop, method: 'getInstanceSwap', ok: false, note: '找不到绑定该属性的子实例' })
143
- return new ErrorHandle(`找不到 instance swap 插槽 "${prop}"`)
144
- }
145
- this.log({ prop, method: 'getInstanceSwap', ok: true })
146
- return new InstanceHandle(hit, this.ctx)
147
- }
148
-
149
- findInstance(layerName: string): InstanceLike | ErrorLike {
150
- const hit = this.node.findOne((n) => n.type === 'INSTANCE' && n.name === layerName) as InstanceNode | null
151
- return hit ? new InstanceHandle(hit, this.ctx) : new ErrorHandle(`找不到图层 "${layerName}"`)
152
- }
153
-
154
- findText(layerName: string): TextLike | ErrorLike {
155
- const hit = this.node.findOne((n) => n.type === 'TEXT' && n.name === layerName) as TextNode | null
156
- return hit ? new TextHandle(hit) : new ErrorHandle(`找不到文本图层 "${layerName}"`)
157
- }
158
-
159
- private template(): Template | null {
160
- const ref = this.ctx.mainByNode.get(this.node.id)
161
- return ref ? lookup(ref.key, ref.name) : null
162
- }
163
-
164
- hasCodeConnect(): boolean {
165
- return this.template() !== null
166
- }
167
-
168
- codeConnectId(): string | null {
169
- const t = this.template()
170
- return t ? t.id : null
171
- }
172
-
173
- /** 递归求值:子模板的输出直接插进父模板的字面量 */
174
- executeTemplate(): TemplateResult | null {
175
- const ref = this.ctx.mainByNode.get(this.node.id)
176
-
177
- if (this.ctx.depth > 8) {
178
- return { example: [{ type: 'ERROR', message: '嵌套层级过深,已截断' }], id: 'depth-limit' }
179
- }
180
-
181
- const t = this.template()
182
- if (!t) {
183
- // 没有映射 → 返回 INSTANCE 段,保留实例身份(真实 CC 在这里渲染成 pill)
184
- return {
185
- example: [
186
- {
187
- type: 'INSTANCE',
188
- guid: this.node.id,
189
- symbolId: ref ? ref.id : '',
190
- name: ref ? ref.name : this.node.name,
191
- },
192
- ],
193
- id: 'unmapped',
194
- }
195
- }
196
-
197
- const child = new InstanceHandle(this.node, { ...this.ctx, depth: this.ctx.depth + 1 })
198
- try {
199
- return t.render(child)
200
- } catch (err) {
201
- return {
202
- example: [{ type: 'ERROR', message: `模板 ${t.id} 执行失败: ${String(err)}` }],
203
- id: t.id,
204
- }
205
- }
206
- }
207
- }
@@ -1,49 +0,0 @@
1
- import type { AccessorCall, ComponentSchema, ResultSection, TemplateMeta } from '../runtime/types'
2
- import type { Finding } from './validate'
3
-
4
- type Candidate = { id: string; name: string; mapped: boolean }
5
- export type UnmappedGroup = {
6
- componentKey: string
7
- componentName: string
8
- count: number
9
- sampleNodeId: string
10
- mapped: boolean
11
- codeComponent: string | null
12
- }
13
-
14
- export type ToUi =
15
- | {
16
- type: 'state'
17
- state: 'empty' | 'unmapped' | 'error'
18
- message: string
19
- schema?: ComponentSchema
20
- /** 该实例在 Figma 里的完整节点地址,拿不到 figma.fileKey 时为 null;仅 unmapped 用得上 */
21
- figmaUrl?: string | null
22
- /** 上一次「一个容器里多个实例」的候选名单,供面板画「返回列表」 */
23
- candidates?: Candidate[]
24
- }
25
- | { type: 'state'; state: 'candidates'; message: string; candidates: Candidate[] }
26
- | { type: 'state'; state: 'scan'; message: string; groups: UnmappedGroup[] }
27
- | {
28
- type: 'state'
29
- state: 'ok'
30
- message: ''
31
- schema: ComponentSchema
32
- template: { id: string; meta: TemplateMeta }
33
- snippet: string
34
- imports: string[]
35
- sections: ResultSection[]
36
- findings: Finding[]
37
- calls: AccessorCall[]
38
- candidates?: Candidate[]
39
- }
40
- | { type: 'download'; filename: string; json: string }
41
-
42
- export type FromUi =
43
- | { type: 'refresh' }
44
- | { type: 'pick'; id: string }
45
- | { type: 'backToList' }
46
- | { type: 'exportSchema' }
47
- | { type: 'scan' }
48
- | { type: 'exportAllSchemas' }
49
- | { type: 'exportMappingTable' }