Skip to content

Commit 939492e

Browse files
feat: emit idiomatic exports for module.exports object literals.
1 parent fde15f4 commit 939492e

8 files changed

Lines changed: 151 additions & 25 deletions

File tree

docs/cli.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ dub [options] <files...>
2323

2424
Examples:
2525

26-
- Transform CJS to ESM into an output directory:
26+
- Transform CJS to ESM into an output directory (mirrors input paths inside the dir):
2727

2828
```bash
2929
dub -t module src/**/*.cjs --out-dir dist
@@ -47,6 +47,12 @@ Examples:
4747
cat input.cjs | dub -t module --stdin-filename input.cjs > output.mjs
4848
```
4949

50+
- Single-file rename via stdout redirect (CLI has no "out file" flag; `--out-dir` always mirrors inputs):
51+
52+
```bash
53+
dub -t module src/some.cjs > src/some.mjs
54+
```
55+
5056
## Options
5157

5258
Short and long forms are supported.
@@ -83,5 +89,5 @@ Short and long forms are supported.
8389
Notes:
8490

8591
- When reading from stdin, output is sent to stdout; `--out-dir` or `--in-place` are not allowed in that mode.
86-
- Specify either `--out-dir` or `--in-place` for file inputs; stdout is used only when a single file is given and neither flag is set.
92+
- Specify either `--out-dir` or `--in-place` for file inputs; stdout is used only when a single file is given and neither flag is set. `--out-dir` always mirrors the input path under that directory (no single-file rename flag). Use stdout redirection if you need to rename one file.
8793
- Diagnostics are printed to stderr; use `--json` for machine-readable output.

docs/roadmap.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ Status: draft
44

55
## Idiomatic Exports
66

7-
Shipped: `idiomaticExports: 'safe'` is now the default for CJS → ESM, with fallback to the helper bag plus diagnostics when unsafe.
7+
Shipped: `idiomaticExports: 'safe'` is now the default for CJS → ESM, with fallback to the helper bag plus diagnostics when unsafe. Auto lifts simple `module.exports = { foo, bar }` object literals to idiomatic exports when safe.
88

99
Next:
1010

1111
- Explore a true `'aggressive'` mode (mixed exports/module.exports, limited reassignments, identifier-safe computed keys) with guarded semantics and explicit diagnostics.
12-
- In `'auto'`, allow idiomatic `module.exports = { foo, bar }` when the object literal is simple: single top-level assignment, no spreads/getters/computed/duplicate keys, only identifier keys, RHS values limited to safe literals/identifiers/function|class expressions, no `require()` inside RHS, and no `__proto__`/`prototype` keys. Add a shorthand-object fixture test and keep falling back to the helper for anything more complex.
1312
- Consider a constrained ESM → CJS “pretty” path where live-binding and TLA semantics permit it.
1413

1514
## CLI

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.3.0-rc.0",
3+
"version": "1.3.0-rc.1",
44
"description": "Bidirectional transform for ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/format.ts

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,7 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
740740
if (entry.reassignments.length) return { ok: false, reason: 'reassignment' }
741741
if (entry.hasNonTopLevelWrite) return { ok: false, reason: 'non-top-level' }
742742
if (entry.writes.length !== 1) return { ok: false, reason: 'multiple-writes' }
743-
if (!isValidExportName(entry.key))
743+
if (entry.key !== 'default' && !isValidExportName(entry.key))
744744
return { ok: false, reason: 'non-identifier-key' }
745745
}
746746

@@ -759,6 +759,52 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
759759
.replace(/\b__filename\b/g, 'import.meta.filename')
760760
}
761761

