From c101ee464a00659da945e59aeda61b1a909f5d58 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 8 Sep 2026 18:57:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(migrate):=20add=20@choojs/migrate=20?= =?UTF-8?q?=E2=80=94=20the=20choo-migrate=20v7=E2=86=92v8=20codemod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regex-based on purpose: converts simple top-level CJS patterns to ESM, remaps package specifiers to their @choojs homes, points retired nano* packages at built-in replacements (choo-lazy-route → lazy()), and reports everything it refuses to guess at instead of guessing. Validated against choo's own v7 example app: migrated output runs on v8 unmodified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd --- packages/migrate/cli.js | 52 +++++++++++++ packages/migrate/lib/transform.js | 98 +++++++++++++++++++++++++ packages/migrate/package.json | 26 +++++++ packages/migrate/test/transform.test.js | 92 +++++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100755 packages/migrate/cli.js create mode 100644 packages/migrate/lib/transform.js create mode 100644 packages/migrate/package.json create mode 100644 packages/migrate/test/transform.test.js diff --git a/packages/migrate/cli.js b/packages/migrate/cli.js new file mode 100755 index 0000000..5ea1f24 --- /dev/null +++ b/packages/migrate/cli.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// choo-migrate [--dry] +// Rewrites choo v7 sources toward v8: CJS → ESM for simple top-level +// patterns, package specifiers remapped, and a per-file report of what +// needs human hands. --dry prints the report without writing. + +import { readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { join, extname } from 'node:path' + +import transform from './lib/transform.js' + +const args = process.argv.slice(2) +const dry = args.includes('--dry') +const targets = args.filter((a) => a !== '--dry') + +if (targets.length === 0) { + console.log('usage: choo-migrate [--dry] ') + process.exit(1) +} + +const SKIP = new Set(['node_modules', '.git', 'dist', 'coverage']) + +async function * walk (path) { + const info = await stat(path) + if (info.isFile()) { + if (extname(path) === '.js' || extname(path) === '.mjs') yield path + return + } + for (const entry of await readdir(path)) { + if (SKIP.has(entry)) continue + yield * walk(join(path, entry)) + } +} + +let changedCount = 0 +for (const target of targets) { + for await (const file of walk(target)) { + const source = await readFile(file, 'utf8') + const { code, changed, notes } = transform(source) + if (!changed && notes.length === 0) continue + + console.log(`\n${file}${changed ? (dry ? ' (would change)' : ' (rewritten)') : ''}`) + for (const note of notes) console.log(` ⚠ ${note}`) + + if (changed && !dry) { + await writeFile(file, code) + changedCount++ + } + } +} + +console.log(`\n${dry ? 'dry run — no files written' : changedCount + ' file(s) rewritten'}. Remember: package.json needs "type": "module" and deps on @choojs/*.`) diff --git a/packages/migrate/lib/transform.js b/packages/migrate/lib/transform.js new file mode 100644 index 0000000..24e083e --- /dev/null +++ b/packages/migrate/lib/transform.js @@ -0,0 +1,98 @@ +// The v7 → v8 source transform. Deliberately regex-based and honest about +// it: simple top-level CJS patterns are converted mechanically, package +// specifiers are remapped, and everything the regexes cannot prove is +// reported as a note instead of guessed at. + +// old specifier → new specifier +export const SPECIFIERS = { + choo: '@choojs/core', + 'choo/html': '@choojs/html', + 'choo/html/raw': '@choojs/html/raw', + 'choo/component': '@choojs/component', + nanohtml: '@choojs/html', + 'nanohtml/raw': '@choojs/html/raw', + nanomorph: '@choojs/html/morph', + nanocomponent: '@choojs/component', + 'choo-devtools': '@choojs/devtools' +} + +// old specifier → guidance (no direct replacement package) +export const RETIRED = { + nanobus: "built into @choojs/core — use app.emitter, or import Nanobus from '@choojs/core' internals is no longer needed", + nanorouter: 'built into @choojs/core — app.route covers it', + nanohref: 'built into @choojs/core — link handling is automatic', + nanotiming: "built into @choojs/core — import from '@choojs/core/timing' if you need it directly", + nanoraf: 'retired — use requestAnimationFrame, or rely on choo render batching', + nanoquery: 'retired — state.query is built in; use URLSearchParams directly elsewhere', + nanoassert: 'retired — use plain checks or node:assert', + nanolru: 'retired — the component cache is built into @choojs/core', + 'choo-lazy-route': "replaced by lazy() from '@choojs/core': app.route('/x', lazy(() => import('./x.js')))", + 'choo-service-worker': 'kept as a concept; bankai v10 will inject the asset manifest — check docs before migrating this one' +} + +export default function transform (source) { + const notes = [] + let code = source + + // --- CJS → ESM, simple top-level forms --- + + // const { a, b } = require('x') + code = code.replace( + /^(\s*)(?:var|let|const)\s*\{([^}]+)\}\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm, + (m, indent, names, q, spec) => `${indent}import {${names}} from '${spec}'` + ) + + // const X = require('x').default / .thing + code = code.replace( + /^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.default\s*;?[^\S\n]*$/gm, + (m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'` + ) + code = code.replace( + /^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.([A-Za-z_$][\w$]*)\s*;?[^\S\n]*$/gm, + (m, indent, name, q, spec, prop) => `${indent}import { ${prop} as ${name} } from '${spec}'` + ) + + // const X = require('x') + code = code.replace( + /^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm, + (m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'` + ) + + // bare require('x') statements (side-effect imports) + code = code.replace( + /^(\s*)require\((['"])([^'"]+)\2\)\s*;?[^\S\n]*$/gm, + (m, indent, q, spec) => `${indent}import '${spec}'` + ) + + // module.exports = X + code = code.replace(/^(\s*)module\.exports\s*=\s*/gm, (m, indent, offset) => { + return `${indent}export default ` + }) + + // --- specifier remapping (imports and any requires we couldn't convert) --- + + code = code.replace( + /(from\s*|import\s*\(\s*|import\s+|require\s*\(\s*)(['"])([^'"]+)\2/g, + (m, lead, q, spec) => { + if (SPECIFIERS[spec]) return `${lead}${q}${SPECIFIERS[spec]}${q}` + if (RETIRED[spec]) { + notes.push(`'${spec}': ${RETIRED[spec]}`) + } + return m + } + ) + + // --- things we refuse to guess at --- + + if (/module\.exports\.[A-Za-z_$]/.test(code)) { + notes.push('module.exports. assignments found — convert to named `export` statements by hand') + } + if (/\brequire\s*\(/.test(code)) { + notes.push('require() calls remain (non-top-level or dynamic) — convert to import/await import() by hand') + } + if (/\bexports\.[A-Za-z_$]/.test(code)) { + notes.push('bare exports. assignments found — convert to named `export` statements by hand') + } + + return { code, changed: code !== source, notes: [...new Set(notes)] } +} diff --git a/packages/migrate/package.json b/packages/migrate/package.json new file mode 100644 index 0000000..e1d1fa2 --- /dev/null +++ b/packages/migrate/package.json @@ -0,0 +1,26 @@ +{ + "name": "@choojs/migrate", + "version": "8.0.0-dev", + "description": "Codemod: migrate choo v7 apps to @choojs v8 (ESM, new package names)", + "type": "module", + "bin": { + "choo-migrate": "./cli.js" + }, + "exports": { + ".": "./lib/transform.js" + }, + "files": [ + "cli.js", + "lib" + ], + "engines": { + "node": ">=24" + }, + "repository": "choojs/choo", + "keywords": [ + "choo", + "codemod", + "migration" + ], + "license": "MIT" +} diff --git a/packages/migrate/test/transform.test.js b/packages/migrate/test/transform.test.js new file mode 100644 index 0000000..ff440c8 --- /dev/null +++ b/packages/migrate/test/transform.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test' +import assert from 'node:assert' + +import transform from '../lib/transform.js' + +test('converts a classic choo v7 header to v8 ESM', () => { + const src = [ + "var choo = require('choo')", + "var html = require('choo/html')", + "var devtools = require('choo-devtools')", + '', + 'var app = choo()', + "app.use(devtools())", + 'module.exports = app' + ].join('\n') + + const { code, changed, notes } = transform(src) + assert.ok(changed) + assert.match(code, /import choo from '@choojs\/core'/) + assert.match(code, /import html from '@choojs\/html'/) + assert.match(code, /import devtools from '@choojs\/devtools'/) + assert.match(code, /export default app/) + assert.deepStrictEqual(notes, []) +}) + +test('destructured and property requires', () => { + const src = [ + "const { render } = require('some-lib')", + "const thing = require('other-lib').thing", + "const dflt = require('third-lib').default", + "require('./side-effect')" + ].join('\n') + + const { code } = transform(src) + assert.match(code, /import { render } from 'some-lib'/) + assert.match(code, /import { thing as thing } from 'other-lib'/) + assert.match(code, /import dflt from 'third-lib'/) + assert.match(code, /import '\.\/side-effect'/) +}) + +test('already-ESM sources get specifiers remapped', () => { + const src = [ + "import choo from 'choo'", + "import html from 'nanohtml'", + "import raw from 'nanohtml/raw'", + "import morph from 'nanomorph'", + "import Component from 'nanocomponent'" + ].join('\n') + + const { code } = transform(src) + assert.match(code, /from '@choojs\/core'/) + assert.match(code, /from '@choojs\/html'\n/) + assert.match(code, /from '@choojs\/html\/raw'/) + assert.match(code, /from '@choojs\/html\/morph'/) + assert.match(code, /from '@choojs\/component'/) +}) + +test('retired packages produce notes, not rewrites', () => { + const src = [ + "var nanobus = require('nanobus')", + "var lazyRoute = require('choo-lazy-route')" + ].join('\n') + + const { code, notes } = transform(src) + assert.match(code, /from 'nanobus'/, 'retired specifier left for the human') + assert.ok(notes.some((n) => n.includes('nanobus'))) + assert.ok(notes.some((n) => n.includes('lazy()')), 'choo-lazy-route points at lazy()') +}) + +test('dynamic import() specifiers are remapped too', () => { + const { code } = transform("const mod = await import('choo/html')") + assert.match(code, /import\('@choojs\/html'\)/) +}) + +test('unconvertible CJS is reported honestly', () => { + const src = [ + "module.exports.helper = function () {}", + "function f () { const x = require('choo') }" + ].join('\n') + + const { code, notes } = transform(src) + assert.match(code, /require\('@choojs\/core'\)/, 'specifier remapped even inside functions') + assert.ok(notes.some((n) => n.includes('module.exports'))) + assert.ok(notes.some((n) => n.includes('require() calls remain'))) +}) + +test('non-choo sources pass through untouched', () => { + const src = "import fs from 'node:fs'\nexport const x = 1\n" + const { changed, notes } = transform(src) + assert.strictEqual(changed, false) + assert.deepStrictEqual(notes, []) +})