feat: JSX reference, multi-root JSX, prompts as markdown files

- Move CODEGEN_PROMPT to codegen.md, loaded via raw-md bundler plugin
- Add JSX_REFERENCE as jsx-reference.md with full prop/tag/example docs
- Copy JSX Reference button (book icon) in Code panel header
- Multi-root JSX: try parsing as-is, wrap in fragment on failure
- Component and Instance tag aliases in JSX renderer
- renderJSX returns RenderResult[] to support fragments
- raw-md plugin for both tsdown and Vite

Co-authored-by: sld0Ant <sld0Ant@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-04-22 17:51:41 +03:00
parent d692953d62
commit 1d5558ddc3
12 changed files with 396 additions and 124 deletions

View file

@ -11,22 +11,32 @@ import type { SceneGraph } from '../scene-graph'
* Works in both Node/Bun and the browser (no native bindings).
*/
export function buildComponent(jsxString: string): () => unknown {
const code = `
const trimmed = jsxString.trim()
const aliases = `
const __h = React.createElement
const __frag = ''
const Frame = 'frame', Text = 'text', Rectangle = 'rectangle', Ellipse = 'ellipse'
const Line = 'line', Star = 'star', Polygon = 'polygon', Vector = 'vector'
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
const Component = 'component', Instance = 'frame'
const Icon = 'icon'
return function Component() { return ${jsxString.trim()} }
`
const result = transform(code, {
transforms: ['typescript', 'jsx'],
const opts = {
transforms: ['typescript', 'jsx'] as Array<'typescript' | 'jsx'>,
jsxPragma: '__h',
jsxFragmentPragma: '__frag',
production: true
})
}
return new Function('React', result.code)(React) as () => unknown
let code: string
try {
code = transform(`${aliases}\nreturn function __render() { return ${trimmed} }`, opts).code
} catch {
code = transform(`${aliases}\nreturn function __render() { return <>${trimmed}</> }`, opts).code
}
return new Function('React', code)(React) as () => unknown
}
interface RenderJSXOptions {
@ -43,7 +53,7 @@ export async function renderJSX(
graph: SceneGraph,
jsxString: string,
options?: RenderJSXOptions
): Promise<RenderResult> {
): Promise<RenderResult[]> {
const Component = buildComponent(jsxString)
const element = React.createElement(Component, null)
const tree = resolveToTree(element)
@ -52,7 +62,19 @@ export async function renderJSX(
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
}
return renderTree(graph, tree, options)
if (tree.type === '' && tree.children.length > 0) {
const results: RenderResult[] = []
for (const child of tree.children) {
if (typeof child === 'string') continue
results.push(await renderTree(graph, child, options))
}
if (results.length === 0) {
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
}
return results
}
return [await renderTree(graph, tree, options)]
}
/**

View file

@ -17,3 +17,8 @@ interface Uint8ArrayConstructor {
interface Uint8Array {
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
}
declare module '*.md' {
const content: string
export default content
}

View file

@ -395,7 +395,8 @@ export {
export * from './io'
export * from './lint'
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
export { default as CODEGEN_PROMPT } from './tools/prompts/codegen.md'
export { default as JSX_REFERENCE } from './tools/prompts/jsx-reference.md'
export {
setPexelsApiKey,
setUnsplashAccessKey,

View file

@ -85,11 +85,12 @@ export const render = defineTool({
}
}
const result = await renderJSX(figma.graph, args.jsx, {
const results = await renderJSX(figma.graph, args.jsx, {
parentId,
x: args.x,
y: args.y
})
const result = results[0]
if (args.replace_id && replaceIndex >= 0) {
figma.graph.reorderChild(result.id, parentId, replaceIndex)
@ -98,7 +99,15 @@ export const render = defineTool({
figma.graph.reorderChild(result.id, parentId, args.insert_index)
}
return { id: result.id, name: result.name, type: result.type, children: result.childIds }
return {
id: result.id,
name: result.name,
type: result.type,
children: result.childIds,
...(results.length > 1
? { siblings: results.slice(1).map((r) => ({ id: r.id, name: r.name, type: r.type })) }
: {})
}
}
})

View file

@ -1,8 +1,8 @@
export const CODEGEN_PROMPT = `# Design to Code
# Design to Code
You convert Figma designs into production frontend code. You have full access to the design document through tools. Never guess — always read the actual design data.
> Note: Auto-layout and visual property names match the JSX props in the render system. See \`describe\` tool output for semantic role and layout analysis of any node.
> Note: Auto-layout and visual property names match the JSX props in the render system. See `describe` tool output for semantic role and layout analysis of any node.
## Workflow
@ -10,7 +10,7 @@ You convert Figma designs into production frontend code. You have full access to
Understand the full picture before writing any code.
\`\`\`
```
get_page_tree → document structure, all top-level frames
get_components → reusable components defined by the designer
list_variables → design tokens (colors, numbers, strings, booleans)
@ -18,9 +18,10 @@ list_collections → variable collections and modes (light/dark, density,
analyze_colors → color palette, frequencies, which colors use variables
analyze_typography → font stacks, sizes, weights in use
analyze_spacing → gap and padding values, grid compliance
\`\`\`
```
After this step you should know:
- How many screens/pages the design has
- What components exist
- What the token system looks like (or if there is none)
@ -31,110 +32,114 @@ After this step you should know:
Identify the component architecture.
\`\`\`
```
analyze_clusters → find repeated visual patterns that should be components
describe (per node) → semantic role, layout direction, visual properties, issues
get_jsx (per node) → structural JSX to understand nesting and layout
\`\`\`
```
Build a component map:
- **Screens** — top-level frames that represent pages/views
- **Components** — COMPONENT/COMPONENT_SET nodes or repeated patterns from analyze_clusters
- **Primitives** — leaf elements (text, icons, dividers) that don't need their own component file
For each component determine:
- **Props** — what content varies between instances (text, color, icon, visibility)
- **Variants** — if the component has multiple states (default/hover/active, small/medium/large)
- **Slots** — where child content is injected
### Step 3 — Extract tokens
\`\`\`
```
list_variables → all variables with values per mode
list_collections → collection structure and mode names
\`\`\`
```
Map design variables to code tokens. The approach depends on the target stack:
#### Tailwind projects
Do NOT create CSS custom properties for font sizes, font weights, spacing, or border radius — Tailwind has its own system for these. Use Tailwind utility classes directly:
- Font sizes → \`text-[13px]\`, \`text-sm\`, \`text-base\`, etc.
- Font weights → \`font-bold\`, \`font-medium\`, \`font-[600]\`
- Spacing → \`gap-3\`, \`p-4\`, \`px-5\`, \`py-[14px]\`, or arbitrary \`gap-[12px]\`
- Border radius → \`rounded-xl\`, \`rounded-[14px]\`, \`rounded-full\`
Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like \`--font-bold\`, \`--text-sm\`, \`--radius-lg\`):
- Font sizes → `text-[13px]`, `text-sm`, `text-base`, etc.
- Font weights → `font-bold`, `font-medium`, `font-[600]`
- Spacing → `gap-3`, `p-4`, `px-5`, `py-[14px]`, or arbitrary `gap-[12px]`
- Border radius → `rounded-xl`, `rounded-[14px]`, `rounded-full`
\`\`\`css
Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like `--font-bold`, `--text-sm`, `--radius-lg`):
```css
:root {
--movie-bg: #0F0F1A;
--movie-surface: #1A1A2E;
--movie-accent: #7C3AED;
--movie-text: #FFFFFF;
--movie-text-dim: #FFFFFF80;
--movie-bg: #0f0f1a;
--movie-surface: #1a1a2e;
--movie-accent: #7c3aed;
--movie-text: #ffffff;
--movie-text-dim: #ffffff80;
}
\`\`\`
```
Reference in Tailwind classes: \`bg-[var(--movie-bg)]\`, \`text-[var(--movie-text)]\`
Reference in Tailwind classes: `bg-[var(--movie-bg)]`, `text-[var(--movie-text)]`
#### CSS Modules / plain CSS projects
Create CSS custom properties for all token categories (colors, spacing, typography, radius). Use a project-specific prefix to avoid collisions:
\`\`\`css
```css
:root {
--app-color-bg: #0F0F1A;
--app-color-bg: #0f0f1a;
--app-space-sm: 4px;
--app-text-sm: 12px;
--app-radius-md: 8px;
}
\`\`\`
```
#### No design variables in the file
If the design has no Figma variables, extract implicit tokens from \`analyze_colors\` and \`analyze_typography\` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else.
If the design has no Figma variables, extract implicit tokens from `analyze_colors` and `analyze_typography` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else.
#### Multi-mode collections (light/dark)
- Generate token values for each mode
- Use CSS custom properties with class-based switching (\`.dark { ... }\`)
- Use CSS custom properties with class-based switching (`.dark { ... }`)
### Step 4 — Generate code
For each component, bottom-up (primitives first, then composites, then screens):
\`\`\`
```
get_jsx id=<component_id> → read structure
describe id=<component_id> → understand semantic role
export_svg ids=[<icon_ids>] → extract vector assets
\`\`\`
```
**Rules:**
- One component per file
- Component name comes from the Figma node name, converted to PascalCase
- Props interface reflects the variable content identified in Step 2
- Use design tokens from Step 3 for semantic colors
- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in \`var()\` indirection
- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in `var()` indirection
- Match measurements exactly: font sizes, spacing, border radii, colors
- Use auto-layout data to determine flex direction, gap, padding, alignment
- Preserve text direction and container flow direction separately when the design uses RTL.
- Absolute positioning only when \`layoutPositioning\` is \`ABSOLUTE\` or layout mode is \`NONE\`
- If a node has \`clipsContent: true\`, use \`overflow: hidden\`
- Absolute positioning only when `layoutPositioning` is `ABSOLUTE` or layout mode is `NONE`
- If a node has `clipsContent: true`, use `overflow: hidden`
- Text nodes: preserve font family, size, weight, line height, letter spacing, alignment
- Images/illustrations: use \`export_svg\` for vectors, placeholder \`<img>\` for raster
- Images/illustrations: use `export_svg` for vectors, placeholder `<img>` for raster
**Interactive states:**
Figma designs rarely include hover/active/focus states unless the component has explicit variants for them. Always add sensible interactive feedback to clickable elements:
- **Buttons (primary):** \`hover:brightness-110 active:brightness-90 transition-all\`
- **Buttons (secondary/ghost):** \`hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors\`
- **Icon buttons:** \`hover:bg-white/[0.15] active:scale-95 transition-all\`
- **Cards/list items (if clickable):** \`hover:bg-white/[0.04] transition-colors\`
- **Links/text buttons:** \`hover:underline\` or \`hover:opacity-80\`
- **All interactive elements:** add \`cursor-pointer\` and \`select-none\`
- **Focus visible:** add \`focus-visible:ring-2 focus-visible:ring-offset-2\` with accent color for accessibility
- **Buttons (primary):** `hover:brightness-110 active:brightness-90 transition-all`
- **Buttons (secondary/ghost):** `hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors`
- **Icon buttons:** `hover:bg-white/[0.15] active:scale-95 transition-all`
- **Cards/list items (if clickable):** `hover:bg-white/[0.04] transition-colors`
- **Links/text buttons:** `hover:underline` or `hover:opacity-80`
- **All interactive elements:** add `cursor-pointer` and `select-none`
- **Focus visible:** add `focus-visible:ring-2 focus-visible:ring-offset-2` with accent color for accessibility
If the design HAS explicit hover/active variants (COMPONENT_SET with state property), use those exact styles instead of defaults above.
@ -142,12 +147,13 @@ If the design HAS explicit hover/active variants (COMPONENT_SET with state prope
After generating code, verify against the design:
\`\`\`
```
describe id=<root> → re-check structure matches
get_jsx id=<root> → compare JSX structure with generated component tree
\`\`\`
```
Check:
- All text content from the design appears in the code
- All colors reference tokens or use correct hex/opacity values
- Spacing values match the design
@ -160,18 +166,18 @@ List any deviations with rationale.
The user specifies the target stack. Adapt code generation accordingly:
**React + Tailwind** — functional components, TypeScript, utility classes, \`className\`
**React + CSS Modules** — functional components, TypeScript, \`.module.css\` files, \`styles.className\`
**Vue 3 + Tailwind** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, Tailwind utility classes
**Vue 3 + CSS** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, scoped \`<style>\`
**Svelte + Tailwind** — \`<script lang="ts">\`, \`$props()\`, Tailwind utility classes
**React + Tailwind** — functional components, TypeScript, utility classes, `className`
**React + CSS Modules** — functional components, TypeScript, `.module.css` files, `styles.className`
**Vue 3 + Tailwind** — `<script setup lang="ts">`, `defineProps`, `<template>`, Tailwind utility classes
**Vue 3 + CSS** — `<script setup lang="ts">`, `defineProps`, `<template>`, scoped `<style>`
**Svelte + Tailwind** — `<script lang="ts">`, `$props()`, Tailwind utility classes
**HTML + CSS** — semantic HTML, BEM or utility classes, CSS custom properties
If the user hasn't specified a stack, ask before generating code.
## Component file structure
\`\`\`
```
components/
Button.tsx (or .vue, .svelte)
Card.tsx
@ -183,53 +189,58 @@ pages/
assets/
icon-arrow.svg
icon-check.svg
\`\`\`
```
## Common patterns
**Auto-layout → Flexbox**
- \`layoutMode: HORIZONTAL\` → \`flex-direction: row\`
- \`layoutMode: VERTICAL\` → \`flex-direction: column\`
- \`itemSpacing\` → \`gap\`
- \`paddingTop/Right/Bottom/Left\` → \`padding\`
- \`primaryAxisAlign: CENTER\` → \`justify-content: center\`
- \`counterAxisAlign: CENTER\` → \`align-items: center\`
- \`layoutWrap: WRAP\` → \`flex-wrap: wrap\`
- \`primaryAxisSizing: HUG\` → no explicit size on primary axis (content-sized)
- \`primaryAxisSizing: FILL\` → \`flex: 1\` or \`width: 100%\` depending on context
- \`counterAxisSizing: FILL\` → \`align-self: stretch\` or explicit \`width/height: 100%\`
- `layoutMode: HORIZONTAL``flex-direction: row`
- `layoutMode: VERTICAL``flex-direction: column`
- `itemSpacing``gap`
- `paddingTop/Right/Bottom/Left``padding`
- `primaryAxisAlign: CENTER``justify-content: center`
- `counterAxisAlign: CENTER``align-items: center`
- `layoutWrap: WRAP``flex-wrap: wrap`
- `primaryAxisSizing: HUG` → no explicit size on primary axis (content-sized)
- `primaryAxisSizing: FILL``flex: 1` or `width: 100%` depending on context
- `counterAxisSizing: FILL``align-self: stretch` or explicit `width/height: 100%`
**Grid layout**
- \`layoutMode: GRID\` → \`display: grid\`
- \`gridTemplateColumns\` → \`grid-template-columns\`
- \`gridTemplateRows\` → \`grid-template-rows\`
- \`gridColumnGap/gridRowGap\` → \`column-gap/row-gap\`
- `layoutMode: GRID``display: grid`
- `gridTemplateColumns``grid-template-columns`
- `gridTemplateRows``grid-template-rows`
- `gridColumnGap/gridRowGap``column-gap/row-gap`
**Sizing**
- \`layoutGrow > 0\` → \`flex-grow: 1\`
- \`layoutAlignSelf: STRETCH\` → cross-axis fill
- Fixed width/height only when sizing mode is \`FIXED\`
- `layoutGrow > 0``flex-grow: 1`
- `layoutAlignSelf: STRETCH` → cross-axis fill
- Fixed width/height only when sizing mode is `FIXED`
**Corner radius**
- \`independentCorners: true\` → per-corner border-radius
- \`cornerRadius\` → uniform border-radius
- `independentCorners: true` → per-corner border-radius
- `cornerRadius` → uniform border-radius
**Effects**
- \`DROP_SHADOW\` → \`box-shadow\`
- \`INNER_SHADOW\` → \`box-shadow: inset ...\`
- \`LAYER_BLUR\` → \`filter: blur(...)\`
- \`BACKGROUND_BLUR\` → \`backdrop-filter: blur(...)\`
- `DROP_SHADOW``box-shadow`
- `INNER_SHADOW``box-shadow: inset ...`
- `LAYER_BLUR``filter: blur(...)`
- `BACKGROUND_BLUR``backdrop-filter: blur(...)`
**Text**
- \`fontFamily\` → \`font-family\`
- \`fontSize\` → \`font-size\`
- \`fontWeight\` → \`font-weight\`
- \`lineHeight\` → \`line-height\` (null = normal/auto)
- \`letterSpacing\` → \`letter-spacing\`
- \`textAlignHorizontal\` → \`text-align\`
- \`textAutoResize: WIDTH_AND_HEIGHT\` → no explicit dimensions
- \`textAutoResize: HEIGHT\` → fixed width, auto height
- \`textAutoResize: NONE\` → fixed width and height
- \`textDecoration\` → \`text-decoration\`
- \`textCase\` → \`text-transform\`
`
- `fontFamily``font-family`
- `fontSize``font-size`
- `fontWeight``font-weight`
- `lineHeight``line-height` (null = normal/auto)
- `letterSpacing``letter-spacing`
- `textAlignHorizontal``text-align`
- `textAutoResize: WIDTH_AND_HEIGHT` → no explicit dimensions
- `textAutoResize: HEIGHT` → fixed width, auto height
- `textAutoResize: NONE` → fixed width and height
- `textDecoration``text-decoration`
- `textCase` → `text-transform`

View file

@ -0,0 +1,175 @@
# OpenPencil JSX Reference
## Elements
| Tag | Description |
| --------- | -------------------------------------- |
| Frame | Container / auto-layout frame |
| Rectangle | Rectangle shape |
| Ellipse | Circle / ellipse shape |
| Text | Text node (children = text content) |
| Line | Line shape |
| Star | Star shape |
| Polygon | Polygon shape (default 3 sides) |
| Vector | Vector path |
| Group | Group container |
| Section | Section (like Frame, for organization) |
| Component | Component definition |
| Icon | Iconify icon (requires name prop) |
Aliases: View = Frame, Rect = Rectangle
## Layout Props
| Prop | Type | Description |
| -------------- | ----------------------------------------- | ------------------------------ |
| flex | "row" \| "col" | Enable auto-layout direction |
| gap | number | Spacing between children |
| wrap | boolean | Enable flex wrap |
| rowGap | number | Cross-axis gap when wrap is on |
| justify | "start" \| "end" \| "center" \| "between" | Main axis alignment |
| items | "start" \| "end" \| "center" \| "stretch" | Cross axis alignment |
| p | number | Padding (all sides) |
| px | number | Horizontal padding |
| py | number | Vertical padding |
| pt, pr, pb, pl | number | Individual side padding |
| grow | number | Flex grow factor |
## Sizing Props
| Prop | Type | Description |
| -------- | ------------------------- | --------------------------------------- |
| w | number \| "fill" \| "hug" | Width |
| h | number \| "fill" \| "hug" | Height |
| x | number | X position |
| y | number | Y position |
| position | "absolute" | Absolute positioning inside auto-layout |
| top | number | Top offset (sets absolute positioning) |
| left | number | Left offset (sets absolute positioning) |
| minW | number | Minimum width |
| maxW | number | Maximum width |
## Appearance Props
| Prop | Type | Description |
| --------------- | -------- | -------------------------------------- |
| bg | string | Background color (hex, e.g. "#FF0000") |
| stroke | string | Stroke color |
| strokeWidth | number | Stroke weight (default 1) |
| rounded | number | Corner radius (all corners) |
| roundedTL | number | Top-left corner radius |
| roundedTR | number | Top-right corner radius |
| roundedBL | number | Bottom-left corner radius |
| roundedBR | number | Bottom-right corner radius |
| cornerSmoothing | number | iOS-style corner smoothing |
| opacity | number | 01 opacity |
| rotate | number | Rotation in degrees |
| blendMode | string | Blend mode (e.g. "multiply") |
| overflow | "hidden" | Clip content |
## Text Props
| Prop | Type | Description |
| -------------- | -------------------------------------------- | -------------------------------------- |
| size | number | Font size (default 14) |
| font | string | Font family |
| weight | number \| "bold" \| "medium" | Font weight |
| color | string | Text color (hex) |
| textAlign | "left" \| "center" \| "right" \| "justified" | Text alignment |
| lineHeight | number | Line height in px |
| letterSpacing | number | Letter spacing in px |
| textDecoration | "underline" \| "strikethrough" | Text decoration |
| textCase | "upper" \| "lower" \| "title" | Text transform |
| maxLines | number | Max visible lines (enables truncation) |
| truncate | boolean | Enable text truncation |
## Shape Props
| Prop | Type | Description |
| ----------- | ------ | ----------------------------------------------- |
| points | number | Point count (Star default 5, Polygon default 3) |
| innerRadius | number | Star inner radius ratio |
## Effect Props
| Prop | Type | Description |
| ------ | ------ | ----------------------------------------- |
| shadow | string | Drop shadow: "offsetX offsetY blur color" |
| blur | number | Layer blur radius |
## Grid Layout
| Prop | Type | Description |
| --------- | ---------------- | ----------------------------------------------- |
| grid | boolean | Enable CSS Grid layout |
| columns | string \| number | Grid template columns (e.g. "1fr 1fr 1fr" or 3) |
| rows | string \| number | Grid template rows |
| columnGap | number | Column gap |
| rowGap | number | Row gap |
| colStart | number | Grid column start (child) |
| rowStart | number | Grid row start (child) |
| colSpan | number | Grid column span (child) |
| rowSpan | number | Grid row span (child) |
## Icon Props
| Prop | Type | Description |
| ----- | ------ | --------------------------------------- |
| name | string | Iconify icon name (e.g. "lucide:heart") |
| size | number | Icon size (default 24) |
| color | string | Icon color (hex) |
| label | string | Display name for the icon node |
## Examples
```jsx
{
/* Card with title and description */
}
;<Frame name="Card" w={320} flex="col" gap={16} p={24} bg="#FFFFFF" rounded={16}>
<Text size={18} weight="bold" color="#111">
Card Title
</Text>
<Text size={14} color="#6B7280">
Description text here
</Text>
</Frame>
{
/* Badge in corner of a card */
}
;<Frame name="Card" w={320} h={200} flex="col" p={24} bg="#FFFFFF" rounded={16}>
<Frame position="absolute" top={8} left={280} w={24} h={24} bg="#EF4444" rounded={12}>
<Text size={10} weight="bold" color="#FFF">
3
</Text>
</Frame>
<Text size={18} weight="bold" color="#111">
Card Title
</Text>
</Frame>
{
/* Horizontal button row */
}
;<Frame flex="row" gap={8} items="center">
<Rectangle w={40} h={40} bg="#3B82F6" rounded={8} />
<Text size={14} weight="medium" color="#000">
Click me
</Text>
</Frame>
{
/* Grid layout */
}
;<Frame grid columns="1fr 1fr 1fr" gap={16} p={16} w={400}>
<Rectangle w={100} h={100} bg="#EF4444" rounded={8} />
<Rectangle w={100} h={100} bg="#22C55E" rounded={8} />
<Rectangle w={100} h={100} bg="#3B82F6" rounded={8} />
</Frame>
{
/* Star shape */
}
;<Star w={48} h={48} points={5} innerRadius={0.38} bg="#EAB308" />
```

View file

@ -268,8 +268,14 @@ export const nodeReplaceWith = defineTool({
const y = node.y
node.remove()
const { renderJSX } = await import('../design-jsx/render.js')
const result = await renderJSX(figma.graph, args.jsx, { parentId, x, y })
return { id: result.id, name: result.name, type: result.type }
const results = await renderJSX(figma.graph, args.jsx, { parentId, x, y })
const result = results[0]
return {
id: result.id,
name: result.name,
type: result.type,
children: results.slice(1).map((r) => ({ id: r.id, name: r.name, type: r.type }))
}
}
})

View file

@ -1,7 +1,20 @@
import { defineConfig } from 'tsdown'
import type { Plugin } from 'rolldown'
function rawMd(): Plugin {
return {
name: 'raw-md',
transform(code, id) {
if (id.endsWith('.md')) {
return { code: `export default ${JSON.stringify(code)}`, map: null }
}
}
}
}
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
plugins: [rawMd()],
unbundle: true,
platform: 'neutral',
format: ['esm'],

View file

@ -5,7 +5,7 @@ import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewpor
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import { selectionToJSX } from '@open-pencil/core'
import { selectionToJSX, JSX_REFERENCE } from '@open-pencil/core'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
@ -34,9 +34,15 @@ const highlightedLines = computed(() => {
return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx'))
})
const { copy: copyRef, copied: copiedRef } = useClipboard({ copiedDuring: 2000 })
function copyCode() {
copy(jsxCode.value)
}
function copyReference() {
copyRef(JSX_REFERENCE)
}
</script>
<template>
@ -63,15 +69,26 @@ function copyCode() {
{{ jsxFormat === 'openpencil' ? 'OpenPencil' : 'Tailwind' }}
</button>
</div>
<button
data-test-id="code-panel-copy"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="copyCode"
>
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? dialogs.copied : dialogs.copy }}
</button>
<div class="flex items-center gap-1">
<button
data-test-id="code-panel-copy-ref"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
title="Copy JSX prop reference to clipboard"
@click="copyReference"
>
<icon-lucide-check v-if="copiedRef" class="size-3 text-green-400" />
<icon-lucide-book-open v-else class="size-3" />
</button>
<button
data-test-id="code-panel-copy"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="copyCode"
>
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? dialogs.copied : dialogs.copy }}
</button>
</div>
</div>
<ScrollAreaRoot class="min-h-0 flex-1">

