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
52 changes: 0 additions & 52 deletions docs/release-1.0.0.md

This file was deleted.

49 changes: 49 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Roadmap / Upcoming Enhancements

Status: draft

## Idiomatic Exports Mode (aka `pretty`)

Goal: Add an opt-in `idiomaticExports` (current shorthand: `pretty`) mode to reduce synthesized helper bags when converting between CJS and ESM. Primary motivation: produce more idiomatic ESM output that is easier for bundlers to tree-shake when inputs qualify for the “safe” path.

### CJS → ESM

- Option: `pretty: 'safe' | 'aggressive'` (default: off). Consider exposing the option as `idiomaticExports` to better signal the intent and tree-shaking upside.
- Safe mode rules (emit direct exports, avoid `__exports` when all are true):
- Only top-level `exports.*` writes or a single `module.exports = { ... }` / `module.exports = fn`.
- No reassignments after initial writes; no getters/setters; no computed/non-identifier keys; no mixed `exports` + `module.exports` unless we can rewrite deterministically.
- No shadowed `module`/`exports`; no top-level `return`; no `require.cache/extensions`; no dynamic require inside export initializers; no TDZ hazards.
- Aggressive mode: allow mixed exports + `module.exports` if we can derive both default and named exports; allow identifier-safe computed keys; allow a single reassignment.
- Emission strategy (tree-shake-friendly when rules pass):
- Named writes → `export const foo = ...` or `export { local as foo }`.
- `module.exports = { ... }` → `export default { ... }` (+ optional named re-exports for plain identifiers if a sub-option is enabled).
- `module.exports = fn` → `export default fn`.
- Fallback to `__exports` when rules fail.
- Tree shaking note: Safe mode outputs static `export` forms, which typical bundlers can eliminate when unused. Aggressive mode may reintroduce helper bags or conservative shapes, so shaking benefits are best-effort there.
- Diagnostics: warn when `pretty` requested but fell back; warn when live-binding fidelity may differ in aggressive mode.
- Tests: fixture matrix (safe object, safe function default, mixed exports+module.exports, computed keys, reassignments) with assertions on generated text (absence/presence of `__exports`) and runtime behavior.

### ESM → CJS

- Option: same `pretty`/`idiomaticExports` flag, but constrained by live bindings and TLA.
- Preconditions for pretty CJS:
- `topLevelAwait === 'error'` or known-wrap path; `liveBindings !== 'strict'` (or accept relaxed semantics in aggressive mode).
- No namespace exports requiring live getters; no export-all with live needs unless we accept relaxed semantics.
- Emission strategy when safe:
- Direct `exports.foo = foo;` and `module.exports = default` without namespace helpers.
- Avoid namespace helper when `export * as ns` can map to `const ns = require(...); exports.ns = ns;` under relaxed live-binding semantics.
- Keep helpers for TLA wrap and strict live bindings.
- Tree shaking note: CJS output is inherently not statically tree-shakeable; “pretty” here is mostly about readability/minimal helpers rather than true shakeability.
- Tests: fixtures verifying helper-free output under safe conditions and fallback when constraints are present.

## Documentation & UX

- Note Node runtime floor (current package.json: Node >=22.21.1 <23 || >=24 <25) for `import.meta.*` support in generated code.
- Document diagnostics behavior when `pretty` cannot be applied.
- Consider a README section on “migration mode” describing pretty output trade-offs and when to avoid it.

## Next Steps

- Prototype CJS→ESM `pretty: 'safe'` path with fixtures and diagnostics.
- Evaluate surface area for ESM→CJS pretty mode; decide if it ships initially or stays experimental.
- Add CLI/API option plumbing and docs once behavior solidifies.
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.

