Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Behavior notes (defaults in parentheses)
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.

See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, interop helpers, top-level await behavior, and `import.meta.main` handling.
See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, interop helpers, top-level await behavior, and `import.meta.main` handling. For CommonJS to ESM lowering details, read [docs/cjs-to-esm.md](docs/cjs-to-esm.md).

## Roadmap

Expand Down
1 change: 1 addition & 0 deletions docs/cjs-exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
## Testing strategy

- Fixtures cover each supported pattern: computed literals, alias chains, destructuring, Object.assign fan-out, overwrite-then-augment sequences, and defineProperty/defineProperties (value + getter). Dynamic non-literal keys have a fixture to verify they remain un-exported.
- Alias-chain fixture covers `module.exports` aliases plus subsequent `exports` writes to ensure both surfaces stay connected.
- Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import.
38 changes: 38 additions & 0 deletions docs/cjs-to-esm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# CommonJS to ESM lowering

## Scope

- Rewrites CommonJS modules to ESM when `target: 'module'` with `transformSyntax` enabled.
- Assumes Node 20.11+ runtime with native ESM.
- Skips lowering when `module` or `exports` are shadowed at module scope to avoid mis-compilation.
- Deprecated CJS features (`require.extensions`, `module.parent`, legacy folder-as-module resolution) are left as-is.

## Imports

