diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index 83cfd153fc..0e96322e26 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -1371,25 +1371,79 @@ type: embed ## Codemods -To ease the upgrade, we provide codemods that will automate most of the changes. Pay close attention to its output, it cannot refactor complex code! The codemod scripts can be run via the following commands: +### updateInstUIImportVersions + +Rewrites `@instructure/*` import paths to a specific version subpath (e.g. `@instructure/ui-buttons/v11_7`). Only touches known versioned components — utilities and non-versioned symbols are left untouched. Imports mixing versioned components with other symbols are automatically split into two declarations. ```sh --- type: code --- -npm install @instructure/ui-codemods@12 -npx jscodeshift@17.3.0 -t node_modules/@instructure/ui-codemods/lib/instUIv12Codemods.ts --usePrettier=false +npm install @instructure/ui-codemods ``` -where `` is the path to the directory (and its subdirectories) to be transformed. +It is worth adding `--extensions=ts,tsx` for TypeScript codebases. Also exclude `node_modules` and declaration files to avoid false matches: + +```sh +--- +type: code +--- +npx jscodeshift@latest node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" \ + --ignore-pattern="**/*.d.ts" \ + --diagnose=true +``` -The codemods that will do the following: +**Diagnose** — inspect which components are imported and at what version, without modifying files: -- TODO add details -- TODO +```sh +--- +type: code +--- +# inspect all versioned imports +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --diagnose=true + +# inspect only specific components +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --diagnose=true --components=Button,Alert +``` + +**Update** — rewrite import paths: + +```sh +--- +type: code +--- +# all versioned components → v11.7 +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionTo=v11.7 + +# only imports already pinned to v11.6 +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionFrom=v11.6 --versionTo=v11.7 + +# specific components only +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionTo=v11.7 --components=Button,Alert +``` -Options for the codemod: +Options: -- `filename`: if specified then emitted warnings are written to this file. -- `usePrettier`: if `true` the transformed code will be run through Prettier. You can customize this through a [Prettier - config file](https://prettier.io/docs/configuration.html) +- `versionTo`: target version, e.g. `v11.7`. Required for update mode. +- `versionFrom`: only rewrite imports already pinned to this version. +- `components`: comma-separated component names. Defaults to all known versioned exports. +- `diagnose`: report matching imports without modifying files. +- `fileName`: write parse errors and transform failures to this file. +- `usePrettier`: run output through Prettier (default `true`). diff --git a/packages/ui-codemods/.gitignore b/packages/ui-codemods/.gitignore index a7c47537dc..d01e61690d 100644 --- a/packages/ui-codemods/.gitignore +++ b/packages/ui-codemods/.gitignore @@ -1 +1,2 @@ types/ +lib/generated/ diff --git a/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts new file mode 100644 index 0000000000..36175a6069 --- /dev/null +++ b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts @@ -0,0 +1,334 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/// +// eslint-disable-next-line no-restricted-imports +import { runInlineTest } from 'jscodeshift/src/testUtils' +import jscodeshift from 'jscodeshift' +import updateInstUIImportVersions from '../updateInstUIImportVersions' + +const FILE_PATH = 'test.tsx' + +function makeApi(reportFn: (msg: string) => void = () => {}) { + const j = jscodeshift.withParser('tsx') + return { + jscodeshift: j, + j, + stats: () => {}, + report: reportFn + } +} + +function makeFileInfo(source: string) { + return { path: FILE_PATH, source } +} + +describe('updateInstUIImportVersions', () => { + it('rewrites unversioned @instructure import to target version', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(`import { Button } from '@instructure/ui'`), + `import { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('rewrites versioned import from versionFrom to versionTo', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', versionFrom: 'v11.6' }, + makeFileInfo(`import { Button } from '@instructure/ui/v11_6'`), + `import { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('returns null when versionFrom does not match', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui/v11_5'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7', versionFrom: 'v11.6' } + ) + expect(result).toBeNull() + }) + + it('rewrites only imports containing the specified component', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button' }, + makeFileInfo( + [ + `import { Button } from '@instructure/ui'`, + `import { Alert } from '@instructure/ui-alerts'` + ].join('\n') + ), + // Only the import that contains 'Button' is rewritten + [ + `import { Button } from '@instructure/ui/v11_7'`, + `import { Alert } from '@instructure/ui-alerts'` + ].join('\n') + ) + }) + + it('returns null when no imports match the specified component', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Alert } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7', components: 'Button' } + ) + expect(result).toBeNull() + }) + + it('returns null for non-@instructure imports', () => { + // Includes component names that are in versionedExports but come from other packages — + // the codemod must check the package, not just the import name. + const cases = [ + `import { something } from 'some-lib'`, + `import { Button } from 'react-bootstrap'`, + `import { Button } from '@mui/material'`, + `import { Alert } from 'antd'` + ] + for (const source of cases) { + const result = updateInstUIImportVersions( + makeFileInfo(source), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result, source).toBeNull() + } + }) + + it('does not rewrite non-versioned components', () => { + // 'withStyle' is not in versionedExports, so it should be skipped + const result = updateInstUIImportVersions( + makeFileInfo(`import { withStyle } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result).toBeNull() + }) + + it('does not split mixed @instructure/ui import: moves all specifiers to versioned path', () => { + // @instructure/ui version files re-export everything including non-component + // symbols (e.g. canvas), so splitting is unnecessary — rewrite the whole import. + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(`import {canvas, Button} from "@instructure/ui"`), + `import { canvas, Button } from '@instructure/ui/v11_7'` + ) + }) + + it('splits mixed import from an individual @instructure package: non-component stays, component moves', () => { + // TestComponent is not in versionedExports, Button is → triggers a split + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo( + `import {TestComponent, Button} from "@instructure/ui-button"` + ), + // TestComponent is not in versionedExports, Button is → split + `import { TestComponent } from '@instructure/ui-button'\nimport { Button } from '@instructure/ui-button/v11_7'` + ) + }) + + it('does not add semicolons to split imports when usePrettier=false and file has no semis', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', usePrettier: false }, + makeFileInfo( + `import {TestComponent, Button} from '@instructure/ui-button'` + ), + `import { TestComponent } from '@instructure/ui-button'\nimport { Button } from '@instructure/ui-button/v11_7'` + ) + }) + + it('preserves semicolons in split imports when file uses semis and usePrettier=false', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', usePrettier: false }, + makeFileInfo( + `import {TestComponent, Button} from '@instructure/ui-button';` + ), + `import { TestComponent } from '@instructure/ui-button';\nimport { Button } from '@instructure/ui-button/v11_7';` + ) + }) + + it('moves type specifier to versioned path when its name is a known component', () => { + // 'type Alert' — inline type modifier, Alert is in versionedExports + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button,Alert' }, + makeFileInfo(`import { Button, type Alert } from '@instructure/ui'`), + `import { Button, type Alert } from '@instructure/ui/v11_7'` + ) + }) + + it('moves all specifiers to versioned path when source is @instructure/ui, even with --components filter', () => { + // ButtonProps is not in the components filter, but @instructure/ui + // version files re-export everything — no split, whole import moves. + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button' }, + makeFileInfo( + `import { Button, type ButtonProps } from '@instructure/ui'` + ), + `import { Button, type ButtonProps } from '@instructure/ui/v11_7'` + ) + }) + + it('leaves default imports and type-only named imports untouched when not in components', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import Service, { type Config } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + // Neither 'Service' (default) nor 'Config' (type, not in versionedExports) match + expect(result).toBeNull() + }) + + it('returns null when versionTo is not provided', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui'`), + makeApi() as Parameters[1], + {} + ) + expect(result).toBeNull() + }) + + it('diagnose mode reports matching imports', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button, Alert } from '@instructure/ui/v11_6'`), + api as Parameters[1], + { diagnose: true } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('Button') + expect(reported[0]).toContain('Alert') + expect(reported[0]).toContain('v11_6') + }) + + it('diagnose mode filters by component name', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button, Alert } from '@instructure/ui/v11_6'`), + api as Parameters[1], + { diagnose: true, components: 'Button' } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('Button') + expect(reported[0]).not.toContain('Alert') + }) + + it('diagnose mode shows "oldest" label for unversioned imports', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui'`), + api as Parameters[1], + { diagnose: true } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('oldest') + }) + + it('rewrites all matching imports in a file with 10+ @instructure imports', () => { + const input = [ + `import React from 'react'`, + `import { Alert } from '@instructure/ui'`, + `import { Avatar } from '@instructure/ui'`, + `import { Badge } from '@instructure/ui'`, + `import { Button } from '@instructure/ui'`, + `import { Checkbox } from '@instructure/ui'`, + `import { Flex, FlexItem } from '@instructure/ui'`, + `import { Heading } from '@instructure/ui'`, + `import { Modal, ModalBody, ModalFooter } from '@instructure/ui'`, + `import { Select } from '@instructure/ui'`, + `import { Spinner } from '@instructure/ui'`, + `import { Text } from '@instructure/ui'`, + `import { TextInput } from '@instructure/ui'`, + `import type { SomeType } from 'some-lib'` + ].join('\n') + + const expected = [ + `import React from 'react'`, + `import { Alert } from '@instructure/ui/v11_7'`, + `import { Avatar } from '@instructure/ui/v11_7'`, + `import { Badge } from '@instructure/ui/v11_7'`, + `import { Button } from '@instructure/ui/v11_7'`, + `import { Checkbox } from '@instructure/ui/v11_7'`, + `import { Flex, FlexItem } from '@instructure/ui/v11_7'`, + `import { Heading } from '@instructure/ui/v11_7'`, + `import { Modal, ModalBody, ModalFooter } from '@instructure/ui/v11_7'`, + `import { Select } from '@instructure/ui/v11_7'`, + `import { Spinner } from '@instructure/ui/v11_7'`, + `import { Text } from '@instructure/ui/v11_7'`, + `import { TextInput } from '@instructure/ui/v11_7'`, + `import type { SomeType } from 'some-lib'` + ].join('\n') + + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(input), + expected + ) + }) + + it('rewrites named imports from individual @instructure packages', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo( + [ + `import { Button } from '@instructure/ui-button'`, + `import { Alert } from '@instructure/ui-alerts'`, + `import { Spinner } from '@instructure/ui-spinner'` + ].join('\n') + ), + [ + `import { Button } from '@instructure/ui-button/v11_7'`, + `import { Alert } from '@instructure/ui-alerts/v11_7'`, + `import { Spinner } from '@instructure/ui-spinner/v11_7'` + ].join('\n') + ) + }) + + it('does not rewrite default imports from @instructure packages', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import Button from '@instructure/ui-button'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result).toBeNull() + }) +}) diff --git a/packages/ui-codemods/lib/index.ts b/packages/ui-codemods/lib/index.ts index 4dae8dd778..166f9b0e2f 100644 --- a/packages/ui-codemods/lib/index.ts +++ b/packages/ui-codemods/lib/index.ts @@ -30,6 +30,7 @@ import renameGetComputedStyleToGetCSSStyleDeclaration from './renameGetComputedS import warnTableCaptionMissing from './warnTableCaptionMissing' import warnCodeEditorRemoved from './warnCodeEditorRemoved' import migrateToNewIcons from './migrateToNewIcons' +import updateInstUIImportVersions from './updateInstUIImportVersions' export { updateV10Breaking, @@ -39,5 +40,6 @@ export { renameGetComputedStyleToGetCSSStyleDeclaration, warnTableCaptionMissing, warnCodeEditorRemoved, - migrateToNewIcons + migrateToNewIcons, + updateInstUIImportVersions } diff --git a/packages/ui-codemods/lib/updateInstUIImportVersions.ts b/packages/ui-codemods/lib/updateInstUIImportVersions.ts new file mode 100644 index 0000000000..be04cb3483 --- /dev/null +++ b/packages/ui-codemods/lib/updateInstUIImportVersions.ts @@ -0,0 +1,278 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { + API, + ASTPath, + Collection, + FileInfo, + ImportDeclaration, + ImportDefaultSpecifier, + ImportNamespaceSpecifier, + ImportSpecifier, + JSCodeshift, + Transform +} from 'jscodeshift' +import type { InstUICodemod } from './utils/instUICodemodExecutor' +import { findImportPath, printWarning } from './utils/codemodHelpers' +import { isImportSpecifier } from './utils/codemodTypeCheckers' +import instUICodemodExecutor from './utils/instUICodemodExecutor' +import versionedExports from './generated/versionedExports' + +type ParsedImportPath = { + base: string + version: string +} +type Specifier = + | ImportSpecifier + | ImportDefaultSpecifier + | ImportNamespaceSpecifier + +function normalizeVersion(raw: string): string { + return raw.replace(/\./g, '_') +} + +// parseImportPath('@instructure/ui-button') → { base: '@instructure/ui-button', version: '' } +// parseImportPath('@instructure/ui-button/v11_7') → { base: '@instructure/ui-button', version: 'v11_7' } +// parseImportPath('react') → null +function parseImportPath(importPath: string): ParsedImportPath | null { + const match = importPath.match(/^(@instructure\/[^/]+)(\/(.+))?$/) + if (!match) return null + return { base: match[1], version: match[3] ?? '' } +} + +function buildImportPath(packageName: string, version: string): string { + return version ? `${packageName}/${version}` : packageName +} + +// detectQuoteStyle(`import { Foo } from "@instructure/ui-button"`) → 'double' +// detectQuoteStyle(`import { Foo } from '@instructure/ui-button'`) → 'single' +function detectQuoteStyle(source: string): 'single' | 'double' { + const match = source.match(/from\s+(['"])@instructure\//) + return match?.[1] === '"' ? 'double' : 'single' +} + +// detectSemi(`import { Foo } from '@instructure/ui-button';`) → true +// detectSemi(`import { Foo } from '@instructure/ui-button'`) → false +function detectSemi(source: string): boolean { + const importLine = source.match( + /^import\b[^'"\n]*from[ \t]+['"][^'"]+['"][^\n]*/m + )?.[0] + return importLine ? /;[ \t]*$/.test(importLine) : false +} + +// stripImportSemis(`import { Foo } from '@instructure/ui-button';`) → `import { Foo } from '@instructure/ui-button'` +function stripImportSemis(source: string): string { + return source.replace( + /^(import\b[^'"\n]*from[ \t]+['"][^'"]+['"][ \t]*);$/gm, + '$1' + ) +} + +function parseComponents(raw: unknown): string[] | undefined { + return raw + ? String(raw) + .split(',') + .map((s) => s.trim()) + : undefined +} + +// Diagnose mode: reports all @instructure imports found in the file. +function runDiagnose( + j: JSCodeshift, + root: Collection, + file: FileInfo, + api: API, + components: string[] | undefined +): void { + findImportPath(j, root, '@instructure/', false).forEach( + (path: ASTPath) => { + const importPath = path.node.source.value as string + const parsed = parseImportPath(importPath) + if (!parsed) return + + const matched: string[] = [] + path.node.specifiers?.forEach((specifier: Specifier) => { + if (isMatchingSpecifier(specifier, components)) + matched.push(String(specifier.imported.name)) + }) + + if (matched.length === 0) return + + const line = path.node.loc?.start.line ?? 0 + api.report( + `${file.path}:${line} ${importPath} [${ + parsed.version || 'oldest' + }] { ${matched.join(', ')} }` + ) + } + ) +} + +function isMatchingSpecifier( + s: Specifier, + components: string[] | undefined +): s is ImportSpecifier { + return ( + isImportSpecifier(s) && + (!components || components.includes(String(s.imported.name))) + ) +} + +// Transform mode: rewrites @instructure import paths to the target version. +// When a single import mixes versioned components with unversioned symbols, +// the import is split: unversioned symbols stay at the original path, versioned +// components are moved to a new import at the target path. +const updateInstUIImportVersionsCodemod = + ( + components: string[] | undefined, + versionTo: string, + versionFrom: string | undefined, + report?: (msg: string) => void + ): InstUICodemod => + (j, root, filePath) => { + let hasChanges = false + + findImportPath(j, root, '@instructure/', false).forEach( + (path: ASTPath) => { + const importPath = path.node.source.value as string + const parsed = parseImportPath(importPath) + if (!parsed) return + + if (versionFrom && parsed.version !== versionFrom) return + + const allSpecifiers = path.node.specifiers ?? [] + const matching = allSpecifiers.filter((s) => + isMatchingSpecifier(s, components) + ) + + if (matching.length === 0) return + + const newPath = buildImportPath(parsed.base, versionTo) + if (newPath === importPath) return + + const remaining = allSpecifiers.filter( + (s) => !isMatchingSpecifier(s, components) + ) + + const line = path.node.loc?.start.line ?? 0 + + if (remaining.length === 0 || parsed.base === '@instructure/ui') { + // Simple path rewrite: either all specifiers match, or the source + // is @instructure/ui whose version files re-export every + // symbol (including non-component utilities like canvas) — no split + // needed. Individual @instructure/* packages are split below because + // they don't have version files for their non-component exports. + // eslint-disable-next-line no-param-reassign + path.node.source.value = newPath + report?.(`${filePath}:${line} '${importPath}' → '${newPath}'`) + } else { + // Mixed import from an individual @instructure package — split: + // keep non-matching specifiers at the original path, + // move matching specifiers to the versioned path. + // eslint-disable-next-line no-param-reassign + path.node.specifiers = remaining + const newDecl = j.importDeclaration( + matching, + j.stringLiteral(newPath) + ) + newDecl.importKind = path.node.importKind + path.insertAfter(newDecl) + report?.( + `${filePath}:${line} '${importPath}' → (split) '${newPath}'` + ) + } + + hasChanges = true + } + ) + + return hasChanges + } + +const updateInstUIImportVersions: Transform = (file, api, options) => { + // When no --components filter is given, fall back to the full list of known + const components = parseComponents(options.components) ?? [ + ...versionedExports + ] + + if (options.diagnose) { + const j = api.jscodeshift.withParser('tsx') + let root + try { + root = j(file.source) + } catch (e) { + printWarning( + file.path, + undefined, + `Failed to parse file: ${e instanceof Error ? e.message : String(e)}` + ) + return null + } + runDiagnose(j, root, file, api, components) + return null + } + + if (!options.versionTo) return null + + const versionTo = normalizeVersion(String(options.versionTo)) + const versionFrom = options.versionFrom + ? normalizeVersion(String(options.versionFrom)) + : undefined + + const quote = detectQuoteStyle(file.source) + const hasSemi = detectSemi(file.source) + + try { + const result = instUICodemodExecutor( + updateInstUIImportVersionsCodemod( + components, + versionTo, + versionFrom, + api.report + ), + file, + api, + { ...options, quote } + ) + // jscodeshift's pretty-printer adds semicolons to import statements by + // default, but we want to preserve the original style when not using + // Prettier. If Prettier is enabled, we assume it will handle semicolons + // according to the user's configuration. + if (result && !hasSemi && options.usePrettier === false) { + return stripImportSemis(result) + } + return result + } catch (e) { + printWarning( + file.path, + undefined, + `Transform failed: ${e instanceof Error ? e.message : String(e)}` + ) + throw e + } +} + +export default updateInstUIImportVersions +export { updateInstUIImportVersionsCodemod } diff --git a/packages/ui-codemods/lib/utils/codemodHelpers.ts b/packages/ui-codemods/lib/utils/codemodHelpers.ts index 9998d36223..3bbde9ce94 100644 --- a/packages/ui-codemods/lib/utils/codemodHelpers.ts +++ b/packages/ui-codemods/lib/utils/codemodHelpers.ts @@ -728,6 +728,7 @@ export { findAttribute, findImport, findEveryImport, + findImportPath, // editing replaceImport, addImportIfNeeded, diff --git a/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts b/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts index 30cd4fc1c7..48d942e25b 100644 --- a/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts +++ b/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts @@ -56,7 +56,11 @@ const instUICodemodExecutor = ( instUICodemods: InstUICodemod[] | InstUICodemod, file: FileInfo, api: API, - options?: { fileName?: string; usePrettier?: boolean } + options?: { + fileName?: string + usePrettier?: boolean + quote?: 'single' | 'double' | 'auto' + } ) => { const j = api.jscodeshift.withParser('tsx') const root = j(file.source) @@ -80,7 +84,7 @@ const instUICodemodExecutor = ( const shouldUsePrettier = options?.usePrettier !== false return shouldUsePrettier ? formatSource(root.toSource(), file.path) - : root.toSource() + : root.toSource({ quote: options?.quote ?? 'double' }) } else { return null } diff --git a/packages/ui-codemods/package.json b/packages/ui-codemods/package.json index 1b275c260e..354e445bba 100644 --- a/packages/ui-codemods/package.json +++ b/packages/ui-codemods/package.json @@ -15,7 +15,8 @@ "test:vitest": "vitest --watch=false", "lint": "ui-scripts lint", "lint:fix": "ui-scripts lint --fix", - "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false" + "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false", + "generate:versioned-exports": "node --experimental-strip-types scripts/generateVersionedExports.ts" }, "license": "MIT", "dependencies": { diff --git a/packages/ui-codemods/scripts/generateVersionedExports.ts b/packages/ui-codemods/scripts/generateVersionedExports.ts new file mode 100644 index 0000000000..75c3398f49 --- /dev/null +++ b/packages/ui-codemods/scripts/generateVersionedExports.ts @@ -0,0 +1,135 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Generates a list of all versioned components based on the @instructure/ui meta package + * Run with: node --experimental-strip-types scripts/generateVersionedExports.ts + * from the packages/ui-codemods directory. + */ + +import { resolve } from 'node:path' +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import ts from 'typescript' + +// '@instructure/ui-button/v8_0' → matches; '@instructure/ui-button' or 'react' → no match +const VERSIONED_INSTUI = /^@instructure\/[^/]+\/v\d+_\d+$/ + +const uiSrcDir = resolve(import.meta.dirname, '../../ui/src') +const outputFile = resolve( + import.meta.dirname, + '../lib/generated/versionedExports.ts' +) + +// parseVersionNumbers('v8_0.ts') → [8, 0] +// parseVersionNumbers('v10_3.ts') → [10, 3] +// parseVersionNumbers('other.ts') → [0, 0] +function parseVersionNumbers(filename: string): [number, number] { + const m = filename.match(/^v(\d+)_(\d+)\.ts$/) + return m ? [Number(m[1]), Number(m[2])] : [0, 0] +} + +function findLatestVersionFile(dir: string): string { + const latest = readdirSync(dir) + .filter((f) => /^v\d+_\d+\.ts$/.test(f)) // matches 'v8_0.ts', 'v10_3.ts'; skips 'index.ts' etc. + .sort((a, b) => { + const [amaj, amin] = parseVersionNumbers(a) + const [bmaj, bmin] = parseVersionNumbers(b) + return amaj - bmaj || amin - bmin + }) + .at(-1) + if (!latest) throw new Error(`No version files found in ${dir}`) + return resolve(dir, latest) +} + +function isVersionedInstUIExport(node: ts.ExportDeclaration): boolean { + const { moduleSpecifier } = node + return ( + moduleSpecifier !== undefined && + ts.isStringLiteral(moduleSpecifier) && + VERSIONED_INSTUI.test(moduleSpecifier.text) + ) +} + +function getExportedNames(node: ts.ExportDeclaration): string[] { + const { exportClause } = node + if (!exportClause || !ts.isNamedExports(exportClause)) return [] + return exportClause.elements.map((el) => el.name.text) +} + +function parseVersionedComponents(filePath: string): string[] { + const source = readFileSync(filePath, 'utf-8') + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest + ) + + return sourceFile.statements + .filter(ts.isExportDeclaration) + .filter(isVersionedInstUIExport) + .flatMap(getExportedNames) +} + +function generateFileContent(components: string[]): string { + const nameList = components.map((n) => `'${n}'`).join(', ') + return `\ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Auto-generated by scripts/generateVersionedExports.ts — do not edit manually. +// Re-run the script when a new version file is added to @instructure/ui. + +const versionedExports: string[] = [${nameList}] + +export default versionedExports +` +} + +const filePath = findLatestVersionFile(uiSrcDir) +const components = parseVersionedComponents(filePath) + +mkdirSync(resolve(outputFile, '..'), { recursive: true }) +writeFileSync(outputFile, generateFileContent(components), 'utf-8') +process.stdout.write(`Written: ${outputFile}\n`) diff --git a/scripts/bootstrap.js b/scripts/bootstrap.js index ee8bc58bd5..76331e631b 100755 --- a/scripts/bootstrap.js +++ b/scripts/bootstrap.js @@ -1,5 +1,4 @@ #! /usr/bin/env node -/* eslint-disable no-console */ /* * The MIT License (MIT) @@ -39,6 +38,14 @@ function buildProject() { execSync('pnpm --filter @instructure/ui-icons prepare-build', opts) + // Executes a ui-codemods script to generate a versioned components list + // from the ui metapackage's latest re-export file. This is required for + // the updateInstUIImportVersions codemod's diagnose mode. + execSync( + 'pnpm --filter @instructure/ui-codemods generate:versioned-exports', + opts + ) + console.info('Building packages with Babel...') try { execSync('pnpm run build', opts)