@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.
@@ -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
+ }