- Top-level static `require()` (and `module.require()`) calls become hoisted ESM imports:
- `const foo = require('./x')` → `import * as foo from './x'`
- `const foo = module.require('./x')` → `import * as foo from './x'`
- Multi-declarator: `const a = require('./x'), { foo } = require('./y')` → hoisted namespace imports plus destructuring from those namespaces.
- `require('./x')` → `import './x'`
- Dynamic/templated/non-literal `require()` calls (or any require that isn't hoistable) stay as-is and trigger a `createRequire` shim:
- `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`
- Nested or block-scoped `require()` calls also stay under `createRequire` and execute at runtime.
- `require.main` maps to `import.meta.main`; comparisons against `module` are simplified to `import.meta.main` / `!import.meta.main`.
- `require.resolve` is rewritten to `import.meta.resolve` via the member-expression formatter.
- `require.cache` becomes an empty object placeholder; `require.extensions` is not rewritten (deprecated).

## Exports

- `exports`/`module.exports` assignments are rewritten to an internal `__exports` object.
- Export metadata is collected to emit real ESM exports at the end of the file:
- Default export synthesized when `module.exports = ...` is found.
- Named exports emitted for `exports.foo = ...`, `Object.assign(exports, {...})`, `Object.defineProperty`, etc.
- Shadowed `module`/`exports` bindings cause the transform to throw to avoid emitting invalid exports.
- Exports inside simple control flow are collected and re-exported; hoisting preserves the original runtime writes on `__exports`.

## Runtime notes

- `__filename`/`__dirname` become `import.meta.url`/`import.meta.dirname` in ESM output.
- `import.meta.filename` seeding ensures `import.meta` is present even if unused in source.
- Live-binding semantics for exports are preserved to the extent they exist in the source; assignments remain on `__exports` and are re-exported by reference.
- Conditional or runtime-dependent `require()` calls are not hoisted; they continue to execute at runtime under `createRequire`.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"description": "Transforms differences between ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
237 changes: 226 additions & 11 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,119 @@ const exportAssignment = (
const defaultInteropName = '__interopDefault'
const interopHelper = `const ${defaultInteropName} = mod => (mod && mod.__esModule ? mod.default : mod);\n`

const isRequireCallee = (callee: any, shadowed: Set<string>) => {
if (
callee.type === 'Identifier' &&
callee.name === 'require' &&
!shadowed.has('require')
) {
return true
}

if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'module' &&
!shadowed.has('module') &&
callee.property.type === 'Identifier' &&
callee.property.name === 'require'
) {
return true
}

return false
}

const isStaticRequire = (node: any, shadowed: Set<string>) =>
node.type === 'CallExpression' &&
isRequireCallee(node.callee, shadowed) &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal' &&
typeof node.arguments[0].value === 'string'

const isRequireCall = (node: any, shadowed: Set<string>) =>
node.type === 'CallExpression' && isRequireCallee(node.callee, shadowed)

type RequireTransform = {
start: number
end: number
code: string
}

const lowerCjsRequireToImports = (
program: any,
code: MagicString,
shadowed: Set<string>,
) => {
const transforms: RequireTransform[] = []
const imports: string[] = []
let nsIndex = 0
let needsCreateRequire = false

for (const stmt of program.body as any[]) {
if (stmt.type === 'VariableDeclaration') {
const decls = stmt.declarations
const allStatic =
decls.length > 0 &&
decls.every((decl: any) => decl.init && isStaticRequire(decl.init, shadowed))

if (allStatic) {
for (const decl of decls) {
const init = decl.init!
const source = code.slice(init.arguments[0].start, init.arguments[0].end)

if (decl.id.type === 'Identifier') {
imports.push(`import * as ${decl.id.name} from ${source};\n`)
} else if (decl.id.type === 'ObjectPattern') {
const ns = `__cjsImport${nsIndex++}`
const pattern = code.slice(decl.id.start, decl.id.end)
imports.push(`import * as ${ns} from ${source};\n`)
imports.push(`const ${pattern} = ${ns};\n`)
} else {
needsCreateRequire = true
}
}

transforms.push({ start: stmt.start, end: stmt.end, code: ';\n' })
continue
}

for (const decl of decls) {
const init = decl.init
if (init && isRequireCall(init, shadowed)) {
needsCreateRequire = true
}
}
}

if (stmt.type === 'ExpressionStatement') {
const expr = stmt.expression

if (expr && isStaticRequire(expr, shadowed)) {
const source = code.slice(expr.arguments[0].start, expr.arguments[0].end)
imports.push(`import ${source};\n`)
transforms.push({ start: stmt.start, end: stmt.end, code: ';\n' })
continue
}

if (expr && isRequireCall(expr, shadowed)) {
needsCreateRequire = true
}
}
}

return { transforms, imports, needsCreateRequire }
}

const isRequireMainMember = (node: any, shadowed: Set<string>) =>
node &&
node.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
node.object.name === 'require' &&
!shadowed.has('require') &&
node.property.type === 'Identifier' &&
node.property.name === 'main'

const hasTopLevelAwait = (program: any) => {
let found = false

Expand Down Expand Up @@ -320,15 +433,33 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
hasDefaultExportBeenReassigned: false,
hasDefaultExportBeenAssigned: false,
} satisfies ExportsMeta
const moduleIdentifiers = await collectModuleIdentifiers(ast.program)
const shadowedBindings = new Set(
[...moduleIdentifiers.entries()]
.filter(([, meta]) => meta.declare.length > 0)
.map(([name]) => name),
)

if (opts.target === 'module' && opts.transformSyntax) {
if (shadowedBindings.has('module') || shadowedBindings.has('exports')) {
throw new Error(
'Cannot transform to ESM: module or exports is shadowed in module scope.',
)
}
}

const exportTable =
opts.target === 'module' ? await collectCjsExports(ast.program) : null
await collectModuleIdentifiers(ast.program)
const shouldCheckTopLevelAwait = opts.target === 'commonjs' && opts.transformSyntax
const containsTopLevelAwait = shouldCheckTopLevelAwait
? hasTopLevelAwait(ast.program)
: false

const shouldLowerCjs = opts.target === 'commonjs' && opts.transformSyntax
const shouldRaiseEsm = opts.target === 'module' && opts.transformSyntax
let hoistedImports: string[] = []
let pendingRequireTransforms: RequireTransform[] = []
let needsCreateRequire = false
let pendingCjsTransforms: {
transforms: Array<ImportTransform | ExportTransform>
needsInterop: boolean
Expand All @@ -340,21 +471,74 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
)
}

