From ea9675c1ba5c0e689b0ee3900ff2e61e5ff8e283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Fri, 14 Aug 2026 11:47:16 -0500 Subject: [PATCH 1/5] feat: add text variants codemod --- .eslintignore | 1 + codemods/README.md | 45 + ...variants-consolidated-components.input.tsx | 39 + ...ariants-consolidated-components.output.tsx | 33 + .../text-variants-heading-name-keys.input.tsx | 30 + ...text-variants-heading-name-keys.output.tsx | 30 + .../text-variants-heading.input.tsx | 20 + .../text-variants-heading.output.tsx | 20 + .../text-variants-idempotence.input.tsx | 14 + .../text-variants-idempotence.output.tsx | 14 + .../text-variants-indirect-heading.input.tsx | 21 + .../text-variants-indirect-heading.output.tsx | 21 + .../text-variants-manual.input.tsx | 61 ++ .../text-variants-manual.output.tsx | 100 ++ .../text-variants-safety.input.tsx | 50 + .../text-variants-safety.output.tsx | 63 ++ .../text-variants-text.input.tsx | 34 + .../text-variants-text.output.tsx | 24 + ...ext-variants-types-and-namespace.input.tsx | 30 + ...xt-variants-types-and-namespace.output.tsx | 52 + codemods/jscodeshift-test-utils.d.ts | 9 + codemods/text-variants.test.ts | 214 ++++ codemods/text-variants.ts | 916 ++++++++++++++++++ package-lock.json | 142 +++ package.json | 4 + tsconfig.json | 1 + 26 files changed, 1988 insertions(+) create mode 100644 codemods/README.md create mode 100644 codemods/__testfixtures__/text-variants-consolidated-components.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-consolidated-components.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-heading-name-keys.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-heading.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-heading.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-idempotence.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-idempotence.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-indirect-heading.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-indirect-heading.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-manual.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-manual.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-safety.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-safety.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-text.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-text.output.tsx create mode 100644 codemods/__testfixtures__/text-variants-types-and-namespace.input.tsx create mode 100644 codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx create mode 100644 codemods/jscodeshift-test-utils.d.ts create mode 100644 codemods/text-variants.test.ts create mode 100644 codemods/text-variants.ts diff --git a/.eslintignore b/.eslintignore index 57bc5417d..12de73853 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,6 +2,7 @@ lint-staged.config.js jest.config.js jest.config.react18.js babel.config.js +codemods/__testfixtures__/ plopfile.mjs release.config.js coverage/ diff --git a/codemods/README.md b/codemods/README.md new file mode 100644 index 000000000..e8fb78f28 --- /dev/null +++ b/codemods/README.md @@ -0,0 +1,45 @@ +# Reactist Text codemod + +Migrates the breaking Text, Heading, and Display APIs to the consolidated Text component and its +named variants. + +## Run + +```bash +npx jscodeshift@17.3.0 \ + --transform ./node_modules/@doist/reactist/codemods/text-variants.ts \ + --extensions js,jsx,ts,tsx \ + --parser tsx \ + src +``` + +Run Prettier and the application's type-check after the transform. + +## Exact mappings + +| Legacy Text size | Regular or omitted | Semibold | Bold | +| ---------------- | ------------------ | ----------- | --------- | +| subtitle | subheader-2 | subheader-1 | Manual | +| body or omitted | body-3 | body-2 | body-1 | +| copy | callout-2 | callout-1 | Manual | +| caption | caption-3 | caption-2 | caption-1 | + +Legacy Heading maps only exact 32px/700 uses to `header-1` and exact 20px/700 uses to `header-3`. +The transform adds `render={}` only when the original level differs from the header variant's +default element. + +Existing named Heading variants map directly from `heading-1` through `heading-4` to `header-1` +through `header-4`. Display variants keep their `display-1` through `display-5` names. Heading and +Display imports are rebound to Text while their local names stay unchanged. + +## Manual migrations + +The transform changes only exact static mappings. Ambiguous size/weight combinations, dynamic +expressions, duplicate props, and prop spreads remain unchanged. Each unresolved use receives a +TODO(reactist-codemod) comment, and the command prints its file and line. Direct Heading JSX uses +still migrate when the same import has indirect references. The indirect statements receive TODOs +for manual migration. Namespace references, removed Heading and Display types, and custom component +values passed to `as` also receive TODOs. + +Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose +the nearest variant: confirm the intended visual hierarchy with design. diff --git a/codemods/__testfixtures__/text-variants-consolidated-components.input.tsx b/codemods/__testfixtures__/text-variants-consolidated-components.input.tsx new file mode 100644 index 000000000..e16469a03 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-consolidated-components.input.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' + +import { + /* display import */ Display, + Display as Hero, + /* heading import */ Heading, + Heading as Title, + Text, +} from '@doist/reactist' + +export function ConsolidatedComponents() { + return ( + <> + + Header 1 + + + Header 2 + + + Header 3 + + + Header 4 + + }> + Custom header + + Display 1 + Display 2 + Display 3 + Display 4 + }> + Display 5 + + Body + + ) +} diff --git a/codemods/__testfixtures__/text-variants-consolidated-components.output.tsx b/codemods/__testfixtures__/text-variants-consolidated-components.output.tsx new file mode 100644 index 000000000..7fdaf667a --- /dev/null +++ b/codemods/__testfixtures__/text-variants-consolidated-components.output.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' + +import { + /* display import */ Text as Display, + Text as Hero, + /* heading import */ Text as Heading, + Text as Title, + Text, +} from '@doist/reactist' + +export function ConsolidatedComponents() { + return ( + <> + Header 1 + Header 2 + Header 3 + }> + Header 4 + + }> + Custom header + + Display 1 + Display 2 + Display 3 + Display 4 + }> + Display 5 + + Body + + ) +} diff --git a/codemods/__testfixtures__/text-variants-heading-name-keys.input.tsx b/codemods/__testfixtures__/text-variants-heading-name-keys.input.tsx new file mode 100644 index 000000000..45f2da76c --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading-name-keys.input.tsx @@ -0,0 +1,30 @@ +import { Heading, Heading as Title } from '@doist/reactist' + +type Labels = { + Heading: string + Title: string +} + +const labels: Labels = { + Heading: 'Heading', + Title: 'Title', +} + +const memberLabels = [labels.Heading, labels.Title] +const { Heading: headingLabel, Title: titleLabel } = labels +const UI = { Heading: 'div', Title: 'span' } + +export function HeadingNameKeys() { + return ( + <> + + {headingLabel} + + {titleLabel} + + +
+ {memberLabels.join(', ')} + + ) +} diff --git a/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx b/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx new file mode 100644 index 000000000..42b0f1b68 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx @@ -0,0 +1,30 @@ +import { Text as Heading, Text as Title } from '@doist/reactist' + +type Labels = { + Heading: string + Title: string +} + +const labels: Labels = { + Heading: 'Heading', + Title: 'Title', +} + +const memberLabels = [labels.Heading, labels.Title] +const { Heading: headingLabel, Title: titleLabel } = labels +const UI = { Heading: 'div', Title: 'span' } + +export function HeadingNameKeys() { + return ( + <> + {headingLabel} + }> + {titleLabel} + + + +
+ {memberLabels.join(', ')} + + ) +} diff --git a/codemods/__testfixtures__/text-variants-heading.input.tsx b/codemods/__testfixtures__/text-variants-heading.input.tsx new file mode 100644 index 000000000..8d502ef23 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading.input.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +import { Heading, Heading as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + + Large + + Current default + + Visual 20 + + + Visual 20 + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-heading.output.tsx b/codemods/__testfixtures__/text-variants-heading.output.tsx new file mode 100644 index 000000000..b6c742903 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading.output.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +import { Text as Heading, Text as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + Large + }> + Current default + + }> + Visual 20 + + }> + Visual 20 + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-idempotence.input.tsx b/codemods/__testfixtures__/text-variants-idempotence.input.tsx new file mode 100644 index 000000000..cfddc70df --- /dev/null +++ b/codemods/__testfixtures__/text-variants-idempotence.input.tsx @@ -0,0 +1,14 @@ +import { Text as Heading } from '@doist/reactist' + +export function HeadingVariants() { + return ( + <> + }> + Page title + + }> + Prominent subsection + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-idempotence.output.tsx b/codemods/__testfixtures__/text-variants-idempotence.output.tsx new file mode 100644 index 000000000..cfddc70df --- /dev/null +++ b/codemods/__testfixtures__/text-variants-idempotence.output.tsx @@ -0,0 +1,14 @@ +import { Text as Heading } from '@doist/reactist' + +export function HeadingVariants() { + return ( + <> + }> + Page title + + }> + Prominent subsection + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-indirect-heading.input.tsx b/codemods/__testfixtures__/text-variants-indirect-heading.input.tsx new file mode 100644 index 000000000..f445957a3 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-indirect-heading.input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +const Title = Heading +const title = React.createElement(Heading, { level: 1, size: 'largest' }, 'Created title') + +export function IndirectHeading() { + return ( + <> + + Direct title + + + Aliased title + + {title} + Body + + ) +} diff --git a/codemods/__testfixtures__/text-variants-indirect-heading.output.tsx b/codemods/__testfixtures__/text-variants-indirect-heading.output.tsx new file mode 100644 index 000000000..4dae63614 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-indirect-heading.output.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { Text as Heading, Text } from '@doist/reactist' + +/* TODO(reactist-codemod): indirect Heading reference */ +const Title = Heading +/* TODO(reactist-codemod): indirect Heading reference */ +const title = React.createElement(Heading, { level: 1, size: 'largest' }, 'Created title') + +export function IndirectHeading() { + return ( + <> + Direct title + + Aliased title + + {title} + Body + + ) +} diff --git a/codemods/__testfixtures__/text-variants-manual.input.tsx b/codemods/__testfixtures__/text-variants-manual.input.tsx new file mode 100644 index 000000000..d9d429300 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-manual.input.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +export function ManualCases({ size, weight, useLabel, level, props }) { + return ( + <> + + No exact match + + Dynamic + Dynamic weight + Dynamic element + Unsupported element + }> + Existing render + + + Duplicate size + + + Duplicate weight + + + Duplicate element + + Spread + + 24px + + 16px + 14px + + 12px + + + Medium + + + Light + + Dynamic level + + Dynamic size + + + Dynamic weight + + Spread + + Spread and dynamic props + + + ) +} + +export function Shadowed() { + const Text = (props: React.ComponentProps<'span'>) => + + return Shadowed Text +} diff --git a/codemods/__testfixtures__/text-variants-manual.output.tsx b/codemods/__testfixtures__/text-variants-manual.output.tsx new file mode 100644 index 000000000..f65a4e52e --- /dev/null +++ b/codemods/__testfixtures__/text-variants-manual.output.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' + +import { Text as Heading, Text } from '@doist/reactist' + +export function ManualCases({ size, weight, useLabel, level, props }) { + return ( + <> + + {/* TODO(reactist-codemod): Text size and weight have no exact variant */} + No exact match + + + {/* TODO(reactist-codemod): dynamic Text size */} + Dynamic + + + {/* TODO(reactist-codemod): dynamic Text weight */} + Dynamic weight + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Dynamic element + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Unsupported element + + }> + {/* TODO(reactist-codemod): Text already has render prop */} + Existing render + + + {/* TODO(reactist-codemod): duplicate Text size props */} + Duplicate size + + + {/* TODO(reactist-codemod): duplicate Text weight props */} + Duplicate weight + + + {/* TODO(reactist-codemod): duplicate Text as props */} + Duplicate element + + + {/* TODO(reactist-codemod): spread props may supply or override text props */} + Spread + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 24px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 16px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 14px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 12px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + Medium + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + Light + + + {/* TODO(reactist-codemod): dynamic Heading level */} + Dynamic level + + + {/* TODO(reactist-codemod): dynamic Heading size */} + Dynamic size + + + {/* TODO(reactist-codemod): dynamic Heading weight */} + Dynamic weight + + + {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level */} + Spread + + + {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level; dynamic Heading size; dynamic Heading weight */} + Spread and dynamic props + + + ) +} + +export function Shadowed() { + const Text = (props: React.ComponentProps<'span'>) => + + return Shadowed Text +} diff --git a/codemods/__testfixtures__/text-variants-safety.input.tsx b/codemods/__testfixtures__/text-variants-safety.input.tsx new file mode 100644 index 000000000..af2e858e6 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-safety.input.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +const anchorRef = React.createRef() + +function RequiredLink({ targetId }: { targetId: string }) { + return +} + +function RequiredChildren({ children }: { children: React.ReactNode }) { + return {children} +} + +function span() { + return +} + +const UI = { Link: RequiredChildren } + +export function SafetyCases() { + return ( + <> + Safe span + + Anchor with props + + + Custom component with required props + + Lowercase variable component + Static member component + + Mixed Text props + + + Mixed Heading props + + } level={1}> + Mixed Heading render props + + + Duplicate Heading variant + + } render={

}> + Duplicate Heading render + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-safety.output.tsx b/codemods/__testfixtures__/text-variants-safety.output.tsx new file mode 100644 index 000000000..1cac9ecc6 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-safety.output.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' + +import { Text as Heading, Text } from '@doist/reactist' + +const anchorRef = React.createRef() + +function RequiredLink({ targetId }: { targetId: string }) { + return +} + +function RequiredChildren({ children }: { children: React.ReactNode }) { + return {children} +} + +function span() { + return +} + +const UI = { Link: RequiredChildren } + +export function SafetyCases() { + return ( + <> + }>Safe span + + {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + Anchor with props + + + {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + Custom component with required props + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Lowercase variable component + + + {/* TODO(reactist-codemod): Text as component requires manual render props */} + Static member component + + + {/* TODO(reactist-codemod): Text mixes variant with legacy size or weight props */} + Mixed Text props + + + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} + Mixed Heading props + + } level={1}> + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} + Mixed Heading render props + + + {/* TODO(reactist-codemod): duplicate Heading variant props */} + Duplicate Heading variant + + } render={

}> + {/* TODO(reactist-codemod): duplicate Heading render props */} + Duplicate Heading render + + + ) +} diff --git a/codemods/__testfixtures__/text-variants-text.input.tsx b/codemods/__testfixtures__/text-variants-text.input.tsx new file mode 100644 index 000000000..c9e9b26b4 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-text.input.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +export function Example() { + return ( + <> + Subheader regular + + Subheader semibold + + Body regular + + Body semibold + + + Body bold + + Callout regular + + Callout semibold + + Caption regular + + Caption semibold + + + Caption bold + + Default body + Default link + + ) +} diff --git a/codemods/__testfixtures__/text-variants-text.output.tsx b/codemods/__testfixtures__/text-variants-text.output.tsx new file mode 100644 index 000000000..8296b5091 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-text.output.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +export function Example() { + return ( + <> + Subheader regular + Subheader semibold + Body regular + Body semibold + }> + Body bold + + Callout regular + Callout semibold + Caption regular + Caption semibold + Caption bold + Default body + }>Default link + + ) +} diff --git a/codemods/__testfixtures__/text-variants-types-and-namespace.input.tsx b/codemods/__testfixtures__/text-variants-types-and-namespace.input.tsx new file mode 100644 index 000000000..ad28e0d21 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-types-and-namespace.input.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' + +import * as Reactist from '@doist/reactist' +import { + type Display, + type DisplayProps, + type DisplayVariant, + type Heading, + type HeadingLevel, + type HeadingProps, + type HeadingVariant, +} from '@doist/reactist' + +export { Heading as PublicHeading, type HeadingProps as PublicHeadingProps } from '@doist/reactist' + +type HeadingComponent = typeof Heading +type NamespacedHeadingProps = Reactist.HeadingProps + +const IndirectHeading = Reactist.Heading +const { Heading: NamespaceHeading, Display: NamespaceDisplay, Text: NamespaceText } = Reactist + +export function NamespaceCases() { + return ( + <> + Caption + Heading + Display + + ) +} diff --git a/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx b/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx new file mode 100644 index 000000000..848573fb3 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' + +import * as Reactist from '@doist/reactist' +import { + type Text as Display, + /* TODO(reactist-codemod): removed DisplayProps type requires manual migration */ + type DisplayProps, + /* TODO(reactist-codemod): removed DisplayVariant type requires manual migration */ + type DisplayVariant, + type Text as Heading, + /* TODO(reactist-codemod): removed HeadingLevel type requires manual migration */ + type HeadingLevel, + /* TODO(reactist-codemod): removed HeadingProps type requires manual migration */ + type HeadingProps, + /* TODO(reactist-codemod): removed HeadingVariant type requires manual migration */ + type HeadingVariant, +} from '@doist/reactist' + +/* TODO(reactist-codemod): re-exported Heading requires manual migration */ +/* TODO(reactist-codemod): re-exported HeadingProps requires manual migration */ +export { Heading as PublicHeading, type HeadingProps as PublicHeadingProps } from '@doist/reactist' + +/* TODO(reactist-codemod): indirect Heading reference */ +type HeadingComponent = typeof Heading +/* TODO(reactist-codemod): namespace HeadingProps type requires manual migration */ +type NamespacedHeadingProps = Reactist.HeadingProps + +/* TODO(reactist-codemod): namespace Heading reference requires manual migration */ +const IndirectHeading = Reactist.Heading +/* TODO(reactist-codemod): namespace Heading destructuring requires manual migration */ +/* TODO(reactist-codemod): namespace Display destructuring requires manual migration */ +/* TODO(reactist-codemod): namespace Text destructuring requires manual migration */ +const { Heading: NamespaceHeading, Display: NamespaceDisplay, Text: NamespaceText } = Reactist + +export function NamespaceCases() { + return ( + <> + + {/* TODO(reactist-codemod): namespace Text reference requires manual migration */} + Caption + + + {/* TODO(reactist-codemod): namespace Heading reference requires manual migration */} + Heading + + + {/* TODO(reactist-codemod): namespace Display reference requires manual migration */} + Display + + + ) +} diff --git a/codemods/jscodeshift-test-utils.d.ts b/codemods/jscodeshift-test-utils.d.ts new file mode 100644 index 000000000..61e044a24 --- /dev/null +++ b/codemods/jscodeshift-test-utils.d.ts @@ -0,0 +1,9 @@ +declare module 'jscodeshift/dist/testUtils' { + import type { Options, Transform } from 'jscodeshift' + + export function applyTransform( + transform: { default: Transform; parser: string }, + options: Options | null, + input: { path?: string; source: string }, + ): string +} diff --git a/codemods/text-variants.test.ts b/codemods/text-variants.test.ts new file mode 100644 index 000000000..9fbfc4ce1 --- /dev/null +++ b/codemods/text-variants.test.ts @@ -0,0 +1,214 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +import jscodeshift from 'jscodeshift' +import { applyTransform } from 'jscodeshift/dist/testUtils' +import * as estree from 'prettier/plugins/estree' +import * as typescript from 'prettier/plugins/typescript' +import * as prettier from 'prettier/standalone' + +import * as transformModule from './text-variants' + +import type { API } from 'jscodeshift' +import type { Plugin } from 'prettier' + +const j = jscodeshift.withParser('tsx') +const transform = transformModule.default +const fixturesDirectory = path.join(__dirname, '__testfixtures__') + +function format(source: string): Promise { + return prettier.format(source, { + parser: 'typescript', + plugins: [typescript, estree as Plugin], + arrowParens: 'always', + printWidth: 100, + semi: false, + singleQuote: true, + tabWidth: 4, + trailingComma: 'all', + }) +} + +async function transformFixture(name: string): Promise { + const inputPath = path.join(fixturesDirectory, name + '.input.tsx') + const source = fs.readFileSync(inputPath, 'utf8') + const output = applyTransform(transformModule, null, { path: inputPath, source }) + const expected = fs.readFileSync(path.join(fixturesDirectory, name + '.output.tsx'), 'utf8') + + expect(await format(output)).toBe(await format(expected)) + return output +} + +function createApi(report = jest.fn()): API { + return { + j, + jscodeshift: j, + report, + stats: jest.fn(), + } +} + +describe('text variants codemod', () => { + describe('Text', () => { + it('maps exact legacy variants', async () => { + expect(await transformFixture('text-variants-text')).toBeTruthy() + }) + + it('marks ambiguous uses for manual migration', async () => { + const output = await transformFixture('text-variants-manual') + + expect(() => j(output)).not.toThrow() + }) + + it('retains unsafe legacy element and mixed props for manual migration', async () => { + const output = await transformFixture('text-variants-safety') + + expect(() => j(output)).not.toThrow() + }) + + it('reports unsafe legacy element and mixed props', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'text-variants-safety.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + transform({ path: 'src/safety.tsx', source }, createApi(report), {}) + + expect(report.mock.calls.map(([message]) => message)).toEqual([ + expect.stringMatching( + /Text as migration requires no props besides size or weight$/, + ), + expect.stringMatching( + /Text as migration requires no props besides size or weight$/, + ), + expect.stringMatching(/dynamic Text as target$/), + expect.stringMatching(/Text as component requires manual render props$/), + expect.stringMatching(/Text mixes variant with legacy size or weight props$/), + expect.stringMatching( + /Heading mixes variant or render with legacy level, size, or weight props$/, + ), + expect.stringMatching( + /Heading mixes variant or render with legacy level, size, or weight props$/, + ), + expect.stringMatching(/duplicate Heading variant props$/), + expect.stringMatching(/duplicate Heading render props$/), + ]) + }) + }) + + describe('Heading and Display', () => { + it('maps exact legacy Heading variants', async () => { + expect(await transformFixture('text-variants-heading')).toBeTruthy() + }) + + it('maps every consolidated variant to Text', async () => { + const output = await transformFixture('text-variants-consolidated-components') + const secondOutput = transform( + { path: 'src/consolidated-components.tsx', source: output }, + createApi(), + {}, + ) + + expect(secondOutput).toBeNull() + }) + + it('migrates direct Heading uses and marks indirect references', async () => { + const output = await transformFixture('text-variants-indirect-heading') + const report = jest.fn() + + const secondOutput = transform( + { path: 'src/indirect-heading.tsx', source: output }, + createApi(report), + {}, + ) + + expect(secondOutput).toBeNull() + expect(report).not.toHaveBeenCalled() + }) + + it('ignores unrelated Heading property and type keys', async () => { + expect(await transformFixture('text-variants-heading-name-keys')).toBeTruthy() + }) + + it('reports indirect Heading references', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'text-variants-indirect-heading.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + transform({ path: 'src/indirect-heading.tsx', source }, createApi(report), {}) + + expect(report.mock.calls.map(([message]) => message)).toEqual([ + expect.stringMatching( + /^src\/indirect-heading\.tsx:\d+ indirect Heading reference$/, + ), + expect.stringMatching( + /^src\/indirect-heading\.tsx:\d+ indirect Heading reference$/, + ), + ]) + }) + + it('marks removed types and namespace references', async () => { + const output = await transformFixture('text-variants-types-and-namespace') + const report = jest.fn() + + const secondOutput = transform( + { path: 'src/types-and-namespace.tsx', source: output }, + createApi(report), + {}, + ) + + expect(output).toContain('type Text as Heading') + expect(secondOutput).toBeNull() + expect(report).not.toHaveBeenCalled() + }) + }) + + describe('repeat runs', () => { + it('leaves consolidated Heading aliases unchanged', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'text-variants-idempotence.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + const output = transform({ path: 'src/idempotence.tsx', source }, createApi(report), {}) + + expect(output).toBeNull() + expect(report).not.toHaveBeenCalled() + }) + + it('leaves reported manual migrations unchanged', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'text-variants-manual.output.tsx'), + 'utf8', + ) + const report = jest.fn() + + const output = transform({ path: 'src/manual.tsx', source }, createApi(report), {}) + + expect(output).toBeNull() + expect(report).not.toHaveBeenCalled() + }) + + it('reports every manual migration with file and line', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'text-variants-manual.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + const output = transform({ path: 'src/manual.tsx', source }, createApi(report), {}) + + expect(report).toHaveBeenCalledTimes(21) + for (const [message] of report.mock.calls) { + expect(message).toMatch(/^src\/manual\.tsx:\d+ /) + } + expect(output).not.toBeNull() + if (output === null) throw new Error('Expected manual migration output') + expect(() => j(output)).not.toThrow() + }) + }) +}) diff --git a/codemods/text-variants.ts b/codemods/text-variants.ts new file mode 100644 index 000000000..ea073703a --- /dev/null +++ b/codemods/text-variants.ts @@ -0,0 +1,916 @@ +import type { + API, + ASTPath, + Collection, + FileInfo, + Identifier, + ImportSpecifier, + JSCodeshift, + JSXAttribute, + JSXElement, + JSXIdentifier, + JSXMemberExpression, + JSXOpeningElement, + MemberExpression, + Node, + Options, +} from 'jscodeshift' + +type NodePath = ASTPath +type Root = Collection +type ElementPath = ASTPath +type RenderName = JSXIdentifier | JSXMemberExpression +type StaticRenderExpression = Identifier | MemberExpression +type ImportSpecifierWithKind = ImportSpecifier & { + importKind?: 'type' | 'typeof' | 'value' +} + +const TEXT_VARIANTS: Record> = { + subtitle: { regular: 'subheader-2', semibold: 'subheader-1' }, + body: { regular: 'body-3', semibold: 'body-2', bold: 'body-1' }, + copy: { regular: 'callout-2', semibold: 'callout-1' }, + caption: { regular: 'caption-3', semibold: 'caption-2', bold: 'caption-1' }, +} + +const HEADING_SIZES: Record> = { + 1: { default: 20, smaller: 16, larger: 24, largest: 32 }, + 2: { default: 16, smaller: 14, larger: 20, largest: 24 }, + 3: { default: 14, smaller: 12, larger: 16, largest: 20 }, + 4: { default: 14, smaller: 14, larger: 16, largest: 20 }, + 5: { default: 14, smaller: 14, larger: 16, largest: 20 }, + 6: { default: 14, smaller: 14, larger: 16, largest: 20 }, +} + +const HEADING_WEIGHTS: Record = { + regular: 700, + medium: 600, + light: 400, +} + +const HEADING_VARIANTS: Record = { + '32:700': 'header-1', + '20:700': 'header-3', +} + +const NAMED_HEADING_VARIANTS: Record = { + 'heading-1': 'header-1', + 'heading-2': 'header-2', + 'heading-3': 'header-3', + 'heading-4': 'header-4', + 'header-1': 'header-1', + 'header-2': 'header-2', + 'header-3': 'header-3', + 'header-4': 'header-4', +} + +const REMOVED_TYPE_IMPORTS = new Set([ + 'DisplayProps', + 'DisplayVariant', + 'HeadingLevel', + 'HeadingProps', + 'HeadingVariant', +]) + +const LEGACY_NAMESPACE_MEMBERS = new Set(['Display', 'Heading', 'Text']) + +const DYNAMIC = Symbol('dynamic') + +function getImportedNames(root: Root, j: JSCodeshift, importedName: string): Set { + const names = new Set() + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === importedName + ) { + names.add( + specifier.local?.type === 'Identifier' ? specifier.local.name : importedName, + ) + } + } + }) + return names +} + +function getNamespaceNames(root: Root, j: JSCodeshift): Set { + const names = new Set() + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type === 'ImportNamespaceSpecifier' && + specifier.local?.type === 'Identifier' + ) { + names.add(specifier.local.name) + } + } + }) + return names +} + +function isImportedBinding(path: NodePath, name: string, importedName: string): boolean { + // ast-types exposes scope and parent paths as `any`. + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ + const bindings = path.scope.lookup(name)?.getBindings()[name] ?? [] + + return bindings.some( + (binding: NodePath) => + binding.parent?.node.type === 'ImportSpecifier' && + binding.parent.parent?.node.type === 'ImportDeclaration' && + binding.parent.parent.node.source.value === '@doist/reactist' && + binding.parent.node.imported.type === 'Identifier' && + binding.parent.node.imported.name === importedName, + ) + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +} + +function isImportedNamespaceBinding(path: NodePath, name: string): boolean { + // ast-types exposes scope and parent paths as `any`. + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ + const bindings = path.scope.lookup(name)?.getBindings()[name] ?? [] + + return bindings.some( + (binding: NodePath) => + binding.parent?.node.type === 'ImportNamespaceSpecifier' && + binding.parent.parent?.node.type === 'ImportDeclaration' && + binding.parent.parent.node.source.value === '@doist/reactist', + ) + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +} + +function isDirectJSXReference(path: NodePath): boolean { + // ast-types exposes parent paths as `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return ( + path.name === 'name' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + ['JSXOpeningElement', 'JSXClosingElement'].includes(path.parent?.node.type) + ) +} + +function isNonReferenceIdentifier(path: NodePath): boolean { + // ast-types exposes parent paths as `any`. + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ + const parent = path.parent?.node + if (!parent) return false + + if (parent.type === 'ImportSpecifier') return true + if ( + parent.type === 'ExportSpecifier' && + (path.name === 'exported' || path.parent?.parent?.node.source) + ) { + return true + } + if (parent.type === 'JSXAttribute' && path.name === 'name') return true + if (parent.type === 'JSXMemberExpression' && path.name === 'property') return true + if (parent.type === 'JSXNamespacedName') return true + if ( + ['MemberExpression', 'OptionalMemberExpression'].includes(parent.type) && + path.name === 'property' && + !parent.computed + ) { + return true + } + if ( + [ + 'ClassMethod', + 'ClassProperty', + 'MethodDefinition', + 'ObjectMethod', + 'ObjectProperty', + 'ObjectTypeProperty', + 'Property', + 'PropertyDefinition', + 'TSMethodSignature', + 'TSPropertySignature', + ].includes(parent.type) && + path.name === 'key' && + !parent.computed + ) { + return true + } + if (parent.type === 'TSQualifiedName' && path.name === 'right') return true + if (parent.type === 'QualifiedTypeIdentifier' && path.name === 'id') return true + if (parent.type === 'TSEnumMember' && path.name === 'id' && !parent.computed) return true + if (parent.type === 'LabeledStatement' && path.name === 'label') return true + if (['BreakStatement', 'ContinueStatement'].includes(parent.type) && path.name === 'label') { + return true + } + + return false + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ +} + +function markStatement( + j: JSCodeshift, + api: API, + file: FileInfo, + path: NodePath, + reason: string, +): boolean { + // ast-types exposes parent paths as `any`. + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ + let statementPath = path + while ( + statementPath.parent?.node && + !['BlockStatement', 'Program'].includes(statementPath.parent.node.type) + ) { + statementPath = statementPath.parent + } + + const marker = 'TODO(reactist-codemod): ' + reason + const alreadyMarked = statementPath.node.comments?.some((comment) => + comment.value.includes(marker), + ) + if (alreadyMarked) return false + + statementPath.node.comments = [ + ...(statementPath.node.comments ?? []), + j.commentBlock(' ' + marker + ' ', true, false), + ] + const line = path.node.loc?.start.line ?? 1 + api.report?.(file.path + ':' + line + ' ' + reason) + return true + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ +} + +function markIndirectHeadingReferences( + root: Root, + j: JSCodeshift, + api: API, + file: FileInfo, + headingNames: Set, +): boolean { + let changed = false + + headingNames.forEach((name) => { + root.find(j.Identifier, { name }).forEach((path) => { + if (!isImportedBinding(path, name, 'Heading')) return + if (isNonReferenceIdentifier(path) || isDirectJSXReference(path)) return + changed = markStatement(j, api, file, path, 'indirect Heading reference') || changed + }) + }) + + return changed +} + +function markRemovedTypeImports(root: Root, j: JSCodeshift, api: API, file: FileInfo): boolean { + let changed = false + + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type !== 'ImportSpecifier' || + specifier.imported.type !== 'Identifier' || + !REMOVED_TYPE_IMPORTS.has(specifier.imported.name) + ) { + continue + } + + const reason = 'removed ' + specifier.imported.name + ' type requires manual migration' + const marker = 'TODO(reactist-codemod): ' + reason + if (specifier.comments?.some((comment) => comment.value.includes(marker))) continue + + specifier.comments = [ + ...(specifier.comments ?? []), + j.commentBlock(' ' + marker + ' ', true, false), + ] + const line = specifier.loc?.start.line ?? 1 + api.report?.(file.path + ':' + line + ' ' + reason) + changed = true + } + }) + + return changed +} + +function markLegacyReexports(root: Root, j: JSCodeshift, api: API, file: FileInfo): boolean { + let changed = false + + root.find(j.ExportNamedDeclaration, { source: { value: '@doist/reactist' } }).forEach( + (path) => { + for (const specifier of path.node.specifiers ?? []) { + if (specifier.type !== 'ExportSpecifier') continue + const local = specifier.local + if (local?.type !== 'Identifier' || typeof local.name !== 'string') continue + + const name = local.name + if (!REMOVED_TYPE_IMPORTS.has(name) && !['Display', 'Heading'].includes(name)) + continue + + changed = + markStatement( + j, + api, + file, + path, + 're-exported ' + name + ' requires manual migration', + ) || changed + } + }, + ) + + return changed +} + +function markNamespaceDestructuring( + root: Root, + j: JSCodeshift, + api: API, + file: FileInfo, + namespaceNames: Set, +): boolean { + let changed = false + + root.find(j.VariableDeclarator).forEach((path) => { + if (path.node.init?.type !== 'Identifier' || path.node.id.type !== 'ObjectPattern') return + const namespaceName = path.node.init.name + if (!namespaceNames.has(namespaceName)) return + if (!isImportedNamespaceBinding(path, namespaceName)) return + + for (const property of path.node.id.properties) { + if ( + (property.type !== 'ObjectProperty' && property.type !== 'Property') || + property.key.type !== 'Identifier' || + typeof property.key.name !== 'string' || + !LEGACY_NAMESPACE_MEMBERS.has(property.key.name) + ) { + continue + } + + changed = + markStatement( + j, + api, + file, + path, + 'namespace ' + property.key.name + ' destructuring requires manual migration', + ) || changed + } + }) + + return changed +} + +function getMemberPropertyName(path: ASTPath): string | undefined { + const { property, computed } = path.node + if (!computed && property.type === 'Identifier') return property.name + if ( + computed && + (property.type === 'StringLiteral' || property.type === 'Literal') && + typeof property.value === 'string' + ) { + return property.value + } + return undefined +} + +function markIndirectNamespaceReferences( + root: Root, + j: JSCodeshift, + api: API, + file: FileInfo, + namespaceNames: Set, +): boolean { + let changed = false + + root.find(j.MemberExpression).forEach((path) => { + if (path.node.object.type !== 'Identifier') return + const namespaceName = path.node.object.name + if (!namespaceNames.has(namespaceName)) return + if (!isImportedNamespaceBinding(path, namespaceName)) return + + const propertyName = getMemberPropertyName(path) + if (!propertyName || !LEGACY_NAMESPACE_MEMBERS.has(propertyName)) return + + changed = + markStatement( + j, + api, + file, + path, + 'namespace ' + propertyName + ' reference requires manual migration', + ) || changed + }) + + root.find(j.TSQualifiedName).forEach((path) => { + if (path.node.left.type !== 'Identifier' || path.node.right.type !== 'Identifier') return + const namespaceName = path.node.left.name + const propertyName = path.node.right.name + if (!namespaceNames.has(namespaceName)) return + if (!isImportedNamespaceBinding(path, namespaceName)) return + if ( + !REMOVED_TYPE_IMPORTS.has(propertyName) && + !LEGACY_NAMESPACE_MEMBERS.has(propertyName) + ) { + return + } + + changed = + markStatement( + j, + api, + file, + path, + 'namespace ' + propertyName + ' type requires manual migration', + ) || changed + }) + + return changed +} + +function getAttribute(openingElement: JSXOpeningElement, name: string): JSXAttribute | undefined { + return (openingElement.attributes ?? []).find( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ) as JSXAttribute | undefined +} + +function getDuplicateAttributes(openingElement: JSXOpeningElement, names: string[]): string[] { + return names.filter( + (name) => + (openingElement.attributes ?? []).filter( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ).length > 1, + ) +} + +function hasSpread(openingElement: JSXOpeningElement): boolean { + return (openingElement.attributes ?? []).some( + (attribute) => attribute.type === 'JSXSpreadAttribute', + ) +} + +function readStaticString( + attribute: JSXAttribute | undefined, + fallback: string | typeof DYNAMIC, +): string | typeof DYNAMIC { + if (!attribute) return fallback + if (!attribute.value) return DYNAMIC + if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') { + return String(attribute.value.value) + } + if (attribute.value.type !== 'JSXExpressionContainer') return DYNAMIC + + const expression = attribute.value.expression + if ( + expression.type === 'StringLiteral' || + (expression.type === 'Literal' && typeof expression.value === 'string') + ) { + return String(expression.value) + } + return DYNAMIC +} + +function readStaticLevel(attribute: JSXAttribute | undefined): number | typeof DYNAMIC { + const value = readStaticString(attribute, DYNAMIC) + if (value !== DYNAMIC && /^[1-6]$/.test(value)) return Number(value) + + if (attribute?.value?.type !== 'JSXExpressionContainer') return DYNAMIC + const expression = attribute.value.expression + if ( + (expression.type === 'NumericLiteral' || expression.type === 'Literal') && + typeof expression.value === 'number' && + Number.isInteger(expression.value) && + expression.value >= 1 && + expression.value <= 6 + ) { + return Number(expression.value) + } + + return DYNAMIC +} + +function removeAttributes(openingElement: JSXOpeningElement, names: string[]): void { + openingElement.attributes = (openingElement.attributes ?? []).filter( + (attribute) => + attribute.type !== 'JSXAttribute' || + attribute.name.type !== 'JSXIdentifier' || + !names.includes(attribute.name.name), + ) +} + +function addVariant(j: JSCodeshift, openingElement: JSXOpeningElement, variant: string): void { + removeAttributes(openingElement, ['size', 'weight']) + const attributes = openingElement.attributes ?? [] + openingElement.attributes = attributes + const levelIndex = attributes.findIndex( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === 'level', + ) + const insertionIndex = levelIndex >= 0 ? levelIndex + 1 : 0 + attributes.splice( + insertionIndex, + 0, + j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant)), + ) +} + +function replaceHeadingProps( + j: JSCodeshift, + openingElement: JSXOpeningElement, + variant: string, + level: number | undefined, +): void { + removeAttributes(openingElement, ['level', 'size', 'weight', 'variant']) + + const attributes = [j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant))] + if (level && variant !== 'header-' + level) { + const renderName = j.jsxIdentifier('h' + level) + const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, []) + attributes.push( + j.jsxAttribute(j.jsxIdentifier('render'), j.jsxExpressionContainer(renderElement)), + ) + } + openingElement.attributes = openingElement.attributes ?? [] + openingElement.attributes.unshift(...attributes) +} + +function replaceMergedComponentImports(root: Root, j: JSCodeshift): boolean { + let changed = false + + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + path.node.specifiers = (path.node.specifiers ?? []).map((specifier) => { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + ['Display', 'Heading'].includes(specifier.imported.name) + ) { + const localName = + specifier.local?.type === 'Identifier' + ? specifier.local.name + : specifier.imported.name + const replacement = j.importSpecifier(j.identifier('Text'), j.identifier(localName)) + ;(replacement as ImportSpecifierWithKind).importKind = ( + specifier as ImportSpecifierWithKind + ).importKind + replacement.comments = specifier.comments + replacement.imported.comments = specifier.imported.comments + if (replacement.local) replacement.local.comments = specifier.local?.comments + changed = true + return replacement + } + return specifier + }) + }) + + return changed +} + +function toJSXName(j: JSCodeshift, expression: StaticRenderExpression): RenderName | null { + if (expression.type === 'Identifier') return j.jsxIdentifier(expression.name) + if (expression.type === 'MemberExpression' && !expression.computed) { + const objectExpression = expression.object + if ( + objectExpression.type !== 'Identifier' && + objectExpression.type !== 'MemberExpression' + ) { + return null + } + const object = toJSXName(j, objectExpression) + if (!object || expression.property.type !== 'Identifier') return null + return j.jsxMemberExpression(object, j.jsxIdentifier(expression.property.name)) + } + return null +} + +function getStaticRenderName( + j: JSCodeshift, + attribute: JSXAttribute | undefined, +): RenderName | null { + if (!attribute?.value) return null + if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') { + return j.jsxIdentifier(String(attribute.value.value)) + } + if (attribute.value.type !== 'JSXExpressionContainer') return null + + const expression = attribute.value.expression + if ( + expression.type === 'StringLiteral' || + (expression.type === 'Literal' && typeof expression.value === 'string') + ) { + return j.jsxIdentifier(String(expression.value)) + } + if (expression.type === 'Identifier' && /^[A-Z]/.test(expression.name)) { + return j.jsxIdentifier(expression.name) + } + if (expression.type === 'MemberExpression' && !expression.computed) { + return toJSXName(j, expression) + } + return null +} + +function isComponentAsTarget(attribute: JSXAttribute): boolean { + if (attribute.value?.type !== 'JSXExpressionContainer') return false + const { expression } = attribute.value + return expression.type === 'Identifier' || expression.type === 'MemberExpression' +} + +function hasAttributesOtherThan(openingElement: JSXOpeningElement, names: string[]): boolean { + return (openingElement.attributes ?? []).some( + (attribute) => + attribute.type === 'JSXSpreadAttribute' || + (attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + !names.includes(attribute.name.name)), + ) +} + +function replaceAsWithRender( + j: JSCodeshift, + asAttribute: JSXAttribute, + renderName: RenderName, +): void { + const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, []) + asAttribute.name = j.jsxIdentifier('render') + asAttribute.value = j.jsxExpressionContainer(renderElement) +} + +function hasManualMarker(path: ElementPath): boolean { + return (path.node.children ?? []).some( + (child) => + child.type === 'JSXExpressionContainer' && + child.expression.type === 'JSXEmptyExpression' && + child.expression.comments?.some((comment) => + comment.value.includes('TODO(reactist-codemod)'), + ), + ) +} + +function markManual( + j: JSCodeshift, + api: API, + file: FileInfo, + path: ElementPath, + reasons: string[], +): void { + const message = ' TODO(reactist-codemod): ' + reasons.join('; ') + ' ' + const alreadyMarked = hasManualMarker(path) + + if (!alreadyMarked) { + const emptyExpression = j.jsxEmptyExpression() + emptyExpression.comments = [j.commentBlock(message)] + path.node.children = path.node.children ?? [] + path.node.children.unshift( + j.jsxText('\n'), + j.jsxExpressionContainer(emptyExpression), + j.jsxText('\n'), + ) + + if (path.node.openingElement.selfClosing) { + const name = path.node.openingElement.name + path.node.openingElement.selfClosing = false + path.node.closingElement = j.jsxClosingElement(name) + } + } + + const line = path.node.loc?.start.line ?? 1 + api.report?.(file.path + ':' + line + ' ' + reasons.join('; ')) +} + +function markNamespaceJSXReferences( + root: Root, + j: JSCodeshift, + api: API, + file: FileInfo, + namespaceNames: Set, +): boolean { + let changed = false + + root.find(j.JSXElement).forEach((path) => { + if (hasManualMarker(path)) return + const name = path.node.openingElement.name + if ( + name.type !== 'JSXMemberExpression' || + name.object.type !== 'JSXIdentifier' || + name.property.type !== 'JSXIdentifier' + ) { + return + } + + const namespaceName = name.object.name + const propertyName = name.property.name + if (typeof namespaceName !== 'string' || typeof propertyName !== 'string') return + if (!namespaceNames.has(namespaceName)) return + if (!LEGACY_NAMESPACE_MEMBERS.has(propertyName)) return + if (!isImportedNamespaceBinding(path, namespaceName)) return + + markManual(j, api, file, path, [ + 'namespace ' + propertyName + ' reference requires manual migration', + ]) + changed = true + }) + + return changed +} + +function transformTextElement( + j: JSCodeshift, + api: API, + file: FileInfo, + path: ElementPath, +): boolean { + if (hasManualMarker(path)) return false + + const openingElement = path.node.openingElement + const hasVariant = Boolean(getAttribute(openingElement, 'variant')) + const legacySizeAttribute = getAttribute(openingElement, 'size') + const legacyWeightAttribute = getAttribute(openingElement, 'weight') + const reasons = [] + if (hasSpread(openingElement)) reasons.push('spread props may supply or override text props') + for (const name of getDuplicateAttributes(openingElement, ['size', 'weight', 'as'])) { + reasons.push('duplicate Text ' + name + ' props') + } + + if (hasVariant && (legacySizeAttribute || legacyWeightAttribute)) { + reasons.push('Text mixes variant with legacy size or weight props') + } + + const sizeAttribute = hasVariant ? undefined : legacySizeAttribute + const weightAttribute = hasVariant ? undefined : legacyWeightAttribute + const asAttribute = getAttribute(openingElement, 'as') + const renderAttribute = getAttribute(openingElement, 'render') + const size = readStaticString(sizeAttribute, 'body') + const weight = readStaticString(weightAttribute, 'regular') + + if (size === DYNAMIC) reasons.push('dynamic Text size') + if (weight === DYNAMIC) reasons.push('dynamic Text weight') + + const variant = + size === DYNAMIC || weight === DYNAMIC ? undefined : TEXT_VARIANTS[size]?.[weight] + if (!hasVariant && (sizeAttribute || weightAttribute) && !variant && reasons.length === 0) { + reasons.push('Text size and weight have no exact variant') + } + + const renderName = asAttribute ? getStaticRenderName(j, asAttribute) : undefined + if (asAttribute && !renderName) { + reasons.push('dynamic Text as target') + } else if ( + asAttribute && + renderName && + isComponentAsTarget(asAttribute) && + !hasAttributesOtherThan(openingElement, ['as', 'size', 'weight']) + ) { + reasons.push('Text as component requires manual render props') + } else if ( + asAttribute && + !renderAttribute && + hasAttributesOtherThan(openingElement, ['as', 'size', 'weight']) + ) { + reasons.push('Text as migration requires no props besides size or weight') + } + if (asAttribute && renderAttribute) reasons.push('Text already has render prop') + + if (reasons.length > 0) { + markManual(j, api, file, path, reasons) + return true + } + if (!hasVariant && (sizeAttribute || weightAttribute)) { + addVariant(j, openingElement, variant!) + } + if (asAttribute && renderName) { + replaceAsWithRender(j, asAttribute, renderName) + } + return ( + Boolean(!hasVariant && (sizeAttribute || weightAttribute)) || + Boolean(asAttribute && renderName) + ) +} + +function transformHeadingElement( + j: JSCodeshift, + api: API, + file: FileInfo, + path: ElementPath, +): boolean { + if (hasManualMarker(path)) return false + + const openingElement = path.node.openingElement + const duplicateAttributes = getDuplicateAttributes(openingElement, ['variant', 'render']) + if (duplicateAttributes.length > 0) { + markManual( + j, + api, + file, + path, + duplicateAttributes.map((name) => 'duplicate Heading ' + name + ' props'), + ) + return true + } + + const variantAttribute = getAttribute(openingElement, 'variant') + const renderAttribute = getAttribute(openingElement, 'render') + const hasLegacyVariantProps = ['size', 'weight'].some((name) => + Boolean(getAttribute(openingElement, name)), + ) + const hasLegacyRenderProps = ['level', 'size', 'weight'].some((name) => + Boolean(getAttribute(openingElement, name)), + ) + + if ((variantAttribute && hasLegacyVariantProps) || (renderAttribute && hasLegacyRenderProps)) { + markManual(j, api, file, path, [ + 'Heading mixes variant or render with legacy level, size, or weight props', + ]) + return true + } + + const reasons = [] + if (hasSpread(openingElement)) { + reasons.push('spread props may supply or override text props') + } + for (const name of getDuplicateAttributes(openingElement, ['level', 'size', 'weight'])) { + reasons.push('duplicate Heading ' + name + ' props') + } + + const levelAttribute = getAttribute(openingElement, 'level') + const sizeAttribute = getAttribute(openingElement, 'size') + const weightAttribute = getAttribute(openingElement, 'weight') + const level = levelAttribute ? readStaticLevel(levelAttribute) : undefined + const size = readStaticString(sizeAttribute, 'default') + const weight = readStaticString(weightAttribute, 'regular') + + if (!variantAttribute && level === undefined) reasons.push('dynamic Heading level') + if (level === DYNAMIC) reasons.push('dynamic Heading level') + if (size === DYNAMIC) reasons.push('dynamic Heading size') + if (weight === DYNAMIC) reasons.push('dynamic Heading weight') + + let variant + if (variantAttribute) { + const namedVariant = readStaticString(variantAttribute, DYNAMIC) + if (namedVariant === DYNAMIC) { + reasons.push('dynamic Heading variant') + } else { + variant = NAMED_HEADING_VARIANTS[namedVariant] + if (!variant) reasons.push('Heading variant has no Text equivalent') + } + } else if (renderAttribute) { + reasons.push('Heading render requires a static variant') + } else if (reasons.length === 0) { + const fontSize = + typeof level === 'number' && typeof size === 'string' + ? HEADING_SIZES[level]?.[size] + : undefined + const fontWeight = typeof weight === 'string' ? HEADING_WEIGHTS[weight] : undefined + variant = HEADING_VARIANTS[fontSize + ':' + fontWeight] + if (!variant) reasons.push('Heading metrics have no exact variant') + } + + if (reasons.length > 0) { + markManual(j, api, file, path, reasons) + return true + } + + if (!variant) { + markManual(j, api, file, path, ['Heading variant has no Text equivalent']) + return true + } + + replaceHeadingProps( + j, + openingElement, + variant, + typeof level === 'number' ? level : renderAttribute ? undefined : 1, + ) + return true +} + +function transform(file: FileInfo, api: API, _options: Options): string | null { + const j = api.jscodeshift + const root = j(file.source) as Root + const textNames = getImportedNames(root, j, 'Text') + const headingNames = getImportedNames(root, j, 'Heading') + const namespaceNames = getNamespaceNames(root, j) + let changed = markIndirectHeadingReferences(root, j, api, file, headingNames) + changed = markRemovedTypeImports(root, j, api, file) || changed + changed = markLegacyReexports(root, j, api, file) || changed + changed = markNamespaceDestructuring(root, j, api, file, namespaceNames) || changed + changed = markIndirectNamespaceReferences(root, j, api, file, namespaceNames) || changed + changed = markNamespaceJSXReferences(root, j, api, file, namespaceNames) || changed + + root.find(j.JSXElement).forEach((path) => { + const openingElement = path.node.openingElement + if (openingElement.name.type !== 'JSXIdentifier') return + + const { name } = openingElement.name + if (typeof name !== 'string') return + if (textNames.has(name) && isImportedBinding(path, name, 'Text')) { + changed = transformTextElement(j, api, file, path) || changed + } else if (headingNames.has(name) && isImportedBinding(path, name, 'Heading')) { + changed = transformHeadingElement(j, api, file, path) || changed + } + }) + + changed = replaceMergedComponentImports(root, j) || changed + + return changed ? root.toSource({ quote: 'single' }) : null +} + +export const parser = 'tsx' +export default transform diff --git a/package-lock.json b/package-lock.json index bc44dafd0..9a77f1953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "@types/classnames": "2.3.4", "@types/jest": "28.1.8", "@types/jest-axe": "3.5.9", + "@types/jscodeshift": "17.3.0", "@types/marked": "4.3.2", "@types/react": "19.2.18", "@types/react-18": "npm:@types/react@18.3.31", @@ -78,6 +79,7 @@ "jest-axe": "5.0.1", "jest-environment-jsdom": "28.1.3", "jest-watch-typeahead": "1.1.0", + "jscodeshift": "17.3.0", "less": "4.9.0", "lint-staged": "10.5.4", "marked": "4.3.0", @@ -869,6 +871,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", @@ -1374,6 +1392,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", @@ -2152,6 +2187,24 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz", + "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-flow-strip-types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -6689,6 +6742,17 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, + "node_modules/@types/jscodeshift": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/@types/jscodeshift/-/jscodeshift-17.3.0.tgz", + "integrity": "sha512-ogvGG8VQQqAQQ096uRh+d6tBHrYuZjsumHirKtvBa5qEyTMN3IQJ7apo+sw9lxaB/iKWIhbbLlF3zmAWk9XQIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "recast": "^0.23.11" + } + }, "node_modules/@types/jsdom": { "version": "16.2.15", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.15.tgz", @@ -11720,6 +11784,29 @@ "dev": true, "license": "ISC" }, + "node_modules/flow-estree": { + "version": "0.328.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.328.0.tgz", + "integrity": "sha512-uMB3dC4nfZYn+dd7/PYkyAK1mqBR4TJ4TWRdOjubpb/ObrLvPFdFSmMTq55fqttCCTygQ4NtD1Zdd+wcjC5t0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/flow-parser": { + "version": "0.328.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.328.0.tgz", + "integrity": "sha512-F+Ik2Of7ndl2FyPmZ77NrhbZp9lYjIsreDXeq4fo0sjxa3SIFv48QAszBqvPeMMzmw7JzqySJUxGR8CSD7EdXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flow-estree": "0.328.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", @@ -15593,6 +15680,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jscodeshift": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.3.0.tgz", + "integrity": "sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/preset-flow": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@babel/register": "^7.24.6", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.7", + "neo-async": "^2.5.0", + "picocolors": "^1.0.1", + "recast": "^0.23.11", + "tmp": "^0.2.3", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + }, + "peerDependenciesMeta": { + "@babel/preset-env": { + "optional": true + } + } + }, "node_modules/jsdom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", @@ -26543,6 +26671,20 @@ "dev": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", diff --git a/package.json b/package.json index 6299ac3fd..6c3126c5f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "CONTRIBUTING.md", "LICENSE", "README.md", + "codemods/README.md", + "codemods/text-variants.ts", "dist/**", "es/**", "lib/**", @@ -102,6 +104,7 @@ "@types/classnames": "2.3.4", "@types/jest": "28.1.8", "@types/jest-axe": "3.5.9", + "@types/jscodeshift": "17.3.0", "@types/marked": "4.3.2", "@types/react": "19.2.18", "@types/react-18": "npm:@types/react@18.3.31", @@ -130,6 +133,7 @@ "jest-axe": "5.0.1", "jest-environment-jsdom": "28.1.3", "jest-watch-typeahead": "1.1.0", + "jscodeshift": "17.3.0", "less": "4.9.0", "lint-staged": "10.5.4", "marked": "4.3.0", diff --git a/tsconfig.json b/tsconfig.json index 8f7f35193..aebc7c909 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": [ "src", "types", + "codemods/*.ts", ".storybook", ".storybook/components/**/*", ".storybook/figma/**/*", From a09d4d5b2137fb70146fe1dc9466e8f0a7405cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Fri, 14 Aug 2026 15:46:09 -0500 Subject: [PATCH 2/5] feat: expand text codemod mappings --- codemods/README.md | 55 ++++++++++++------- .../text-variants-heading.input.tsx | 33 +++++++++++ .../text-variants-heading.output.tsx | 37 +++++++++++++ .../text-variants-manual.input.tsx | 11 ---- .../text-variants-manual.output.tsx | 20 ------- .../text-variants-text.input.tsx | 6 ++ .../text-variants-text.output.tsx | 2 + codemods/text-variants.test.ts | 6 +- codemods/text-variants.ts | 14 ++++- 9 files changed, 129 insertions(+), 55 deletions(-) diff --git a/codemods/README.md b/codemods/README.md index e8fb78f28..cc815c282 100644 --- a/codemods/README.md +++ b/codemods/README.md @@ -15,18 +15,35 @@ npx jscodeshift@17.3.0 \ Run Prettier and the application's type-check after the transform. -## Exact mappings - -| Legacy Text size | Regular or omitted | Semibold | Bold | -| ---------------- | ------------------ | ----------- | --------- | -| subtitle | subheader-2 | subheader-1 | Manual | -| body or omitted | body-3 | body-2 | body-1 | -| copy | callout-2 | callout-1 | Manual | -| caption | caption-3 | caption-2 | caption-1 | - -Legacy Heading maps only exact 32px/700 uses to `header-1` and exact 20px/700 uses to `header-3`. -The transform adds `render={}` only when the original level differs from the header variant's -default element. +## Mappings + +| Legacy Text size | Regular or omitted | Semibold | Bold | +| ---------------- | ------------------ | ----------- | ----------- | +| subtitle | subheader-2 | subheader-1 | subheader-1 | +| body or omitted | body-3 | body-2 | body-1 | +| copy | callout-2 | callout-1 | callout-1 | +| caption | caption-3 | caption-2 | caption-1 | + +Bold subtitle and copy text use the nearest named variant and change from 700 to 600 weight. Other +Text mappings preserve size and weight. + +| Legacy Heading metrics | Text variant | +| ---------------------- | ------------ | +| 32px/700 | header-1 | +| 24px/700 | header-2 | +| 20px/700 | header-3 | +| 16px/700 or 16px/600 | subheader-1 | +| 16px/400 | subheader-2 | +| 14px/700 | body-1 | +| 14px/600 | body-2 | +| 14px/400 | body-3 | +| 12px/700 | caption-1 | +| 12px/600 | caption-2 | +| 12px/400 | caption-3 | + +The 24px/700 mapping changes to 26px/700. The 16px/700 mapping changes to 16px/600. Other Heading +mappings preserve size and weight. The transform preserves the original semantic heading level +with `render={}` when the variant does not render that element by default. Existing named Heading variants map directly from `heading-1` through `heading-4` to `header-1` through `header-4`. Display variants keep their `display-1` through `display-5` names. Heading and @@ -34,12 +51,12 @@ Display imports are rebound to Text while their local names stay unchanged. ## Manual migrations -The transform changes only exact static mappings. Ambiguous size/weight combinations, dynamic -expressions, duplicate props, and prop spreads remain unchanged. Each unresolved use receives a -TODO(reactist-codemod) comment, and the command prints its file and line. Direct Heading JSX uses -still migrate when the same import has indirect references. The indirect statements receive TODOs -for manual migration. Namespace references, removed Heading and Display types, and custom component -values passed to `as` also receive TODOs. +The transform changes only the documented static mappings. Unsupported size/weight combinations, +dynamic expressions, duplicate props, and prop spreads remain unchanged. Each unresolved use +receives a TODO(reactist-codemod) comment, and the command prints its file and line. Direct Heading +JSX uses still migrate when the same import has indirect references. The indirect statements +receive TODOs for manual migration. Namespace references, removed Heading and Display types, and +custom component values passed to `as` also receive TODOs. Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose -the nearest variant: confirm the intended visual hierarchy with design. +an undocumented nearest variant: confirm the intended visual hierarchy with design. diff --git a/codemods/__testfixtures__/text-variants-heading.input.tsx b/codemods/__testfixtures__/text-variants-heading.input.tsx index 8d502ef23..9c1e02bdc 100644 --- a/codemods/__testfixtures__/text-variants-heading.input.tsx +++ b/codemods/__testfixtures__/text-variants-heading.input.tsx @@ -15,6 +15,39 @@ export function ExactHeadings() { Visual 20 + + Header 2 rendered as h1 + + + Header 2 rendered as h2 + + Subheader 1 from bold + + Subheader 1 from medium + + + Subheader 2 + + Body 1 + + Body 2 + + Body 1 rendered as h5 + + Subheader 1 rendered as h6 + + + Body 3 + + + Caption 1 + + + Caption 2 + + + Caption 3 + ) } diff --git a/codemods/__testfixtures__/text-variants-heading.output.tsx b/codemods/__testfixtures__/text-variants-heading.output.tsx index b6c742903..2edaaa79a 100644 --- a/codemods/__testfixtures__/text-variants-heading.output.tsx +++ b/codemods/__testfixtures__/text-variants-heading.output.tsx @@ -15,6 +15,43 @@ export function ExactHeadings() { }> Visual 20 + }> + Header 2 rendered as h1 + + Header 2 rendered as h2 + }> + Subheader 1 from bold + + }> + Subheader 1 from medium + + }> + Subheader 2 + + }> + Body 1 + + }> + Body 2 + + }> + Body 1 rendered as h5 + + }> + Subheader 1 rendered as h6 + + }> + Body 3 + + }> + Caption 1 + + }> + Caption 2 + + }> + Caption 3 + ) } diff --git a/codemods/__testfixtures__/text-variants-manual.input.tsx b/codemods/__testfixtures__/text-variants-manual.input.tsx index d9d429300..4b98e4459 100644 --- a/codemods/__testfixtures__/text-variants-manual.input.tsx +++ b/codemods/__testfixtures__/text-variants-manual.input.tsx @@ -5,9 +5,6 @@ import { Heading, Text } from '@doist/reactist' export function ManualCases({ size, weight, useLabel, level, props }) { return ( <> - - No exact match - Dynamic Dynamic weight Dynamic element @@ -25,14 +22,6 @@ export function ManualCases({ size, weight, useLabel, level, props }) { Duplicate element Spread - - 24px - - 16px - 14px - - 12px - Medium diff --git a/codemods/__testfixtures__/text-variants-manual.output.tsx b/codemods/__testfixtures__/text-variants-manual.output.tsx index f65a4e52e..1216aee0e 100644 --- a/codemods/__testfixtures__/text-variants-manual.output.tsx +++ b/codemods/__testfixtures__/text-variants-manual.output.tsx @@ -5,10 +5,6 @@ import { Text as Heading, Text } from '@doist/reactist' export function ManualCases({ size, weight, useLabel, level, props }) { return ( <> - - {/* TODO(reactist-codemod): Text size and weight have no exact variant */} - No exact match - {/* TODO(reactist-codemod): dynamic Text size */} Dynamic @@ -45,22 +41,6 @@ export function ManualCases({ size, weight, useLabel, level, props }) { {/* TODO(reactist-codemod): spread props may supply or override text props */} Spread - - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - 24px - - - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - 16px - - - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - 14px - - - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - 12px - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} Medium diff --git a/codemods/__testfixtures__/text-variants-text.input.tsx b/codemods/__testfixtures__/text-variants-text.input.tsx index c9e9b26b4..a2e1b1c51 100644 --- a/codemods/__testfixtures__/text-variants-text.input.tsx +++ b/codemods/__testfixtures__/text-variants-text.input.tsx @@ -9,6 +9,9 @@ export function Example() { Subheader semibold + + Subheader bold + Body regular Body semibold @@ -20,6 +23,9 @@ export function Example() { Callout semibold + + Callout bold + Caption regular Caption semibold diff --git a/codemods/__testfixtures__/text-variants-text.output.tsx b/codemods/__testfixtures__/text-variants-text.output.tsx index 8296b5091..cd6f26663 100644 --- a/codemods/__testfixtures__/text-variants-text.output.tsx +++ b/codemods/__testfixtures__/text-variants-text.output.tsx @@ -7,6 +7,7 @@ export function Example() { <> Subheader regular Subheader semibold + Subheader bold Body regular Body semibold }> @@ -14,6 +15,7 @@ export function Example() { Callout regular Callout semibold + Callout bold Caption regular Caption semibold Caption bold diff --git a/codemods/text-variants.test.ts b/codemods/text-variants.test.ts index 9fbfc4ce1..fc373d376 100644 --- a/codemods/text-variants.test.ts +++ b/codemods/text-variants.test.ts @@ -50,7 +50,7 @@ function createApi(report = jest.fn()): API { describe('text variants codemod', () => { describe('Text', () => { - it('maps exact legacy variants', async () => { + it('maps documented legacy variants', async () => { expect(await transformFixture('text-variants-text')).toBeTruthy() }) @@ -98,7 +98,7 @@ describe('text variants codemod', () => { }) describe('Heading and Display', () => { - it('maps exact legacy Heading variants', async () => { + it('maps documented legacy Heading variants', async () => { expect(await transformFixture('text-variants-heading')).toBeTruthy() }) @@ -202,7 +202,7 @@ describe('text variants codemod', () => { const output = transform({ path: 'src/manual.tsx', source }, createApi(report), {}) - expect(report).toHaveBeenCalledTimes(21) + expect(report).toHaveBeenCalledTimes(16) for (const [message] of report.mock.calls) { expect(message).toMatch(/^src\/manual\.tsx:\d+ /) } diff --git a/codemods/text-variants.ts b/codemods/text-variants.ts index ea073703a..80aacfd81 100644 --- a/codemods/text-variants.ts +++ b/codemods/text-variants.ts @@ -26,9 +26,9 @@ type ImportSpecifierWithKind = ImportSpecifier & { } const TEXT_VARIANTS: Record> = { - subtitle: { regular: 'subheader-2', semibold: 'subheader-1' }, + subtitle: { regular: 'subheader-2', semibold: 'subheader-1', bold: 'subheader-1' }, body: { regular: 'body-3', semibold: 'body-2', bold: 'body-1' }, - copy: { regular: 'callout-2', semibold: 'callout-1' }, + copy: { regular: 'callout-2', semibold: 'callout-1', bold: 'callout-1' }, caption: { regular: 'caption-3', semibold: 'caption-2', bold: 'caption-1' }, } @@ -49,7 +49,17 @@ const HEADING_WEIGHTS: Record = { const HEADING_VARIANTS: Record = { '32:700': 'header-1', + '24:700': 'header-2', '20:700': 'header-3', + '16:700': 'subheader-1', + '16:600': 'subheader-1', + '16:400': 'subheader-2', + '14:700': 'body-1', + '14:600': 'body-2', + '14:400': 'body-3', + '12:700': 'caption-1', + '12:600': 'caption-2', + '12:400': 'caption-3', } const NAMED_HEADING_VARIANTS: Record = { From 053883d6850709b3c51666b167c851bd8c68bdc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Fri, 14 Aug 2026 17:28:17 -0500 Subject: [PATCH 3/5] feat: automate text codemod migrations --- codemods/README.md | 47 +- ...text-variants-heading-name-keys.output.tsx | 4 +- .../text-variants-heading.output.tsx | 62 +- .../text-variants-manual.output.tsx | 60 +- .../text-variants-safety.output.tsx | 27 +- ...xt-variants-types-and-namespace.output.tsx | 18 +- codemods/text-variants.test.ts | 385 ++++++++- codemods/text-variants.ts | 764 +++++++++++++++--- 8 files changed, 1126 insertions(+), 241 deletions(-) diff --git a/codemods/README.md b/codemods/README.md index cc815c282..9e46ddf64 100644 --- a/codemods/README.md +++ b/codemods/README.md @@ -5,6 +5,27 @@ named variants. ## Run +First, run a strict dry run. It exits with an error if parsing fails or manual work remains. + +```bash +npx jscodeshift@17.3.0 \ + --dry \ + --run-in-band \ + --verbose=0 \ + --fail-on-error \ + --fail-on-manual=true \ + --transform ./node_modules/@doist/reactist/codemods/text-variants.ts \ + --extensions js,jsx,ts,tsx \ + --parser tsx \ + src +``` + +The dry-run summary groups every manual migration by reason. Remove +`--fail-on-manual=true` when you want to inspect the automatic changes before you resolve the +remaining cases. + +Apply the transform after you review the dry run: + ```bash npx jscodeshift@17.3.0 \ --transform ./node_modules/@doist/reactist/codemods/text-variants.ts \ @@ -47,16 +68,28 @@ with `render={}` when the variant does not render that element by default. Existing named Heading variants map directly from `heading-1` through `heading-4` to `header-1` through `header-4`. Display variants keep their `display-1` through `display-5` names. Heading and -Display imports are rebound to Text while their local names stay unchanged. +Display imports with only direct, fully migrated JSX uses merge into one Text import. Aliased, +commented, indirect, or unresolved bindings keep their local names. + +Literal conditional expressions also migrate when every branch has an exact mapping. This includes +an `undefined` branch, which uses the legacy default. The transform does not trace variables or +infer values across files. + +Static Text `as` values migrate to `render`. Props for the rendered element move into that element. +Text styling props, `key`, and `ref` stay on Text. Dynamic targets, existing `render` props, and prop +spreads remain manual. ## Manual migrations -The transform changes only the documented static mappings. Unsupported size/weight combinations, -dynamic expressions, duplicate props, and prop spreads remain unchanged. Each unresolved use -receives a TODO(reactist-codemod) comment, and the command prints its file and line. Direct Heading -JSX uses still migrate when the same import has indirect references. The indirect statements -receive TODOs for manual migration. Namespace references, removed Heading and Display types, and -custom component values passed to `as` also receive TODOs. +The transform changes only documented mappings. Unsupported size/weight combinations, non-finite +dynamic expressions, duplicate props, and prop spreads remain unchanged. Each unresolved use gets +a nearby TODO(reactist-codemod) comment, and the command prints its file and line. Direct Heading JSX +uses still migrate when the same import has indirect references. The indirect statements get TODOs +for manual migration. Namespace references and removed Heading and Display types also get TODOs. +References such as `TextProps['size']` and `Pick` get precise TODOs +because the replacement type depends on the consumer API. + +The transform supports imports from `@doist/reactist`. Deep imports are outside its scope. Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose an undocumented nearest variant: confirm the intended visual hierarchy with design. diff --git a/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx b/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx index 42b0f1b68..4d71faab4 100644 --- a/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx +++ b/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx @@ -1,4 +1,4 @@ -import { Text as Heading, Text as Title } from '@doist/reactist' +import { Text, Text as Title } from '@doist/reactist' type Labels = { Heading: string @@ -17,7 +17,7 @@ const UI = { Heading: 'div', Title: 'span' } export function HeadingNameKeys() { return ( <> - {headingLabel} + {headingLabel} }> {titleLabel} diff --git a/codemods/__testfixtures__/text-variants-heading.output.tsx b/codemods/__testfixtures__/text-variants-heading.output.tsx index 2edaaa79a..02e0708f5 100644 --- a/codemods/__testfixtures__/text-variants-heading.output.tsx +++ b/codemods/__testfixtures__/text-variants-heading.output.tsx @@ -1,57 +1,57 @@ import * as React from 'react' -import { Text as Heading, Text as Title } from '@doist/reactist' +import { Text, Text as Title } from '@doist/reactist' export function ExactHeadings() { return ( <> - Large - }> + Large + }> Current default - + }> Visual 20 - }> + }> Visual 20 - - }> + + }> Header 2 rendered as h1 - - Header 2 rendered as h2 - }> + + Header 2 rendered as h2 + }> Subheader 1 from bold - - }> + + }> Subheader 1 from medium - - }> + + }> Subheader 2 - - }> + + }> Body 1 - - }> + + }> Body 2 - - }> + + }> Body 1 rendered as h5 - - }> + + }> Subheader 1 rendered as h6 - - }> + + }> Body 3 - - }> + + }> Caption 1 - - }> + + }> Caption 2 - - }> + + }> Caption 3 - + ) } diff --git a/codemods/__testfixtures__/text-variants-manual.output.tsx b/codemods/__testfixtures__/text-variants-manual.output.tsx index 1216aee0e..5d8c5630a 100644 --- a/codemods/__testfixtures__/text-variants-manual.output.tsx +++ b/codemods/__testfixtures__/text-variants-manual.output.tsx @@ -5,68 +5,54 @@ import { Text as Heading, Text } from '@doist/reactist' export function ManualCases({ size, weight, useLabel, level, props }) { return ( <> - - {/* TODO(reactist-codemod): dynamic Text size */} - Dynamic - - - {/* TODO(reactist-codemod): dynamic Text weight */} - Dynamic weight - - - {/* TODO(reactist-codemod): dynamic Text as target */} - Dynamic element - - - {/* TODO(reactist-codemod): dynamic Text as target */} - Unsupported element - + {/* TODO(reactist-codemod): dynamic Text size */} + Dynamic + {/* TODO(reactist-codemod): dynamic Text weight */} + Dynamic weight + {/* TODO(reactist-codemod): dynamic Text as target */} + Dynamic element + {/* TODO(reactist-codemod): dynamic Text as target */} + Unsupported element + {/* TODO(reactist-codemod): Text already has render prop */} }> - {/* TODO(reactist-codemod): Text already has render prop */} Existing render + {/* TODO(reactist-codemod): duplicate Text size props */} - {/* TODO(reactist-codemod): duplicate Text size props */} Duplicate size + {/* TODO(reactist-codemod): duplicate Text weight props */} - {/* TODO(reactist-codemod): duplicate Text weight props */} Duplicate weight + {/* TODO(reactist-codemod): duplicate Text as props */} - {/* TODO(reactist-codemod): duplicate Text as props */} Duplicate element - - {/* TODO(reactist-codemod): spread props may supply or override text props */} - Spread - + {/* TODO(reactist-codemod): spread props may supply or override text props */} + Spread + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} Medium + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} - {/* TODO(reactist-codemod): Heading metrics have no exact variant */} Light - - {/* TODO(reactist-codemod): dynamic Heading level */} - Dynamic level - + {/* TODO(reactist-codemod): dynamic Heading level */} + Dynamic level + {/* TODO(reactist-codemod): dynamic Heading size */} - {/* TODO(reactist-codemod): dynamic Heading size */} Dynamic size + {/* TODO(reactist-codemod): dynamic Heading weight */} - {/* TODO(reactist-codemod): dynamic Heading weight */} Dynamic weight - - {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level */} - Spread - + {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level */} + Spread + {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level; dynamic Heading size; dynamic Heading weight */} - {/* TODO(reactist-codemod): spread props may supply or override text props; dynamic Heading level; dynamic Heading size; dynamic Heading weight */} Spread and dynamic props diff --git a/codemods/__testfixtures__/text-variants-safety.output.tsx b/codemods/__testfixtures__/text-variants-safety.output.tsx index 1cac9ecc6..9b7a3e2fc 100644 --- a/codemods/__testfixtures__/text-variants-safety.output.tsx +++ b/codemods/__testfixtures__/text-variants-safety.output.tsx @@ -22,40 +22,33 @@ export function SafetyCases() { return ( <> }>Safe span - - {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + } ref={anchorRef}> Anchor with props - - {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + }> Custom component with required props - - {/* TODO(reactist-codemod): dynamic Text as target */} - Lowercase variable component - - - {/* TODO(reactist-codemod): Text as component requires manual render props */} - Static member component - + {/* TODO(reactist-codemod): dynamic Text as target */} + Lowercase variable component + }>Static member component + {/* TODO(reactist-codemod): Text mixes variant with legacy size or weight props */} - {/* TODO(reactist-codemod): Text mixes variant with legacy size or weight props */} Mixed Text props + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} - {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} Mixed Heading props + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} } level={1}> - {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} Mixed Heading render props + {/* TODO(reactist-codemod): duplicate Heading variant props */} - {/* TODO(reactist-codemod): duplicate Heading variant props */} Duplicate Heading variant + {/* TODO(reactist-codemod): duplicate Heading render props */} } render={

}> - {/* TODO(reactist-codemod): duplicate Heading render props */} Duplicate Heading render diff --git a/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx b/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx index 848573fb3..cdd4e0769 100644 --- a/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx +++ b/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx @@ -35,18 +35,12 @@ const { Heading: NamespaceHeading, Display: NamespaceDisplay, Text: NamespaceTex export function NamespaceCases() { return ( <> - - {/* TODO(reactist-codemod): namespace Text reference requires manual migration */} - Caption - - - {/* TODO(reactist-codemod): namespace Heading reference requires manual migration */} - Heading - - - {/* TODO(reactist-codemod): namespace Display reference requires manual migration */} - Display - + {/* TODO(reactist-codemod): namespace Text reference requires manual migration */} + Caption + {/* TODO(reactist-codemod): namespace Heading reference requires manual migration */} + Heading + {/* TODO(reactist-codemod): namespace Display reference requires manual migration */} + Display ) } diff --git a/codemods/text-variants.test.ts b/codemods/text-variants.test.ts index fc373d376..62358fd26 100644 --- a/codemods/text-variants.test.ts +++ b/codemods/text-variants.test.ts @@ -39,15 +39,23 @@ async function transformFixture(name: string): Promise { return output } -function createApi(report = jest.fn()): API { +function createApi(report = jest.fn(), stats = jest.fn()): API { return { j, jscodeshift: j, report, - stats: jest.fn(), + stats, } } +async function transformSource(source: string, options = {}): Promise { + const output = transform({ path: 'src/example.tsx', source }, createApi(), options) + + expect(output).not.toBeNull() + if (output === null) throw new Error('Expected transformed output') + return format(output) +} + describe('text variants codemod', () => { describe('Text', () => { it('maps documented legacy variants', async () => { @@ -60,7 +68,7 @@ describe('text variants codemod', () => { expect(() => j(output)).not.toThrow() }) - it('retains unsafe legacy element and mixed props for manual migration', async () => { + it('migrates static as targets and retains ambiguous uses', async () => { const output = await transformFixture('text-variants-safety') expect(() => j(output)).not.toThrow() @@ -76,14 +84,7 @@ describe('text variants codemod', () => { transform({ path: 'src/safety.tsx', source }, createApi(report), {}) expect(report.mock.calls.map(([message]) => message)).toEqual([ - expect.stringMatching( - /Text as migration requires no props besides size or weight$/, - ), - expect.stringMatching( - /Text as migration requires no props besides size or weight$/, - ), expect.stringMatching(/dynamic Text as target$/), - expect.stringMatching(/Text as component requires manual render props$/), expect.stringMatching(/Text mixes variant with legacy size or weight props$/), expect.stringMatching( /Heading mixes variant or render with legacy level, size, or weight props$/, @@ -95,6 +96,136 @@ describe('text variants codemod', () => { expect.stringMatching(/duplicate Heading render props$/), ]) }) + + it('moves element props into render for static as targets', async () => { + const source = ` + import * as React from 'react' + import { Box, Text } from '@doist/reactist' + + const labelRef = React.createRef() + + export function Example() { + return ( + <> + + Name + + + Box + + + + ) + } + ` + const expected = ` + import * as React from 'react' + import { Box, Text } from '@doist/reactist' + + const labelRef = React.createRef() + + export function Example() { + return ( + <> + } + tone="secondary" + ref={labelRef} + > + Name + + }> + Box + + } children="Child" /> + + ) + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('maps finite conditional size and weight expressions', async () => { + const source = ` + import { Text } from '@doist/reactist' + + export function Example({ compact, selected, strong }) { + return ( + <> + Size + Weight + + Optional weight + + + ) + } + ` + const expected = ` + import { Text } from '@doist/reactist' + + export function Example({ compact, selected, strong }) { + return ( + <> + Size + Weight + + Optional weight + + + ) + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('maps two finite conditional props without repeating their tests', async () => { + const source = ` + import { Text } from '@doist/reactist' + + export function Example({ compact, selected }) { + return ( + + Copy + + ) + } + ` + const expected = ` + import { Text } from '@doist/reactist' + + export function Example({ compact, selected }) { + return ( + + Copy + + ) + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) }) describe('Heading and Display', () => { @@ -141,12 +272,8 @@ describe('text variants codemod', () => { transform({ path: 'src/indirect-heading.tsx', source }, createApi(report), {}) expect(report.mock.calls.map(([message]) => message)).toEqual([ - expect.stringMatching( - /^src\/indirect-heading\.tsx:\d+ indirect Heading reference$/, - ), - expect.stringMatching( - /^src\/indirect-heading\.tsx:\d+ indirect Heading reference$/, - ), + expect.stringMatching(/^line \d+: indirect Heading reference$/), + expect.stringMatching(/^line \d+: indirect Heading reference$/), ]) }) @@ -164,6 +291,228 @@ describe('text variants codemod', () => { expect(secondOutput).toBeNull() expect(report).not.toHaveBeenCalled() }) + + it('maps finite conditional Heading sizes', async () => { + const source = ` + import { Heading } from '@doist/reactist' + + export function Example({ large }) { + return Title + } + ` + const expected = ` + import { Text } from '@doist/reactist' + + export function Example({ large }) { + return ( + }> + Title + + ) + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('uses one Text import for direct Heading and Display JSX', async () => { + const source = ` + import { Display, Heading, Text } from '@doist/reactist' + + export function Example() { + return ( + <> + Title + Hero + Body + + ) + } + ` + const expected = ` + import { Text } from '@doist/reactist' + + export function Example() { + return ( + <> + }>Title + Hero + Body + + ) + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('merges direct components when Text uses a separate import declaration', async () => { + const source = ` + import { Heading } from '@doist/reactist' + import { Text } from '@doist/reactist' + + export const Example = () => ( + <> + Title + Body + + ) + ` + const expected = ` + import { Text } from '@doist/reactist' + + export const Example = () => ( + <> + }>Title + Body + + ) + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('removes an unused legacy import without adding a conflicting Text import', async () => { + const source = ` + import { Heading } from '@doist/reactist' + + const Text = () => null + export { Text } + ` + const expected = ` + const Text = () => null + export { Text } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + }) + + describe('manual migration reports', () => { + it('keeps self-closing JSX intact when it adds a TODO', () => { + const source = ` + import { Text } from '@doist/reactist' + + export function Example({ size }) { + return + } + ` + const output = transform({ path: 'src/example.tsx', source }, createApi(), {}) + + expect(output).toContain('TODO(reactist-codemod): dynamic Text size') + expect(output).toContain('') + expect(output).not.toContain('') + }) + + it('reports legacy TextProps property references precisely', () => { + const source = ` + import { type TextProps } from '@doist/reactist' + + type Size = TextProps['size'] + type Props = Pick + type Tone = TextProps['tone'] + ` + const report = jest.fn() + + const output = transform({ path: 'src/types.ts', source }, createApi(report), {}) + + expect(output).toContain( + "TODO(reactist-codemod): TextProps['size'] uses a removed Text prop", + ) + expect(output).toContain( + 'TODO(reactist-codemod): Pick includes removed size and weight props', + ) + expect(report.mock.calls.map(([message]) => message)).toEqual([ + expect.stringMatching(/TextProps\['size'\] uses a removed Text prop$/), + expect.stringMatching(/Pick includes removed size and weight props$/), + ]) + }) + + it('records manual totals and reason categories', () => { + const source = ` + import { Text } from '@doist/reactist' + export const Example = ({ size }) => + ` + const stats = jest.fn() + + transform({ path: 'src/example.tsx', source }, createApi(jest.fn(), stats), {}) + + expect(stats.mock.calls).toEqual([ + ['manual migrations'], + ['manual reason: dynamic Text size'], + ]) + }) + + it('can fail a dry run when manual migrations remain', () => { + const source = ` + import { Text } from '@doist/reactist' + export const Example = ({ size }) => + ` + + expect(() => + transform({ path: 'src/example.tsx', source }, createApi(), { + 'fail-on-manual': true, + }), + ).toThrow('1 manual migration remains in src/example.tsx') + }) + + it('fails strict repeat runs while an existing TODO remains', () => { + const source = ` + import { Text } from '@doist/reactist' + export const Example = ({ size }) => ( + /* TODO(reactist-codemod): dynamic Text size */ + + ) + ` + + expect(() => + transform({ path: 'src/example.tsx', source }, createApi(), { + 'fail-on-manual': true, + }), + ).toThrow('1 manual migration remains in src/example.tsx') + }) + + it('fails strict runs for existing TODOs after the Reactist import is removed', () => { + const source = ` + /* TODO(reactist-codemod): dynamic Text size */ + export const size = 'body' + ` + + expect(() => + transform({ path: 'src/example.ts', source }, createApi(), { + 'fail-on-manual': true, + }), + ).toThrow('1 manual migration remains in src/example.ts') + }) + }) + + describe('source filtering', () => { + it('does not parse files that cannot use the transform', () => { + const invalidTypeScript = ` + declare module '*.svg' { + export { ReactComponent } + } + ` + + expect( + transform( + { path: 'src/@types/global/assets/svg.d.ts', source: invalidTypeScript }, + createApi(), + {}, + ), + ).toBeNull() + }) + + it('does not parse invalid files that only mention Reactist in data', () => { + const invalidTypeScript = ` + const packageName = '@doist/reactist' + export { ReactComponent } + ` + + expect( + transform({ path: 'src/data.ts', source: invalidTypeScript }, createApi(), {}), + ).toBeNull() + }) }) describe('repeat runs', () => { @@ -193,7 +542,7 @@ describe('text variants codemod', () => { expect(report).not.toHaveBeenCalled() }) - it('reports every manual migration with file and line', () => { + it('reports every manual migration with its line', () => { const source = fs.readFileSync( path.join(fixturesDirectory, 'text-variants-manual.input.tsx'), 'utf8', @@ -204,7 +553,7 @@ describe('text variants codemod', () => { expect(report).toHaveBeenCalledTimes(16) for (const [message] of report.mock.calls) { - expect(message).toMatch(/^src\/manual\.tsx:\d+ /) + expect(message).toMatch(/^line \d+: /) } expect(output).not.toBeNull() if (output === null) throw new Error('Expected manual migration output') diff --git a/codemods/text-variants.ts b/codemods/text-variants.ts index 80aacfd81..892c00466 100644 --- a/codemods/text-variants.ts +++ b/codemods/text-variants.ts @@ -2,18 +2,29 @@ import type { API, ASTPath, Collection, + ConditionalExpression, + Expression, FileInfo, Identifier, ImportSpecifier, JSCodeshift, JSXAttribute, JSXElement, + JSXFragment, JSXIdentifier, JSXMemberExpression, JSXOpeningElement, + Literal, MemberExpression, Node, Options, + ParenthesizedExpression, + StringLiteral, + TemplateLiteral, + TSAsExpression, + TSLiteralType, + TSTypeAssertion, + TSUnionType, } from 'jscodeshift' type NodePath = ASTPath @@ -21,6 +32,11 @@ type Root = Collection type ElementPath = ASTPath type RenderName = JSXIdentifier | JSXMemberExpression type StaticRenderExpression = Identifier | MemberExpression +type MappedVariantExpression = StringLiteral | ConditionalExpression +type ManualReporter = { + readonly count: number + report(line: number, reasons: string[]): void +} type ImportSpecifierWithKind = ImportSpecifier & { importKind?: 'type' | 'typeof' | 'value' } @@ -82,9 +98,100 @@ const REMOVED_TYPE_IMPORTS = new Set([ ]) const LEGACY_NAMESPACE_MEMBERS = new Set(['Display', 'Heading', 'Text']) +const REMOVED_TEXT_PROP_NAMES = new Set(['as', 'size', 'weight']) + +const TEXT_OWNED_PROPS = new Set([ + 'align', + 'case', + 'children', + 'decoration', + 'exceptionallySetClassName', + 'key', + 'lineClamp', + 'ref', + 'tone', + 'variant', +]) const DYNAMIC = Symbol('dynamic') +function hasRootReactistImport(source: string): boolean { + let importStatement = '' + let blockComment = false + let braceDepth = 0 + + for (const rawLine of source.split(/\r?\n/)) { + let line = '' + for (let index = 0; index < rawLine.length; index += 1) { + const pair = rawLine.slice(index, index + 2) + if (blockComment) { + if (pair === '*/') { + blockComment = false + index += 1 + } + continue + } + if (pair === '/*') { + blockComment = true + index += 1 + continue + } + if (pair === '//') break + line += rawLine[index] + } + + const trimmedLine = line.trim() + if (!importStatement) { + if (!/^(?:import|export)\b/.test(trimmedLine)) continue + importStatement = trimmedLine + } else { + importStatement += ' ' + trimmedLine + } + + for (const character of trimmedLine) { + if (character === '{') braceDepth += 1 + if (character === '}') braceDepth -= 1 + } + if (/(?:\bfrom\s*|^import\s*)['"]@doist\/reactist['"]/.test(importStatement)) { + return true + } + if (braceDepth <= 0) importStatement = '' + } + + return false +} + +function getExistingManualReasons(source: string): string[] { + return Array.from(source.matchAll(/TODO\(reactist-codemod\): ([^*\n]+)/g), (match) => + match[1]?.trim(), + ).filter((reason): reason is string => Boolean(reason)) +} + +function recordManualStats(api: API, reasons: string[]): void { + api.stats?.('manual migrations') + for (const reason of reasons) { + for (const category of reason.split('; ')) { + api.stats?.('manual reason: ' + category) + } + } +} + +function createManualReporter(api: API, existingReasons: string[]): ManualReporter { + let count = existingReasons.length + for (const reason of existingReasons) recordManualStats(api, [reason]) + + return { + get count() { + return count + }, + report(line, reasons) { + count += 1 + recordManualStats(api, reasons) + api.report?.('line ' + line + ': ' + reasons.join('; ')) + }, + } +} + function getImportedNames(root: Root, j: JSCodeshift, importedName: string): Set { const names = new Set() root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { @@ -213,8 +320,7 @@ function isNonReferenceIdentifier(path: NodePath): boolean { function markStatement( j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, path: NodePath, reason: string, ): boolean { @@ -239,7 +345,7 @@ function markStatement( j.commentBlock(' ' + marker + ' ', true, false), ] const line = path.node.loc?.start.line ?? 1 - api.report?.(file.path + ':' + line + ' ' + reason) + reporter.report(line, [reason]) return true /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ } @@ -247,8 +353,7 @@ function markStatement( function markIndirectHeadingReferences( root: Root, j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, headingNames: Set, ): boolean { let changed = false @@ -257,14 +362,14 @@ function markIndirectHeadingReferences( root.find(j.Identifier, { name }).forEach((path) => { if (!isImportedBinding(path, name, 'Heading')) return if (isNonReferenceIdentifier(path) || isDirectJSXReference(path)) return - changed = markStatement(j, api, file, path, 'indirect Heading reference') || changed + changed = markStatement(j, reporter, path, 'indirect Heading reference') || changed }) }) return changed } -function markRemovedTypeImports(root: Root, j: JSCodeshift, api: API, file: FileInfo): boolean { +function markRemovedTypeImports(root: Root, j: JSCodeshift, reporter: ManualReporter): boolean { let changed = false root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { @@ -286,7 +391,7 @@ function markRemovedTypeImports(root: Root, j: JSCodeshift, api: API, file: File j.commentBlock(' ' + marker + ' ', true, false), ] const line = specifier.loc?.start.line ?? 1 - api.report?.(file.path + ':' + line + ' ' + reason) + reporter.report(line, [reason]) changed = true } }) @@ -294,7 +399,79 @@ function markRemovedTypeImports(root: Root, j: JSCodeshift, api: API, file: File return changed } -function markLegacyReexports(root: Root, j: JSCodeshift, api: API, file: FileInfo): boolean { +function getLiteralTypeNames(node: Node): string[] { + if (node.type === 'TSUnionType') { + return (node as TSUnionType).types.flatMap((type) => getLiteralTypeNames(type)) + } + if (node.type !== 'TSLiteralType') return [] + const { literal } = node as TSLiteralType + if (literal.type === 'StringLiteral' && typeof literal.value === 'string') { + return [literal.value] + } + return [] +} + +function markLegacyTextPropsReferences( + root: Root, + j: JSCodeshift, + reporter: ManualReporter, +): boolean { + let changed = false + + root.find(j.TSIndexedAccessType).forEach((path) => { + const { objectType, indexType } = path.node + if ( + objectType.type !== 'TSTypeReference' || + objectType.typeName.type !== 'Identifier' || + objectType.typeName.name !== 'TextProps' || + !isImportedBinding(path, 'TextProps', 'TextProps') + ) { + return + } + + const [name] = getLiteralTypeNames(indexType) + if (!name || !REMOVED_TEXT_PROP_NAMES.has(name)) return + changed = + markStatement( + j, + reporter, + path, + "TextProps['" + name + "'] uses a removed Text prop", + ) || changed + }) + + root.find(j.TSTypeReference).forEach((path) => { + if (path.node.typeName.type !== 'Identifier' || path.node.typeName.name !== 'Pick') return + const parameters = path.node.typeParameters?.params ?? [] + const sourceType = parameters[0] + const keysType = parameters[1] + if ( + sourceType?.type !== 'TSTypeReference' || + sourceType.typeName.type !== 'Identifier' || + sourceType.typeName.name !== 'TextProps' || + !keysType || + !isImportedBinding(path, 'TextProps', 'TextProps') + ) { + return + } + + const removedNames = getLiteralTypeNames(keysType).filter((name) => + REMOVED_TEXT_PROP_NAMES.has(name), + ) + if (removedNames.length === 0) return + changed = + markStatement( + j, + reporter, + path, + 'Pick includes removed ' + removedNames.join(' and ') + ' props', + ) || changed + }) + + return changed +} + +function markLegacyReexports(root: Root, j: JSCodeshift, reporter: ManualReporter): boolean { let changed = false root.find(j.ExportNamedDeclaration, { source: { value: '@doist/reactist' } }).forEach( @@ -311,8 +488,7 @@ function markLegacyReexports(root: Root, j: JSCodeshift, api: API, file: FileInf changed = markStatement( j, - api, - file, + reporter, path, 're-exported ' + name + ' requires manual migration', ) || changed @@ -326,8 +502,7 @@ function markLegacyReexports(root: Root, j: JSCodeshift, api: API, file: FileInf function markNamespaceDestructuring( root: Root, j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, namespaceNames: Set, ): boolean { let changed = false @@ -351,8 +526,7 @@ function markNamespaceDestructuring( changed = markStatement( j, - api, - file, + reporter, path, 'namespace ' + property.key.name + ' destructuring requires manual migration', ) || changed @@ -378,8 +552,7 @@ function getMemberPropertyName(path: ASTPath): string | undefi function markIndirectNamespaceReferences( root: Root, j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, namespaceNames: Set, ): boolean { let changed = false @@ -396,8 +569,7 @@ function markIndirectNamespaceReferences( changed = markStatement( j, - api, - file, + reporter, path, 'namespace ' + propertyName + ' reference requires manual migration', ) || changed @@ -419,8 +591,7 @@ function markIndirectNamespaceReferences( changed = markStatement( j, - api, - file, + reporter, path, 'namespace ' + propertyName + ' type requires manual migration', ) || changed @@ -468,15 +639,174 @@ function readStaticString( if (attribute.value.type !== 'JSXExpressionContainer') return DYNAMIC const expression = attribute.value.expression + if (expression.type === 'JSXEmptyExpression') return DYNAMIC + return readStaticExpression(expression, fallback) +} + +function readStaticExpression( + expression: Expression, + fallback: string | typeof DYNAMIC, +): string | typeof DYNAMIC { if ( expression.type === 'StringLiteral' || - (expression.type === 'Literal' && typeof expression.value === 'string') + (expression.type === 'Literal' && typeof (expression as Literal).value === 'string') + ) { + return String((expression as StringLiteral).value) + } + if (expression.type === 'Identifier' && (expression as Identifier).name === 'undefined') { + return fallback + } + if ( + expression.type === 'TemplateLiteral' && + (expression as TemplateLiteral).expressions.length === 0 ) { - return String(expression.value) + return (expression as TemplateLiteral).quasis[0]?.value.cooked ?? DYNAMIC } + const wrappedExpression = getWrappedExpression(expression) + if (wrappedExpression) return readStaticExpression(wrappedExpression, fallback) return DYNAMIC } +function getWrappedExpression(expression: Expression): Expression | undefined { + if ( + expression.type !== 'ParenthesizedExpression' && + expression.type !== 'TSAsExpression' && + expression.type !== 'TSTypeAssertion' + ) { + return undefined + } + return (expression as ParenthesizedExpression | TSAsExpression | TSTypeAssertion).expression +} + +function mapFiniteStringExpression( + j: JSCodeshift, + expression: Expression, + fallback: string, + mapValue: (value: string) => string | undefined, +): MappedVariantExpression | null { + const value = readStaticExpression(expression, fallback) + if (value !== DYNAMIC) { + const mapped = mapValue(value) + return mapped ? j.stringLiteral(mapped) : null + } + if (expression.type === 'ConditionalExpression') { + const conditional = expression as ConditionalExpression + const consequent = mapFiniteStringExpression(j, conditional.consequent, fallback, mapValue) + const alternate = mapFiniteStringExpression(j, conditional.alternate, fallback, mapValue) + if (!consequent || !alternate) return null + return j.conditionalExpression(conditional.test, consequent, alternate) + } + const wrappedExpression = getWrappedExpression(expression) + if (wrappedExpression) { + return mapFiniteStringExpression(j, wrappedExpression, fallback, mapValue) + } + return null +} + +function mapFiniteStringPair( + j: JSCodeshift, + first: Expression, + firstFallback: string, + second: Expression, + secondFallback: string, + mapValues: (firstValue: string, secondValue: string) => string | undefined, +): MappedVariantExpression | null { + const firstValue = readStaticExpression(first, firstFallback) + const secondValue = readStaticExpression(second, secondFallback) + if (firstValue !== DYNAMIC && secondValue !== DYNAMIC) { + const mapped = mapValues(firstValue, secondValue) + return mapped ? j.stringLiteral(mapped) : null + } + + const wrappedFirst = getWrappedExpression(first) + if (wrappedFirst) { + return mapFiniteStringPair( + j, + wrappedFirst, + firstFallback, + second, + secondFallback, + mapValues, + ) + } + const wrappedSecond = getWrappedExpression(second) + if (wrappedSecond) { + return mapFiniteStringPair( + j, + first, + firstFallback, + wrappedSecond, + secondFallback, + mapValues, + ) + } + if (first.type === 'ConditionalExpression') { + const conditional = first as ConditionalExpression + const consequent = mapFiniteStringPair( + j, + conditional.consequent, + firstFallback, + second, + secondFallback, + mapValues, + ) + const alternate = mapFiniteStringPair( + j, + conditional.alternate, + firstFallback, + second, + secondFallback, + mapValues, + ) + if (!consequent || !alternate) return null + return j.conditionalExpression(conditional.test, consequent, alternate) + } + if (second.type === 'ConditionalExpression') { + const conditional = second as ConditionalExpression + const consequent = mapFiniteStringPair( + j, + first, + firstFallback, + conditional.consequent, + secondFallback, + mapValues, + ) + const alternate = mapFiniteStringPair( + j, + first, + firstFallback, + conditional.alternate, + secondFallback, + mapValues, + ) + if (!consequent || !alternate) return null + return j.conditionalExpression(conditional.test, consequent, alternate) + } + return null +} + +function getAttributeExpression(attribute: JSXAttribute): Expression | undefined { + if ( + attribute.value?.type !== 'JSXExpressionContainer' || + attribute.value.expression.type === 'JSXEmptyExpression' + ) { + return undefined + } + return attribute.value.expression +} + +function mapFiniteStringAttribute( + j: JSCodeshift, + attribute: JSXAttribute, + fallback: string, + mapValue: (value: string) => string | undefined, +): MappedVariantExpression | null { + if (attribute.value?.type !== 'JSXExpressionContainer') return null + const { expression } = attribute.value + if (expression.type === 'JSXEmptyExpression') return null + return mapFiniteStringExpression(j, expression, fallback, mapValue) +} + function readStaticLevel(attribute: JSXAttribute | undefined): number | typeof DYNAMIC { const value = readStaticString(attribute, DYNAMIC) if (value !== DYNAMIC && /^[1-6]$/.test(value)) return Number(value) @@ -505,7 +835,21 @@ function removeAttributes(openingElement: JSXOpeningElement, names: string[]): v ) } -function addVariant(j: JSCodeshift, openingElement: JSXOpeningElement, variant: string): void { +function createVariantAttribute( + j: JSCodeshift, + variant: string | MappedVariantExpression, +): JSXAttribute { + return j.jsxAttribute( + j.jsxIdentifier('variant'), + typeof variant === 'string' ? j.stringLiteral(variant) : j.jsxExpressionContainer(variant), + ) +} + +function addVariant( + j: JSCodeshift, + openingElement: JSXOpeningElement, + variant: string | MappedVariantExpression, +): void { removeAttributes(openingElement, ['size', 'weight']) const attributes = openingElement.attributes ?? [] openingElement.attributes = attributes @@ -516,23 +860,19 @@ function addVariant(j: JSCodeshift, openingElement: JSXOpeningElement, variant: attribute.name.name === 'level', ) const insertionIndex = levelIndex >= 0 ? levelIndex + 1 : 0 - attributes.splice( - insertionIndex, - 0, - j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant)), - ) + attributes.splice(insertionIndex, 0, createVariantAttribute(j, variant)) } function replaceHeadingProps( j: JSCodeshift, openingElement: JSXOpeningElement, - variant: string, + variant: string | MappedVariantExpression, level: number | undefined, ): void { removeAttributes(openingElement, ['level', 'size', 'weight', 'variant']) - const attributes = [j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant))] - if (level && variant !== 'header-' + level) { + const attributes = [createVariantAttribute(j, variant)] + if (level && (typeof variant !== 'string' || variant !== 'header-' + level)) { const renderName = j.jsxIdentifier('h' + level) const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, []) attributes.push( @@ -543,8 +883,99 @@ function replaceHeadingProps( openingElement.attributes.unshift(...attributes) } -function replaceMergedComponentImports(root: Root, j: JSCodeshift): boolean { +function hasImportComments(specifier: ImportSpecifier): boolean { + return Boolean( + specifier.comments?.length || + specifier.imported.comments?.length || + specifier.local?.comments?.length, + ) +} + +function canRenameToText(path: NodePath): boolean { + // ast-types exposes scope as `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return !path.scope.lookup('Text') || isImportedBinding(path, 'Text', 'Text') +} + +function consolidateDirectComponentImports(root: Root, j: JSCodeshift): boolean { let changed = false + let hasTextImport = getImportedNames(root, j, 'Text').has('Text') + + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach( + (importPath) => { + const declarationIsTypeOnly = importPath.node.importKind === 'type' + const originalSpecifierCount = importPath.node.specifiers?.length ?? 0 + const nextSpecifiers = [] + + for (const specifier of importPath.node.specifiers ?? []) { + if ( + declarationIsTypeOnly || + specifier.type !== 'ImportSpecifier' || + specifier.imported.type !== 'Identifier' || + !['Display', 'Heading'].includes(specifier.imported.name) || + (specifier as ImportSpecifierWithKind).importKind === 'type' || + specifier.local?.type !== 'Identifier' || + specifier.local.name !== specifier.imported.name || + hasImportComments(specifier) + ) { + nextSpecifiers.push(specifier) + continue + } + + const importedName = specifier.imported.name + const references: NodePath[] = [] + let hasUnsafeReference = false + root.find(j.Identifier, { name: importedName }).forEach((path) => { + if (!isImportedBinding(path, importedName, importedName)) return + if (isNonReferenceIdentifier(path)) return + if (!isDirectJSXReference(path) || !canRenameToText(path)) { + hasUnsafeReference = true + return + } + // ast-types exposes parent paths as `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (path.parent?.node.type === 'JSXOpeningElement') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const elementPath = path.parent.parent as ElementPath + if (hasManualMarker(elementPath)) { + hasUnsafeReference = true + return + } + } + references.push(path) + }) + + if (hasUnsafeReference) { + nextSpecifiers.push(specifier) + continue + } + + if (references.length === 0) { + changed = true + continue + } + for (const reference of references) { + ;(reference.node as Identifier).name = 'Text' + } + if (!hasTextImport) { + nextSpecifiers.push(j.importSpecifier(j.identifier('Text'))) + hasTextImport = true + } + changed = true + } + + importPath.node.specifiers = nextSpecifiers + if (originalSpecifierCount > 0 && nextSpecifiers.length === 0) { + j(importPath).remove() + } + }, + ) + + return changed +} + +function replaceMergedComponentImports(root: Root, j: JSCodeshift): boolean { + let changed = consolidateDirectComponentImports(root, j) root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { path.node.specifiers = (path.node.specifiers ?? []).map((specifier) => { @@ -617,33 +1048,70 @@ function getStaticRenderName( return null } -function isComponentAsTarget(attribute: JSXAttribute): boolean { - if (attribute.value?.type !== 'JSXExpressionContainer') return false - const { expression } = attribute.value - return expression.type === 'Identifier' || expression.type === 'MemberExpression' -} - -function hasAttributesOtherThan(openingElement: JSXOpeningElement, names: string[]): boolean { - return (openingElement.attributes ?? []).some( - (attribute) => - attribute.type === 'JSXSpreadAttribute' || - (attribute.type === 'JSXAttribute' && - attribute.name.type === 'JSXIdentifier' && - !names.includes(attribute.name.name)), - ) -} - function replaceAsWithRender( j: JSCodeshift, + openingElement: JSXOpeningElement, asAttribute: JSXAttribute, renderName: RenderName, ): void { - const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, []) + const targetAttributes = (openingElement.attributes ?? []).filter( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute !== asAttribute && + attribute.name.type === 'JSXIdentifier' && + !['size', 'weight'].includes(attribute.name.name) && + !TEXT_OWNED_PROPS.has(attribute.name.name), + ) as JSXAttribute[] + openingElement.attributes = (openingElement.attributes ?? []).filter( + (attribute) => !targetAttributes.includes(attribute as JSXAttribute), + ) + const renderElement = j.jsxElement( + j.jsxOpeningElement(renderName, targetAttributes, true), + null, + [], + ) asAttribute.name = j.jsxIdentifier('render') asAttribute.value = j.jsxExpressionContainer(renderElement) } +function getJSXParent(path: ElementPath): JSXElement | JSXFragment | undefined { + // ast-types exposes parent paths as `any`. + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const parent = path.parent?.node + const jsxParent = + parent?.type === 'JSXElement' || parent?.type === 'JSXFragment' ? parent : undefined + return jsxParent as JSXElement | JSXFragment | undefined + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +} + function hasManualMarker(path: ElementPath): boolean { + if (path.node.comments?.some((comment) => comment.value.includes('TODO(reactist-codemod)'))) { + return true + } + + const parent = getJSXParent(path) + if (parent) { + const children = parent.children ?? [] + const index = children.indexOf(path.node) + for (let current = index - 1; current >= 0; current -= 1) { + const sibling = children[current] + if (!sibling) break + if (sibling.type === 'JSXText' && sibling.value.trim() === '') continue + if ( + sibling.type === 'JSXExpressionContainer' && + sibling.expression.type === 'JSXEmptyExpression' && + Boolean( + sibling.expression.comments?.some((comment) => + comment.value.includes('TODO(reactist-codemod)'), + ), + ) + ) { + return true + } + break + } + } + return (path.node.children ?? []).some( (child) => child.type === 'JSXExpressionContainer' && @@ -656,8 +1124,7 @@ function hasManualMarker(path: ElementPath): boolean { function markManual( j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, path: ElementPath, reasons: string[], ): void { @@ -667,29 +1134,22 @@ function markManual( if (!alreadyMarked) { const emptyExpression = j.jsxEmptyExpression() emptyExpression.comments = [j.commentBlock(message)] - path.node.children = path.node.children ?? [] - path.node.children.unshift( - j.jsxText('\n'), - j.jsxExpressionContainer(emptyExpression), - j.jsxText('\n'), - ) - - if (path.node.openingElement.selfClosing) { - const name = path.node.openingElement.name - path.node.openingElement.selfClosing = false - path.node.closingElement = j.jsxClosingElement(name) + const parent = getJSXParent(path) + if (parent) { + path.insertBefore(j.jsxExpressionContainer(emptyExpression)) + } else { + path.node.comments = [...(path.node.comments ?? []), j.commentBlock(message)] } } const line = path.node.loc?.start.line ?? 1 - api.report?.(file.path + ':' + line + ' ' + reasons.join('; ')) + reporter.report(line, reasons) } function markNamespaceJSXReferences( root: Root, j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, namespaceNames: Set, ): boolean { let changed = false @@ -712,7 +1172,7 @@ function markNamespaceJSXReferences( if (!LEGACY_NAMESPACE_MEMBERS.has(propertyName)) return if (!isImportedNamespaceBinding(path, namespaceName)) return - markManual(j, api, file, path, [ + markManual(j, reporter, path, [ 'namespace ' + propertyName + ' reference requires manual migration', ]) changed = true @@ -723,8 +1183,7 @@ function markNamespaceJSXReferences( function transformTextElement( j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, path: ElementPath, ): boolean { if (hasManualMarker(path)) return false @@ -750,11 +1209,43 @@ function transformTextElement( const size = readStaticString(sizeAttribute, 'body') const weight = readStaticString(weightAttribute, 'regular') - if (size === DYNAMIC) reasons.push('dynamic Text size') - if (weight === DYNAMIC) reasons.push('dynamic Text weight') + let variant: string | MappedVariantExpression | undefined + if (size !== DYNAMIC && weight !== DYNAMIC) { + variant = TEXT_VARIANTS[size]?.[weight] + } else if (size === DYNAMIC && weight !== DYNAMIC && sizeAttribute) { + variant = + mapFiniteStringAttribute( + j, + sizeAttribute, + 'body', + (value) => TEXT_VARIANTS[value]?.[weight], + ) ?? undefined + } else if (weight === DYNAMIC && size !== DYNAMIC && weightAttribute) { + variant = + mapFiniteStringAttribute( + j, + weightAttribute, + 'regular', + (value) => TEXT_VARIANTS[size]?.[value], + ) ?? undefined + } else if (sizeAttribute && weightAttribute) { + const sizeExpression = getAttributeExpression(sizeAttribute) + const weightExpression = getAttributeExpression(weightAttribute) + if (sizeExpression && weightExpression) { + variant = + mapFiniteStringPair( + j, + sizeExpression, + 'body', + weightExpression, + 'regular', + (sizeValue, weightValue) => TEXT_VARIANTS[sizeValue]?.[weightValue], + ) ?? undefined + } + } - const variant = - size === DYNAMIC || weight === DYNAMIC ? undefined : TEXT_VARIANTS[size]?.[weight] + if (size === DYNAMIC && !variant) reasons.push('dynamic Text size') + if (weight === DYNAMIC && !variant) reasons.push('dynamic Text weight') if (!hasVariant && (sizeAttribute || weightAttribute) && !variant && reasons.length === 0) { reasons.push('Text size and weight have no exact variant') } @@ -762,31 +1253,18 @@ function transformTextElement( const renderName = asAttribute ? getStaticRenderName(j, asAttribute) : undefined if (asAttribute && !renderName) { reasons.push('dynamic Text as target') - } else if ( - asAttribute && - renderName && - isComponentAsTarget(asAttribute) && - !hasAttributesOtherThan(openingElement, ['as', 'size', 'weight']) - ) { - reasons.push('Text as component requires manual render props') - } else if ( - asAttribute && - !renderAttribute && - hasAttributesOtherThan(openingElement, ['as', 'size', 'weight']) - ) { - reasons.push('Text as migration requires no props besides size or weight') } if (asAttribute && renderAttribute) reasons.push('Text already has render prop') if (reasons.length > 0) { - markManual(j, api, file, path, reasons) + markManual(j, reporter, path, reasons) return true } if (!hasVariant && (sizeAttribute || weightAttribute)) { addVariant(j, openingElement, variant!) } if (asAttribute && renderName) { - replaceAsWithRender(j, asAttribute, renderName) + replaceAsWithRender(j, openingElement, asAttribute, renderName) } return ( Boolean(!hasVariant && (sizeAttribute || weightAttribute)) || @@ -796,8 +1274,7 @@ function transformTextElement( function transformHeadingElement( j: JSCodeshift, - api: API, - file: FileInfo, + reporter: ManualReporter, path: ElementPath, ): boolean { if (hasManualMarker(path)) return false @@ -807,8 +1284,7 @@ function transformHeadingElement( if (duplicateAttributes.length > 0) { markManual( j, - api, - file, + reporter, path, duplicateAttributes.map((name) => 'duplicate Heading ' + name + ' props'), ) @@ -825,7 +1301,7 @@ function transformHeadingElement( ) if ((variantAttribute && hasLegacyVariantProps) || (renderAttribute && hasLegacyRenderProps)) { - markManual(j, api, file, path, [ + markManual(j, reporter, path, [ 'Heading mixes variant or render with legacy level, size, or weight props', ]) return true @@ -848,37 +1324,75 @@ function transformHeadingElement( if (!variantAttribute && level === undefined) reasons.push('dynamic Heading level') if (level === DYNAMIC) reasons.push('dynamic Heading level') - if (size === DYNAMIC) reasons.push('dynamic Heading size') - if (weight === DYNAMIC) reasons.push('dynamic Heading weight') - - let variant + let variant: string | MappedVariantExpression | undefined if (variantAttribute) { const namedVariant = readStaticString(variantAttribute, DYNAMIC) if (namedVariant === DYNAMIC) { - reasons.push('dynamic Heading variant') + variant = + mapFiniteStringAttribute( + j, + variantAttribute, + '', + (value) => NAMED_HEADING_VARIANTS[value], + ) ?? undefined + if (!variant) reasons.push('dynamic Heading variant') } else { variant = NAMED_HEADING_VARIANTS[namedVariant] if (!variant) reasons.push('Heading variant has no Text equivalent') } } else if (renderAttribute) { reasons.push('Heading render requires a static variant') - } else if (reasons.length === 0) { - const fontSize = - typeof level === 'number' && typeof size === 'string' - ? HEADING_SIZES[level]?.[size] - : undefined - const fontWeight = typeof weight === 'string' ? HEADING_WEIGHTS[weight] : undefined - variant = HEADING_VARIANTS[fontSize + ':' + fontWeight] - if (!variant) reasons.push('Heading metrics have no exact variant') + } else if (typeof level === 'number') { + if (size !== DYNAMIC && weight !== DYNAMIC) { + const fontSize = HEADING_SIZES[level]?.[size] + const fontWeight = HEADING_WEIGHTS[weight] + variant = HEADING_VARIANTS[fontSize + ':' + fontWeight] + } else if (size === DYNAMIC && weight !== DYNAMIC && sizeAttribute) { + variant = + mapFiniteStringAttribute(j, sizeAttribute, 'default', (value) => { + const fontSize = HEADING_SIZES[level]?.[value] + return HEADING_VARIANTS[fontSize + ':' + HEADING_WEIGHTS[weight]] + }) ?? undefined + } else if (weight === DYNAMIC && size !== DYNAMIC && weightAttribute) { + variant = + mapFiniteStringAttribute(j, weightAttribute, 'regular', (value) => { + const fontSize = HEADING_SIZES[level]?.[size] + return HEADING_VARIANTS[fontSize + ':' + HEADING_WEIGHTS[value]] + }) ?? undefined + } else if (sizeAttribute && weightAttribute) { + const sizeExpression = getAttributeExpression(sizeAttribute) + const weightExpression = getAttributeExpression(weightAttribute) + if (sizeExpression && weightExpression) { + variant = + mapFiniteStringPair( + j, + sizeExpression, + 'default', + weightExpression, + 'regular', + (sizeValue, weightValue) => { + const fontSize = HEADING_SIZES[level]?.[sizeValue] + return HEADING_VARIANTS[fontSize + ':' + HEADING_WEIGHTS[weightValue]] + }, + ) ?? undefined + } + } + + if (!variant && size !== DYNAMIC && weight !== DYNAMIC) { + reasons.push('Heading metrics have no exact variant') + } } + if (size === DYNAMIC && !variant) reasons.push('dynamic Heading size') + if (weight === DYNAMIC && !variant) reasons.push('dynamic Heading weight') + if (reasons.length > 0) { - markManual(j, api, file, path, reasons) + markManual(j, reporter, path, reasons) return true } if (!variant) { - markManual(j, api, file, path, ['Heading variant has no Text equivalent']) + markManual(j, reporter, path, ['Heading variant has no Text equivalent']) return true } @@ -891,18 +1405,29 @@ function transformHeadingElement( return true } -function transform(file: FileInfo, api: API, _options: Options): string | null { +function transform(file: FileInfo, api: API, options: Options): string | null { + const failOnManual = Boolean(options.failOnManual ?? options['fail-on-manual']) + const reporter = createManualReporter(api, getExistingManualReasons(file.source)) + if (!hasRootReactistImport(file.source)) { + if (failOnManual && reporter.count > 0) { + const noun = reporter.count === 1 ? 'migration remains' : 'migrations remain' + throw new Error(reporter.count + ' manual ' + noun + ' in ' + file.path) + } + return null + } + const j = api.jscodeshift const root = j(file.source) as Root const textNames = getImportedNames(root, j, 'Text') const headingNames = getImportedNames(root, j, 'Heading') const namespaceNames = getNamespaceNames(root, j) - let changed = markIndirectHeadingReferences(root, j, api, file, headingNames) - changed = markRemovedTypeImports(root, j, api, file) || changed - changed = markLegacyReexports(root, j, api, file) || changed - changed = markNamespaceDestructuring(root, j, api, file, namespaceNames) || changed - changed = markIndirectNamespaceReferences(root, j, api, file, namespaceNames) || changed - changed = markNamespaceJSXReferences(root, j, api, file, namespaceNames) || changed + let changed = markIndirectHeadingReferences(root, j, reporter, headingNames) + changed = markRemovedTypeImports(root, j, reporter) || changed + changed = markLegacyTextPropsReferences(root, j, reporter) || changed + changed = markLegacyReexports(root, j, reporter) || changed + changed = markNamespaceDestructuring(root, j, reporter, namespaceNames) || changed + changed = markIndirectNamespaceReferences(root, j, reporter, namespaceNames) || changed + changed = markNamespaceJSXReferences(root, j, reporter, namespaceNames) || changed root.find(j.JSXElement).forEach((path) => { const openingElement = path.node.openingElement @@ -911,14 +1436,19 @@ function transform(file: FileInfo, api: API, _options: Options): string | null { const { name } = openingElement.name if (typeof name !== 'string') return if (textNames.has(name) && isImportedBinding(path, name, 'Text')) { - changed = transformTextElement(j, api, file, path) || changed + changed = transformTextElement(j, reporter, path) || changed } else if (headingNames.has(name) && isImportedBinding(path, name, 'Heading')) { - changed = transformHeadingElement(j, api, file, path) || changed + changed = transformHeadingElement(j, reporter, path) || changed } }) changed = replaceMergedComponentImports(root, j) || changed + if (failOnManual && reporter.count > 0) { + const noun = reporter.count === 1 ? 'migration remains' : 'migrations remain' + throw new Error(reporter.count + ' manual ' + noun + ' in ' + file.path) + } + return changed ? root.toSource({ quote: 'single' }) : null } From 0179bdac7bca617cb71657303853630b958c7cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Mon, 17 Aug 2026 11:51:19 -0500 Subject: [PATCH 4/5] doc: Update codemode readme --- codemods/README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/codemods/README.md b/codemods/README.md index 9e46ddf64..8577b27ca 100644 --- a/codemods/README.md +++ b/codemods/README.md @@ -1,4 +1,4 @@ -# Reactist Text codemod +# Text Variants codemod Migrates the breaking Text, Heading, and Display APIs to the consolidated Text component and its named variants. @@ -34,8 +34,6 @@ npx jscodeshift@17.3.0 \ src ``` -Run Prettier and the application's type-check after the transform. - ## Mappings | Legacy Text size | Regular or omitted | Semibold | Bold | @@ -90,6 +88,3 @@ References such as `TextProps['size']` and `Pick` because the replacement type depends on the consumer API. The transform supports imports from `@doist/reactist`. Deep imports are outside its scope. - -Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose -an undocumented nearest variant: confirm the intended visual hierarchy with design. From 72039a5287cb1d4fbda591f37e26d2a56bd5ad03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Mon, 17 Aug 2026 12:29:13 -0500 Subject: [PATCH 5/5] fix: harden text codemod edge cases - Upgrade type-only Text imports when a direct component becomes Text - Resolve TextProps import aliases in removed-prop checks - Detect imports that break the line before the module name - Cap conditional size and weight expansion at 16 combinations --- codemods/text-variants.test.ts | 119 +++++++++++++++++++++++++++++++++ codemods/text-variants.ts | 107 ++++++++++++++++++++++++++--- 2 files changed, 217 insertions(+), 9 deletions(-) diff --git a/codemods/text-variants.test.ts b/codemods/text-variants.test.ts index 62358fd26..93507552b 100644 --- a/codemods/text-variants.test.ts +++ b/codemods/text-variants.test.ts @@ -372,6 +372,49 @@ describe('text variants codemod', () => { expect(await transformSource(source)).toBe(await format(expected)) }) + it('upgrades an inline type-only Text import when consolidating direct components', async () => { + const source = ` + import { Heading, type Text } from '@doist/reactist' + + export function Example() { + return Title + } + ` + const expected = ` + import { Text } from '@doist/reactist' + + export function Example() { + return }>Title + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('upgrades a type-only Text import declaration when consolidating direct components', async () => { + const source = ` + import type { Text, TextProps } from '@doist/reactist' + import { Heading } from '@doist/reactist' + + type Tone = TextProps['tone'] + + export function Example() { + return Title + } + ` + const expected = ` + import { Text, type TextProps } from '@doist/reactist' + + type Tone = TextProps['tone'] + + export function Example() { + return }>Title + } + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + it('removes an unused legacy import without adding a conflicting Text import', async () => { const source = ` import { Heading } from '@doist/reactist' @@ -428,6 +471,50 @@ describe('text variants codemod', () => { ]) }) + it('reports aliased TextProps property references', () => { + const source = ` + import { type TextProps as LegacyTextProps } from '@doist/reactist' + + type Size = LegacyTextProps['size'] + type Props = Pick + type Tone = LegacyTextProps['tone'] + ` + const report = jest.fn() + + const output = transform({ path: 'src/types.ts', source }, createApi(report), {}) + + expect(output).toContain( + "TODO(reactist-codemod): LegacyTextProps['size'] uses a removed Text prop", + ) + expect(output).toContain( + 'TODO(reactist-codemod): Pick includes removed size and weight props', + ) + expect(report).toHaveBeenCalledTimes(2) + }) + + it('marks oversized conditional size and weight combinations for manual migration', () => { + const source = ` + import { Text } from '@doist/reactist' + + export function Example({ a, b, c, d, e, f, g }) { + return ( + + Too many combinations + + ) + } + ` + const output = transform({ path: 'src/example.tsx', source }, createApi(), {}) + + expect(output).toContain( + 'TODO(reactist-codemod): dynamic Text size; dynamic Text weight', + ) + expect(output).not.toContain('variant=') + }) + it('records manual totals and reason categories', () => { const source = ` import { Text } from '@doist/reactist' @@ -503,6 +590,38 @@ describe('text variants codemod', () => { ).toBeNull() }) + it('transforms files whose Reactist import spans lines before the module name', async () => { + const source = ` + import { Text } + from '@doist/reactist' + + export const Example = () => ( + + Caption + + ) + ` + const expected = ` + import { Text } from '@doist/reactist' + + export const Example = () => Caption + ` + + expect(await transformSource(source)).toBe(await format(expected)) + }) + + it('detects multi-line Reactist re-exports', () => { + const source = ` + export { Heading } + from '@doist/reactist' + ` + const output = transform({ path: 'src/reexport.ts', source }, createApi(), {}) + + expect(output).toContain( + 'TODO(reactist-codemod): re-exported Heading requires manual migration', + ) + }) + it('does not parse invalid files that only mention Reactist in data', () => { const invalidTypeScript = ` const packageName = '@doist/reactist' diff --git a/codemods/text-variants.ts b/codemods/text-variants.ts index 892c00466..6d5abd7ee 100644 --- a/codemods/text-variants.ts +++ b/codemods/text-variants.ts @@ -115,6 +115,10 @@ const TEXT_OWNED_PROPS = new Set([ const DYNAMIC = Symbol('dynamic') +// Distributing one conditional prop over the other multiplies their branches, so +// cap the expansion and leave larger expressions as a manual migration. +const MAX_VARIANT_COMBINATIONS = 16 + function hasRootReactistImport(source: string): boolean { let importStatement = '' let blockComment = false @@ -144,6 +148,9 @@ function hasRootReactistImport(source: string): boolean { if (!importStatement) { if (!/^(?:import|export)\b/.test(trimmedLine)) continue importStatement = trimmedLine + } else if (braceDepth <= 0 && /^(?:import|export)\b/.test(trimmedLine)) { + // A new statement starts before the previous one named a module. + importStatement = trimmedLine } else { importStatement += ' ' + trimmedLine } @@ -155,7 +162,10 @@ function hasRootReactistImport(source: string): boolean { if (/(?:\bfrom\s*|^import\s*)['"]@doist\/reactist['"]/.test(importStatement)) { return true } - if (braceDepth <= 0) importStatement = '' + // Balanced braces alone do not end an import; wait for the module specifier. + if (braceDepth <= 0 && /(?:\bfrom\s*|^import\s*)['"][^'"]*['"]/.test(importStatement)) { + importStatement = '' + } } return false @@ -417,14 +427,15 @@ function markLegacyTextPropsReferences( reporter: ManualReporter, ): boolean { let changed = false + const textPropsNames = getImportedNames(root, j, 'TextProps') root.find(j.TSIndexedAccessType).forEach((path) => { const { objectType, indexType } = path.node if ( objectType.type !== 'TSTypeReference' || objectType.typeName.type !== 'Identifier' || - objectType.typeName.name !== 'TextProps' || - !isImportedBinding(path, 'TextProps', 'TextProps') + !textPropsNames.has(objectType.typeName.name) || + !isImportedBinding(path, objectType.typeName.name, 'TextProps') ) { return } @@ -436,7 +447,7 @@ function markLegacyTextPropsReferences( j, reporter, path, - "TextProps['" + name + "'] uses a removed Text prop", + objectType.typeName.name + "['" + name + "'] uses a removed Text prop", ) || changed }) @@ -448,9 +459,9 @@ function markLegacyTextPropsReferences( if ( sourceType?.type !== 'TSTypeReference' || sourceType.typeName.type !== 'Identifier' || - sourceType.typeName.name !== 'TextProps' || + !textPropsNames.has(sourceType.typeName.name) || !keysType || - !isImportedBinding(path, 'TextProps', 'TextProps') + !isImportedBinding(path, sourceType.typeName.name, 'TextProps') ) { return } @@ -464,7 +475,11 @@ function markLegacyTextPropsReferences( j, reporter, path, - 'Pick includes removed ' + removedNames.join(' and ') + ' props', + 'Pick<' + + sourceType.typeName.name + + '> includes removed ' + + removedNames.join(' and ') + + ' props', ) || changed }) @@ -703,6 +718,19 @@ function mapFiniteStringExpression( return null } +function countConditionalBranches(expression: Expression): number { + if (expression.type === 'ConditionalExpression') { + const conditional = expression as ConditionalExpression + return ( + countConditionalBranches(conditional.consequent) + + countConditionalBranches(conditional.alternate) + ) + } + const wrappedExpression = getWrappedExpression(expression) + if (wrappedExpression) return countConditionalBranches(wrappedExpression) + return 1 +} + function mapFiniteStringPair( j: JSCodeshift, first: Expression, @@ -711,6 +739,12 @@ function mapFiniteStringPair( secondFallback: string, mapValues: (firstValue: string, secondValue: string) => string | undefined, ): MappedVariantExpression | null { + if ( + countConditionalBranches(first) * countConditionalBranches(second) > + MAX_VARIANT_COMBINATIONS + ) { + return null + } const firstValue = readStaticExpression(first, firstFallback) const secondValue = readStaticExpression(second, secondFallback) if (firstValue !== DYNAMIC && secondValue !== DYNAMIC) { @@ -897,9 +931,62 @@ function canRenameToText(path: NodePath): boolean { return !path.scope.lookup('Text') || isImportedBinding(path, 'Text', 'Text') } +function hasValueTextImport(root: Root, j: JSCodeshift): boolean { + let found = false + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + if (path.node.importKind === 'type') return + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'Text' && + (specifier as ImportSpecifierWithKind).importKind !== 'type' && + (specifier.local?.type !== 'Identifier' || specifier.local.name === 'Text') + ) { + found = true + } + } + }) + return found +} + +function upgradeTypeOnlyTextImport(root: Root, j: JSCodeshift): boolean { + let upgraded = false + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + if (upgraded) return + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type !== 'ImportSpecifier' || + specifier.imported.type !== 'Identifier' || + specifier.imported.name !== 'Text' || + (specifier.local?.type === 'Identifier' && specifier.local.name !== 'Text') + ) { + continue + } + + const declarationIsTypeOnly = path.node.importKind === 'type' + const specifierIsTypeOnly = (specifier as ImportSpecifierWithKind).importKind === 'type' + if (!declarationIsTypeOnly && !specifierIsTypeOnly) continue + + if (declarationIsTypeOnly) { + path.node.importKind = 'value' + for (const other of path.node.specifiers ?? []) { + if (other !== specifier && other.type === 'ImportSpecifier') { + ;(other as ImportSpecifierWithKind).importKind = 'type' + } + } + } + ;(specifier as ImportSpecifierWithKind).importKind = 'value' + upgraded = true + return + } + }) + return upgraded +} + function consolidateDirectComponentImports(root: Root, j: JSCodeshift): boolean { let changed = false - let hasTextImport = getImportedNames(root, j, 'Text').has('Text') + let hasTextImport = hasValueTextImport(root, j) root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach( (importPath) => { @@ -958,7 +1045,9 @@ function consolidateDirectComponentImports(root: Root, j: JSCodeshift): boolean ;(reference.node as Identifier).name = 'Text' } if (!hasTextImport) { - nextSpecifiers.push(j.importSpecifier(j.identifier('Text'))) + if (!upgradeTypeOnlyTextImport(root, j)) { + nextSpecifiers.push(j.importSpecifier(j.identifier('Text'))) + } hasTextImport = true } changed = true