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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ type ModuleOptions = {
idiomaticExports?: 'off' | 'safe' | 'aggressive'
topLevelAwait?: 'error' | 'wrap' | 'preserve'
out?: string
cwd?: string
inPlace?: boolean
}
```
Expand All @@ -159,7 +160,7 @@ type ModuleOptions = {
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
- `idiomaticExports` (`safe`): when raising CJS to ESM, attempt to synthesize `export` statements directly when it is safe. `off` always uses the helper bag; `aggressive` currently matches `safe` heuristics.
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
- CommonJS → ESM lowering will throw on `with` statements and unshadowed `eval` calls to avoid unsound rewrites.
- `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths.

> [!NOTE]
> 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.
Expand Down
19 changes: 2 additions & 17 deletions package-lock.json

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

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.1.1",
"version": "1.2.0",
"description": "Bidirectional transform for ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand All @@ -27,7 +27,7 @@
"prettier": "prettier -w .",
"prettier:check": "prettier -c .",
"lint": "oxlint --config oxlint.json .",
"prepare": "husky install",
"prepare": "husky",
"test": "c8 --reporter=text --reporter=text-summary --reporter=lcov tsx --test --test-reporter=spec test/*.ts",
"build:types": "tsc --emitDeclarationOnly",
"build:dual": "babel-dual-package src --extensions .ts",
Expand Down Expand Up @@ -72,7 +72,6 @@
},
"dependencies": {
"magic-string": "^0.30.21",
"node-module-type": "^1.0.4",
"oxc-parser": "^0.105.0",
"periscopic": "^4.0.2"
},
Expand Down
10 changes: 8 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,17 +226,19 @@ const defaultOptions = {
idiomaticExports: 'safe',
importMetaPrelude: 'auto',
topLevelAwait: 'error',
cwd: undefined,
out: undefined,
inPlace: false,
} satisfies ModuleOptions
const transform = async (filename: string, options: ModuleOptions = defaultOptions) => {
const opts = { ...defaultOptions, ...options, filePath: filename }
const cwdBase = opts.cwd ? resolve(opts.cwd) : process.cwd()
const appendMode: AppendJsExtensionMode =
options?.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off')
const dirIndex =
opts.appendDirectoryIndex === undefined ? 'index.js' : opts.appendDirectoryIndex
const detectCycles: DetectCircularRequires = opts.detectCircularRequires ?? 'off'
const file = resolve(filename)
const file = resolve(cwdBase, filename)
const code = (await readFile(file)).toString()
const ast = parse(filename, code)
let source = await format(code, ast, opts)
Expand All @@ -261,7 +263,11 @@ const transform = async (filename: string, options: ModuleOptions = defaultOptio
await detectCircularRequireGraph(file, detectCycles, dirIndex || 'index.js')
}

const outputPath = opts.inPlace ? file : opts.out ? resolve(opts.out) : undefined
const outputPath = opts.inPlace
? file
: opts.out
? resolve(cwdBase, opts.out)
: undefined

if (outputPath) {
await writeFile(outputPath, source)
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export type ModuleOptions = {
diagnostics?: (diag: Diagnostic) => void
/** Optional source file path used for diagnostics context. */
filePath?: string
/** Base directory used to resolve relative `out` paths; defaults to process.cwd(). */
cwd?: string
/** Output directory or file path when writing. */
out?: string
/** Overwrite input files instead of writing to out. */
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/cwdFile.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const value = { answer: 42 }

module.exports = value
60 changes: 59 additions & 1 deletion test/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { resolve, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
import { rm, stat, writeFile } from 'node:fs/promises'
import { copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises'
import type { Stats } from 'node:fs'

import { transform } from '../src/module.js'
Expand Down Expand Up @@ -1819,6 +1819,64 @@ describe('@knighted/module', () => {
assert.ok(result.includes('__dirname'))
})

it('resolves relative out paths against cwd option', async t => {
const source = join(fixtures, 'file.cjs')
const relOut = join('cwd-out', 'out.mjs')
const expectedDir = join(fixtures, 'cwd-out')
const expected = join(expectedDir, 'out.mjs')
const unintendedDir = join(process.cwd(), 'cwd-out')
const valuesSrc = join(fixtures, 'values.cjs')
const valuesDst = join(expectedDir, 'values.cjs')

await mkdir(expectedDir, { recursive: true })
await copyFile(valuesSrc, valuesDst)

t.after(async () => {
await rm(expectedDir, { recursive: true, force: true })
if (unintendedDir !== expectedDir) {
await rm(unintendedDir, { recursive: true, force: true })
}
})

await transform(source, { target: 'module', out: relOut, cwd: fixtures })

assert.equal(await isValidFilename(expected), true)
if (unintendedDir !== expectedDir) {
assert.equal(await isValidFilename(join(unintendedDir, 'out.mjs')), false)
}

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

it('resolves relative input and output against cwd', async t => {
const relSource = 'cwdFile.cjs'
const relOut = join('cwd-rel', 'out.mjs')
const expectedDir = join(fixtures, 'cwd-rel')
const expectedOut = join(expectedDir, 'out.mjs')
const unintendedDir = join(process.cwd(), 'cwd-rel')

await mkdir(expectedDir, { recursive: true })

t.after(async () => {
await rm(expectedDir, { recursive: true, force: true })
if (unintendedDir !== expectedDir) {
await rm(unintendedDir, { recursive: true, force: true })
}
})

await transform(relSource, { target: 'module', cwd: fixtures, out: relOut })

assert.equal(await isValidFilename(expectedOut), true)
if (unintendedDir !== expectedDir) {
assert.equal(await isValidFilename(join(unintendedDir, 'out.mjs')), false)
}

const mod = await import(pathToFileURL(expectedOut).href)
const exported = (mod as any).default ?? (mod as any)
assert.equal(exported.answer, 42)
})

it('writes transformed source to a file when option enabled', async t => {
const mjs = join(fixtures, 'transformed.mjs')
const cjs = join(fixtures, 'transformed.cjs')
Expand Down