diff --git a/.eslintignore b/.eslintignore index 57bc5417..12de7385 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 00000000..3d5239ec --- /dev/null +++ b/codemods/README.md @@ -0,0 +1,90 @@ +# Text Variants codemod + +Migrates the breaking Text, Heading, and Display APIs to the consolidated Text component and its +named variants, introduced in #1152. + +## 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 \ + --extensions js,jsx,ts,tsx \ + --parser tsx \ + src +``` + +## 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 +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 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. 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 00000000..e16469a0 --- /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 00000000..7fdaf667 --- /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 00000000..45f2da76 --- /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 00000000..4d71faab --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading-name-keys.output.tsx @@ -0,0 +1,30 @@ +import { Text, 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 00000000..9c1e02bd --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading.input.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' + +import { Heading, Heading as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + + Large + + Current default + + Visual 20 + + + 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 new file mode 100644 index 00000000..02e0708f --- /dev/null +++ b/codemods/__testfixtures__/text-variants-heading.output.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' + +import { Text, Text as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + Large + }> + Current default + + }> + Visual 20 + + }> + 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-idempotence.input.tsx b/codemods/__testfixtures__/text-variants-idempotence.input.tsx new file mode 100644 index 00000000..cfddc70d --- /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 00000000..cfddc70d --- /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 00000000..f445957a --- /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 00000000..4dae6361 --- /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 00000000..4b98e445 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-manual.input.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +export function ManualCases({ size, weight, useLabel, level, props }) { + return ( + <> + Dynamic + Dynamic weight + Dynamic element + Unsupported element + }> + Existing render + + + Duplicate size + + + Duplicate weight + + + Duplicate element + + Spread + + 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 00000000..5d8c5630 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-manual.output.tsx @@ -0,0 +1,66 @@ +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): 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 */} + + 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 00000000..af2e858e --- /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 00000000..9b7a3e2f --- /dev/null +++ b/codemods/__testfixtures__/text-variants-safety.output.tsx @@ -0,0 +1,56 @@ +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 + } ref={anchorRef}> + Anchor with props + + }> + Custom component with required props + + {/* 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 */} + + Mixed Text 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}> + Mixed Heading render props + + {/* TODO(reactist-codemod): duplicate Heading variant props */} + + Duplicate Heading variant + + {/* TODO(reactist-codemod): duplicate Heading render props */} + } render={

}> + 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 00000000..a2e1b1c5 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-text.input.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +export function Example() { + return ( + <> + Subheader regular + + Subheader semibold + + + Subheader bold + + Body regular + + Body semibold + + + Body bold + + Callout regular + + Callout semibold + + + Callout bold + + 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 00000000..cd6f2666 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-text.output.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +export function Example() { + return ( + <> + Subheader regular + Subheader semibold + Subheader bold + Body regular + Body semibold + }> + Body bold + + Callout regular + Callout semibold + Callout bold + 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 00000000..ad28e0d2 --- /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 00000000..cdd4e076 --- /dev/null +++ b/codemods/__testfixtures__/text-variants-types-and-namespace.output.tsx @@ -0,0 +1,46 @@ +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 00000000..61e044a2 --- /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 00000000..93507552 --- /dev/null +++ b/codemods/text-variants.test.ts @@ -0,0 +1,682 @@ +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(), stats = jest.fn()): API { + return { + j, + jscodeshift: j, + report, + 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 () => { + 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('migrates static as targets and retains ambiguous uses', 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(/dynamic Text as target$/), + 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$/), + ]) + }) + + 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', () => { + it('maps documented 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(/^line \d+: indirect Heading reference$/), + expect.stringMatching(/^line \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() + }) + + 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('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' + + 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('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' + 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('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' + export { ReactComponent } + ` + + expect( + transform({ path: 'src/data.ts', source: invalidTypeScript }, createApi(), {}), + ).toBeNull() + }) + }) + + 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 its 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(16) + for (const [message] of report.mock.calls) { + expect(message).toMatch(/^line \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 00000000..6d5abd7e --- /dev/null +++ b/codemods/text-variants.ts @@ -0,0 +1,1545 @@ +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 +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' +} + +const TEXT_VARIANTS: Record> = { + 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', bold: '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', + '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 = { + '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 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') + +// 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 + 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 if (braceDepth <= 0 && /^(?:import|export)\b/.test(trimmedLine)) { + // A new statement starts before the previous one named a module. + 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 + } + // 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 +} + +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) => { + 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, + reporter: ManualReporter, + 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 + reporter.report(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, + reporter: ManualReporter, + 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, reporter, path, 'indirect Heading reference') || changed + }) + }) + + return changed +} + +function markRemovedTypeImports(root: Root, j: JSCodeshift, reporter: ManualReporter): 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 + reporter.report(line, [reason]) + changed = true + } + }) + + return changed +} + +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 + 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' || + !textPropsNames.has(objectType.typeName.name) || + !isImportedBinding(path, objectType.typeName.name, 'TextProps') + ) { + return + } + + const [name] = getLiteralTypeNames(indexType) + if (!name || !REMOVED_TEXT_PROP_NAMES.has(name)) return + changed = + markStatement( + j, + reporter, + path, + objectType.typeName.name + "['" + 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' || + !textPropsNames.has(sourceType.typeName.name) || + !keysType || + !isImportedBinding(path, sourceType.typeName.name, '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<' + + sourceType.typeName.name + + '> 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( + (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, + reporter, + path, + 're-exported ' + name + ' requires manual migration', + ) || changed + } + }, + ) + + return changed +} + +function markNamespaceDestructuring( + root: Root, + j: JSCodeshift, + reporter: ManualReporter, + 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, + reporter, + 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, + reporter: ManualReporter, + 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, + reporter, + 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, + reporter, + 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 === '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 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 (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 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, + firstFallback: string, + second: Expression, + 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) { + 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) + + 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 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 + 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, createVariantAttribute(j, variant)) +} + +function replaceHeadingProps( + j: JSCodeshift, + openingElement: JSXOpeningElement, + variant: string | MappedVariantExpression, + level: number | undefined, +): void { + removeAttributes(openingElement, ['level', 'size', 'weight', 'variant']) + + 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( + j.jsxAttribute(j.jsxIdentifier('render'), j.jsxExpressionContainer(renderElement)), + ) + } + openingElement.attributes = openingElement.attributes ?? [] + openingElement.attributes.unshift(...attributes) +} + +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 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 = hasValueTextImport(root, j) + + 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) { + if (!upgradeTypeOnlyTextImport(root, j)) { + 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) => { + 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 replaceAsWithRender( + j: JSCodeshift, + openingElement: JSXOpeningElement, + asAttribute: JSXAttribute, + renderName: RenderName, +): void { + 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' && + child.expression.type === 'JSXEmptyExpression' && + child.expression.comments?.some((comment) => + comment.value.includes('TODO(reactist-codemod)'), + ), + ) +} + +function markManual( + j: JSCodeshift, + reporter: ManualReporter, + 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)] + 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 + reporter.report(line, reasons) +} + +function markNamespaceJSXReferences( + root: Root, + j: JSCodeshift, + reporter: ManualReporter, + 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, reporter, path, [ + 'namespace ' + propertyName + ' reference requires manual migration', + ]) + changed = true + }) + + return changed +} + +function transformTextElement( + j: JSCodeshift, + reporter: ManualReporter, + 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') + + 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 + } + } + + 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') + } + + const renderName = asAttribute ? getStaticRenderName(j, asAttribute) : undefined + if (asAttribute && !renderName) { + reasons.push('dynamic Text as target') + } + if (asAttribute && renderAttribute) reasons.push('Text already has render prop') + + if (reasons.length > 0) { + markManual(j, reporter, path, reasons) + return true + } + if (!hasVariant && (sizeAttribute || weightAttribute)) { + addVariant(j, openingElement, variant!) + } + if (asAttribute && renderName) { + replaceAsWithRender(j, openingElement, asAttribute, renderName) + } + return ( + Boolean(!hasVariant && (sizeAttribute || weightAttribute)) || + Boolean(asAttribute && renderName) + ) +} + +function transformHeadingElement( + j: JSCodeshift, + reporter: ManualReporter, + 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, + reporter, + 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, reporter, 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') + let variant: string | MappedVariantExpression | undefined + if (variantAttribute) { + const namedVariant = readStaticString(variantAttribute, DYNAMIC) + if (namedVariant === DYNAMIC) { + 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 (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, reporter, path, reasons) + return true + } + + if (!variant) { + markManual(j, reporter, 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 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, 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 + 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, reporter, path) || changed + } else if (headingNames.has(name) && isImportedBinding(path, name, 'Heading')) { + 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 +} + +export const parser = 'tsx' +export default transform diff --git a/package-lock.json b/package-lock.json index c0f12f70..33f8ad24 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", @@ -77,6 +78,7 @@ "jest": "28.1.3", "jest-axe": "11.0.0", "jest-environment-jsdom": "28.1.3", + "jscodeshift": "17.3.0", "less": "4.9.0", "lint-staged": "10.5.4", "marked": "4.3.0", @@ -868,6 +870,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", @@ -1373,6 +1391,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", @@ -2151,6 +2186,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", @@ -6714,6 +6767,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", @@ -11745,6 +11809,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", @@ -15539,6 +15626,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", @@ -26505,6 +26633,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 aa054a1f..d9629e7f 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", @@ -129,6 +132,7 @@ "jest": "28.1.3", "jest-axe": "11.0.0", "jest-environment-jsdom": "28.1.3", + "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 8f7f3519..aebc7c90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": [ "src", "types", + "codemods/*.ts", ".storybook", ".storybook/components/**/*", ".storybook/figma/**/*",