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
31 changes: 7 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Highlights
- Configurable lowering modes: full syntax transforms or globals-only.
- Specifier tools: add extensions, add directory indexes, or map with a custom callback.
- Output control: write to disk (`out`/`inPlace`) or return the transformed string.
- CLI: `dub` for batch transforms, dry-run/list/summary, stdin/stdout, and colorized diagnostics. See [docs/cli.md](docs/cli.md).

> [!IMPORTANT]
> All parsing logic is applied under the assumption the code is in [strict mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode) which [modules run under by default](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_classic_scripts).
Expand Down Expand Up @@ -143,7 +144,7 @@ type ModuleOptions = {
### Behavior notes (defaults in parentheses)

- `target` (`commonjs`): output module system.
- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](#globals-only-scope).
- `transformSyntax` (`true`): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](docs/globals-only.md).
- `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`).
- `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers.
- `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable).
Expand All @@ -159,7 +160,7 @@ type ModuleOptions = {
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
- `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.
- `out`/`inPlace`: choose output location. Default returns the transformed string (CLI emits to stdout). `out` writes to the provided path. `inPlace` overwrites the input files on disk and does not return/emit the code.
- `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths.

> [!NOTE]
Expand All @@ -170,13 +171,6 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings,
> [!NOTE]
> Known limitations: `with` and unshadowed `eval` are rejected when raising CJS to ESM because the rewrite would be unsound; bare specifiers are not rewritten—only relative specifiers participate in `rewriteSpecifier`.

### Globals-only scope

- Rewrites module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) for the target side.
- Optional specifier rewrites still run (`rewriteSpecifier`, `appendJsExtension`, `appendDirectoryIndex`).
- Leaves imports/exports and interop untouched (no export bag, no idiomaticExports, no live-binding synthesis, no helpers like `__requireResolve`).
- CJS→ESM: `require.resolve` maps to `import.meta.resolve` (URL return, ESM resolver) and may differ from CJS resolution. ESM→CJS: `import.meta` maps to CJS globals; no import lowering.

### Diagnostics callback example

