openpencil/packages/design-jsx/src/component-properties.ts
Danila Poyarkov 1dfd501d83
feat: name the call and list every problem in validation errors (#911)
* feat: name the call and list every problem in validation errors

Tool arguments were checked with v.parse, whose error names only the first problem and neither the tool nor the argument, so a model that sent a wrong create_shape call read 'Expected string but received 42'. Tool arguments in Core, which AI chat, MCP, the CLI, and WebMCP all reach, the automation bridge's file commands, design JSX component properties, and gradient stops now throw a heading that names the call followed by v.summarize, which lists each issue with its path. parseToolArgs is exported for the app's own parses of tool arguments.

* test(design-jsx): pass a deliberately wrong property value through the types

The test checks what a script sees for a non-string instance property, which the Instance types reject at compile time.

* docs: say that MCP clients get the MCP SDK's validation report

The MCP SDK validates tool arguments against the registered schema before OpenPencil's handler runs, so an MCP client already received every problem with its path and does not see parseToolArgs' message.
2026-10-05 14:06:26 +00:00

118 lines
4.7 KiB
TypeScript

import * as v from 'valibot'
import { applyComponentPropertyValue, componentPropertyDefinitions } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyReference,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
import { parseScriptInput } from './validation'
const definitionSchema = v.object({
id: v.string(),
name: v.string(),
type: v.picklist(['VARIANT', 'TEXT', 'BOOLEAN', 'INSTANCE_SWAP']),
defaultValue: v.string(),
variantOptions: v.optional(v.array(v.string())),
preferredValues: v.optional(v.array(v.string()))
}) satisfies v.GenericSchema<ComponentPropertyDefinition>
const referenceSchema = v.object({
propertyId: v.string(),
field: v.picklist(['VISIBLE', 'TEXT', 'INSTANCE_SWAP'])
}) satisfies v.GenericSchema<ComponentPropertyReference>
/** Accept the native graph contracts, without introducing a second property model. */
export function componentMetadata(
props: Record<string, unknown>,
type: SceneNode['type'],
definitions?: readonly ComponentPropertyDefinition[]
): Partial<SceneNode> {
const result: Partial<SceneNode> = {}
if (props.properties !== undefined && type !== 'INSTANCE') {
if (type !== 'COMPONENT' && type !== 'COMPONENT_SET')
throw new Error('Only components, component sets, and instances accept properties')
const definitions = parseScriptInput(
'Invalid properties',
v.array(definitionSchema),
props.properties
)
if (new Set(definitions.map((definition) => definition.id)).size !== definitions.length)
throw new Error('Duplicate component property IDs')
const variantNames = definitions
.filter((item) => item.type === 'VARIANT')
.map((item) => item.name)
if (new Set(variantNames).size !== variantNames.length)
throw new Error('Duplicate variant property names')
result.componentPropertyDefinitions = definitions
}
if (props.propertyRefs !== undefined) {
const references = parseScriptInput(
'Invalid propertyRefs',
v.array(referenceSchema),
props.propertyRefs
)
for (const reference of references) {
if (reference.field === 'TEXT' && type !== 'TEXT')
throw new Error('TEXT properties require a text node')
if (reference.field === 'INSTANCE_SWAP' && type !== 'INSTANCE')
throw new Error('INSTANCE_SWAP properties require an instance')
if (definitions) {
const definition = definitions.find((item) => item.id === reference.propertyId)
if (!definition)
throw new Error(`Unknown component property reference: ${reference.propertyId}`)
const expectedField = definition.type === 'BOOLEAN' ? 'VISIBLE' : definition.type
if (reference.field !== expectedField)
throw new Error(`Component property ${definition.id} cannot bind ${reference.field}`)
}
}
result.componentPropertyReferences = references
}
return result
}
/** Match the nearest component scope, including definitions inherited from its set. */
export function componentPropertyScope(
graph: SceneGraph,
parentId: string
): readonly ComponentPropertyDefinition[] | undefined {
let parent = graph.getNode(parentId)
while (parent) {
if (parent.type === 'INSTANCE') return componentPropertyDefinitions(graph, parent)
if (parent.type === 'COMPONENT_SET') return parent.componentPropertyDefinitions
if (parent.type === 'COMPONENT') {
const set = parent.parentId ? graph.getNode(parent.parentId) : undefined
return set?.type === 'COMPONENT_SET'
? [...set.componentPropertyDefinitions, ...parent.componentPropertyDefinitions]
: parent.componentPropertyDefinitions
}
parent = parent.parentId ? graph.getNode(parent.parentId) : undefined
}
return undefined
}
export function assignComponentProperties(
graph: SceneGraph,
instance: SceneNode,
input: unknown
): void {
if (input === undefined) return
const assignments = parseScriptInput(
'Invalid properties on <Instance>',
v.record(v.string(), v.string()),
input
)
const definitions = componentPropertyDefinitions(graph, instance)
for (const [id, value] of Object.entries(assignments)) {
const definition = definitions.find((item) => item.id === id)
if (!definition) throw new Error(`Unknown component property: ${id}`)
if (definition.type === 'VARIANT')
throw new Error('Select a component-set variant when creating the instance')
if (definition.type === 'BOOLEAN' && value !== 'true' && value !== 'false')
throw new Error(`Expected true or false for component property: ${id}`)
if (!applyComponentPropertyValue(graph, instance.id, definition, value))
throw new Error(`Cannot assign component property: ${id}`)
}
}