mini-figma-code-connect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/mini-code-connect/SKILL.md +100 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/figma-mapping.config.example.json +11 -0
- package/package.json +70 -0
- package/scripts/build-plugin.mjs +132 -0
- package/scripts/copy-mapping-table.mjs +19 -0
- package/scripts/figma-mapping/ai-generate.mjs +102 -0
- package/scripts/figma-mapping/index.mjs +250 -0
- package/scripts/figma-mapping/lib.mjs +112 -0
- package/scripts/figma-mapping/registry.mjs +12 -0
- package/scripts/figma-mapping/scaffold.mjs +153 -0
- package/scripts/generate-registry.mjs +94 -0
- package/scripts/install-skill.mjs +79 -0
- package/scripts/is-cli-entrypoint.mjs +21 -0
- package/scripts/scaffold-manifest.mjs +84 -0
- package/scripts/scaffold-plugin.mjs +172 -0
- package/src/dev/demo.ts +207 -0
- package/src/dev/export.ts +28 -0
- package/src/main/code.ts +422 -0
- package/src/main/handle.ts +207 -0
- package/src/main/messages.ts +49 -0
- package/src/main/schema.ts +45 -0
- package/src/main/validate.ts +49 -0
- package/src/runtime/define.ts +6 -0
- package/src/runtime/index.ts +21 -0
- package/src/runtime/registry.ts +14 -0
- package/src/runtime/render.ts +26 -0
- package/src/runtime/tagged.ts +46 -0
- package/src/runtime/types.ts +86 -0
- package/src/ui/ui.html +114 -0
- package/src/ui/ui.ts +271 -0
package/src/ui/ui.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
type Level = 'error' | 'warn'
|
|
2
|
+
type Finding = { level: Level; text: string }
|
|
3
|
+
type PropertyDef = { name: string; type: string; variantOptions?: string[] }
|
|
4
|
+
type Schema = {
|
|
5
|
+
componentId: string
|
|
6
|
+
componentKey: string
|
|
7
|
+
componentName: string
|
|
8
|
+
properties: PropertyDef[]
|
|
9
|
+
}
|
|
10
|
+
type AccessorCall = { depth: number; prop: string; method: string; ok: boolean; note?: string }
|
|
11
|
+
type Candidate = { id: string; name: string; mapped: boolean }
|
|
12
|
+
type Msg =
|
|
13
|
+
| {
|
|
14
|
+
type: 'state'
|
|
15
|
+
state: 'empty' | 'unmapped' | 'error'
|
|
16
|
+
message: string
|
|
17
|
+
schema?: Schema
|
|
18
|
+
figmaUrl?: string | null
|
|
19
|
+
candidates?: Candidate[]
|
|
20
|
+
}
|
|
21
|
+
| { type: 'state'; state: 'candidates'; message: string; candidates: Candidate[] }
|
|
22
|
+
| {
|
|
23
|
+
type: 'state'
|
|
24
|
+
state: 'scan'
|
|
25
|
+
message: string
|
|
26
|
+
groups: {
|
|
27
|
+
componentKey: string
|
|
28
|
+
componentName: string
|
|
29
|
+
count: number
|
|
30
|
+
sampleNodeId: string
|
|
31
|
+
mapped: boolean
|
|
32
|
+
codeComponent: string | null
|
|
33
|
+
}[]
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: 'state'
|
|
37
|
+
state: 'ok'
|
|
38
|
+
schema: Schema
|
|
39
|
+
template: { id: string; meta: { url: string; source: string; component: string } }
|
|
40
|
+
snippet: string
|
|
41
|
+
imports: string[]
|
|
42
|
+
findings: Finding[]
|
|
43
|
+
calls: AccessorCall[]
|
|
44
|
+
candidates?: Candidate[]
|
|
45
|
+
}
|
|
46
|
+
| { type: 'download'; filename: string; json: string }
|
|
47
|
+
|
|
48
|
+
const body = document.getElementById('body') as HTMLElement
|
|
49
|
+
const send = (type: string, extra?: Record<string, unknown>) =>
|
|
50
|
+
parent.postMessage({ pluginMessage: { type, ...extra } }, '*')
|
|
51
|
+
|
|
52
|
+
const esc = (s: string) =>
|
|
53
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
54
|
+
|
|
55
|
+
function schemaTable(schema: Schema, calls: AccessorCall[]): string {
|
|
56
|
+
const consumed = new Set(calls.filter((c) => c.depth === 0).map((c) => c.prop))
|
|
57
|
+
const rows = schema.properties
|
|
58
|
+
.map((p) => {
|
|
59
|
+
const tag = consumed.has(p.name)
|
|
60
|
+
? '<span class="tag ok">已映射</span>'
|
|
61
|
+
: '<span class="tag miss">未映射</span>'
|
|
62
|
+
const opts = p.variantOptions ? esc(p.variantOptions.join(' / ')) : '—'
|
|
63
|
+
return `<tr><td>${esc(p.name)}</td><td><code>${p.type}</code></td><td>${opts}</td><td>${tag}</td></tr>`
|
|
64
|
+
})
|
|
65
|
+
.join('')
|
|
66
|
+
return `<table><thead><tr><th>属性</th><th>类型</th><th>可选值</th><th></th></tr></thead><tbody>${rows}</tbody></table>`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function backBar(candidates?: Candidate[]): string {
|
|
70
|
+
if (!candidates || candidates.length === 0) return ''
|
|
71
|
+
return `<div class="row"><button class="ghost back" type="button">← 返回列表(${candidates.length} 个实例)</button></div>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wireBack(): void {
|
|
75
|
+
const btn = document.querySelector<HTMLButtonElement>('.back')
|
|
76
|
+
if (btn) btn.addEventListener('click', () => send('backToList'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 一个按钮身兼两职:候选列表状态(选中的是容器,没有单个实例)导出全部;其它状态导出当前选中的单个实例
|
|
80
|
+
const exportSchemaBtn = document.getElementById('export-schema') as HTMLButtonElement
|
|
81
|
+
let inCandidatesMode = false
|
|
82
|
+
function syncExportButton(state: string, count: number): void {
|
|
83
|
+
inCandidatesMode = state === 'candidates'
|
|
84
|
+
if (state === 'candidates') {
|
|
85
|
+
exportSchemaBtn.disabled = count === 0
|
|
86
|
+
exportSchemaBtn.textContent = count > 0 ? `导出全部 schema.json(${count} 个)` : '导出全部 schema.json'
|
|
87
|
+
} else {
|
|
88
|
+
exportSchemaBtn.disabled = false
|
|
89
|
+
exportSchemaBtn.textContent = '导出 schema.json'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function render(msg: Msg): void {
|
|
94
|
+
if (msg.type !== 'download') {
|
|
95
|
+
syncExportButton(msg.state, msg.state === 'candidates' ? msg.candidates.length : 0)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (msg.type === 'download') {
|
|
99
|
+
const url = URL.createObjectURL(new Blob([msg.json], { type: 'application/json' }))
|
|
100
|
+
const a = document.createElement('a')
|
|
101
|
+
a.href = url
|
|
102
|
+
a.download = msg.filename
|
|
103
|
+
document.body.appendChild(a)
|
|
104
|
+
a.click()
|
|
105
|
+
a.remove()
|
|
106
|
+
URL.revokeObjectURL(url)
|
|
107
|
+
// 兜底:iframe 的 sandbox 若拦掉下载,至少把内容留在剪贴板里
|
|
108
|
+
void navigator.clipboard.writeText(msg.json).catch(() => undefined)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (msg.state === 'scan') {
|
|
113
|
+
// 未映射排前面(跟 scanPage 排序一致),仿 Figma 官方 Code Connect UI 那张组件目录列表的样式:
|
|
114
|
+
// 一行一个组件,左边 Figma 组件名,右边状态 + 代码组件名,顶部一个搜索框过滤
|
|
115
|
+
const rows = msg.groups
|
|
116
|
+
.map(
|
|
117
|
+
(g) => `
|
|
118
|
+
<li class="component-row" data-search="${esc(g.componentName.toLowerCase())}">
|
|
119
|
+
<button class="row-name" type="button" data-id="${esc(g.sampleNodeId)}">
|
|
120
|
+
<span class="diamond">◆</span>
|
|
121
|
+
<span>${esc(g.componentName)}</span>
|
|
122
|
+
<span class="dim">${g.count} 处</span>
|
|
123
|
+
</button>
|
|
124
|
+
<span class="row-code">
|
|
125
|
+
${
|
|
126
|
+
g.mapped
|
|
127
|
+
? `<span class="status-dot ok">✓</span><code><${esc(g.codeComponent ?? '?')}></code>`
|
|
128
|
+
: `<span class="status-dot miss">×</span><span class="dim">未映射</span>`
|
|
129
|
+
}
|
|
130
|
+
</span>
|
|
131
|
+
</li>`,
|
|
132
|
+
)
|
|
133
|
+
.join('')
|
|
134
|
+
|
|
135
|
+
body.innerHTML = `
|
|
136
|
+
<section>
|
|
137
|
+
<p class="hint">${esc(msg.message)}</p>
|
|
138
|
+
<input class="scan-search" type="text" placeholder="搜索组件…" />
|
|
139
|
+
<div class="component-list">
|
|
140
|
+
<div class="list-header"><span>Figma 组件</span><span>代码组件</span></div>
|
|
141
|
+
<ul class="component-rows">${rows}</ul>
|
|
142
|
+
</div>
|
|
143
|
+
</section>
|
|
144
|
+
`
|
|
145
|
+
body.querySelectorAll<HTMLButtonElement>('.row-name').forEach((el) => {
|
|
146
|
+
el.addEventListener('click', () => send('pick', { id: el.dataset.id }))
|
|
147
|
+
})
|
|
148
|
+
const search = document.querySelector<HTMLInputElement>('.scan-search')
|
|
149
|
+
if (search) {
|
|
150
|
+
search.addEventListener('input', () => {
|
|
151
|
+
const q = search.value.trim().toLowerCase()
|
|
152
|
+
body.querySelectorAll<HTMLLIElement>('.component-row').forEach((row) => {
|
|
153
|
+
row.style.display = !q || (row.dataset.search ?? '').includes(q) ? '' : 'none'
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (msg.state === 'candidates') {
|
|
161
|
+
const items = msg.candidates
|
|
162
|
+
.map(
|
|
163
|
+
(c, i) =>
|
|
164
|
+
`<li><button class="ghost pick" type="button" data-id="${esc(c.id)}"><span>${i + 1}. ${esc(
|
|
165
|
+
c.name,
|
|
166
|
+
)}</span><span class="tag ${c.mapped ? 'ok' : 'miss'}">${c.mapped ? '已映射' : '未映射'}</span></button></li>`,
|
|
167
|
+
)
|
|
168
|
+
.join('')
|
|
169
|
+
body.innerHTML = `<section><p class="hint">${esc(msg.message)}</p><ul class="candidates">${items}</ul></section>`
|
|
170
|
+
body.querySelectorAll<HTMLButtonElement>('.pick').forEach((btn) => {
|
|
171
|
+
btn.addEventListener('click', () => send('pick', { id: btn.dataset.id }))
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (msg.state !== 'ok') {
|
|
177
|
+
const extra =
|
|
178
|
+
msg.schema && msg.schema.properties.length > 0
|
|
179
|
+
? `<section><h2>读到的属性 schema</h2>${schemaTable(msg.schema, [])}</section>`
|
|
180
|
+
: ''
|
|
181
|
+
// 未映射可能是因为压根没有对应的代码组件——给一条能直接丢进 AI 工具的提示词,
|
|
182
|
+
// 先把组件生成出来(组件名跟 Figma 实例名保持一致,方便后面接 mini-code-connect 映射)
|
|
183
|
+
const genPrompt =
|
|
184
|
+
msg.state === 'unmapped' && msg.schema && msg.figmaUrl
|
|
185
|
+
? `<section>
|
|
186
|
+
<div class="row"><h2>生成通用组件</h2><button class="ghost copy" id="copy-prompt" type="button">复制</button></div>
|
|
187
|
+
<pre id="gen-prompt">/figma-design-to-code ${esc(msg.figmaUrl)},生成通用组件"${esc(
|
|
188
|
+
msg.schema.componentName,
|
|
189
|
+
)}",组件名和实例名保持一致</pre>
|
|
190
|
+
</section>`
|
|
191
|
+
: ''
|
|
192
|
+
body.innerHTML = `${backBar(msg.candidates)}<section><p class="hint">${esc(
|
|
193
|
+
msg.message,
|
|
194
|
+
)}</p></section>${genPrompt}${extra}`
|
|
195
|
+
wireBack()
|
|
196
|
+
const copyPrompt = document.getElementById('copy-prompt')
|
|
197
|
+
if (copyPrompt) {
|
|
198
|
+
copyPrompt.addEventListener('click', () => {
|
|
199
|
+
const pre = document.getElementById('gen-prompt')
|
|
200
|
+
if (!pre || !pre.textContent) return
|
|
201
|
+
void navigator.clipboard.writeText(pre.textContent).then(() => {
|
|
202
|
+
copyPrompt.textContent = '已复制'
|
|
203
|
+
setTimeout(() => (copyPrompt.textContent = '复制'), 1200)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { schema, template, snippet, imports, findings, calls } = msg
|
|
211
|
+
|
|
212
|
+
const bindingCard = `
|
|
213
|
+
<div class="card">
|
|
214
|
+
<div class="kv"><span>Figma 组件</span><span>${esc(schema.componentName)}</span></div>
|
|
215
|
+
<div class="kv"><span>source</span><span><code>${esc(template.meta.source)}</code></span></div>
|
|
216
|
+
<div class="kv"><span>component</span><span><code>${esc(template.meta.component)}</code></span></div>
|
|
217
|
+
<div class="kv"><span>template id</span><span><code>${esc(template.id)}</code></span></div>
|
|
218
|
+
</div>`
|
|
219
|
+
|
|
220
|
+
const findingList =
|
|
221
|
+
findings.length === 0
|
|
222
|
+
? '<p class="hint">没有发现问题。</p>'
|
|
223
|
+
: `<ul class="findings">${findings
|
|
224
|
+
.map((f) => `<li class="${f.level}">${esc(f.text)}</li>`)
|
|
225
|
+
.join('')}</ul>`
|
|
226
|
+
|
|
227
|
+
const importLines = imports.length ? esc(imports.join('\n')) + '\n\n' : ''
|
|
228
|
+
|
|
229
|
+
body.innerHTML = `
|
|
230
|
+
${backBar(msg.candidates)}
|
|
231
|
+
<section><h2>绑定</h2>${bindingCard}</section>
|
|
232
|
+
<section>
|
|
233
|
+
<div class="row"><h2>生成的代码片段</h2><button class="ghost copy" id="copy" type="button">复制</button></div>
|
|
234
|
+
<pre id="snippet">${importLines}${esc(snippet)}</pre>
|
|
235
|
+
</section>
|
|
236
|
+
<section><h2>属性 schema 与覆盖率</h2>${schemaTable(schema, calls)}</section>
|
|
237
|
+
<section><h2>自检(Step 6)</h2>${findingList}</section>
|
|
238
|
+
<section><h2>本次 accessor 调用</h2><pre>${esc(
|
|
239
|
+
calls
|
|
240
|
+
.map((c) => `${' '.repeat(c.depth)}${c.method}("${c.prop}")${c.ok ? '' : ` ⚠ ${c.note ?? ''}`}`)
|
|
241
|
+
.join('\n') || '(无)',
|
|
242
|
+
)}</pre></section>`
|
|
243
|
+
|
|
244
|
+
const copy = document.getElementById('copy')
|
|
245
|
+
if (copy) {
|
|
246
|
+
copy.addEventListener('click', () => {
|
|
247
|
+
const pre = document.getElementById('snippet')
|
|
248
|
+
if (!pre || !pre.textContent) return
|
|
249
|
+
void navigator.clipboard.writeText(pre.textContent).then(() => {
|
|
250
|
+
copy.textContent = '已复制'
|
|
251
|
+
setTimeout(() => (copy.textContent = '复制'), 1200)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
wireBack()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
;(document.getElementById('refresh') as HTMLButtonElement).addEventListener('click', () => send('refresh'))
|
|
259
|
+
;(document.getElementById('scan') as HTMLButtonElement).addEventListener('click', () => send('scan'))
|
|
260
|
+
exportSchemaBtn.addEventListener('click', () => send(inCandidatesMode ? 'exportAllSchemas' : 'exportSchema'))
|
|
261
|
+
;(document.getElementById('export-mapping-table') as HTMLButtonElement).addEventListener('click', () =>
|
|
262
|
+
send('exportMappingTable'),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
onmessage = (e: MessageEvent) => {
|
|
266
|
+
const msg = e.data && e.data.pluginMessage
|
|
267
|
+
if (msg) render(msg as Msg)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// UI 就绪后主动拉一次。主线程不做首次 post —— 那会早于这行代码执行,首帧会丢
|
|
271
|
+
send('refresh')
|