diff --git a/.changeset/p2-14-migrate-interpolations.md b/.changeset/p2-14-migrate-interpolations.md new file mode 100644 index 0000000..0792ba0 --- /dev/null +++ b/.changeset/p2-14-migrate-interpolations.md @@ -0,0 +1,5 @@ +--- +'@typestyles/migrate': minor +--- + +Migrate codemod: convert prop-based styled-components interpolations to `createVar` + `assignVars`, boolean prop ternaries to `styles.component` variants, destructured prop params, and `@media` blocks (P2.14). diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index f8da708..d17f3af 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -113,15 +113,40 @@ Bugs and credibility issues that lose evaluations on contact. Do these first. `packages/migrate`, `packages/props`, `packages/open-props`, and `packages/build-runner`; root README links the full package index. -- [ ] **P2.14 — Migrate codemod: handle interpolations** (PR: ) - - The codemod skips any template literal with interpolations — i.e. most real - styled-components code. Convert prop-based interpolations to - `createVar` + `assignVars` so migration is genuinely automated. +- [x] **P2.14 — Migrate codemod: handle interpolations** (PR: #99) + - Convert prop-based interpolations (`props => props.color`, destructured + `({ color }) => color`, suffixes like `px`) to `createVar` + `assignVars`. + - Convert boolean prop ternaries (`props.primary ? A : B`) to `styles.component` + variants with JSX call-site rewrites. + - Parse `@media` (and other at-rules) in static templates into nested style objects. - [ ] **P2.15 — Per-route critical CSS** (PR: ) - `getRegisteredCss()` returns everything ever registered. Use the extraction manifest in `@typestyles/next/build` to emit route-level CSS. +- [ ] **P2.16 — Migrate codemod: Emotion css prop** (PR: ) + - Hoist inline `css={css\`...\`}`JSX attributes to module-level`styles.class`/`styles.component`and rewrite to`className`. + +- [ ] **P2.17 — Migrate codemod: attrs and css composition** (PR: ) + - Parse `styled.*.attrs(…)` chains and merge static attrs onto rewritten JSX. + - Inline referenced `css\`...\`` fragments when composing styled templates. + +- [ ] **P2.18 — Migrate codemod: export migration opt-in** (PR: ) + - Add `--include-exports` to migrate exported styled components into named + function wrappers without silently changing public APIs by default. + +- [ ] **P2.19 — Migrate codemod: theme → tokens heuristic** (PR: ) + - Optional `--theme-prefix` rewrite for `props.theme…` interpolations to + `tokens.use(…)` references with warnings for unmatched paths. + +- [ ] **P2.20 — Migrate CLI hardening** (PR: ) + - `--strict` / non-zero exit on warnings; spread-prop detection (`{...props}`) + with actionable hints; optional partial migration for mixed templates. + +- [ ] **P2.21 — Migrate codemod: class → component second pass** (PR: ) + - Optional `--to-component` pass that groups related `styles.class` calls in a + file into `styles.component` recipes with a `base` slot. + ## P3 — Later (not scheduled) - VS Code extension: hover preview of emitted CSS, token autocomplete with swatches diff --git a/docs/content/docs/migration.md b/docs/content/docs/migration.md index 2f669f4..bb65d66 100644 --- a/docs/content/docs/migration.md +++ b/docs/content/docs/migration.md @@ -958,9 +958,12 @@ The `@typestyles/migrate` package includes an early CLI to help with static migr ### Scope in this first version -- Converts static tagged templates (`styled.*`, `styled(...)`, and `css\`...\``) into `styles.class(...)`. +- Converts static tagged templates (`styled.*`, `styled(...)`, and `css\`...\``) into `styles.class(...)`or`styles.component(...)` when boolean prop variants are detected. - Rewrites JSX usage for safely transformable styled components. -- Skips dynamic template interpolations and emits warnings instead of doing unsafe rewrites. +- Skips unsupported template interpolations (theme access, non-literal ternaries, etc.) and emits warnings instead of doing unsafe rewrites. +- Converts prop-based interpolations such as `` `${props => props.color}` `` to `createVar` + `assignVars`. +- Converts boolean prop ternaries such as `` `${props => props.primary ? '#0066ff' : '#6b7280'}` `` to `styles.component` variants. +- Supports destructured prop params (`` `${({ color }) => color}` ``) and `@media` blocks in static templates. ### Usage @@ -983,10 +986,12 @@ Useful options: ### Current limitations -- Dynamic interpolations (for example `${(props) => ...}`) are intentionally not auto-migrated. +- Theme access and other non-literal interpolations are not auto-migrated. - Exported styled components are skipped to avoid accidental API-shape changes. - Complex non-JSX references to styled component variables are skipped. +Prop-based interpolations (for example `` `${(props) => props.color}` `` or `` `${props => props.width}px` ``) are converted to `createVar` + `assignVars` at JSX call sites. Boolean prop ternaries become `styles.component` variants. Destructured prop params and `@media` blocks in static templates are supported. + ## Troubleshooting migration issues ### Styles not applying diff --git a/packages/migrate/README.md b/packages/migrate/README.md index 0f407a1..056d95a 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -8,12 +8,16 @@ Full migration guide: [typestyles.dev/docs/migration](https://typestyles.dev/doc ## What it transforms -| Source | Becomes | -| ------------------------ | --------------------------------------------------- | -| `styled.div\`...\`` | `styles.class('div', { ... })` + `className` on JSX | -| `styled(Button)\`...\`` | `styles.class('button', { ... })` + `className` | -| `` css`...` `` (Emotion) | `styles.class(...)` | -| Static template literals | CSS object properties (via PostCSS parser) | +| Source | Becomes | +| ----------------------------------- | --------------------------------------------------- | +| `styled.div\`...\`` | `styles.class('div', { ... })` + `className` on JSX | +| `styled(Button)\`...\`` | `styles.class('button', { ... })` + `className` | +| `` css`...` `` (Emotion) | `styles.class(...)` | +| Static template literals | CSS object properties (via PostCSS parser) | +| `` `${props => props.x}` `` | `createVar` + `assignVars` + `styles.class` | +| `` `${props => props.x ? A : B}` `` | `styles.component` variants + JSX rewrite | +| `` `${({ x }) => x}` `` | `createVar` + `assignVars` (destructured params) | +| `@media` in templates | Nested `'@media (…)'` objects in style definitions | The codemod rewrites JSX usage for styled components it can safely transform and adds the required `import { styles } from 'typestyles'`. @@ -21,11 +25,11 @@ The codemod rewrites JSX usage for styled components it can safely transform and Honest automation beats silent breakage: -- **Template interpolations** — `` `${props => ...}` ``, theme access, and any dynamic expression inside backticks +- **Unsupported interpolations** — theme access (`props.theme…`), non-literal ternaries, and other non-prop expressions - **Exported styled components** — avoids changing your public API shape without review - **Non-JSX references** to styled component variables -Dynamic styles should become [`createVar` + `assignVars`](https://typestyles.dev/docs/css-variables) or inline styles. Interpolation support is planned ([P2.14](https://github.com/type-styles/typestyles/blob/main/IMPROVEMENTS.md)). +Prop-based patterns like `` `${props => props.color}` `` and `` `${(props) => props.width}px` `` are converted to [`createVar` + `assignVars`](https://typestyles.dev/docs/dynamic-styles). Boolean prop ternaries like `` `${props => props.primary ? '#0066ff' : '#6b7280'}` `` become `styles.component` variants. Suffix text after the interpolation (e.g. `px`) is applied at the call site. ## Installation diff --git a/packages/migrate/src/css.ts b/packages/migrate/src/css.ts index 9b789dd..ef6f045 100644 --- a/packages/migrate/src/css.ts +++ b/packages/migrate/src/css.ts @@ -6,8 +6,11 @@ function camelCaseProperty(property: string): string { return property.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); } -function toValueNode(value: string): t.Expression { +function toValueNode(value: string, varReplacements?: Map): t.Expression { const trimmed = value.trim(); + if (varReplacements?.has(trimmed)) { + return varReplacements.get(trimmed)!; + } if (/^-?\d+(\.\d+)?$/.test(trimmed)) { return t.numericLiteral(Number(trimmed)); } @@ -24,22 +27,32 @@ function toKeyNode(key: string): t.Expression { function nodesToObject( nodes: ChildNode[] | undefined, warnings: MigrationWarning[], + varReplacements?: Map, ): t.ObjectExpression { const properties: t.ObjectProperty[] = []; for (const node of nodes ?? []) { if (node.type === 'decl') { const normalized = node.prop.startsWith('--') ? node.prop : camelCaseProperty(node.prop); - properties.push(t.objectProperty(toKeyNode(normalized), toValueNode(node.value))); + properties.push( + t.objectProperty(toKeyNode(normalized), toValueNode(node.value, varReplacements)), + ); continue; } if (node.type === 'rule') { - const nested = nodesToObject(node.nodes, warnings); + const nested = nodesToObject(node.nodes, warnings, varReplacements); properties.push(t.objectProperty(t.stringLiteral(node.selector.trim()), nested)); continue; } + if (node.type === 'atrule') { + const nested = nodesToObject(node.nodes, warnings, varReplacements); + const atRuleKey = `@${node.name}${node.params ? ` ${node.params}` : ''}`; + properties.push(t.objectProperty(t.stringLiteral(atRuleKey), nested)); + continue; + } + warnings.push({ message: `Unsupported CSS node "${node.type}" skipped.`, }); @@ -51,10 +64,11 @@ function nodesToObject( export function cssToObjectExpression( cssText: string, warnings: MigrationWarning[], + varReplacements?: Map, ): t.ObjectExpression | null { try { const root = postcss.parse(cssText); - return nodesToObject((root as unknown as Container).nodes, warnings); + return nodesToObject((root as unknown as Container).nodes, warnings, varReplacements); } catch (error) { warnings.push({ message: `Could not parse CSS template literal: ${(error as Error).message}`, diff --git a/packages/migrate/src/interpolations.ts b/packages/migrate/src/interpolations.ts new file mode 100644 index 0000000..f8ad581 --- /dev/null +++ b/packages/migrate/src/interpolations.ts @@ -0,0 +1,245 @@ +import * as t from '@babel/types'; + +export type PropInterpolation = { + index: number; + propName: string; + suffix: string; +}; + +export type BooleanTernaryInterpolation = { + index: number; + propName: string; + trueValue: string; + falseValue: string; + cssProperty: string; +}; + +const PLACEHOLDER_PREFIX = '__ts_migrate_var_'; + +function camelCaseProperty(property: string): string { + return property.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +export function placeholderToken(index: number): string { + return `${PLACEHOLDER_PREFIX}${index}__`; +} + +function extractSuffixFromQuasi(quasiText: string): string { + const match = quasiText.match(/^([^;\n]*)/); + return match?.[1] ?? ''; +} + +function literalToCssValue(node: t.Node): string | null { + if (t.isStringLiteral(node)) return node.value; + if (t.isNumericLiteral(node)) return String(node.value); + return null; +} + +function unwrapExpression(expression: t.Expression): t.Expression { + if (t.isParenthesizedExpression(expression)) { + return unwrapExpression(expression.expression); + } + return expression; +} + +function extractCssPropertyBeforeInterpolation(quasiText: string): string | null { + const match = quasiText.match(/([a-zA-Z-]+)\s*:\s*$/); + return match?.[1] ?? null; +} + +function stripTrailingPropertyDeclaration(quasiText: string): string { + return quasiText.replace(/[a-zA-Z-]+\s*:\s*$/, ''); +} + +function stripLeadingDeclarationRemainder(quasiText: string): string { + const semiIdx = quasiText.indexOf(';'); + if (semiIdx === -1) return quasiText; + return quasiText.slice(semiIdx + 1).trimStart(); +} + +/** + * Parse a styled-components prop interpolation such as + * `(props) => props.color`, `props => props.width`, or `({ color }) => color`. + */ +export function parsePropInterpolation(expression: t.Expression): { propName: string } | null { + if (!t.isArrowFunctionExpression(expression)) return null; + + const param = expression.params[0]; + if (!param) return null; + + const body = unwrapExpression(expression.body as t.Expression); + if (!t.isExpression(body)) return null; + + if (t.isIdentifier(param)) { + if ( + t.isMemberExpression(body) && + !body.computed && + t.isIdentifier(body.object, { name: param.name }) && + t.isIdentifier(body.property) + ) { + return { propName: body.property.name }; + } + return null; + } + + if (t.isObjectPattern(param) && t.isIdentifier(body)) { + const propName = body.name; + const hasProp = param.properties.some((property) => { + if (!t.isObjectProperty(property)) return false; + const keyName = t.isIdentifier(property.key) + ? property.key.name + : t.isStringLiteral(property.key) + ? property.key.value + : null; + return keyName === propName; + }); + if (!hasProp) return null; + return { propName }; + } + + return null; +} + +/** + * Parse `${(props) => (props.primary ? '#0066ff' : '#6b7280')}` style interpolations. + */ +export function parseBooleanTernaryInterpolation( + expression: t.Expression, +): { propName: string; trueValue: string; falseValue: string } | null { + if (!t.isArrowFunctionExpression(expression)) return null; + + const param = expression.params[0]; + if (!t.isIdentifier(param)) return null; + + const body = unwrapExpression(expression.body as t.Expression); + if (!t.isConditionalExpression(body)) return null; + + if ( + !t.isMemberExpression(body.test) || + body.test.computed || + !t.isIdentifier(body.test.object, { name: param.name }) || + !t.isIdentifier(body.test.property) + ) { + return null; + } + + const trueValue = literalToCssValue(body.consequent); + const falseValue = literalToCssValue(body.alternate); + if (trueValue === null || falseValue === null) return null; + + return { + propName: body.test.property.name, + trueValue, + falseValue, + }; +} + +export function parseTemplateInterpolations( + template: t.TemplateLiteral, +): PropInterpolation[] | null { + const interpolations: PropInterpolation[] = []; + + for (let i = 0; i < template.expressions.length; i++) { + const expression = template.expressions[i]; + if (!t.isExpression(expression)) return null; + + const parsed = parsePropInterpolation(expression); + if (!parsed) return null; + + const followingQuasi = template.quasis[i + 1]?.value.cooked ?? ''; + interpolations.push({ + index: i, + propName: parsed.propName, + suffix: extractSuffixFromQuasi(followingQuasi), + }); + } + + return interpolations; +} + +export function parseBooleanTemplateInterpolations( + template: t.TemplateLiteral, +): BooleanTernaryInterpolation[] | null { + const interpolations: BooleanTernaryInterpolation[] = []; + + for (let i = 0; i < template.expressions.length; i++) { + const expression = template.expressions[i]; + if (!t.isExpression(expression)) return null; + + const parsed = parseBooleanTernaryInterpolation(expression); + if (!parsed) return null; + + const quasiBefore = template.quasis[i]?.value.cooked ?? ''; + const cssProperty = extractCssPropertyBeforeInterpolation(quasiBefore); + if (!cssProperty) return null; + + interpolations.push({ + index: i, + propName: parsed.propName, + trueValue: parsed.trueValue, + falseValue: parsed.falseValue, + cssProperty: camelCaseProperty(cssProperty), + }); + } + + return interpolations; +} + +export function reconstructInterpolatedCss( + template: t.TemplateLiteral, + interpolations: PropInterpolation[], +): string { + let result = ''; + + for (let i = 0; i < template.quasis.length; i++) { + let quasiText = template.quasis[i].value.cooked ?? ''; + + if (i > 0) { + const interpolation = interpolations[i - 1]; + if (interpolation?.suffix && quasiText.startsWith(interpolation.suffix)) { + quasiText = quasiText.slice(interpolation.suffix.length); + } + } + + result += quasiText; + + if (i < interpolations.length) { + result += placeholderToken(interpolations[i].index); + } + } + + return result; +} + +export function reconstructStaticCssWithoutVariants( + template: t.TemplateLiteral, + booleanInterpolations: BooleanTernaryInterpolation[], +): string { + const skipIndices = new Set(booleanInterpolations.map((interpolation) => interpolation.index)); + let result = ''; + + for (let i = 0; i < template.quasis.length; i++) { + let quasiText = template.quasis[i].value.cooked ?? ''; + + if (skipIndices.has(i)) { + quasiText = stripTrailingPropertyDeclaration(quasiText); + } + + if (i > 0 && skipIndices.has(i - 1)) { + quasiText = stripLeadingDeclarationRemainder(quasiText); + } + + result += quasiText; + } + + return result.trim(); +} + +export function toVarDebugName(componentName: string, propName: string): string { + return `${componentName}${propName.charAt(0).toUpperCase()}${propName.slice(1)}`; +} + +export function toComponentConstName(componentName: string): string { + if (!componentName) return componentName; + return componentName.charAt(0).toLowerCase() + componentName.slice(1); +} diff --git a/packages/migrate/src/transform.ts b/packages/migrate/src/transform.ts index 9101017..1026bbc 100644 --- a/packages/migrate/src/transform.ts +++ b/packages/migrate/src/transform.ts @@ -3,16 +3,35 @@ import generate from '@babel/generator'; import traverse, { type NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { cssToObjectExpression } from './css'; +import { + parseBooleanTemplateInterpolations, + parseTemplateInterpolations, + placeholderToken, + reconstructInterpolatedCss, + reconstructStaticCssWithoutVariants, + toComponentConstName, + toVarDebugName, +} from './interpolations'; import type { FileMigrationResult, MigrationWarning } from './types'; +import { buildVariantComponentConfig } from './variants'; type StyledTarget = | { kind: 'intrinsic'; jsxName: t.JSXIdentifier } | { kind: 'component'; jsxName: t.JSXIdentifier | t.JSXMemberExpression }; +type PropVarBinding = { + propName: string; + varConstName: string; + suffix: string; +}; + type StyledTransform = { originalName: string; + mode: 'class' | 'component'; classConstName: string; target: StyledTarget; + propVars: PropVarBinding[]; + variantProps: string[]; }; function toKebabCase(input: string): string { @@ -71,13 +90,13 @@ function addWarning(warnings: MigrationWarning[], message: string, nodeName?: st function createMergedClassExpression( existing: t.Expression, - classNameIdentifier: t.Identifier, + classNameExpression: t.Expression, ): t.Expression { return t.callExpression( t.memberExpression( t.callExpression( t.memberExpression( - t.arrayExpression([existing, classNameIdentifier]), + t.arrayExpression([existing, classNameExpression]), t.identifier('filter'), ), [t.identifier('Boolean')], @@ -90,7 +109,7 @@ function createMergedClassExpression( function updateClassNameAttribute( openingElement: t.JSXOpeningElement, - classNameIdentifier: t.Identifier, + classNameExpression: t.Expression, ): void { const existingAttr = openingElement.attributes.find( (attribute): attribute is t.JSXAttribute => @@ -99,19 +118,19 @@ function updateClassNameAttribute( if (!existingAttr) { openingElement.attributes.push( - t.jsxAttribute(t.jsxIdentifier('className'), t.jsxExpressionContainer(classNameIdentifier)), + t.jsxAttribute(t.jsxIdentifier('className'), t.jsxExpressionContainer(classNameExpression)), ); return; } if (!existingAttr.value) { - existingAttr.value = t.jsxExpressionContainer(classNameIdentifier); + existingAttr.value = t.jsxExpressionContainer(classNameExpression); return; } if (t.isStringLiteral(existingAttr.value)) { existingAttr.value = t.jsxExpressionContainer( - createMergedClassExpression(t.stringLiteral(existingAttr.value.value), classNameIdentifier), + createMergedClassExpression(t.stringLiteral(existingAttr.value.value), classNameExpression), ); return; } @@ -120,13 +139,19 @@ function updateClassNameAttribute( existingAttr.value = t.jsxExpressionContainer( createMergedClassExpression( existingAttr.value.expression as t.Expression, - classNameIdentifier, + classNameExpression, ), ); } } -function ensureStylesImport(ast: t.File): void { +function ensureTypestylesImport(ast: t.File, needsVars: boolean): void { + const requiredSpecifiers = new Set(['styles']); + if (needsVars) { + requiredSpecifiers.add('createVar'); + requiredSpecifiers.add('assignVars'); + } + let typestylesImport: t.ImportDeclaration | null = null; for (const statement of ast.program.body) { @@ -136,27 +161,27 @@ function ensureStylesImport(ast: t.File): void { } } + const buildSpecifiers = (names: Set): t.ImportSpecifier[] => + [...names].map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))); + if (!typestylesImport) { ast.program.body.unshift( - t.importDeclaration( - [t.importSpecifier(t.identifier('styles'), t.identifier('styles'))], - t.stringLiteral('typestyles'), - ), + t.importDeclaration(buildSpecifiers(requiredSpecifiers), t.stringLiteral('typestyles')), ); return; } - const hasStylesSpecifier = typestylesImport.specifiers.some( - (specifier) => - t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.imported, { name: 'styles' }) && - t.isIdentifier(specifier.local, { name: 'styles' }), - ); - - if (!hasStylesSpecifier) { - typestylesImport.specifiers.push( - t.importSpecifier(t.identifier('styles'), t.identifier('styles')), + for (const name of requiredSpecifiers) { + const hasSpecifier = typestylesImport.specifiers.some( + (specifier) => + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name }) && + t.isIdentifier(specifier.local, { name }), ); + + if (!hasSpecifier) { + typestylesImport.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name))); + } } } @@ -170,7 +195,9 @@ function cleanupUnusedImports(ast: t.File): void { if ( path.node.source.value === 'typestyles' && t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.local, { name: 'styles' }) + (specifier.local.name === 'styles' || + specifier.local.name === 'createVar' || + specifier.local.name === 'assignVars') ) { return false; } @@ -191,6 +218,248 @@ function cleanupUnusedImports(ast: t.File): void { }); } +function createAssignVarValue(propExpression: t.Expression, suffix: string): t.Expression { + if (!suffix) return propExpression; + + if (t.isStringLiteral(propExpression)) { + return t.stringLiteral(`${propExpression.value}${suffix}`); + } + + return t.binaryExpression('+', propExpression, t.stringLiteral(suffix)); +} + +function buildAssignVarsCall( + propVars: PropVarBinding[], + propValues: Map, +): t.CallExpression { + const properties = propVars + .filter((binding) => propValues.has(binding.propName)) + .map((binding) => { + const propExpression = propValues.get(binding.propName)!; + return t.objectProperty( + t.identifier(binding.varConstName), + createAssignVarValue(propExpression, binding.suffix), + true, + ); + }); + + return t.callExpression(t.identifier('assignVars'), [t.objectExpression(properties)]); +} + +function mergeStyleExpressions( + existing: t.Expression, + assignVarsCall: t.CallExpression, +): t.Expression { + return t.objectExpression([t.spreadElement(existing), t.spreadElement(assignVarsCall)]); +} + +function updateStyleAttribute( + openingElement: t.JSXOpeningElement, + assignVarsCall: t.CallExpression, +): void { + const existingAttr = openingElement.attributes.find( + (attribute): attribute is t.JSXAttribute => + t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' }), + ); + + if (!existingAttr) { + openingElement.attributes.push( + t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(assignVarsCall)), + ); + return; + } + + if (!existingAttr.value) { + existingAttr.value = t.jsxExpressionContainer(assignVarsCall); + return; + } + + if (t.isJSXExpressionContainer(existingAttr.value)) { + existingAttr.value = t.jsxExpressionContainer( + mergeStyleExpressions(existingAttr.value.expression as t.Expression, assignVarsCall), + ); + } +} + +function collectPropValuesFromJsx( + openingElement: t.JSXOpeningElement, + propNames: Set, +): Map { + const values = new Map(); + + for (const attribute of openingElement.attributes) { + if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name)) continue; + if (!propNames.has(attribute.name.name)) continue; + + if (!attribute.value) { + values.set(attribute.name.name, t.booleanLiteral(true)); + continue; + } + + if (t.isStringLiteral(attribute.value)) { + values.set(attribute.name.name, t.stringLiteral(attribute.value.value)); + continue; + } + + if (t.isJSXExpressionContainer(attribute.value)) { + values.set(attribute.name.name, attribute.value.expression as t.Expression); + } + } + + return values; +} + +function removeStyledProps(openingElement: t.JSXOpeningElement, propNames: Set): void { + openingElement.attributes = openingElement.attributes.filter((attribute) => { + if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name)) return true; + return !propNames.has(attribute.name.name); + }); +} + +function buildComponentClassNameExpression( + componentConstName: string, + variantProps: string[], + propValues: Map, +): t.CallExpression { + const properties = variantProps + .filter((propName) => propValues.has(propName)) + .map((propName) => t.objectProperty(t.identifier(propName), propValues.get(propName)!)); + + if (properties.length === 0) { + return t.callExpression(t.identifier(componentConstName), []); + } + + return t.callExpression(t.identifier(componentConstName), [t.objectExpression(properties)]); +} + +function migrateBooleanVariantTemplate( + path: NodePath, + variableName: string, + template: t.TemplateLiteral, + styledTarget: StyledTarget, + warnings: MigrationWarning[], + styledTransforms: Map, +): boolean { + const booleanInterpolations = parseBooleanTemplateInterpolations(template); + if (!booleanInterpolations) return false; + + const staticCss = reconstructStaticCssWithoutVariants(template, booleanInterpolations); + const baseObject = cssToObjectExpression(staticCss, warnings); + if (!baseObject) { + addWarning(warnings, 'Skipped because CSS could not be parsed.', variableName); + return false; + } + + const componentConstName = path.scope.generateUidIdentifier( + toComponentConstName(variableName), + ).name; + const componentConfig = buildVariantComponentConfig(baseObject, booleanInterpolations); + const variantProps = [ + ...new Set(booleanInterpolations.map((interpolation) => interpolation.propName)), + ]; + + const declaration = path.parentPath; + if (!declaration.isVariableDeclaration()) return false; + + declaration.node.declarations = [ + t.variableDeclarator( + t.identifier(componentConstName), + t.callExpression(t.memberExpression(t.identifier('styles'), t.identifier('component')), [ + t.stringLiteral(toKebabCase(variableName)), + componentConfig, + ]), + ), + ]; + + styledTransforms.set(variableName, { + originalName: variableName, + mode: 'component', + classConstName: componentConstName, + target: styledTarget, + propVars: [], + variantProps, + }); + + return true; +} + +function migrateInterpolatedTemplate( + path: NodePath, + variableName: string, + template: t.TemplateLiteral, + styledTarget: StyledTarget, + warnings: MigrationWarning[], + styledTransforms: Map, +): boolean { + const interpolations = parseTemplateInterpolations(template); + if (!interpolations) { + addWarning( + warnings, + 'Skipped template literal with unsupported interpolations. Only prop-based patterns like `${props => props.color}` are migrated.', + variableName, + ); + return false; + } + + const cssText = reconstructInterpolatedCss(template, interpolations); + const propVars: PropVarBinding[] = interpolations.map((interpolation) => ({ + propName: interpolation.propName, + varConstName: path.scope.generateUidIdentifier( + `${variableName}${interpolation.propName.charAt(0).toUpperCase()}${interpolation.propName.slice(1)}Var`, + ).name, + suffix: interpolation.suffix, + })); + + const varReplacements = new Map(); + for (let i = 0; i < interpolations.length; i++) { + varReplacements.set( + placeholderToken(interpolations[i].index), + t.identifier(propVars[i].varConstName), + ); + } + + const objectExpression = cssToObjectExpression(cssText, warnings, varReplacements); + if (!objectExpression) { + addWarning(warnings, 'Skipped because CSS could not be parsed.', variableName); + return false; + } + + const classConstName = path.scope.generateUidIdentifier(`${variableName}Class`).name; + const varDeclarators = propVars.map((propVar) => + t.variableDeclarator( + t.identifier(propVar.varConstName), + t.callExpression(t.identifier('createVar'), [ + t.stringLiteral(toVarDebugName(variableName, propVar.propName)), + ]), + ), + ); + + const declaration = path.parentPath; + if (!declaration.isVariableDeclaration()) return false; + + declaration.node.declarations = [ + ...varDeclarators, + t.variableDeclarator( + t.identifier(classConstName), + t.callExpression(t.memberExpression(t.identifier('styles'), t.identifier('class')), [ + t.stringLiteral(toKebabCase(variableName)), + objectExpression, + ]), + ), + ]; + + styledTransforms.set(variableName, { + originalName: variableName, + mode: 'class', + classConstName, + target: styledTarget, + propVars, + variantProps: [], + }); + + return true; +} + function isOnlyJsxReferences( binding: NodePath['scope']['bindings'][string], ): boolean { @@ -216,6 +485,7 @@ export function migrateSource(filePath: string, source: string): FileMigrationRe const styledNames = new Set(); const cssTagNames = new Set(); const styledTransforms = new Map(); + let needsVars = false; traverse(ast, { ImportDeclaration(path) { @@ -262,12 +532,64 @@ export function migrateSource(filePath: string, source: string): FileMigrationRe if (!binding) return; const template = path.node.init.quasi; - if (template.expressions.length > 0) { - addWarning( - warnings, - 'Skipped template literal with interpolations. Only static templates are migrated.', - variableName, - ); + const hasInterpolations = template.expressions.length > 0; + const styledTarget = parseStyledTarget(path.node.init.tag, styledNames); + + if (hasInterpolations) { + if (!styledTarget) { + addWarning( + warnings, + 'Skipped template literal with interpolations. Only styled-components prop patterns are migrated.', + variableName, + ); + return; + } + + const declaration = path.parentPath; + if ( + declaration.parentPath?.isExportNamedDeclaration() || + declaration.parentPath?.isExportDefaultDeclaration() + ) { + addWarning( + warnings, + 'Skipped exported styled component to avoid changing external API shape.', + variableName, + ); + return; + } + + if (!isOnlyJsxReferences(binding)) { + addWarning(warnings, 'Skipped styled component with non-JSX references.', variableName); + return; + } + + if ( + migrateBooleanVariantTemplate( + path, + variableName, + template, + styledTarget, + warnings, + styledTransforms, + ) + ) { + changed = true; + return; + } + + if ( + migrateInterpolatedTemplate( + path, + variableName, + template, + styledTarget, + warnings, + styledTransforms, + ) + ) { + needsVars = true; + changed = true; + } return; } @@ -278,7 +600,6 @@ export function migrateSource(filePath: string, source: string): FileMigrationRe return; } - const styledTarget = parseStyledTarget(path.node.init.tag, styledNames); if (styledTarget) { const declaration = path.parentPath; if ( @@ -307,8 +628,11 @@ export function migrateSource(filePath: string, source: string): FileMigrationRe styledTransforms.set(variableName, { originalName: variableName, + mode: 'class', classConstName, target: styledTarget, + propVars: [], + variantProps: [], }); changed = true; return; @@ -337,13 +661,37 @@ export function migrateSource(filePath: string, source: string): FileMigrationRe path.node.closingElement.name = transform.target.jsxName; } - updateClassNameAttribute(path.node.openingElement, t.identifier(transform.classConstName)); + const classNameExpression = + transform.mode === 'component' + ? buildComponentClassNameExpression( + transform.classConstName, + transform.variantProps, + collectPropValuesFromJsx(path.node.openingElement, new Set(transform.variantProps)), + ) + : t.identifier(transform.classConstName); + + updateClassNameAttribute(path.node.openingElement, classNameExpression); + + if (transform.propVars.length > 0) { + const propNames = new Set(transform.propVars.map((propVar) => propVar.propName)); + const propValues = collectPropValuesFromJsx(path.node.openingElement, propNames); + + if (propValues.size > 0) { + const assignVarsCall = buildAssignVarsCall(transform.propVars, propValues); + updateStyleAttribute(path.node.openingElement, assignVarsCall); + removeStyledProps(path.node.openingElement, propNames); + } + } + + if (transform.variantProps.length > 0) { + removeStyledProps(path.node.openingElement, new Set(transform.variantProps)); + } }, }); } if (changed) { - ensureStylesImport(ast); + ensureTypestylesImport(ast, needsVars); cleanupUnusedImports(ast); } diff --git a/packages/migrate/src/variants.ts b/packages/migrate/src/variants.ts new file mode 100644 index 0000000..27bf1ba --- /dev/null +++ b/packages/migrate/src/variants.ts @@ -0,0 +1,61 @@ +import * as t from '@babel/types'; +import type { BooleanTernaryInterpolation } from './interpolations'; + +function toCssValueNode(value: string): t.Expression { + if (/^-?\d+(\.\d+)?$/.test(value.trim())) { + return t.numericLiteral(Number(value)); + } + return t.stringLiteral(value); +} + +function toKeyNode(key: string): t.Expression { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) { + return t.identifier(key); + } + return t.stringLiteral(key); +} + +function buildVariantStyles(properties: Record): t.ObjectExpression { + return t.objectExpression( + Object.entries(properties).map(([key, value]) => + t.objectProperty(toKeyNode(key), toCssValueNode(value)), + ), + ); +} + +export function buildVariantComponentConfig( + baseObject: t.ObjectExpression, + booleanInterpolations: BooleanTernaryInterpolation[], +): t.ObjectExpression { + const variantGroups = new Map< + string, + { true: Record; false: Record } + >(); + + for (const interpolation of booleanInterpolations) { + const group = variantGroups.get(interpolation.propName) ?? { true: {}, false: {} }; + group.true[interpolation.cssProperty] = interpolation.trueValue; + group.false[interpolation.cssProperty] = interpolation.falseValue; + variantGroups.set(interpolation.propName, group); + } + + const variantsProperties = [...variantGroups.entries()].map(([propName, values]) => + t.objectProperty( + t.identifier(propName), + t.objectExpression([ + t.objectProperty(t.identifier('true'), buildVariantStyles(values.true)), + t.objectProperty(t.identifier('false'), buildVariantStyles(values.false)), + ]), + ), + ); + + const defaultVariantProperties = [...variantGroups.keys()].map((propName) => + t.objectProperty(t.identifier(propName), t.booleanLiteral(false)), + ); + + return t.objectExpression([ + t.objectProperty(t.identifier('base'), baseObject), + t.objectProperty(t.identifier('variants'), t.objectExpression(variantsProperties)), + t.objectProperty(t.identifier('defaultVariants'), t.objectExpression(defaultVariantProperties)), + ]); +} diff --git a/packages/migrate/test/css.test.ts b/packages/migrate/test/css.test.ts index 5b3e708..c795147 100644 --- a/packages/migrate/test/css.test.ts +++ b/packages/migrate/test/css.test.ts @@ -104,11 +104,28 @@ describe('cssToObjectExpression', () => { expect(typeof result === 'object').toBe(true); }); - it('adds a warning for unsupported CSS node types (e.g. at-rules)', () => { + it('converts @media rules to nested objects', () => { const warnings: MigrationWarning[] = []; - cssToObjectExpression('@media (max-width: 640px) { color: red; }', warnings); - expect(warnings.length).toBeGreaterThan(0); - expect(warnings[0]?.message).toContain('Unsupported CSS node'); + const result = cssToObjectExpression( + 'padding: 16px; @media (max-width: 640px) { padding: 8px; }', + warnings, + ); + expect(result).not.toBeNull(); + expect(warnings).toHaveLength(0); + expect(toPlainObject(result!)).toEqual({ + padding: '16px', + '@media (max-width: 640px)': { padding: '8px' }, + }); + }); + + it('converts @supports rules to nested objects', () => { + const warnings: MigrationWarning[] = []; + const result = cssToObjectExpression('@supports (display: grid) { display: grid; }', warnings); + expect(result).not.toBeNull(); + expect(warnings).toHaveLength(0); + expect(toPlainObject(result!)).toEqual({ + '@supports (display: grid)': { display: 'grid' }, + }); }); it('uses string literal key for properties that are not valid identifiers', () => { diff --git a/packages/migrate/test/fixtures/destructured-input.tsx b/packages/migrate/test/fixtures/destructured-input.tsx new file mode 100644 index 0000000..687563d --- /dev/null +++ b/packages/migrate/test/fixtures/destructured-input.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Button = styled.button` + color: ${({ color }) => color}; +`; + +export function App() { + return