15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@knighted/module",
"version": "1.0.0-rc.4",
"description": "Transforms differences between ES modules and CommonJS.",
"version": "1.0.0-rc.5",
"description": "Bidirectional transform for ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
"exports": {
Expand Down Expand Up @@ -44,13 +44,12 @@
"postpack": "node scripts/restoreImportsToSrc.js"
},
"keywords": [
"transform",
"es module",
"esm",
"commonjs",
"require",
"require.resolve",
"import.meta.url",
"import.meta.dirname",
"transform",
"cjs-to-esm",
"esm-to-cjs",
"import.meta",
"__dirname",
"__filename"
],
Expand Down
24 changes: 22 additions & 2 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,11 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
isValidExportName(name)
? `${exportsRename}.${name}`
: `${exportsRename}[${JSON.stringify(name)}]`
const exportValueFor = (name: string) => {
if (name === '__dirname') return 'import.meta.dirname'
if (name === '__filename') return 'import.meta.filename'
return name
}
const tempNameFor = (name: string) => {
const sanitized = name.replace(/[^$\w]/g, '_') || 'value'
const safe = /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized
Expand All @@ -909,7 +914,15 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
const defaultEntry = exportTable.get('default')
if (defaultEntry) {
const def = defaultEntry.fromIdentifier ?? exportsRename
lines.push(`export default ${def};`)
const defExpr = exportValueFor(def)

if (defExpr !== def) {
const temp = tempNameFor(def)
lines.push(`const ${temp} = ${defExpr};`)
lines.push(`export default ${temp};`)
} else {
lines.push(`export default ${defExpr};`)
}
}

for (const [key, entry] of exportTable) {
Expand All @@ -923,7 +936,14 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
}

if (entry.fromIdentifier) {
lines.push(`export { ${entry.fromIdentifier} as ${asExportName(key)} };`)
const resolved = exportValueFor(entry.fromIdentifier)
if (resolved !== entry.fromIdentifier) {
const temp = tempNameFor(entry.fromIdentifier)
lines.push(`const ${temp} = ${resolved};`)
lines.push(`export { ${temp} as ${asExportName(key)} };`)
} else {
lines.push(`export { ${resolved} as ${asExportName(key)} };`)
}
} else {
const temp = tempNameFor(key)
lines.push(`const ${temp} = ${accessProp(key)};`)
Expand Down
39 changes: 39 additions & 0 deletions test/fixtures/complexFile.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Complex CJS fixture combining common patterns
const { join } = require('node:path')
const { readFileSync } = require('node:fs')
const url = require('node:url')
const dynamic = require('./values.cjs')

// module.exports and exports used together
module.exports.base = 'cjs'
exports.extra = 'kept'

// live binding style mutation
exports.counter = 0
exports.bump = () => {
exports.counter += 1
return exports.counter
}

// require.resolve usage
exports.resolved = require.resolve('./values.cjs')

// import.meta analogues via url/path
exports.url = url.pathToFileURL(__filename).href
exports.dirname = __dirname
exports.filename = __filename

// dynamic-ish require wrapped in a function
exports.load = name => require(join(__dirname, name))

// re-export style through aliasing
const alias = exports
alias.aliased = 'ok'

// computed key
const key = 'weird-key'
exports[key] = 'strange'

// ensure file read works
exports.file = readFileSync(join(__dirname, 'values.cjs')).toString().includes('commonjs')
exports.dynamic = dynamic
42 changes: 42 additions & 0 deletions test/fixtures/complexFile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Complex ESM fixture combining common patterns
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { dirname, filename } from './meta.mjs'
import * as helpers from './values.mjs'
export * from './reexport.mjs'

export const base = 'esm'
export const extra = 'kept'

// live binding through direct named export
export let counter = 0
export function bump() {
counter += 1
return counter
}

const resolvedValue =
typeof import.meta.resolve === 'function'
? import.meta.resolve('./values.mjs')
: new URL('./values.mjs', import.meta.url).href

export const resolved = resolvedValue
export const url = import.meta.url
export { dirname, filename }

export async function load(name) {
const mod = await import(pathToFileURL(join(dirname, name)).href)
return mod.default ?? mod
}

export const aliasTarget = helpers
export const aliased = 'ok'

const key = 'weird-key'
export const computedKey = key
export const computedValue = 'strange'

export const file = readFileSync(join(dirname, 'values.mjs'))
.toString()
.includes('module')
1 change: 1 addition & 0 deletions test/fixtures/edgecases/dir/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = 'dir-ok'
1 change: 1 addition & 0 deletions test/fixtures/edgecases/dirTrailing.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dir/')
1 change: 1 addition & 0 deletions test/fixtures/edgecases/dirnameAlias.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const dirnameAlias = import.meta.dirname
4 changes: 4 additions & 0 deletions test/fixtures/edgecases/importMetaMainGuard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const mainFlag = import.meta.main ? 'main' : 'not-main'
export function run() {
return import.meta.main ? 'main-run' : 'lib-run'
}
7 changes: 7 additions & 0 deletions test/fixtures/edgecases/mixedReassign.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
exports.alpha = 1
exports.beta = 2

module.exports = { gamma: 3 }
exports.delta = 4

module.exports.extra = true
16 changes: 16 additions & 0 deletions test/fixtures/edgecases/shadowedParams.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
function wrap(__dirname, __filename) {
const localDir = __dirname
const localFile = __filename

return {
localDir,
localFile,
load: name => require(name),
}
}

const result = wrap('fake-dir', 'fake-file')

exports.topDir = __dirname
exports.topFile = __filename
exports.local = result
16 changes: 16 additions & 0 deletions test/fixtures/edgecases/tlaMixed.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export let counter = 0
export function inc() {
counter += 1
return counter
}

const double = () => counter * 2
export default function current() {
return counter
}

await Promise.resolve().then(() => {
counter += 1
})

export const doubled = double()
8 changes: 8 additions & 0 deletions test/fixtures/meta.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { fileURLToPath } from 'node:url'
import { dirname as pathDirname } from 'node:path'

const filename = fileURLToPath(import.meta.url)
const dirname = pathDirname(filename)

export { dirname, filename }
export default { dirname, filename }
2 changes: 2 additions & 0 deletions test/fixtures/reexport.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const fromReexport = 'from-reexport'
export { foo as fromValues, esmodule } from './values.mjs'
Loading