Skip to content

Commit a0fc62a

Browse files
feat: cjs to esm exporting. (#16)
1 parent 69b05e6 commit a0fc62a

31 files changed

Lines changed: 932 additions & 29 deletions

codecov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ coverage:
77
patch:
88
default:
99
target: 80.0
10-
threshold: 5.0
10+
threshold: 20

docs/cjs-exports.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# CommonJS export discovery
2+
3+
## Supported patterns
4+
5+
- `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.
6+
- Property writes: `exports.foo = x`, `module.exports.bar = y`, and computed literals like `exports['foo']`, `exports[42]`, `module.exports[`template`]`.
7+
- Aliases to the export bag: `const e = exports; e.foo = 1`, `const m = module.exports; m.bar = 2` (alias chains are tracked).
8+
- Destructuring to properties: `({ value: exports.foo } = obj)`.
9+
- `Object.assign(exports, { foo, bar: baz })` and `Object.assign(module.exports, { ... })` with literal keys.
10+
- `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).
11+
12+
## Ignored or intentionally excluded
13+
14+
- 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.
15+
- Symbol keys on exports/module.exports are ignored.
16+
- Patterns that do not resolve to `exports`/`module.exports` or aliases are ignored.
17+
18+
## Emission rules
19+
20+
- We rename the CJS surface to `__exports` and emit ESM bindings from the collected table.
21+
- Default export: `module.exports = id` emits `export default id`; otherwise `export default __exports`.
22+
- Named exports reuse identifiers when available; otherwise we create a temporary binding that reads from `__exports` (using bracket access for non-identifier names).
23+
24+
## Testing strategy
25+
26+
- 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.
27+
- Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import.

package-lock.json

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.0.0-beta.0",
3+
"version": "1.0.0-beta.1",
44
"description": "Transforms differences between ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",
@@ -21,10 +21,10 @@
2121
"imports": {
2222
"#parse": "./src/parse.js",
2323
"#format": "./src/format.js",
24-
"#utils": "./src/utils.js",
24+
"#utils/*.js": "./src/utils/*.js",
2525
"#walk": "./src/walk.js",
26-
"#helpers/*": "./src/helpers/*.js",
27-
"#formatters/*": "./src/formatters/*.js"
26+
"#helpers/*.js": "./src/helpers/*.js",
27+
"#formatters/*.js": "./src/formatters/*.js"
2828
},
2929
"engines": {
3030
"node": ">=20.11.0"

src/format.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ import type { ParseResult } from 'oxc-parser'
22
import type { FormatterOptions, ExportsMeta } from './types.js'
33
import MagicString from 'magic-string'
44

5-
import { identifier } from './formatters/identifier.js'
6-
import { metaProperty } from './formatters/metaProperty.js'
7-
import { memberExpression } from './formatters/memberExpression.js'
8-
import { assignmentExpression } from './formatters/assignmentExpression.js'
9-
import { isValidUrl, exportsRename, collectModuleIdentifiers } from './utils.js'
10-
import { isIdentifierName } from './helpers/identifier.js'
11-
import { ancestorWalk } from './walk.js'
5+
import { identifier } from '#formatters/identifier.js'
6+
import { metaProperty } from '#formatters/metaProperty.js'
7+
import { memberExpression } from '#formatters/memberExpression.js'
8+
import { assignmentExpression } from '#formatters/assignmentExpression.js'
9+
import { isValidUrl } from '#utils/url.js'
10+
import { exportsRename, collectCjsExports } from '#utils/exports.js'
11+
import { collectModuleIdentifiers } from '#utils/identifiers.js'
12+
import { isIdentifierName } from '#helpers/identifier.js'
13+
import { ancestorWalk } from '#walk'
1214

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

2732
if (opts.target === 'module' && opts.transformSyntax) {
@@ -144,6 +149,45 @@ void import.meta.filename;
144149
},
145150
})
146151

152+
if (opts.target === 'module' && opts.transformSyntax && exportTable) {
153+
const isValidExportName = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name)
154+
const asExportName = (name: string) =>
155+
isValidExportName(name) ? name : JSON.stringify(name)
156+
const accessProp = (name: string) =>
157+
isValidExportName(name)
158+
? `${exportsRename}.${name}`
159+
: `${exportsRename}[${JSON.stringify(name)}]`
160+
const tempNameFor = (name: string) => {
161+
const sanitized = name.replace(/[^$\w]/g, '_') || 'value'
162+
const safe = /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized
163+
return `__export_${safe}`
164+
}
165+
166+
const lines: string[] = []
167+
168+
const defaultEntry = exportTable.get('default')
169+
if (defaultEntry) {
170+
const def = defaultEntry.fromIdentifier ?? exportsRename
171+
lines.push(`export default ${def};`)
172+
}
173+
174+
for (const [key, entry] of exportTable) {
175+
if (key === 'default') continue
176+
177+
if (entry.fromIdentifier) {
178+
lines.push(`export { ${entry.fromIdentifier} as ${asExportName(key)} };`)
179+
} else {
180+
const temp = tempNameFor(key)
181+
lines.push(`const ${temp} = ${accessProp(key)};`)
182+
lines.push(`export { ${temp} as ${asExportName(key)} };`)
183+
}
184+
}
185+
186+
if (lines.length) {
187+
code.append(`\n${lines.join('\n')}\n`)
188+
}
189+
}
190+
147191
return code.toString()
148192
}
149193

src/formatters/assignmentExpression.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import MagicString from 'magic-string'
22
import type { Node, AssignmentExpression } from 'oxc-parser'
3-
import { walk } from '../walk.js'
3+
import { walk } from '#walk'
44

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

src/formatters/identifier.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import MagicString from 'magic-string'
22
import type { Node, IdentifierName } from 'oxc-parser'
33

44
import type { FormatterOptions, ExportsMeta } from '../types.js'
5-
import { exportsRename } from '../utils.js'
6-
import { identifier as ident } from '../helpers/identifier.js'
5+
import { exportsRename } from '#utils/exports.js'
6+
import { identifier as ident } from '#helpers/identifier.js'
77

88
type IdentifierArg = {
99
node: IdentifierName

src/formatters/memberExpression.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import MagicString from 'magic-string'
22
import type { MemberExpression, Node } from 'oxc-parser'
33

44
import type { FormatterOptions } from '../types.js'
5-
import { exportsRename } from '../utils.js'
5+
import { exportsRename } from '#utils/exports.js'
66

77
export const memberExpression = (
88
node: MemberExpression,

src/module.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises'
33

44
import { specifier } from '@knighted/specifier'
55

6-
import { parse } from './parse.js'
7-
import { format } from './format.js'
8-
import { getLangFromExt } from './utils.js'
6+
import { parse } from '#parse'
7+
import { format } from '#format'
8+
import { getLangFromExt } from '#utils/lang.js'
99
import type { ModuleOptions } from './types.js'
1010

1111
const defaultOptions = {

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ export type ExportsMeta = {
4141
defaultExportValue: unknown
4242
}
4343

44+
export type CjsExport = {
45+
key: string
46+
writes: SpannedNode[]
47+
fromIdentifier?: string
48+
via: Set<'exports' | 'module.exports'>
49+
reassignments: SpannedNode[]
50+
hasGetter?: boolean
51+
}
52+
4453
export type IdentMeta = {
4554
/*
4655
`var` can be redeclared in the same scope.

0 commit comments

Comments
 (0)