diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83bf943ac..65eb61013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: path: | bulma-ui/coverage create-bestax/coverage + bestax-migrate/coverage retention-days: 7 - name: Archive Bundle Stats @@ -239,3 +240,13 @@ jobs: run: | git log -1 --show-signature pnpm exec semantic-release + + - name: Semantic Release (bestax-migrate) + working-directory: bestax-migrate + env: + GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} + GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git log -1 --show-signature + pnpm exec semantic-release diff --git a/.prettierignore b/.prettierignore index 883de0be3..9d1b1c059 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,10 @@ pnpm-lock.yaml # Generated by scripts/gen-component-catalog.mjs skills/bestax-custom-component/references/component-catalog.md + +# Codemod test fixtures: byte-exact expected transform output β€” reformatting +# them would change what the tests assert. The kitchen-sink fixture app is +# migration *input* (react-bulma-components source), also left untouched. +**/__testfixtures__/ +bestax-migrate/fixtures/ +bestax-migrate/.e2e-tmp/ diff --git a/README.md b/README.md index 14f636add..56921c2fc 100644 --- a/README.md +++ b/README.md @@ -91,14 +91,15 @@ function Root() { ## πŸ“¦ What's in this repo -| Path | Package | What it is | -| ---------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| [`bulma-ui/`](bulma-ui/) | [`@allxsmith/bestax-bulma`](https://www.npmjs.com/package/@allxsmith/bestax-bulma) | The component library | -| [`create-bestax/`](create-bestax/) | [`create-bestax`](https://www.npmjs.com/package/create-bestax) | Project scaffolder β€” `npm create bestax@latest` | -| [`docs/`](docs/) | β€” | Docusaurus source of [bestax.io](https://bestax.io) | -| [`skills/`](skills/) | β€” | [Agent Skills](https://bestax.io/docs/skills/intro) for coding agents (also bundled into `create-bestax`) | - -The two packages are versioned and released independently. +| Path | Package | What it is | +| ------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| [`bulma-ui/`](bulma-ui/) | [`@allxsmith/bestax-bulma`](https://www.npmjs.com/package/@allxsmith/bestax-bulma) | The component library | +| [`create-bestax/`](create-bestax/) | [`create-bestax`](https://www.npmjs.com/package/create-bestax) | Project scaffolder β€” `npm create bestax@latest` | +| [`bestax-migrate/`](bestax-migrate/) | [`bestax-migrate`](https://www.npmjs.com/package/bestax-migrate) | Codemods for migrating existing apps from other React Bulma libraries | +| [`docs/`](docs/) | β€” | Docusaurus source of [bestax.io](https://bestax.io) | +| [`skills/`](skills/) | β€” | [Agent Skills](https://bestax.io/docs/skills/intro) for coding agents (also bundled into `create-bestax`) | + +The published packages are versioned and released independently. --- diff --git a/bestax-migrate/.gitignore b/bestax-migrate/.gitignore new file mode 100644 index 000000000..0f9039e64 --- /dev/null +++ b/bestax-migrate/.gitignore @@ -0,0 +1,3 @@ +dist +coverage +.e2e-tmp diff --git a/bestax-migrate/.prettierignore b/bestax-migrate/.prettierignore new file mode 100644 index 000000000..a4d5e0cd5 --- /dev/null +++ b/bestax-migrate/.prettierignore @@ -0,0 +1,8 @@ +# Codemod fixtures are byte-exact expected transform output; reformatting them +# would change what the tests assert. Mirrors the repo-root .prettierignore for +# package-local prettier runs (pnpm --filter bestax-migrate format:check). +src/sources/*/__testfixtures__/ +fixtures/ +.e2e-tmp/ +dist/ +coverage/ diff --git a/bestax-migrate/CLAUDE.md b/bestax-migrate/CLAUDE.md new file mode 100644 index 000000000..6330dd997 --- /dev/null +++ b/bestax-migrate/CLAUDE.md @@ -0,0 +1,65 @@ +# bestax-migrate β€” codemods onto bestax-bulma + +jscodeshift-based CLI (`pnpm dlx bestax-migrate `) that migrates existing +apps from other React Bulma libraries to `@allxsmith/bestax-bulma`. Multi-source by design: +each source library registers in `src/sources/registry.ts`; the first is +`react-bulma-components` (v4 only). + +## Hard rules + +- **react-bulma-components is NEVER installed anywhere in this repo** (supply-chain + policy). Fixtures are read as _text_ (`__testfixtures__`, `fixtures/kitchen-sink`); the + migration _input_ is never typechecked or executed. Validation typechecks the migrated + _output_ against the workspace `@allxsmith/bestax-bulma`. +- Mapping-table first: `src/sources/react-bulma-components/mapping.ts` is the single source + of truth. Every RBC v4 export must have an entry (`mapped`/`partial`/`todo`) β€” the + `mapping-coverage` test walks the vendored `RBC_EXPORTS` list against it. New coverage is + a table edit (plus a `special` handler in `specials.ts` when structure changes). +- Anything unsafe gets a `// TODO(bestax-migrate): ` comment on the enclosing + statement + a report entry β€” never a silent skip, never a best-guess rewrite of dynamic + values. + +## Architecture + +- `src/cli.ts` β€” file walk + per-type routing (js-ish β†’ jscodeshift transform, + .scss/.sass β†’ `transformStyles`, nearest package.json β†’ `updateDependencies`) with an + in-process runner (NOT jscodeshift's worker Runner: fragile from ESM, hides per-file + stats). `src/runner.ts` is shared by CLI and tests. `--css bestax|bulma|keep` picks the + stylesheet target; `--no-deps` skips the manifest step. +- `src/sources/react-bulma-components/`: `transform.ts` (orchestration: imports + css β†’ + per-element special/rename/responsive/props β†’ import rewrite), `mapping.ts` (data), + `specials.ts` (structural handlers), `props.ts` (PropAction interpreter), `responsive.ts` + (breakpoint-object flattening), `styles.ts` (line-based SCSS/SASS transform β€” Sass has + no jscodeshift parser, so it only rewrites provably-safe patterns), `deps.ts` + (package.json updater; never runs an install), `jsx-utils.ts` (AST helpers). +- Components with no bestax equivalent (Element, Tile) keep a trimmed, TODO-annotated RBC + import so the code still runs during gradual migration. + +## Testing + +- Fixture pairs: `__testfixtures__/.input.tsx` β†’ `.output.tsx`, exact-match. To + update outputs after changing the transform, regenerate them by running the built + transform over the inputs and reviewing the diff β€” do not hand-edit drift in. +- Kitchen sink e2e (`e2e/kitchen-sink.test.ts`): copies `fixtures/kitchen-sink` to + `.e2e-tmp/`, migrates it, and runs `tsc --noEmit` on the output against the built + bulma-ui (`turbo` orders the build; see `bestax-migrate#test` in root turbo.json). + `src/leftovers.tsx` holds every intentionally-unsupported pattern β€” excluded from the + typecheck, asserted via TODO rules instead. New unsupported patterns go there; new + supported ones go in the other fixture files, which must stay TODO-free. +- Fixtures and `.e2e-tmp` are excluded from tsc/eslint/prettier at both root and package + level (root `eslint.config.js` + both `.prettierignore`s) β€” keep them that way. +- Real-world corpus: `pnpm --filter bestax-migrate validate:corpus` fetches the + react-bulma-components repo's own MIT-licensed Storybook stories (pinned SHA, text only, + into `.e2e-tmp/`) and scores the transform over them β€” fails on any crash or + unknown-component TODO. Deliberately NOT in CI (no third-party fetches in the pipeline); + run it before releases and after mapping changes, and eyeball the before/after diffs it + writes to `.e2e-tmp/corpus-out/`. + +## Releases + +Independent semantic-release, keyed off the `bestax-migrate` commit scope +(`release.config.js`, tag `bestax-migrate@x.y.z`). The skill lives at repo-root +`skills/bestax-migrate/` and is deliberately **not** bundled into create-bestax (existing +sites only) β€” keep `create-bestax/scripts/sync-skills.mjs` untouched. When the mapping +gains or loses coverage, update `skills/bestax-migrate/references/` and the docs migration +guide in the same PR. diff --git a/bestax-migrate/README.md b/bestax-migrate/README.md new file mode 100644 index 000000000..3f7512d0e --- /dev/null +++ b/bestax-migrate/README.md @@ -0,0 +1,59 @@ +# bestax-migrate + +Codemods that migrate existing React apps to [`@allxsmith/bestax-bulma`](https://www.npmjs.com/package/@allxsmith/bestax-bulma) β€” the actively maintained React component library for **Bulma v1**. + +Currently supported source libraries: + +| Source | Status | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [`react-bulma-components`](https://github.com/couds/react-bulma-components) (v4) | βœ… All 32 components mapped (a few patterns are flagged as TODOs) | + +## Usage + +```bash +# Preview what would change (no writes) +pnpm dlx bestax-migrate react-bulma-components src/ --dry + +# Apply the migration +pnpm dlx bestax-migrate react-bulma-components src/ +``` + +npm and yarn work too: `npx bestax-migrate …` / `yarn dlx bestax-migrate …`. + +The codemod uses [jscodeshift](https://github.com/facebook/jscodeshift) to: + +- rewrite `react-bulma-components` imports to `@allxsmith/bestax-bulma` (including `const { Input } = Form` destructuring and namespace imports) +- rename components and compound sub-components (`Form.Textarea` β†’ `TextArea`, `Card.Footer.Item` β†’ `Card.FooterItem`, `Hero.Footer` β†’ `Hero.Foot`, …) +- convert props (`renderAs` β†’ `as`, `loading` β†’ `isLoading`, numeric spacing/text sizes β†’ string unions, `textAlign="center"` β†’ `textAlign="centered"`, …) +- flatten responsive breakpoint objects (`mobile={{ size: 4 }}` β†’ `sizeMobile={4}`) +- restructure patterns bestax models differently (`Table.Container`, Navbar dropdowns, `Form.Help`, icon-font children β†’ ``) +- migrate stylesheets: CSS imports converge on `@allxsmith/bestax-bulma/bestax.css`, and SCSS files move from Bulma 0.9's `@import` + `$var !default` overrides to `@use 'bulma/sass' with (…)` plus `@use '@allxsmith/bestax-bulma/scss/extras'` +- update `package.json`: remove `react-bulma-components`, add `@allxsmith/bestax-bulma`, bump `bulma` to `^1`, and swap the dead `node-sass` for dart `sass` (no install is ever run) + +Anything without a safe automatic conversion is left in place with a `// TODO(bestax-migrate): …` comment, and the run ends with a report of every TODO by file and line. TODOs are expected output, not errors β€” resolve them with the [migration guide](https://bestax.io/docs/guides/getting-started/migration/react-bulma-components), or let the [`bestax-migrate` Agent Skill](https://bestax.io/docs/skills/intro) walk them for you: + +```bash +npx skills add https://github.com/allxsmith/bestax --skill bestax-migrate +``` + +## Options + +| Flag | Description | +| -------------------- | -------------------------------------------------------------- | +| `--dry`, `-d` | Report what would change without writing files | +| `--print`, `-p` | Print transformed sources to stdout | +| `--extensions`, `-e` | File extensions to include (default `js,jsx,ts,tsx,scss,sass`) | +| `--css ` | Stylesheet target: `bestax` (default), `bulma`, or `keep` | +| `--no-deps` | Skip updating package.json dependencies | + +## After the codemod + +1. Run your package manager's install (the codemod rewrote `package.json` but never installs) +2. Search for `TODO(bestax-migrate)` and resolve each comment +3. Typecheck/build and review the rendered app + +Full walkthrough: [react-bulma-components migration guide](https://bestax.io/docs/guides/getting-started/migration/react-bulma-components). + +## License + +MIT Β© Alex Smith diff --git a/bestax-migrate/e2e/kitchen-sink.test.ts b/bestax-migrate/e2e/kitchen-sink.test.ts new file mode 100644 index 000000000..7ed749152 --- /dev/null +++ b/bestax-migrate/e2e/kitchen-sink.test.ts @@ -0,0 +1,209 @@ +/** + * End-to-end gate: copy the source-only react-bulma-components kitchen-sink + * app into .e2e-tmp/, run the codemod on every file, then typecheck the + * MIGRATED output against the real @allxsmith/bestax-bulma types (workspace + * dependency β€” bulma-ui must be built first; turbo orders this). + * + * react-bulma-components itself is never installed: the input app is never + * typechecked, and leftovers.tsx β€” the file that exercises everything the + * codemod intentionally refuses to convert β€” is excluded from the output + * typecheck and asserted through its TODO annotations instead. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { reactBulmaComponents } from '../src/sources/react-bulma-components/index.js'; +import { runTransform } from '../src/runner.js'; +import type { TodoEntry } from '../src/types.js'; + +const packageRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const fixtureDir = path.join(packageRoot, 'fixtures', 'kitchen-sink'); +const tmpDir = path.join(packageRoot, '.e2e-tmp', 'kitchen-sink'); + +interface MigratedApp { + todosByFile: Map; + files: string[]; +} + +function migrateKitchenSink(): MigratedApp { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.cpSync(fixtureDir, tmpDir, { recursive: true }); + + const srcDir = path.join(tmpDir, 'src'); + const files = fs.readdirSync(srcDir).filter(f => /\.(tsx?|jsx?)$/.test(f)); + const todosByFile = new Map(); + + for (const file of files) { + const filePath = path.join(srcDir, file); + const source = fs.readFileSync(filePath, 'utf8'); + const todos: TodoEntry[] = []; + const { output } = runTransform( + reactBulmaComponents.transform, + file, + source, + { + add: entry => todos.push(entry), + } + ); + if (output !== null) fs.writeFileSync(filePath, output); + todosByFile.set(file, todos); + } + + // Stylesheets and the manifest go through their dedicated transforms. + const scssPath = path.join(srcDir, 'styles.scss'); + const scssTodos: TodoEntry[] = []; + const scssOut = reactBulmaComponents.transformStyles!( + 'styles.scss', + fs.readFileSync(scssPath, 'utf8'), + { add: entry => scssTodos.push(entry) }, + { cssMode: 'bestax' } + ); + if (scssOut !== null) fs.writeFileSync(scssPath, scssOut); + todosByFile.set('styles.scss', scssTodos); + + const pkgPath = path.join(tmpDir, 'package.json'); + const pkgTodos: TodoEntry[] = []; + const pkgNext = reactBulmaComponents.updateDependencies!( + 'package.json', + JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + { add: entry => pkgTodos.push(entry) }, + { cssMode: 'bestax', bulmaReferenced: true } + ); + if (pkgNext !== null) { + fs.writeFileSync(pkgPath, `${JSON.stringify(pkgNext, null, 2)}\n`); + } + + return { todosByFile, files }; +} + +describe('kitchen-sink e2e', () => { + let app: MigratedApp; + + beforeAll(() => { + app = migrateKitchenSink(); + }); + + it('migrates every kitchen-sink file', () => { + expect(app.files.sort()).toEqual([ + 'App.tsx', + 'columns.tsx', + 'components.tsx', + 'elements.tsx', + 'form.tsx', + 'helpers.tsx', + 'layout.tsx', + 'leftovers.tsx', + ]); + for (const file of app.files) { + const migrated = fs.readFileSync(path.join(tmpDir, 'src', file), 'utf8'); + // Only leftovers.tsx may keep its (trimmed, TODO-annotated) RBC import. + if (file !== 'leftovers.tsx') { + expect(migrated).not.toContain('react-bulma-components'); + } + } + }); + + it('leaves no TODOs outside leftovers.tsx', () => { + for (const [file, todos] of app.todosByFile) { + if (file === 'leftovers.tsx') continue; + expect({ file, todos }).toEqual({ file, todos: [] }); + } + }); + + it('adopts the bestax combined CSS bundle in App.tsx', () => { + const migrated = fs.readFileSync( + path.join(tmpDir, 'src', 'App.tsx'), + 'utf8' + ); + expect(migrated).toContain('import "@allxsmith/bestax-bulma/bestax.css";'); + expect(migrated).not.toContain('bulma/css'); + }); + + it('migrates the SCSS entry to Bulma v1 modules with folded variables', () => { + const scss = fs.readFileSync( + path.join(tmpDir, 'src', 'styles.scss'), + 'utf8' + ); + expect(scss).toContain("@use 'bulma/sass' with ("); + expect(scss).toContain('$primary: #1e6b99,'); + expect(scss).toContain("$family-primary: 'Nunito', sans-serif"); + expect(scss).toContain("@use '@allxsmith/bestax-bulma/scss/extras';"); + expect(scss).not.toContain('@import'); + expect(scss).toContain('.app-shell'); + }); + + it('migrates the package.json dependency set', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8') + ); + expect(pkg.dependencies['react-bulma-components']).toBeUndefined(); + expect(pkg.dependencies['@allxsmith/bestax-bulma']).toBe('^5'); + expect(pkg.dependencies.bulma).toBe('^1.0.4'); + expect(pkg.devDependencies['node-sass']).toBeUndefined(); + expect(pkg.devDependencies.sass).toBe('^1.79.0'); + expect(pkg.dependencies.react).toBe('^18.2.0'); + }); + + it('annotates every intentionally unsupported pattern in leftovers.tsx', () => { + const todos = app.todosByFile.get('leftovers.tsx') ?? []; + const rules = new Set(todos.map(t => t.rule)); + expect(rules).toContain('component:Element'); + expect(rules).toContain('component:Tile'); + expect(rules).toContain('responsive'); + expect(rules).toContain('prop:colorVariant'); + expect(rules).toContain('prop:value'); + expect(rules).toContain('prop:delta'); + expect(todos.length).toBeGreaterThanOrEqual(10); + const migrated = fs.readFileSync( + path.join(tmpDir, 'src', 'leftovers.tsx'), + 'utf8' + ); + expect(migrated).toContain('TODO(bestax-migrate)'); + // Unmappable components keep a trimmed, TODO-annotated RBC import. + expect(migrated).toMatch( + /import \{ Element, Tile \} from 'react-bulma-components';/ + ); + }); + + it('typechecks the migrated output against @allxsmith/bestax-bulma', () => { + fs.writeFileSync( + path.join(tmpDir, 'global.d.ts'), + "declare module '*.css';\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + jsx: 'react-jsx', + module: 'ESNext', + target: 'ES2022', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + skipLibCheck: true, + }, + include: ['src/**/*', 'global.d.ts'], + exclude: ['src/leftovers.tsx'], + }, + null, + 2 + ) + ); + const result = spawnSync('pnpm', ['exec', 'tsc', '-p', tmpDir], { + cwd: packageRoot, + encoding: 'utf8', + }); + const diagnostics = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); + expect({ status: result.status, diagnostics }).toEqual({ + status: 0, + diagnostics: '', + }); + }, 120000); +}); diff --git a/bestax-migrate/fixtures/kitchen-sink/package.json b/bestax-migrate/fixtures/kitchen-sink/package.json new file mode 100644 index 000000000..917257bea --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/package.json @@ -0,0 +1,13 @@ +{ + "name": "kitchen-sink", + "private": true, + "dependencies": { + "bulma": "^0.9.4", + "react": "^18.2.0", + "react-bulma-components": "^4.1.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "node-sass": "^7.0.3" + } +} diff --git a/bestax-migrate/fixtures/kitchen-sink/src/App.tsx b/bestax-migrate/fixtures/kitchen-sink/src/App.tsx new file mode 100644 index 000000000..afbad9399 --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/src/App.tsx @@ -0,0 +1,20 @@ +import 'bulma/css/bulma.min.css'; +import { Elements } from './elements'; +import { Grid } from './columns'; +import { Widgets } from './components'; +import { SignupForm } from './form'; +import { Layout } from './layout'; +import { Helpers } from './helpers'; + +export default function App() { + return ( +
+ + + + + + +
+ ); +} diff --git a/bestax-migrate/fixtures/kitchen-sink/src/columns.tsx b/bestax-migrate/fixtures/kitchen-sink/src/columns.tsx new file mode 100644 index 000000000..2fcb0d0c8 --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/src/columns.tsx @@ -0,0 +1,21 @@ +import { Columns } from 'react-bulma-components'; + +export function Grid() { + return ( + + + Main content + + Sidebar + + Responsive column + + + ); +} diff --git a/bestax-migrate/fixtures/kitchen-sink/src/components.tsx b/bestax-migrate/fixtures/kitchen-sink/src/components.tsx new file mode 100644 index 000000000..1e198699b --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/src/components.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { + Breadcrumb, + Card, + Dropdown, + Menu, + Message, + Modal, + Navbar, + Pagination, + Panel, + Tabs, +} from 'react-bulma-components'; + +export function Widgets() { + const [page, setPage] = useState(1); + const [open, setOpen] = useState(false); + return ( +
+ + + + bestax + + + + + + Docs + + + + More + + About + + Jobs + + + + + + + + Bulma + + + Documentation + + + + + + Widgets + β–Ό + + Body + + Save + + + + + Dashboard + Customers + + + + Error + Something failed. + + setOpen(false)}> + + + Terms + + Fine print. + The end. + + + + Pictures + Music + + + Repositories + + All + Public + + + + + + bestax + + + + First + + Second + + +
+ ); +} diff --git a/bestax-migrate/fixtures/kitchen-sink/src/elements.tsx b/bestax-migrate/fixtures/kitchen-sink/src/elements.tsx new file mode 100644 index 000000000..6be68d66b --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/src/elements.tsx @@ -0,0 +1,87 @@ +import { + Block, + Box, + Button, + Container, + Content, + Footer, + Heading, + Icon, + Image, + Loader, + Notification, + Progress, + Section, + Table, + Tag, +} from 'react-bulma-components'; + +export function Elements() { + return ( +
+ + + Elements + + + Every basic piece of the page + + Stats + + +

Rich text lives here.

+
+
+ + + + + +
+ ); +} diff --git a/bestax-migrate/fixtures/kitchen-sink/src/form.tsx b/bestax-migrate/fixtures/kitchen-sink/src/form.tsx new file mode 100644 index 000000000..d48f09902 --- /dev/null +++ b/bestax-migrate/fixtures/kitchen-sink/src/form.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Button, Form } from 'react-bulma-components'; + +const { Input, Field, Control, Label, Textarea, Select, Checkbox, Radio, Help, InputFile } = Form; + +export function SignupForm() { + const [fileName, setFileName] = useState(''); + return ( +
+ + + + + + This username is taken + + + + + + + + + + + +