From 05e4a29a084c7ca711c06bbcaafd56fefa35a81c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 31 May 2026 18:33:56 +0300 Subject: [PATCH] feat(design-jsx): support components and instances --- CHANGELOG.md | 4 + packages/core/src/design-jsx/components.ts | 22 +++- packages/core/src/design-jsx/index.ts | 5 +- packages/core/src/design-jsx/jsx-runtime.ts | 3 + packages/core/src/design-jsx/render.ts | 7 +- packages/core/src/design-jsx/renderer.ts | 137 +++++++++++++++++++- packages/core/src/design-jsx/tree.ts | 1 + packages/core/src/index.ts | 4 + tests/engine/render/jsx/render-tree.test.ts | 52 +++++++- 9 files changed, 226 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c28652d..58579aa0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- Add JSX authoring support for components, component sets, and instances. + ### Fixes - Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup. diff --git a/packages/core/src/design-jsx/components.ts b/packages/core/src/design-jsx/components.ts index 06823e7c4..0b9d928ea 100644 --- a/packages/core/src/design-jsx/components.ts +++ b/packages/core/src/design-jsx/components.ts @@ -55,10 +55,23 @@ export function Section(props: BaseProps, ...children: Child[]): TreeNode { return withChildren('section', props, children) } +export function Component(props: BaseProps, ...children: Child[]): TreeNode { + return withChildren('component', props, children) +} + +export function ComponentSet(props: BaseProps, ...children: Child[]): TreeNode { + return withChildren('component-set', props, children) +} + +export function Instance( + props: BaseProps & { component?: string; componentId?: string; of?: string }, + ...children: Child[] +): TreeNode { + return withChildren('instance', props, children) +} + export const View = Frame export const Rect = Rectangle -export const Component = Frame -export const Instance = Frame export const Page = Frame export const INTRINSIC_ELEMENTS = [ @@ -71,5 +84,8 @@ export const INTRINSIC_ELEMENTS = [ 'polygon', 'vector', 'group', - 'section' + 'section', + 'component', + 'component-set', + 'instance' ] as const diff --git a/packages/core/src/design-jsx/index.ts b/packages/core/src/design-jsx/index.ts index 4236f9896..4f0b62e94 100644 --- a/packages/core/src/design-jsx/index.ts +++ b/packages/core/src/design-jsx/index.ts @@ -9,10 +9,11 @@ export { Vector, Group, Section, + Component, + ComponentSet, + Instance, View, Rect, - Component, - Instance, Page, INTRINSIC_ELEMENTS } from './components' diff --git a/packages/core/src/design-jsx/jsx-runtime.ts b/packages/core/src/design-jsx/jsx-runtime.ts index f2fc2dd97..db86eecdf 100644 --- a/packages/core/src/design-jsx/jsx-runtime.ts +++ b/packages/core/src/design-jsx/jsx-runtime.ts @@ -28,6 +28,9 @@ export namespace JSX { vector: BaseProps group: BaseProps section: BaseProps + component: BaseProps + 'component-set': BaseProps + instance: BaseProps & { component?: string; componentId?: string; of?: string } } export interface ElementChildrenAttribute { diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 95c975e6d..33d0c97aa 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -111,7 +111,10 @@ const SUPPORTED_PROPS = new Set([ 'pointCount', 'innerRadius', 'label', - 'style' + 'style', + 'component', + 'componentId', + 'of' ]) function stripHtmlComments(jsxString: string): string { @@ -144,7 +147,7 @@ export function buildComponent(jsxString: string): React.ComponentType { 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 Component = 'component', ComponentSet = 'component-set', Instance = 'instance' const Icon = 'icon' ` const opts = { diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index da6b1e929..e5b683c86 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -3,7 +3,13 @@ import type { RenderOptions } from '#core/design-jsx/types' import { fetchIcons } from '#core/icons' import { createIconFromPaths } from '#core/icons/render' import { computeAllLayouts } from '#core/layout' -import type { SceneGraph, SceneNode, NodeType } from '#core/scene-graph' +import { randomHex } from '#core/random' +import type { + ComponentPropertyDefinition, + SceneGraph, + SceneNode, + NodeType +} from '#core/scene-graph' import { applySizeOverrides, propsToOverrides } from './props-overrides' import { isTreeNode } from './tree' @@ -23,6 +29,8 @@ const TYPE_MAP: Partial> = { group: 'GROUP', section: 'SECTION', component: 'COMPONENT', + 'component-set': 'COMPONENT_SET', + componentset: 'COMPONENT_SET', div: 'FRAME', main: 'FRAME', header: 'FRAME', @@ -100,8 +108,133 @@ async function renderIconNode( return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides) } +function parseVariantValues(name: string): Record { + const entries = name + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + const values: Record = {} + for (const entry of entries) { + const [key = '', ...rest] = entry.split('=') + const property = key.trim() + const value = rest.join('=').trim() + if (property && value) values[property] = value + } + return values +} + +function inferComponentSetProperties(graph: SceneGraph, componentSetId: string): void { + const componentSet = graph.getNode(componentSetId) + if (componentSet?.type !== 'COMPONENT_SET') return + if (componentSet.componentPropertyDefinitions.length > 0) return + + const variants = graph.getChildren(componentSetId).filter((node) => node.type === 'COMPONENT') + const options = new Map>() + const valuesById = new Map>() + + for (const variant of variants) { + const values = parseVariantValues(variant.name) + valuesById.set(variant.id, values) + for (const [property, value] of Object.entries(values)) { + let set = options.get(property) + if (!set) { + set = new Set() + options.set(property, set) + } + set.add(value) + } + } + + const definitions: ComponentPropertyDefinition[] = [...options.entries()].map( + ([name, values]) => { + const variantOptions = [...values] + return { + id: `prop:${randomHex(8)}`, + name, + type: 'VARIANT', + defaultValue: variantOptions[0] ?? '', + variantOptions + } + } + ) + if (definitions.length === 0) return + + for (const [id, values] of valuesById) { + graph.updateNode(id, { componentPropertyValues: values }) + } + graph.updateNode(componentSetId, { componentPropertyDefinitions: definitions }) +} + +function findComponentByName(graph: SceneGraph, name: string): SceneNode | undefined { + for (const node of graph.getAllNodes()) { + if (node.type === 'COMPONENT' && node.name === name) return node + } + return undefined +} + +function findVariantInSet( + graph: SceneGraph, + componentSet: SceneNode, + props: Record +) { + const requested = Object.fromEntries( + Object.entries(props) + .filter(([key]) => !['component', 'componentId', 'of', 'name', 'children'].includes(key)) + .map(([key, value]) => [key, String(value)]) + ) + const variants = graph.getChildren(componentSet.id).filter((node) => node.type === 'COMPONENT') + return ( + variants.find((variant) => + Object.entries(requested).every( + ([key, value]) => variant.componentPropertyValues[key] === value + ) + ) ?? variants[0] + ) +} + +function resolveComponent( + graph: SceneGraph, + props: Record +): SceneNode | undefined { + const ref = props.component ?? props.componentId ?? props.of + if (typeof ref !== 'string') return undefined + + const byId = graph.getNode(ref) + if (byId?.type === 'COMPONENT') return byId + if (byId?.type === 'COMPONENT_SET') return findVariantInSet(graph, byId, props) + + const byName = findComponentByName(graph, ref) + if (byName) return byName + + for (const node of graph.getAllNodes()) { + if (node.type === 'COMPONENT_SET' && node.name === ref) + return findVariantInSet(graph, node, props) + } + return undefined +} + +async function renderInstanceNode( + graph: SceneGraph, + tree: TreeNode, + parentId: string +): Promise { + const parent = graph.getNode(parentId) + const parentLayout = parent?.layoutMode ?? 'NONE' + const component = resolveComponent(graph, tree.props) + if (!component) { + const ref = tree.props.component ?? tree.props.componentId ?? tree.props.of + const label = typeof ref === 'string' || typeof ref === 'number' ? String(ref) : '' + throw new Error(` component not found: ${label}`) + } + const overrides = propsToOverrides(tree.props, false, parentLayout) + return ( + graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId) + ) +} + async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise { if (tree.type === 'icon') return renderIconNode(graph, tree, parentId) + if (tree.type === 'instance') return renderInstanceNode(graph, tree, parentId) const nodeType = TYPE_MAP[tree.type] if (!nodeType) throw new Error(`Unknown element: <${tree.type}>`) @@ -134,5 +267,7 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): } } + if (node.type === 'COMPONENT_SET') inferComponentSetProperties(graph, node.id) + return node } diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 7aa64cc9a..300e3424d 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -154,6 +154,7 @@ export type BaseProps = StyleProps & { name?: string key?: string | number children?: unknown + [key: string]: unknown } export type TextProps = BaseProps diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9d3352ea..d9d271de4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -322,7 +322,11 @@ export { Section, View, Rect as RectNode, + Component, Component as ComponentNode, + ComponentSet, + ComponentSet as ComponentSetNode, + Instance, Instance as InstanceNode, Page as PageNode, INTRINSIC_ELEMENTS, diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index 312ead19f..239f9f243 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -11,7 +11,10 @@ import { Line, Star, Group, - Section + Section, + Component, + ComponentSet, + Instance } from '@open-pencil/core' import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert' @@ -76,6 +79,53 @@ describe('renderTree', () => { expect(heading.fills.length).toBe(1) }) + it('renders components and instances', async () => { + const g = makeSceneGraph() + const component = await renderTree( + g, + Component({ + name: 'Badge', + w: 64, + h: 24, + children: [Text({ name: 'Label', color: '#000', children: 'Live' })] + }) + ) + + const instance = await renderTree( + g, + Instance({ component: component.id, name: 'Badge Instance' }) + ) + const node = getNodeOrThrow(g, instance.id) + + expect(component.type).toBe('COMPONENT') + expect(node.type).toBe('INSTANCE') + expect(node.componentId).toBe(component.id) + expect(node.childIds.length).toBe(1) + }) + + it('renders component sets and variant instances', async () => { + const g = makeSceneGraph() + const set = await renderTree( + g, + ComponentSet({ + name: 'Button', + children: [ + Component({ name: 'variant=Primary', w: 120, h: 40, bg: '#2563EB' }), + Component({ name: 'variant=Secondary', w: 120, h: 40, bg: '#FFFFFF' }) + ] + }) + ) + + const setNode = getNodeOrThrow(g, set.id) + expect(setNode.type).toBe('COMPONENT_SET') + expect(setNode.componentPropertyDefinitions[0]?.name).toBe('variant') + + const instance = await renderTree(g, Instance({ of: set.id, variant: 'Secondary' })) + const node = getNodeOrThrow(g, instance.id) + expect(node.type).toBe('INSTANCE') + expect(getNodeOrThrow(g, node.componentId ?? '').name).toBe('variant=Secondary') + }) + it('renders nested structure', async () => { const g = makeSceneGraph() const tree = Frame({