762+
const tryObjectLiteralExport = (
763+
rhs: Node,
764+
baseIsModuleExports: boolean,
765+
propName: string,
766+
) => {
767+
if (!baseIsModuleExports || propName !== 'exports') return null
768+
if (rhs.type !== 'ObjectExpression') return null
769+
770+
const exportsOut: string[] = []
771+
const seenKeys = new Set<string>()
772+
773+
for (const prop of rhs.properties) {
774+
if (prop.type !== 'Property') return null
775+
if (prop.kind !== 'init') return null
776+
if (prop.computed || prop.method) return null
777+
778+
if (prop.key.type !== 'Identifier') return null
779+
const key = prop.key.name
780+
781+
if (key === '__proto__' || key === 'prototype') return null
782+
if (!isValidExportName(key)) return null
783+
if (seenKeys.has(key)) return null
784+
785+
const value =
786+
prop.value.type === 'Identifier' && prop.shorthand ? prop.key : prop.value
787+
788+
if (!isAllowedRhs(value)) return null
789+
if (expressionHasRequireCall(value, requireShadowed)) return null
790+
791+
const rhsSrc = rhsSourceFor(value)
792+
if (value.type === 'Identifier' && value.name === key) {
793+
exportsOut.push(`export { ${key} };`)
794+
} else if (value.type === 'Identifier') {
795+
exportsOut.push(`export { ${rhsSrc} as ${key} };`)
796+
} else {
797+
exportsOut.push(`export const ${key} = ${rhsSrc};`)
798+
}
799+
800+
seenKeys.add(key)
801+
}
802+
803+
exportsOut.push(`export default ${rhsSourceFor(rhs)};`)
804+
805+
return { exportsOut, seenKeys }
806+
}
807+
762808
for (const entry of entries) {
763809
const write = entry.writes[0]
764810
if (write.type !== 'AssignmentExpression') {
@@ -778,28 +824,45 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
778824
const propName = left.property.name
779825
const baseIsExports = base.type === 'Identifier' && base.name === 'exports'
780826
const baseIsModuleExports =
781-
base.type === 'MemberExpression' &&
782-
base.object.type === 'Identifier' &&
783-
base.object.name === 'module' &&
784-
base.property.type === 'Identifier' &&
785-
base.property.name === 'exports'
827+
(base.type === 'Identifier' &&
828+
base.name === 'module' &&
829+
propName === 'exports') ||
830+
(base.type === 'MemberExpression' &&
831+
base.object.type === 'Identifier' &&
832+
base.object.name === 'module' &&
833+
base.property.type === 'Identifier' &&
834+
base.property.name === 'exports')
786835

787836
if (!baseIsExports && !baseIsModuleExports) {
788837
return { ok: false, reason: 'unsupported-base' }
789838
}
790839

791840
const rhs = write.right
792-
if (!isAllowedRhs(rhs)) return { ok: false, reason: 'unsupported-rhs' }
793-
if (expressionHasRequireCall(rhs, requireShadowed)) {
794-
return { ok: false, reason: 'rhs-require' }
841+
const objectLiteralPlan = tryObjectLiteralExport(
842+
rhs,
843+
baseIsModuleExports,
844+
propName,
845+
)
846+
if (!objectLiteralPlan) {
847+
if (!isAllowedRhs(rhs)) return { ok: false, reason: 'unsupported-rhs' }
848+
if (expressionHasRequireCall(rhs, requireShadowed)) {
849+
return { ok: false, reason: 'rhs-require' }
850+
}
795851
}
796852

797853
const rhsSrc = rhsSourceFor(rhs)
798854
if (propName === 'exports' && baseIsModuleExports) {
799-
// module.exports = ... handles default
800-
if (seen.has('default')) return { ok: false, reason: 'duplicate-default' }
801-
seen.add('default')
802-
exportsOut.push(`export default ${rhsSrc};`)
855+
if (objectLiteralPlan) {
856+
for (const line of objectLiteralPlan.exportsOut) {
857+
exportsOut.push(line)
858+
}
859+
objectLiteralPlan.seenKeys.forEach(k => seen.add(k))
860+
} else {
861+
// module.exports = ... handles default
862+
if (seen.has('default')) return { ok: false, reason: 'duplicate-default' }
863+
seen.add('default')
864+
exportsOut.push(`export default ${rhsSrc};`)
865+
}
803866
} else {
804867
if (seen.has(propName)) return { ok: false, reason: 'duplicate-key' }
805868
seen.add(propName)
@@ -1162,11 +1225,14 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
11621225
code.overwrite(rep.start, rep.end, idiomaticPlan!.exports[idx])
11631226
})
11641227
} else {
1165-
for (const rep of idiomaticPlan.replacements) {
1166-
code.overwrite(rep.start, rep.end, ';')
1228+
const [first, ...rest] = idiomaticPlan.replacements
1229+
if (first) {
1230+
code.overwrite(first.start, first.end, idiomaticPlan.exports.join('\n'))
11671231
}
1168-
if (idiomaticPlan.exports.length) {
1169-
code.append(`\n${idiomaticPlan.exports.join('\n')}\n`)
1232+
for (const rep of rest) {
1233+
const original = code.slice(rep.start, rep.end)
1234+
const hasSemicolon = original.trimEnd().endsWith(';')
1235+
code.overwrite(rep.start, rep.end, hasSemicolon ? ';' : '')
11701236
}
11711237
}
11721238
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
['x']: 1,
3+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const foo = 1
2+
const bar = () => 'bar'
3+
const Baz = class {
4+
value() {
5+
return 'baz'
6+
}
7+
}
8+
9+
module.exports = { foo, bar, Baz }

test/module.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,49 @@ describe('@knighted/module', () => {
207207
})
208208
})
209209

210+
it('emits idiomatic exports for simple module.exports object literal', async t => {
211+
const fixturePath = join(fixtures, 'exportsObjectShorthand.cjs')
212+
const result = await transform(fixturePath, {
213+
target: 'module',
214+
})
215+
const outFile = join(fixtures, 'exportsObjectShorthand.mjs')
216+
217+
t.after(() => {
218+
rm(outFile, { force: true })
219+
})
220+
221+
await writeFile(outFile, result)
222+
223+
assert.ok(!result.includes('__exports'))
224+
assert.ok(result.includes('export { foo };'))
225+
assert.ok(result.includes('export { bar };'))
226+
assert.ok(
227+
result.includes('export { Baz };') || result.includes('export const Baz = class'),
228+
)
229+
230+
const mod = await import(pathToFileURL(outFile).href)
231+
assert.equal((mod as any).foo, 1)
232+
assert.equal((mod as any).bar(), 'bar')
233+
const baz = new (mod as any).Baz()
234+
assert.equal(baz.value(), 'baz')
235+
})
236+
237+
it('falls back to helper for unsupported object literal keys', async t => {
238+
const fixturePath = join(fixtures, 'exportsObjectComputed.cjs')
239+
const result = await transform(fixturePath, { target: 'module' })
240+
const outFile = join(fixtures, 'exportsObjectComputed.mjs')
241+
242+
t.after(() => {
243+
rm(outFile, { force: true })
244+
})
245+
246+
await writeFile(outFile, result)
247+
248+
assert.ok(result.includes('__exports'))
249+
const mod = await import(pathToFileURL(outFile).href)
250+
assert.equal((mod as any).default.x, 1)
251+
})
252+
210253
it('rewrites multi-declarator static require to imports when lowering to esm', async t => {
211254
const fixturePath = join(fixtures, 'requireMulti.cjs')
212255
const outFile = join(fixtures, 'requireMulti.mjs')
@@ -1006,7 +1049,7 @@ describe('@knighted/module', () => {
10061049
assert.equal(!/\sexports\s/.test(result), true)
10071050
assert.equal(result.indexOf('require.cache'), -1)
10081051
assert.ok(/import\.meta/.test(result))
1009-
assert.ok(result.indexOf('{}') > -1)
1052+
assert.ok(result.indexOf('export default {') > -1)
10101053

10111054
const { status } = spawnSync('node', [outFile], { stdio: 'inherit' })
10121055
assert.equal(status, 0)

0 commit comments

Comments
 (0)