Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: dynamic styles are mapped to bindings when generating builder #1673

Merged
merged 5 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-yaks-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@builder.io/mitosis': patch
---

Builder: dynamic styles are mapped to bindings when generating
29 changes: 29 additions & 0 deletions packages/core/src/__tests__/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,35 @@ describe('Builder', () => {
}
"
`);

const json = componentToBuilder()({ component: mitosis });
expect(json).toMatchInlineSnapshot(`
{
"data": {
"blocks": [
{
"@type": "@builder.io/sdk:Element",
"actions": {},
"bindings": {
"responsiveStyles.large.color": "state.color",
"responsiveStyles.small.left": "state.left",
"responsiveStyles.small.top": "state.top",
"style.fontSize": "state.fontSize",
},
"children": [],
"code": {
"actions": {},
"bindings": {},
},
"properties": {},
"tagName": "div",
},
],
"jsCode": "",
"tsCode": "",
},
}
`);
});
});

Expand Down
152 changes: 151 additions & 1 deletion packages/core/src/generators/builder/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import { replaceNodes } from '@/helpers/replace-identifiers';
import { checkHasState } from '@/helpers/state';
import { isBuilderElement, symbolBlocksAsChildren } from '@/parsers/builder';
import { hashCodeAsString } from '@/symbols/symbol-processor';
import { ForNode, MitosisNode } from '@/types/mitosis-node';
import { Binding, ForNode, MitosisNode } from '@/types/mitosis-node';
import { MitosisStyles } from '@/types/mitosis-styles';
import { TranspilerArgs } from '@/types/transpiler';
import { traverse as babelTraverse, types } from '@babel/core';
import generate from '@babel/generator';
import { parseExpression } from '@babel/parser';
import type { Node } from '@babel/types';
import { BuilderContent, BuilderElement } from '@builder.io/sdk';
import json5 from 'json5';
import { attempt, mapValues, omit, omitBy, set } from 'lodash';
Expand Down Expand Up @@ -333,6 +335,150 @@ const processLocalizedValues = (element: BuilderElement, node: MitosisNode) => {
return element;
};

/**
* Turns a stringified object into an object that can be looped over.
* Since values in the stringified object could be JS expressions, all
* values in the resulting object will remain strings.
* @param input - The stringified object
*/
const parseJSObject = (
input: string,
): {
parsed: Record<string, string>;
unparsed?: string;
} => {
const unparsed: string[] = [];
let parsed: Record<string, string> = {};

try {
const ast = parseExpression(`(${input})`, {
plugins: ['jsx', 'typescript'],
sourceType: 'module',
});

if (ast.type !== 'ObjectExpression') {
return { parsed, unparsed: input };
}

for (const prop of ast.properties) {
/**
* If the object includes spread or method, we stop. We can't really break the component into Key/Value
* and the whole expression is considered dynamic. We return `false` to signify that.
*/
if (prop.type === 'ObjectMethod' || prop.type === 'SpreadElement') {
if (!!prop.start && !!prop.end) {
if (typeof input === 'string') {
unparsed.push(input.slice(prop.start - 1, prop.end - 1));
}
}
continue;
}

/**
* Ignore shorthand objects when processing incomplete objects. Otherwise we may
* create identifiers unintentionally.
* Example: When accounting for shorthand objects, "{ color" would become
* { color: color } thus creating a "color" identifier that does not exist.
*/
if (prop.type === 'ObjectProperty') {
if (prop.extra?.shorthand) {
if (typeof input === 'string') {
unparsed.push(input.slice(prop.start! - 1, prop.end! - 1));
}
continue;
}

let key = '';
if (prop.key.type === 'Identifier') {
key = prop.key.name;
} else if (prop.key.type === 'StringLiteral') {
key = prop.key.value;
} else {
continue;
}

if (typeof input === 'string') {
const [val, err] = extractValue(input, prop.value);
if (err === null) {
parsed[key] = val;
}
}
}
}

return {
parsed,
unparsed: unparsed.length > 0 ? `{${unparsed.join('\n')}}` : undefined,
};
} catch (err) {
return {
parsed,
unparsed: unparsed.length > 0 ? `{${unparsed.join('\n')}}` : undefined,
};
}
};

const extractValue = (input: string, node: Node | null): [string, null] | [null, string] => {
const start = node?.loc?.start;
const end = node?.loc?.end;
const startIndex =
start !== undefined && 'index' in start && typeof start['index'] === 'number'
? start['index']
: undefined;
const endIndex =
end !== undefined && 'index' in end && typeof end['index'] === 'number'
? end['index']
: undefined;

if (startIndex === undefined || endIndex === undefined || node === null) {
const err = `bad value: ${node}`;
return [null, err];
}

const value = input.slice(startIndex - 1, endIndex - 1);
return [value, null];
};

/**
* Maps and styles that are bound with dynamic values onto their respective
* binding keys for Builder elements. This function also maps media queries
* with dynamic values.
* @param - bindings - The bindings object that has your styles. This param
* will be modified in-place, and the old "style" key will be removed.
*/
const mapBoundStyles = (bindings: { [key: string]: Binding | undefined }) => {
const styles = bindings['style'];
if (!styles) {
return;
}
const { parsed } = parseJSObject(styles.code);

for (const key in parsed) {
const mediaQueryMatch = key.match(mediaQueryRegex);

if (mediaQueryMatch) {
const { parsed: mParsed } = parseJSObject(parsed[key]);
const [_, pixelSize] = mediaQueryMatch;
const size = sizes.getSizeForWidth(Number(pixelSize));
for (const mKey in mParsed) {
bindings[`responsiveStyles.${size}.${mKey}`] = {
code: mParsed[mKey],
bindingType: 'expression',
type: 'single',
};
}
} else {
bindings[`style.${key}`] = {
code: parsed[key],
bindingType: 'expression',
type: 'single',
};
}
}

delete bindings['style'];
};

export const blockToBuilder = (
json: MitosisNode,
options: ToBuilderOptions = {},
Expand Down Expand Up @@ -393,6 +539,10 @@ export const blockToBuilder = (
actionBody;
delete bindings[key];
}

if (key === 'style') {
mapBoundStyles(bindings);
}
}

const builderBindings: Record<string, string> = {};
Expand Down