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..1fcf8d2946 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,165 @@ 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 -> 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(); + 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 -> 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: [ + { + '@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: {}, + }, + }, + }, + }, + ], + }, + }; + + // 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]; + + expect(symbol?.component?.name).toBe('Symbol'); + expect(symbol?.component?.options?.symbol?.name).toBe('Copyright Reserved'); + }); +}); 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/generators/builder/generator.ts b/packages/core/src/generators/builder/generator.ts index 2a1f21c336..06ca38424c 100644 --- a/packages/core/src/generators/builder/generator.ts +++ b/packages/core/src/generators/builder/generator.ts @@ -656,6 +656,54 @@ export const blockToBuilder = ( const element = mapper(json, options); return processLocalizedValues(element, json); } + + 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; + } + } + + 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; + } + } + } + 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 }; + } + + const element = el( + { + component: { + name: 'Symbol', + options: { + symbol: symbolOptions, + }, + }, + }, + options, + ); + return processLocalizedValues(element, json); + } + } + if (json.properties._text || json.bindings._text?.code) { const element = el( { diff --git a/packages/core/src/parsers/builder/builder.ts b/packages/core/src/parsers/builder/builder.ts index bc59beb632..5457f3c448 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,47 @@ const componentMappers: { const styleString = getStyleStringFromBlock(block, options); const actionBindings = getActionBindingsFromBlock(block, options); + 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); + + // 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; + + // 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 +412,7 @@ const componentMappers: { }; return createMitosisNode({ - name: 'Symbol', + name: componentName, bindings: bindings, meta: getMetaFromBlock(block, options), }); @@ -631,7 +697,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 +1133,8 @@ function extractSymbols(json: BuilderContent) { continue; } - const componentName = 'Symbol' + ++symbolsFound; + const symbolName = elContent.name || symbolValue?.name; + const componentName = sanitizeSymbolName(symbolName) || `Symbol${++symbolsFound}`; el.component!.name = componentName;