diff --git a/.changeset/config.json b/.changeset/config.json index 67eba4b98a..60a8f22d4f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,9 +8,12 @@ "fixed": [ [ "@skillsoft/gamut-agent-tools", + "@skillsoft/gamut-codemods", "@skillsoft/gamut-icons", + "@skillsoft/gamut-illustrations", "@skillsoft/gamut-patterns", "@skillsoft/gamut-styles", + "@skillsoft/gamut-tests", "@skillsoft/gamut", "@skillsoft/variance" ] diff --git a/.changeset/consumer-friendly-deps.md b/.changeset/consumer-friendly-deps.md new file mode 100644 index 0000000000..7ff73112d2 --- /dev/null +++ b/.changeset/consumer-friendly-deps.md @@ -0,0 +1,18 @@ +--- +'@skillsoft/gamut': patch +'@skillsoft/gamut-styles': patch +'@skillsoft/gamut-icons': patch +'@skillsoft/gamut-illustrations': patch +'@skillsoft/gamut-patterns': patch +'@skillsoft/gamut-tests': patch +'@skillsoft/variance': patch +--- + +Fewer packages to install by hand. `@skillsoft/gamut` now needs only `react`, `react-dom`, `@emotion/react`, and `@emotion/styled` alongside it. + +- `@skillsoft/gamut-styles`: `stylis`, `lodash`, and `@emotion/cache` are now regular dependencies instead of peer dependencies. +- `@skillsoft/gamut-icons`: dropped an unused `lodash` peer dependency. +- `@skillsoft/variance`: the `typescript` peer dependency is now optional. +- `@skillsoft/*` packages depend on each other with `^` ranges instead of exact versions, so installs share one copy of `gamut-styles` and `variance`. +- `@skillsoft/gamut-tests`: `component-test-setup` is now `^0.3.1` instead of `*`. +- `@skillsoft/gamut`: `@types/marked` moved to devDependencies. The published types don't reference it. diff --git a/.changeset/gamut-codemods-scope-swap.md b/.changeset/gamut-codemods-scope-swap.md new file mode 100644 index 0000000000..f7d48bf20d --- /dev/null +++ b/.changeset/gamut-codemods-scope-swap.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +Add `@skillsoft/gamut-codemods` with a `scope-swap` preset for moving a repo from `@codecademy/gamut*` to `@skillsoft/gamut*`. Run `npx @skillsoft/gamut-codemods scope-swap .` from a clean working tree. It renames imports, mocks, and dependencies, moves `Video` imports to `@skillsoft/gamut/Video`, rewrites `/dist/` deep imports, replaces `@codecademy/gamut-kit`, and finishes with a list of what's left to fix by hand. diff --git a/.changeset/gamut-form-field-styles.md b/.changeset/gamut-form-field-styles.md new file mode 100644 index 0000000000..d75fa130a5 --- /dev/null +++ b/.changeset/gamut-form-field-styles.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut': minor +--- + +Export `formFieldStyles`, `formFieldPaddingStyles`, and `conditionalStyles` from the package root, for custom inputs that should look like `Input`, such as third-party hosted payment fields. The rest of `Form/styles` stays internal. Coming from `@codecademy/gamut/dist/Form/styles`, `npx @skillsoft/gamut-codemods scope-swap .` rewrites imports of these three. diff --git a/.changeset/gamut-menu-selectdropdown-renames.md b/.changeset/gamut-menu-selectdropdown-renames.md new file mode 100644 index 0000000000..84bcef371e --- /dev/null +++ b/.changeset/gamut-menu-selectdropdown-renames.md @@ -0,0 +1,15 @@ +--- +'@skillsoft/gamut': minor +--- + +**Breaking:** export Menu's list elements and SelectDropdown's option component from the package root, under new names that don't collide with the public `List` component and `IconOption` type. + +| Old (`dist/Menu/elements`, `dist/Form/SelectDropdown/elements`) | New (`@skillsoft/gamut`) | +| --------------------------------------------------------------- | ----------------------------------- | +| `List`, `ListProps` | `MenuList`, `MenuListProps` | +| `ListItem`, `ListItemProps` | `MenuListItem`, `MenuListItemProps` | +| `ListLink`, `ListLinkProps` | `MenuListLink`, `MenuListLinkProps` | +| `ListButton` | `MenuListButton` | +| `IconOption` (the component) | `IconOptionComponent` | + +`MenuToolTipWrapper` is now exported from the root too. The `IconOption` type is unchanged. Coming from `@codecademy/gamut`, `npx @skillsoft/gamut-codemods scope-swap .` rewrites these imports for you. diff --git a/.changeset/gamut-video-node10-and-types.md b/.changeset/gamut-video-node10-and-types.md new file mode 100644 index 0000000000..2fdaa4c386 --- /dev/null +++ b/.changeset/gamut-video-node10-and-types.md @@ -0,0 +1,6 @@ +--- +'@skillsoft/gamut': patch +--- + +- `@skillsoft/gamut/Video` now resolves under TypeScript's `moduleResolution: "node"` (node10) and in bundlers that ignore `package.json#exports`, through a `Video/package.json` stub. +- Export the `HTMLToReactNode` and `ButtonBaseProps` types from the package root. diff --git a/.changeset/nice-peaches-hear.md b/.changeset/nice-peaches-hear.md new file mode 100644 index 0000000000..2bdfb62ff3 --- /dev/null +++ b/.changeset/nice-peaches-hear.md @@ -0,0 +1,12 @@ +--- +'@skillsoft/eslint-plugin-gamut': patch +'@skillsoft/gamut': patch +'@skillsoft/gamut-icons': patch +'@skillsoft/gamut-illustrations': patch +'@skillsoft/gamut-patterns': patch +'@skillsoft/gamut-styles': patch +'@skillsoft/gamut-tests': patch +'@skillsoft/variance': patch +--- + +Swap Emotion's transform to swc/plugin and explicit type exporting diff --git a/.eslintignore b/.eslintignore index 790b3f75f1..3b8050a6ca 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,4 @@ packages/gamut-patterns/src/patterns packages/code-connect .nx packages/gamut-agent-tools/skills/*-workspace +packages/gamut-codemods/**/__testfixtures__ diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 441f68ea96..d1caef6201 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -16,7 +16,10 @@ name: Preview on: pull_request: - branches: [main] + # TEMPORARY: cass-gmt-1795 is here so PRs stacked on it (the + # @skillsoft/gamut-codemods PR) publish previews. Revert this before + # either branch ships to main. + branches: [main, cass-gmt-1795] env: NODE_OPTIONS: '--max_old_space_size=8196' diff --git a/.prettierignore b/.prettierignore index 104cb1cfe7..17524c399a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,8 @@ packages/gamut-patterns/src/patterns packages/gamut-styles/**/*.d.ts packages/styleguide/stories/Core/Atoms/Markdown/*.md packages/code-connect/**/* +# Codemod fixtures are exact expected output, formatting included. +packages/gamut-codemods/**/__testfixtures__ /.nx/cache /.nx/workspace-data diff --git a/packages/gamut-codemods/.gitignore b/packages/gamut-codemods/.gitignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/gamut-codemods/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md new file mode 100644 index 0000000000..3372a62581 --- /dev/null +++ b/packages/gamut-codemods/README.md @@ -0,0 +1,131 @@ +# @skillsoft/gamut-codemods + +Codemods for moving a repo onto `@skillsoft/gamut` and between its releases. + +## Presets + +Each preset is one upgrade. Run them in order if you're more than one behind. + +| Preset | What it's for | +| ------------ | ------------------------------------------------------- | +| `scope-swap` | Moving from `@codecademy/gamut*` to `@skillsoft/gamut*` | + +`npx @skillsoft/gamut-codemods list` shows every preset and the migrations inside it. + +## Running scope-swap + +Start from a clean working tree. The codemod won't write into uncommitted changes (pass `--force` to override), so `git checkout .` always undoes a run. + +```sh +# Preview without writing anything +npx @skillsoft/gamut-codemods scope-swap . --dry + +# Run it +npx @skillsoft/gamut-codemods scope-swap . + +# Run only some migrations +npx @skillsoft/gamut-codemods scope-swap . --only=moved-exports,scope-rename +``` + +A run ends with three things: + +1. **Warnings**, prefixed with the migration name, e.g. `[moved-exports] src/Player.test.tsx:12 mock of '@codecademy/gamut' stubs Video/VideoProps`. Each one says what to do. +2. **Leftovers**: every line that still names an old package. These are for you to fix: jest `moduleNameMapper` regexes, tsconfig `paths`, flat ESLint configs, comments, and docs. +3. **Next steps**, built from what actually happened in the run. + +### Trying it against a preview build + +Every skillsoft-gamut pull request publishes installable previews through pkg.pr.new. To test a migration against one: + +1. Add the `@skillsoft/*` packages to your root `package.json` first, pointed at the preview URLs. The codemod keeps any `@skillsoft/*` entry that's already there. +2. Preview packages point at each other through commit-pinned URLs, so without help yarn installs a second copy of `gamut-styles`, `variance`, and so on under `@skillsoft/gamut/node_modules`. Pin every `@skillsoft/*` package you use with `resolutions`: + + ```json + "resolutions": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@", + "@skillsoft/gamut-styles": "https://pkg.pr.new/@skillsoft/gamut-styles@", + "@skillsoft/variance": "https://pkg.pr.new/@skillsoft/variance@" + } + ``` + + Published releases don't need this. The packages share one version through the changesets `fixed` group, so their dependencies on each other line up. + +3. Commit, then run the codemod (or pass `--force`). + +### What scope-swap changes + +| Migration | What it does | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Menu's `List*` and SelectDropdown's `IconOption` component were renamed on the way to the root (`MenuList*`, `IconOptionComponent`); local names are kept, so `import { List }` becomes `import { MenuList as List }`. `Form/styles` is only partly public (`formFieldStyles`, `formFieldPaddingStyles`, `conditionalStyles`), so imports of anything else from it are left alone with a warning. Also warns on deep imports with no public replacement (`ButtonBase/ButtonBase`) and on ones it doesn't know. | +| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | +| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | +| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | +| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | +| `package-json` | Renames dependencies and sets their versions. A new package that's already listed keeps its value (a preview URL, say), and a `*` peer range stays `*`. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | +| `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | +| `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | +| `tsconfig-dom` | Report only. Warns about `tsconfig` files whose `lib` has no `"dom"`, since Video used to supply the DOM types through the root import. | + +### Known limits + +- Every file is parsed with the `tsx` parser, so Flow-typed JS won't parse. +- Flat ESLint configs (`eslint.config.*`) and `.eslintrc.js` aren't rewritten. In a flat config the rule prefix is whatever key you register the plugin under, so renaming rule keys blindly would break it. They show up in the leftovers. +- The leftovers report uses regex, not an AST, so expect some false positives. + +## Contributing + +### How it's put together + +``` +src/ + presets/ + index.ts # every preset the CLI can run + / + index.ts # ordered migrations + next-steps checklist + manifest.ts # the data: what changed in this release + migrations// + index.ts # a reusable migration + __testfixtures__/ # .input., .output., optional .warnings.json + lib/ # shared types, module-string finder, leftovers, git guard + transform.ts # jscodeshift entry point + cli.ts, bin.ts +``` + +Two rules hold it together: + +- **Manifests hold the data; migrations hold the code.** Most Gamut changes need a manifest row and a fixture, not a new migration. +- **Manifests use the old package names.** In `scope-swap`, `scope-rename` runs last and swaps the scope, so nothing before it needs to know the new one. + +### Three kinds of migration + +- **`splice`** (the default choice): uses the AST to find ranges, then edits the original text by offset. Use it for anything that swaps one string for another. recast never prints the file, so nothing else in it moves. +- **`ast`**: mutates the AST, and recast prints the file. Only for structural rewrites like splitting an import. recast can reprint more than you changed: mutating one string literal in a semicolon-less file added semicolons and changed JSX text whitespace in neighbouring statements. Presets must list every `ast` migration before any `splice` one, and `validatePreset` enforces it. +- **`file`**: gets raw text for files its `match()` accepts (package.json, .eslintrc). + +### Changing a preset's manifest + +1. Add the row to `src/presets//manifest.ts`. +2. Add a fixture pair under the migration that handles it, e.g. `src/migrations/moved-exports/__testfixtures__/my-case.input.tsx` and `.output.tsx`. +3. If it should warn, add `my-case.warnings.json`: a list of substrings that must appear in the warnings. +4. `yarn nx test gamut-codemods`. + +`manifest.test.ts` type-checks every export a manifest points at against the built packages, so a row can't promise an export that doesn't exist. + +### Adding a migration + +1. Create `src/migrations//index.ts` exporting an `AstMigration`, `SpliceMigration`, or `FileMigration` (see `src/lib/types.ts`). +2. Add it to the presets that need it. In `scope-swap`, put it before `scope-rename` if it matches on old names. +3. Add fixtures. They're picked up automatically, and every source migration is also checked for idempotence. +4. If you find a new place a module name can appear, teach `lib/module-strings.ts` rather than special-casing it. +5. If it leaves follow-up work, call `note('')` and add the key to the preset's `conditionalChecklist`. + +### Adding a preset + +Create `src/presets//` with a manifest and an `index.ts`, register it in `src/presets/index.ts`, and reuse migrations from `src/migrations`. + +### recast gotchas already hit + +- Rebuild specifiers instead of mutating them. Changing `imported` on `import { List }` drops the alias, because `imported` and `local` share one source range. +- Clone nodes before moving them into a new declaration, or recast's patcher can crash. +- `insertAfter` and `replace(a, b)` copy the gap after the original node, which can leave a stray blank line. `moved-exports.postPrint` closes that specific gap. diff --git a/packages/gamut-codemods/jest.config.ts b/packages/gamut-codemods/jest.config.ts new file mode 100644 index 0000000000..dc5f1ad9f1 --- /dev/null +++ b/packages/gamut-codemods/jest.config.ts @@ -0,0 +1,19 @@ +/** @jest-config-loader ts-node */ +/* eslint-disable */ +import base from '../../jest.config.base'; + +export default base('gamut-codemods', { + testEnvironment: 'node', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + /* Fixtures are inputs, not tests, and may be deliberately odd code. */ + testPathIgnorePatterns: ['node_modules', 'dist', '__testfixtures__'], + setupFiles: [], + setupFilesAfterEnv: [], +}); diff --git a/packages/gamut-codemods/package.json b/packages/gamut-codemods/package.json new file mode 100644 index 0000000000..6acbecb748 --- /dev/null +++ b/packages/gamut-codemods/package.json @@ -0,0 +1,38 @@ +{ + "name": "@skillsoft/gamut-codemods", + "description": "Codemods for upgrading to and between @skillsoft/gamut releases", + "version": "0.0.1", + "author": "Skillsoft Engineering ", + "bin": "./dist/bin.js", + "dependencies": { + "jscodeshift": "^17.3.0" + }, + "devDependencies": { + "@skillsoft/gamut": "workspace:*", + "@types/jscodeshift": "^17.3.0" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist" + ], + "keywords": [ + "codemod", + "gamut", + "jscodeshift", + "migration" + ], + "license": "MIT", + "main": "./dist/cli.js", + "publishConfig": { + "access": "public" + }, + "repository": "Codecademy/skillsoft-gamut.git", + "scripts": { + "build": "nx build gamut-codemods", + "verify": "tsc --noEmit -p tsconfig.lib.json && tsc --noEmit -p tsconfig.spec.json" + }, + "types": "./dist/cli.d.ts", + "type": "commonjs" +} diff --git a/packages/gamut-codemods/project.json b/packages/gamut-codemods/project.json new file mode 100644 index 0000000000..91d962dcc2 --- /dev/null +++ b/packages/gamut-codemods/project.json @@ -0,0 +1,46 @@ +{ + "name": "gamut-codemods", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/gamut-codemods/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/gamut-codemods", + "commands": [ + "rm -rf ./dist", + "tsc -p tsconfig.lib.json", + "chmod +x ./dist/bin.js" + ], + "parallel": false + } + }, + "test": { + "executor": "@nx/jest:jest", + "dependsOn": ["^build"], + "outputs": ["{workspaceRoot}/coverage/packages/gamut-codemods"], + "options": { + "jestConfig": "packages/gamut-codemods/jest.config.ts" + } + }, + "verify-package": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "cwd": "{projectRoot}", + "commands": ["publint ."], + "parallel": false + } + }, + "publish-build": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "commands": [], + "parallel": false + } + } + } +} diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx new file mode 100644 index 0000000000..32a4d06d2e --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx @@ -0,0 +1,7 @@ +import { Box, Video } from '@codecademy/gamut'; +import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import { theme } from '@codecademy/gamut-styles'; + +// eslint-disable-next-line gamut/no-inline-style +export const Thing = (p: PopoverContainerProps) => ; +export const shared = { '@codecademy/gamut-kit': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx new file mode 100644 index 0000000000..2b457b6ceb --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx @@ -0,0 +1,8 @@ +import { Box } from '@skillsoft/gamut'; +import { Video } from '@skillsoft/gamut/Video'; +import { PopoverContainerProps } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; + +// eslint-disable-next-line @skillsoft/gamut/no-inline-style +export const Thing = (p: PopoverContainerProps) => ; +export const shared = { '@skillsoft/gamut': { singleton: true }, '@skillsoft/gamut-icons': { singleton: true }, '@skillsoft/gamut-illustrations': { singleton: true }, '@skillsoft/gamut-patterns': { singleton: true }, '@skillsoft/gamut-styles': { singleton: true }, '@skillsoft/variance': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/fixtures.test.ts b/packages/gamut-codemods/src/__tests__/fixtures.test.ts new file mode 100644 index 0000000000..cc5f0f2d48 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { applyTransform } from 'jscodeshift/src/testUtils'; + +import { presets } from '../presets'; +import * as transform from '../transform'; + +/* + Fixture-driven. Every `.input.` in a migration's + __testfixtures__ runs through that migration alone and must match + `.output.`. An optional `.warnings.json` lists substrings + that must each appear in some warning. `{{version}}` in an output stands + for this package's version. Adding a case means adding files, not code. +*/ + +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') +) as { version: string }; + +interface Case { + name: string; + ext: string; + input: string; + output: string; + warnings: string[]; +} + +const readCases = (dir: string): Case[] => { + if (!fs.existsSync(dir)) return []; + const read = (f: string) => fs.readFileSync(path.join(dir, f), 'utf8'); + return fs + .readdirSync(dir) + .filter((f) => f.includes('.input.')) + .map((input) => { + const [name, ext] = input.split('.input.'); + const warnings = `${name}.warnings.json`; + return { + name, + ext, + input: read(input), + output: read(`${name}.output.${ext}`).replace(/{{version}}/g, version), + warnings: fs.existsSync(path.join(dir, warnings)) + ? (JSON.parse(read(warnings)) as string[]) + : [], + }; + }); +}; + +const fixtureDir = (migration: string) => + path.join(__dirname, '../migrations', migration, '__testfixtures__'); + +const expectWarnings = (actual: string[], expected: string[]) => { + for (const needle of expected) { + expect(actual.join('\n')).toContain(needle); + } +}; + +const runSource = (preset: string, source: string, only?: string) => { + const warnings: string[] = []; + const out = applyTransform( + transform, + { preset, only, onWarn: (w: string) => warnings.push(w) }, + { source, path: 'fixture.tsx' }, + { parser: 'tsx' } + ); + return { out: out || source, warnings }; +}; + +describe.each(Object.values(presets))('preset $name', (preset) => { + for (const migration of preset.migrations) { + const cases = readCases(fixtureDir(migration.name)); + if (cases.length === 0) continue; + + describe(migration.name, () => { + it.each(cases)('$name', (c) => { + if (migration.kind === 'file') { + const warnings: string[] = []; + const out = migration.run({ + source: c.input, + manifest: preset.manifest, + warn: (w) => warnings.push(w), + note: () => {}, + transformSource: (code) => runSource(preset.name, code).out, + }); + expect((out ?? c.input).trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + return; + } + + const { out, warnings } = runSource( + preset.name, + c.input, + migration.name + ); + expect(out.trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + + /* Idempotent: a second run over the output changes nothing. */ + expect( + runSource(preset.name, c.output, migration.name).out.trim() + ).toBe(c.output.trim()); + }); + }); + } + + const endToEnd = readCases(path.join(__dirname, '__testfixtures__')); + if (endToEnd.length > 0) { + it.each(endToEnd)('every migration: $name', (c) => { + expect(runSource(preset.name, c.input).out.trim()).toBe(c.output.trim()); + }); + } +}); diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts new file mode 100644 index 0000000000..81de758d10 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parseArgs } from '../cli'; +import { dirtyTreeReason } from '../lib/git'; +import { findLeftovers } from '../lib/leftovers'; +import { validatePreset } from '../presets'; +import { scopeSwap } from '../presets/scope-swap'; +import { manifest } from '../presets/scope-swap/manifest'; + +const tmp = () => + fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-test-')); + +describe('validatePreset', () => { + it('accepts scope-swap', () => { + expect(() => validatePreset(scopeSwap)).not.toThrow(); + }); + + it('rejects an ast migration listed after a splice one', () => { + const [deep, moved, mf] = scopeSwap.migrations; + expect(() => + validatePreset({ ...scopeSwap, migrations: [deep, mf, moved] }) + ).toThrow(/moved-exports/); + }); +}); + +describe('parseArgs', () => { + it('reads a preset, paths, and flags', () => { + expect( + parseArgs(['scope-swap', 'a', '--dry', '--only=scope-rename']) + ).toEqual( + expect.objectContaining({ + preset: 'scope-swap', + paths: [path.resolve('a')], + dry: true, + force: false, + only: 'scope-rename', + }) + ); + }); + + it('rejects unknown flags', () => { + expect(() => parseArgs(['scope-swap', 'a', '--drry'])).toThrow(/--drry/); + }); +}); + +describe('dirtyTreeReason', () => { + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'ignore' }); + + it('refuses outside a git repo, and on uncommitted changes', () => { + const dir = tmp(); + expect(dirtyTreeReason(dir)).toMatch(/not inside a git repository/); + + git(dir, 'init', '-q'); + fs.writeFileSync(path.join(dir, 'a.ts'), ''); + expect(dirtyTreeReason(dir)).toMatch(/uncommitted changes/); + + git(dir, 'add', '-A'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'x'); + expect(dirtyTreeReason(dir)).toBeNull(); + }); +}); + +describe('findLeftovers', () => { + it('finds old names but not new names that contain them', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'a.js'), + [ + "moduleNameMapper: { '^@codecademy/gamut(.*)$': 'x' }", + "'@skillsoft/eslint-plugin-gamut'", + "'@skillsoft/gamut-styles'", + "'@codecademy/gamut-kit'", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 4]); + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts new file mode 100644 index 0000000000..f48b665496 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +import { renameSpecifier } from '../lib/manifest'; +import { presets } from '../presets'; +import { manifest } from '../presets/scope-swap/manifest'; + +/* + Guards against a manifest that promises something the packages don't + deliver. Runs against the built dist, so `^build` has to run first + (project.json wires that up). +*/ + +const REPO = path.join(__dirname, '../../../..'); +const readJson = (file: string) => + JSON.parse(fs.readFileSync(path.join(REPO, file), 'utf8')); + +/* + Every name a manifest claims is importable from a new entry point: + moved exports at their destination, and renamed deep-import exports at + theirs. Written as one virtual file so tsc resolves it the way a + consumer's bundler would, through the exports map. +*/ +const importsToCheck = () => { + const byModule = new Map>(); + const add = (spec: string | null, names: string[]) => { + if (!spec) return; + const set = byModule.get(spec) ?? new Set(); + names.forEach((name) => set.add(name)); + byModule.set(spec, set); + }; + for (const preset of Object.values(presets)) { + const m = preset.manifest; + for (const rule of m.movedExports) { + add(renameSpecifier(m, rule.to), rule.names); + } + for (const row of m.deepImports) { + if (!row.to) continue; + const to = renameSpecifier(m, row.to); + if (row.onlyNames) { + add( + to, + row.onlyNames.map((name) => row.renames?.[name] ?? name) + ); + } + if (row.renames) add(to, Object.values(row.renames)); + } + } + return [...byModule] + .map( + ([spec, names]) => + `import type { ${[...names].join(', ')} } from '${spec}';` + ) + .join('\n'); +}; + +/* `@codecademy/gamut/dist/Form/styles` -> packages/gamut/src/Form/styles(.ts|/index.tsx|...) */ +const sourceModuleFor = (deepPath: string) => { + const match = /^@codecademy\/([\w-]+)\/dist\/(.+)$/.exec(deepPath); + if (!match) return null; + const base = path.join(REPO, 'packages', match[1], 'src', match[2]); + return ( + ['.ts', '.tsx', '/index.ts', '/index.tsx'] + .map((ext) => base + ext) + .find((file) => fs.existsSync(file)) ?? null + ); +}; + +/* Export names of each module specifier, resolved like a bundler would. */ +const exportsOf = (specifiers: string[]) => { + const file = path.join(REPO, 'packages/gamut-codemods/__exports-check__.ts'); + const source = specifiers + .map((spec, i) => `import * as m${i} from '${spec}';`) + .join('\n'); + const host = ts.createCompilerHost({}); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const checker = program.getTypeChecker(); + const imports = program + .getSourceFile(file)! + .statements.filter(ts.isImportDeclaration); + return specifiers.map((_, i) => { + const symbol = checker.getSymbolAtLocation(imports[i].moduleSpecifier); + return new Set( + symbol ? checker.getExportsOfModule(symbol).map((s) => s.name) : [] + ); + }); +}; + +describe('manifests', () => { + /* + A deep-import row sends every name imported from `from` to `to`, so + everything `from` exports has to be available there: directly, via + `renames`, or be left out on purpose by listing what is public in + `onlyNames`. Otherwise the codemod writes imports that don't compile. + */ + it('only rewrite deep imports whose names all exist at the target', () => { + const rows = manifest.deepImports.filter((row) => row.to && !row.onlyNames); + const sources = rows.map((row) => sourceModuleFor(row.from)); + const targets = rows.map((row) => renameSpecifier(manifest, row.to!)!); + const found = exportsOf([ + ...sources.map((s) => s ?? 'missing'), + ...targets, + ]); + + const problems = rows.flatMap((row, i) => { + if (!sources[i]) return [`${row.from}: no source module found`]; + const available = found[rows.length + i]; + const missing = [...found[i]] + .map((name) => row.renames?.[name] ?? name) + .filter((name) => !available.has(name)); + return missing.length + ? [`${row.from} -> ${targets[i]}: missing ${missing.join(', ')}`] + : []; + }); + + expect(problems).toEqual([]); + }); + + it('only point at exports that exist in the built packages', () => { + const file = path.join( + REPO, + 'packages/gamut-codemods/__manifest-check__.ts' + ); + const host = ts.createCompilerHost({}); + const source = importsToCheck(); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const errors = ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + + expect(errors).toEqual([]); + }); + + it('rename every package to one that exists in this repo', () => { + const published = new Set( + fs + .readdirSync(path.join(REPO, 'packages')) + .map((dir) => path.join(REPO, 'packages', dir, 'package.json')) + .filter((file) => fs.existsSync(file)) + .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')).name) + ); + for (const { to } of Object.values(manifest.packages)) { + expect(published).toContain(to); + } + }); + + it('keep scope-swap target versions in step with the packages', () => { + const { fixed } = readJson('.changeset/config.json') as { + fixed: string[][]; + }; + const group = + fixed.find((g) => g.includes('@skillsoft/gamut-codemods')) ?? []; + const eslintPlugin = readJson('packages/eslint-plugin-gamut/package.json'); + + for (const [pkg, range] of Object.entries(manifest.targetVersions)) { + if (pkg === eslintPlugin.name) { + expect(range).toBe(`^${eslintPlugin.version}`); + } else { + /* Versioned by the fixed group, which is what makes GROUP_RANGE right. */ + expect(group).toContain(pkg); + } + } + }); +}); diff --git a/packages/gamut-codemods/src/bin.ts b/packages/gamut-codemods/src/bin.ts new file mode 100644 index 0000000000..1d6eb0af32 --- /dev/null +++ b/packages/gamut-codemods/src/bin.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { main } from './cli'; + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (err: unknown) => { + // eslint-disable-next-line no-console + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +); diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts new file mode 100644 index 0000000000..ed8dff62c6 --- /dev/null +++ b/packages/gamut-codemods/src/cli.ts @@ -0,0 +1,210 @@ +/* eslint-disable no-console -- it's a CLI; stdout is the interface. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import jscodeshift from 'jscodeshift'; + +import { dirtyTreeReason } from './lib/git'; +import { findLeftovers, walk } from './lib/leftovers'; +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { FileMigration, Preset } from './lib/types'; +import { getPreset, presets } from './presets'; + +/* No published types for the Runner; it's jscodeshift's programmatic API. */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Runner = require('jscodeshift/src/Runner') as { + run( + transformFile: string, + paths: string[], + options: Record + ): Promise<{ error: number }>; +}; + +const USAGE = `Usage: gamut-codemods [options] + gamut-codemods list + +Options: + --dry Don't write anything; show what would change + --force Run even if the target has uncommitted changes + --only=a,b Run only these migrations + --no-report Skip the leftovers report`; + +interface Options { + preset?: string; + paths: string[]; + dry: boolean; + force: boolean; + only?: string; + report: boolean; +} + +export const parseArgs = (argv: string[]): Options | 'help' | 'list' => { + const [command, ...rest] = argv; + if (!command || command === '-h' || command === '--help') return 'help'; + if (command === 'list') return 'list'; + + const opts: Options = { + preset: command, + paths: [], + dry: false, + force: false, + report: true, + }; + for (const arg of rest) { + if (arg === '--dry') opts.dry = true; + else if (arg === '--force') opts.force = true; + else if (arg === '--no-report') opts.report = false; + else if (arg.startsWith('--only=')) opts.only = arg.slice('--only='.length); + else if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`); + else opts.paths.push(path.resolve(arg)); + } + return opts; +}; + +const heading = (text: string) => console.log(`\n— ${text} —`); + +const runFileMigrations = ( + preset: Preset, + opts: Options, + note: (key: string) => void +) => { + const only = opts.only ? new Set(opts.only.split(',')) : null; + const active = preset.migrations.filter( + (m): m is FileMigration => m.kind === 'file' && (!only || only.has(m.name)) + ); + let changed = 0; + + for (const root of opts.paths) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + for (const migration of active) { + if (!migration.match(file)) continue; + const source = fs.readFileSync(file, 'utf8'); + const warn = (msg: string) => + console.log(`[${migration.name}] ${file} ${msg}`); + const transformSource = (code: string) => + mentionsOldNames(preset.manifest, code) + ? runSourceMigrations({ + j: jscodeshift.withParser('tsx'), + source: code, + migrations: activeSourceMigrations( + preset.migrations, + opts.only + ), + manifest: preset.manifest, + warnFor: (name) => (_node, message) => + console.log(`[${name}] ${file} ${message}`), + note, + }) + : code; + const next = migration.run({ + source, + manifest: preset.manifest, + warn, + note, + transformSource, + }); + if (next === null) continue; + changed += 1; + console.log(`${opts.dry ? 'would update' : 'updated'} ${file}`); + if (!opts.dry) fs.writeFileSync(file, next); + } + } + } + return changed; +}; + +const printChecklist = (preset: Preset, notes: Set) => { + heading('next steps'); + const items = [ + ...Object.entries(preset.conditionalChecklist) + .filter(([key]) => notes.has(key)) + .map(([, text]) => text), + ...preset.checklist, + ]; + items.forEach((item, i) => console.log(`${i + 1}. ${item}`)); +}; + +export const main = async (argv: string[]) => { + const opts = parseArgs(argv); + + if (opts === 'help') { + console.log(USAGE); + return 0; + } + if (opts === 'list') { + for (const preset of Object.values(presets)) { + console.log(`${preset.name} ${preset.description}`); + for (const m of preset.migrations) { + console.log(` ${m.name.padEnd(18)} ${m.description}`); + } + } + return 0; + } + + const preset = getPreset(opts.preset!); + if (opts.paths.length === 0) { + console.log(USAGE); + return 1; + } + + if (!opts.dry && !opts.force) { + const reasons = opts.paths.map(dirtyTreeReason).filter(Boolean); + if (reasons.length > 0) { + reasons.forEach((reason) => console.error(reason)); + console.error( + 'Re-run with --force to write anyway, or --dry to preview.' + ); + return 1; + } + } + + const notesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-')); + try { + heading('source files'); + const result = await Runner.run( + path.join(__dirname, 'transform.js'), + opts.paths, + { + preset: preset.name, + only: opts.only, + notesDir, + dry: opts.dry, + extensions: 'ts,tsx,js,jsx,mjs,cjs,mts,cts', + /* .d.ts stays in: Emotion theme augmentations import gamut-styles. */ + ignorePattern: ['**/node_modules/**', '**/dist/**', '**/build/**'], + parser: 'tsx', + babel: false, + verbose: 0, + } + ); + + const notes = new Set(fs.readdirSync(notesDir).map(decodeURIComponent)); + + heading('package.json and .eslintrc'); + const changed = runFileMigrations(preset, opts, (key) => notes.add(key)); + console.log(`${changed} file(s) ${opts.dry ? 'would change' : 'changed'}`); + + if (opts.report && opts.dry) { + heading( + 'leftovers skipped in --dry (nothing was written, so everything would show up)' + ); + } else if (opts.report) { + const hits = findLeftovers(opts.paths, preset.manifest); + heading(`leftovers (${hits.length})`); + hits.forEach((hit) => + console.log(`${hit.file}:${hit.line} ${hit.text}`) + ); + } + + if (!opts.dry) printChecklist(preset, notes); + return result.error > 0 ? 1 : 0; + } finally { + fs.rmSync(notesDir, { recursive: true, force: true }); + } +}; diff --git a/packages/gamut-codemods/src/lib/git.ts b/packages/gamut-codemods/src/lib/git.ts new file mode 100644 index 0000000000..6929f0f277 --- /dev/null +++ b/packages/gamut-codemods/src/lib/git.ts @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/* + The CLI refuses to write into a dirty tree, so `git checkout .` always + undoes a run completely and the codemod's diff never mixes with work in + progress. Returns a reason to refuse, or null if it's safe. +*/ +export const dirtyTreeReason = (target: string) => { + const cwd = fs.statSync(target).isDirectory() ? target : path.dirname(target); + let status: string; + try { + /* execFileSync with an argument array: no shell, so paths can't inject. */ + status = execFileSync('git', ['status', '--porcelain', '--', '.'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return `${target} is not inside a git repository, so a run couldn't be undone.`; + } + if (status.trim()) { + return `${target} has uncommitted changes. Commit or stash them first, so the codemod's diff stands on its own.`; + } + return null; +}; diff --git a/packages/gamut-codemods/src/lib/leftovers.ts b/packages/gamut-codemods/src/lib/leftovers.ts new file mode 100644 index 0000000000..9ddf45e9a0 --- /dev/null +++ b/packages/gamut-codemods/src/lib/leftovers.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Manifest } from './manifest'; + +/* + After the migrations run, anything still naming an old package is for a + person: jest moduleNameMapper regexes, tsconfig paths, flat ESLint + configs, comments, docs. This lists them as file:line. +*/ + +export const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.git', + '.next', + '.nx', + 'tmp', +]); +const SKIP_FILES = + /(^|\/)(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|CHANGELOG\.md)$/; +const TEXT_EXT = + /\.(m?[jt]sx?|c[jt]s|json|ya?ml|md|mdx|html|scss|css)$|(^|\/)\.eslintrc$/; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* + Old names, not already followed by a longer package name, and not the + tail of a new one (`@skillsoft/eslint-plugin-gamut` contains + `eslint-plugin-gamut`). +*/ +const oldNamesPattern = (manifest: Manifest) => { + const names = [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + ] + .sort((a, b) => b.length - a.length) + .map(escape); + const prefix = escape(manifest.eslintPlugin.from); + return new RegExp( + `(? { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name)); + } else { + yield path.join(dir, entry.name); + } + } +} + +export interface Leftover { + file: string; + line: number; + text: string; +} + +export const findLeftovers = (roots: string[], manifest: Manifest) => { + const pattern = oldNamesPattern(manifest); + const hits: Leftover[] = []; + for (const root of roots) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + if (SKIP_FILES.test(file) || !TEXT_EXT.test(file)) continue; + fs.readFileSync(file, 'utf8') + .split('\n') + .forEach((text, i) => { + if (pattern.test(text)) + hits.push({ file, line: i + 1, text: text.trim() }); + }); + } + } + return hits; +}; diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts new file mode 100644 index 0000000000..8fdc71137a --- /dev/null +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -0,0 +1,79 @@ +/* + The shape of a preset's manifest: the data describing what changed. Most + contributions should only add rows to a preset's manifest.ts plus a + fixture; migrations read from here and never hardcode package or export + names. +*/ + +export interface PackageRename { + to: string; +} + +export interface RemovedPackage { + /* Old package names to depend on instead, in package.json. */ + expandTo: string[]; + /* Old package names to share as singletons in Module Federation config. */ + mfSharedAs: string[]; + note: string; +} + +export interface MovedExport { + from: string; + to: string; + names: string[]; + note: string; +} + +export interface DeepImport { + from: string; + /* null when there's no public replacement; the migration warns instead. */ + to: string | null; + /* Old imported name -> new exported name. Local bindings are kept. */ + renames?: Record; + /* + When only some of the old module went public: the old names that work + from `to` (after `renames`). A declaration importing anything else is + left alone with a warning. manifest.test.ts fails if a row sends names + to `to` that it doesn't export, so partial rows must list these. + */ + onlyNames?: string[]; + note?: string; +} + +export interface Manifest { + /* Old package -> new package. */ + packages: Record; + /* Packages with no replacement under the new name. */ + removedPackages: Record; + /* Version range written to package.json, keyed by NEW package name. */ + targetVersions: Record; + movedExports: MovedExport[]; + deepImports: DeepImport[]; + /* ESLint plugin prefix, for rule keys, `plugin:` extends, and comments. */ + eslintPlugin: { from: string; to: string }; +} + +/* + Match a specifier against a package name, exact or with a subpath. + '@codecademy/gamut' matches '@codecademy/gamut/Video' but not + '@codecademy/gamut-styles'. Returns the subpath ('' or '/Video') or null. +*/ +export const matchPackage = (specifier: string, pkg: string) => { + if (specifier === pkg) return ''; + if (specifier.startsWith(`${pkg}/`)) return specifier.slice(pkg.length); + return null; +}; + +/* The new name for an old specifier, keeping any subpath. */ +export const renameSpecifier = (manifest: Manifest, value: string) => { + for (const [from, { to }] of Object.entries(manifest.packages)) { + const subpath = matchPackage(value, from); + if (subpath !== null) return to + subpath; + } + return null; +}; + +export const removedPackageFor = (manifest: Manifest, value: string) => + Object.keys(manifest.removedPackages).find( + (pkg) => matchPackage(value, pkg) !== null + ); diff --git a/packages/gamut-codemods/src/lib/module-strings.ts b/packages/gamut-codemods/src/lib/module-strings.ts new file mode 100644 index 0000000000..50b7a5f484 --- /dev/null +++ b/packages/gamut-codemods/src/lib/module-strings.ts @@ -0,0 +1,81 @@ +import type { Collection, JSCodeshift, StringLiteral } from 'jscodeshift'; + +/* + Finds every string in a file that names a module: import and export + sources, require(), dynamic import(), jest/vi mocks, `import('x').T` types, + and `declare module 'x'` augmentations. Migrations that rewrite a specifier + should go through here, so a new place a module name can appear only has + to be taught once. +*/ + +const CALLEE_OBJECTS = new Set(['jest', 'vi']); +const CALLEE_METHODS = new Set([ + 'mock', + 'doMock', + 'unmock', + 'dontMock', + 'requireActual', + 'requireMock', + 'importActual', + 'importMock', + 'setMock', +]); + +/* + The tsx parser produces StringLiteral; the babel/estree ones produce + Literal. Both carry babel's start/end offsets, which splice edits rely on. +*/ +export type StringNode = StringLiteral & { start: number; end: number }; + +export const isStringLiteral = (node: unknown): node is StringNode => { + const n = node as { type?: string; value?: unknown } | null; + return ( + !!n && + (n.type === 'StringLiteral' || + (n.type === 'Literal' && typeof n.value === 'string')) + ); +}; + +/* + Identifier-ish nodes (Identifier, JSXIdentifier, TSTypeParameter) all + carry a string `name`, but the jscodeshift types don't share one shape. +*/ +export const nameOf = (node: unknown) => { + const name = (node as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' ? name : ''; +}; + +const isModuleCall = (callee: any): boolean => { + if (callee.type === 'Import') return true; + if (callee.type === 'Identifier') return callee.name === 'require'; + if (callee.type === 'MemberExpression' && !callee.computed) { + const { object, property } = callee; + if (object.type !== 'Identifier') return false; + if (object.name === 'require') return property.name === 'resolve'; + return CALLEE_OBJECTS.has(object.name) && CALLEE_METHODS.has(property.name); + } + return false; +}; + +/* The string literal nodes that name a module, in source order. */ +export const findModuleStrings = (j: JSCodeshift, root: Collection) => { + const found: StringNode[] = []; + const push = (node: unknown) => { + if (isStringLiteral(node)) found.push(node); + }; + + root.find(j.ImportDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportNamedDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportAllDeclaration).forEach((p) => push(p.node.source)); + root.find(j.CallExpression).forEach((p) => { + if (isModuleCall(p.node.callee)) push(p.node.arguments[0]); + }); + root.find(j.ImportExpression).forEach((p) => push(p.node.source)); + root.find(j.TSImportType).forEach((p) => push(p.node.argument)); + root.find(j.TSModuleDeclaration).forEach((p) => push(p.node.id)); + root + .find(j.TSExternalModuleReference) + .forEach((p) => push(p.node.expression)); + + return found; +}; diff --git a/packages/gamut-codemods/src/lib/run-source.ts b/packages/gamut-codemods/src/lib/run-source.ts new file mode 100644 index 0000000000..7a78043b7d --- /dev/null +++ b/packages/gamut-codemods/src/lib/run-source.ts @@ -0,0 +1,108 @@ +import type { JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; +import type { + AstMigration, + Edit, + Locatable, + Note, + SourceMigration, + SpliceMigration, +} from './types'; + +/* + Runs source migrations over one piece of source text. Two phases: + + 1. AST migrations share one parse and mutate it. If any changed + something, recast prints the text once and `postPrint` hooks run. + 2. Splice migrations run in order, each against a fresh parse of the + current text, returning `{ start, end, text }` edits applied back to + front. The re-parse means a later splice sees an earlier one's output + (mf-shared writes @codecademy keys that scope-rename then renames). + + Shared by the jscodeshift transform (whole files) and file migrations + that embed code, like MDX's ESM blocks. +*/ + +export interface RunSourceOptions { + j: JSCodeshift; + source: string; + migrations: SourceMigration[]; + manifest: Manifest; + warnFor: (migration: string) => (node: Locatable, message: string) => void; + note: Note; +} + +const applyEdits = (source: string, edits: Edit[]) => + [...edits] + .sort((a, b) => b.start - a.start) + .reduce( + (out, { start, end, text }) => + out.slice(0, start) + text + out.slice(end), + source + ); + +export const runSourceMigrations = ({ + j, + source: original, + migrations, + manifest, + warnFor, + note, +}: RunSourceOptions) => { + let source = original; + + const astMigrations = migrations.filter( + (m): m is AstMigration => m.kind === 'ast' + ); + if (astMigrations.length > 0) { + const root = j(source); + let changed = false; + for (const m of astMigrations) { + changed = + m.run({ j, root, source, manifest, warn: warnFor(m.name), note }) || + changed; + } + if (changed) { + source = astMigrations.reduce( + (out, m) => (m.postPrint ? m.postPrint(out, { manifest }) : out), + root.toSource({ quote: 'single' }) + ); + } + } + + for (const m of migrations.filter( + (x): x is SpliceMigration => x.kind === 'splice' + )) { + const root = j(source); + const edits = m.edits({ + j, + root, + source, + manifest, + warn: warnFor(m.name), + note, + }); + if (edits.length > 0) source = applyEdits(source, edits); + } + + return source; +}; + +/* The pre-filter every caller uses so big repos don't parse every file. */ +export const mentionsOldNames = (manifest: Manifest, source: string) => + [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + `${manifest.eslintPlugin.from}/`, + ].some((name) => source.includes(name)); + +export const activeSourceMigrations = ( + migrations: { kind: string; name: string }[], + only?: string +) => { + const names = only ? new Set(only.split(',')) : null; + return migrations.filter( + (m) => m.kind !== 'file' && (!names || names.has(m.name)) + ) as SourceMigration[]; +}; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts new file mode 100644 index 0000000000..4321561927 --- /dev/null +++ b/packages/gamut-codemods/src/lib/types.ts @@ -0,0 +1,90 @@ +import type { Collection, JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; + +/* Anything with a source location, so warnings can point at a line. */ +export type Locatable = + | { loc?: { start: { line: number } } | null } + | null + | undefined; + +export type Warn = (node: Locatable, message: string) => void; + +/* + Records that a checklist item applies to this run. Keys are defined by the + preset; the CLI prints the matching items once every file is done. +*/ +export type Note = (checklistKey: string) => void; + +export interface SourceContext { + j: JSCodeshift; + root: Collection; + source: string; + manifest: Manifest; + warn: Warn; + note: Note; +} + +export interface Edit { + start: number; + end: number; + text: string; +} + +interface MigrationBase { + name: string; + description: string; +} + +/* + Mutates the shared AST. Use for structural rewrites only: recast reprints + whatever it thinks changed, and in semicolon-less files that has spread to + neighbouring statements. Return true if anything changed. +*/ +export interface AstMigration extends MigrationBase { + kind: 'ast'; + run(ctx: SourceContext): boolean; + /* Formatting fixes on the printed output that the AST can't express. */ + postPrint?(source: string, ctx: { manifest: Manifest }): string; +} + +/* + Uses the AST to find ranges, then edits the original text by offset. The + default for anything that swaps one string for another. +*/ +export interface SpliceMigration extends MigrationBase { + kind: 'splice'; + edits(ctx: SourceContext): Edit[]; +} + +/* Gets raw text for non-source files (package.json, .eslintrc, .mdx). */ +export interface FileMigration extends MigrationBase { + kind: 'file'; + match(path: string): boolean; + run(ctx: { + source: string; + manifest: Manifest; + warn: (message: string) => void; + note: Note; + /* + Runs the preset's source migrations (respecting --only) over a + snippet of JS/TS, for files that embed code, like MDX's ESM blocks. + */ + transformSource: (code: string) => string; + }): string | null; +} + +export type SourceMigration = AstMigration | SpliceMigration; +export type Migration = SourceMigration | FileMigration; + +export interface Preset { + name: string; + description: string; + manifest: Manifest; + /* Run in order. All `ast` migrations must come before any `splice` one. */ + migrations: Migration[]; + /* Printed at the end of every run. */ + checklist: string[]; + /* Printed only when a migration calls note(key). */ + conditionalChecklist: Record; +} diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx new file mode 100644 index 0000000000..11737f72e1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx @@ -0,0 +1,4 @@ +import { conditionalStyles, formFieldStyles } from '@codecademy/gamut/dist/Form/styles'; +import { formFieldPaddingStyles as padding } from '@codecademy/gamut/dist/Form/styles'; +import { formFieldStyles as f, checkboxLabel } from '@codecademy/gamut/dist/Form/styles'; +import * as formStyles from '@codecademy/gamut/dist/Form/styles'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx new file mode 100644 index 0000000000..1c8ceea9f4 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx @@ -0,0 +1,4 @@ +import { conditionalStyles, formFieldStyles } from '@codecademy/gamut'; +import { formFieldPaddingStyles as padding } from '@codecademy/gamut'; +import { formFieldStyles as f, checkboxLabel } from '@codecademy/gamut/dist/Form/styles'; +import * as formStyles from '@codecademy/gamut/dist/Form/styles'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json new file mode 100644 index 0000000000..e127ff61a0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json @@ -0,0 +1 @@ +["checkboxLabel isn't exported from '@codecademy/gamut'", "* isn't exported from '@codecademy/gamut'"] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx new file mode 100644 index 0000000000..32ee05b6fa --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx @@ -0,0 +1,6 @@ +import { List, ListItem as Item, type ListProps } from '@codecademy/gamut/dist/Menu/elements'; +import { IconOption } from '@codecademy/gamut/dist/Form/SelectDropdown/elements'; + +export { ListButton } from '@codecademy/gamut/dist/Menu/elements'; + +jest.mock('@codecademy/gamut/dist/Menu/elements', () => ({ List: () => null })); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx new file mode 100644 index 0000000000..e05682435d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx @@ -0,0 +1,6 @@ +import { MenuList as List, MenuListItem as Item, type MenuListProps as ListProps } from '@codecademy/gamut'; +import { IconOptionComponent as IconOption } from '@codecademy/gamut'; + +export { MenuListButton as ListButton } from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ List: () => null })); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json new file mode 100644 index 0000000000..6b61771448 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json @@ -0,0 +1 @@ +["That now covers the whole package, and some exports were renamed. Check it by hand."] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx new file mode 100644 index 0000000000..3fad9f3b4b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx @@ -0,0 +1,7 @@ +import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import type { Prop } from '@codecademy/variance/dist/types/config'; +import { formStyles } from '@codecademy/gamut/dist/Form/styles'; +import { ButtonBase } from '@codecademy/gamut/dist/ButtonBase/ButtonBase'; +import { Mystery } from '@codecademy/gamut/dist/Nope'; + +jest.mock('@codecademy/gamut/dist/Box/props'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx new file mode 100644 index 0000000000..d882b3d9f0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx @@ -0,0 +1,7 @@ +import { PopoverContainerProps } from '@codecademy/gamut'; +import type { Prop } from '@codecademy/variance'; +import { formStyles } from '@codecademy/gamut/dist/Form/styles'; +import { ButtonBase } from '@codecademy/gamut/dist/ButtonBase/ButtonBase'; +import { Mystery } from '@codecademy/gamut/dist/Nope'; + +jest.mock('@codecademy/gamut'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json new file mode 100644 index 0000000000..99cf5b0f92 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut/dist/Form/styles' is only partly public. formStyles isn't exported from '@codecademy/gamut'", "'@codecademy/gamut/dist/ButtonBase/ButtonBase' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'", "rewrote '@codecademy/gamut/dist/Box/props' to '@codecademy/gamut' in a mock or require(). That now covers the whole package."] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/index.ts b/packages/gamut-codemods/src/migrations/deep-imports/index.ts new file mode 100644 index 0000000000..f703d6b9cb --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/index.ts @@ -0,0 +1,154 @@ +import type { + ExportSpecifier, + ImportSpecifier, + JSCodeshift, +} from 'jscodeshift'; + +import { findModuleStrings, nameOf } from '../../lib/module-strings'; +import type { AstMigration } from '../../lib/types'; + +/* + `@codecademy/gamut/dist/...` -> the public entry point from + manifest.deepImports. Renamed exports keep their local name so call sites + don't change: `import { List }` becomes `import { MenuList as List }`. +*/ + +const DIST = /^(@codecademy\/[\w-]+|eslint-plugin-gamut)\/dist(\/|$)/; + +type Specifier = ImportSpecifier | ExportSpecifier | { type: string }; + +/* + Specifiers are rebuilt rather than mutated. `import { List }` has imported + and local sharing one source range, so recast's patcher drops the alias if + you only swap `imported`. +*/ +const renameSpecifiers = ( + j: JSCodeshift, + specifiers: Specifier[], + renames: Record +) => + specifiers.map((spec) => { + if (spec.type === 'ImportSpecifier') { + const { imported, local, importKind } = spec as ImportSpecifier & { + importKind?: string; + }; + const next = renames[nameOf(imported)]; + if (!next) return spec; + const renamed = j.importSpecifier( + j.identifier(next), + j.identifier(nameOf(local ?? imported)) + ); + Object.assign(renamed, { importKind }); + return renamed; + } + if (spec.type === 'ExportSpecifier') { + const { local, exported } = spec as ExportSpecifier; + const next = renames[nameOf(local)]; + if (!next) return spec; + return j.exportSpecifier.from({ + local: j.identifier(next), + exported: j.identifier(nameOf(exported ?? local)), + }); + } + return spec; + }); + +export const deepImports: AstMigration = { + name: 'deep-imports', + kind: 'ast', + description: 'Rewrite /dist/ deep imports to public entry points.', + run({ j, root, manifest, warn }) { + const byPath = new Map(manifest.deepImports.map((row) => [row.from, row])); + let changed = false; + + /* Declarations first, so renames can be applied to their specifiers. */ + const declarations = [ + ...root.find(j.ImportDeclaration).nodes(), + ...root.find(j.ExportNamedDeclaration).nodes(), + ...root.find(j.ExportAllDeclaration).nodes(), + ]; + const handled = new Set(); + + for (const node of declarations) { + const source = node.source as { value?: unknown } | null | undefined; + if (typeof source?.value !== 'string' || !DIST.test(source.value)) { + continue; + } + handled.add(source); + const row = byPath.get(source.value); + if (!row) { + warn( + node, + `unmapped deep import '${source.value}'. Add it to the preset manifest's deepImports.` + ); + continue; + } + if (!row.to) { + warn( + node, + `'${row.from}' has no public replacement. ${row.note ?? ''}` + ); + continue; + } + if (row.onlyNames) { + const allowed = new Set(row.onlyNames); + const specifiers = + 'specifiers' in node ? ((node.specifiers ?? []) as Specifier[]) : []; + const blocked = specifiers + .map((spec) => + spec.type === 'ImportSpecifier' + ? nameOf((spec as ImportSpecifier).imported) + : spec.type === 'ExportSpecifier' + ? nameOf((spec as ExportSpecifier).local) + : '*' + ) + .filter((name) => !allowed.has(name)); + if (node.type === 'ExportAllDeclaration') blocked.push('*'); + if (blocked.length > 0) { + warn( + node, + `'${row.from}' is only partly public. ${blocked.join( + ', ' + )} isn't exported from '${row.to}'. ${row.note ?? ''}` + ); + continue; + } + } + if (row.renames && 'specifiers' in node && node.specifiers) { + node.specifiers = renameSpecifiers( + j, + node.specifiers, + row.renames + ) as typeof node.specifiers; + } + node.source = j.stringLiteral(row.to); + changed = true; + } + + /* Then mocks, require(), import(): only the string can change here. */ + for (const literal of findModuleStrings(j, root)) { + if (handled.has(literal) || !DIST.test(literal.value)) continue; + const row = byPath.get(literal.value); + if (!row?.to) { + warn( + literal, + `deep import '${literal.value}' in a mock or require() needs a manual rewrite.` + ); + continue; + } + /* jest.mock of a deep path now mocks the whole package. */ + warn( + literal, + `rewrote '${literal.value}' to '${ + row.to + }' in a mock or require(). That now covers the whole package${ + row.renames ? ', and some exports were renamed' : '' + }. Check it by hand.` + ); + literal.value = row.to; + changed = true; + } + + return changed; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx new file mode 100644 index 0000000000..61b977827c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx @@ -0,0 +1,6 @@ +// eslint-disable-next-line gamut/no-inline-style +const a = 1; +/* eslint-disable gamut/prefer-themed, react/no-danger */ +// eslint-disable-next-line @skillsoft/gamut/no-css-standalone +const b = 2; +// talking about gamut/no-inline-style in prose stays put diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx new file mode 100644 index 0000000000..6902386e06 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx @@ -0,0 +1,6 @@ +// eslint-disable-next-line @skillsoft/gamut/no-inline-style +const a = 1; +/* eslint-disable @skillsoft/gamut/prefer-themed, react/no-danger */ +// eslint-disable-next-line @skillsoft/gamut/no-css-standalone +const b = 2; +// talking about gamut/no-inline-style in prose stays put diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/index.ts b/packages/gamut-codemods/src/migrations/eslint-comments/index.ts new file mode 100644 index 0000000000..977550f4d0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/index.ts @@ -0,0 +1,49 @@ +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + `// eslint-disable-next-line gamut/no-inline-style` -> + `// eslint-disable-next-line @skillsoft/gamut/no-inline-style`. + Rule names are unchanged; only the plugin prefix moves. +*/ + +const DIRECTIVE = /^\s*(eslint-disable|eslint-enable|eslint\s)/; + +interface CommentNode { + type: string; + value: string; + start: number; + end: number; +} + +const isBlock = (comment: CommentNode) => + comment.type === 'CommentBlock' || comment.type === 'Block'; + +export const eslintComments: SpliceMigration = { + name: 'eslint-comments', + kind: 'splice', + description: + 'Update ESLint directive comments to the new plugin rule prefix.', + edits({ j, root, manifest }) { + const { from, to } = manifest.eslintPlugin; + /* The prefix at the start of a rule name, not one already scoped. */ + const prefix = new RegExp(`(^|[\\s,])${from}/`, 'g'); + const edits: Edit[] = []; + const seen = new Set(); + + /* j.Comment paths point at the owning node, so walk node.comments. */ + root.find(j.Node).forEach((p) => { + const comments = (p.node as { comments?: CommentNode[] }).comments ?? []; + for (const comment of comments) { + if (seen.has(comment.start)) continue; + seen.add(comment.start); + if (!DIRECTIVE.test(comment.value)) continue; + const value = comment.value.replace(prefix, `$1${to}/`); + if (value === comment.value) continue; + const text = isBlock(comment) ? `/*${value}*/` : `//${value}`; + edits.push({ start: comment.start, end: comment.end, text }); + } + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json new file mode 100644 index 0000000000..8fc62bdc2b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json @@ -0,0 +1,9 @@ +{ + "plugins": ["gamut"], + "extends": ["plugin:gamut/recommended"], + "rules": { + // keep these on + "gamut/no-css-standalone": "error", + "gamut/no-inline-style": "error" + } +} diff --git a/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json new file mode 100644 index 0000000000..cfb581e347 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json @@ -0,0 +1,9 @@ +{ + "plugins": ["@skillsoft/gamut"], + "extends": ["plugin:@skillsoft/gamut/recommended"], + "rules": { + // keep these on + "@skillsoft/gamut/no-css-standalone": "error", + "@skillsoft/gamut/no-inline-style": "error" + } +} diff --git a/packages/gamut-codemods/src/migrations/eslint-config/index.ts b/packages/gamut-codemods/src/migrations/eslint-config/index.ts new file mode 100644 index 0000000000..34f50ed381 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/index.ts @@ -0,0 +1,28 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Legacy .eslintrc / .eslintrc.json only. These are often JSONC (mono's + have comments), so this is a targeted text rewrite rather than + JSON.parse. JS and flat configs are left alone: in a flat config the rule + prefix is whatever key the plugin is registered under, so blindly + renaming rule keys would break it. The leftovers report lists them. +*/ + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const eslintConfig: FileMigration = { + name: 'eslint-config', + kind: 'file', + description: + 'Update the plugin name, `plugin:` extends, and rule prefix in .eslintrc(.json).', + match: (file) => /(^|\/)\.eslintrc(\.json)?$/.test(file), + run({ source, manifest }) { + const { from, to } = manifest.eslintPlugin; + const old = escape(from); + const next = source + .replace(new RegExp(`"plugin:${old}/`, 'g'), `"plugin:${to}/`) + .replace(new RegExp(`"${old}/`, 'g'), `"${to}/`) + .replace(new RegExp(`"(eslint-plugin-)?${old}"`, 'g'), `"${to}"`); + return next === source ? null : next; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx new file mode 100644 index 0000000000..513edb7e2b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx @@ -0,0 +1,18 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box, Video } from '@codecademy/gamut'; +import { + PopoverContainerProps, +} from '@codecademy/gamut/dist/PopoverContainer/types'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx new file mode 100644 index 0000000000..4acaac1535 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx @@ -0,0 +1,19 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@skillsoft/gamut'; +import { Video } from '@skillsoft/gamut/Video'; +import { + PopoverContainerProps, +} from '@skillsoft/gamut'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/index.ts b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts new file mode 100644 index 0000000000..f98acf3d56 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts @@ -0,0 +1,63 @@ +import type { FileMigration } from '../../lib/types'; + +/* + MDX files (Storybook docs, mostly) import from Gamut like any module, but + jscodeshift can't parse MDX. This pulls out the top-level ESM statements + (`import ... from '...'`, `export ... from '...'`) and runs each one + through the preset's source migrations, so stories get the same scope + rename, Video split, and deep-import rewrites as .tsx files. + + Fenced code blocks are left alone. They're documentation, and a code + sample that still says @codecademy is the leftovers report's job. +*/ + +const FENCE = /^\s*(```|~~~)/; +const STATEMENT_START = /^(import\s|export\s+(\*|\{|type\s+\{))/; +const STATEMENT_END = + /(from\s*(['"])[^'"\n]*\2|^import\s*(['"])[^'"\n]*\3)\s*;?\s*$/; +/* A statement that runs longer than this isn't one we understand. */ +const MAX_STATEMENT_LINES = 50; + +export const mdxImports: FileMigration = { + name: 'mdx-imports', + kind: 'file', + description: + 'Run the source migrations over import/export statements in .mdx files.', + match: (file) => file.endsWith('.mdx'), + run({ source, transformSource }) { + const lines = source.split('\n'); + const out: string[] = []; + let inFence = false; + let changed = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (FENCE.test(line)) inFence = !inFence; + if (inFence || !STATEMENT_START.test(line)) { + out.push(line); + continue; + } + + let end = i; + while ( + end < lines.length - 1 && + end - i < MAX_STATEMENT_LINES && + !STATEMENT_END.test(lines.slice(i, end + 1).join('\n')) + ) { + end += 1; + } + const statement = lines.slice(i, end + 1).join('\n'); + if (!STATEMENT_END.test(statement)) { + out.push(line); + continue; + } + + const next = transformSource(statement); + if (next !== statement) changed = true; + out.push(next); + i = end; + } + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts new file mode 100644 index 0000000000..04847f011a --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts @@ -0,0 +1,11 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-kit': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-kit': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts new file mode 100644 index 0000000000..e3154ac7d5 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts @@ -0,0 +1,19 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-icons': { eager: true }, + '@codecademy/gamut-illustrations': { eager: true }, + '@codecademy/gamut-patterns': { eager: true }, + '@codecademy/gamut-styles': { eager: true }, + '@codecademy/variance': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts new file mode 100644 index 0000000000..caf4b09e6d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts @@ -0,0 +1,11 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut-kit': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts new file mode 100644 index 0000000000..4c70a53c17 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts @@ -0,0 +1,16 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..cc4b2bc076 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit' in Module Federation shared config"] diff --git a/packages/gamut-codemods/src/migrations/mf-shared/index.ts b/packages/gamut-codemods/src/migrations/mf-shared/index.ts new file mode 100644 index 0000000000..9d3e677821 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/index.ts @@ -0,0 +1,112 @@ +import { isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Module Federation `shared` config that names a removed package: + + shared: { '@codecademy/gamut-kit': { singleton: true } } + becomes + shared: { + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + ... + } + + Handles `const shared = { ... }` the same way. + + MF matches shared entries by import name, and code imports the individual + packages, not the kit. So the old entry never deduped anything, and after + this Gamut really is a singleton across host and remotes. That's a runtime + change, hence the warning and checklist item. scope-rename runs after this + and swaps the scope on the new keys. +*/ + +const keyName = (key: any): string | null => { + if (!key) return null; + if (key.type === 'Identifier') return key.name; + if (isStringLiteral(key)) return key.value; + return null; +}; + +/* + The object in `shared: { ... }` (inline plugin options) or + `const shared = { ... }` (declared, then passed in). +*/ +const sharedObject = (node: any) => { + if ( + (node.type === 'ObjectProperty' || node.type === 'Property') && + keyName(node.key) === 'shared' && + node.value?.type === 'ObjectExpression' + ) { + return node.value; + } + if ( + node.type === 'VariableDeclarator' && + keyName(node.id) === 'shared' && + node.init?.type === 'ObjectExpression' + ) { + return node.init; + } + return null; +}; + +export const mfShared: SpliceMigration = { + name: 'mf-shared', + kind: 'splice', + description: + 'Replace removed packages in Module Federation `shared` config with the packages they bundled.', + edits({ j, root, source, manifest, warn, note }) { + const edits: Edit[] = []; + + root + .find(j.Node) + .filter((p) => sharedObject(p.node) !== null) + .forEach((p) => { + const shared = sharedObject(p.node); + const existing = new Set( + shared.properties.map((prop: any) => keyName(prop.key)) + ); + + for (const prop of shared.properties) { + const removed = manifest.removedPackages[keyName(prop.key) ?? '']; + if (!removed) continue; + + const lineStart = source.lastIndexOf('\n', prop.start) + 1; + const before = source.slice(lineStart, prop.start); + /* One entry per line if the old one had its own line, else inline. */ + const ownLine = /^\s*$/.test(before); + const separator = ownLine ? `,\n${before}` : ', '; + const value = source.slice(prop.value.start, prop.value.end); + const additions = removed.mfSharedAs.filter( + (pkg) => !existing.has(pkg) + ); + const text = additions + .map((pkg) => `'${pkg}': ${value}`) + .join(separator); + + /* Nothing to add: drop the entry and the comma that follows it. */ + const end = + text || source[prop.end] !== ',' + ? prop.end + : prop.end + + 1 + + (source.slice(prop.end + 1).match(/^\s*/)?.[0].length ?? 0); + edits.push({ + start: text || !ownLine ? prop.start : lineStart, + end, + text, + }); + + warn( + prop, + `replaced '${keyName( + prop.key + )}' in Module Federation shared config. Gamut is now actually shared as a singleton; test the host and remotes together.` + ); + note('mf-shared'); + } + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx new file mode 100644 index 0000000000..811c580c56 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx @@ -0,0 +1,5 @@ +import { Text, Video } from '@codecademy/gamut'; +import { VideoProps } from '@codecademy/gamut/Video'; + +export const x: VideoProps = { videoUrl: '' }; +export const y = [Text, Video]; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx new file mode 100644 index 0000000000..2342b88e7d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx @@ -0,0 +1,5 @@ +import { Text } from '@codecademy/gamut'; +import { VideoProps, Video } from '@codecademy/gamut/Video'; + +export const x: VideoProps = { videoUrl: '' }; +export const y = [Text, Video]; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx new file mode 100644 index 0000000000..98db12ff86 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx @@ -0,0 +1,7 @@ +import { Box, Video, type VideoProps } from '@codecademy/gamut'; + +export const Player = (props: VideoProps) => ( + + +); diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx new file mode 100644 index 0000000000..a2058bdebb --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx @@ -0,0 +1,8 @@ +import { Box } from '@codecademy/gamut'; +import { Video, type VideoProps } from '@codecademy/gamut/Video'; + +export const Player = (props: VideoProps) => ( + + +); diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts new file mode 100644 index 0000000000..d12c1f8c44 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts @@ -0,0 +1 @@ +export { Box, Video } from '@codecademy/gamut'; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts new file mode 100644 index 0000000000..efcc9aafd3 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts @@ -0,0 +1,2 @@ +export { Box } from '@codecademy/gamut'; +export { Video } from '@codecademy/gamut/Video'; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx new file mode 100644 index 0000000000..1d2a688be7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx @@ -0,0 +1,10 @@ +import * as Gamut from '@codecademy/gamut'; + +export * from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ + ...jest.requireActual('@codecademy/gamut'), + Video: () => null, +})); + +export const V = Gamut.Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx new file mode 100644 index 0000000000..1d2a688be7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx @@ -0,0 +1,10 @@ +import * as Gamut from '@codecademy/gamut'; + +export * from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ + ...jest.requireActual('@codecademy/gamut'), + Video: () => null, +})); + +export const V = Gamut.Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json new file mode 100644 index 0000000000..ba02d37ffd --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json @@ -0,0 +1 @@ +["namespace import uses Video", "'export *' from '@codecademy/gamut'", "mock of '@codecademy/gamut' stubs Video"] diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx new file mode 100644 index 0000000000..690bc76dad --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx @@ -0,0 +1,6 @@ +// the player +import { Video } from '@codecademy/gamut'; +import type { VideoProps } from '@codecademy/gamut'; + +export type P = VideoProps; +export const V = Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx new file mode 100644 index 0000000000..968c84aef1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx @@ -0,0 +1,6 @@ +// the player +import { Video } from '@codecademy/gamut/Video'; +import type { VideoProps } from '@codecademy/gamut/Video'; + +export type P = VideoProps; +export const V = Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/index.ts b/packages/gamut-codemods/src/migrations/moved-exports/index.ts new file mode 100644 index 0000000000..250d882da6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/index.ts @@ -0,0 +1,246 @@ +import type { + ASTPath, + Collection, + ExportNamedDeclaration, + ExportSpecifier, + ImportDeclaration, + ImportSpecifier, + JSCodeshift, +} from 'jscodeshift'; + +import type { Manifest, MovedExport } from '../../lib/manifest'; +import { nameOf } from '../../lib/module-strings'; +import type { AstMigration, Warn } from '../../lib/types'; + +/* + Moves named exports that left a package root onto their new subpath, per + manifest.movedExports. For scope-swap that's Video and VideoProps: + + import { Box, Video } from '@codecademy/gamut'; + becomes + import { Box } from '@codecademy/gamut'; + import { Video } from '@codecademy/gamut/Video'; + + Shapes it can't rewrite safely (namespace imports, `export *`, mock + factories) get a warning instead. +*/ + +type Declaration = ImportDeclaration | ExportNamedDeclaration; +type NamedSpecifier = ImportSpecifier | ExportSpecifier; + +const isNamed = (spec: { type: string }): spec is NamedSpecifier => + spec.type === 'ImportSpecifier' || spec.type === 'ExportSpecifier'; + +const specName = (spec: NamedSpecifier) => + nameOf(spec.type === 'ImportSpecifier' ? spec.imported : spec.local); + +const kindOf = (node: Declaration) => + (node as { importKind?: string; exportKind?: string }).importKind ?? + (node as { exportKind?: string }).exportKind ?? + 'value'; + +/* + Fresh copies, not the original nodes. Reparenting nodes that still carry + their source location trips recast's patcher when the file has other edits. +*/ +const cloneSpecifier = (j: JSCodeshift, spec: NamedSpecifier) => { + if (spec.type === 'ExportSpecifier') { + return j.exportSpecifier.from({ + local: j.identifier(nameOf(spec.local)), + exported: j.identifier(nameOf(spec.exported ?? spec.local)), + }); + } + const copy = j.importSpecifier( + j.identifier(nameOf(spec.imported)), + j.identifier(nameOf(spec.local ?? spec.imported)) + ); + Object.assign(copy, { + importKind: (spec as { importKind?: string }).importKind, + }); + return copy; +}; + +/* An existing all-named declaration of the same kind we can merge into. */ +const findTarget = ( + j: JSCodeshift, + root: Collection, + node: Declaration, + to: string, + kind: string +): Declaration | undefined => { + const matches = (candidate: Declaration) => + kindOf(candidate) === kind && + (candidate.specifiers ?? []).every((s) => isNamed(s)); + return node.type === 'ImportDeclaration' + ? root + .find(j.ImportDeclaration, { source: { value: to } }) + .nodes() + .find(matches) + : root + .find(j.ExportNamedDeclaration, { source: { value: to } }) + .nodes() + .find(matches); +}; + +const moveSpecifiers = ( + j: JSCodeshift, + root: Collection, + path: ASTPath, + rule: MovedExport +) => { + const { node } = path; + const names = new Set(rule.names); + const specifiers = (node.specifiers ?? []) as { type: string }[]; + const matched = specifiers.filter( + (s): s is NamedSpecifier => isNamed(s) && names.has(specName(s)) + ); + if (matched.length === 0) return false; + + const moving = matched.map((s) => cloneSpecifier(j, s)); + const staying = specifiers.filter( + (s) => !matched.includes(s as NamedSpecifier) + ); + const kind = kindOf(node); + const existing = findTarget(j, root, node, rule.to, kind); + + if (existing) { + (existing.specifiers as unknown[]).push(...moving); + if (staying.length === 0) path.prune(); + else node.specifiers = staying as typeof node.specifiers; + return true; + } + + if (staying.length === 0) { + /* Whole declaration moves. Keep the node so its comments stay put. */ + node.source = j.stringLiteral(rule.to); + return true; + } + + const created = + node.type === 'ImportDeclaration' + ? j.importDeclaration( + moving as ImportSpecifier[], + j.stringLiteral(rule.to) + ) + : j.exportNamedDeclaration( + null, + moving as ExportSpecifier[], + j.stringLiteral(rule.to) + ); + Object.assign( + created, + node.type === 'ImportDeclaration' + ? { importKind: kind } + : { exportKind: kind } + ); + node.specifiers = staying as typeof node.specifiers; + path.replace(node, created); + return true; +}; + +const warnAboutUnsafeShapes = ( + j: JSCodeshift, + root: Collection, + rule: MovedExport, + warn: Warn +) => { + const list = rule.names.join('/'); + + root + .find(j.ImportDeclaration, { source: { value: rule.from } }) + .forEach((p) => { + for (const spec of p.node.specifiers ?? []) { + if (spec.type !== 'ImportNamespaceSpecifier') continue; + const ns = nameOf(spec.local); + const member = root + .find(j.MemberExpression, { object: { name: ns } }) + .filter((m) => rule.names.includes(nameOf(m.node.property))); + const qualified = root + .find(j.TSQualifiedName, { left: { name: ns } }) + .filter((q) => rule.names.includes(nameOf(q.node.right))); + if (member.size() + qualified.size() > 0) { + warn( + p.node, + `namespace import uses ${list}, which moved to '${rule.to}'. Rewrite by hand.` + ); + } + } + }); + + root + .find(j.ExportAllDeclaration, { source: { value: rule.from } }) + .forEach((p) => { + warn( + p.node, + `'export *' from '${rule.from}' no longer re-exports ${list}. Add an export from '${rule.to}' if you need them.` + ); + }); + + root + .find(j.CallExpression, { arguments: [{ value: rule.from }] }) + .filter((p) => p.node.arguments.length > 1) + .forEach((p) => { + const mentions = j(p.get('arguments', 1)) + .find(j.Identifier) + .filter((id) => rule.names.includes(id.node.name)); + if (mentions.size() > 0) { + warn( + p.node, + `mock of '${rule.from}' stubs ${list}. Mock '${rule.to}' instead.` + ); + } + }); +}; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* An old specifier and its renamed form, since scope-rename may have run. */ +const bothScopes = (manifest: Manifest, spec: string) => { + const pkg = Object.keys(manifest.packages).find( + (p) => spec === p || spec.startsWith(`${p}/`) + ); + return pkg + ? [spec, manifest.packages[pkg].to + spec.slice(pkg.length)] + : [spec]; +}; + +export const movedExports: AstMigration = { + name: 'moved-exports', + kind: 'ast', + description: + 'Move exports that left a package root (e.g. Video) to their new subpath.', + run({ j, root, manifest, warn }) { + let changed = false; + for (const rule of manifest.movedExports) { + const paths = [ + ...root + .find(j.ImportDeclaration, { source: { value: rule.from } }) + .paths(), + ...root + .find(j.ExportNamedDeclaration, { source: { value: rule.from } }) + .paths(), + ] as ASTPath[]; + for (const p of paths) { + changed = moveSpecifiers(j, root, p, rule) || changed; + } + warnAboutUnsafeShapes(j, root, rule, warn); + } + return changed; + }, + /* + recast copies the gap that followed the original declaration onto the + new one, so a split can leave a blank line between the two halves. + Close exactly that gap and nothing else. + */ + postPrint(source, { manifest }) { + return manifest.movedExports.reduce((out, rule) => { + const from = bothScopes(manifest, rule.from).map(escape).join('|'); + const to = bothScopes(manifest, rule.to).map(escape).join('|'); + const split = new RegExp( + `(from '(?:${from})';?)\\n\\n((?:import|export)[^\\n]*from '(?:${to})';?)`, + 'g' + ); + return out.replace(split, '$1\n$2'); + }, source); + }, +}; diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json new file mode 100644 index 0000000000..a332e52190 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut-kit": "3.0.25", + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@codecademy/gamut-styles": "21.2.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json new file mode 100644 index 0000000000..e51edd4454 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json new file mode 100644 index 0000000000..1ad0d5bf74 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json @@ -0,0 +1,11 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut": "73.6.1", + "@codecademy/gamut-kit": "3.0.24", + "react": "^19.0.0" + }, + "devDependencies": { + "eslint-plugin-gamut": "3.1.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json new file mode 100644 index 0000000000..779450d73c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json @@ -0,0 +1,16 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "^{{version}}", + "@skillsoft/gamut-icons": "^{{version}}", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}", + "react": "^19.0.0" + }, + "devDependencies": { + "@skillsoft/eslint-plugin-gamut": "^0.0.1" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..47a5eebf34 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit'"] diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json new file mode 100644 index 0000000000..27dbc10c94 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@codecademy/gamut": "*", + "@codecademy/gamut-styles": "^21.0.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json new file mode 100644 index 0000000000..e4ecfdfaf1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@skillsoft/gamut": "*", + "@skillsoft/gamut-styles": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts new file mode 100644 index 0000000000..257bd4fa73 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -0,0 +1,84 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Renames dependencies in package.json and pins them to + manifest.targetVersions. Removed packages are replaced by the packages + they bundled. Only touched fields are re-sorted; indentation and the + trailing newline are kept. + + A new package that's already listed keeps its value. People add the new + names by hand before running this, often pointed at a preview tarball, + and overwriting that with a range that isn't published yet breaks + install. A `*` peer range stays `*`: it means "whatever the host has", + and narrowing it is a decision for the package owner. +*/ + +const FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +const sortKeys = (obj: Record) => + Object.fromEntries( + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) + ); + +const detectIndent = (text: string) => + (text.match(/^[ \t]+(?=")/m) ?? [' '])[0]; + +export const packageJson: FileMigration = { + name: 'package-json', + kind: 'file', + description: 'Rename dependencies in package.json.', + match: (file) => /(^|\/)package\.json$/.test(file), + run({ source, manifest, warn, note }) { + const pkg = JSON.parse(source) as Record; + let changed = false; + + for (const field of FIELDS) { + const deps = pkg[field] as Record | undefined; + if (!deps) continue; + const next = { ...deps }; + let touched = false; + + for (const [removed, { expandTo, note: why }] of Object.entries( + manifest.removedPackages + )) { + if (!(removed in next)) continue; + delete next[removed]; + for (const old of expandTo) { + const renamed = manifest.packages[old]?.to; + if (!(old in next) && !(renamed && renamed in next)) next[old] = '*'; + } + warn( + `${field}: replaced '${removed}' with its individual packages. ${why}` + ); + note(`removed:${removed}`); + touched = true; + } + + for (const [from, { to }] of Object.entries(manifest.packages)) { + if (!(from in next)) continue; + const range = next[from]; + delete next[from]; + touched = true; + if (to in next) continue; + next[to] = + field === 'peerDependencies' && range === '*' + ? '*' + : manifest.targetVersions[to]; + } + + if (touched) { + pkg[field] = sortKeys(next); + changed = true; + } + } + + if (!changed) return null; + const trailing = source.endsWith('\n') ? '\n' : ''; + return JSON.stringify(pkg, null, detectIndent(source)) + trailing; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx new file mode 100644 index 0000000000..db77a7d96b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx @@ -0,0 +1,20 @@ +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; +import { Video } from '@codecademy/gamut/Video'; +import { setupRtl } from '@codecademy/gamut-tests'; +import type { CoreTheme } from '@codecademy/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@codecademy/gamut-icons'; + +declare module '@codecademy/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@codecademy/variance'); +const lazy = () => import('@codecademy/gamut-patterns'); +const req = require('@codecademy/gamut-illustrations'); +type T = import('@codecademy/gamut').BoxProps; + +export const transpilePackages = ['@codecademy/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx new file mode 100644 index 0000000000..dca4d84c5d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx @@ -0,0 +1,20 @@ +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; +import { Video } from '@skillsoft/gamut/Video'; +import { setupRtl } from '@skillsoft/gamut-tests'; +import type { CoreTheme } from '@skillsoft/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@skillsoft/gamut-icons'; + +declare module '@skillsoft/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@skillsoft/variance'); +const lazy = () => import('@skillsoft/gamut-patterns'); +const req = require('@skillsoft/gamut-illustrations'); +type T = import('@skillsoft/gamut').BoxProps; + +export const transpilePackages = ['@skillsoft/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json new file mode 100644 index 0000000000..158b86d60b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut-kit' has no replacement package", "renamed 1 package-name string(s) outside imports (@codecademy/gamut)"] diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx new file mode 100644 index 0000000000..5570545ab3 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx @@ -0,0 +1,13 @@ +import { Box } from '@codecademy/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@codecademy/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx new file mode 100644 index 0000000000..fac3c38590 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx @@ -0,0 +1,13 @@ +import { Box } from '@skillsoft/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@skillsoft/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/index.ts b/packages/gamut-codemods/src/migrations/scope-rename/index.ts new file mode 100644 index 0000000000..0b62ee9295 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/index.ts @@ -0,0 +1,69 @@ +import { removedPackageFor, renameSpecifier } from '../../lib/manifest'; +import type { StringNode } from '../../lib/module-strings'; +import { findModuleStrings, isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Swaps old package names for new ones in every module position: imports, + exports, require, mocks, `declare module`, import types. Runs last, since + every other migration is written against the old names. + + Any other string that exactly names an old package (Next's + transpilePackages, Module Federation shared keys, lint ignore lists) is + rewritten too. Across mono, platform, front, and talent-intelligence every + one of those was a correct rename, so they get one warning per file + asking for a look at the diff, not one per string. + + This is a splice migration: it edits the text inside the quotes and never + asks recast to print. Mutating even one literal's value made recast + reprint neighbouring statements in semicolon-less files, including JSX + text whitespace. +*/ + +export const scopeRename: SpliceMigration = { + name: 'scope-rename', + kind: 'splice', + description: 'Swap old package specifiers for their new names.', + edits({ j, root, manifest, warn }) { + const edits: Edit[] = []; + const moduleStrings = new Set(findModuleStrings(j, root)); + const outsideImports: string[] = []; + + const visit = (literal: StringNode, inModulePosition: boolean) => { + const removed = removedPackageFor(manifest, literal.value); + if (removed) { + warn( + literal, + `'${removed}' has no replacement package. ${manifest.removedPackages[removed].note}` + ); + return; + } + const next = renameSpecifier(manifest, literal.value); + if (!next) return; + if (!inModulePosition) outsideImports.push(literal.value); + /* Inside the quotes only, so the file's quote style survives. */ + edits.push({ + start: literal.start + 1, + end: literal.end - 1, + text: next, + }); + }; + + for (const literal of moduleStrings) visit(literal, true); + + root + .find(j.Node) + .filter((p) => isStringLiteral(p.node) && !moduleStrings.has(p.node)) + .forEach((p) => visit(p.node as StringNode, false)); + + if (outsideImports.length > 0) { + const names = [...new Set(outsideImports)].join(', '); + warn( + null, + `renamed ${outsideImports.length} package-name string(s) outside imports (${names}). Check the diff.` + ); + } + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json new file mode 100644 index 0000000000..38cca72cc6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json @@ -0,0 +1 @@ +{ "compilerOptions": { "lib": ["es2022", "DOM", "dom.iterable"] } } diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json new file mode 100644 index 0000000000..38cca72cc6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json @@ -0,0 +1 @@ +{ "compilerOptions": { "lib": ["es2022", "DOM", "dom.iterable"] } } diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json new file mode 100644 index 0000000000..3a90a0ce69 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json @@ -0,0 +1,4 @@ +{ + // server code + "compilerOptions": { "lib": ["es2022"] } +} diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json new file mode 100644 index 0000000000..3a90a0ce69 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json @@ -0,0 +1,4 @@ +{ + // server code + "compilerOptions": { "lib": ["es2022"] } +} diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json new file mode 100644 index 0000000000..f83a4ee348 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json @@ -0,0 +1 @@ +["\"lib\" has no \"dom\""] diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts b/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts new file mode 100644 index 0000000000..86e7f69b7d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts @@ -0,0 +1,30 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Report-only. Before scope-swap, the gamut root re-exported Video, which + pulled @vidstack/react's DOM types into every program that imported + gamut. A tsconfig with an explicit `lib` and no "dom" got DOM globals + anyway. Once Video moves to its subpath they're gone, and `document`, + `HTMLInputElement`, `ResizeObserver` and friends stop type-checking. + Adding "dom" is the owner's call, so this warns rather than rewriting. +*/ + +const LIB = /"lib"\s*:\s*\[([^\]]*)\]/; + +export const tsconfigDom: FileMigration = { + name: 'tsconfig-dom', + kind: 'file', + description: + 'Warn about tsconfig `lib` settings without "dom", which Video used to supply.', + match: (file) => /(^|\/)tsconfig[\w.-]*\.json$/.test(file), + run({ source, warn, note }) { + const lib = LIB.exec(source); + if (lib && !/["']dom["']/i.test(lib[1])) { + warn( + `"lib" has no "dom". Gamut's root import used to bring DOM types in through Video; add "dom" if type-checking now fails on document, HTMLElement, and so on.` + ); + note('tsconfig-no-dom'); + } + return null; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml new file mode 100644 index 0000000000..6452a33ad1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - eslint-plugin-gamut # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml new file mode 100644 index 0000000000..7a6d8c80bf --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - '@skillsoft/eslint-plugin-gamut' # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml new file mode 100644 index 0000000000..8c251e0841 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml @@ -0,0 +1,10 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml new file mode 100644 index 0000000000..0d09a98e03 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml @@ -0,0 +1,9 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/index.ts b/packages/gamut-codemods/src/migrations/yarnrc/index.ts new file mode 100644 index 0000000000..34074ee969 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/index.ts @@ -0,0 +1,45 @@ +import { renameSpecifier } from '../../lib/manifest'; +import type { FileMigration } from '../../lib/types'; + +/* + Package names listed in .yarnrc.yml, like npmPreapprovedPackages: + + npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + + An old name is renamed, or dropped if a scope glob in the same file + (`'@skillsoft/*'`) already covers the new one. Only exact list items are + touched; anything more involved shows up in the leftovers report. +*/ + +const LIST_ITEM = /^(\s*-\s*)(['"]?)([^'"\s#]+)\2(\s*(#.*)?)$/; + +export const yarnrc: FileMigration = { + name: 'yarnrc', + kind: 'file', + description: 'Rename or drop old package names in .yarnrc.yml lists.', + match: (file) => /(^|\/)\.yarnrc\.yml$/.test(file), + run({ source, manifest }) { + const lines = source.split('\n'); + const globs = lines + .map((line) => LIST_ITEM.exec(line)?.[3]) + .filter((value): value is string => !!value?.endsWith('/*')) + .map((glob) => glob.slice(0, -1)); + + let changed = false; + const out = lines.flatMap((line) => { + const match = LIST_ITEM.exec(line); + const next = match && renameSpecifier(manifest, match[3]); + if (!match || !next) return [line]; + changed = true; + if (globs.some((prefix) => next.startsWith(prefix))) return []; + const [, lead, quote, , trail] = match; + /* `@` is reserved in YAML, so a plain scalar can't start with it. */ + const q = quote || (next.startsWith('@') ? "'" : ''); + return [`${lead}${q}${next}${q}${trail}`]; + }); + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/presets/index.ts b/packages/gamut-codemods/src/presets/index.ts new file mode 100644 index 0000000000..2ff0c9294a --- /dev/null +++ b/packages/gamut-codemods/src/presets/index.ts @@ -0,0 +1,43 @@ +import type { Preset } from '../lib/types'; +import { scopeSwap } from './scope-swap'; + +/* + Every preset the CLI can run. A preset is one upgrade: its own manifest, + an ordered list of migrations, and the checklist to print afterwards. + Later presets are named for the release they target and can reuse + migrations from src/migrations with a different manifest. +*/ +export const presets: Record = { + [scopeSwap.name]: scopeSwap, +}; + +/* + AST migrations print through recast once, then splice migrations edit that + output by offset. Interleaving them would need a print and re-parse per + switch, so presets have to list every `ast` migration before any + `splice` one. +*/ +export const validatePreset = (preset: Preset) => { + const source = preset.migrations.filter((m) => m.kind !== 'file'); + const firstSplice = source.findIndex((m) => m.kind === 'splice'); + const lateAst = + firstSplice === -1 + ? undefined + : source.slice(firstSplice).find((m) => m.kind === 'ast'); + if (lateAst) { + throw new Error( + `preset '${preset.name}': ast migration '${lateAst.name}' is listed after a splice migration. List every ast migration first.` + ); + } + return preset; +}; + +export const getPreset = (name: string) => { + const preset = presets[name]; + if (!preset) { + throw new Error( + `unknown preset '${name}'. Available: ${Object.keys(presets).join(', ')}` + ); + } + return validatePreset(preset); +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts new file mode 100644 index 0000000000..268ec8a107 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -0,0 +1,44 @@ +import type { Preset } from '../../lib/types'; +import { deepImports } from '../../migrations/deep-imports'; +import { eslintComments } from '../../migrations/eslint-comments'; +import { eslintConfig } from '../../migrations/eslint-config'; +import { mdxImports } from '../../migrations/mdx-imports'; +import { mfShared } from '../../migrations/mf-shared'; +import { movedExports } from '../../migrations/moved-exports'; +import { packageJson } from '../../migrations/package-json'; +import { scopeRename } from '../../migrations/scope-rename'; +import { tsconfigDom } from '../../migrations/tsconfig-dom'; +import { yarnrc } from '../../migrations/yarnrc'; +import { manifest } from './manifest'; + +export const scopeSwap: Preset = { + name: 'scope-swap', + description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', + manifest, + migrations: [ + deepImports, + movedExports, + mfShared, + eslintComments, + /* Last: everything above matches on @codecademy names. */ + scopeRename, + packageJson, + eslintConfig, + mdxImports, + yarnrc, + tsconfigDom, + ], + checklist: [ + 'Run your formatter (prettier --write / eslint --fix). Split imports come out in recast style.', + 'Reinstall (yarn), then type-check and run your tests.', + 'Work through the warnings above and the leftovers list.', + ], + conditionalChecklist: { + 'removed:@codecademy/gamut-kit': + 'gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script.', + 'tsconfig-no-dom': + 'Some tsconfig files set "lib" without "dom" (see the tsconfig-dom warnings). Video no longer loads with the root import, and it was supplying the DOM types. Add "dom" to those files if type-checking fails on document, HTMLElement, ResizeObserver, and so on.', + 'mf-shared': + "Module Federation shared config changed. Gamut is now a real singleton: run the host and every remote together, and update the remotes' shared config to match.", + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts new file mode 100644 index 0000000000..3edf272f1b --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Manifest } from '../../lib/manifest'; + +/* + What changed between @codecademy/gamut* and @skillsoft/gamut*. Every entry + uses the OLD (@codecademy) names; scope-rename runs last and swaps the + scope, so no other migration needs to know the new one. + + manifest.test.ts type-checks every export this file points at against the + built packages, so a row can't reference an export that doesn't exist. +*/ + +/* + This package is in the changesets `fixed` group with the packages it + migrates to, so its own version is theirs. +*/ +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8') +) as { version: string }; + +const GROUP_RANGE = `^${version}`; + +export const manifest: Manifest = { + packages: { + '@codecademy/gamut': { to: '@skillsoft/gamut' }, + '@codecademy/gamut-styles': { to: '@skillsoft/gamut-styles' }, + '@codecademy/gamut-icons': { to: '@skillsoft/gamut-icons' }, + '@codecademy/gamut-illustrations': { to: '@skillsoft/gamut-illustrations' }, + '@codecademy/gamut-patterns': { to: '@skillsoft/gamut-patterns' }, + '@codecademy/gamut-tests': { to: '@skillsoft/gamut-tests' }, + '@codecademy/variance': { to: '@skillsoft/variance' }, + 'eslint-plugin-gamut': { to: '@skillsoft/eslint-plugin-gamut' }, + }, + + removedPackages: { + '@codecademy/gamut-kit': { + expandTo: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/gamut-tests', + '@codecademy/variance', + ], + /* gamut-tests is test-only, so it has no business in a runtime share. */ + mfSharedAs: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/variance', + ], + note: 'gamut-kit is not published under @skillsoft. Depend on the individual packages instead.', + }, + }, + + targetVersions: { + '@skillsoft/gamut': GROUP_RANGE, + '@skillsoft/gamut-styles': GROUP_RANGE, + '@skillsoft/gamut-icons': GROUP_RANGE, + '@skillsoft/gamut-illustrations': GROUP_RANGE, + '@skillsoft/gamut-patterns': GROUP_RANGE, + '@skillsoft/gamut-tests': GROUP_RANGE, + '@skillsoft/variance': GROUP_RANGE, + /* + Versioned outside the fixed group. manifest.test.ts fails if this + falls behind packages/eslint-plugin-gamut/package.json. + */ + '@skillsoft/eslint-plugin-gamut': '^0.0.1', + }, + + movedExports: [ + { + from: '@codecademy/gamut', + to: '@codecademy/gamut/Video', + names: ['Video', 'VideoProps'], + note: 'Video is no longer on the root barrel. See packages/gamut/src/index.tsx.', + }, + ], + + /* + From the GMT-1740 deep-import inventory. Rows are only added once the + target is really exported; manifest.test.ts checks. + */ + deepImports: [ + { + from: '@codecademy/gamut/dist/PopoverContainer/types', + to: '@codecademy/gamut', + }, + { + from: '@codecademy/gamut/dist/Form/SelectDropdown/types', + to: '@codecademy/gamut', + }, + { from: '@codecademy/gamut/dist/Form/types', to: '@codecademy/gamut' }, + { + from: '@codecademy/gamut/dist/Box/props', + to: '@codecademy/gamut', + onlyNames: ['BoxProps', 'FlexBoxProps', 'GridBoxProps'], + }, + { + from: '@codecademy/gamut/dist/Tip/shared/types', + to: '@codecademy/gamut', + }, + { + from: '@codecademy/gamut/dist/Button/shared', + to: '@codecademy/gamut', + onlyNames: ['ButtonBaseProps', 'ButtonProps', 'buttonProps'], + }, + { + from: '@codecademy/gamut/dist/Markdown/libs/overrides', + to: '@codecademy/gamut', + onlyNames: [ + 'HTMLToReactNode', + 'MarkdownOverrideSetting', + 'MarkdownOverrideSettings', + 'createCodeBlockOverride', + 'createInputOverride', + 'createTagOverride', + 'createVideoOverride', + 'standardOverrides', + ], + }, + /* + Renamed on the way to the root: `List` and `IconOption` collided with + the public List component and IconOption type. This is the rename + upstream GMT-1740 held back (upstream 009aa43f6); skillsoft-gamut + ships it. + */ + { + from: '@codecademy/gamut/dist/Menu/elements', + to: '@codecademy/gamut', + renames: { + List: 'MenuList', + ListProps: 'MenuListProps', + ListItem: 'MenuListItem', + ListItemProps: 'MenuListItemProps', + ListLink: 'MenuListLink', + ListLinkProps: 'MenuListLinkProps', + ListButton: 'MenuListButton', + }, + }, + { + from: '@codecademy/gamut/dist/Form/SelectDropdown/elements', + to: '@codecademy/gamut', + renames: { IconOption: 'IconOptionComponent' }, + onlyNames: ['IconOption'], + }, + { + from: '@codecademy/gamut/dist/ButtonBase/ButtonBase', + to: null, + note: 'ButtonBase is deliberately not public. Use FillButton, StrokeButton, TextButton, IconButton, or CTAButton.', + }, + { + from: '@codecademy/gamut/dist/Form/styles', + to: '@codecademy/gamut', + onlyNames: [ + 'conditionalStyles', + 'formFieldPaddingStyles', + 'formFieldStyles', + ], + note: 'The rest of Form/styles is internal. Inline the styles, or use system props / css().', + }, + { + from: '@codecademy/gamut-styles/dist/AssetProvider', + to: '@codecademy/gamut-styles', + }, + { + from: '@codecademy/gamut-icons/dist/types', + to: '@codecademy/gamut-icons', + }, + { + from: '@codecademy/gamut-icons/dist/props', + to: '@codecademy/gamut-icons', + onlyNames: ['GamutIconProps', 'IconStyleProps'], + }, + { + from: '@codecademy/variance/dist/types/config', + to: '@codecademy/variance', + }, + ], + + eslintPlugin: { from: 'gamut', to: '@skillsoft/gamut' }, +}; diff --git a/packages/gamut-codemods/src/transform.ts b/packages/gamut-codemods/src/transform.ts new file mode 100644 index 0000000000..4892c2781e --- /dev/null +++ b/packages/gamut-codemods/src/transform.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; + +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { Locatable } from './lib/types'; +import { getPreset } from './presets'; + +/* + jscodeshift entry point: runs a preset's source migrations over one file + (see lib/run-source.ts for the two phases). + + Options: + preset: preset name (default: scope-swap) + only: comma-separated migration names to run (default: all) + notesDir: directory the CLI reads checklist notes back from. jscodeshift + only aggregates api.stats in --dry runs, so workers leave an + empty marker file per note key instead. + onWarn: (message) => void, for tests; otherwise warnings go to api.report +*/ + +export interface TransformOptions { + preset?: string; + only?: string; + notesDir?: string; + onWarn?: (message: string) => void; +} + +export default function transform( + file: FileInfo, + api: API, + options: TransformOptions = {} +) { + const preset = getPreset(options.preset ?? 'scope-swap'); + const { manifest } = preset; + if (!mentionsOldNames(manifest, file.source)) return undefined; + + const note = (key: string) => { + if (options.notesDir) { + fs.writeFileSync( + path.join(options.notesDir, encodeURIComponent(key)), + '' + ); + } + }; + const warnFor = (name: string) => (node: Locatable, message: string) => { + const line = node?.loc ? `:${node.loc.start.line}` : ''; + const text = `[${name}] ${file.path}${line} ${message}`; + if (options.onWarn) options.onWarn(text); + else api.report(text); + }; + + const source = runSourceMigrations({ + j: api.jscodeshift, + source: file.source, + migrations: activeSourceMigrations(preset.migrations, options.only), + manifest, + warnFor, + note, + }); + + return source === file.source ? undefined : source; +} + +export const parser = 'tsx'; diff --git a/packages/gamut-codemods/tsconfig.json b/packages/gamut-codemods/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/packages/gamut-codemods/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/gamut-codemods/tsconfig.lib.json b/packages/gamut-codemods/tsconfig.lib.json new file mode 100644 index 0000000000..8c51e7c29d --- /dev/null +++ b/packages/gamut-codemods/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "CommonJS", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["es2022"], + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.test.ts", "**/__testfixtures__/**"], + "include": ["./src/**/*.ts"] +} diff --git a/packages/gamut-codemods/tsconfig.spec.json b/packages/gamut-codemods/tsconfig.spec.json new file mode 100644 index 0000000000..38a15e287a --- /dev/null +++ b/packages/gamut-codemods/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist", + "lib": ["es2022"], + "types": ["jest", "node"] + }, + "exclude": ["**/__testfixtures__/**"], + "include": ["jest.config.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/packages/gamut-icons/package.json b/packages/gamut-icons/package.json index 7b1e72c985..19adbe5907 100644 --- a/packages/gamut-icons/package.json +++ b/packages/gamut-icons/package.json @@ -4,8 +4,8 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1" + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1" }, "exports": { "./package.json": "./package.json", @@ -24,7 +24,6 @@ "peerDependencies": { "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "lodash": "^4.17.23", "react": "^17.0.2 || ^18.3.0 || ^19.0.0" }, "publishConfig": { diff --git a/packages/gamut-illustrations/package.json b/packages/gamut-illustrations/package.json index b2e3f118e0..6dc03baed4 100644 --- a/packages/gamut-illustrations/package.json +++ b/packages/gamut-illustrations/package.json @@ -5,7 +5,7 @@ "author": "Skillsoft Engineering ", "bugs": "https://github.com/Codecademy/skillsoft-gamut/issues", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", "classnames": "^2.2.5" }, "exports": { diff --git a/packages/gamut-patterns/package.json b/packages/gamut-patterns/package.json index 5bcd8a2d6e..62c4677fd8 100644 --- a/packages/gamut-patterns/package.json +++ b/packages/gamut-patterns/package.json @@ -5,8 +5,8 @@ "author": "Skillsoft Engineering ", "bugs": "https://github.com/Codecademy/skillsoft-gamut/issues", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1", "classnames": "^2.2.5" }, "exports": { diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index e43eceef59..142fecbde2 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -4,11 +4,14 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { + "@emotion/cache": "^11.4.0", "@emotion/is-prop-valid": "^1.1.0", - "@skillsoft/variance": "0.0.1", + "@skillsoft/variance": "^0.0.1", "framer-motion": "^12.0.0", "get-nonce": "^1.0.0", - "polished": "^4.1.2" + "lodash": "^4.17.23", + "polished": "^4.1.2", + "stylis": "^4.2.0" }, "exports": { "./package.json": "./package.json", @@ -26,12 +29,9 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "lodash": "^4.17.23", - "react": "^17.0.2 || ^18.3.0 || ^19.0.0", - "stylis": "^4.0.7" + "react": "^17.0.2 || ^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-tests/package.json b/packages/gamut-tests/package.json index d8fbf72a64..62e2bb15fb 100644 --- a/packages/gamut-tests/package.json +++ b/packages/gamut-tests/package.json @@ -4,8 +4,8 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "component-test-setup": "*", + "@skillsoft/gamut-styles": "^0.0.1", + "component-test-setup": "^0.3.1", "lodash": "^4.17.23" }, "exports": { diff --git a/packages/gamut/Video/package.json b/packages/gamut/Video/package.json new file mode 100644 index 0000000000..9a1490663a --- /dev/null +++ b/packages/gamut/Video/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/Video/index.js", + "module": "../dist/Video/index.mjs", + "types": "../dist/Video/index.d.ts" +} diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 30593dae53..c04b2fbe56 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -16,6 +16,7 @@ exports[`Gamut Exported Keys 1`] = ` "Checkbox", "Coachmark", "Column", + "conditionalStyles", "ConnectedCheckbox", "ConnectedForm", "ConnectedFormGroup", @@ -54,6 +55,8 @@ exports[`Gamut Exported Keys 1`] = ` "focusVisibleStyle", "Form", "FormError", + "formFieldPaddingStyles", + "formFieldStyles", "FormGroup", "FormGroupDescription", "FormGroupLabel", @@ -65,6 +68,7 @@ exports[`Gamut Exported Keys 1`] = ` "GridForm", "GridFormContent", "IconButton", + "IconOptionComponent", "iFrameWrapper", "InfoTip", "Input", @@ -78,7 +82,12 @@ exports[`Gamut Exported Keys 1`] = ` "matchDisabledDates", "Menu", "MenuItem", + "MenuList", + "MenuListButton", + "MenuListItem", + "MenuListLink", "MenuSeparator", + "MenuToolTipWrapper", "Modal", "omitProps", "Overlay", diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 1e67294cb6..010bbacd8d 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -7,12 +7,11 @@ "dependencies": { "@formatjs/intl-locale": "5.3.1", "@react-aria/interactions": "3.25.0", - "@skillsoft/gamut-icons": "0.0.1", - "@skillsoft/gamut-illustrations": "0.0.1", - "@skillsoft/gamut-patterns": "0.0.1", - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1", - "@types/marked": "^4.0.8", + "@skillsoft/gamut-icons": "^0.0.1", + "@skillsoft/gamut-illustrations": "^0.0.1", + "@skillsoft/gamut-patterns": "^0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1", "@vidstack/react": "^1.12.12", "classnames": "^2.2.5", "framer-motion": "^12.0.0", @@ -30,6 +29,9 @@ "react-use": "^17.6.1", "sanitize-markdown": "^2.6.7" }, + "devDependencies": { + "@types/marked": "^4.0.8" + }, "exports": { "./package.json": "./package.json", ".": { @@ -45,7 +47,8 @@ }, "files": [ "dist", - "bin" + "bin", + "Video" ], "license": "MIT", "main": "./dist/index.js", diff --git a/packages/gamut/project.json b/packages/gamut/project.json index 5a12ae3d78..ce38f314a7 100644 --- a/packages/gamut/project.json +++ b/packages/gamut/project.json @@ -42,10 +42,7 @@ "dependsOn": ["build"], "options": { "cwd": "{projectRoot}", - "commands": [ - "publint .", - "attw --pack . --ignore-rules false-cjs no-resolution" - ], + "commands": ["publint .", "attw --pack . --ignore-rules false-cjs"], "parallel": false } }, diff --git a/packages/gamut/src/Button/index.ts b/packages/gamut/src/Button/index.ts index dae5e26ff3..dd02cffb1d 100644 --- a/packages/gamut/src/Button/index.ts +++ b/packages/gamut/src/Button/index.ts @@ -1,4 +1,4 @@ -export type { ButtonProps } from './shared'; +export type { ButtonBaseProps, ButtonProps } from './shared'; export { buttonProps } from './shared'; export * from './CTAButton'; export * from './FillButton'; diff --git a/packages/gamut/src/Form/SelectDropdown/core/constants.ts b/packages/gamut/src/Form/SelectDropdown/core/constants.ts index a5629613a2..50c1009384 100644 --- a/packages/gamut/src/Form/SelectDropdown/core/constants.ts +++ b/packages/gamut/src/Form/SelectDropdown/core/constants.ts @@ -6,7 +6,7 @@ import { CustomInput, CustomValueContainer, DropdownButton, - IconOption, + IconOptionComponent, MultiValueRemoveButton, MultiValueWithColorMode, RemoveAllButton, @@ -20,7 +20,7 @@ const baseDefaultComponents = { ValueContainer: CustomValueContainer, MultiValue: MultiValueWithColorMode, MultiValueRemove: MultiValueRemoveButton, - Option: IconOption, + Option: IconOptionComponent, SingleValue: AbbreviatedSingleValue, Input: CustomInput, }; diff --git a/packages/gamut/src/Form/SelectDropdown/elements/index.ts b/packages/gamut/src/Form/SelectDropdown/elements/index.ts index 5a67d3619b..9ab1c6655e 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/index.ts +++ b/packages/gamut/src/Form/SelectDropdown/elements/index.ts @@ -18,7 +18,7 @@ export { } from './containers'; export { - IconOption, + IconOptionComponent, AbbreviatedSingleValue, formatOptionLabel, formatGroupLabel, diff --git a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx index e91b25178a..17b011b7d4 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx @@ -46,7 +46,7 @@ const IconOptionLabel: React.FC< * Also manages ARIA attributes for accessibility. * Skips the check icon for react-select/creatable's "Add" row (__isNew__). */ -export const IconOption = ({ +export const IconOptionComponent = ({ children, ...rest }: CustomSelectComponentProps) => { diff --git a/packages/gamut/src/Form/SelectDropdown/index.tsx b/packages/gamut/src/Form/SelectDropdown/index.tsx index fd0cb718f8..f6d233827e 100644 --- a/packages/gamut/src/Form/SelectDropdown/index.tsx +++ b/packages/gamut/src/Form/SelectDropdown/index.tsx @@ -1,3 +1,4 @@ export type { SelectDropdownTranslations } from './core/translations'; export * from './types'; +export { IconOptionComponent } from './elements'; export * from './SelectDropdown'; diff --git a/packages/gamut/src/Form/index.tsx b/packages/gamut/src/Form/index.tsx index f59e8507d7..7301e9103c 100644 --- a/packages/gamut/src/Form/index.tsx +++ b/packages/gamut/src/Form/index.tsx @@ -13,3 +13,12 @@ export * from './inputs/Radio'; export * from './inputs/RadioGroup'; export * from './inputs/types'; export * from './types'; +/* + For custom inputs that should look like Input, such as third-party hosted + payment fields. The rest of ./styles is internal. +*/ +export { + conditionalStyles, + formFieldPaddingStyles, + formFieldStyles, +} from './styles'; diff --git a/packages/gamut/src/Markdown/index.tsx b/packages/gamut/src/Markdown/index.tsx index 8b04affd7a..80af520bcb 100644 --- a/packages/gamut/src/Markdown/index.tsx +++ b/packages/gamut/src/Markdown/index.tsx @@ -191,6 +191,7 @@ export class Markdown extends PureComponent { } export type { + HTMLToReactNode, MarkdownOverrideSetting, MarkdownOverrideSettings, } from './libs/overrides'; diff --git a/packages/gamut/src/Menu/Menu.tsx b/packages/gamut/src/Menu/Menu.tsx index 83b6bd328d..10d647f08e 100644 --- a/packages/gamut/src/Menu/Menu.tsx +++ b/packages/gamut/src/Menu/Menu.tsx @@ -1,12 +1,12 @@ import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; -import { List } from './elements'; +import { MenuList } from './elements'; import { MenuProvider, useMenu } from './MenuContext'; export const Menu = forwardRef< HTMLUListElement | HTMLOListElement, - Omit, 'root'> + Omit, 'root'> >( ( { children, variant = 'popover', spacing = 'normal', role, ...rest }, @@ -15,9 +15,9 @@ export const Menu = forwardRef< const currentContext = useMenu({ variant, role, spacing }); return ( - + {children} - + ); } ); diff --git a/packages/gamut/src/Menu/MenuItem.tsx b/packages/gamut/src/Menu/MenuItem.tsx index 04ca7efb7c..3288ab987a 100644 --- a/packages/gamut/src/Menu/MenuItem.tsx +++ b/packages/gamut/src/Menu/MenuItem.tsx @@ -6,8 +6,13 @@ import { forwardRef, useId } from 'react'; import { FlexBox } from '../Box'; import type { ToolTipProps } from '../Tip/ToolTip'; import { Text } from '../Typography'; -import type { ListItemProps, ListLinkProps } from './elements'; -import { ListButton, ListItem, ListLink, MenuToolTipWrapper } from './elements'; +import type { MenuListItemProps, MenuListLinkProps } from './elements'; +import { + MenuListButton, + MenuListItem, + MenuListLink, + MenuToolTipWrapper, +} from './elements'; import { useMenuContext } from './MenuContext'; const getListItemType = (href: boolean, onClick: boolean) => @@ -26,7 +31,7 @@ const currentItemText = { type HTMLProps = Partial>; type ForwardListItemProps = Omit< - ComponentProps, + ComponentProps, 'variant' | 'selected' | 'active-navlink' | 'children' >; @@ -97,7 +102,7 @@ export const MenuItem = forwardRef< role: listItemRole, height, width, - } as ListItemProps; + } as MenuListItemProps; const ariaLabel = label ? typeof label === 'string' @@ -139,18 +144,18 @@ export const MenuItem = forwardRef< if (listItemType === 'link' && !disabled) { return ( - + - (ref)} target={target} > {content} - + - + ); } @@ -160,32 +165,32 @@ export const MenuItem = forwardRef< : (props.onClick as any as MouseEventHandler); return ( - + - (ref)} onClick={handleClick} > {content} - + - + ); } return ( // These are non-interactive and will never have tooltips (nor should they). - (ref)} > {content} - + ); } ); diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index d0f63d75cc..6ce6501958 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -34,7 +34,7 @@ const listProps = variance.compose( system.color ); -export interface ListProps extends ListStyleProps, StyleStateProps { +export interface MenuListProps extends ListStyleProps, StyleStateProps { /** How offset spacing should be */ spacing?: 'normal' | 'condensed'; /** Menu variants for specific use cases and styles */ @@ -46,7 +46,7 @@ export interface ListProps extends ListStyleProps, StyleStateProps { showBorder?: boolean; } -const StyledList = styled('ul', styledOptions<'ul'>())( +const StyledList = styled('ul', styledOptions<'ul'>())( system.css({ listStyle: 'none', width: 1, @@ -70,7 +70,7 @@ const StyledList = styled('ul', styledOptions<'ul'>())( listProps ); -export const List = forwardRef< +export const MenuList = forwardRef< HTMLUListElement, ComponentProps >(({ context = true, m = 0, root = true, variant, ...rest }, ref) => ( @@ -195,14 +195,17 @@ const sizeVariants = system.variant({ }, }); -export interface ListItemProps +export interface MenuListItemProps extends ListStyleProps, StyleStateProps, StyleProps, StyleProps, StyleProps {} -export const ListItem = styled('li', styledOptions<'li'>())( +export const MenuListItem = styled( + 'li', + styledOptions<'li'>() +)( interactiveVariants, activeStates, sizeVariants, @@ -210,12 +213,12 @@ export const ListItem = styled('li', styledOptions<'li'>())( listProps ); -export interface ListLinkProps extends ListItemProps { +export interface MenuListLinkProps extends MenuListItemProps { active?: boolean; navlink?: boolean; } -const StyledListLink = styled('a', styledOptions<'a'>())( +const StyledListLink = styled('a', styledOptions<'a'>())( resetStyles, interactiveVariants, activeStates, @@ -224,17 +227,17 @@ const StyledListLink = styled('a', styledOptions<'a'>())( listProps ); -export const ListLink = forwardRef< +export const MenuListLink = forwardRef< HTMLAnchorElement, ComponentProps >(({ zIndex = 1, ...rest }, ref) => ( )); -export const ListButton = styled( +export const MenuListButton = styled( 'button', styledOptions<'button'>() -)( +)( resetStyles, interactiveVariants, activeStates, diff --git a/packages/gamut/src/Menu/index.tsx b/packages/gamut/src/Menu/index.tsx index 65077f603d..54f17e7928 100644 --- a/packages/gamut/src/Menu/index.tsx +++ b/packages/gamut/src/Menu/index.tsx @@ -1,3 +1,4 @@ +export * from './elements'; export * from './Menu'; export * from './MenuItem'; export * from './MenuSeparator'; diff --git a/packages/variance/package.json b/packages/variance/package.json index 9625a1bcc1..a750678e6a 100644 --- a/packages/variance/package.json +++ b/packages/variance/package.json @@ -40,5 +40,10 @@ "build": "nx build variance" }, "sideEffects": false, - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } diff --git a/yarn.lock b/yarn.lock index cb150b23ce..175d006d2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -437,7 +437,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": +"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -473,12 +473,12 @@ __metadata: languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" +"@babel/helper-annotate-as-pure@npm:^7.25.9, @babel/helper-annotate-as-pure@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.25.9" - checksum: 10c0/095b6ba50489d797733abebc4596a81918316a99e3632755c9f02508882912b00c2ae5e468532a25a5c2108d109ddbe9b7da78333ee7cc13817fc50c00cf06fe + "@babel/types": "npm:^7.29.7" + checksum: 10c0/c56536b52d17632d89d49db2063ed6102f0e3bbadf6a0ccb74e6599d6a77173b644c7fe8c3ef17c7a162709d55b75ee5145ef6db917d16ba7f375fbffcf2e942 languageName: node linkType: hard @@ -505,20 +505,20 @@ __metadata: languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-create-class-features-plugin@npm:7.25.9" +"@babel/helper-create-class-features-plugin@npm:^7.25.9, @babel/helper-create-class-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.25.9" - "@babel/helper-member-expression-to-functions": "npm:^7.25.9" - "@babel/helper-optimise-call-expression": "npm:^7.25.9" - "@babel/helper-replace-supers": "npm:^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/b2bdd39f38056a76b9ba00ec5b209dd84f5c5ebd998d0f4033cf0e73d5f2c357fbb49d1ce52db77a2709fb29ee22321f84a5734dc9914849bdfee9ad12ce8caf + checksum: 10c0/75f34905b5e708b473f1e9b33e07b2fcc8f4c60676df8bc74541bb91c77f387c32a948dd04d5071e469ba454d72d0a872e3ace40fbb1d1e7aaa8569efcf09ed4 languageName: node linkType: hard @@ -557,13 +557,13 @@ __metadata: languageName: node linkType: hard -"@babel/helper-member-expression-to-functions@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-member-expression-to-functions@npm:7.25.9" +"@babel/helper-member-expression-to-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/e08c7616f111e1fb56f398365e78858e26e466d4ac46dff25921adc5ccae9b232f66e952a2f4162bbe336627ba336c7fd9eca4835b6548935973d3380d77eaff + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/eef7940ce0797208854a5af1049a98fee9abbffb5c619640c69ff5a555f8e3552295bb18756490b02bc6af7df8c1babcb83f12203aac2deb9dfecfc78846e12d languageName: node linkType: hard @@ -590,12 +590,12 @@ __metadata: languageName: node linkType: hard -"@babel/helper-optimise-call-expression@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-optimise-call-expression@npm:7.25.9" +"@babel/helper-optimise-call-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-optimise-call-expression@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.25.9" - checksum: 10c0/90203e6607edeadd2a154940803fd616c0ed92c1013d6774c4b8eb491f1a5a3448b68faae6268141caa5c456e55e3ee49a4ed2bd7ddaf2365daea321c435914c + "@babel/types": "npm:^7.29.7" + checksum: 10c0/fd0244b9bfbb487db02d59aa2703c6991d654ea5f3f39d912682842bdca2e87b5ae8643b0ce8069bf5fbee39d1aa9db7abefeb5e6ba1aa650dca12777cf5b7e2 languageName: node linkType: hard @@ -619,36 +619,26 @@ __metadata: languageName: node linkType: hard -"@babel/helper-replace-supers@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-replace-supers@npm:7.25.9" +"@babel/helper-replace-supers@npm:^7.25.9, @babel/helper-replace-supers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-replace-supers@npm:7.29.7" dependencies: - "@babel/helper-member-expression-to-functions": "npm:^7.25.9" - "@babel/helper-optimise-call-expression": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/0b40d7d2925bd3ba4223b3519e2e4d2456d471ad69aa458f1c1d1783c80b522c61f8237d3a52afc9e47c7174129bbba650df06393a6787d5722f2ec7f223c3f4 + checksum: 10c0/1c7ae37797f226e965ab85f6affa53d25a10c169c604a4daeb36f9df09e673471e6522f631c13761cf9fbafeca2ea14c241dea8d723a51039d561beb01d86ac4 languageName: node linkType: hard -"@babel/helper-simple-access@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-simple-access@npm:7.25.9" - dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/3f1bcdb88ee3883ccf86959869a867f6bbf8c4737cd44fb9f799c38e54f67474590bc66802500ae9fe18161792875b2cfb7ec15673f48ed6c8663f6d09686ca8 - languageName: node - linkType: hard - -"@babel/helper-skip-transparent-expression-wrappers@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.25.9" +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.25.9, @babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/09ace0c6156961624ac9524329ce7f45350bab94bbe24335cbe0da7dfaa1448e658771831983cb83fe91cf6635b15d0a3cab57c03b92657480bfb49fb56dd184 + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8c59493621487fc491f27adfc200af82a6aca3b9a5511e4e6050f8716593b4b243472cb56c8d2016e828b7ae12d605a819205aa8600ca08ee291dcd58d65c832 languageName: node linkType: hard @@ -841,6 +831,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-flow@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b2e330dd0dc535c7364937ce2b29a06065e60b1d523db75f36ab4fb89e4ba664e2230cbb1937b35712c9a0ebafc3358f323d6e6b22247d5ebad12fdd3e1f6e98 + languageName: node + linkType: hard + "@babel/plugin-syntax-import-assertions@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-assertions@npm:7.26.0" @@ -1066,15 +1067,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.22.5, @babel/plugin-transform-class-properties@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-class-properties@npm:7.25.9" +"@babel/plugin-transform-class-properties@npm:^7.22.5, @babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/f0603b6bd34d8ba62c03fc0572cb8bbc75874d097ac20cc7c5379e001081210a84dba1749e7123fca43b978382f605bb9973c99caf2c5b4c492d5c0a4a441150 + checksum: 10c0/c370700423439aa9f0c1f8c4b97f2ef7c2dc46a1b04ec3b10e83e6bae5e4e2159f56d8e4376c9d669b3cf827650cc3740170a36e3924e3e9970d27fd85f4e48a languageName: node linkType: hard @@ -1198,6 +1199,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-flow-strip-types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-syntax-flow": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/083e864a60927cde7bd75822bdf5c6c72de200ee955e48f6ff5923f0d2b0744ab8f1b64c45e74b88ae3796f03721489afffdb0536f25c54d0dd58508a8904f40 + languageName: node + linkType: hard + "@babel/plugin-transform-for-of@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-for-of@npm:7.25.9" @@ -1279,16 +1292,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-modules-commonjs@npm:7.25.9" +"@babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" - "@babel/helper-simple-access": "npm:^7.25.9" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/6ce771fb04d4810257fc8900374fece877dacaed74b05eaa16ad9224b390f43795c4d046cbe9ae304e1eb5aad035d37383895e3c64496d647c2128d183916e74 + checksum: 10c0/9791cb524438b2a8ba6cb8715788fa1e202fbecd4e76b3ccab0af0819fd69212b40ae30d72ac377012f7149889f7792ed8bf91e97bbe9113ca9641f8ad3bf332 languageName: node linkType: hard @@ -1341,14 +1353,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.25.9" +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/eb623db5be078a1c974afe7c7797b0309ba2ea9e9237c0b6831ade0f56d8248bb4ab3432ab34495ff8c877ec2fe412ff779d1e9b3c2b8139da18e1753d950bc3 + checksum: 10c0/b0c186fe38bc66830e1be76f06fabbae8a655d3896a841ba5ffa12d6c40bb9c8a6ecd38a7e2196034b1d7470653109b1ceac1ef5e46a5fdc9291d14afa56e0d0 languageName: node linkType: hard @@ -1399,15 +1411,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-optional-chaining@npm:7.25.9" +"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.25.9" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/041ad2beae5affb8e68a0bcb6882a2dadb758db3c629a0e012f57488ab43a822ac1ea17a29db8ef36560a28262a5dfa4dbbbf06ed6e431db55abe024b7cd3961 + checksum: 10c0/71feacf9a7083030f4c69bf4e91db75f2fceae28e58c58b63db040006d908e15bc1e85a464f24ad659f17702e91a64eabbff9f1dd555ba33d78bb1af8a1d697a languageName: node linkType: hard @@ -1422,15 +1434,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-methods@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-private-methods@npm:7.25.9" +"@babel/plugin-transform-private-methods@npm:^7.24.7, @babel/plugin-transform-private-methods@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/64bd71de93d39daefa3e6c878d6f2fd238ed7d4ecfb13b0e771ddbbc131487def3ceb405b62b534a5cbb5043046b504e1b189b0a45229cc75af979a9fbcaa7bd + checksum: 10c0/d0dc12fa478d05e346dfb02fad2ed99b308d9a7324bada71530a62dc1ccbf07b4c2581ac677b7196b3994f191ef1922199e2c8f33957b726e2019ce07b4ced0f languageName: node linkType: hard @@ -1788,6 +1800,19 @@ __metadata: languageName: node linkType: hard +"@babel/preset-flow@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/preset-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + "@babel/plugin-transform-flow-strip-types": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/17bb0f3c320fadb631ab64951ec0f6e05a62b15418c7864c181645b3205cabd3a8541dd3f497bf06cb1d0e04a3806f6cffee7675c97a87d4aaa04961f334cdcb + languageName: node + linkType: hard + "@babel/preset-modules@npm:0.1.6-no-external-plugins": version: 0.1.6-no-external-plugins resolution: "@babel/preset-modules@npm:0.1.6-no-external-plugins" @@ -1832,6 +1857,21 @@ __metadata: languageName: node linkType: hard +"@babel/register@npm:^7.24.6": + version: 7.29.7 + resolution: "@babel/register@npm:7.29.7" + dependencies: + clone-deep: "npm:^4.0.1" + find-cache-dir: "npm:^2.0.0" + make-dir: "npm:^2.1.0" + pirates: "npm:^4.0.6" + source-map-support: "npm:^0.5.16" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/154dc72cc354a3d2b7b69d10738f02278774bbf53d3b365f7897035c9f26ad6433b24e81ad277c0fbf33139e08230e0cab3913e91d9325308af532da854765f2 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.28.2 resolution: "@babel/runtime@npm:7.28.2" @@ -7603,6 +7643,18 @@ __metadata: languageName: unknown linkType: soft +"@skillsoft/gamut-codemods@workspace:packages/gamut-codemods": + version: 0.0.0-use.local + resolution: "@skillsoft/gamut-codemods@workspace:packages/gamut-codemods" + dependencies: + "@skillsoft/gamut": "workspace:*" + "@types/jscodeshift": "npm:^17.3.0" + jscodeshift: "npm:^17.3.0" + bin: + gamut-codemods: ./dist/bin.js + languageName: unknown + linkType: soft + "@skillsoft/gamut-docs@workspace:packages/gamut-docs": version: 0.0.0-use.local resolution: "@skillsoft/gamut-docs@workspace:packages/gamut-docs" @@ -7625,25 +7677,24 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-icons@npm:0.0.1, @skillsoft/gamut-icons@workspace:*, @skillsoft/gamut-icons@workspace:packages/gamut-icons": +"@skillsoft/gamut-icons@npm:^0.0.1, @skillsoft/gamut-icons@workspace:*, @skillsoft/gamut-icons@workspace:packages/gamut-icons": version: 0.0.0-use.local resolution: "@skillsoft/gamut-icons@workspace:packages/gamut-icons" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" peerDependencies: "@emotion/react": ^11.4.0 "@emotion/styled": ^11.3.0 - lodash: ^4.17.23 react: ^17.0.2 || ^18.3.0 || ^19.0.0 languageName: unknown linkType: soft -"@skillsoft/gamut-illustrations@npm:0.0.1, @skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations": +"@skillsoft/gamut-illustrations@npm:^0.0.1, @skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations": version: 0.0.0-use.local resolution: "@skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" classnames: "npm:^2.2.5" peerDependencies: "@emotion/react": ^11.4.0 @@ -7653,12 +7704,12 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-patterns@npm:0.0.1, @skillsoft/gamut-patterns@workspace:packages/gamut-patterns": +"@skillsoft/gamut-patterns@npm:^0.0.1, @skillsoft/gamut-patterns@workspace:packages/gamut-patterns": version: 0.0.0-use.local resolution: "@skillsoft/gamut-patterns@workspace:packages/gamut-patterns" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" classnames: "npm:^2.2.5" peerDependencies: "@emotion/react": ^11.4.0 @@ -7668,22 +7719,22 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-styles@npm:0.0.1, @skillsoft/gamut-styles@workspace:*, @skillsoft/gamut-styles@workspace:packages/gamut-styles": +"@skillsoft/gamut-styles@npm:^0.0.1, @skillsoft/gamut-styles@workspace:*, @skillsoft/gamut-styles@workspace:packages/gamut-styles": version: 0.0.0-use.local resolution: "@skillsoft/gamut-styles@workspace:packages/gamut-styles" dependencies: + "@emotion/cache": "npm:^11.4.0" "@emotion/is-prop-valid": "npm:^1.1.0" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/variance": "npm:^0.0.1" framer-motion: "npm:^12.0.0" get-nonce: "npm:^1.0.0" + lodash: "npm:^4.17.23" polished: "npm:^4.1.2" + stylis: "npm:^4.2.0" peerDependencies: - "@emotion/cache": ^11.4.0 "@emotion/react": ^11.4.0 "@emotion/styled": ^11.3.0 - lodash: ^4.17.23 react: ^17.0.2 || ^18.3.0 || ^19.0.0 - stylis: ^4.0.7 languageName: unknown linkType: soft @@ -7691,8 +7742,8 @@ __metadata: version: 0.0.0-use.local resolution: "@skillsoft/gamut-tests@workspace:packages/gamut-tests" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - component-test-setup: "npm:*" + "@skillsoft/gamut-styles": "npm:^0.0.1" + component-test-setup: "npm:^0.3.1" lodash: "npm:^4.17.23" peerDependencies: react: ^17.0.2 || ^18.3.0 || ^19.0.0 @@ -7705,11 +7756,11 @@ __metadata: dependencies: "@formatjs/intl-locale": "npm:5.3.1" "@react-aria/interactions": "npm:3.25.0" - "@skillsoft/gamut-icons": "npm:0.0.1" - "@skillsoft/gamut-illustrations": "npm:0.0.1" - "@skillsoft/gamut-patterns": "npm:0.0.1" - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-icons": "npm:^0.0.1" + "@skillsoft/gamut-illustrations": "npm:^0.0.1" + "@skillsoft/gamut-patterns": "npm:^0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" "@types/marked": "npm:^4.0.8" "@vidstack/react": "npm:^1.12.12" classnames: "npm:^2.2.5" @@ -7755,7 +7806,7 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/variance@npm:0.0.1, @skillsoft/variance@workspace:packages/variance": +"@skillsoft/variance@npm:^0.0.1, @skillsoft/variance@workspace:packages/variance": version: 0.0.0-use.local resolution: "@skillsoft/variance@workspace:packages/variance" dependencies: @@ -7764,6 +7815,9 @@ __metadata: peerDependencies: "@emotion/react": "*" typescript: ">=4.3.5" + peerDependenciesMeta: + typescript: + optional: true languageName: unknown linkType: soft @@ -8859,6 +8913,16 @@ __metadata: languageName: node linkType: hard +"@types/jscodeshift@npm:^17.3.0": + version: 17.3.0 + resolution: "@types/jscodeshift@npm:17.3.0" + dependencies: + ast-types: "npm:^0.16.1" + recast: "npm:^0.23.11" + checksum: 10c0/56b889b290f12322b43d74c7434b69cef3e1ca8444e37891e08585f716b1c23f44596f650fb92220a8c6e5776d3dbc7d8ece67e950b6f9e733eee8e712128c73 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -11411,6 +11475,17 @@ __metadata: languageName: node linkType: hard +"clone-deep@npm:^4.0.1": + version: 4.0.1 + resolution: "clone-deep@npm:4.0.1" + dependencies: + is-plain-object: "npm:^2.0.4" + kind-of: "npm:^6.0.2" + shallow-clone: "npm:^3.0.0" + checksum: 10c0/637753615aa24adf0f2d505947a1bb75e63964309034a1cf56ba4b1f30af155201edd38d26ffe26911adaae267a3c138b344a4947d39f5fc1b6d6108125aa758 + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -11585,7 +11660,7 @@ __metadata: languageName: node linkType: hard -"component-test-setup@npm:*, component-test-setup@npm:^0.3.1": +"component-test-setup@npm:^0.3.1": version: 0.3.3 resolution: "component-test-setup@npm:0.3.3" checksum: 10c0/f85ee9a5bf5f65b8440cd6fe03a626e1222b1bba2f29798a4de1c1d7fdbf87397a5ad11e19b0ed20030033c7bede66d2ae6c5bfba426e00b9383adbe0dd0a584 @@ -13950,6 +14025,17 @@ __metadata: languageName: node linkType: hard +"find-cache-dir@npm:^2.0.0": + version: 2.1.0 + resolution: "find-cache-dir@npm:2.1.0" + dependencies: + commondir: "npm:^1.0.1" + make-dir: "npm:^2.0.0" + pkg-dir: "npm:^3.0.0" + checksum: 10c0/556117fd0af14eb88fb69250f4bba9e905e7c355c6136dff0e161b9cbd1f5285f761b778565a278da73a130f42eccc723d7ad4c002ae547ed1d698d39779dabb + languageName: node + linkType: hard + "find-cache-dir@npm:^3.3.1, find-cache-dir@npm:^3.3.2": version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" @@ -13993,6 +14079,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10c0/2c2e7d0a26db858e2f624f39038c74739e38306dee42b45f404f770db357947be9d0d587f1cac72d20c114deb38aa57316e879eb0a78b17b46da7dab0a3bd6e3 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -14047,6 +14142,22 @@ __metadata: languageName: node linkType: hard +"flow-estree@npm:0.331.0": + version: 0.331.0 + resolution: "flow-estree@npm:0.331.0" + checksum: 10c0/3e967611d5e4f83a4fde886d90cffe48078702b51258eddcc2f6c2e5d8bca37986df5d890b9f787b0760bb35886bcc3aecd7db6f7db1fff0f9f2ef7b216cd281 + languageName: node + linkType: hard + +"flow-parser@npm:0.*": + version: 0.331.0 + resolution: "flow-parser@npm:0.331.0" + dependencies: + flow-estree: "npm:0.331.0" + checksum: 10c0/bfeb7652a3f82c6689b7290ec2e40973c0a665990a199fd160e16854f6f163449e348c296df218cebe303f5e547665a32131e4598a4eeba821ac72fd415e7134 + languageName: node + linkType: hard + "focus-lock@npm:^1.3.6": version: 1.3.6 resolution: "focus-lock@npm:1.3.6" @@ -15836,6 +15947,15 @@ __metadata: languageName: node linkType: hard +"is-plain-object@npm:^2.0.4": + version: 2.0.4 + resolution: "is-plain-object@npm:2.0.4" + dependencies: + isobject: "npm:^3.0.1" + checksum: 10c0/f050fdd5203d9c81e8c4df1b3ff461c4bc64e8b5ca383bcdde46131361d0a678e80bcf00b5257646f6c636197629644d53bd8e2375aea633de09a82d57e942f4 + languageName: node + linkType: hard + "is-plain-object@npm:^5.0.0": version: 5.0.0 resolution: "is-plain-object@npm:5.0.0" @@ -16003,6 +16123,13 @@ __metadata: languageName: node linkType: hard +"isobject@npm:^3.0.1": + version: 3.0.1 + resolution: "isobject@npm:3.0.1" + checksum: 10c0/03344f5064a82f099a0cd1a8a407f4c0d20b7b8485e8e816c39f249e9416b06c322e8dec5b842b6bb8a06de0af9cb48e7bc1b5352f0fadc2f0abac033db3d4db + languageName: node + linkType: hard + "isomorphic-ws@npm:5.0.0": version: 5.0.0 resolution: "isomorphic-ws@npm:5.0.0" @@ -16642,6 +16769,39 @@ __metadata: languageName: node linkType: hard +"jscodeshift@npm:^17.3.0": + version: 17.4.0 + resolution: "jscodeshift@npm:17.4.0" + dependencies: + "@babel/core": "npm:^7.24.7" + "@babel/parser": "npm:^7.24.7" + "@babel/plugin-transform-class-properties": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/preset-flow": "npm:^7.24.7" + "@babel/preset-typescript": "npm:^7.24.7" + "@babel/register": "npm:^7.24.6" + flow-parser: "npm:0.*" + graceful-fs: "npm:^4.2.4" + neo-async: "npm:^2.5.0" + picocolors: "npm:^1.0.1" + picomatch: "npm:^4.0.2" + recast: "npm:^0.23.11" + tmp: "npm:^0.2.3" + write-file-atomic: "npm:^5.0.1" + peerDependencies: + "@babel/preset-env": ^7.1.6 + peerDependenciesMeta: + "@babel/preset-env": + optional: true + bin: + jscodeshift: bin/jscodeshift.js + checksum: 10c0/ef7b71c22f211cbba8ae96ffd2d3e8af28450d8f703d32050ff9f6cb29042bc163ce7d8239798b672b543bb12203adea20fb4d2c0034acc5440d26cb17c7a42f + languageName: node + linkType: hard + "jsdom@npm:^26.1.0": version: 26.1.0 resolution: "jsdom@npm:26.1.0" @@ -16813,7 +16973,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.3": +"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 @@ -17088,6 +17248,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10c0/3db394b7829a7fe2f4fbdd25d3c4689b85f003c318c5da4052c7e56eed697da8f1bce5294f685c69ff76e32cba7a33629d94396976f6d05fb7f4c755c5e2ae8b + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -17309,6 +17479,16 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0": + version: 2.1.0 + resolution: "make-dir@npm:2.1.0" + dependencies: + pify: "npm:^4.0.1" + semver: "npm:^5.6.0" + checksum: 10c0/ada869944d866229819735bee5548944caef560d7a8536ecbc6536edca28c72add47cc4f6fc39c54fb25d06b58da1f8994cf7d9df7dadea047064749efc085d8 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -18609,7 +18789,7 @@ __metadata: languageName: node linkType: hard -"neo-async@npm:^2.6.2": +"neo-async@npm:^2.5.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" checksum: 10c0/c2f5a604a54a8ec5438a342e1f356dff4bc33ccccdb6dc668d94fe8e5eccfc9d2c2eea6064b0967a767ba63b33763f51ccf2cd2441b461a7322656c1f06b3f5d @@ -19370,7 +19550,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -19397,6 +19577,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10c0/7b7f06f718f19e989ce6280ed4396fb3c34dabdee0df948376483032f9d5ec22fdf7077ec942143a75827bb85b11da72016497fc10dac1106c837ed593969ee8 + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -19619,6 +19808,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10c0/17d6a5664bc0a11d48e2b2127d28a0e58822c6740bde30403f08013da599182289c56518bec89407e3f31d3c2b6b296a4220bc3f867f0911fee6952208b04167 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -19737,6 +19933,13 @@ __metadata: languageName: node linkType: hard +"pify@npm:^4.0.1": + version: 4.0.1 + resolution: "pify@npm:4.0.1" + checksum: 10c0/6f9d404b0d47a965437403c9b90eca8bb2536407f03de165940e62e72c8c8b75adda5516c6b9b23675a5877cc0bcac6bdfb0ef0e39414cd2476d5495da40e7cf + languageName: node + linkType: hard + "pinkie-promise@npm:^2.0.0": version: 2.0.1 resolution: "pinkie-promise@npm:2.0.1" @@ -19753,13 +19956,22 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": +"pirates@npm:^4.0.6, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10c0/a51f108dd811beb779d58a76864bbd49e239fa40c7984cd11596c75a121a8cc789f1c8971d8bb15f0dbf9d48b76c05bb62fcbce840f89b688c0fa64b37e8478a languageName: node linkType: hard +"pkg-dir@npm:^3.0.0": + version: 3.0.0 + resolution: "pkg-dir@npm:3.0.0" + dependencies: + find-up: "npm:^3.0.0" + checksum: 10c0/902a3d0c1f8ac43b1795fa1ba6ffeb37dfd53c91469e969790f6ed5e29ff2bdc50b63ba6115dc056d2efb4a040aa2446d512b3804bdafdf302f734fb3ec21847 + languageName: node + linkType: hard + "pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -20717,16 +20929,16 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.23.5": - version: 0.23.9 - resolution: "recast@npm:0.23.9" +"recast@npm:^0.23.11, recast@npm:^0.23.5": + version: 0.23.21 + resolution: "recast@npm:0.23.21" dependencies: ast-types: "npm:^0.16.1" esprima: "npm:~4.0.0" source-map: "npm:~0.6.1" tiny-invariant: "npm:^1.3.3" tslib: "npm:^2.0.1" - checksum: 10c0/65d6e780351f0180ea4fe5c9593ac18805bf2b79977f5bedbbbf26f6d9b619ed0f6992c1bf9e06dd40fca1aea727ad6d62463cfb5d3a33342ee5a6e486305fe5 + checksum: 10c0/afee4673fda5a95b0db8199a3f75c6f675269f00328a3673f241a6088c99b5a58f7082b6cff683456a182e3302bfb57313ebb1c2073228b260724b0993ff1f9a languageName: node linkType: hard @@ -21778,6 +21990,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^5.6.0": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 + languageName: node + linkType: hard + "semver@npm:^6.0.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -21880,6 +22101,15 @@ __metadata: languageName: node linkType: hard +"shallow-clone@npm:^3.0.0": + version: 3.0.1 + resolution: "shallow-clone@npm:3.0.1" + dependencies: + kind-of: "npm:^6.0.2" + checksum: 10c0/7bab09613a1b9f480c85a9823aebec533015579fa055ba6634aa56ba1f984380670eaf33b8217502931872aa1401c9fcadaa15f9f604d631536df475b05bcf1e + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0" @@ -22338,7 +22568,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -22868,10 +23098,10 @@ __metadata: languageName: node linkType: hard -"stylis@npm:^4.3.0": - version: 4.3.4 - resolution: "stylis@npm:4.3.4" - checksum: 10c0/4899c2674cd2538e314257abd1ba7ea3c2176439659ddac6593c78192cfd4a06f814a0a4fc69bc7f8fcc6b997e13d383dd9b578b71074746a0fb86045a83e42d +"stylis@npm:^4.2.0, stylis@npm:^4.3.0": + version: 4.4.0 + resolution: "stylis@npm:4.4.0" + checksum: 10c0/259be096d90dfbfe903c8656dcb7591e52a421e577e950ef42ebd9ca02f387623a1165dd08761492fb6e92a7a562d62a53a694a10b0a2f6dcd7a0db107b4bf55 languageName: node linkType: hard @@ -23266,10 +23496,10 @@ __metadata: languageName: node linkType: hard -"tmp@npm:~0.2.1": - version: 0.2.3 - resolution: "tmp@npm:0.2.3" - checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125 +"tmp@npm:^0.2.3, tmp@npm:~0.2.1": + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357 languageName: node linkType: hard