diff --git a/packages/graph-editor/src/components/flow/nodes/typeConverterNode.tsx b/packages/graph-editor/src/components/flow/nodes/typeConverterNode.tsx new file mode 100644 index 00000000..52e8399e --- /dev/null +++ b/packages/graph-editor/src/components/flow/nodes/typeConverterNode.tsx @@ -0,0 +1,89 @@ +import { Handle } from '../handles.js'; +import { HandleContainer } from '../handles.js'; +import { Stack, Text } from '@tokens-studio/ui'; +import { extractType, extractTypeIcon } from '../wrapper/nodeV2.js'; +import { icons } from '@/registry/icon.js'; +import { useLocalGraph } from '@/context/graph.js'; +import { useSelector } from 'react-redux'; +import React from 'react'; + +export const TypeConverterNode = (args) => { + const { id } = args; + const graph = useLocalGraph(); + const node = graph.getNode(id); + const iconTypeRegistry = useSelector(icons); + + if (!node) return null; + + const inputPort = node.inputs.value; + const outputPort = node.outputs.value; + const inputTypeCol = extractTypeIcon(inputPort, iconTypeRegistry); + const outputTypeCol = extractTypeIcon(outputPort, iconTypeRegistry); + const inputType = extractType(inputPort.type); + const outputType = extractType(outputPort.type); + + // Get conversion description from node annotations + const conversionDescription = + (node.annotations['conversion.description'] as string) || 'TYPE'; + + return ( + + + + + + + + + {conversionDescription} + + + + + + + + + ); +}; diff --git a/packages/graph-editor/src/editor/actions/connect.ts b/packages/graph-editor/src/editor/actions/connect.ts index 73c37415..86dcd1fd 100644 --- a/packages/graph-editor/src/editor/actions/connect.ts +++ b/packages/graph-editor/src/editor/actions/connect.ts @@ -1,6 +1,7 @@ import { Connection, Edge } from 'reactflow'; import { Dispatch } from '@/redux/store.js'; -import { Graph } from '@tokens-studio/graph-engine'; +import { Graph, canConvertSchemaTypesExtended } from '@tokens-studio/graph-engine'; +import { TYPE_CONVERTER } from '@/ids.js'; import { deletable } from '@/annotations/index.js'; import { getVariadicIndex, stripVariadic } from '@/utils/stripVariadic.js'; @@ -9,10 +10,14 @@ export const connectNodes = graph, setEdges, dispatch, + nodeLookup, + reactFlowInstance, }: { graph: Graph; setEdges: React.Dispatch>; dispatch: Dispatch; + nodeLookup: Record; + reactFlowInstance: any; }) => (params: Connection) => { //Create the connection in the underlying graph @@ -70,6 +75,107 @@ export const connectNodes = graph.removeEdge(targetPort._edges[0].id); } + // Check if types are compatible and need conversion + const sourceType = sourcePort.type; + const targetType = targetPort.type; + const needsConversion = sourceType.$id !== targetType.$id && + canConvertSchemaTypesExtended(sourceType, targetType) && + sourceType.$id !== 'https://schemas.tokens.studio/any.json' && + targetType.$id !== 'https://schemas.tokens.studio/any.json'; + + if (needsConversion) { + // Create a type converter node + const TypeConverterClass = nodeLookup[TYPE_CONVERTER]; + if (TypeConverterClass) { + const converterNode = new TypeConverterClass({ graph }); + + // Set the conversion types + converterNode.setConversionTypes(sourceType, targetType); + + // Calculate position between source and target + const sourcePosition = reactFlowInstance?.getNode(params.source!)?.position || { x: 0, y: 0 }; + const targetPosition = reactFlowInstance?.getNode(params.target!)?.position || { x: 100, y: 0 }; + const converterPosition = { + x: (sourcePosition.x + targetPosition.x) / 2, + y: (sourcePosition.y + targetPosition.y) / 2 + }; + + // Add the converter node to the graph + graph.addNode(converterNode); + + // Add the converter node to React Flow + const converterFlowNode = { + id: converterNode.id, + type: TYPE_CONVERTER, + position: converterPosition, + data: {}, + }; + + reactFlowInstance?.addNodes(converterFlowNode); + + // Connect source to converter + const sourceToConverterEdge = graph.connect( + sourceNode, + sourcePort, + converterNode, + converterNode.inputs.value + ); + + // Connect converter to target + const converterToTargetEdge = graph.connect( + converterNode, + converterNode.outputs.value, + targetNode, + targetPort + ); + + // Create React Flow edges + const sourceToConverterFlowEdge = { + id: sourceToConverterEdge.id, + source: params.source!, + sourceHandle: params.sourceHandle!, + target: converterNode.id, + targetHandle: 'value', + type: 'custom', + }; + + const converterToTargetFlowEdge = { + id: converterToTargetEdge.id, + source: converterNode.id, + sourceHandle: 'value', + target: params.target!, + targetHandle: params.targetHandle!, + type: 'custom', + }; + + dispatch.graph.appendLog({ + time: new Date(), + type: 'info', + data: { + msg: `Type converter inserted between ${sourceType.$id} and ${targetType.$id}`, + }, + }); + + return setEdges((eds) => { + const newEdgs = eds.reduce( + (acc, edge) => { + //All our inputs take a single input only, disconnect if we have a connection already + if ( + edge.targetHandle == params.targetHandle && + edge.target === params.target + ) { + return acc; + } + acc.push(edge); + return acc; + }, + [sourceToConverterFlowEdge, converterToTargetFlowEdge] as Edge[], + ); + return newEdgs; + }); + } + } + const newGraphEdge = graph.connect( sourceNode, sourcePort, diff --git a/packages/graph-editor/src/editor/graph.tsx b/packages/graph-editor/src/editor/graph.tsx index 0f7b738e..f58bafc0 100644 --- a/packages/graph-editor/src/editor/graph.tsx +++ b/packages/graph-editor/src/editor/graph.tsx @@ -49,12 +49,13 @@ import { GraphContextProvider } from '@/context/graph.js'; import { GraphEditorProps, ImperativeEditorRef } from './editorTypes.js'; import { GraphToolbar } from '@/components/toolbar/index.js'; import { HotKeys } from '@/components/hotKeys/index.js'; -import { NOTE, PASSTHROUGH } from '@/ids.js'; +import { NOTE, PASSTHROUGH, TYPE_CONVERTER } from '@/ids.js'; import { NodeContextMenu } from '../components/contextMenus/nodeContextMenu.js'; import { NodeV2 } from '@/components/index.js'; import { PaneContextMenu } from '../components/contextMenus/paneContextMenu.js'; import { PassthroughNode } from '@/components/flow/nodes/passthroughNode.js'; import { SelectionContextMenu } from '@/components/contextMenus/selectionContextMenu.js'; +import { TypeConverterNode } from '@/components/flow/nodes/typeConverterNode.js'; import { capabilitiesSelector, nodeTypesSelector, @@ -373,6 +374,7 @@ export const EditorApp = React.forwardRef< ...customNodeUI, GenericNode: NodeV2, [PASSTHROUGH]: PassthroughNode, + [TYPE_CONVERTER]: TypeConverterNode, [EditorNodeTypes.GROUP]: groupNode, [NOTE]: noteNode, }); @@ -530,8 +532,15 @@ export const EditorApp = React.forwardRef< }, []); const onConnect = useMemo( - () => connectNodes({ graph, setEdges, dispatch }), - [dispatch, graph, setEdges], + () => + connectNodes({ + graph, + setEdges, + dispatch, + nodeLookup: fullNodeLookup, + reactFlowInstance, + }), + [dispatch, graph, setEdges, fullNodeLookup, reactFlowInstance], ); const onNodeDragStop = useCallback( diff --git a/packages/graph-editor/src/ids.ts b/packages/graph-editor/src/ids.ts index 3ace9833..0643e70b 100644 --- a/packages/graph-editor/src/ids.ts +++ b/packages/graph-editor/src/ids.ts @@ -1,4 +1,5 @@ export const INPUT = 'studio.tokens.generic.input'; export const OUTPUT = 'studio.tokens.generic.output'; export const PASSTHROUGH = 'studio.tokens.generic.passthrough'; +export const TYPE_CONVERTER = 'studio.tokens.generic.typeConverter'; export const NOTE = 'studio.tokens.generic.note'; diff --git a/packages/graph-engine/src/nodes/generic/index.ts b/packages/graph-engine/src/nodes/generic/index.ts index 978149db..1bf88069 100644 --- a/packages/graph-engine/src/nodes/generic/index.ts +++ b/packages/graph-engine/src/nodes/generic/index.ts @@ -10,6 +10,7 @@ import panic from './panic.js'; import passthrough from './passthrough.js'; import subgraph from './subgraph.js'; import time from './time.js'; +import typeConverter from './typeConverter.js'; export const nodes = [ constant, @@ -23,5 +24,6 @@ export const nodes = [ objectPath, objectMerge, time, - delay + delay, + typeConverter ]; diff --git a/packages/graph-engine/src/nodes/generic/typeConverter.ts b/packages/graph-engine/src/nodes/generic/typeConverter.ts new file mode 100644 index 00000000..ab08b741 --- /dev/null +++ b/packages/graph-engine/src/nodes/generic/typeConverter.ts @@ -0,0 +1,72 @@ +import { + AnySchema, + convertSchemaTypeExtended, + getConversionDescriptionExtended +} from '../../schemas/index.js'; +import { INodeDefinition, ToInput, ToOutput } from '../../index.js'; +import { Node } from '../../programmatic/node.js'; + +/** + * A node that converts between different types automatically. + * This node is typically inserted automatically when connecting incompatible but convertible types. + */ +export default class NodeDefinition extends Node { + static title = 'Type Converter'; + static type = 'studio.tokens.generic.typeConverter'; + static description = 'Automatically converts between compatible types'; + + declare inputs: ToInput<{ + value: TInput; + }>; + declare outputs: ToOutput<{ + value: TOutput; + }>; + + private sourceType: any = AnySchema; + private targetType: any = AnySchema; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('value', { + type: AnySchema + }); + this.addOutput('value', { + type: AnySchema + }); + } + + /** + * Sets the source and target types for this converter + */ + setConversionTypes(sourceType: any, targetType: any) { + this.sourceType = sourceType; + this.targetType = targetType; + + // Update the input and output types using the proper API + this.inputs.value.setType(sourceType); + this.outputs.value.setType(targetType); + + // Store conversion info in annotations for UI display + this.annotations['conversion.source'] = sourceType.$id; + this.annotations['conversion.target'] = targetType.$id; + this.annotations['conversion.description'] = + getConversionDescriptionExtended(sourceType, targetType); + } + + /** + * Gets the conversion description for display + */ + getConversionDescription(): string { + return (this.annotations['conversion.description'] as string) || 'ANY→ANY'; + } + + execute(): void | Promise { + const input = this.inputs.value; + const convertedValue = convertSchemaTypeExtended( + this.sourceType, + this.targetType, + input.value + ); + this.outputs.value.set(convertedValue, this.targetType); + } +} diff --git a/packages/graph-engine/src/schemas/index.ts b/packages/graph-engine/src/schemas/index.ts index 59c45825..2ad14026 100644 --- a/packages/graph-engine/src/schemas/index.ts +++ b/packages/graph-engine/src/schemas/index.ts @@ -343,6 +343,7 @@ export const canConvertSchemaTypes = ( if (src.$id === target.$id) return true; //Any can always accept anything if (target.$id === ANY) return true; + if (src.$id === ANY) return true; if (src.type == 'array' && target.$id == ANY_ARRAY) { return true; @@ -355,29 +356,149 @@ export const canConvertSchemaTypes = ( case NUMBER: switch (target.$id) { case STRING: + case BOOLEAN: + case COLOR: + case VEC2: + case VEC3: return true; + } + break; + case STRING: + switch (target.$id) { + case NUMBER: case BOOLEAN: + case COLOR: + case OBJECT: + case ANY_ARRAY: return true; } break; - case STRING: { + case BOOLEAN: switch (target.$id) { + case STRING: case NUMBER: return true; - case BOOLEAN: + } + break; + case COLOR: + switch (target.$id) { + case STRING: + case VEC3: + case NUMBER: return true; } - } + break; + case VEC2: + switch (target.$id) { + case STRING: + case NUMBER: + case VEC3: + return true; + } + break; + case VEC3: + switch (target.$id) { + case STRING: + case NUMBER: + case VEC2: + case COLOR: + return true; + } + break; + case OBJECT: + switch (target.$id) { + case STRING: + return true; + } + break; + case ANY_ARRAY: + switch (target.$id) { + case STRING: + return true; + } + break; + case CURVE: + switch (target.$id) { + case STRING: + return true; + } + break; + case GRADIENT: + switch (target.$id) { + case STRING: + return true; + } + break; + case FLOATCURVE: + switch (target.$id) { + case STRING: + return true; + } + break; } return false; }; +/** + * Helper function to parse color from string + */ +const parseColorFromString = (str: string) => { + try { + // Handle hex colors + if (str.startsWith('#')) { + const hex = str.slice(1); + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16) / 255; + const g = parseInt(hex[1] + hex[1], 16) / 255; + const b = parseInt(hex[2] + hex[2], 16) / 255; + return { channels: [r, g, b], space: 'srgb' }; + } else if (hex.length === 6) { + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + return { channels: [r, g, b], space: 'srgb' }; + } + } + // Handle rgb() format + if (str.startsWith('rgb(')) { + const values = str + .slice(4, -1) + .split(',') + .map(v => parseFloat(v.trim())); + if (values.length >= 3) { + return { + channels: [values[0] / 255, values[1] / 255, values[2] / 255], + space: 'srgb' + }; + } + } + // Fallback to black + return { channels: [0, 0, 0], space: 'srgb' }; + } catch { + return { channels: [0, 0, 0], space: 'srgb' }; + } +}; + +/** + * Helper function to convert color to hex string + */ +const colorToHex = (color: any) => { + if (!color || !color.channels || !Array.isArray(color.channels)) { + return '#000000'; + } + const [r, g, b] = color.channels; + const toHex = (n: number) => { + const hex = Math.round(Math.max(0, Math.min(255, n * 255))).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +}; + /** * Handles the actual conversion of a value from one schema to another * @param srcSchema * @param targetSchema * @param src - * @param target * @returns */ export const convertSchemaType = ( @@ -392,18 +513,257 @@ export const convertSchemaType = ( return String(src); case BOOLEAN: return Boolean(src); + case COLOR: + return { channels: [src, src, src], space: 'srgb' }; + case VEC2: + return [src, src]; + case VEC3: + return [src, src, src]; } break; case STRING: switch (targetSchema.$id) { + case NUMBER: + return parseFloat(src) || 0; case BOOLEAN: return Boolean(src); + case COLOR: + return parseColorFromString(src); + case OBJECT: + try { + return JSON.parse(src); + } catch { + return {}; + } + case ANY_ARRAY: + try { + return JSON.parse(src); + } catch { + return []; + } + } + break; + case BOOLEAN: + switch (targetSchema.$id) { + case STRING: + return String(src); + case NUMBER: + return Number(src); + } + break; + case COLOR: + switch (targetSchema.$id) { + case STRING: + return colorToHex(src); + case VEC3: + return src?.channels || [0, 0, 0]; + case NUMBER: + return src?.channels?.[0] || 0; + } + break; + case VEC2: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + case NUMBER: + return Array.isArray(src) ? src[0] || 0 : 0; + case VEC3: + return Array.isArray(src) ? [...src, 0] : [0, 0, 0]; + } + break; + case VEC3: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + case NUMBER: + return Array.isArray(src) ? src[0] || 0 : 0; + case VEC2: + return Array.isArray(src) ? [src[0] || 0, src[1] || 0] : [0, 0]; + case COLOR: + return { + channels: Array.isArray(src) ? src : [0, 0, 0], + space: 'srgb' + }; + } + break; + case OBJECT: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + } + break; + case ANY_ARRAY: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + } + break; + case CURVE: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + } + break; + case GRADIENT: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); + } + break; + case FLOATCURVE: + switch (targetSchema.$id) { + case STRING: + return JSON.stringify(src); } break; } return src; }; +/** + * Gets a human-readable description of the conversion between two schema types + * @param srcSchema + * @param targetSchema + * @returns + */ +export const getConversionDescription = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject +): string => { + const getShortName = (schema: SchemaObject): string => { + switch (schema.$id) { + case NUMBER: + return 'NUM'; + case STRING: + return 'STR'; + case BOOLEAN: + return 'BOOL'; + case COLOR: + return 'COL'; + case VEC2: + return 'V2'; + case VEC3: + return 'V3'; + case OBJECT: + return 'OBJ'; + case ANY_ARRAY: + return 'ARR'; + case CURVE: + return 'CRV'; + case GRADIENT: + return 'GRAD'; + case FLOATCURVE: + return 'FCRV'; + case ANY: + return 'ANY'; + default: + return 'UNK'; + } + }; + + return `${getShortName(srcSchema)}→${getShortName(targetSchema)}`; +}; + +/** + * Type for conversion extension functions + */ +export type ConversionExtension = { + canConvertSchemaTypes?: (src: SchemaObject, target: SchemaObject) => boolean; + convertSchemaType?: ( + srcSchema: SchemaObject, + targetSchema: SchemaObject, + src: any + ) => any; + getConversionDescription?: ( + srcSchema: SchemaObject, + targetSchema: SchemaObject + ) => string; +}; + +/** + * Registry for conversion extensions + */ +const conversionExtensions: ConversionExtension[] = []; + +/** + * Register a conversion extension + */ +export const registerConversionExtension = (extension: ConversionExtension) => { + conversionExtensions.push(extension); +}; + +/** + * Extended canConvertSchemaTypes that checks extensions + */ +export const canConvertSchemaTypesExtended = ( + src: SchemaObject, + target: SchemaObject +): boolean => { + // Check base conversions first + if (canConvertSchemaTypes(src, target)) { + return true; + } + + // Check extensions + for (const extension of conversionExtensions) { + if (extension.canConvertSchemaTypes?.(src, target)) { + return true; + } + } + + return false; +}; + +/** + * Extended convertSchemaType that uses extensions + */ +export const convertSchemaTypeExtended = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject, + src: any +): any => { + // Try extensions first + for (const extension of conversionExtensions) { + if (extension.canConvertSchemaTypes?.(srcSchema, targetSchema)) { + const result = extension.convertSchemaType?.( + srcSchema, + targetSchema, + src + ); + if (result !== undefined) { + return result; + } + } + } + + // Fall back to base conversion + return convertSchemaType(srcSchema, targetSchema, src); +}; + +/** + * Extended getConversionDescription that uses extensions + */ +export const getConversionDescriptionExtended = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject +): string => { + // Try extensions first + for (const extension of conversionExtensions) { + if (extension.canConvertSchemaTypes?.(srcSchema, targetSchema)) { + const result = extension.getConversionDescription?.( + srcSchema, + targetSchema + ); + if (result) { + return result; + } + } + } + + // Fall back to base description + return getConversionDescription(srcSchema, targetSchema); +}; + export type GraphSchema = SchemaObject; export const AllSchemas = [ diff --git a/packages/graph-engine/tests/suites/connectionWithConversion.test.ts b/packages/graph-engine/tests/suites/connectionWithConversion.test.ts new file mode 100644 index 00000000..28259f6f --- /dev/null +++ b/packages/graph-engine/tests/suites/connectionWithConversion.test.ts @@ -0,0 +1,125 @@ +import { Graph } from '../../src/graph/graph.js'; +import { + NumberSchema, + StringSchema, + canConvertSchemaTypes +} from '../../src/schemas/index.js'; +import { describe, expect, test } from 'vitest'; +import ConstantNode from '../../src/nodes/generic/constant.js'; +import TypeConverter from '../../src/nodes/generic/typeConverter.js'; + +describe('Connection with Type Conversion', () => { + test('simulates the connection logic that would trigger automatic conversion', () => { + const graph = new Graph(); + + // Create source node (number constant) + const sourceNode = new ConstantNode({ graph }); + sourceNode.inputs.value.setValue(42); + sourceNode.inputs.value.setType(NumberSchema); + sourceNode.outputs.value.setType(NumberSchema); + + // Create target node (string constant) + const targetNode = new ConstantNode({ graph }); + targetNode.inputs.value.setType(StringSchema); + targetNode.outputs.value.setType(StringSchema); + + // Simulate the connection logic check + const sourceType = sourceNode.outputs.value.type; + const targetType = targetNode.inputs.value.type; + const needsConversion = + sourceType.$id !== targetType.$id && + canConvertSchemaTypes(sourceType, targetType) && + sourceType.$id !== 'https://schemas.tokens.studio/any.json' && + targetType.$id !== 'https://schemas.tokens.studio/any.json'; + + // Verify that conversion would be needed + expect(needsConversion).toBe(true); + expect(sourceType.$id).toBe('https://schemas.tokens.studio/number.json'); + expect(targetType.$id).toBe('https://schemas.tokens.studio/string.json'); + }); + + test('verifies that same types do not trigger conversion', () => { + const graph = new Graph(); + + // Create two nodes with the same type + const sourceNode = new ConstantNode({ graph }); + sourceNode.outputs.value.setType(NumberSchema); + + const targetNode = new ConstantNode({ graph }); + targetNode.inputs.value.setType(NumberSchema); + + // Simulate the connection logic check + const sourceType = sourceNode.outputs.value.type; + const targetType = targetNode.inputs.value.type; + const needsConversion = + sourceType.$id !== targetType.$id && + canConvertSchemaTypes(sourceType, targetType) && + sourceType.$id !== 'https://schemas.tokens.studio/any.json' && + targetType.$id !== 'https://schemas.tokens.studio/any.json'; + + // Verify that conversion would NOT be needed + expect(needsConversion).toBe(false); + expect(sourceType.$id).toBe(targetType.$id); + }); + + test('verifies converter node positioning logic', () => { + // Simulate React Flow node positions + const sourcePosition = { x: 100, y: 100 }; + const targetPosition = { x: 300, y: 200 }; + + // Calculate converter position (should be in the middle) + const converterPosition = { + x: (sourcePosition.x + targetPosition.x) / 2, + y: (sourcePosition.y + targetPosition.y) / 2 + }; + + expect(converterPosition.x).toBe(200); + expect(converterPosition.y).toBe(150); + }); + + test('verifies full conversion chain execution', async () => { + const graph = new Graph(); + + // Create source node + const sourceNode = new ConstantNode({ graph }); + sourceNode.inputs.value.setValue(123); + sourceNode.inputs.value.setType(NumberSchema); + sourceNode.outputs.value.setType(NumberSchema); + + // Create converter + const converter = new TypeConverter({ graph }); + converter.setConversionTypes(NumberSchema, StringSchema); + + // Create target node + const targetNode = new ConstantNode({ graph }); + targetNode.inputs.value.setType(StringSchema); + targetNode.outputs.value.setType(StringSchema); + + // Connect the chain: source -> converter -> target + graph.connect( + sourceNode, + sourceNode.outputs.value, + converter, + converter.inputs.value + ); + graph.connect( + converter, + converter.outputs.value, + targetNode, + targetNode.inputs.value + ); + + // Execute the chain + await sourceNode.execute(); + await converter.execute(); + await targetNode.execute(); + + // Verify the conversion worked + expect(sourceNode.outputs.value.value).toBe(123); + expect(converter.outputs.value.value).toBe('123'); + expect(targetNode.outputs.value.value).toBe('123'); + + // Verify the converter shows the right description + expect(converter.getConversionDescription()).toBe('NUM→STR'); + }); +}); diff --git a/packages/graph-engine/tests/suites/typeConverter.test.ts b/packages/graph-engine/tests/suites/typeConverter.test.ts new file mode 100644 index 00000000..c9786d6a --- /dev/null +++ b/packages/graph-engine/tests/suites/typeConverter.test.ts @@ -0,0 +1,39 @@ +import { Graph } from '../../src/graph/graph.js'; +import { + NumberSchema, + StringSchema, + canConvertSchemaTypes, + convertSchemaType +} from '../../src/schemas/index.js'; +import { describe, expect, test } from 'vitest'; +import TypeConverter from '../../src/nodes/generic/typeConverter.js'; + +describe('Type Converter', () => { + test('can convert number to string', () => { + const result = convertSchemaType(NumberSchema, StringSchema, 42); + expect(result).toBe('42'); + }); + + test('can check type compatibility', () => { + expect(canConvertSchemaTypes(NumberSchema, StringSchema)).toBe(true); + expect(canConvertSchemaTypes(StringSchema, NumberSchema)).toBe(true); + }); + + test('type converter node works correctly', async () => { + const graph = new Graph(); + const converter = new TypeConverter({ graph }); + + // Set up conversion from number to string + converter.setConversionTypes(NumberSchema, StringSchema); + + // Set input value + converter.inputs.value.setValue(123); + + // Execute the node + await converter.execute(); + + // Check output + expect(converter.outputs.value.value).toBe('123'); + expect(converter.getConversionDescription()).toBe('NUM→STR'); + }); +}); diff --git a/packages/nodes-design-tokens/src/extensions/typeConversions.ts b/packages/nodes-design-tokens/src/extensions/typeConversions.ts new file mode 100644 index 00000000..86fefff8 --- /dev/null +++ b/packages/nodes-design-tokens/src/extensions/typeConversions.ts @@ -0,0 +1,122 @@ +import { + SchemaObject, + canConvertSchemaTypes as baseCanConvertSchemaTypes, + convertSchemaType as baseConvertSchemaType, + getConversionDescription as baseGetConversionDescription, + registerConversionExtension +} from '@tokens-studio/graph-engine'; +import { TokenSchema, TokenSetSchema } from '../schemas/index.js'; +import { arrayOf } from '../schemas/utils.js'; +import { flatTokensRestoreToMap, flatten } from '../utils/index.js'; +import type { DeepKeyTokenMap, SingleToken } from '@tokens-studio/types'; + +/** + * Extended conversion description generator for design tokens + */ +export const getConversionDescription = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject +): string => { + const srcIsTokenArray = srcSchema.type === 'array' && srcSchema.items?.$id === TokenSchema.$id; + const targetIsTokenArray = targetSchema.type === 'array' && targetSchema.items?.$id === TokenSchema.$id; + + // Design token specific descriptions + if (srcIsTokenArray && targetSchema.$id === TokenSetSchema.$id) { + return 'TOK[]→SET'; + } + if (srcSchema.$id === TokenSetSchema.$id && targetIsTokenArray) { + return 'SET→TOK[]'; + } + if (srcSchema.$id === TokenSchema.$id && targetIsTokenArray) { + return 'TOK→TOK[]'; + } + + // Fall back to base descriptions + return baseGetConversionDescription(srcSchema, targetSchema); +}; + +/** + * Extended type conversion checker that includes design token conversions + */ +export const canConvertSchemaTypes = ( + src: SchemaObject, + target: SchemaObject +): boolean => { + // Add design token specific conversions + const srcIsTokenArray = src.type === 'array' && src.items?.$id === TokenSchema.$id; + const targetIsTokenArray = target.type === 'array' && target.items?.$id === TokenSchema.$id; + + // Array of tokens to token set + if (srcIsTokenArray && target.$id === TokenSetSchema.$id) { + return true; + } + + // Token set to array of tokens + if (src.$id === TokenSetSchema.$id && targetIsTokenArray) { + return true; + } + + // Single token to array of tokens + if (src.$id === TokenSchema.$id && targetIsTokenArray) { + return true; + } + + return false; +}; + +/** + * Extended type converter that includes design token conversions + */ +export const convertSchemaType = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject, + src: any +): any => { + // Handle design token specific conversions + const srcIsTokenArray = srcSchema.type === 'array' && srcSchema.items?.$id === TokenSchema.$id; + const targetIsTokenArray = targetSchema.type === 'array' && targetSchema.items?.$id === TokenSchema.$id; + + // Array of tokens to token set + if (srcIsTokenArray && targetSchema.$id === TokenSetSchema.$id) { + try { + // Cast SingleToken[] to IResolvedToken[] as done in existing nodes + return flatTokensRestoreToMap(src as any); + } catch (error) { + console.warn('Failed to convert token array to token set:', error); + return {}; + } + } + + // Token set to array of tokens + if (srcSchema.$id === TokenSetSchema.$id && targetIsTokenArray) { + try { + // Use flatten to convert token set to array, then cast to SingleToken[] + return flatten(src as DeepKeyTokenMap) as any; + } catch (error) { + console.warn('Failed to convert token set to token array:', error); + return []; + } + } + + // Single token to array of tokens + if (srcSchema.$id === TokenSchema.$id && targetIsTokenArray) { + return [src]; + } + + // Return undefined to indicate no conversion was performed + return undefined; +}; + +/** + * Register the design token conversion extensions + */ +export const registerDesignTokenConversions = () => { + registerConversionExtension({ + canConvertSchemaTypes, + convertSchemaType, + getConversionDescription + }); +}; + +// Auto-register when this module is imported +registerDesignTokenConversions(); diff --git a/packages/nodes-design-tokens/src/index.ts b/packages/nodes-design-tokens/src/index.ts index a77b9975..677efb82 100644 --- a/packages/nodes-design-tokens/src/index.ts +++ b/packages/nodes-design-tokens/src/index.ts @@ -2,3 +2,6 @@ export * from './nodes/index.js'; export * from './schemas/index.js'; export * from './ui/index.js'; export * from './utils/index.js'; + +// Import extensions to register them +import './extensions/typeConversions.js'; diff --git a/packages/nodes-design-tokens/src/schemas/conversions.ts b/packages/nodes-design-tokens/src/schemas/conversions.ts new file mode 100644 index 00000000..1ec4c0e3 --- /dev/null +++ b/packages/nodes-design-tokens/src/schemas/conversions.ts @@ -0,0 +1,101 @@ +import { + SchemaObject, + canConvertSchemaTypes as baseCanConvertSchemaTypes, + convertSchemaType as baseConvertSchemaType +} from '@tokens-studio/graph-engine'; +import { TokenSchema, TokenSetSchema } from './index.js'; +import { arrayOf } from './utils.js'; +import { flatTokensRestoreToMap } from '../utils/index.js'; +import type { SingleToken } from '@tokens-studio/types'; + +// Define the token array schema +export const TOKEN_ARRAY = 'https://schemas.tokens.studio/tokenArray.json'; +export const TokenArraySchema: SchemaObject = arrayOf(TokenSchema); + +/** + * Extended type conversion checker that includes design token conversions + */ +export const canConvertSchemaTypes = ( + src: SchemaObject, + target: SchemaObject +): boolean => { + // First check the base conversions + if (baseCanConvertSchemaTypes(src, target)) { + return true; + } + + // Add design token specific conversions + switch (src.$id || (src.type === 'array' && src.items?.$id)) { + case TokenSchema.$id: + // Single token to array of tokens + if (target.type === 'array' && target.items?.$id === TokenSchema.$id) { + return true; + } + break; + case TokenSchema.$id: // When src is array of tokens + if (src.type === 'array' && src.items?.$id === TokenSchema.$id) { + switch (target.$id) { + case TokenSetSchema.$id: + return true; // Array of tokens to token set + } + } + break; + case TokenSetSchema.$id: + // Token set to array of tokens + if (target.type === 'array' && target.items?.$id === TokenSchema.$id) { + return true; + } + break; + } + + return false; +}; + +/** + * Extended type converter that includes design token conversions + */ +export const convertSchemaType = ( + srcSchema: SchemaObject, + targetSchema: SchemaObject, + src: any +): any => { + // First try the base conversions + const baseResult = baseConvertSchemaType(srcSchema, targetSchema, src); + if (baseResult !== src) { + return baseResult; // Base conversion handled it + } + + // Handle design token specific conversions + const srcIsTokenArray = srcSchema.type === 'array' && srcSchema.items?.$id === TokenSchema.$id; + const targetIsTokenArray = targetSchema.type === 'array' && targetSchema.items?.$id === TokenSchema.$id; + + // Array of tokens to token set + if (srcIsTokenArray && targetSchema.$id === TokenSetSchema.$id) { + try { + // Cast SingleToken[] to IResolvedToken[] as done in existing nodes + return flatTokensRestoreToMap(src as any); + } catch (error) { + console.warn('Failed to convert token array to token set:', error); + return {}; + } + } + + // Token set to array of tokens + if (srcSchema.$id === TokenSetSchema.$id && targetIsTokenArray) { + try { + // This is just a placeholder - the real implementation is in extensions/typeConversions.ts + return []; + } catch (error) { + console.warn('Failed to convert token set to token array:', error); + return []; + } + } + + // Single token to array of tokens + if (srcSchema.$id === TokenSchema.$id && targetIsTokenArray) { + return [src]; + } + + // No conversion available, return original + return src; +}; diff --git a/packages/nodes-design-tokens/tests/extensions/typeConversions.test.ts b/packages/nodes-design-tokens/tests/extensions/typeConversions.test.ts new file mode 100644 index 00000000..56417582 --- /dev/null +++ b/packages/nodes-design-tokens/tests/extensions/typeConversions.test.ts @@ -0,0 +1,79 @@ +import '../../src/extensions/typeConversions.js'; +import { Graph, canConvertSchemaTypesExtended, convertSchemaTypeExtended, getConversionDescriptionExtended } from '@tokens-studio/graph-engine'; +import { TokenSchema, TokenSetSchema } from '../../src/schemas/index.js'; +import { arrayOf } from '../../src/schemas/utils.js'; +import { beforeAll, describe, expect, test } from 'vitest'; // Import to register extensions + +describe('Design Token Type Conversions', () => { + const TokenArraySchema = arrayOf(TokenSchema); + + test('can convert token array to token set', () => { + expect(canConvertSchemaTypesExtended(TokenArraySchema, TokenSetSchema)).toBe(true); + }); + + test('can convert token set to token array', () => { + expect(canConvertSchemaTypesExtended(TokenSetSchema, TokenArraySchema)).toBe(true); + }); + + test('can convert single token to token array', () => { + expect(canConvertSchemaTypesExtended(TokenSchema, TokenArraySchema)).toBe(true); + }); + + test('converts token array to token set correctly', () => { + const tokenArray = [ + { + name: 'colors.primary', + value: '#ff0000', + type: 'color' + }, + { + name: 'colors.secondary', + value: '#00ff00', + type: 'color' + } + ]; + + const result = convertSchemaTypeExtended(TokenArraySchema, TokenSetSchema, tokenArray); + + expect(result).toEqual({ + colors: { + primary: { + value: '#ff0000', + type: 'color' + }, + secondary: { + value: '#00ff00', + type: 'color' + } + } + }); + }); + + test('converts single token to token array correctly', () => { + const token = { + name: 'colors.primary', + value: '#ff0000', + type: 'color' + }; + + const result = convertSchemaTypeExtended(TokenSchema, TokenArraySchema, token); + + expect(result).toEqual([token]); + }); + + test('provides correct conversion descriptions', () => { + expect(getConversionDescriptionExtended(TokenArraySchema, TokenSetSchema)).toBe('TOK[]→SET'); + expect(getConversionDescriptionExtended(TokenSetSchema, TokenArraySchema)).toBe('SET→TOK[]'); + expect(getConversionDescriptionExtended(TokenSchema, TokenArraySchema)).toBe('TOK→TOK[]'); + }); + + test('handles conversion errors gracefully', () => { + // Test with invalid token array + const invalidTokenArray = [{ invalid: 'data' }]; + + const result = convertSchemaTypeExtended(TokenArraySchema, TokenSetSchema, invalidTokenArray); + + // Should return empty object on error + expect(result).toEqual({}); + }); +}); diff --git a/packages/nodes-design-tokens/tests/integration/automaticConversion.test.ts b/packages/nodes-design-tokens/tests/integration/automaticConversion.test.ts new file mode 100644 index 00000000..5cb4c500 --- /dev/null +++ b/packages/nodes-design-tokens/tests/integration/automaticConversion.test.ts @@ -0,0 +1,78 @@ +import '../../src/extensions/typeConversions.js'; +import { Graph, nodeLookup } from '@tokens-studio/graph-engine'; +import { TokenSchema, TokenSetSchema } from '../../src/schemas/index.js'; +import { arrayOf } from '../../src/schemas/utils.js'; +import { describe, expect, test } from 'vitest'; // Register extensions + +const TypeConverter = nodeLookup['studio.tokens.generic.typeConverter']; + +describe('Automatic Type Conversion Integration', () => { + test('type converter can handle token array to token set conversion', async () => { + const graph = new Graph(); + + // Create a type converter for token array to token set + const converter = new TypeConverter({ graph }); + const TokenArraySchema = arrayOf(TokenSchema); + converter.setConversionTypes(TokenArraySchema, TokenSetSchema); + + // Set input token array + const tokenArray = [ + { + name: 'colors.primary', + value: '#ff0000', + type: 'color' + }, + { + name: 'spacing.small', + value: '8px', + type: 'dimension' + } + ]; + + converter.inputs.value.setValue(tokenArray); + + // Execute conversion + await converter.execute(); + + // Verify output + const result = converter.outputs.value.value; + expect(result).toEqual({ + colors: { + primary: { + value: '#ff0000', + type: 'color' + } + }, + spacing: { + small: { + value: '8px', + type: 'dimension' + } + } + }); + + // Verify conversion description + expect(converter.getConversionDescription()).toBe('TOK[]→SET'); + }); + + test('single token to array conversion works', async () => { + const graph = new Graph(); + + const converter = new TypeConverter({ graph }); + const TokenArraySchema = arrayOf(TokenSchema); + converter.setConversionTypes(TokenSchema, TokenArraySchema); + + const singleToken = { + name: 'colors.primary', + value: '#ff0000', + type: 'color' + }; + + converter.inputs.value.setValue(singleToken); + await converter.execute(); + + const result = converter.outputs.value.value; + expect(result).toEqual([singleToken]); + expect(converter.getConversionDescription()).toBe('TOK→TOK[]'); + }); +}); diff --git a/packages/nodes-design-tokens/tests/integration/endToEndConversion.test.ts b/packages/nodes-design-tokens/tests/integration/endToEndConversion.test.ts new file mode 100644 index 00000000..c7b0b3ab --- /dev/null +++ b/packages/nodes-design-tokens/tests/integration/endToEndConversion.test.ts @@ -0,0 +1,180 @@ +import '../../src/extensions/typeConversions.js'; +import { Graph, canConvertSchemaTypesExtended, convertSchemaTypeExtended, nodeLookup } from '@tokens-studio/graph-engine'; +import { TokenSchema, TokenSetSchema } from '../../src/schemas/index.js'; +import { arrayOf } from '../../src/schemas/utils.js'; +import { describe, expect, test } from 'vitest'; // Register extensions + +const TypeConverter = nodeLookup['studio.tokens.generic.typeConverter']; + +describe('End-to-End Type Conversion', () => { + test('complete workflow: token set → array → token set with type converters', async () => { + const graph = new Graph(); + + // Original token set + const originalTokenSet = { + colors: { + primary: { + value: '#ff0000', + type: 'color' + }, + secondary: { + value: '#00ff00', + type: 'color' + } + }, + spacing: { + small: { + value: '8px', + type: 'dimension' + }, + medium: { + value: '16px', + type: 'dimension' + } + } + }; + + // Step 1: Convert token set to array using type converter + const setToArrayConverter = new TypeConverter({ graph }); + const TokenArraySchema = arrayOf(TokenSchema); + setToArrayConverter.setConversionTypes(TokenSetSchema, TokenArraySchema); + + setToArrayConverter.inputs.value.setValue(originalTokenSet); + await setToArrayConverter.execute(); + + const tokenArray = setToArrayConverter.outputs.value.value; + + // Verify array conversion + expect(Array.isArray(tokenArray)).toBe(true); + expect(tokenArray.length).toBe(4); // 2 colors + 2 spacing tokens + expect(setToArrayConverter.getConversionDescription()).toBe('SET→TOK[]'); + + // Verify token names are correct + const tokenNames = tokenArray.map(token => token.name); + expect(tokenNames).toContain('colors.primary'); + expect(tokenNames).toContain('colors.secondary'); + expect(tokenNames).toContain('spacing.small'); + expect(tokenNames).toContain('spacing.medium'); + + // Step 2: Convert array back to token set using type converter + const arrayToSetConverter = new TypeConverter({ graph }); + arrayToSetConverter.setConversionTypes(TokenArraySchema, TokenSetSchema); + + arrayToSetConverter.inputs.value.setValue(tokenArray); + await arrayToSetConverter.execute(); + + const finalTokenSet = arrayToSetConverter.outputs.value.value; + + // Verify round-trip conversion + expect(finalTokenSet).toEqual(originalTokenSet); + expect(arrayToSetConverter.getConversionDescription()).toBe('TOK[]→SET'); + }); + + test('type compatibility checking works correctly', () => { + const TokenArraySchema = arrayOf(TokenSchema); + + // These should be compatible + expect(canConvertSchemaTypesExtended(TokenArraySchema, TokenSetSchema)).toBe(true); + expect(canConvertSchemaTypesExtended(TokenSetSchema, TokenArraySchema)).toBe(true); + expect(canConvertSchemaTypesExtended(TokenSchema, TokenArraySchema)).toBe(true); + + // Same types should be compatible (but won't need conversion) + expect(canConvertSchemaTypesExtended(TokenSchema, TokenSchema)).toBe(true); + expect(canConvertSchemaTypesExtended(TokenSetSchema, TokenSetSchema)).toBe(true); + expect(canConvertSchemaTypesExtended(TokenArraySchema, TokenArraySchema)).toBe(true); + }); + + test('direct conversion functions work correctly', () => { + const TokenArraySchema = arrayOf(TokenSchema); + + // Test token array to token set + const tokenArray = [ + { name: 'colors.primary', value: '#ff0000', type: 'color' }, + { name: 'spacing.small', value: '8px', type: 'dimension' } + ]; + + const tokenSet = convertSchemaTypeExtended(TokenArraySchema, TokenSetSchema, tokenArray); + + expect(tokenSet).toEqual({ + colors: { + primary: { + value: '#ff0000', + type: 'color' + } + }, + spacing: { + small: { + value: '8px', + type: 'dimension' + } + } + }); + + // Test token set to token array + const backToArray = convertSchemaTypeExtended(TokenSetSchema, TokenArraySchema, tokenSet); + + expect(Array.isArray(backToArray)).toBe(true); + expect(backToArray.length).toBe(2); + + const names = backToArray.map(token => token.name); + expect(names).toContain('colors.primary'); + expect(names).toContain('spacing.small'); + }); + + test('single token to array conversion', () => { + const TokenArraySchema = arrayOf(TokenSchema); + const singleToken = { name: 'colors.primary', value: '#ff0000', type: 'color' }; + + const result = convertSchemaTypeExtended(TokenSchema, TokenArraySchema, singleToken); + + expect(result).toEqual([singleToken]); + }); + + test('handles complex nested token structures', async () => { + const graph = new Graph(); + const converter = new TypeConverter({ graph }); + const TokenArraySchema = arrayOf(TokenSchema); + + // Complex nested structure + const complexTokenSet = { + design: { + system: { + colors: { + brand: { + primary: { + value: '#007bff', + type: 'color' + }, + secondary: { + value: '#6c757d', + type: 'color' + } + } + }, + typography: { + heading: { + large: { + value: '32px', + type: 'dimension' + } + } + } + } + } + }; + + converter.setConversionTypes(TokenSetSchema, TokenArraySchema); + converter.inputs.value.setValue(complexTokenSet); + await converter.execute(); + + const result = converter.outputs.value.value; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + + const tokenNames = result.map(token => token.name); + expect(tokenNames).toContain('design.system.colors.brand.primary'); + expect(tokenNames).toContain('design.system.colors.brand.secondary'); + expect(tokenNames).toContain('design.system.typography.heading.large'); + }); +});