if (opts.target === 'module' && opts.transformSyntax) {
/**
* Prepare ESM output by renaming `exports` to `__exports` and seeding an
* `import.meta.filename` touch so import.meta is present even when the
* original source never referenced it.
*/
code.prepend(`let ${exportsRename} = {};
void import.meta.filename;
`)
if (shouldRaiseEsm) {
const {
transforms,
imports,
needsCreateRequire: reqCreate,
} = lowerCjsRequireToImports(ast.program, code, shadowedBindings)

pendingRequireTransforms = transforms
hoistedImports = imports
needsCreateRequire = reqCreate
}

await ancestorWalk(ast.program, {
async enter(node, ancestors) {
const parent = ancestors[ancestors.length - 2] ?? null

if (shouldRaiseEsm && node.type === 'BinaryExpression') {
const op = node.operator
const isEquality = op === '===' || op === '==' || op === '!==' || op === '!='

if (isEquality) {
const leftMain = isRequireMainMember(node.left, shadowedBindings)
const rightMain = isRequireMainMember(node.right, shadowedBindings)
const leftModule =
node.left.type === 'Identifier' &&
node.left.name === 'module' &&
!shadowedBindings.has('module')
const rightModule =
node.right.type === 'Identifier' &&
node.right.name === 'module' &&
!shadowedBindings.has('module')

if ((leftMain && rightModule) || (rightMain && leftModule)) {
const negate = op === '!==' || op === '!='
code.update(
node.start,
node.end,
negate ? '!import.meta.main' : 'import.meta.main',
)
return
}
}
}

if (
shouldRaiseEsm &&
node.type === 'CallExpression' &&
isRequireCall(node, shadowedBindings)
) {
const isStatic = isStaticRequire(node, shadowedBindings)
const parent = ancestors[ancestors.length - 2] ?? null
const grandparent = ancestors[ancestors.length - 3] ?? null
const greatGrandparent = ancestors[ancestors.length - 4] ?? null

// Hoistable cases are handled separately and don't need createRequire.
const topLevelExprStmt =
parent?.type === 'ExpressionStatement' && grandparent?.type === 'Program'
const topLevelVarDecl =
parent?.type === 'VariableDeclarator' &&
grandparent?.type === 'VariableDeclaration' &&
greatGrandparent?.type === 'Program'
const hoistableTopLevel = isStatic && (topLevelExprStmt || topLevelVarDecl)

if (!isStatic || !hoistableTopLevel) {
needsCreateRequire = true
}
}

if (
node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
Expand Down Expand Up @@ -445,7 +629,7 @@ void import.meta.filename;
}

if (node.type === 'MemberExpression') {
memberExpression(node, parent, code, opts)
memberExpression(node, parent, code, opts, shadowedBindings)
}

if (isIdentifierName(node)) {
Expand All @@ -455,11 +639,18 @@ void import.meta.filename;
code,
opts,
meta: exportsMeta,
shadowed: shadowedBindings,
})
}
},
})

if (pendingRequireTransforms.length) {
for (const t of pendingRequireTransforms) {
code.overwrite(t.start, t.end, t.code)
}
}

if (shouldLowerCjs) {
const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs(
ast.program,
Expand Down Expand Up @@ -525,6 +716,30 @@ void import.meta.filename;
}
}

if (shouldRaiseEsm && opts.transformSyntax) {
const importPrelude: string[] = []

if (needsCreateRequire) {
importPrelude.push('import { createRequire } from "node:module";\n')
}

if (hoistedImports.length) {
importPrelude.push(...hoistedImports)
}

const requireInit = needsCreateRequire
? 'const require = createRequire(import.meta.url);\n'
: ''

const prelude = `${importPrelude.join('')}${
importPrelude.length ? '\n' : ''
}${requireInit}let ${exportsRename} = {};
void import.meta.filename;
`

code.prepend(prelude)
}

if (opts.target === 'commonjs' && opts.transformSyntax && containsTopLevelAwait) {
const body = code.toString()

Expand Down
Loading