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 codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ coverage:
patch:
default:
target: 80.0
threshold: 5.0
threshold: 20
27 changes: 27 additions & 0 deletions docs/cjs-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# CommonJS export discovery

## Supported patterns

- `module.exports = value` → captured as default; if assigned an identifier, we re-use it, otherwise we export the `__exports` bag. Overwrite-then-augment flows like `module.exports = fn; module.exports.extra = 1` are supported.
- Property writes: `exports.foo = x`, `module.exports.bar = y`, and computed literals like `exports['foo']`, `exports[42]`, `module.exports[`template`]`.
- Aliases to the export bag: `const e = exports; e.foo = 1`, `const m = module.exports; m.bar = 2` (alias chains are tracked).
- Destructuring to properties: `({ value: exports.foo } = obj)`.
- `Object.assign(exports, { foo, bar: baz })` and `Object.assign(module.exports, { ... })` with literal keys.
- `Object.defineProperty` / `Object.defineProperties` on exports/module.exports (aliases included) with literal keys; value and getter descriptors are collected. Getter-based exports are emitted via a proxy read (best-effort, not true ESM live bindings).

## Ignored or intentionally excluded

- Non-literal computed keys (e.g., `exports[key()] = x`) are ignored for static export emission; they may still exist on the runtime bag but no ESM binding is generated.
- Symbol keys on exports/module.exports are ignored.
- Patterns that do not resolve to `exports`/`module.exports` or aliases are ignored.

## Emission rules

- We rename the CJS surface to `__exports` and emit ESM bindings from the collected table.
- Default export: `module.exports = id` emits `export default id`; otherwise `export default __exports`.
- Named exports reuse identifiers when available; otherwise we create a temporary binding that reads from `__exports` (using bracket access for non-identifier names).

## 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.
- Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import.
5 changes: 2 additions & 3 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.1",
"description": "Transforms differences between ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand All @@ -21,10 +21,10 @@
"imports": {
"#parse": "./src/parse.js",
"#format": "./src/format.js",
"#utils": "./src/utils.js",
"#utils/*.js": "./src/utils/*.js",
"#walk": "./src/walk.js",
"#helpers/*": "./src/helpers/*.js",
"#formatters/*": "./src/formatters/*.js"
"#helpers/*.js": "./src/helpers/*.js",
"#formatters/*.js": "./src/formatters/*.js"
},
"engines": {
"node": ">=20.11.0"
Expand Down
62 changes: 53 additions & 9 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import type { ParseResult } from 'oxc-parser'
import type { FormatterOptions, ExportsMeta } from './types.js'
import MagicString from 'magic-string'

import { identifier } from './formatters/identifier.js'
import { metaProperty } from './formatters/metaProperty.js'
import { memberExpression } from './formatters/memberExpression.js'
import { assignmentExpression } from './formatters/assignmentExpression.js'
import { isValidUrl, exportsRename, collectModuleIdentifiers } from './utils.js'
import { isIdentifierName } from './helpers/identifier.js'
import { ancestorWalk } from './walk.js'
import { identifier } from '#formatters/identifier.js'
import { metaProperty } from '#formatters/metaProperty.js'
import { memberExpression } from '#formatters/memberExpression.js'
import { assignmentExpression } from '#formatters/assignmentExpression.js'
import { isValidUrl } from '#utils/url.js'
import { exportsRename, collectCjsExports } from '#utils/exports.js'
import { collectModuleIdentifiers } from '#utils/identifiers.js'
import { isIdentifierName } from '#helpers/identifier.js'
import { ancestorWalk } from '#walk'

/**
* Note, there is no specific conversion for `import.meta.main` as it does not exist.
* @see https://github.com/nodejs/node/issues/49440
* Node added support for import.meta.main.
* Added in: v24.2.0, v22.18.0
* @see https://nodejs.org/api/esm.html#importmetamain
*/
const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => {
const code = new MagicString(src)
Expand All @@ -22,6 +25,8 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
hasDefaultExportBeenReassigned: false,
hasDefaultExportBeenAssigned: false,
} satisfies ExportsMeta
const exportTable =
opts.target === 'module' ? await collectCjsExports(ast.program) : null
await collectModuleIdentifiers(ast.program)

if (opts.target === 'module' && opts.transformSyntax) {
Expand Down Expand Up @@ -144,6 +149,45 @@ void import.meta.filename;
},
})

if (opts.target === 'module' && opts.transformSyntax && exportTable) {
const isValidExportName = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name)
const asExportName = (name: string) =>
isValidExportName(name) ? name : JSON.stringify(name)
const accessProp = (name: string) =>
isValidExportName(name)
? `${exportsRename}.${name}`
: `${exportsRename}[${JSON.stringify(name)}]`
const tempNameFor = (name: string) => {
const sanitized = name.replace(/[^$\w]/g, '_') || 'value'
const safe = /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized
return `__export_${safe}`
}

const lines: string[] = []

const defaultEntry = exportTable.get('default')
if (defaultEntry) {
const def = defaultEntry.fromIdentifier ?? exportsRename
lines.push(`export default ${def};`)
}

