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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ Node.js utility for transforming a JavaScript or TypeScript file from an ES modu

Highlights

- ESM ➡️ CJS and CJS ➡️ ESM with one function call.
- Defaults to safe CommonJS output: strict live bindings, import.meta shims, and specifier preservation.
- Opt into stricter/looser behaviors: live binding enforcement, import.meta.main gating, and top-level await strategies.
- Can optionally rewrite relative specifiers and write transformed output to disk.
- 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.

> [!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).

By default `@knighted/module` transforms the one-to-one [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs). Options let you control syntax rewriting, specifier updates, and output.
By default `@knighted/module` transforms the one-to-one [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs). Options let you control syntax rewriting (full vs globals-only), specifier updates, and output.

## Requirements

Expand All @@ -30,9 +32,9 @@ By default `@knighted/module` transforms the one-to-one [differences between ES
npm install @knighted/module
```

## Example
## Quick examples

Given an ES module:
ESM ➡️ CJS:

**file.js**

Expand Down Expand Up @@ -93,6 +95,17 @@ use@computer: $ node file.cjs
invoked directly by node
```

CJS ➡️ ESM:

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

await transform('./file.cjs', {
target: 'module',
out: './file.mjs',
})
```

## Options

```ts
Expand Down Expand Up @@ -124,20 +137,21 @@ type ModuleOptions = {
}
```

Behavior notes (defaults in parentheses)
### 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).
- `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).
- `appenders` precedence: `rewriteSpecifier` runs first; if it returns a string, that result is used. If it returns `undefined` or `null`, `appendJsExtension` and `appendDirectoryIndex` still run. Bare specifiers are never modified by appenders.
- `dirFilename` (`inject`): inject `__dirname`/`__filename`, preserve existing, or throw.
- `importMeta` (`shim`): rewrite `import.meta.*` to CommonJS equivalents.
- `importMetaMain` (`shim`): gate `import.meta.main` with shimming/warning/error when Node support is too old.
- `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
- `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw.
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback.
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback. Precedence: the callback (if provided) runs first; if it returns a string, that wins. If it returns `undefined` or `null`, the appenders still apply.
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
Expand Down
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.0.0-rc.6",
"version": "1.0.0",
"description": "Bidirectional transform for ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
2 changes: 1 addition & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const rewriteSpecifierValue = (
const relative = /^(?:\.\.?)\//

if (relative.test(collapsed)) {
return value.replace(/(.+)\.(?:m|c)?(?:j|t)s([)'"]*)?$/, `$1${rewriteSpecifier}$2`)
return value.replace(/(.+)\.(?:m|c)?(?:j|t)sx?([)'"]*)?$/, `$1${rewriteSpecifier}$2`)
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/specifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ const formatSpecifiers = async (src: string, ast: ParseResult, cb: Callback) =>

await walk(ast.program, {
enter(node) {
if (node.type === 'ImportExpression') {
formatExpression(node)
}

if (node.type === 'ExpressionStatement') {
const { expression } = node

Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export type ModuleOptions = {
appendJsExtension?: 'off' | 'relative-only' | 'all'
/** Add directory index (e.g. /index.js) or disable. */
appendDirectoryIndex?: string | false
/** Control __dirname and __filename handling. */
/** Precedence: rewriteSpecifier runs first; if it returns a string that wins. If it returns undefined or null, appenders apply. Bare specifiers are never modified by appenders. */
/** Control __dirname/__filename handling (inject shims, preserve existing, or throw on use). */
dirFilename?: 'inject' | 'preserve' | 'error'
/** How to treat import.meta. */
importMeta?: 'preserve' | 'shim' | 'error'
Expand Down
17 changes: 17 additions & 0 deletions test/fixtures/projects/ts-webapp/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Config } from './utils/config.js'
import { renderApp } from './ui/app.js'

export const boot = async (url: string) => {
const { loadConfig } = await import('./utils/config.js')
const loaded = await loadConfig(url)
const rendered = renderApp(loaded)
return { rendered, url }
}

export const lazyApp = async () => {
const { renderApp: render } = await import('./ui/app.js')
return render({ title: 'lazy' })
}

export const hydrate = (target: HTMLElement, config: Config) =>
renderApp({ ...config, targetId: target.id })
17 changes: 17 additions & 0 deletions test/fixtures/projects/ts-webapp/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
declare namespace JSX {
type Element = any
interface IntrinsicElements {
[elemName: string]: any
}
}

declare module 'react/jsx-runtime' {
export const jsx: any
export const jsxs: any
export const Fragment: any
}

declare module 'react/jsx-dev-runtime' {
export const jsxDEV: any
export const Fragment: any
}
11 changes: 11 additions & 0 deletions test/fixtures/projects/ts-webapp/src/ui/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Config } from '../utils/config.js'
import { View } from './view.js'

export type Rendered = { node: JSX.Element; props: Config }

export const renderApp = (config: Config): Rendered => ({
node: <View title={config.title} target={config.targetId ?? 'root'} />,
props: config,
})

export const mount = (config: Config) => renderApp(config)
6 changes: 6 additions & 0 deletions test/fixtures/projects/ts-webapp/src/ui/view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const View = ({ title, target }: { title: string; target: string }) => (
<section data-target={target}>
<h1>{title}</h1>
<p>ready</p>
</section>
)
6 changes: 6 additions & 0 deletions test/fixtures/projects/ts-webapp/src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type Config = { title: string; targetId?: string }

export const loadConfig = async (url: string): Promise<Config> => ({
title: new URL(url).hostname || 'app',
targetId: 'root',
})
33 changes: 33 additions & 0 deletions test/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,39 @@ describe('@knighted/module', () => {
}
})

it('rewrites ts/tsx/jsx specifiers for a bundler-style web app', async () => {
const projectRoot = join(fixtures, 'projects', 'ts-webapp')
const mainFile = join(projectRoot, 'src', 'main.tsx')
const appFile = join(projectRoot, 'src', 'ui', 'app.tsx')

const [mainOut, appOut] = await Promise.all([
transform(mainFile, {
target: 'module',
transformSyntax: 'globals-only',
rewriteSpecifier: '.js',
}),
transform(appFile, {
target: 'module',
transformSyntax: 'globals-only',
rewriteSpecifier: '.js',
}),
])

assert.ok(mainOut.includes("from './ui/app.js'"))
assert.ok(mainOut.includes("from './utils/config.js'"))
assert.ok(mainOut.includes("import('./ui/app.js')"))
assert.ok(mainOut.includes("import('./utils/config.js')"))
assert.equal(mainOut.includes('.tsx'), false)

assert.ok(appOut.includes("from './view.js'"))
assert.ok(appOut.includes("from '../utils/config.js'"))
assert.ok(
appOut.includes("<View title={config.title} target={config.targetId ?? 'root'} />"),
)
assert.equal(appOut.includes('.tsx'), false)
assert.equal(appOut.includes('.jsx'), false)
})

it('converts a small commonjs project to esm', async t => {
const projectRoot = join(fixtures, 'projects', 'cjs-app')
const entry = join(projectRoot, 'index.cjs')
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
"types": ["node"],
"jsx": "preserve"
},
"include": ["src", "test"]
}