5
src/global.d.ts vendored
View file

@ -42,3 +42,8 @@ interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_STORE__?: any
}
declare module '*.md' {
const content: string
export default content
}

View file

@ -336,7 +336,7 @@ describe('renderJSX (string → scene graph)', () => {
<Text name="Hello" size={16} color="#000">World</Text>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
expect(result.name).toBe('Test')
const node = g.nodes.get(result.id)!
@ -356,7 +356,7 @@ describe('renderJSX (string → scene graph)', () => {
<Text name="Description" size={14} color="#6B7280">Lorem ipsum</Text>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
const card = g.nodes.get(result.id)!
expect(card.layoutMode).toBe('VERTICAL')
@ -365,7 +365,7 @@ describe('renderJSX (string → scene graph)', () => {
it('renders with position', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
const [result] = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
const node = g.nodes.get(result.id)!
expect(node.x).toBe(100)
@ -530,7 +530,7 @@ describe('grid layout rendering', () => {
<Rectangle name="B" w={50} h={50} />
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
const frame = g.nodes.get(result.id)!
expect(frame.layoutMode).toBe('GRID')
@ -605,7 +605,7 @@ describe('grid layout rendering', () => {
</Frame>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
expect(grid.width).toBe(360)
@ -623,7 +623,7 @@ describe('grid layout rendering', () => {
</Frame>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
expect(grid.width).toBe(350)
@ -642,7 +642,7 @@ describe('grid layout rendering', () => {
</Frame>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const content = g.getChildren(result.id).find(c => c.name === 'Content')!
const grid = g.getChildren(content.id).find(c => c.name === 'G')!
@ -661,7 +661,7 @@ describe('grid layout rendering', () => {
</Frame>
</Frame>
`
const result = await renderJSX(g, jsx)
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
expect(grid.height).toBe(460)
@ -671,7 +671,7 @@ describe('grid layout rendering', () => {
describe('text props round-trip', () => {
it('lineHeight renders and exports', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
const [result] = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
const n = g.getNode(result.id)!
expect(n.lineHeight).toBe(24)
const jsx = sceneNodeToJSX(n.id, g)
@ -680,7 +680,7 @@ describe('text props round-trip', () => {
it('letterSpacing renders and exports', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
const [result] = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
const n = g.getNode(result.id)!
expect(n.letterSpacing).toBe(2)
const jsx = sceneNodeToJSX(n.id, g)
@ -689,7 +689,7 @@ describe('text props round-trip', () => {
it('textDecoration renders and exports', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
const [result] = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
const n = g.getNode(result.id)!
expect(n.textDecoration).toBe('UNDERLINE')
const jsx = sceneNodeToJSX(n.id, g)
@ -698,7 +698,7 @@ describe('text props round-trip', () => {
it('textCase renders and exports', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
const [result] = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
const n = g.getNode(result.id)!
expect(n.textCase).toBe('UPPER')
const jsx = sceneNodeToJSX(n.id, g)
@ -707,7 +707,7 @@ describe('text props round-trip', () => {
it('maxLines renders with truncation', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
const [result] = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
const n = g.getNode(result.id)!
expect(n.maxLines).toBe(2)
expect(n.textTruncation).toBe('ENDING')
@ -717,7 +717,7 @@ describe('text props round-trip', () => {
it('truncate without maxLines', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
const [result] = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
const n = g.getNode(result.id)!
expect(n.textTruncation).toBe('ENDING')
const jsx = sceneNodeToJSX(n.id, g)
@ -726,7 +726,7 @@ describe('text props round-trip', () => {
it('defaults omit text props', async () => {
const g = createGraph()
const result = await renderJSX(g, '<Text color="#000">Plain</Text>')
const [result] = await renderJSX(g, '<Text color="#000">Plain</Text>')
const n = g.getNode(result.id)!
expect(n.lineHeight).toBeNull()
expect(n.letterSpacing).toBe(0)
@ -744,7 +744,7 @@ describe('text props round-trip', () => {
it('text w="fill" in flex="col" exports as w="fill" not w={computed}', async () => {
const g = createGraph()
const result = await renderJSX(g, `
const [result] = await renderJSX(g, `
<Frame name="Card" flex="col" w={300} p={20}>
<Text name="Title" size={22} weight="bold" color="#111" w="fill">Hello World</Text>
</Frame>
@ -760,7 +760,7 @@ describe('text props round-trip', () => {
it('text grow={1} in flex="row" exports as grow not w={computed}', async () => {
const g = createGraph()
const result = await renderJSX(g, `
const [result] = await renderJSX(g, `
<Frame name="Row" flex="row" w={300}>
<Text name="Label" color="#999" w={60}>Label</Text>
<Text name="Value" color="#111" w="fill">Some value text</Text>

View file

@ -37,6 +37,14 @@ export default defineConfig(async ({ command }) => ({
)
},
plugins: [
{
name: 'raw-md',
transform(code: string, id: string) {
if (id.endsWith('.md')) {
return { code: `export default ${JSON.stringify(code)}`, map: null }
}
}
},
{
name: 'copy-canvaskit-wasm',
buildStart() {