Skip to content

Commit 04efe64

Browse files
fix: cjs to esm gaps. (#26)
1 parent 68ad163 commit 04efe64

28 files changed

Lines changed: 957 additions & 107 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ type ModuleOptions = {
101101
sourceType?: 'auto' | 'module' | 'commonjs'
102102
transformSyntax?: boolean
103103
liveBindings?: 'strict' | 'loose' | 'off'
104+
appendJsExtension?: 'off' | 'relative-only' | 'all'
105+
appendDirectoryIndex?: string | false
104106
rewriteSpecifier?:
105107
| '.js'
106108
| '.mjs'
@@ -112,6 +114,8 @@ type ModuleOptions = {
112114
dirFilename?: 'inject' | 'preserve' | 'error'
113115
importMeta?: 'preserve' | 'shim' | 'error'
114116
importMetaMain?: 'shim' | 'warn' | 'error'
117+
requireMainStrategy?: 'import-meta-main' | 'realpath'
118+
detectCircularRequires?: 'off' | 'warn' | 'error'
115119
requireSource?: 'builtin' | 'create-require'
116120
cjsDefault?: 'module-exports' | 'auto' | 'none'
117121
topLevelAwait?: 'error' | 'wrap' | 'preserve'
@@ -125,16 +129,23 @@ Behavior notes (defaults in parentheses)
125129
- `target` (`commonjs`): output module system.
126130
- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass.
127131
- `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`).
132+
- `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers.
133+
- `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable).
128134
- `dirFilename` (`inject`): inject `__dirname`/`__filename`, preserve existing, or throw.
129135
- `importMeta` (`shim`): rewrite `import.meta.*` to CommonJS equivalents.
130136
- `importMetaMain` (`shim`): gate `import.meta.main` with shimming/warning/error when Node support is too old.
137+
- `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
138+
- `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw.
131139
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
132140
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback.
133141
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
134142
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
135143
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
136144
- CommonJS → ESM lowering will throw on `with` statements and unshadowed `eval` calls to avoid unsound rewrites.
137145
146+
> [!NOTE]
147+
> Package-level metadata (`package.json` updates such as setting `"type": "module"` or authoring `exports`) is not edited by this tool today; plan that change outside the per-file transform.
148+
138149
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).
139150
140151
> [!NOTE]

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.0.0-rc.2",
3+
"version": "1.0.0-rc.3",
44
"description": "Transforms differences between ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/format.ts

