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.
- package/LICENSE +13 -9
- package/README.md +10 -2
- package/dist/build.mjs +222 -0
- package/dist/figma-mapping.mjs +611 -0
- package/dist/generate-registry.mjs +79 -0
- package/dist/install-skill.mjs +85 -0
- package/dist/main/code.d.ts +1 -0
- package/dist/main/code.js +388 -0
- package/dist/main/handle.d.ts +55 -0
- package/dist/main/handle.js +167 -0
- package/dist/main/mapping-table.generated.json +1 -0
- package/dist/main/messages.d.ts +71 -0
- package/dist/main/messages.js +1 -0
- package/dist/main/schema.d.ts +9 -0
- package/dist/main/schema.js +39 -0
- package/dist/main/validate.d.ts +16 -0
- package/dist/main/validate.js +40 -0
- package/dist/runtime/define.d.ts +3 -0
- package/dist/runtime/define.js +4 -0
- package/{src/runtime/index.ts → dist/runtime/index.d.ts} +3 -14
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/registry.d.ts +5 -0
- package/dist/runtime/registry.generated.d.ts +3 -0
- package/dist/runtime/registry.generated.js +1 -0
- package/{src/runtime/registry.ts → dist/runtime/registry.js} +7 -8
- package/dist/runtime/render.d.ts +6 -0
- package/dist/runtime/render.js +27 -0
- package/dist/runtime/tagged.d.ts +6 -0
- package/dist/runtime/tagged.js +45 -0
- package/dist/runtime/types.d.ts +107 -0
- package/dist/runtime/types.js +7 -0
- package/dist/scaffold-manifest.mjs +80 -0
- package/dist/scaffold-plugin.mjs +462 -0
- package/dist/ui/ui.d.ts +84 -0
- package/dist/ui/ui.js +190 -0
- package/package.json +23 -18
- package/scripts/build-plugin.mjs +0 -132
- package/scripts/copy-mapping-table.mjs +0 -19
- package/scripts/figma-mapping/ai-generate.mjs +0 -102
- package/scripts/figma-mapping/index.mjs +0 -250
- package/scripts/figma-mapping/lib.mjs +0 -112
- package/scripts/figma-mapping/registry.mjs +0 -12
- package/scripts/figma-mapping/scaffold.mjs +0 -153
- package/scripts/generate-registry.mjs +0 -94
- package/scripts/install-skill.mjs +0 -79
- package/scripts/is-cli-entrypoint.mjs +0 -21
- package/scripts/scaffold-manifest.mjs +0 -84
- package/scripts/scaffold-plugin.mjs +0 -172
- package/src/dev/demo.ts +0 -207
- package/src/dev/export.ts +0 -28
- package/src/main/code.ts +0 -422
- package/src/main/handle.ts +0 -207
- package/src/main/messages.ts +0 -49
- package/src/main/schema.ts +0 -45
- package/src/main/validate.ts +0 -49
- package/src/runtime/define.ts +0 -6
- package/src/runtime/render.ts +0 -26
- package/src/runtime/tagged.ts +0 -46
- package/src/runtime/types.ts +0 -86
- package/src/ui/ui.ts +0 -271
- /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
|
-
}
|
package/src/main/handle.ts
DELETED
|
@@ -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
|
-
}
|
package/src/main/messages.ts
DELETED
|
@@ -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' }
|