for (const [key, entry] of exportTable) {
if (key === 'default') continue

if (entry.fromIdentifier) {
lines.push(`export { ${entry.fromIdentifier} as ${asExportName(key)} };`)
} else {
const temp = tempNameFor(key)
lines.push(`const ${temp} = ${accessProp(key)};`)
lines.push(`export { ${temp} as ${asExportName(key)} };`)
}
}

if (lines.length) {
code.append(`\n${lines.join('\n')}\n`)
}
}

return code.toString()
}

Expand Down
2 changes: 1 addition & 1 deletion src/formatters/assignmentExpression.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import MagicString from 'magic-string'
import type { Node, AssignmentExpression } from 'oxc-parser'
import { walk } from '../walk.js'
import { walk } from '#walk'

import type { FormatterOptions, ExportsMeta } from '../types.js'

Expand Down
4 changes: 2 additions & 2 deletions src/formatters/identifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import MagicString from 'magic-string'
import type { Node, IdentifierName } from 'oxc-parser'

import type { FormatterOptions, ExportsMeta } from '../types.js'
import { exportsRename } from '../utils.js'
import { identifier as ident } from '../helpers/identifier.js'
import { exportsRename } from '#utils/exports.js'
import { identifier as ident } from '#helpers/identifier.js'

type IdentifierArg = {
node: IdentifierName
Expand Down
2 changes: 1 addition & 1 deletion src/formatters/memberExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import MagicString from 'magic-string'
import type { MemberExpression, Node } from 'oxc-parser'

import type { FormatterOptions } from '../types.js'
import { exportsRename } from '../utils.js'
import { exportsRename } from '#utils/exports.js'

export const memberExpression = (
node: MemberExpression,
Expand Down
6 changes: 3 additions & 3 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises'

import { specifier } from '@knighted/specifier'

import { parse } from './parse.js'
import { format } from './format.js'
import { getLangFromExt } from './utils.js'
import { parse } from '#parse'
import { format } from '#format'
import { getLangFromExt } from '#utils/lang.js'
import type { ModuleOptions } from './types.js'

const defaultOptions = {
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export type ExportsMeta = {
defaultExportValue: unknown
}

export type CjsExport = {
key: string
writes: SpannedNode[]
fromIdentifier?: string
via: Set<'exports' | 'module.exports'>
reassignments: SpannedNode[]
hasGetter?: boolean
}

export type IdentMeta = {
/*
`var` can be redeclared in the same scope.
Expand Down
90 changes: 89 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ancestorWalk } from './walk.js'
import type { Node } from 'oxc-parser'
import type { Specifier } from '@knighted/specifier'

import type { IdentMeta, SpannedNode, Scope } from './types.js'
import type { IdentMeta, SpannedNode, Scope, CjsExport } from './types.js'
import { scopes as scopeNodes } from './helpers/scope.js'
import { identifier } from './helpers/identifier.js'

Expand Down Expand Up @@ -39,6 +39,93 @@ const isValidUrl = (url: string) => {
const exportsRename = '__exports'
const requireMainRgx = /(require\.main\s*===\s*module|module\s*===\s*require\.main)/g

const resolveExportTarget = (node: Node) => {
if (node.type !== 'MemberExpression') return null

const base = node.object
const prop = node.property

if (prop.type !== 'Identifier') return null

if (base.type === 'Identifier' && base.name === 'exports') {
return { key: prop.name, via: 'exports' as const }
}

if (
base.type === 'MemberExpression' &&
base.object.type === 'Identifier' &&
base.object.name === 'module' &&
base.property.type === 'Identifier' &&
base.property.name === 'exports'
) {
return { key: prop.name, via: 'module.exports' as const }
}

if (
base.type === 'Identifier' &&
base.name === 'module' &&
prop.type === 'Identifier' &&
prop.name === 'exports'
) {
return { key: 'default', via: 'module.exports' as const }
}

return null
}

const collectCjsExports = async (ast: Node) => {
const exportsMap = new Map<string, CjsExport>()
const localToExport = new Map<string, Set<string>>()

await ancestorWalk(ast, {
enter(node) {
if (node.type === 'AssignmentExpression') {
const target = resolveExportTarget(node.left)

if (target) {
const entry = exportsMap.get(target.key) ?? {
key: target.key,
writes: [],
via: new Set(),
reassignments: [],
}

entry.via.add(target.via)
entry.writes.push(node)

if (node.right.type === 'Identifier') {
entry.fromIdentifier ??= node.right.name
if (entry.fromIdentifier) {
const set = localToExport.get(entry.fromIdentifier) ?? new Set<string>()
set.add(target.key)
localToExport.set(entry.fromIdentifier, set)
}
}

exportsMap.set(target.key, entry)
return
}

if (node.left.type === 'Identifier') {
const keys = localToExport.get(node.left.name)

if (keys) {
keys.forEach(key => {
const entry = exportsMap.get(key)
if (entry) {
entry.reassignments.push(node)
exportsMap.set(key, entry)
}
})
}
}
}
},
})

return exportsMap
}

const collectScopeIdentifiers = (node: Node, scopes: Scope[]) => {
const { type } = node

Expand Down Expand Up @@ -246,6 +333,7 @@ export {
isValidUrl,
collectScopeIdentifiers,
collectModuleIdentifiers,
collectCjsExports,
exportsRename,
requireMainRgx,
}
Loading