Lines changed: 167 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const exportAssignment = (
2929

3030
const defaultInteropName = '__interopDefault'
3131
const interopHelper = `const ${defaultInteropName} = mod => (mod && mod.__esModule ? mod.default : mod);\n`
32+
const requireInteropName = '__requireDefault'
33+
const requireInteropHelper = `const ${requireInteropName} = mod => (mod && typeof mod === 'object' && 'default' in mod ? mod.default : mod);\n`
3234

3335
const isRequireCallee = (callee: any, shadowed: Set<string>) => {
3436
if (
@@ -76,8 +78,15 @@ const lowerCjsRequireToImports = (
7678
) => {
7779
const transforms: RequireTransform[] = []
7880
const imports: string[] = []
81+
const hoisted: string[] = []
7982
let nsIndex = 0
8083
let needsCreateRequire = false
84+
let needsInteropHelper = false
85+
86+
const isJsonSpecifier = (value: string) => {
87+
const base = value.split(/[?#]/)[0] ?? value
88+
return base.endsWith('.json')
89+
}
8190

8291
for (const stmt of program.body as any[]) {
8392
if (stmt.type === 'VariableDeclaration') {
@@ -89,15 +98,43 @@ const lowerCjsRequireToImports = (
8998
if (allStatic) {
9099
for (const decl of decls) {
91100
const init = decl.init!
92-
const source = code.slice(init.arguments[0].start, init.arguments[0].end)
101+
const arg = init.arguments[0]
102+
const source = code.slice(arg.start, arg.end)
103+
const value = (arg as any).value as string
104+
const isJson = typeof value === 'string' && isJsonSpecifier(value)
105+
106+
const ns = `__cjsImport${nsIndex++}`
107+
108+
const jsonImport = isJson ? `${source} with { type: "json" }` : source
93109

94110
if (decl.id.type === 'Identifier') {
95-
imports.push(`import * as ${decl.id.name} from ${source};\n`)
96-
} else if (decl.id.type === 'ObjectPattern') {
97-
const ns = `__cjsImport${nsIndex++}`
111+
imports.push(
112+
isJson
113+
? `import ${ns} from ${jsonImport};\n`
114+
: `import * as ${ns} from ${jsonImport};\n`,
115+
)
116+
hoisted.push(
117+
isJson
118+
? `const ${decl.id.name} = ${ns};\n`
119+
: `const ${decl.id.name} = ${requireInteropName}(${ns});\n`,
120+
)
121+
needsInteropHelper ||= !isJson
122+
} else if (
123+
decl.id.type === 'ObjectPattern' ||
124+
decl.id.type === 'ArrayPattern'
125+
) {
98126
const pattern = code.slice(decl.id.start, decl.id.end)
99-
imports.push(`import * as ${ns} from ${source};\n`)
100-
imports.push(`const ${pattern} = ${ns};\n`)
127+
imports.push(
128+
isJson
129+
? `import ${ns} from ${jsonImport};\n`
130+
: `import * as ${ns} from ${jsonImport};\n`,
131+
)
132+
hoisted.push(
133+
isJson
134+
? `const ${pattern} = ${ns};\n`
135+
: `const ${pattern} = ${requireInteropName}(${ns});\n`,
136+
)
137+
needsInteropHelper ||= !isJson
101138
} else {
102139
needsCreateRequire = true
103140
}
@@ -119,8 +156,14 @@ const lowerCjsRequireToImports = (
119156
const expr = stmt.expression
120157

121158
if (expr && isStaticRequire(expr, shadowed)) {
122-
const source = code.slice(expr.arguments[0].start, expr.arguments[0].end)
123-
imports.push(`import ${source};\n`)
159+
const arg = expr.arguments[0]
160+
const source = code.slice(arg.start, arg.end)
161+
const value = (arg as any).value as string
162+
const isJson = typeof value === 'string' && isJsonSpecifier(value)
163+
164+
const jsonImport = isJson ? `${source} with { type: "json" }` : source
165+
166+
imports.push(`import ${jsonImport};\n`)
124167
transforms.push({ start: stmt.start, end: stmt.end, code: ';\n' })
125168
continue
126169
}
@@ -131,7 +174,7 @@ const lowerCjsRequireToImports = (
131174
}
132175
}
133176

134-
return { transforms, imports, needsCreateRequire }
177+
return { transforms, imports, hoisted, needsCreateRequire, needsInteropHelper }
135178
}
136179

137180
const isRequireMainMember = (node: any, shadowed: Set<string>) =>
@@ -187,6 +230,26 @@ const hasTopLevelAwait = (program: any) => {
187230
return found
188231
}
189232

233+
const isAsyncContext = (ancestors: any[]) => {
234+
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
235+
const node = ancestors[i]
236+
if (
237+
node.type === 'FunctionDeclaration' ||
238+
node.type === 'FunctionExpression' ||
239+
node.type === 'ArrowFunctionExpression'
240+
) {
241+
return !!node.async
242+
}
243+
244+
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
245+
return false
246+
}
247+
}
248+
249+
// Program scope (top-level) supports await in ESM.
250+
return true
251+
}
252+
190253
const lowerEsmToCjs = (
191254
program: any,
192255
code: MagicString,
@@ -454,12 +517,18 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
454517
const containsTopLevelAwait = shouldCheckTopLevelAwait
455518
? hasTopLevelAwait(ast.program)
456519
: false
520+
const requireMainStrategy = opts.requireMainStrategy ?? 'import-meta-main'
521+
let requireMainNeedsRealpath = false
522+
let needsRequireResolveHelper = false
523+
const nestedRequireStrategy = opts.nestedRequireStrategy ?? 'create-require'
457524

458525
const shouldLowerCjs = opts.target === 'commonjs' && opts.transformSyntax
459526
const shouldRaiseEsm = opts.target === 'module' && opts.transformSyntax
460527
let hoistedImports: string[] = []
528+
let hoistedStatements: string[] = []
461529
let pendingRequireTransforms: RequireTransform[] = []
462530
let needsCreateRequire = false
531+
let needsImportInterop = false
463532
let pendingCjsTransforms: {
464533
transforms: Array<ImportTransform | ExportTransform>
465534
needsInterop: boolean
@@ -475,12 +544,16 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
475544
const {
476545
transforms,
477546
imports,
547+
hoisted,
478548
needsCreateRequire: reqCreate,
549+
needsInteropHelper: reqInteropHelper,
479550
} = lowerCjsRequireToImports(ast.program, code, shadowedBindings)
480551

481552
pendingRequireTransforms = transforms
482553
hoistedImports = imports
554+
hoistedStatements = hoisted
483555
needsCreateRequire = reqCreate
556+
needsImportInterop = reqInteropHelper
484557
}
485558

486559
await ancestorWalk(ast.program, {
@@ -505,11 +578,14 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
505578

506579
if ((leftMain && rightModule) || (rightMain && leftModule)) {
507580
const negate = op === '!==' || op === '!='
508-
code.update(
509-
node.start,
510-
node.end,
511-
negate ? '!import.meta.main' : 'import.meta.main',
512-
)
581+
const mainExpr =
582+
requireMainStrategy === 'import-meta-main'
583+
? 'import.meta.main'
584+
: 'import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href'
585+
if (requireMainStrategy === 'realpath') {
586+
requireMainNeedsRealpath = true
587+
}
588+
code.update(node.start, node.end, negate ? `!(${mainExpr})` : mainExpr)
513589
return
514590
}
515591
}
@@ -549,6 +625,24 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
549625
const hoistableTopLevel = isStatic && (topLevelExprStmt || topLevelVarDecl)
550626

551627
if (!isStatic || !hoistableTopLevel) {
628+
if (nestedRequireStrategy === 'dynamic-import') {
629+
const asyncCapable = isAsyncContext(ancestors)
630+
631+
if (asyncCapable) {
632+
const arg = node.arguments[0]
633+
const argSrc = arg ? code.slice(arg.start, arg.end) : 'undefined'
634+
const literalVal = (arg as any)?.value
635+
const isJson =
636+
arg?.type === 'Literal' &&
637+
typeof literalVal === 'string' &&
638+
(literalVal.split(/[?#]/)[0] ?? literalVal).endsWith('.json')
639+
const importTarget = isJson ? `${argSrc} with { type: "json" }` : argSrc
640+
641+
code.update(node.start, node.end, `(await import(${importTarget}))`)
642+
return
643+
}
644+
}
645+
552646
needsCreateRequire = true
553647
}
554648
}
@@ -643,7 +737,31 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
643737
}
644738

645739
if (node.type === 'MemberExpression') {
646-
memberExpression(node, parent, code, opts, shadowedBindings)
740+
memberExpression(node, parent, code, opts, shadowedBindings, {
741+
onRequireResolve: () => {
742+
if (shouldRaiseEsm) needsRequireResolveHelper = true
743+
},
744+
requireResolveName: '__requireResolve',
745+
})
746+
}
747+
748+
if (shouldRaiseEsm && node.type === 'ThisExpression') {
749+
const bindsThis = (ancestor: any) => {
750+
return (
751+
ancestor.type === 'FunctionDeclaration' ||
752+
ancestor.type === 'FunctionExpression' ||
753+
ancestor.type === 'ClassDeclaration' ||
754+
ancestor.type === 'ClassExpression'
755+
)
756+
}
757+
758+
const bindingAncestor = ancestors.find(ancestor => bindsThis(ancestor))
759+
const isTopLevel = !bindingAncestor
760+
761+
if (isTopLevel) {
762+
code.update(node.start, node.end, exportsRename)
763+
return
764+
}
647765
}
648766

649767
if (isIdentifierName(node)) {
@@ -733,21 +851,53 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
733851
if (shouldRaiseEsm && opts.transformSyntax) {
734852
const importPrelude: string[] = []
735853

736-
if (needsCreateRequire) {
854+
if (needsCreateRequire || needsRequireResolveHelper) {
737855
importPrelude.push('import { createRequire } from "node:module";\n')
738856
}
739857

858+
if (needsRequireResolveHelper) {
859+
importPrelude.push('import { fileURLToPath } from "node:url";\n')
860+
}
861+
862+
if (requireMainNeedsRealpath) {
863+
importPrelude.push('import { realpathSync } from "node:fs";\n')
864+
importPrelude.push('import { pathToFileURL } from "node:url";\n')
865+
}
866+
740867
if (hoistedImports.length) {
741868
importPrelude.push(...hoistedImports)
742869
}
743870

871+
const setupPrelude: string[] = []
872+
873+
if (needsImportInterop) {
874+
setupPrelude.push(requireInteropHelper)
875+
}
876+
877+
if (hoistedStatements.length) {
878+
setupPrelude.push(...hoistedStatements)
879+
}
880+
744881
const requireInit = needsCreateRequire
745882
? 'const require = createRequire(import.meta.url);\n'
746883
: ''
747884

885+
const requireResolveInit = needsRequireResolveHelper
886+
? needsCreateRequire
887+
? `const __requireResolve = (id, parent) => {
888+
const resolved = require.resolve(id, parent);
889+
return resolved.startsWith("file://") ? fileURLToPath(resolved) : resolved;
890+
};\n`
891+
: `const __requireResolve = (id, parent) => {
892+
const req = createRequire(parent ?? import.meta.url);
893+
const resolved = req.resolve(id, parent);
894+
return resolved.startsWith("file://") ? fileURLToPath(resolved) : resolved;
895+
};\n`
896+
: ''
897+
748898
const prelude = `${importPrelude.join('')}${
749899
importPrelude.length ? '\n' : ''
750-
}${requireInit}let ${exportsRename} = {};
900+
}${setupPrelude.join('')}${setupPrelude.length ? '\n' : ''}${requireInit}${requireResolveInit}let ${exportsRename} = {};
751901
void import.meta.filename;
752902
`
753903

src/formatters/identifier.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const identifier = ({
3131

3232
switch (name) {
3333
case '__filename':
34-
code.update(start, end, 'import.meta.url')
34+
code.update(start, end, 'import.meta.filename')
3535
break
3636
case '__dirname':
3737
code.update(start, end, 'import.meta.dirname')

0 commit comments

Comments
 (0)