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/**/*",