@nnao45/figma-use 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/CHANGELOG.md +991 -0
- package/LICENSE +22 -0
- package/README.md +569 -0
- package/SKILL.md +683 -0
- package/bin/figma-use.js +9 -0
- package/dist/cli/index.js +496 -0
- package/package.json +87 -0
- package/packages/cli/src/render/component-set.tsx +157 -0
- package/packages/cli/src/render/components.tsx +115 -0
- package/packages/cli/src/render/icon.ts +166 -0
- package/packages/cli/src/render/index.ts +47 -0
- package/packages/cli/src/render/jsx-dev-runtime.ts +6 -0
- package/packages/cli/src/render/jsx-runtime.ts +90 -0
- package/packages/cli/src/render/mini-react.ts +33 -0
- package/packages/cli/src/render/render-from-string.ts +121 -0
- package/packages/cli/src/render/render-jsx.ts +44 -0
- package/packages/cli/src/render/tree.ts +148 -0
- package/packages/cli/src/render/vars.ts +186 -0
- package/packages/cli/src/render/widget-renderer.ts +163 -0
- package/packages/plugin/src/main.ts +2747 -0
- package/packages/plugin/src/query.ts +253 -0
- package/packages/plugin/src/rpc.ts +5238 -0
- package/packages/plugin/src/ui.html +25 -0
- package/packages/plugin/src/ui.ts +74 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as esbuild from 'esbuild'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
import { sendCommand } from '../client.ts'
|
|
5
|
+
import { loadVariablesIntoRegistry, isRegistryLoaded, preloadIcons, collectIcons } from './index.ts'
|
|
6
|
+
import { renderWithWidgetApi } from './widget-renderer.ts'
|
|
7
|
+
|
|
8
|
+
import type { NodeRef } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
const MOCK_RENDER_MODULE = `
|
|
11
|
+
export const Frame = 'frame'
|
|
12
|
+
export const Text = 'text'
|
|
13
|
+
export const Rectangle = 'rectangle'
|
|
14
|
+
export const Ellipse = 'ellipse'
|
|
15
|
+
export const Line = 'line'
|
|
16
|
+
export const Image = 'image'
|
|
17
|
+
export const SVG = 'svg'
|
|
18
|
+
export const Icon = (props) => ({ __icon: true, name: props.name, size: props.size, color: props.color })
|
|
19
|
+
export const View = 'frame'
|
|
20
|
+
export const Rect = 'rectangle'
|
|
21
|
+
export const Section = 'section'
|
|
22
|
+
export const Group = 'group'
|
|
23
|
+
export const defineComponent = (name, el) => () => el
|
|
24
|
+
export const defineVars = (vars) => Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.value]))
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
export async function buildComponent(input: string): Promise<Function> {
|
|
28
|
+
let code = input.trim()
|
|
29
|
+
|
|
30
|
+
// Pure JSX snippet (no import/export) - wrap it
|
|
31
|
+
if (!code.includes('import ') && !code.includes('export ')) {
|
|
32
|
+
code = `import { Frame, Text, Rectangle, Ellipse, Line, Image, SVG, Icon } from 'figma-use/render'
|
|
33
|
+
export default () => ${code}`
|
|
34
|
+
}
|
|
35
|
+
// Has imports but no export
|
|
36
|
+
else if (!code.includes('export ')) {
|
|
37
|
+
code = `${code}\nexport default () => null`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = await esbuild.build({
|
|
41
|
+
stdin: { contents: code, loader: 'tsx' },
|
|
42
|
+
bundle: true,
|
|
43
|
+
write: false,
|
|
44
|
+
format: 'iife',
|
|
45
|
+
globalName: '__mod',
|
|
46
|
+
jsx: 'transform',
|
|
47
|
+
jsxFactory: 'React.createElement',
|
|
48
|
+
plugins: [
|
|
49
|
+
{
|
|
50
|
+
name: 'mock-imports',
|
|
51
|
+
setup(build) {
|
|
52
|
+
build.onResolve({ filter: /^figma-use\/render$|^\./ }, (args) => ({
|
|
53
|
+
path: args.path,
|
|
54
|
+
namespace: 'mock'
|
|
55
|
+
}))
|
|
56
|
+
build.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({
|
|
57
|
+
contents: MOCK_RENDER_MODULE,
|
|
58
|
+
loader: 'js'
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const bundled = result.outputFiles![0]!.text
|
|
66
|
+
const fn = new Function('React', `${bundled}; return __mod.default`)
|
|
67
|
+
return fn(React)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readStdin(): Promise<string> {
|
|
71
|
+
const chunks: Buffer[] = []
|
|
72
|
+
for await (const chunk of process.stdin) {
|
|
73
|
+
chunks.push(chunk)
|
|
74
|
+
}
|
|
75
|
+
return Buffer.concat(chunks).toString('utf-8')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RenderOptions {
|
|
79
|
+
x?: number
|
|
80
|
+
y?: number
|
|
81
|
+
parent?: string
|
|
82
|
+
props?: Record<string, unknown>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RenderResult {
|
|
86
|
+
id: string
|
|
87
|
+
name: string
|
|
88
|
+
type: string
|
|
89
|
+
x: number
|
|
90
|
+
y: number
|
|
91
|
+
width: number
|
|
92
|
+
height: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function renderFromString(
|
|
96
|
+
jsx: string,
|
|
97
|
+
options: RenderOptions = {}
|
|
98
|
+
): Promise<RenderResult> {
|
|
99
|
+
const Component = await buildComponent(jsx)
|
|
100
|
+
|
|
101
|
+
if (!isRegistryLoaded()) {
|
|
102
|
+
try {
|
|
103
|
+
const vars = await sendCommand<NodeRef[]>('get-variables', { simple: true })
|
|
104
|
+
loadVariablesIntoRegistry(vars)
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const props = options.props ?? {}
|
|
109
|
+
const element = React.createElement(Component as React.FC, props)
|
|
110
|
+
|
|
111
|
+
const icons = collectIcons(element)
|
|
112
|
+
if (icons.length > 0) {
|
|
113
|
+
await preloadIcons(icons)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (await renderWithWidgetApi(element as unknown, {
|
|
117
|
+
parent: options.parent,
|
|
118
|
+
x: options.x,
|
|
119
|
+
y: options.y
|
|
120
|
+
})) as RenderResult
|
|
121
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { transformSync } from 'esbuild'
|
|
2
|
+
|
|
3
|
+
import { sendCommand } from '../client.ts'
|
|
4
|
+
import { loadVariablesIntoRegistry, isRegistryLoaded, preloadIcons, collectIcons } from './index.ts'
|
|
5
|
+
import * as React from './mini-react.ts'
|
|
6
|
+
import { renderWithWidgetApi } from './widget-renderer.ts'
|
|
7
|
+
|
|
8
|
+
import type { NodeRef } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
function buildComponent(jsx: string): React.FC {
|
|
11
|
+
const code = `
|
|
12
|
+
const h = React.createElement
|
|
13
|
+
const Frame = 'frame', Text = 'text', Rectangle = 'rectangle', Ellipse = 'ellipse', Line = 'line', Image = 'image', SVG = 'svg'
|
|
14
|
+
return function Component() { return ${jsx.trim()} }
|
|
15
|
+
`
|
|
16
|
+
const result = transformSync(code, {
|
|
17
|
+
loader: 'tsx',
|
|
18
|
+
jsx: 'transform',
|
|
19
|
+
jsxFactory: 'h'
|
|
20
|
+
})
|
|
21
|
+
return new Function('React', result.code)(React) as React.FC
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function renderJsx(
|
|
25
|
+
jsx: string,
|
|
26
|
+
options?: { x?: number; y?: number; parent?: string }
|
|
27
|
+
): Promise<NodeRef[]> {
|
|
28
|
+
const Component = buildComponent(jsx)
|
|
29
|
+
|
|
30
|
+
if (!isRegistryLoaded()) {
|
|
31
|
+
try {
|
|
32
|
+
const vars = await sendCommand<NodeRef[]>('get-variables', { simple: true })
|
|
33
|
+
loadVariablesIntoRegistry(vars)
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const element = React.createElement(Component, null)
|
|
38
|
+
|
|
39
|
+
const icons = collectIcons(element)
|
|
40
|
+
if (icons.length > 0) await preloadIcons(icons)
|
|
41
|
+
|
|
42
|
+
const result = await renderWithWidgetApi(element, options)
|
|
43
|
+
return [result]
|
|
44
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma Tree Node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type Props = Record<string, unknown>
|
|
6
|
+
|
|
7
|
+
export interface TreeNode {
|
|
8
|
+
type: string
|
|
9
|
+
props: Props
|
|
10
|
+
children: (TreeNode | string)[]
|
|
11
|
+
key?: string | number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReactElement {
|
|
15
|
+
type: unknown
|
|
16
|
+
props: Props
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isTreeNode(x: unknown): x is TreeNode {
|
|
20
|
+
if (x === null || typeof x !== 'object') return false
|
|
21
|
+
const obj = x as Props
|
|
22
|
+
return typeof obj.type === 'string' && 'props' in obj && Array.isArray(obj.children)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isReactElement(x: unknown): x is ReactElement {
|
|
26
|
+
return x !== null && typeof x === 'object' && 'type' in x && 'props' in x
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveReactElement(el: ReactElement): TreeNode | null {
|
|
30
|
+
if (typeof el.type === 'function') {
|
|
31
|
+
const result = (el.type as (p: Props) => unknown)(el.props)
|
|
32
|
+
if (isTreeNode(result)) return result
|
|
33
|
+
if (isReactElement(result)) return resolveReactElement(result)
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processChild(child: unknown): TreeNode | string | null {
|
|
39
|
+
if (child == null) return null
|
|
40
|
+
if (typeof child === 'string' || typeof child === 'number') return String(child)
|
|
41
|
+
if (isTreeNode(child)) return child
|
|
42
|
+
if (isReactElement(child)) return resolveReactElement(child)
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function node(type: string, props: BaseProps): TreeNode {
|
|
47
|
+
const { children, ...rest } = props
|
|
48
|
+
const processed = [children]
|
|
49
|
+
.flat(Infinity)
|
|
50
|
+
.map(processChild)
|
|
51
|
+
.filter((c): c is TreeNode | string => c !== null)
|
|
52
|
+
return { type, props: rest, children: processed }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Style props
|
|
56
|
+
export interface StyleProps {
|
|
57
|
+
// Layout
|
|
58
|
+
flex?: 'row' | 'col' | 'column'
|
|
59
|
+
gap?: number
|
|
60
|
+
wrap?: boolean
|
|
61
|
+
rowGap?: number
|
|
62
|
+
justify?: 'start' | 'end' | 'center' | 'between'
|
|
63
|
+
items?: 'start' | 'end' | 'center'
|
|
64
|
+
position?: 'absolute' | 'relative'
|
|
65
|
+
grow?: number
|
|
66
|
+
stretch?: boolean
|
|
67
|
+
|
|
68
|
+
// Size
|
|
69
|
+
w?: number | 'fill'
|
|
70
|
+
h?: number | 'fill'
|
|
71
|
+
width?: number | 'fill'
|
|
72
|
+
height?: number | 'fill'
|
|
73
|
+
minW?: number
|
|
74
|
+
maxW?: number
|
|
75
|
+
minH?: number
|
|
76
|
+
maxH?: number
|
|
77
|
+
|
|
78
|
+
// Position
|
|
79
|
+
x?: number
|
|
80
|
+
y?: number
|
|
81
|
+
|
|
82
|
+
// Padding
|
|
83
|
+
p?: number
|
|
84
|
+
px?: number
|
|
85
|
+
py?: number
|
|
86
|
+
pt?: number
|
|
87
|
+
pr?: number
|
|
88
|
+
pb?: number
|
|
89
|
+
pl?: number
|
|
90
|
+
padding?: number
|
|
91
|
+
|
|
92
|
+
// Appearance
|
|
93
|
+
bg?: string
|
|
94
|
+
fill?: string
|
|
95
|
+
stroke?: string
|
|
96
|
+
strokeWidth?: number
|
|
97
|
+
strokeAlign?: 'inside' | 'outside' | 'center'
|
|
98
|
+
strokeTop?: number
|
|
99
|
+
strokeBottom?: number
|
|
100
|
+
strokeLeft?: number
|
|
101
|
+
strokeRight?: number
|
|
102
|
+
rounded?: number
|
|
103
|
+
cornerRadius?: number
|
|
104
|
+
roundedTL?: number
|
|
105
|
+
roundedTR?: number
|
|
106
|
+
roundedBL?: number
|
|
107
|
+
roundedBR?: number
|
|
108
|
+
cornerSmoothing?: number
|
|
109
|
+
opacity?: number
|
|
110
|
+
blendMode?: string
|
|
111
|
+
rotate?: number
|
|
112
|
+
overflow?: 'hidden' | 'visible'
|
|
113
|
+
shadow?: string
|
|
114
|
+
blur?: number
|
|
115
|
+
|
|
116
|
+
// Text
|
|
117
|
+
size?: number
|
|
118
|
+
fontSize?: number
|
|
119
|
+
font?: string
|
|
120
|
+
fontFamily?: string
|
|
121
|
+
weight?: number | 'bold' | 'medium' | 'normal'
|
|
122
|
+
fontWeight?: number | 'bold' | 'medium' | 'normal'
|
|
123
|
+
color?: string
|
|
124
|
+
|
|
125
|
+
// Other
|
|
126
|
+
src?: string
|
|
127
|
+
href?: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Custom child type that allows TreeNode in JSX
|
|
131
|
+
type FigmaChild =
|
|
132
|
+
| TreeNode
|
|
133
|
+
| string
|
|
134
|
+
| number
|
|
135
|
+
| null
|
|
136
|
+
| undefined
|
|
137
|
+
| boolean
|
|
138
|
+
| (TreeNode | string | number | null | undefined | boolean)[]
|
|
139
|
+
|
|
140
|
+
export interface BaseProps extends StyleProps {
|
|
141
|
+
name?: string
|
|
142
|
+
key?: string | number
|
|
143
|
+
children?: FigmaChild
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface TextProps extends BaseProps {
|
|
147
|
+
children?: FigmaChild
|
|
148
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma Variable Bindings (StyleX-inspired API)
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* // tokens.figma.ts - with explicit values (recommended)
|
|
7
|
+
* export const colors = defineVars({
|
|
8
|
+
* primary: { name: 'Colors/Gray/50', value: '#F8FAFC' },
|
|
9
|
+
* accent: { name: 'Colors/Blue/500', value: '#3B82F6' },
|
|
10
|
+
* })
|
|
11
|
+
*
|
|
12
|
+
* // tokens.figma.ts - name only (value loaded from Figma)
|
|
13
|
+
* export const colors = defineVars({
|
|
14
|
+
* primary: 'Colors/Gray/50',
|
|
15
|
+
* accent: 'Colors/Blue/500',
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* // Card.figma.tsx
|
|
19
|
+
* <Frame style={{ backgroundColor: colors.primary }}>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const VAR_SYMBOL = Symbol.for('figma.variable')
|
|
24
|
+
|
|
25
|
+
/** Variable definition - either string name or object with name and value */
|
|
26
|
+
export type VarDef = string | { name: string; value: string }
|
|
27
|
+
|
|
28
|
+
export interface FigmaVariable {
|
|
29
|
+
[VAR_SYMBOL]: true
|
|
30
|
+
name: string // Variable name like "Colors/Gray/50"
|
|
31
|
+
value?: string // Fallback color value like "#F8FAFC"
|
|
32
|
+
_resolved?: {
|
|
33
|
+
// Filled in at render time
|
|
34
|
+
id: string
|
|
35
|
+
sessionID: number
|
|
36
|
+
localID: number
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedVariable {
|
|
41
|
+
id: string
|
|
42
|
+
sessionID: number
|
|
43
|
+
localID: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if value is a Figma variable reference
|
|
48
|
+
*/
|
|
49
|
+
export function isVariable(value: unknown): value is FigmaVariable {
|
|
50
|
+
return typeof value === 'object' && value !== null && VAR_SYMBOL in value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Variable registry - maps names to IDs
|
|
55
|
+
* Populated by loadVariables() before render
|
|
56
|
+
*/
|
|
57
|
+
const variableRegistry = new Map<string, ResolvedVariable>()
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load variables from Figma into registry
|
|
61
|
+
*/
|
|
62
|
+
import type { NodeRef } from '../types.ts'
|
|
63
|
+
|
|
64
|
+
export function loadVariablesIntoRegistry(variables: NodeRef[]) {
|
|
65
|
+
variableRegistry.clear()
|
|
66
|
+
for (const v of variables) {
|
|
67
|
+
const match = v.id.match(/VariableID:(\d+):(\d+)/)
|
|
68
|
+
if (match) {
|
|
69
|
+
variableRegistry.set(v.name, {
|
|
70
|
+
id: v.id,
|
|
71
|
+
sessionID: parseInt(match[1]!, 10),
|
|
72
|
+
localID: parseInt(match[2]!, 10)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a variable name to its ID
|
|
80
|
+
* @throws if variable not found in registry
|
|
81
|
+
*/
|
|
82
|
+
export function resolveVariable(variable: FigmaVariable): ResolvedVariable {
|
|
83
|
+
// Already resolved?
|
|
84
|
+
if (variable._resolved) {
|
|
85
|
+
return variable._resolved
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check if it's an ID format (legacy support)
|
|
89
|
+
const idMatch = variable.name.match(/^(?:VariableID:)?(\d+):(\d+)$/)
|
|
90
|
+
if (idMatch) {
|
|
91
|
+
const resolved = {
|
|
92
|
+
id: `VariableID:${idMatch[1]}:${idMatch[2]}`,
|
|
93
|
+
sessionID: parseInt(idMatch[1]!, 10),
|
|
94
|
+
localID: parseInt(idMatch[2]!, 10)
|
|
95
|
+
}
|
|
96
|
+
variable._resolved = resolved
|
|
97
|
+
return resolved
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Lookup by name
|
|
101
|
+
const resolved = variableRegistry.get(variable.name)
|
|
102
|
+
if (!resolved) {
|
|
103
|
+
const available = Array.from(variableRegistry.keys()).slice(0, 5).join(', ')
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Variable "${variable.name}" not found. ` +
|
|
106
|
+
`Available: ${available}${variableRegistry.size > 5 ? '...' : ''}. ` +
|
|
107
|
+
`Make sure variables are loaded before render.`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
variable._resolved = resolved
|
|
112
|
+
return resolved
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if variable registry is populated
|
|
117
|
+
*/
|
|
118
|
+
export function isRegistryLoaded(): boolean {
|
|
119
|
+
return variableRegistry.size > 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get registry size (for debugging)
|
|
124
|
+
*/
|
|
125
|
+
export function getRegistrySize(): number {
|
|
126
|
+
return variableRegistry.size
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Define Figma variables for use in styles
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // With explicit fallback values (recommended)
|
|
135
|
+
* export const colors = defineVars({
|
|
136
|
+
* primary: { name: 'Colors/Gray/50', value: '#F8FAFC' },
|
|
137
|
+
* accent: { name: 'Colors/Blue/500', value: '#3B82F6' },
|
|
138
|
+
* })
|
|
139
|
+
*
|
|
140
|
+
* // Name only (value loaded from Figma registry)
|
|
141
|
+
* export const colors = defineVars({
|
|
142
|
+
* primary: 'Colors/Gray/50',
|
|
143
|
+
* })
|
|
144
|
+
*
|
|
145
|
+
* // Use in components:
|
|
146
|
+
* <Frame style={{ backgroundColor: colors.primary }} />
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export function defineVars<T extends Record<string, VarDef>>(
|
|
150
|
+
vars: T
|
|
151
|
+
): { [K in keyof T]: FigmaVariable } {
|
|
152
|
+
const result = {} as { [K in keyof T]: FigmaVariable }
|
|
153
|
+
|
|
154
|
+
for (const [key, def] of Object.entries(vars)) {
|
|
155
|
+
if (typeof def === 'string') {
|
|
156
|
+
result[key as keyof T] = {
|
|
157
|
+
[VAR_SYMBOL]: true,
|
|
158
|
+
name: def
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
result[key as keyof T] = {
|
|
162
|
+
[VAR_SYMBOL]: true,
|
|
163
|
+
name: def.name,
|
|
164
|
+
value: def.value
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Shorthand for single variable
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* const primaryColor = figmaVar('Colors/Gray/50', '#F8FAFC')
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
export function figmaVar(name: string, value?: string): FigmaVariable {
|
|
181
|
+
return {
|
|
182
|
+
[VAR_SYMBOL]: true,
|
|
183
|
+
name,
|
|
184
|
+
value
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSX → Figma Widget API renderer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { sendCommand } from '../client.ts'
|
|
6
|
+
import { loadIconSvg } from './icon.ts'
|
|
7
|
+
import { isTreeNode, type TreeNode, type ReactElement, type Props } from './tree.ts'
|
|
8
|
+
|
|
9
|
+
import type { NodeRef } from '../types.ts'
|
|
10
|
+
|
|
11
|
+
interface IconNode {
|
|
12
|
+
__icon: true
|
|
13
|
+
name: string
|
|
14
|
+
size?: number
|
|
15
|
+
color?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isReactElement(x: unknown): x is ReactElement {
|
|
19
|
+
return x !== null && typeof x === 'object' && 'type' in x && 'props' in x
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function convertReactElementToTree(el: ReactElement): TreeNode {
|
|
23
|
+
const children: (TreeNode | string)[] = []
|
|
24
|
+
const elChildren = el.props.children
|
|
25
|
+
|
|
26
|
+
if (elChildren != null) {
|
|
27
|
+
const childArray = Array.isArray(elChildren) ? elChildren : [elChildren]
|
|
28
|
+
for (const child of childArray.flat()) {
|
|
29
|
+
if (child == null) continue
|
|
30
|
+
if (typeof child === 'string' || typeof child === 'number') {
|
|
31
|
+
children.push(String(child))
|
|
32
|
+
} else if (isReactElement(child)) {
|
|
33
|
+
const resolved = resolveElement(child)
|
|
34
|
+
if (resolved) children.push(resolved)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { children: _, ...props } = el.props
|
|
40
|
+
return { type: el.type as string, props, children }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isIconNode(x: unknown): x is IconNode {
|
|
44
|
+
return x !== null && typeof x === 'object' && '__icon' in x && (x as IconNode).__icon === true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveElement(element: unknown, depth = 0): TreeNode | null {
|
|
48
|
+
if (depth > 100) throw new Error('Component resolution depth exceeded')
|
|
49
|
+
if (isTreeNode(element)) return element
|
|
50
|
+
|
|
51
|
+
// Icon nodes are handled separately in processIcons
|
|
52
|
+
if (isIconNode(element)) {
|
|
53
|
+
return {
|
|
54
|
+
type: '__icon__',
|
|
55
|
+
props: { name: element.name, size: element.size, color: element.color },
|
|
56
|
+
children: []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isReactElement(element)) {
|
|
61
|
+
if (typeof element.type === 'function') {
|
|
62
|
+
return resolveElement((element.type as (p: Props) => unknown)(element.props), depth + 1)
|
|
63
|
+
}
|
|
64
|
+
if (typeof element.type === 'string') {
|
|
65
|
+
return convertReactElementToTree(element)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function svgChildToString(child: TreeNode): string {
|
|
73
|
+
const { type, props } = child
|
|
74
|
+
const attrs = Object.entries(props)
|
|
75
|
+
.map(([k, v]) => {
|
|
76
|
+
// Convert camelCase to kebab-case
|
|
77
|
+
const kebab = k.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`)
|
|
78
|
+
return `${kebab}="${v}"`
|
|
79
|
+
})
|
|
80
|
+
.join(' ')
|
|
81
|
+
return `<${type}${attrs ? ' ' + attrs : ''}/>`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function serializeInlineSvg(tree: TreeNode): string {
|
|
85
|
+
const { props, children } = tree
|
|
86
|
+
const { width, height, viewBox, fill, w, h } = props as Record<string, unknown>
|
|
87
|
+
const attrs = [
|
|
88
|
+
'xmlns="http://www.w3.org/2000/svg"',
|
|
89
|
+
width || w ? `width="${width || w}"` : '',
|
|
90
|
+
height || h ? `height="${height || h}"` : '',
|
|
91
|
+
viewBox ? `viewBox="${viewBox}"` : '',
|
|
92
|
+
fill ? `fill="${fill}"` : ''
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(' ')
|
|
96
|
+
const inner = children
|
|
97
|
+
.filter((c): c is TreeNode => typeof c !== 'string')
|
|
98
|
+
.map(svgChildToString)
|
|
99
|
+
.join('')
|
|
100
|
+
return `<svg ${attrs}>${inner}</svg>`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function processIcons(tree: TreeNode): Promise<TreeNode> {
|
|
104
|
+
if (tree.type === '__icon__') {
|
|
105
|
+
const { name, size = 24, color } = tree.props as { name: string; size?: number; color?: string }
|
|
106
|
+
const iconData = await loadIconSvg(name, size)
|
|
107
|
+
if (!iconData) {
|
|
108
|
+
throw new Error(`Icon not found: ${name}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let svg = iconData.svg
|
|
112
|
+
if (color) {
|
|
113
|
+
svg = svg.replace(/currentColor/g, color)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
type: 'svg',
|
|
118
|
+
props: { src: svg, w: size, h: size, name },
|
|
119
|
+
children: []
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle inline <svg> with children (path, rect, etc.)
|
|
124
|
+
if (tree.type === 'svg' && tree.children.length > 0 && !tree.props.src) {
|
|
125
|
+
const svgString = serializeInlineSvg(tree)
|
|
126
|
+
return {
|
|
127
|
+
type: 'svg',
|
|
128
|
+
props: { ...tree.props, __svgString: svgString },
|
|
129
|
+
children: []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Process children recursively
|
|
134
|
+
const children = await Promise.all(
|
|
135
|
+
tree.children.map(async (child) => {
|
|
136
|
+
if (typeof child === 'string') return child
|
|
137
|
+
return processIcons(child)
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return { ...tree, children }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function renderWithWidgetApi(
|
|
145
|
+
element: unknown,
|
|
146
|
+
options?: { x?: number; y?: number; parent?: string }
|
|
147
|
+
): Promise<NodeRef> {
|
|
148
|
+
let tree = typeof element === 'function' ? element() : resolveElement(element)
|
|
149
|
+
|
|
150
|
+
if (!tree) {
|
|
151
|
+
throw new Error('Root must be a Figma component (Frame, Text, etc)')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Process icons (load SVG data)
|
|
155
|
+
tree = await processIcons(tree)
|
|
156
|
+
|
|
157
|
+
return sendCommand('create-from-jsx', {
|
|
158
|
+
tree,
|
|
159
|
+
x: options?.x,
|
|
160
|
+
y: options?.y,
|
|
161
|
+
parentId: options?.parent
|
|
162
|
+
})
|
|
163
|
+
}
|