From 4123bfcb14edf9e718a79fba5e74b9d0cb5eb9cd Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Mon, 1 Dec 2025 17:19:27 -0800 Subject: [PATCH 1/6] add symbol name serialization and inputs to level props --- .../__tests__/__snapshots__/qwik.test.ts.snap | 4 +- .../__snapshots__/builder.test.ts.snap | 114 ++++++++++++++++++ .../src/__tests__/builder/builder.test.ts | 82 +++++++++++++ .../__tests__/data/builder/symbol-basic.json | 24 ++++ .../data/builder/symbol-multiple.json | 72 +++++++++++ .../data/builder/symbol-with-inputs.json | 34 ++++++ .../data/builder/symbol-with-named-entry.json | 38 ++++++ packages/core/src/parsers/builder/builder.ts | 75 +++++++++++- 8 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/__tests__/data/builder/symbol-basic.json create mode 100644 packages/core/src/__tests__/data/builder/symbol-multiple.json create mode 100644 packages/core/src/__tests__/data/builder/symbol-with-inputs.json create mode 100644 packages/core/src/__tests__/data/builder/symbol-with-named-entry.json diff --git a/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap b/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap index 7471e25d64..c6696e3b73 100644 --- a/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap @@ -10145,7 +10145,7 @@ exports[`qwik > page-with-symbol 1`] = ` }); }; ", - "low.js": "import { Symbol1 } from \\"./med.js\\"; + "low.js": "import { SymbolHeaderSymbol } from \\"./med.js\\"; import { Fragment, @@ -10178,7 +10178,7 @@ export const MyComponentOnMount = (p) => { return h( Fragment, null, - h(Symbol1, { + h(SymbolHeaderSymbol, { class: \\"c713ty2\\", symbol: { model: \\"page\\", diff --git a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap index 1cf19a14be..d16de9b979 100644 --- a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap +++ b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap @@ -4925,3 +4925,117 @@ function MyComponent(props) { export default MyComponent; " `; + +exports[`Symbol Serialization > Multiple symbols with different names 1`] = ` +"import { + SymbolPrimaryButton, + SymbolSecondaryButton, + SymbolFooterSection, +} from \\"@components\\"; + +export default function MyComponent(props) { + return ( + <> + + + + + ); +} +" +`; + +exports[`Symbol Serialization > Symbol with basic metadata 1`] = ` +"import { SymbolBasicSymbol } from \\"@components\\"; + +export default function MyComponent(props) { + return ( + + ); +} +" +`; + +exports[`Symbol Serialization > Symbol with entry name 1`] = ` +"import { SymbolHeaderNavigation } from \\"@components\\"; + +export default function MyComponent(props) { + return ( + + ); +} +" +`; + +exports[`Symbol Serialization > Symbol with inputs as top-level props 1`] = ` +"import { SymbolButtonComponent } from \\"@components\\"; + +export default function MyComponent(props) { + return ( + + ); +} +" +`; diff --git a/packages/core/src/__tests__/builder/builder.test.ts b/packages/core/src/__tests__/builder/builder.test.ts index 871e1de5c2..0f513c91ee 100644 --- a/packages/core/src/__tests__/builder/builder.test.ts +++ b/packages/core/src/__tests__/builder/builder.test.ts @@ -34,6 +34,10 @@ import lazyLoadSection from '../data/builder/lazy-load-section.json?raw'; import localization from '../data/builder/localization.json?raw'; import slotsContent from '../data/builder/slots.json?raw'; import slots2Content from '../data/builder/slots2.json?raw'; +import symbolBasic from '../data/builder/symbol-basic.json?raw'; +import symbolMultiple from '../data/builder/symbol-multiple.json?raw'; +import symbolWithInputs from '../data/builder/symbol-with-inputs.json?raw'; +import symbolWithNamedEntry from '../data/builder/symbol-with-named-entry.json?raw'; import tagNameContent from '../data/builder/tag-name.json?raw'; import textBindings from '../data/builder/text-bindings.json?raw'; import advancedFor from '../data/for/advanced-for.raw.tsx?raw'; @@ -2094,3 +2098,81 @@ const bindingJson = { ], }, }; + +describe('Symbol Serialization', () => { + test('Symbol with basic metadata', () => { + const builderContent = JSON.parse(symbolBasic) as BuilderContent; + const component = builderContentToMitosisComponent(builderContent); + const mitosis = componentToMitosis(mitosisOptions)({ component }); + + // Verify symbol name is sanitized and prefixed + expect(component.children[0].name).toBe('SymbolBasicSymbol'); + expect(mitosis).toMatchSnapshot(); + }); + + test('Symbol with entry name', () => { + const builderContent = JSON.parse(symbolWithNamedEntry) as BuilderContent; + const component = builderContentToMitosisComponent(builderContent); + const mitosis = componentToMitosis(mitosisOptions)({ component }); + + // Verify symbol name is sanitized and prefixed + expect(component.children[0].name).toBe('SymbolHeaderNavigation'); + expect(mitosis).toMatchSnapshot(); + }); + + test('Symbol with inputs as top-level props', () => { + const builderContent = JSON.parse(symbolWithInputs) as BuilderContent; + const component = builderContentToMitosisComponent(builderContent); + const mitosis = componentToMitosis(mitosisOptions)({ component }); + + // Verify inputs are top-level bindings + const symbolNode = component.children[0]; + expect(symbolNode.name).toBe('SymbolButtonComponent'); + expect(symbolNode.bindings).toHaveProperty('buttonText'); + expect(symbolNode.bindings).toHaveProperty('variant'); + expect(symbolNode.bindings).toHaveProperty('isDisabled'); + expect(symbolNode.bindings).toHaveProperty('count'); + expect(symbolNode.bindings).toHaveProperty('config'); + expect(symbolNode.bindings.symbol).toBeDefined(); + + // Verify symbol binding doesn't contain data anymore + const symbolBinding = JSON.parse(symbolNode.bindings.symbol.code); + expect(symbolBinding.data).toBeUndefined(); + + expect(mitosis).toMatchSnapshot(); + }); + + test('Multiple symbols with different names', () => { + const builderContent = JSON.parse(symbolMultiple) as BuilderContent; + const component = builderContentToMitosisComponent(builderContent); + const mitosis = componentToMitosis(mitosisOptions)({ component }); + + // Verify each symbol has unique name + expect(component.children[0].name).toBe('SymbolPrimaryButton'); + expect(component.children[1].name).toBe('SymbolSecondaryButton'); + expect(component.children[2].name).toBe('SymbolFooterSection'); + + // Verify inputs are extracted for each + expect(component.children[0].bindings).toHaveProperty('text'); + expect(component.children[0].bindings).toHaveProperty('variant'); + expect(component.children[1].bindings).toHaveProperty('text'); + expect(component.children[1].bindings).toHaveProperty('variant'); + expect(component.children[2].bindings).toHaveProperty('copyrightText'); + expect(component.children[2].bindings).toHaveProperty('showSocialLinks'); + + expect(mitosis).toMatchSnapshot(); + }); + + test('Symbol roundtrip: Builder -> Mitosis -> Builder', () => { + const original = JSON.parse(symbolWithInputs) as BuilderContent; + const mitosisComponent = builderContentToMitosisComponent(original); + const backToBuilder = componentToBuilder()({ component: mitosisComponent }); + + // Verify the symbol structure is preserved + const originalSymbol = original.data?.blocks?.[0]; + const roundtripSymbol = backToBuilder.data?.blocks?.[0]; + + expect(roundtripSymbol?.component?.name).toBeDefined(); + expect(roundtripSymbol?.component?.options?.symbol).toBeDefined(); + }); +}); diff --git a/packages/core/src/__tests__/data/builder/symbol-basic.json b/packages/core/src/__tests__/data/builder/symbol-basic.json new file mode 100644 index 0000000000..d09d06728b --- /dev/null +++ b/packages/core/src/__tests__/data/builder/symbol-basic.json @@ -0,0 +1,24 @@ +{ + "data": { + "blocks": [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-abc123", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "5a009380a7274b1388f8f1e500d2e28a", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Basic Symbol" + } + } + } + } + } + ] + } +} diff --git a/packages/core/src/__tests__/data/builder/symbol-multiple.json b/packages/core/src/__tests__/data/builder/symbol-multiple.json new file mode 100644 index 0000000000..558863e35a --- /dev/null +++ b/packages/core/src/__tests__/data/builder/symbol-multiple.json @@ -0,0 +1,72 @@ +{ + "data": { + "blocks": [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-jkl012", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Primary Button" + }, + "data": { + "text": "Get Started", + "variant": "primary" + } + } + } + } + }, + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-mno345", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Secondary Button" + }, + "data": { + "text": "Learn More", + "variant": "secondary" + } + } + } + } + }, + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-pqr678", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Footer Section" + }, + "data": { + "copyrightText": "© 2024 Company Name", + "showSocialLinks": true + } + } + } + } + } + ] + } +} diff --git a/packages/core/src/__tests__/data/builder/symbol-with-inputs.json b/packages/core/src/__tests__/data/builder/symbol-with-inputs.json new file mode 100644 index 0000000000..126fafa0b4 --- /dev/null +++ b/packages/core/src/__tests__/data/builder/symbol-with-inputs.json @@ -0,0 +1,34 @@ +{ + "data": { + "blocks": [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-def456", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "7b8c9d0e1a2b3c4d5e6f7a8b9c0d1e2f", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Button Component" + }, + "data": { + "buttonText": "Click me!", + "variant": "primary", + "isDisabled": false, + "count": 42, + "config": { + "showIcon": true, + "iconPosition": "left" + } + } + } + } + } + } + ] + } +} diff --git a/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json b/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json new file mode 100644 index 0000000000..66e018864c --- /dev/null +++ b/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json @@ -0,0 +1,38 @@ +{ + "data": { + "blocks": [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + "id": "builder-ghi789", + "component": { + "name": "Symbol", + "options": { + "symbol": { + "entry": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + "model": "symbol", + "ownerId": "99d964b716f94737a50de4d76134d098", + "content": { + "name": "Header Navigation" + }, + "data": { + "logoUrl": "/logo.png", + "showSearch": true, + "menuItems": [ + { + "label": "Home", + "url": "/" + }, + { + "label": "About", + "url": "/about" + } + ] + } + } + } + } + } + ] + } +} diff --git a/packages/core/src/parsers/builder/builder.ts b/packages/core/src/parsers/builder/builder.ts index bc59beb632..f785ead99e 100644 --- a/packages/core/src/parsers/builder/builder.ts +++ b/packages/core/src/parsers/builder/builder.ts @@ -241,6 +241,37 @@ const wrapBindingIfNeeded = (value: string, options: BuilderToMitosisOptions) => return value; }; +/** + * Sanitizes a symbol name to be a valid JSX component name. + * - Converts to PascalCase + * - Removes invalid characters + * - Adds "Symbol" prefix to avoid collisions + * - Returns "Symbol" if no valid name can be generated + */ +const sanitizeSymbolName = (name: string | undefined): string => { + if (!name || typeof name !== 'string') { + return 'Symbol'; + } + + // Remove special characters and split into words + const words = name + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .split(/\s+/) + .filter(Boolean); + + if (words.length === 0) { + return 'Symbol'; + } + + // Convert to PascalCase + const pascalCase = words + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); + + // Add Symbol prefix to avoid collisions with other components + return `Symbol${pascalCase}`; +}; + const getBlockActions = (block: BuilderElement, options: BuilderToMitosisOptions) => { const obj = { ...block.actions, @@ -330,12 +361,38 @@ const componentMappers: { const styleString = getStyleStringFromBlock(block, options); const actionBindings = getActionBindingsFromBlock(block, options); + // Extract symbol name for component naming + // Note: extractSymbols may have already set block.component.name, so use that if available + const symbolOptions = block.component?.options?.symbol; + const symbolName = symbolOptions?.content?.name || symbolOptions?.name; + const componentName = + block.component?.name !== 'Symbol' + ? block.component?.name // Use name already set by extractSymbols + : sanitizeSymbolName(symbolName); // Otherwise sanitize it ourselves + + // Phase 2: Extract inputs from symbol.data to create top-level bindings + const symbolData = symbolOptions?.data || {}; + const inputBindings: Dictionary = {}; + const hasInputs = Object.keys(symbolData).length > 0; + + // Only extract inputs if there are any to avoid data loss + if (hasInputs) { + // Create individual bindings for each input + for (const key in symbolData) { + inputBindings[key] = createSingleBinding({ + code: json5.stringify(symbolData[key]), + }); + } + } + + // Keep symbol metadata - only omit data if we extracted inputs + const symbolMetadata = hasInputs ? omit(symbolOptions, 'data') : symbolOptions; + const bindings: Dictionary = { symbol: createSingleBinding({ - code: JSON.stringify({ - ...block.component?.options.symbol, - }), + code: JSON.stringify(symbolMetadata), }), + ...inputBindings, ...actionBindings, ...(styleString && { style: createSingleBinding({ code: styleString }), @@ -346,7 +403,7 @@ const componentMappers: { }; return createMitosisNode({ - name: 'Symbol', + name: componentName, bindings: bindings, meta: getMetaFromBlock(block, options), }); @@ -631,7 +688,11 @@ export const builderElementToMitosisNode = ( } } const mapper = - !_internalOptions.skipMapper && block.component && componentMappers[block.component!.name]; + !_internalOptions.skipMapper && + block.component && + (componentMappers[block.component!.name] || + // Handle symbols that were renamed by extractSymbols (e.g., "SymbolButtonComponent") + (block.component!.name.startsWith('Symbol') ? componentMappers['Symbol'] : undefined)); if (mapper) { return mapper(block, options); @@ -1063,7 +1124,9 @@ function extractSymbols(json: BuilderContent) { continue; } - const componentName = 'Symbol' + ++symbolsFound; + // Use actual symbol name instead of generic counter + const symbolName = elContent.name || symbolValue?.name; + const componentName = sanitizeSymbolName(symbolName) || `Symbol${++symbolsFound}`; el.component!.name = componentName; From d4c57b5652168950433f3f937b5d51f2d7c2069f Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Wed, 3 Dec 2025 00:27:16 -0800 Subject: [PATCH 2/6] handle converting symbol components back to Symbol for Builder --- .../src/__tests__/builder/builder.test.ts | 63 +++++++++++++++++++ .../core/src/generators/builder/generator.ts | 46 ++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/packages/core/src/__tests__/builder/builder.test.ts b/packages/core/src/__tests__/builder/builder.test.ts index 0f513c91ee..23a12eea6e 100644 --- a/packages/core/src/__tests__/builder/builder.test.ts +++ b/packages/core/src/__tests__/builder/builder.test.ts @@ -2175,4 +2175,67 @@ describe('Symbol Serialization', () => { expect(roundtripSymbol?.component?.name).toBeDefined(); expect(roundtripSymbol?.component?.options?.symbol).toBeDefined(); }); + + test('Symbol roundtrip: Named symbol converts back to "Symbol" component name', () => { + const original = JSON.parse(symbolWithInputs) as BuilderContent; + + // Step 1: Builder -> Mitosis (named component) + const mitosisComponent = builderContentToMitosisComponent(original); + expect(mitosisComponent.children[0].name).toBe('SymbolButtonComponent'); + + // Step 2: Mitosis -> Builder (should be "Symbol" not "SymbolButtonComponent") + const backToBuilder = componentToBuilder()({ component: mitosisComponent }); + const roundtripSymbol = backToBuilder.data?.blocks?.[0]; + + // CRITICAL: Builder Editor requires component.name === "Symbol" + expect(roundtripSymbol?.component?.name).toBe('Symbol'); + + // Verify symbol metadata is preserved + expect(roundtripSymbol?.component?.options?.symbol).toBeDefined(); + expect(roundtripSymbol?.component?.options?.symbol?.entry).toBeDefined(); + + // Verify the display name is preserved for next roundtrip + expect(roundtripSymbol?.component?.options?.symbol?.name).toBe('Button Component'); + + // Verify inputs are merged back into symbol.data + expect(roundtripSymbol?.component?.options?.symbol?.data).toBeDefined(); + expect(roundtripSymbol?.component?.options?.symbol?.data?.buttonText).toBe('Click me!'); + }); + + test('Symbol roundtrip preserves symbol.name for re-conversion to JSX', () => { + // Simulate what MCP returns: symbol with name field + const builderWithSymbolName: BuilderContent = { + data: { + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-roundtrip-test', + component: { + name: 'Symbol', + options: { + symbol: { + entry: 'test-entry-123', + model: 'symbol', + name: 'Copyright Reserved', // This should be used for component naming + data: {}, + }, + }, + }, + }, + ], + }, + }; + + // Builder -> Mitosis: should use symbol.name for component name + const mitosisComponent = builderContentToMitosisComponent(builderWithSymbolName); + expect(mitosisComponent.children[0].name).toBe('SymbolCopyrightReserved'); + + // Mitosis -> Builder: should preserve name and use "Symbol" as component name + const backToBuilder = componentToBuilder()({ component: mitosisComponent }); + const symbol = backToBuilder.data?.blocks?.[0]; + + expect(symbol?.component?.name).toBe('Symbol'); + expect(symbol?.component?.options?.symbol?.name).toBe('Copyright Reserved'); + }); }); diff --git a/packages/core/src/generators/builder/generator.ts b/packages/core/src/generators/builder/generator.ts index 2a1f21c336..c3d46591c3 100644 --- a/packages/core/src/generators/builder/generator.ts +++ b/packages/core/src/generators/builder/generator.ts @@ -656,6 +656,52 @@ export const blockToBuilder = ( const element = mapper(json, options); return processLocalizedValues(element, json); } + + // Handle Symbol* components (e.g., SymbolCopyrightReserved) - convert back to "Symbol" for Builder + // These are generated by the parser for LLM readability but Builder needs component.name = "Symbol" + if (json.name.startsWith('Symbol') && json.name !== 'Symbol' && json.bindings.symbol?.code) { + const symbolOptions = attempt(() => json5.parse(json.bindings.symbol!.code)); + + if (!(symbolOptions instanceof Error)) { + if (!symbolOptions.name) { + const displayName = json.name + .replace(/^Symbol/, '') + .replace(/([A-Z])/g, ' $1') + .trim(); + if (displayName) { + symbolOptions.name = displayName; + } + } + + // Merge any top-level input props back into symbol.data + const inputData: Record = {}; + for (const key of Object.keys(json.bindings)) { + if (key !== 'symbol' && key !== 'css' && key !== 'style') { + const value = attempt(() => json5.parse(json.bindings[key]!.code)); + if (!(value instanceof Error)) { + inputData[key] = value; + } + } + } + if (Object.keys(inputData).length > 0) { + symbolOptions.data = { ...symbolOptions.data, ...inputData }; + } + + const element = el( + { + component: { + name: 'Symbol', // Always use "Symbol" for Builder compatibility + options: { + symbol: symbolOptions, + }, + }, + }, + options, + ); + return processLocalizedValues(element, json); + } + } + if (json.properties._text || json.bindings._text?.code) { const element = el( { From 2e04131bb60fc7da658abb095f44a3d3f18da0d9 Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Wed, 3 Dec 2025 01:09:27 -0800 Subject: [PATCH 3/6] add round trip testing --- .../src/__tests__/builder/builder.test.ts | 41 ++++++++++++++----- .../core/src/generators/builder/generator.ts | 6 +++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/builder/builder.test.ts b/packages/core/src/__tests__/builder/builder.test.ts index 23a12eea6e..1fcf8d2946 100644 --- a/packages/core/src/__tests__/builder/builder.test.ts +++ b/packages/core/src/__tests__/builder/builder.test.ts @@ -2136,7 +2136,7 @@ describe('Symbol Serialization', () => { expect(symbolNode.bindings.symbol).toBeDefined(); // Verify symbol binding doesn't contain data anymore - const symbolBinding = JSON.parse(symbolNode.bindings.symbol.code); + const symbolBinding = JSON.parse(symbolNode.bindings.symbol!.code); expect(symbolBinding.data).toBeUndefined(); expect(mitosis).toMatchSnapshot(); @@ -2163,13 +2163,22 @@ describe('Symbol Serialization', () => { expect(mitosis).toMatchSnapshot(); }); - test('Symbol roundtrip: Builder -> Mitosis -> Builder', () => { + test('Symbol roundtrip: Builder -> Mitosis -> JSX -> Mitosis -> Builder', () => { const original = JSON.parse(symbolWithInputs) as BuilderContent; + + // Step 1: Builder JSON -> Mitosis Component const mitosisComponent = builderContentToMitosisComponent(original); - const backToBuilder = componentToBuilder()({ component: mitosisComponent }); - // Verify the symbol structure is preserved - const originalSymbol = original.data?.blocks?.[0]; + // Step 2: Mitosis Component -> Mitosis JSX string (what AI sees) + const jsxString = componentToMitosis()({ component: mitosisComponent }); + + // Step 3: Mitosis JSX string -> Mitosis Component (after AI edits) + const parsedComponent = parseJsx(jsxString); + + // Step 4: Mitosis Component -> Builder JSON + const backToBuilder = componentToBuilder()({ component: parsedComponent }); + + // Verify the symbol structure is preserved through full roundtrip const roundtripSymbol = backToBuilder.data?.blocks?.[0]; expect(roundtripSymbol?.component?.name).toBeDefined(); @@ -2183,8 +2192,14 @@ describe('Symbol Serialization', () => { const mitosisComponent = builderContentToMitosisComponent(original); expect(mitosisComponent.children[0].name).toBe('SymbolButtonComponent'); - // Step 2: Mitosis -> Builder (should be "Symbol" not "SymbolButtonComponent") - const backToBuilder = componentToBuilder()({ component: mitosisComponent }); + // Step 2: Mitosis -> JSX string (what AI sees) + const jsxString = componentToMitosis()({ component: mitosisComponent }); + + // Step 3: JSX string -> Mitosis (after AI edits) + const parsedComponent = parseJsx(jsxString); + + // Step 4: Mitosis -> Builder (should be "Symbol" not "SymbolButtonComponent") + const backToBuilder = componentToBuilder()({ component: parsedComponent }); const roundtripSymbol = backToBuilder.data?.blocks?.[0]; // CRITICAL: Builder Editor requires component.name === "Symbol" @@ -2227,12 +2242,18 @@ describe('Symbol Serialization', () => { }, }; - // Builder -> Mitosis: should use symbol.name for component name + // Step 1: Builder -> Mitosis: should use symbol.name for component name const mitosisComponent = builderContentToMitosisComponent(builderWithSymbolName); expect(mitosisComponent.children[0].name).toBe('SymbolCopyrightReserved'); - // Mitosis -> Builder: should preserve name and use "Symbol" as component name - const backToBuilder = componentToBuilder()({ component: mitosisComponent }); + // Step 2: Mitosis -> JSX string (what AI sees) + const jsxString = componentToMitosis()({ component: mitosisComponent }); + + // Step 3: JSX string -> Mitosis (after AI edits) + const parsedComponent = parseJsx(jsxString); + + // Step 4: Mitosis -> Builder: should preserve name and use "Symbol" as component name + const backToBuilder = componentToBuilder()({ component: parsedComponent }); const symbol = backToBuilder.data?.blocks?.[0]; expect(symbol?.component?.name).toBe('Symbol'); diff --git a/packages/core/src/generators/builder/generator.ts b/packages/core/src/generators/builder/generator.ts index c3d46591c3..7493eb912a 100644 --- a/packages/core/src/generators/builder/generator.ts +++ b/packages/core/src/generators/builder/generator.ts @@ -683,6 +683,12 @@ export const blockToBuilder = ( } } } + // Also check properties for inputs that became simple string props after JSX roundtrip + for (const key of Object.keys(json.properties)) { + if (!key.startsWith('$') && !key.startsWith('_') && !key.startsWith('data-')) { + inputData[key] = json.properties[key]; + } + } if (Object.keys(inputData).length > 0) { symbolOptions.data = { ...symbolOptions.data, ...inputData }; } From da0575d6f127482a0b5e40658f35665d3ecff915 Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Wed, 3 Dec 2025 01:21:02 -0800 Subject: [PATCH 4/6] removed unnecessary comments --- packages/core/src/generators/builder/generator.ts | 6 +----- packages/core/src/parsers/builder/builder.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/core/src/generators/builder/generator.ts b/packages/core/src/generators/builder/generator.ts index 7493eb912a..06ca38424c 100644 --- a/packages/core/src/generators/builder/generator.ts +++ b/packages/core/src/generators/builder/generator.ts @@ -657,8 +657,6 @@ export const blockToBuilder = ( return processLocalizedValues(element, json); } - // Handle Symbol* components (e.g., SymbolCopyrightReserved) - convert back to "Symbol" for Builder - // These are generated by the parser for LLM readability but Builder needs component.name = "Symbol" if (json.name.startsWith('Symbol') && json.name !== 'Symbol' && json.bindings.symbol?.code) { const symbolOptions = attempt(() => json5.parse(json.bindings.symbol!.code)); @@ -673,7 +671,6 @@ export const blockToBuilder = ( } } - // Merge any top-level input props back into symbol.data const inputData: Record = {}; for (const key of Object.keys(json.bindings)) { if (key !== 'symbol' && key !== 'css' && key !== 'style') { @@ -683,7 +680,6 @@ export const blockToBuilder = ( } } } - // Also check properties for inputs that became simple string props after JSX roundtrip for (const key of Object.keys(json.properties)) { if (!key.startsWith('$') && !key.startsWith('_') && !key.startsWith('data-')) { inputData[key] = json.properties[key]; @@ -696,7 +692,7 @@ export const blockToBuilder = ( const element = el( { component: { - name: 'Symbol', // Always use "Symbol" for Builder compatibility + name: 'Symbol', options: { symbol: symbolOptions, }, diff --git a/packages/core/src/parsers/builder/builder.ts b/packages/core/src/parsers/builder/builder.ts index f785ead99e..cad3285fad 100644 --- a/packages/core/src/parsers/builder/builder.ts +++ b/packages/core/src/parsers/builder/builder.ts @@ -361,16 +361,13 @@ const componentMappers: { const styleString = getStyleStringFromBlock(block, options); const actionBindings = getActionBindingsFromBlock(block, options); - // Extract symbol name for component naming - // Note: extractSymbols may have already set block.component.name, so use that if available const symbolOptions = block.component?.options?.symbol; const symbolName = symbolOptions?.content?.name || symbolOptions?.name; const componentName = block.component?.name !== 'Symbol' ? block.component?.name // Use name already set by extractSymbols - : sanitizeSymbolName(symbolName); // Otherwise sanitize it ourselves + : sanitizeSymbolName(symbolName); - // Phase 2: Extract inputs from symbol.data to create top-level bindings const symbolData = symbolOptions?.data || {}; const inputBindings: Dictionary = {}; const hasInputs = Object.keys(symbolData).length > 0; @@ -1124,7 +1121,6 @@ function extractSymbols(json: BuilderContent) { continue; } - // Use actual symbol name instead of generic counter const symbolName = elContent.name || symbolValue?.name; const componentName = sanitizeSymbolName(symbolName) || `Symbol${++symbolsFound}`; From 5c65b846e46336bc7eb5a5b62e602d27ebb682aa Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Tue, 9 Dec 2025 19:14:21 +0300 Subject: [PATCH 5/6] add comments about What symbolData contains: It's extracted from symbol.options.data and contains key-value pairs for props passed to the symbol instance in Builder.io --- packages/core/src/parsers/builder/builder.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/src/parsers/builder/builder.ts b/packages/core/src/parsers/builder/builder.ts index cad3285fad..5457f3c448 100644 --- a/packages/core/src/parsers/builder/builder.ts +++ b/packages/core/src/parsers/builder/builder.ts @@ -368,6 +368,18 @@ const componentMappers: { ? block.component?.name // Use name already set by extractSymbols : sanitizeSymbolName(symbolName); + // Extract inputs from symbol.data to make them visible as top-level JSX props + // + // In Builder.io, Symbol components can receive inputs through symbol.options.data, + // which contains key-value pairs for props passed to the symbol instance. + // + // We extract these from the nested data structure and create individual bindings + // for each input so they become first-class props in the generated code + // (e.g., ) instead of being buried in metadata + // (e.g., ). + // + // This transformation enables proper prop passing and makes the component usage + // more idiomatic in the target framework. const symbolData = symbolOptions?.data || {}; const inputBindings: Dictionary = {}; const hasInputs = Object.keys(symbolData).length > 0; From 831ca6a89b4f559858815194d571013b9c4c8cb9 Mon Sep 17 00:00:00 2001 From: Nicholas Koech Date: Sat, 13 Dec 2025 07:22:42 +0300 Subject: [PATCH 6/6] remove comments and enable snapshot test --- .../__snapshots__/builder.test.ts.snap | 103 ++++++++++++++++++ .../src/__tests__/builder/builder.test.ts | 66 ++--------- .../core/src/parsers/builder/builder.test.ts | 6 - 3 files changed, 115 insertions(+), 60 deletions(-) diff --git a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap index d16de9b979..5d84278e70 100644 --- a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap +++ b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap @@ -4927,6 +4927,71 @@ export default MyComponent; `; exports[`Symbol Serialization > Multiple symbols with different names 1`] = ` +[ + { + "bindings": { + "symbol": { + "bindingType": "expression", + "code": "{\\"entry\\":\\"2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e\\",\\"model\\":\\"symbol\\",\\"ownerId\\":\\"99d964b716f94737a50de4d76134d098\\"}", + "type": "single", + }, + "text": { + "bindingType": "expression", + "code": "'Get Started'", + "type": "single", + }, + "variant": { + "bindingType": "expression", + "code": "'primary'", + "type": "single", + }, + }, + "name": "SymbolPrimaryButton", + }, + { + "bindings": { + "symbol": { + "bindingType": "expression", + "code": "{\\"entry\\":\\"3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f\\",\\"model\\":\\"symbol\\",\\"ownerId\\":\\"99d964b716f94737a50de4d76134d098\\"}", + "type": "single", + }, + "text": { + "bindingType": "expression", + "code": "'Learn More'", + "type": "single", + }, + "variant": { + "bindingType": "expression", + "code": "'secondary'", + "type": "single", + }, + }, + "name": "SymbolSecondaryButton", + }, + { + "bindings": { + "copyrightText": { + "bindingType": "expression", + "code": "'© 2024 Company Name'", + "type": "single", + }, + "showSocialLinks": { + "bindingType": "expression", + "code": "true", + "type": "single", + }, + "symbol": { + "bindingType": "expression", + "code": "{\\"entry\\":\\"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a\\",\\"model\\":\\"symbol\\",\\"ownerId\\":\\"99d964b716f94737a50de4d76134d098\\"}", + "type": "single", + }, + }, + "name": "SymbolFooterSection", + }, +] +`; + +exports[`Symbol Serialization > Multiple symbols with different names 2`] = ` "import { SymbolPrimaryButton, SymbolSecondaryButton, @@ -5016,6 +5081,44 @@ export default function MyComponent(props) { `; exports[`Symbol Serialization > Symbol with inputs as top-level props 1`] = ` +{ + "bindings": { + "buttonText": { + "bindingType": "expression", + "code": "'Click me!'", + "type": "single", + }, + "config": { + "bindingType": "expression", + "code": "{showIcon:true,iconPosition:'left'}", + "type": "single", + }, + "count": { + "bindingType": "expression", + "code": "42", + "type": "single", + }, + "isDisabled": { + "bindingType": "expression", + "code": "false", + "type": "single", + }, + "symbol": { + "bindingType": "expression", + "code": "{\\"entry\\":\\"7b8c9d0e1a2b3c4d5e6f7a8b9c0d1e2f\\",\\"model\\":\\"symbol\\",\\"ownerId\\":\\"99d964b716f94737a50de4d76134d098\\"}", + "type": "single", + }, + "variant": { + "bindingType": "expression", + "code": "'primary'", + "type": "single", + }, + }, + "name": "SymbolButtonComponent", +} +`; + +exports[`Symbol Serialization > Symbol with inputs as top-level props 2`] = ` "import { SymbolButtonComponent } from \\"@components\\"; export default function MyComponent(props) { diff --git a/packages/core/src/__tests__/builder/builder.test.ts b/packages/core/src/__tests__/builder/builder.test.ts index 1fcf8d2946..35e152369b 100644 --- a/packages/core/src/__tests__/builder/builder.test.ts +++ b/packages/core/src/__tests__/builder/builder.test.ts @@ -1403,7 +1403,6 @@ describe('Builder', () => { }); test('layerLocked roundtrip conversion', () => { - // Test Builder -> Mitosis -> Builder roundtrip const originalBuilder = { data: { blocks: [ @@ -1422,17 +1421,13 @@ describe('Builder', () => { }, }; - // Convert to Mitosis const mitosisComponent = builderContentToMitosisComponent(originalBuilder); - // Verify Mitosis conversion expect(mitosisComponent.children[0].properties['data-builder-layerLocked']).toBe('true'); expect(mitosisComponent.children[0].properties.$name).toBe('test-layer'); - // Convert back to Builder const backToBuilder = componentToBuilder()({ component: mitosisComponent }); - // Verify roundtrip conversion expect(backToBuilder.data?.blocks?.[0]?.layerLocked).toBe(true); expect(backToBuilder.data?.blocks?.[0]?.layerName).toBe('test-layer'); }); @@ -1473,7 +1468,6 @@ describe('Builder', () => { }); test('groupLocked roundtrip conversion', () => { - // Test Builder -> Mitosis -> Builder roundtrip const originalBuilder = { data: { blocks: [ @@ -1492,17 +1486,13 @@ describe('Builder', () => { }, }; - // Convert to Mitosis const mitosisComponent = builderContentToMitosisComponent(originalBuilder); - // Verify Mitosis conversion expect(mitosisComponent.children[0].properties['data-builder-groupLocked']).toBe('true'); expect(mitosisComponent.children[0].properties.$name).toBe('test-layer'); - // Convert back to Builder const backToBuilder = componentToBuilder()({ component: mitosisComponent }); - // Verify roundtrip conversion expect(backToBuilder.data?.blocks?.[0]?.groupLocked).toBe(true); expect(backToBuilder.data?.blocks?.[0]?.layerName).toBe('test-layer'); }); @@ -1517,7 +1507,6 @@ describe('Builder', () => { refs: {}, state: { dataBuilderList1: { - // Should not use this key type: 'property', code: '[1,2,3,4,5]', propertyType: 'normal', @@ -1614,7 +1603,6 @@ describe('Builder', () => { } `); - // Test roundtrip conversion const backToMitosis = builderContentToMitosisComponent(builderJson); expect(backToMitosis).toMatchInlineSnapshot(` { @@ -2105,7 +2093,6 @@ describe('Symbol Serialization', () => { const component = builderContentToMitosisComponent(builderContent); const mitosis = componentToMitosis(mitosisOptions)({ component }); - // Verify symbol name is sanitized and prefixed expect(component.children[0].name).toBe('SymbolBasicSymbol'); expect(mitosis).toMatchSnapshot(); }); @@ -2115,7 +2102,6 @@ describe('Symbol Serialization', () => { const component = builderContentToMitosisComponent(builderContent); const mitosis = componentToMitosis(mitosisOptions)({ component }); - // Verify symbol name is sanitized and prefixed expect(component.children[0].name).toBe('SymbolHeaderNavigation'); expect(mitosis).toMatchSnapshot(); }); @@ -2125,17 +2111,12 @@ describe('Symbol Serialization', () => { const component = builderContentToMitosisComponent(builderContent); const mitosis = componentToMitosis(mitosisOptions)({ component }); - // Verify inputs are top-level bindings const symbolNode = component.children[0]; - expect(symbolNode.name).toBe('SymbolButtonComponent'); - expect(symbolNode.bindings).toHaveProperty('buttonText'); - expect(symbolNode.bindings).toHaveProperty('variant'); - expect(symbolNode.bindings).toHaveProperty('isDisabled'); - expect(symbolNode.bindings).toHaveProperty('count'); - expect(symbolNode.bindings).toHaveProperty('config'); - expect(symbolNode.bindings.symbol).toBeDefined(); - - // Verify symbol binding doesn't contain data anymore + expect({ + name: symbolNode.name, + bindings: symbolNode.bindings, + }).toMatchSnapshot(); + const symbolBinding = JSON.parse(symbolNode.bindings.symbol!.code); expect(symbolBinding.data).toBeUndefined(); @@ -2147,18 +2128,12 @@ describe('Symbol Serialization', () => { const component = builderContentToMitosisComponent(builderContent); const mitosis = componentToMitosis(mitosisOptions)({ component }); - // Verify each symbol has unique name - expect(component.children[0].name).toBe('SymbolPrimaryButton'); - expect(component.children[1].name).toBe('SymbolSecondaryButton'); - expect(component.children[2].name).toBe('SymbolFooterSection'); - - // Verify inputs are extracted for each - expect(component.children[0].bindings).toHaveProperty('text'); - expect(component.children[0].bindings).toHaveProperty('variant'); - expect(component.children[1].bindings).toHaveProperty('text'); - expect(component.children[1].bindings).toHaveProperty('variant'); - expect(component.children[2].bindings).toHaveProperty('copyrightText'); - expect(component.children[2].bindings).toHaveProperty('showSocialLinks'); + expect( + component.children.map((child) => ({ + name: child.name, + bindings: child.bindings, + })), + ).toMatchSnapshot(); expect(mitosis).toMatchSnapshot(); }); @@ -2166,19 +2141,14 @@ describe('Symbol Serialization', () => { test('Symbol roundtrip: Builder -> Mitosis -> JSX -> Mitosis -> Builder', () => { const original = JSON.parse(symbolWithInputs) as BuilderContent; - // Step 1: Builder JSON -> Mitosis Component const mitosisComponent = builderContentToMitosisComponent(original); - // Step 2: Mitosis Component -> Mitosis JSX string (what AI sees) const jsxString = componentToMitosis()({ component: mitosisComponent }); - // Step 3: Mitosis JSX string -> Mitosis Component (after AI edits) const parsedComponent = parseJsx(jsxString); - // Step 4: Mitosis Component -> Builder JSON const backToBuilder = componentToBuilder()({ component: parsedComponent }); - // Verify the symbol structure is preserved through full roundtrip const roundtripSymbol = backToBuilder.data?.blocks?.[0]; expect(roundtripSymbol?.component?.name).toBeDefined(); @@ -2188,37 +2158,29 @@ describe('Symbol Serialization', () => { test('Symbol roundtrip: Named symbol converts back to "Symbol" component name', () => { const original = JSON.parse(symbolWithInputs) as BuilderContent; - // Step 1: Builder -> Mitosis (named component) const mitosisComponent = builderContentToMitosisComponent(original); expect(mitosisComponent.children[0].name).toBe('SymbolButtonComponent'); - // Step 2: Mitosis -> JSX string (what AI sees) const jsxString = componentToMitosis()({ component: mitosisComponent }); - // Step 3: JSX string -> Mitosis (after AI edits) const parsedComponent = parseJsx(jsxString); - // Step 4: Mitosis -> Builder (should be "Symbol" not "SymbolButtonComponent") const backToBuilder = componentToBuilder()({ component: parsedComponent }); const roundtripSymbol = backToBuilder.data?.blocks?.[0]; // CRITICAL: Builder Editor requires component.name === "Symbol" expect(roundtripSymbol?.component?.name).toBe('Symbol'); - // Verify symbol metadata is preserved expect(roundtripSymbol?.component?.options?.symbol).toBeDefined(); expect(roundtripSymbol?.component?.options?.symbol?.entry).toBeDefined(); - // Verify the display name is preserved for next roundtrip expect(roundtripSymbol?.component?.options?.symbol?.name).toBe('Button Component'); - // Verify inputs are merged back into symbol.data expect(roundtripSymbol?.component?.options?.symbol?.data).toBeDefined(); expect(roundtripSymbol?.component?.options?.symbol?.data?.buttonText).toBe('Click me!'); }); test('Symbol roundtrip preserves symbol.name for re-conversion to JSX', () => { - // Simulate what MCP returns: symbol with name field const builderWithSymbolName: BuilderContent = { data: { blocks: [ @@ -2232,7 +2194,7 @@ describe('Symbol Serialization', () => { symbol: { entry: 'test-entry-123', model: 'symbol', - name: 'Copyright Reserved', // This should be used for component naming + name: 'Copyright Reserved', data: {}, }, }, @@ -2242,17 +2204,13 @@ describe('Symbol Serialization', () => { }, }; - // Step 1: Builder -> Mitosis: should use symbol.name for component name const mitosisComponent = builderContentToMitosisComponent(builderWithSymbolName); expect(mitosisComponent.children[0].name).toBe('SymbolCopyrightReserved'); - // Step 2: Mitosis -> JSX string (what AI sees) const jsxString = componentToMitosis()({ component: mitosisComponent }); - // Step 3: JSX string -> Mitosis (after AI edits) const parsedComponent = parseJsx(jsxString); - // Step 4: Mitosis -> Builder: should preserve name and use "Symbol" as component name const backToBuilder = componentToBuilder()({ component: parsedComponent }); const symbol = backToBuilder.data?.blocks?.[0]; diff --git a/packages/core/src/parsers/builder/builder.test.ts b/packages/core/src/parsers/builder/builder.test.ts index f4a41b9598..7671c91427 100644 --- a/packages/core/src/parsers/builder/builder.test.ts +++ b/packages/core/src/parsers/builder/builder.test.ts @@ -23,9 +23,7 @@ describe('Unpaired Surrogates', () => { }; const output = builderContentToMitosisComponent(builderContent); - // Text should be cleaned of unpaired surrogates expect(output.children[0].properties.text).toBe('Hello World. Welcome to section'); - // Verify unpaired surrogates are removed expect(output.children[0].properties.text).not.toContain('\uD800'); expect(output.children[0].properties.text).not.toContain('\uDFFF'); }); @@ -81,12 +79,10 @@ describe('Unpaired Surrogates', () => { }, }; - // Convert Builder JSON to Mitosis JSON const mitosisCmp = builderContentToMitosisComponent(builderContent); expect(mitosisCmp.children[0].name).toBe('Text123'); expect(mitosisCmp.children[0].properties['data-builder-originalName']).toBe('Text:123'); - // Convert Mitosis JSON to Mitosis JSX const mitosisJsx = componentToMitosis()({ component: mitosisCmp }); expect(mitosisJsx).toMatchInlineSnapshot(` "import { Text123 } from \\"@components\\"; @@ -97,12 +93,10 @@ describe('Unpaired Surrogates', () => { " `); - // Convert back Mitosis JSX to Mitosis JSON const backToMitosisCmp = parseJsx(mitosisJsx); expect(backToMitosisCmp.children[0].name).toBe('Text123'); expect(backToMitosisCmp.children[0].properties['data-builder-originalName']).toBe('Text:123'); - // Convert back Mitosis JSON to Builder JSON const backToBuilder = componentToBuilder()({ component: backToMitosisCmp }); expect(backToBuilder?.data?.blocks?.[0]?.component?.name).toBe('Text:123'); expect(backToBuilder?.data?.blocks?.[0]?.component?.options).not.toHaveProperty(