Pass a `diagnostics` callback to surface CJS→ESM edge cases (mixed `module.exports`/`exports`, top-level `return`, legacy `require.cache`/`require.extensions`, live-binding reassignments, string-literal export names):
Expand Down Expand Up @@ -213,20 +207,9 @@ TypeScript reports asymmetric module-global errors (e.g., `import.meta` in CJS,

Minimal flow:

```js
import { glob } from 'glob'
import { transform } from '@knighted/module'

const files = await glob('src/**/*.{ts,js,mts,cts}', { ignore: 'node_modules/**' })

for (const file of files) {
await transform(file, {
target: 'commonjs', // or 'module' when raising CJS → ESM
inPlace: true,
transformSyntax: true,
})
}
// then run `tsc`
```bash
dub -t commonjs "src/**/*.{ts,js,mts,cts}" --ignore node_modules/** --transform-syntax globals-only --in-place
tsc
```

This pre-`tsc` step removes the flagged globals in the compiled orientation; runtime semantics still match the target build.
This pre-`tsc` step rewrites globals-only (keeps import/export syntax) so the TypeScript checker sees already-rewritten sources; runtime semantics still match the target build.
87 changes: 87 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# CLI: `dub`

Command-line wrapper around `@knighted/module` for transforming files between ESM and CommonJS.

## Requirements

- Node >= 22.21.1 (or Node 24+)

## Installation

The CLI is shipped with the package. Install locally or use `npx`:

```bash
npm install @knighted/module
npx dub --help
```

## Usage

```bash
dub [options] <files...>
```

Examples:

- Transform CJS to ESM into an output directory:

```bash
dub -t module src/**/*.cjs --out-dir dist
```

- In-place ESM to CJS rewrite:

```bash
dub -t commonjs src/**/*.mjs --in-place
```

- Preview changes without writing:

```bash
dub -t module src/**/*.cjs --out-dir dist --dry-run --list --summary
```

- Stdin/stdout pipeline:

```bash
cat input.cjs | dub -t module --stdin-filename input.cjs > output.mjs
```

## Options

Short and long forms are supported.

<!-- prettier-ignore-start -->
| Short | Long | Description |
| ----- | -------------------------- | --------------------------------------------------------------- |
| -t | --target | Output format (module \| commonjs) |
| -x | --transform-syntax | Syntax transforms (true \| false \| globals-only) |
| -r | --rewrite-specifier | Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts) |
| -j | --append-js-extension | Append .js to relative imports (off \| relative-only \| all) |
| -i | --append-directory-index | Append directory index (e.g. index.js) or false |
| -c | --detect-circular-requires | Warn/error on circular require (off \| warn \| error) |
| -a | --top-level-await | TLA handling (error \| wrap \| preserve) |
| -d | --cjs-default | Default interop (module-exports \| auto \| none) |
| -e | --idiomatic-exports | Emit idiomatic exports when safe (off \| safe \| aggressive) |
| -m | --import-meta-prelude | Emit import.meta prelude (off \| auto \| on) |
| -n | --nested-require-strategy | Rewrite nested require (create-require \| dynamic-import) |
| -R | --require-main-strategy | Detect main (import-meta-main \| realpath) |
| -l | --live-bindings | Live binding strategy (strict \| loose \| off) |
| -o | --out-dir | Write outputs to a directory mirror |
| -p | --in-place | Rewrite files in place |
| -y | --dry-run | Do not write files; report planned changes |
| -L | --list | List files that would change |
| -s | --summary | Print a summary of work performed |
| -J | --json | Emit machine-readable JSON summary/diagnostics |
| -C | --cwd | Working directory for resolving files/out paths |
| -f | --stdin-filename | Virtual filename when reading from stdin |
| -g | --ignore | Glob pattern(s) to ignore (repeatable) |
| -h | --help | Show help |
| -v | --version | Show version |
<!-- prettier-ignore-end -->

Notes:

- When reading from stdin, output is sent to stdout; `--out-dir` or `--in-place` are not allowed in that mode.
- 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.
- Diagnostics are printed to stderr; use `--json` for machine-readable output.
47 changes: 47 additions & 0 deletions docs/globals-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Globals-only transform

`transformSyntax: 'globals-only'` rewrites well-known module globals lexically without emitting any runtime helpers or preludes. Imports/exports and interop are left untouched; runtime correctness depends on the target environment providing the globals you opt into.

## Scope

- Pure identifier/member rewrites; no injected helpers, shims, or prelude imports.
- Works in both directions (CJS ➜ ESM, ESM ➜ CJS).
- Diagnostics still surface for legacy constructs (e.g., `require.extensions`, `module.parent`).

## Rewrites at a glance

<!-- prettier-ignore-start -->
| Direction | Input | Output | Notes |
| --- | --- | --- | --- |
| CJS ➜ ESM | `__dirname` | `import.meta.dirname` | lexical swap |
| CJS ➜ ESM | `__filename` | `import.meta.filename` | lexical swap |
| CJS ➜ ESM | `require.main` | `import.meta.main` | skipped inside equality checks |
| CJS ➜ ESM | `require.resolve(...)` | `import.meta.resolve(...)` | no helper emitted* |
| CJS ➜ ESM | `module.require(...)` | `require(...)` | collapses to ambient require |
| CJS ➜ ESM | `require.cache` | `{}` | best-effort stub + warning |
| CJS ➜ ESM | `require.extensions` | (unchanged) | warning emitted |
| CJS ➜ ESM | `module.parent / module.children` | (unchanged) | warning emitted |
| ESM ➜ CJS | `import.meta` | `module` | bare meta expression |
| ESM ➜ CJS | `import.meta.url` | `require("node:url").pathToFileURL(__filename).href` | URL-shaped contract |
| ESM ➜ CJS | `import.meta.filename` | `__filename` | lexical swap |
| ESM ➜ CJS | `import.meta.dirname` | `__dirname` | lexical swap |
| ESM ➜ CJS | `import.meta.resolve(...)` | `require.resolve(...)` | string-path semantics |
| ESM ➜ CJS | `import.meta.main` | `process.argv[1] === __filename` | inline check |
| ESM ➜ CJS | other `import.meta.*` | `module.*` | no extra objects created |
<!-- prettier-ignore-end -->

\* In globals-only, we do not inject the CJS-style `require.resolve` helper. The rewrite relies on the host's native `import.meta.resolve`, whose semantics differ (URL-based, parent handling). Use full transforms if you need the helper that preserves CJS resolution behavior.

## When to use it

- Pre-`tsc` mitigation for TypeScript’s [asymmetry on module globals](https://github.com/microsoft/TypeScript/issues/58658) (see `test/cli.ts`): rewrite globals so the checker sees the target-side shapes without altering import/export syntax.
- Tooling pipelines that only need syntax-level compatibility and will supply the required globals at runtime.

## When not to use it

- When you need runtime shims (e.g., `createRequire`, import.meta polyfills) or interop helpers: use `transformSyntax: true` instead.

## Caveats

- Behavior relies on the target runtime’s built-ins; no guarantees are made about availability or semantics of the rewritten globals.
- Legacy fields like `require.cache`, `require.extensions`, `module.parent`, and `module.children` only receive warnings/stubs—behavior may differ from Node.
9 changes: 4 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ Shipped: `idiomaticExports: 'safe'` is now the default for CJS → ESM, with fal
Next:

- Explore a true `'aggressive'` mode (mixed exports/module.exports, limited reassignments, identifier-safe computed keys) with guarded semantics and explicit diagnostics.
- 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.
- Consider a constrained ESM → CJS “pretty” path where live-binding and TLA semantics permit it.

## CLI

- Deliver a `knighted-module` CLI that wraps the core transform with parity to API options (targets, rewriteSpecifier, appendJsExtension/appendDirectoryIndex, detectCircularRequires, topLevelAwait, cjsDefault, diagnostics hooks, out/in-place).
- Input handling: accept file/glob lists plus stdin/stdout piping; respect `package.json` `type` and `.cjs/.mjs` extensions; allow per-invocation overrides via flags and a config file.
- Output handling: in-place rewrite or out-dir mirroring with extension rewriting; emit diagnostics to stderr and machine-readable JSON when requested; non-zero exit on diagnostics of severity error.
- Performance ergonomics: batch parse/format where possible, optional concurrency flag, and a `--watch` mode that rebuilds on change with minimal restarts.
- DX: `--dry-run` to preview planned rewrites, `--list` to show which files would change, `--summary` to print counts of transformed specifiers/globals, and `--help`/`--version` aligned with package metadata.
- Shipped parity CLI wrapping the core transform (targets, rewriteSpecifier, appendJsExtension/appendDirectoryIndex, detectCircularRequires, topLevelAwait, cjsDefault, diagnostics hooks, out/in-place) with stdin/stdout support, JSON/summary, and list/dry-run paths.
- Next: optional concurrency flag, `--watch` mode with minimal restarts, and a tiny stream type surface to keep test stubs and embedding clean.
- DX polish: keep help/examples in sync with tests; retain single-fixture CLI coverage unless new CLI-specific behaviors emerge (e.g., multi-ext glob ordering, large-input streaming), since transform semantics are already exercised in module fixtures.

## Tooling & Diagnostics

Expand Down
2 changes: 1 addition & 1 deletion oxlint.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
}
},
{
"files": ["test/**"],
"files": ["test/**", "!test/cli.ts"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
Expand Down
Loading