diff --git a/.changeset/gamut-codemods-color-roles.md b/.changeset/gamut-codemods-color-roles.md new file mode 100644 index 0000000000..2e35d7bc11 --- /dev/null +++ b/.changeset/gamut-codemods-color-roles.md @@ -0,0 +1,13 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +Add a `color-roles` preset that moves Gamut color names onto the generated color system's roles, using design's migration map. + +- Renames and merges are applied. Names that split, were retired or are still pending stay in place, with a `/* color-roles: … */` comment carrying design's rule, and keep failing the type check until someone picks. +- Old palette names (`navy`, `gray-200`) and color names built at runtime get a warning. +- Old `--color-*` CSS variables are renamed to `--color-` in `.css`, `.scss` and `.less` files and inside JS strings and template literals; splits get the same comment. The type check can't see stylesheets, so the leftovers report lists every one left. Indented-syntax `.sass` isn't rewritten. +- Card's `variant` moves onto design's two: `white`, `beige` and `navy` become `default`, `yellow` and `hyper` become `brand`. A `navy` card gets a comment, since it now renders light. +- Old theme names move to the brand themes that replace them: `percipioTheme` → `skillsoftTheme`, `coreTheme` → `originalCodecademyTheme`, and the `PercipioTheme` type → `SkillsoftTheme`. An aliased import (`percipioTheme as theme`) only changes the imported name. A `CoreTheme` type gets a comment pointing at `SkillsoftTheme`, which types every brand. +- Follows design's map: `shadow-primary` and `shadow-secondary` split onto `shadow-key` and `shadow-ambient`. +- Presets can bring their own manifest type, pre-filter and leftovers report. `scope-swap` is unchanged. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index b552c85940..3e0134eaed 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -6,9 +6,10 @@ Codemods for moving a repo onto `@skillsoft/gamut` and between its releases. 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*` | +| Preset | What it's for | +| ------------- | ---------------------------------------------------------------- | +| `scope-swap` | Moving from `@codecademy/gamut*` to `@skillsoft/gamut*` | +| `color-roles` | Moving Gamut color names onto the generated color system's roles | `npx @skillsoft/gamut-codemods list` shows every preset and the migrations inside it. @@ -76,6 +77,34 @@ Every skillsoft-gamut pull request publishes installable previews through pkg.pr - Every file is parsed with the `tsx` parser, so Flow-typed JS won't parse. - The leftovers report uses regex, not an AST, so expect some false positives. +## Running color-roles + +```sh +npx @skillsoft/gamut-codemods color-roles . --dry +npx @skillsoft/gamut-codemods color-roles . +``` + +It maps today's color names (`text`, `primary`, `background-current`, …) onto the roles of the generated color system, using design's migration map (`aliases.ts`, generated from `src/migration.ts` in `@skillsoft/gamut-brand`). + +| Design's kind | What the codemod does | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Rename, merge | Replaces the name: `bg="background-primary"` → `bg="surface-default"`, `theme.colors.text` → `theme.colors['text-default']` | +| Split | Leaves the name and puts `/* color-roles: 'background' splits into surface-raised \| surface-default. */` in front of it | +| Retire, pending | The same, with design's rule for what to do instead | +| Old palette color | Warns. `navy`, `hyper-500` and the rest have no role of their own; design maps them per component | + +A name left in place is still an old name, so it fails the type check until someone picks its role. The leftovers report lists every `color-roles:` comment still in the code; delete each one once its name is resolved. + +It looks for color names where a color goes: the color props (`color`, `textColor`, `bg`, `borderColor`, `borderColor{X,Y,Left,Right,Top,Bottom}`) in JSX and in style objects, including ternaries, `&&`, and responsive objects and arrays; `theme.colors.x` and `theme.colors['x']`; and `themed('colors.x')`. `variant="primary"` and other strings that only look like color names are left alone. + +Limits: + +- A name held in a variable before it reaches a color prop (`const bg = 'background'`) isn't seen. +- Names built at runtime (`` `${variant}-hover` ``) only warn. +- MDX bodies aren't rewritten. + +On gamut's own source (branch `cass-color-brands`), a run changes 60 files and 273 lines, warns 171 times (about 120 splits, retirements and pending names, 50 old palette colors, 3 runtime names), and takes the type errors from 284 to 145. A second run changes nothing. + ## Contributing ### How it's put together diff --git a/packages/gamut-codemods/src/__tests__/color-roles-manifest.test.ts b/packages/gamut-codemods/src/__tests__/color-roles-manifest.test.ts new file mode 100644 index 0000000000..99b500c360 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/color-roles-manifest.test.ts @@ -0,0 +1,34 @@ +import { isAutomatic, manifest } from '../presets/color-roles/manifest'; + +/* + The color-roles data is generated from design's map, so these guard the + generator and any hand edit: a row the codemod applies must name exactly one + real role, and no name can be both an alias and an old palette color. +*/ +describe('color-roles manifest', () => { + const rows = Object.entries(manifest.aliases); + const roles = new Set(manifest.roles); + + it('has every alias design maps', () => { + expect(rows).toHaveLength(32); + }); + + it.each(rows.filter(([, a]) => isAutomatic(a)))( + '%s names exactly one role', + (_, alias) => { + expect(alias.to).toHaveLength(1); + } + ); + + it('only targets real roles', () => { + const bad = rows.flatMap(([name, a]) => + a.to.filter((t) => !roles.has(t)).map((t) => `${name} → ${t}`) + ); + expect(bad).toEqual([]); + }); + + it('keeps aliases and old palette names apart', () => { + const both = manifest.oldPalette.filter((n) => n in manifest.aliases); + expect(both).toEqual([]); + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/fixtures.test.ts b/packages/gamut-codemods/src/__tests__/fixtures.test.ts index cc5f0f2d48..76f8f6a203 100644 --- a/packages/gamut-codemods/src/__tests__/fixtures.test.ts +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -85,6 +85,16 @@ describe.each(Object.values(presets))('preset $name', (preset) => { }); expect((out ?? c.input).trim()).toBe(c.output.trim()); expectWarnings(warnings, c.warnings); + + /* Idempotent: a second run over the output changes nothing. */ + const again = migration.run({ + source: c.output, + manifest: preset.manifest, + warn: () => {}, + note: () => {}, + transformSource: (code) => runSource(preset.name, code).out, + }); + expect((again ?? c.output).trim()).toBe(c.output.trim()); return; } @@ -104,7 +114,14 @@ describe.each(Object.values(presets))('preset $name', (preset) => { }); } - const endToEnd = readCases(path.join(__dirname, '__testfixtures__')); + /* scope-swap's end-to-end cases sit at the top; other presets' in a folder named for them. */ + const endToEnd = readCases( + path.join( + __dirname, + '__testfixtures__', + preset.name === 'scope-swap' ? '' : preset.name + ) + ); 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__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts index 471776e2e1..54e7bbb02d 100644 --- a/packages/gamut-codemods/src/__tests__/manifest.test.ts +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -3,10 +3,18 @@ import path from 'node:path'; import ts from 'typescript'; -import { type DeepImport, renameSpecifier } from '../lib/manifest'; +import { + type DeepImport, + type Manifest, + renameSpecifier, +} from '../lib/manifest'; import { presets } from '../presets'; import { manifest } from '../presets/scope-swap/manifest'; +/* Manifests that promise exports: scope-swap's shape. Other presets have none. */ +const movesExports = (m: unknown): m is Manifest => + typeof m === 'object' && m !== null && 'movedExports' in m; + /* Guards against a manifest that promises something the packages don't deliver. Runs against the built dist, so `^build` has to run first @@ -31,8 +39,9 @@ const importsToCheck = () => { names.forEach((name) => set.add(name)); byModule.set(spec, set); }; - for (const preset of Object.values(presets)) { - const m = preset.manifest; + for (const m of Object.values(presets) + .map((preset) => preset.manifest) + .filter(movesExports)) { for (const rule of m.movedExports) { add(renameSpecifier(m, rule.to), rule.names); } diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts index 7315dd62a3..b0c2aaf13a 100644 --- a/packages/gamut-codemods/src/cli.ts +++ b/packages/gamut-codemods/src/cli.ts @@ -5,14 +5,9 @@ import path from 'node:path'; import jscodeshift from 'jscodeshift'; -import { findFlatEslintConfig } from './lib/eslint'; import { dirtyTreeReason } from './lib/git'; -import { findLeftovers, walk } from './lib/leftovers'; -import { - activeSourceMigrations, - mentionsOldNames, - runSourceMigrations, -} from './lib/run-source'; +import { walk } from './lib/leftovers'; +import { activeSourceMigrations, runSourceMigrations } from './lib/run-source'; import type { FileMigration, Preset } from './lib/types'; import { getPreset, presets } from './presets'; @@ -72,14 +67,14 @@ export const parseArgs = (argv: string[]): Options | 'help' | 'list' => { const heading = (text: string) => console.log(`\n— ${text} —`); const runFileMigrations = ( - preset: Preset, + preset: Preset, opts: Options, note: (key: string) => void ) => { const only = opts.only ? new Set(opts.only.split(',')) : null; const skip = new Set(opts.skip ? opts.skip.split(',') : []); const active = preset.migrations.filter( - (m): m is FileMigration => + (m): m is FileMigration => m.kind === 'file' && (!only || only.has(m.name)) && !skip.has(m.name) ); let changed = 0; @@ -93,7 +88,7 @@ const runFileMigrations = ( const warn = (msg: string) => console.log(`[${migration.name}] ${file} ${msg}`); const transformSource = (code: string) => - mentionsOldNames(preset.manifest, code) + preset.mentions(code) ? runSourceMigrations({ j: jscodeshift.withParser('tsx'), source: code, @@ -125,7 +120,7 @@ const runFileMigrations = ( return changed; }; -const printChecklist = (preset: Preset, notes: Set) => { +const printChecklist = (preset: Preset, notes: Set) => { heading('next steps'); const items = [ ...Object.entries(preset.conditionalChecklist) @@ -213,10 +208,8 @@ export const main = async (argv: string[]) => { heading( 'leftovers skipped in --dry (nothing was written, so everything would show up)' ); - } else if (opts.report) { - const hits = findLeftovers(opts.paths, preset.manifest, { - eslintRuleKeys: !findFlatEslintConfig(opts.paths), - }); + } else if (opts.report && preset.leftovers) { + const hits = preset.leftovers(opts.paths); heading(`leftovers (${hits.length})`); hits.forEach((hit) => console.log( diff --git a/packages/gamut-codemods/src/lib/run-source.ts b/packages/gamut-codemods/src/lib/run-source.ts index 42eb019392..47a2d57595 100644 --- a/packages/gamut-codemods/src/lib/run-source.ts +++ b/packages/gamut-codemods/src/lib/run-source.ts @@ -5,6 +5,7 @@ import type { AstMigration, Edit, Locatable, + Migration, Note, SourceMigration, SpliceMigration, @@ -24,11 +25,11 @@ import type { that embed code, like MDX's ESM blocks. */ -export interface RunSourceOptions { +export interface RunSourceOptions { j: JSCodeshift; source: string; - migrations: SourceMigration[]; - manifest: Manifest; + migrations: SourceMigration[]; + manifest: M; warnFor: (migration: string) => (node: Locatable, message: string) => void; note: Note; } @@ -42,18 +43,18 @@ const applyEdits = (source: string, edits: Edit[]) => source ); -export const runSourceMigrations = ({ +export const runSourceMigrations = ({ j, source: original, migrations, manifest, warnFor, note, -}: RunSourceOptions) => { +}: RunSourceOptions) => { let source = original; const astMigrations = migrations.filter( - (m): m is AstMigration => m.kind === 'ast' + (m): m is AstMigration => m.kind === 'ast' ); if (astMigrations.length > 0) { const root = j(source); @@ -72,7 +73,7 @@ export const runSourceMigrations = ({ } for (const m of migrations.filter( - (x): x is SpliceMigration => x.kind === 'splice' + (x): x is SpliceMigration => x.kind === 'splice' )) { const root = j(source); const edits = m.edits({ @@ -89,7 +90,7 @@ export const runSourceMigrations = ({ return source; }; -/* The pre-filter every caller uses so big repos don't parse every file. */ +/* scope-swap's pre-filter (Preset.mentions): an old package name or rule prefix. */ export const mentionsOldNames = (manifest: Manifest, source: string) => [ ...Object.keys(manifest.packages), @@ -98,8 +99,8 @@ export const mentionsOldNames = (manifest: Manifest, source: string) => ].some((name) => source.includes(name)); /* `only` and `skip` are comma-separated migration names. */ -export const activeSourceMigrations = ( - migrations: { kind: string; name: string }[], +export const activeSourceMigrations = ( + migrations: Migration[], only?: string, skip?: string ) => { @@ -108,5 +109,5 @@ export const activeSourceMigrations = ( return migrations.filter( (m) => m.kind !== 'file' && (!names || names.has(m.name)) && !skipped.has(m.name) - ) as SourceMigration[]; + ) as SourceMigration[]; }; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts index ee3e0690fb..027465c760 100644 --- a/packages/gamut-codemods/src/lib/types.ts +++ b/packages/gamut-codemods/src/lib/types.ts @@ -1,5 +1,6 @@ import type { Collection, JSCodeshift } from 'jscodeshift'; +import type { Leftover } from './leftovers'; import type { Manifest } from './manifest'; /* Anything with a source location, so warnings can point at a line. */ @@ -16,11 +17,15 @@ export type Warn = (node: Locatable, message: string) => void; */ export type Note = (checklistKey: string) => void; -export interface SourceContext { +/* + `M` is the preset's manifest type. It defaults to scope-swap's Manifest, so + migrations written for that preset don't name it. +*/ +export interface SourceContext { j: JSCodeshift; root: Collection; source: string; - manifest: Manifest; + manifest: M; warn: Warn; note: Note; } @@ -31,7 +36,7 @@ export interface Edit { text: string; } -interface MigrationBase { +interface MigrationBase { name: string; description: string; /* @@ -39,7 +44,7 @@ interface MigrationBase { skip this migration for the whole run (the CLI prints it and notes `skipped:`), or null to run it. */ - skipReason?(roots: string[], manifest: Manifest): string | null; + skipReason?(roots: string[], manifest: M): string | null; } /* @@ -47,24 +52,24 @@ interface MigrationBase { 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 { +export interface AstMigration extends MigrationBase { kind: 'ast'; - run(ctx: SourceContext): boolean; + run(ctx: SourceContext): boolean; /* Formatting fixes on the printed output that the AST can't express. */ - postPrint?(source: string, ctx: { manifest: Manifest }): string; + postPrint?(source: string, ctx: { manifest: M }): 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 { +export interface SpliceMigration extends MigrationBase { kind: 'splice'; - edits(ctx: SourceContext): Edit[]; + edits(ctx: SourceContext): Edit[]; } /* Gets raw text for non-source files (package.json, .eslintrc, .mdx). */ -export interface FileMigration extends MigrationBase { +export interface FileMigration extends MigrationBase { kind: 'file'; /* Not `match`: CodeQL reads `migration.match(file)` as @@ -73,7 +78,7 @@ export interface FileMigration extends MigrationBase { appliesTo(path: string): boolean; run(ctx: { source: string; - manifest: Manifest; + manifest: M; warn: (message: string) => void; note: Note; /* @@ -84,15 +89,24 @@ export interface FileMigration extends MigrationBase { }): string | null; } -export type SourceMigration = AstMigration | SpliceMigration; -export type Migration = SourceMigration | FileMigration; +export type SourceMigration = + | AstMigration + | SpliceMigration; +export type Migration = SourceMigration | FileMigration; -export interface Preset { +export interface Preset { name: string; description: string; - manifest: Manifest; + manifest: M; + /* + The cheap pre-filter every caller uses, so big repos don't parse every + file: false means no migration in this preset could change `source`. + */ + mentions(source: string): boolean; + /* After a write run, what's left for a person. Omit for no report. */ + leftovers?(roots: string[]): Leftover[]; /* Run in order. All `ast` migrations must come before any `splice` one. */ - migrations: Migration[]; + migrations: Migration[]; /* Printed at the end of every run. */ checklist: string[]; /* Printed only when a migration calls note(key). */ diff --git a/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.input.scss b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.input.scss new file mode 100644 index 0000000000..a9f77c82c1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.input.scss @@ -0,0 +1,16 @@ +.panel { + color: var(--color-text); + border-color: var(--color-border-secondary); + background: var(--color-background); + box-shadow: 0 1px 4px var(--color-shadow-primary); +} + +.legacy { + fill: var(--color-navy-800); + outline-color: var(--color-primary, var(--color-text-secondary)); +} + +/* Already on the new names: untouched. */ +.done { + color: var(--color-text-default); +} diff --git a/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.output.scss b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.output.scss new file mode 100644 index 0000000000..20b63dc277 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.output.scss @@ -0,0 +1,16 @@ +.panel { + color: var(--color-text-default); + border-color: var(--color-border-default); + background: /* color-roles: 'background' splits into surface-raised | surface-default. Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`. */ var(--color-background); + box-shadow: 0 1px 4px /* color-roles: 'shadow-primary' splits into shadow-key | shadow-ambient. Every elevation shadow composes both: `shadow-key` for the tight contact layer, `shadow-ambient` for the soft falloff. The elevation tokens hold only offset, blur and spread; see the elevation system for which depth uses which geometry. */ var(--color-shadow-primary); +} + +.legacy { + fill: var(--color-navy-800); + outline-color: var(--color-action-primary, var(--color-text-subtle)); +} + +/* Already on the new names: untouched. */ +.done { + color: var(--color-text-default); +} diff --git a/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.warnings.json b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.warnings.json new file mode 100644 index 0000000000..834e4ce144 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/__testfixtures__/vars.warnings.json @@ -0,0 +1,5 @@ +[ + "'background' splits into", + "'shadow-primary' splits into shadow-key | shadow-ambient", + "'--color-navy-800' is an old palette color" +] diff --git a/packages/gamut-codemods/src/migrations/color-roles-stylesheets/index.ts b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/index.ts new file mode 100644 index 0000000000..0c0fd03b36 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-stylesheets/index.ts @@ -0,0 +1,69 @@ +import type { FileMigration } from '../../lib/types'; +import type { ColorRolesManifest } from '../../presets/color-roles/manifest'; +import { + commentText, + describe, + findOldVars, + markedBefore, + mentionsOldVar, + paletteMessage, +} from '../color-roles/cssVars'; + +/* + Old color variables in stylesheets: `var(--color-text)` in .css, .scss and + .less. The type check can't see these, so this is the only place they're + caught. + + - Renames and merges become `--color-`. + - Splits, retirements and pending names stay, with a + `/* color-roles: … *\/` comment carrying design's rule in front of them. + The preset's leftovers report lists every one by file and line. + - Old palette colors (`--color-navy-800`) only warn. + + Indented-syntax .sass isn't rewritten: its comment rules differ, so a + comment mid-value could break the file. +*/ +export const colorRolesStylesheets: FileMigration = { + name: 'color-roles-stylesheets', + kind: 'file', + description: + 'Rename old `--color-*` variables in stylesheets; mark the ones a person has to pick.', + appliesTo: (file) => /\.(css|scss|less)$/.test(file), + run({ source, manifest, warn, note }) { + if (!mentionsOldVar(source)) return null; + const hits = findOldVars(source, manifest); + if (hits.length === 0) return null; + + let out = ''; + let last = 0; + for (const hit of hits) { + out += source.slice(last, hit.at); + last = hit.at; + if (hit.role) { + out += `--color-${hit.role}`; + last = hit.at + `--color-${hit.name}`.length; + } else if (hit.alias) { + /* In front of the `var(` the variable sits in, if there is one. */ + const varAt = source.lastIndexOf('var(', hit.at); + const at = + varAt !== -1 && source.slice(varAt + 4, hit.at).trim() === '' + ? varAt + : hit.at; + if (!markedBefore(source, at)) { + out = + out.slice(0, out.length - (hit.at - at)) + + commentText(hit.name, hit.alias) + + source.slice(at, hit.at); + } + warn(describe(hit.name, hit.alias)); + note('color-roles:manual'); + } else if (hit.palette) { + warn(paletteMessage(hit.name)); + note('color-roles:palette'); + } + } + out += source.slice(last); + note('color-roles:stylesheets'); + return out === source ? null : out; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.input.tsx b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.input.tsx new file mode 100644 index 0000000000..98701af305 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.input.tsx @@ -0,0 +1,22 @@ +import type { CoreTheme, PercipioTheme } from '@skillsoft/gamut-styles'; +import { + coreTheme, + GamutProvider, + percipioTheme as theme, +} from '@skillsoft/gamut-styles'; + +import { settings } from './settings'; + +export const Percipio = () => ; + +export const Core = () => ; + +export const themes = { coreTheme, dark: { ...coreTheme, mode: 'dark' } }; + +export const label = settings.coreTheme; + +export type Typed = PercipioTheme; + +declare module '@emotion/react' { + export interface Theme extends CoreTheme {} +} diff --git a/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.output.tsx b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.output.tsx new file mode 100644 index 0000000000..1c791d525f --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.output.tsx @@ -0,0 +1,22 @@ +import type { /* color-roles: 'CoreTheme' types the old core theme, which has no color roles. Brand themes share one shape: use 'SkillsoftTheme', which types every brand (originalCodecademyTheme included). */ CoreTheme, SkillsoftTheme } from '@skillsoft/gamut-styles'; +import { + originalCodecademyTheme, + GamutProvider, + skillsoftTheme as theme, +} from '@skillsoft/gamut-styles'; + +import { settings } from './settings'; + +export const Percipio = () => ; + +export const Core = () => ; + +export const themes = { coreTheme: originalCodecademyTheme, dark: { ...originalCodecademyTheme, mode: 'dark' } }; + +export const label = settings.coreTheme; + +export type Typed = SkillsoftTheme; + +declare module '@emotion/react' { + export interface Theme extends CoreTheme {} +} diff --git a/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.warnings.json b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.warnings.json new file mode 100644 index 0000000000..f01189a4b9 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-themes/__testfixtures__/themes.warnings.json @@ -0,0 +1 @@ +["'CoreTheme' types the old core theme"] diff --git a/packages/gamut-codemods/src/migrations/color-roles-themes/index.ts b/packages/gamut-codemods/src/migrations/color-roles-themes/index.ts new file mode 100644 index 0000000000..6a60fefd75 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles-themes/index.ts @@ -0,0 +1,133 @@ +import type { Edit, SpliceMigration } from '../../lib/types'; +import type { ColorRolesManifest } from '../../presets/color-roles/manifest'; +import { MARK, markedBefore } from '../color-roles/cssVars'; + +/* + The old themes' names → the brand themes that replace them, wherever + they're imported from gamut-styles: + + - `percipioTheme` → `skillsoftTheme`, its replacement (`percipioTheme` is + removed), and the `PercipioTheme` type → `SkillsoftTheme`. + - `coreTheme` → `originalCodecademyTheme`, its rebuild. `coreTheme` still + exists but defines no roles, so components lose their colors on it. + + An aliased import (`percipioTheme as theme`) only has its imported name + changed. An unaliased one is renamed everywhere the file uses it. + + The `CoreTheme` type has no same-named successor: brand themes share one + shape, typed by `SkillsoftTheme`. It gets a `color-roles:` comment for a + person, since it's usually a Theme augmentation the app owns. +*/ + +const RENAMES: Record = { + percipioTheme: 'skillsoftTheme', + coreTheme: 'originalCodecademyTheme', + PercipioTheme: 'SkillsoftTheme', + PercipioThemeShape: 'SkillsoftThemeShape', +}; + +const MANUAL: Record = { + CoreTheme: `/* ${MARK} 'CoreTheme' types the old core theme, which has no color roles. Brand themes share one shape: use 'SkillsoftTheme', which types every brand (originalCodecademyTheme included). */ `, +}; + +const GAMUT_STYLES = /(^|\/)gamut-styles$/; + +interface Node { + type: string; + start: number; + end: number; + [key: string]: any; +} + +export const colorRolesThemes: SpliceMigration = { + name: 'color-roles-themes', + kind: 'splice', + description: + 'Rename percipioTheme and coreTheme to the brand themes that replace them.', + edits({ j, root, source, warn, note }) { + const edits: Edit[] = []; + const seen = new Set(); + const push = (e: Edit) => { + if (seen.has(e.start)) return; + seen.add(e.start); + edits.push(e); + }; + /* Unaliased imports, renamed across the file. */ + const local = new Map(); + + root.find(j.ImportDeclaration).forEach((p) => { + const decl = p.node as unknown as Node; + if (!GAMUT_STYLES.test(decl.source.value)) return; + for (const spec of decl.specifiers ?? []) { + if (spec.type !== 'ImportSpecifier') continue; + const name: string = spec.imported?.name; + const to = RENAMES[name]; + if (to) { + push({ + start: spec.imported.start, + end: spec.imported.end, + text: to, + }); + if (spec.local?.name === name) local.set(name, to); + note('color-roles:themes'); + } else if (MANUAL[name]) { + if (!markedBefore(source, spec.start)) { + push({ start: spec.start, end: spec.start, text: MANUAL[name] }); + } + warn( + spec, + `'${name}' types the old core theme, which has no color roles. Use 'SkillsoftTheme', which types every brand.` + ); + note('color-roles:themes'); + } + } + }); + + if (local.size === 0) return edits; + + /* `{ percipioTheme }` keeps its key: `{ percipioTheme: skillsoftTheme }`. */ + root.find(j.Node).forEach((p) => { + const node = p.node as unknown as Node; + if ( + (node.type === 'ObjectProperty' || node.type === 'Property') && + node.shorthand && + node.value?.type === 'Identifier' && + local.has(node.value.name) + ) { + push({ + start: node.start, + end: node.end, + text: `${node.value.name}: ${local.get(node.value.name)}`, + }); + } + }); + + root.find(j.Identifier).forEach((p) => { + const id = p.node as unknown as Node; + const to = local.get(id.name); + if (!to) return; + const parent = p.parent?.node as Node | undefined; + /* `x.percipioTheme` and `{ percipioTheme: … }` name something else. */ + if ( + parent && + (parent.type === 'MemberExpression' || + parent.type === 'OptionalMemberExpression') && + parent.property === p.node && + !parent.computed + ) { + return; + } + if ( + parent && + (parent.type === 'ObjectProperty' || parent.type === 'Property') && + parent.key === p.node && + !parent.computed + ) { + return; + } + push({ start: id.start, end: id.end, text: to }); + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.input.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.input.tsx new file mode 100644 index 0000000000..f3ebfe7913 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.input.tsx @@ -0,0 +1,13 @@ +import { Card } from '@skillsoft/gamut'; +import { Card as OtherCard } from './OtherCard'; + +export const Cards = ({ featured }: { featured: boolean }) => ( + <> + White + Beige + Navy + Yellow + Hyper + Not gamut's + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.output.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.output.tsx new file mode 100644 index 0000000000..5f83a5f7f8 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.output.tsx @@ -0,0 +1,13 @@ +import { Card } from '@skillsoft/gamut'; +import { Card as OtherCard } from './OtherCard'; + +export const Cards = ({ featured }: { featured: boolean }) => ( + <> + White + Beige + . */ variant="default">Navy + Yellow + Hyper + Not gamut's + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.warnings.json b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.warnings.json new file mode 100644 index 0000000000..b03dcb8772 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/card.warnings.json @@ -0,0 +1 @@ +["Card's 'navy' variant becomes 'default'"] diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.input.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.input.tsx new file mode 100644 index 0000000000..c020a037cc --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.input.tsx @@ -0,0 +1,14 @@ +import { Box, Text } from '@skillsoft/gamut'; + +export const Card = ({ active }: { active: boolean }) => ( + + + Title + + Not a variant: secondary is a color here. + + + + + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.output.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.output.tsx new file mode 100644 index 0000000000..503a4c410e --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.output.tsx @@ -0,0 +1,14 @@ +import { Box, Text } from '@skillsoft/gamut'; + +export const Card = ({ active }: { active: boolean }) => ( + + + Title + + Not a variant: secondary is a color here. + + + + + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.warnings.json b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.warnings.json new file mode 100644 index 0000000000..8eca099b43 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/jsx.warnings.json @@ -0,0 +1 @@ +["'navy' is an old palette color with no role of its own"] diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.input.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.input.tsx new file mode 100644 index 0000000000..f8fe5d3d0a --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.input.tsx @@ -0,0 +1,9 @@ +import { Background, Box } from '@skillsoft/gamut'; + +export const Panel = ({ muted }: { muted: boolean }) => ( + + + + + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.output.tsx b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.output.tsx new file mode 100644 index 0000000000..bfc5f8609d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.output.tsx @@ -0,0 +1,9 @@ +import { Background, Box } from '@skillsoft/gamut'; + +export const Panel = ({ muted }: { muted: boolean }) => ( + + + + + +); diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.warnings.json b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.warnings.json new file mode 100644 index 0000000000..7f8443a697 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/manual.warnings.json @@ -0,0 +1,7 @@ +[ + "'background' splits into surface-raised | surface-default", + "'border-primary' splits into border-default | border-subtle", + "'text-disabled' splits into action-disabled | surface-raised | text-subtle", + "'shadow-primary' splits into shadow-key | shadow-ambient", + "'background-contrast' is retired (closest: surface-raised)" +] diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.input.ts b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.input.ts new file mode 100644 index 0000000000..f4f16d5b75 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.input.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const Panel = styled.div` + color: var(--color-text); + border: 1px solid var(--color-border-secondary); +`; + +export const Raised = styled.div` + background: var(--color-background); +`; + +export const fill = 'var(--color-navy-800)'; +export const link = { color: 'var(--color-primary)' }; diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.output.ts b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.output.ts new file mode 100644 index 0000000000..894da2d332 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.output.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const Panel = styled.div` + color: var(--color-text-default); + border: 1px solid var(--color-border-default); +`; + +export const Raised = styled.div/* color-roles: 'background' splits into surface-raised | surface-default. Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`. */ ` + background: var(--color-background); +`; + +export const fill = 'var(--color-navy-800)'; +export const link = { color: 'var(--color-action-primary)' }; diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.warnings.json b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.warnings.json new file mode 100644 index 0000000000..c668608f2c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/strings.warnings.json @@ -0,0 +1,4 @@ +[ + "'background' splits into", + "'--color-navy-800' is an old palette color" +] diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.input.ts b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.input.ts new file mode 100644 index 0000000000..e11ae5ce51 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.input.ts @@ -0,0 +1,29 @@ +import styled from '@emotion/styled'; +import { css, states, themed, variant } from '@skillsoft/gamut-styles'; + +export const base = css({ + bg: 'primary', + color: 'background', + '&:hover': { bg: 'primary-hover' }, +}); + +export const variants = variant({ + variants: { + quiet: { textColor: 'text-secondary', borderColor: 'border-tertiary' }, + loud: { bg: 'danger', variant: 'primary' }, + }, +}); + +export const flags = states({ selected: { bg: 'background-selected' } }); + +export const Swatch = styled.div<{ variant: string }>` + color: ${({ theme }) => theme.colors.text}; + background: ${(props) => props.theme.colors['background-hover']}; + border-color: ${({ theme }) => theme.colors.background}; + outline-color: ${themed('colors.primary')}; +`; + +export const runtime = (v: string) => css({ bg: `${v}-hover` }); + +const kind = 'primary'; +export const untouched = { variant: kind, label: 'text' }; diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.output.ts b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.output.ts new file mode 100644 index 0000000000..6b862c558d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.output.ts @@ -0,0 +1,29 @@ +import styled from '@emotion/styled'; +import { css, states, themed, variant } from '@skillsoft/gamut-styles'; + +export const base = css({ + bg: 'action-primary', + /* color-roles: 'background' splits into surface-raised | surface-default. Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`. */ color: 'background', + '&:hover': { bg: 'action-primary-hover' }, +}); + +export const variants = variant({ + variants: { + quiet: { textColor: 'text-subtle', /* color-roles: 'border-tertiary' splits into border-subtle | border-strong. A divider or card hairline is `border-subtle`. Where it marks an active field or a selected row, it is `border-strong`. Gamut’s own two usages, the FeatureShimmer edge and the List scroll shadow, are both `border-subtle`. Core resolves it to a light navy and Percipio to a dark one, so check each app usage for which it meant. */ borderColor: 'border-tertiary' }, + loud: { bg: 'action-danger', variant: 'primary' }, + }, +}); + +export const flags = states({ selected: { bg: 'state-selected' } }); + +export const Swatch = styled.div<{ variant: string }>` + color: ${({ theme }) => theme.colors['text-default']}; + background: ${(props) => /* color-roles: 'background-hover' splits into state-hover | surface-sunken. As a hover, it is the `state-hover` veil. Used as a resting fill (the subtle Alert, a tinted panel), it is `surface-sunken`. */ props.theme.colors['background-hover']}; + border-color: ${({ theme }) => /* color-roles: 'background' splits into surface-raised | surface-default. Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`. */ theme.colors.background}; + outline-color: ${themed('colors.action-primary')}; +`; + +export const runtime = (v: string) => css({ bg: `${v}-hover` }); + +const kind = 'primary'; +export const untouched = { variant: kind, label: 'text' }; diff --git a/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.warnings.json b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.warnings.json new file mode 100644 index 0000000000..ddd7fac912 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/__testfixtures__/styles.warnings.json @@ -0,0 +1,6 @@ +[ + "fixture.tsx:6 'background' splits into", + "'border-tertiary' splits into border-subtle | border-strong", + "'background-hover' splits into state-hover | surface-sunken", + "color name built at runtime (`${v}-hover`)" +] diff --git a/packages/gamut-codemods/src/migrations/color-roles/cssVars.ts b/packages/gamut-codemods/src/migrations/color-roles/cssVars.ts new file mode 100644 index 0000000000..63c1ed4d9f --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/cssVars.ts @@ -0,0 +1,78 @@ +import type { + ColorAlias, + ColorRolesManifest, +} from '../../presets/color-roles/manifest'; +import { isAutomatic } from '../../presets/color-roles/manifest'; + +export const MARK = 'color-roles:'; + +/* "'background' splits into surface-raised | surface-default. " */ +export const describe = (name: string, { kind, to, rule }: ColorAlias) => { + const roles = to.join(' | '); + const what = + kind === 'split' + ? `splits into ${roles}` + : kind === 'retire' + ? `is retired${roles ? ` (closest: ${roles})` : ''}` + : 'is pending'; + return `'${name}' ${what}. ${rule}`; +}; + +/* The same, safe inside a block comment. */ +export const commentText = (name: string, alias: ColorAlias) => + `/* ${MARK} ${describe(name, alias).replace(/\*\//g, '* /')} */ `; + +/* Already marked by an earlier run: a color-roles comment right before `at`. */ +export const markedBefore = (text: string, at: number) => { + const before = text.slice(0, at).trimEnd(); + if (!before.endsWith('*/')) return false; + const open = before.lastIndexOf('/*'); + return open !== -1 && before.slice(open).includes(MARK); +}; + +/* + An old color variable: `--color-`. The rewritten name is `--color-`, + and no role is also an old name, so a second run leaves it alone. +*/ +const OLD_VAR = /--color-([A-Za-z0-9][\w-]*)/g; + +export const mentionsOldVar = (text: string) => + /--color-[A-Za-z0-9]/.test(text); + +export interface VarHit { + /* Offset of `--color-…` in the text. */ + at: number; + name: string; + /* The role it becomes, for renames and merges. */ + role?: string; + /* Design's row, for splits, retirements and pending names. */ + alias?: ColorAlias; + /* An old palette color (navy-800): no role replaces it by name. */ + palette?: boolean; +} + +/* Every old color variable in `text` the manifest knows about. */ +export const findOldVars = ( + text: string, + manifest: ColorRolesManifest +): VarHit[] => { + const palette = new Set(manifest.oldPalette); + const hits: VarHit[] = []; + for (const m of text.matchAll(OLD_VAR)) { + const name = m[1]; + const alias = manifest.aliases[name]; + if (alias) { + hits.push( + isAutomatic(alias) + ? { at: m.index, name, role: alias.to[0] } + : { at: m.index, name, alias } + ); + } else if (palette.has(name)) { + hits.push({ at: m.index, name, palette: true }); + } + } + return hits; +}; + +export const paletteMessage = (name: string) => + `'--color-${name}' is an old palette color with no role of its own. Pick the role from design's component map (@skillsoft/gamut-brand src/migration.ts, COMPONENTS).`; diff --git a/packages/gamut-codemods/src/migrations/color-roles/index.ts b/packages/gamut-codemods/src/migrations/color-roles/index.ts new file mode 100644 index 0000000000..ed27797bef --- /dev/null +++ b/packages/gamut-codemods/src/migrations/color-roles/index.ts @@ -0,0 +1,368 @@ +import type { Edit, SpliceMigration } from '../../lib/types'; +import type { ColorRolesManifest } from '../../presets/color-roles/manifest'; +import { isAutomatic } from '../../presets/color-roles/manifest'; +import { + commentText, + describe, + findOldVars, + MARK, + markedBefore, + mentionsOldVar, + paletteMessage, +} from './cssVars'; + +/* + Today's Gamut color names → roles, wherever a color name sits: + + - a color prop in JSX (`bg="background-primary"`) or in a style object + (`css`, `variant`, `states`, plain objects), including the branches of a + ternary or `&&`, and responsive objects and arrays; + - `theme.colors.text` / `theme.colors['text-disabled']`; + - `themed('colors.primary')`; + - `var(--color-text)` inside a string or template literal, which becomes + `var(--color-text-default)` (stylesheets are the + color-roles-stylesheets migration); + - Card's `variant`, onto design's `default` and `brand`. + + Rename and merge rows are applied. Split, retire and pending rows need a + person: the name stays (so it still fails the type check) and a + `/* color-roles: … *\/` comment with design's rule goes in front of it. + Old palette names (navy, hyper-500) and names built at runtime + (`${variant}-hover`) only warn. Anything outside those positions, like a + name in a const that's passed to `bg` later, isn't seen. +*/ + +/* + Card's variants are design's two: white, beige and navy + become `default`; yellow and hyper become `brand`. +*/ +const CARD_VARIANTS: Record = { + white: 'default', + beige: 'default', + navy: 'default', + yellow: 'brand', + hyper: 'brand', +}; + +const NAVY_CARD = `/* ${MARK} Card's 'navy' variant becomes 'default' (surface-raised), which renders light. If this card should stay dark, wrap it in . */ `; + +const GAMUT = /(^|\/)gamut$/; + +/* Babel AST nodes, loosely: positions plus whatever the type carries. */ +interface Node { + type: string; + start: number; + end: number; + loc?: { start: { line: number } } | null; + [key: string]: any; +} + +const isString = (n: Node | null | undefined): n is Node & { value: string } => + !!n && + (n.type === 'StringLiteral' || + (n.type === 'Literal' && typeof n.value === 'string')); + +const keyName = (prop: Node): string | undefined => { + if (prop.computed) return undefined; + const { key } = prop; + if (key?.type === 'Identifier') return key.name; + if (isString(key)) return key.value; + return undefined; +}; + +/* `theme.colors` or `.theme.colors`. */ +const isThemeColors = (n: Node | null | undefined) => + !!n && + (n.type === 'MemberExpression' || n.type === 'OptionalMemberExpression') && + !n.computed && + n.property?.name === 'colors' && + ((n.object?.type === 'Identifier' && n.object.name === 'theme') || + (n.object?.type?.endsWith('MemberExpression') && + !n.object.computed && + n.object.property?.name === 'theme')); + +export const colorRoles: SpliceMigration = { + name: 'color-roles', + kind: 'splice', + description: + 'Rename Gamut color names to roles; mark splits, retirements and pending names with design’s rule.', + edits({ j, root, source, manifest, warn, note }) { + const props = new Set(manifest.colorProps); + const palette = new Set(manifest.oldPalette); + const edits: Edit[] = []; + const seen = new Set(); + + const marked = (at: number) => markedBefore(source, at); + + const mark = (at: number, name: string) => { + const alias = manifest.aliases[name]; + if (!marked(at)) { + edits.push({ + start: at, + end: at, + text: commentText(name, alias), + }); + } + }; + + /* + One color name. `replace` swaps it for a role; `markAt` is where a + comment can go (in front of a JSX attribute, not inside it). + */ + const visit = ( + node: Node, + name: string, + replace: (role: string) => Edit, + markAt: number + ) => { + if (seen.has(node.start)) return; + seen.add(node.start); + const alias = manifest.aliases[name]; + if (alias) { + if (isAutomatic(alias)) { + edits.push(replace(alias.to[0])); + } else { + mark(markAt, name); + warn(node, describe(name, alias)); + note('color-roles:manual'); + } + } else if (palette.has(name)) { + warn( + node, + `'${name}' is an old palette color with no role of its own. Pick the role from design's component map (@skillsoft/gamut-brand src/migration.ts, COMPONENTS).` + ); + note('color-roles:palette'); + } + }; + + const visitString = (lit: Node, markAt: number) => + visit( + lit, + lit.value, + (role) => ({ start: lit.start + 1, end: lit.end - 1, text: role }), + markAt + ); + + /* Every string a color-valued expression can produce. */ + const collect = (expr: Node | null | undefined, markAt?: number) => { + if (!expr) return; + switch (expr.type) { + case 'StringLiteral': + case 'Literal': + if (isString(expr)) visitString(expr, markAt ?? expr.start); + return; + case 'ConditionalExpression': + collect(expr.consequent); + collect(expr.alternate); + return; + case 'LogicalExpression': + collect(expr.left); + collect(expr.right); + return; + case 'ObjectExpression': + for (const p of expr.properties) { + if (p.type === 'ObjectProperty' || p.type === 'Property') { + collect(p.value); + } + } + return; + case 'ArrayExpression': + expr.elements.forEach((el: Node) => collect(el)); + return; + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + case 'TypeCastExpression': + collect(expr.expression, markAt); + return; + case 'TemplateLiteral': + if (expr.expressions.length > 0) { + warn( + expr, + `color name built at runtime (${source.slice( + expr.start, + expr.end + )}). The codemod can't map it; check each value it can take.` + ); + note('color-roles:runtime'); + } + return; + default: + } + }; + + root.find(j.JSXAttribute).forEach((p) => { + const attr = p.node as unknown as Node; + if (attr.name?.type !== 'JSXIdentifier' || !props.has(attr.name.name)) { + return; + } + const { value } = attr; + if (isString(value)) collect(value, attr.start); + else if (value?.type === 'JSXExpressionContainer') { + collect(value.expression); + } + }); + + root.find(j.Node).forEach((p) => { + const node = p.node as unknown as Node; + if (node.type === 'ObjectProperty' || node.type === 'Property') { + const key = keyName(node); + if (key && props.has(key)) { + collect(node.value, isString(node.value) ? node.start : undefined); + } + return; + } + + if ( + (node.type === 'MemberExpression' || + node.type === 'OptionalMemberExpression') && + isThemeColors(node.object) + ) { + const { property } = node; + const name = node.computed + ? isString(property) + ? property.value + : undefined + : property?.name; + if (!name) return; + visit( + node, + name, + (role) => + node.computed + ? { start: property.start + 1, end: property.end - 1, text: role } + : { + /* `.text` → `['text-default']`, keeping a `?.` */ + start: node.object.end, + end: property.end, + text: `${ + source.slice(node.object.end, property.start).includes('?.') + ? '?.' + : '' + }['${role}']`, + }, + node.start + ); + return; + } + + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + node.callee.name === 'themed' && + isString(node.arguments?.[0]) && + node.arguments[0].value.startsWith('colors.') + ) { + const arg = node.arguments[0]; + const name = arg.value.slice('colors.'.length); + visit( + arg, + name, + (role) => ({ + start: arg.start + 1, + end: arg.end - 1, + text: `colors.${role}`, + }), + node.start + ); + } + }); + + /* + `var(--color-…)` in a string or template literal. A rename is applied + in place; a name for a person gets its comment in front of the + literal, outside the string. + */ + root.find(j.Node).forEach((p) => { + const node = p.node as unknown as Node; + const literal = isString(node) || node.type === 'TemplateLiteral'; + if (!literal) return; + const raw = source.slice(node.start, node.end); + if (!mentionsOldVar(raw)) return; + for (const hit of findOldVars(raw, manifest)) { + const at = node.start + hit.at; + if (hit.role) { + edits.push({ + start: at, + end: at + `--color-${hit.name}`.length, + text: `--color-${hit.role}`, + }); + } else if (hit.alias) { + if (!marked(node.start) && !seen.has(node.start)) { + seen.add(node.start); + edits.push({ + start: node.start, + end: node.start, + text: commentText(hit.name, hit.alias), + }); + } + warn(node, describe(hit.name, hit.alias)); + note('color-roles:manual'); + } else if (hit.palette) { + warn(node, paletteMessage(hit.name)); + note('color-roles:palette'); + } + } + }); + + /* Card's variant, when Card comes from gamut. */ + const cardNames = new Set(); + root.find(j.ImportDeclaration).forEach((p) => { + const decl = p.node as unknown as Node; + if (!GAMUT.test(decl.source.value)) return; + for (const spec of decl.specifiers ?? []) { + if (spec.type === 'ImportSpecifier' && spec.imported?.name === 'Card') { + cardNames.add(spec.local?.name ?? 'Card'); + } + } + }); + if (cardNames.size > 0) { + root.find(j.JSXOpeningElement).forEach((p) => { + const el = p.node as unknown as Node; + if (el.name?.type !== 'JSXIdentifier' || !cardNames.has(el.name.name)) { + return; + } + for (const attr of el.attributes ?? []) { + if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'variant') { + continue; + } + const lits: Node[] = []; + const gather = (e: Node | null | undefined) => { + if (!e) return; + if (isString(e)) lits.push(e); + else if (e.type === 'ConditionalExpression') { + gather(e.consequent); + gather(e.alternate); + } else if (e.type === 'LogicalExpression') gather(e.right); + }; + if (isString(attr.value)) gather(attr.value); + else if (attr.value?.type === 'JSXExpressionContainer') { + gather(attr.value.expression); + } + for (const lit of lits) { + const to = CARD_VARIANTS[lit.value]; + if (!to) continue; + edits.push({ start: lit.start + 1, end: lit.end - 1, text: to }); + if (lit.value === 'navy') { + if (!marked(attr.start)) { + edits.push({ + start: attr.start, + end: attr.start, + text: NAVY_CARD, + }); + } + warn( + lit, + "Card's 'navy' variant becomes 'default', which renders light. Wrap the card in if it should stay dark." + ); + note('color-roles:card-navy'); + } + } + } + }); + } + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/presets/color-roles/aliases.ts b/packages/gamut-codemods/src/presets/color-roles/aliases.ts new file mode 100644 index 0000000000..97c992222b --- /dev/null +++ b/packages/gamut-codemods/src/presets/color-roles/aliases.ts @@ -0,0 +1,331 @@ +import type { ColorAlias, ColorRolesManifest } from './manifest'; + +/* + Generated from design's migration map: @skillsoft/gamut-brand + src/migration.ts (TOKEN_MAP). Regenerate when design's map changes + (~/z_team/everest/color-tokens/codemod/gen-manifest.ts); don't edit rows by + hand. +*/ +export const aliases: Record = { + text: { + kind: 'rename', + to: ['text-default'], + rule: 'Body copy and headings.', + }, + 'text-accent': { + kind: 'merge', + to: ['text-default'], + rule: 'Headings take `text-default` too. Emphasis comes from weight and size, not from a second ink.', + }, + 'text-secondary': { + kind: 'rename', + to: ['text-subtle'], + rule: 'Descriptions, metadata, helper copy. An opaque color in every theme, so it no longer depends on what sits underneath it.', + }, + 'text-disabled': { + kind: 'split', + to: ['action-disabled', 'surface-raised', 'text-subtle'], + rule: 'A disabled label or icon drawn straight on a surface (text button, link, menu item, form label) is `action-disabled`, the same color as a disabled fill. On a disabled fill the label is a knockout: it takes the surface the control sits on, usually `surface-raised`. The value inside a disabled input is the one place `text-subtle` stays. The proposal describes `action-disabled` as a fill only; its use as the disabled ink is an addition for the proposal’s next revision.', + }, + 'feedback-error': { + kind: 'rename', + to: ['text-error'], + rule: 'Error text and icons, including inline validation and the border of a field in error. Destructive buttons are `action-danger`, not this.', + }, + 'feedback-success': { + kind: 'rename', + to: ['text-success'], + rule: 'Success text and icons. Always with an icon.', + }, + 'feedback-warning': { + kind: 'rename', + to: ['text-warning'], + rule: 'Warning text and icons. Dark enough to be read as text, so it can carry a message on its own rather than only an icon.', + }, + background: { + kind: 'split', + to: ['surface-raised', 'surface-default'], + rule: 'Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`.', + }, + 'background-primary': { + kind: 'rename', + to: ['surface-default'], + rule: 'The page background, the base every other surface is placed on.', + }, + 'background-current': { + kind: 'split', + to: ['surface-default', 'surface-raised', 'surface-sunken'], + rule: 'Whatever surface the element sits on. Becomes a `surface-current` alias that every surface container (the page, raised, sunken, `Background`) sets to its own role, so it always resolves to one of these three. Masks and backdrops that must match their parent read it: sticky List headers, scroll and truncation fades, Drawer, DataList empty rows, the transparent Disclosure. Nothing sets it by hand.', + }, + 'background-contrast': { + kind: 'retire', + to: ['surface-raised'], + rule: 'Use `surface-raised`. Maximum contrast against the page is what raised already provides in dark mode, and light mode has no surface lighter than it. In Gamut it backs ToolTip and InfoTip, block Tabs and the video player’s tooltip and slider value, all remapped; none needs it to be black.', + }, + 'background-selected': { + kind: 'rename', + to: ['state-selected'], + rule: 'A veil composited over the element’s own fill, not an opaque fill that replaces it. The element keeps its surface underneath.', + }, + 'background-hover': { + kind: 'split', + to: ['state-hover', 'surface-sunken'], + rule: 'As a hover, it is the `state-hover` veil. Used as a resting fill (the subtle Alert, a tinted panel), it is `surface-sunken`.', + }, + 'background-disabled': { + kind: 'rename', + to: ['action-disabled'], + rule: 'The disabled fill for every action variant. One token, whatever was disabled.', + }, + 'background-success': { + kind: 'rename', + to: ['surface-success'], + rule: 'The success tint behind an alert or inline confirmation.', + }, + 'background-warning': { + kind: 'rename', + to: ['surface-warning'], + rule: 'The warning tint behind an alert or inline caution.', + }, + 'background-error': { + kind: 'rename', + to: ['surface-error'], + rule: 'The error tint behind an alert or inline validation message.', + }, + 'shadow-primary': { + kind: 'split', + to: ['shadow-key', 'shadow-ambient'], + rule: 'Every elevation shadow composes both: `shadow-key` for the tight contact layer, `shadow-ambient` for the soft falloff. The elevation tokens hold only offset, blur and spread; see the elevation system for which depth uses which geometry.', + }, + 'shadow-secondary': { + kind: 'split', + to: ['shadow-key', 'shadow-ambient'], + rule: 'Same as `shadow-primary`. Depth comes from geometry, not from a second color, so both legacy tokens land on the same pair.', + }, + primary: { + kind: 'rename', + to: ['action-primary'], + rule: 'Primary fills, strokes, links and focus rings. Pressed is new: `action-primary-pressed`.', + }, + 'primary-hover': { + kind: 'rename', + to: ['action-primary-hover'], + rule: 'Hover for the primary action.', + }, + 'primary-inverse': { + kind: 'retire', + to: ['action-primary'], + rule: 'A primary action on an inverted ground is `action-primary` inside a dark-mode scope. The mode does the inverting.', + }, + secondary: { + kind: 'rename', + to: ['action-neutral'], + rule: 'The secondary button and secondary icon/text buttons. Labels on it are `text-inverse`.', + }, + 'secondary-hover': { + kind: 'rename', + to: ['action-neutral-hover'], + rule: 'Hover for the secondary action.', + }, + danger: { + kind: 'rename', + to: ['action-danger'], + rule: 'Destructive actions: delete, remove, revoke. Error messaging is `text-error`.', + }, + 'danger-hover': { + kind: 'rename', + to: ['action-danger-hover'], + rule: 'Hover for the destructive action.', + }, + interface: { + kind: 'merge', + to: ['action-primary'], + rule: 'Checkboxes, radios, toggles, focus rings and inline links share the one interactive blue.', + }, + 'interface-hover': { + kind: 'merge', + to: ['action-primary-hover'], + rule: 'Hover for the same controls.', + }, + 'border-primary': { + kind: 'split', + to: ['border-default', 'border-subtle'], + rule: 'A border that carries meaning (field outline, control stroke, a container that needs an edge to be seen) is `border-default`. A border that only decorates or backs up a shadow (card hairline, table rule, the edge of a menu, popover, toast or dialog) is `border-subtle`.', + }, + 'border-secondary': { + kind: 'merge', + to: ['border-default'], + rule: 'Same as `border-primary`. One meaningful border weight.', + }, + 'border-tertiary': { + kind: 'split', + to: ['border-subtle', 'border-strong'], + rule: 'A divider or card hairline is `border-subtle`. Where it marks an active field or a selected row, it is `border-strong`. Gamut’s own two usages, the FeatureShimmer edge and the List scroll shadow, are both `border-subtle`. Core resolves it to a light navy and Percipio to a dark one, so check each app usage for which it meant.', + }, + 'border-disabled': { + kind: 'merge', + to: ['border-default'], + rule: 'A disabled control is marked by its `action-disabled` fill. Its outline stays `border-default`.', + }, +}; + +/* Every role the generated system has (gamut-brand src/roles.ts), so the manifest test can check targets. */ +export const roles: ColorRolesManifest['roles'] = [ + 'surface-sunken', + 'surface-default', + 'surface-raised', + 'surface-success', + 'surface-warning', + 'surface-error', + 'surface-brand', + 'surface-brand-subtle', + 'text-default', + 'text-subtle', + 'text-inverse', + 'text-success', + 'text-warning', + 'text-error', + 'border-strong', + 'border-default', + 'border-subtle', + 'action-primary', + 'action-primary-hover', + 'action-primary-pressed', + 'action-danger', + 'action-danger-hover', + 'action-danger-pressed', + 'action-neutral', + 'action-neutral-hover', + 'action-neutral-pressed', + 'action-disabled', + 'state-hover', + 'state-pressed', + 'state-selected', + 'viz-1', + 'viz-2', + 'viz-3', + 'viz-4', + 'viz-5', + 'viz-6', + 'viz-7', + 'viz-8', + 'viz-seq-1', + 'viz-seq-2', + 'viz-seq-3', + 'viz-seq-4', + 'shadow-key', + 'shadow-ambient', + 'shadow-scrim', +]; + +/* + Every palette name the old themes define (core, admin, platform, percipio, + lxStudio) that isn't an alias, from main's built themes. No role replaces + them by name; design maps them per component. +*/ +export const oldPalette: ColorRolesManifest['oldPalette'] = [ + 'beige', + 'beige-0', + 'beige-100', + 'blue', + 'blue-0', + 'blue-100', + 'blue-300', + 'blue-400', + 'blue-500', + 'blue-800', + 'comment-dark', + 'comment-light', + 'gold', + 'gold-800', + 'gray-100', + 'gray-200', + 'gray-300', + 'gray-600', + 'gray-800', + 'gray-900', + 'green', + 'green-0', + 'green-100', + 'green-400', + 'green-700', + 'green-900', + 'hyper', + 'hyper-400', + 'hyper-500', + 'indent-active-dark', + 'indent-active-light', + 'indent-inactive-dark', + 'indent-inactive-light', + 'lightBeige', + 'lightBlue', + 'lightGreen', + 'line-number-active-dark', + 'line-number-active-light', + 'line-number-inactive-dark', + 'line-number-inactive-light', + 'lxStudioBgPrimary', + 'lxStudioSuccess', + 'navy', + 'navy-100', + 'navy-200', + 'navy-300', + 'navy-400', + 'navy-500', + 'navy-600', + 'navy-700', + 'navy-800', + 'navy-900', + 'orange', + 'orange-100', + 'orange-500', + 'orange-800', + 'paleBlue', + 'paleGreen', + 'palePink', + 'paleRed', + 'paleYellow', + 'percipioActionDangerHover', + 'percipioActionPrimaryHover', + 'percipioActionSecondary', + 'percipioActionSecondaryHover', + 'percipioBgError', + 'percipioBgPrimary', + 'percipioBgSuccess', + 'percipioBgWarning', + 'percipioDanger', + 'percipioFeedbackSuccess', + 'percipioFeedbackWarning', + 'percipioTextAccent', + 'percipioTextDisabled', + 'percipioTextPrimary', + 'percipioTextSecondary', + 'pink', + 'pink-0', + 'pink-400', + 'pink-800', + 'purple', + 'purple-300', + 'red', + 'red-0', + 'red-300', + 'red-400', + 'red-500', + 'red-600', + 'red-900', + 'sapphire', + 'teal', + 'teal-500', + 'white-100', + 'white-200', + 'white-300', + 'white-400', + 'white-500', + 'white-600', + 'white-700', + 'yellow', + 'yellow-0', + 'yellow-400', + 'yellow-500', + 'yellow-900', +]; diff --git a/packages/gamut-codemods/src/presets/color-roles/index.ts b/packages/gamut-codemods/src/presets/color-roles/index.ts new file mode 100644 index 0000000000..2c5fb2b498 --- /dev/null +++ b/packages/gamut-codemods/src/presets/color-roles/index.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs'; + +import type { Leftover } from '../../lib/leftovers'; +import { walk } from '../../lib/leftovers'; +import type { Preset } from '../../lib/types'; +import { colorRoles } from '../../migrations/color-roles'; +import { colorRolesStylesheets } from '../../migrations/color-roles-stylesheets'; +import { colorRolesThemes } from '../../migrations/color-roles-themes'; +import type { ColorRolesManifest } from './manifest'; +import { manifest } from './manifest'; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/* + A quoted old name, one read off `theme.colors`, an old `--color-*` + variable, a Card (whose variants are renamed), or an old theme name. +*/ +const OLD_NAMES = new RegExp( + `['"\`](?:${[...Object.keys(manifest.aliases), ...manifest.oldPalette] + .map(escape) + .join( + '|' + )})['"\`]|colors\\.[A-Za-z]|--color-[A-Za-z0-9]| = { + name: 'color-roles', + description: + 'Move Gamut color names onto the roles of the generated color system.', + manifest, + mentions: (source) => OLD_NAMES.test(source), + /* Every name the run marked for a person and nobody has resolved yet. */ + leftovers(roots) { + const hits: Leftover[] = []; + for (const root of roots) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + if (!SOURCE.test(file)) continue; + fs.readFileSync(file, 'utf8') + .split('\n') + .forEach((text, i) => { + if (text.includes('color-roles:')) { + hits.push({ file, line: i + 1, text: text.trim() }); + } + }); + } + } + return hits; + }, + migrations: [colorRoles, colorRolesThemes, colorRolesStylesheets], + checklist: [ + 'Run your formatter (prettier --write). The `color-roles:` comments can make lines long.', + 'Type-check. Every name the codemod left in place is still an old name, so it fails until someone picks its role.', + 'Work through the leftovers: each `color-roles:` comment carries design’s rule for choosing. Replace the name, then delete the comment.', + ], + conditionalChecklist: { + 'color-roles:manual': + 'Some names are splits, retirements or pending (see the warnings). Splits need the role for where the color is used; retired names have a rule instead of a role; pending names wait for a system that doesn’t exist yet. Shadows split onto `shadow-key` and `shadow-ambient`, the two layers every elevation shadow draws.', + 'color-roles:stylesheets': + 'Stylesheets used old `--color-*` variables. Renames are done; the type check can’t see stylesheets, so the leftovers report below is the only list of what’s left. Indented-syntax .sass files aren’t rewritten: search them for `--color-` by hand.', + 'color-roles:themes': + 'Old theme names moved: `percipioTheme` → `skillsoftTheme` (its replacement) and `coreTheme` → `originalCodecademyTheme` (its rebuild). A `CoreTheme` type, usually in a Theme augmentation, is left with a comment: brand themes share one shape, typed by `SkillsoftTheme`.', + 'color-roles:card-navy': + 'Some Cards used the `navy` variant. It becomes `default`, which renders light; wrap a card in `` where it should stay dark, then delete the comment.', + 'color-roles:palette': + 'Some code uses old palette colors (navy, hyper-500, …) directly. No role replaces them by name. Pick each one from design’s component map, in @skillsoft/gamut-brand src/migration.ts (COMPONENTS).', + 'color-roles:runtime': + 'Some color names are built at runtime, like `${variant}-hover`. The codemod can’t see their values; check each name the template can produce.', + }, +}; diff --git a/packages/gamut-codemods/src/presets/color-roles/manifest.ts b/packages/gamut-codemods/src/presets/color-roles/manifest.ts new file mode 100644 index 0000000000..15e2186b91 --- /dev/null +++ b/packages/gamut-codemods/src/presets/color-roles/manifest.ts @@ -0,0 +1,56 @@ +import { aliases, oldPalette, roles } from './aliases'; + +/* + color-roles: today's Gamut color names → the roles of the generated color + system. The rows are design's (aliases.ts, generated); this file holds the + shape and the props a color can appear in. +*/ + +/* + - rename / merge: one role replaces the name. Applied. + - split: one of several roles, depending on where it's used. + - retire: no role replaces it; `rule` says what to do instead. + - pending: decided, but its role arrives with a system that doesn't exist + yet. No row is pending today; shadows became a split. + The last three need a person; the codemod leaves the name and a comment. +*/ +export type ColorKind = 'rename' | 'merge' | 'split' | 'retire' | 'pending'; + +export interface ColorAlias { + kind: ColorKind; + /* Roles, most common first. Empty for some retire and pending rows. */ + to: string[]; + rule: string; +} + +export interface ColorRolesManifest { + aliases: Record; + /* Every role of the generated system; alias targets must be among them. */ + roles: string[]; + /* Old palette names (navy, hyper-500, …). No role replaces them by name. */ + oldPalette: string[]; + /* Props whose values are color names: gamut-styles variance/config.ts `color`. */ + colorProps: string[]; +} + +export const manifest: ColorRolesManifest = { + aliases, + roles, + oldPalette, + colorProps: [ + 'color', + 'textColor', + 'bg', + 'borderColor', + 'borderColorX', + 'borderColorY', + 'borderColorLeft', + 'borderColorRight', + 'borderColorTop', + 'borderColorBottom', + ], +}; + +/* Applied by the codemod, vs. left for a person. */ +export const isAutomatic = (alias: ColorAlias) => + alias.kind === 'rename' || alias.kind === 'merge'; diff --git a/packages/gamut-codemods/src/presets/index.ts b/packages/gamut-codemods/src/presets/index.ts index 2ff0c9294a..76c153c249 100644 --- a/packages/gamut-codemods/src/presets/index.ts +++ b/packages/gamut-codemods/src/presets/index.ts @@ -1,4 +1,5 @@ import type { Preset } from '../lib/types'; +import { colorRolesPreset } from './color-roles'; import { scopeSwap } from './scope-swap'; /* @@ -7,8 +8,10 @@ import { scopeSwap } from './scope-swap'; Later presets are named for the release they target and can reuse migrations from src/migrations with a different manifest. */ -export const presets: Record = { +/* Each preset has its own manifest type; callers only pass it through. */ +export const presets: Record> = { [scopeSwap.name]: scopeSwap, + [colorRolesPreset.name]: colorRolesPreset, }; /* @@ -17,7 +20,7 @@ export const presets: Record = { switch, so presets have to list every `ast` migration before any `splice` one. */ -export const validatePreset = (preset: Preset) => { +export const validatePreset = (preset: Preset) => { const source = preset.migrations.filter((m) => m.kind !== 'file'); const firstSplice = source.findIndex((m) => m.kind === 'splice'); const lateAst = diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index c08b1b14e7..57b1f3b497 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -1,3 +1,6 @@ +import { findFlatEslintConfig } from '../../lib/eslint'; +import { findLeftovers } from '../../lib/leftovers'; +import { mentionsOldNames } from '../../lib/run-source'; import type { Preset } from '../../lib/types'; import { deepImports } from '../../migrations/deep-imports'; import { eslintComments } from '../../migrations/eslint-comments'; @@ -17,6 +20,11 @@ export const scopeSwap: Preset = { name: 'scope-swap', description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', manifest, + mentions: (source) => mentionsOldNames(manifest, source), + leftovers: (roots) => + findLeftovers(roots, manifest, { + eslintRuleKeys: !findFlatEslintConfig(roots), + }), migrations: [ deepImports, movedExports, diff --git a/packages/gamut-codemods/src/transform.ts b/packages/gamut-codemods/src/transform.ts index e1e60939f0..be7cf254b4 100644 --- a/packages/gamut-codemods/src/transform.ts +++ b/packages/gamut-codemods/src/transform.ts @@ -3,11 +3,7 @@ import path from 'node:path'; import type { API, FileInfo } from 'jscodeshift'; -import { - activeSourceMigrations, - mentionsOldNames, - runSourceMigrations, -} from './lib/run-source'; +import { activeSourceMigrations, runSourceMigrations } from './lib/run-source'; import type { Locatable } from './lib/types'; import { getPreset } from './presets'; @@ -41,7 +37,7 @@ export default function transform( ) { const preset = getPreset(options.preset ?? 'scope-swap'); const { manifest } = preset; - if (!mentionsOldNames(manifest, file.source)) return undefined; + if (!preset.mentions(file.source)) return undefined; const note = (key: string) => { if (options.notesDir) {