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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ type ModuleOptions = {
target: 'module' | 'commonjs'
sourceType?: 'auto' | 'module' | 'commonjs'
transformSyntax?: boolean | 'globals-only'
sourceMap?: boolean
liveBindings?: 'strict' | 'loose' | 'off'
appendJsExtension?: 'off' | 'relative-only' | 'all'
appendDirectoryIndex?: string | false
Expand Down Expand Up @@ -167,6 +168,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`: 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.
- `sourceMap` (`false`): when true, returns `{ code, map }` from `transform` and writes the map if you also set `out`/`inPlace`. Maps are generated from the same MagicString pipeline used for the code.
- `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths.

> [!NOTE]
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Short and long forms are supported.
| -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) |
| | --source-map | Emit a source map (sidecar); use --source-map=inline for stdout |
| -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) |
Expand Down
1 change: 0 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ Status: draft

## Tooling & Diagnostics

- Emit source maps and clearer diagnostics for transform choices.
- Benchmark scope analysis choices: compare `periscopic`, `scope-analyzer`, and `eslint-scope` on fixtures and pick the final adapter.

## Potential Breaking Changes (flag/document clearly)
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.4.0",
"version": "1.5.0-rc.0",
"description": "Bidirectional transform for ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
126 changes: 117 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
stderr as defaultStderr,
} from 'node:process'
import { parseArgs } from 'node:util'
import { readFile, mkdir } from 'node:fs/promises'
import { dirname, resolve, relative, join } from 'node:path'
import { readFile, mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve, relative, join, basename } from 'node:path'
import { glob } from 'glob'

import type { TemplateLiteral } from '@oxc-project/types'
Expand Down Expand Up @@ -41,6 +41,7 @@ const defaultOptions: ModuleOptions = {
idiomaticExports: 'safe',
importMetaPrelude: 'auto',
topLevelAwait: 'error',
sourceMap: false,
cwd: undefined,
out: undefined,
inPlace: false,
Expand Down Expand Up @@ -248,6 +249,12 @@ const optionsTable = [
type: 'string',
desc: 'Emit import.meta prelude (off|auto|on)',
},
{
long: 'source-map',
short: undefined,
type: 'boolean',
desc: 'Emit a source map alongside transformed output (use --source-map=inline for stdout)',
},
{
long: 'nested-require-strategy',
short: 'n',
Expand Down Expand Up @@ -448,6 +455,7 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
values['live-bindings'] as string | undefined,
['strict', 'loose', 'off'] as const,
) ?? defaultOptions.liveBindings,
sourceMap: Boolean(values['source-map']),
cwd: values.cwd ? resolve(String(values.cwd)) : defaultOptions.cwd,
}

Expand All @@ -462,6 +470,58 @@ const readStdin = async (stdin: typeof defaultStdin) => {
return Buffer.concat(chunks).toString('utf8')
}

const normalizeSourceMapArgv = (argv: string[]) => {
let sourceMapInline = false
let invalidSourceMapValue: string | null = null
const normalized: string[] = []
const recordInvalid = (value: string) => {
if (!invalidSourceMapValue) invalidSourceMapValue = value
}

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]

if (arg === '--source-map') {
const next = argv[i + 1]
if (next === 'inline') {
sourceMapInline = true
normalized.push('--source-map')
i += 1
continue
}
if (next === 'true' || next === 'false') {
normalized.push(`--source-map=${next}`)
i += 1
continue
}
}

if (arg.startsWith('--source-map=')) {
const value = arg.slice('--source-map='.length)
if (value === 'inline') {
sourceMapInline = true
normalized.push('--source-map')
continue
}
if (value === 'true' || value === 'false') {
normalized.push(arg)
continue
}
recordInvalid(value)
continue
}

if (arg === '--source-map' && argv[i + 1] && argv[i + 1].startsWith('--')) {
normalized.push('--source-map')
continue
}

normalized.push(arg)
}

return { argv: normalized, sourceMapInline, invalidSourceMapValue }
}

const expandFiles = async (patterns: string[], cwd: string, ignore?: string[]) => {
const files = new Set<string>()
for (const pattern of patterns) {
Expand Down Expand Up @@ -577,6 +637,7 @@ const runFiles = async (
outDir?: string
inPlace: boolean
allowStdout: boolean
sourceMapInline: boolean
},
) => {
const results: FileResult[] = []
Expand Down Expand Up @@ -604,8 +665,11 @@ const runFiles = async (
hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard,
}

const allowWrites = !flags.dryRun && !flags.list
const writeInPlace = allowWrites && flags.inPlace
let writeTarget: string | undefined
if (!flags.dryRun && !flags.list) {

if (allowWrites) {
if (flags.inPlace) {
perFileOpts.inPlace = true
} else if (outPath) {
Expand All @@ -618,8 +682,16 @@ const runFiles = async (
}
}

const output = await transform(file, perFileOpts)
if (moduleOpts.sourceMap && (writeTarget || writeInPlace)) {
perFileOpts.out = undefined
perFileOpts.inPlace = false
}

const transformed = await transform(file, perFileOpts)
const output = typeof transformed === 'string' ? transformed : transformed.code
const map = typeof transformed === 'string' ? null : transformed.map
const changed = output !== original
let finalOutput = output

if (projectHazards) {
const extras = projectHazards.get(file)
Expand All @@ -630,8 +702,27 @@ const runFiles = async (
logger.info(file)
}

if (!flags.dryRun && !flags.list && !writeTarget && !perFileOpts.inPlace) {
io.stdout.write(output)
if (map && flags.sourceMapInline && !writeTarget && !writeInPlace) {
const mapUri = Buffer.from(JSON.stringify(map)).toString('base64')
finalOutput = `${output.replace(/\/\/# sourceMappingURL=.*/g, '').trimEnd()}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${mapUri}\n`
} else if (map && (writeTarget || writeInPlace)) {
const target = writeTarget ?? file
const mapPath = `${target}.map`
const mapFile = basename(mapPath)
map.file = basename(target)

const updated = `${output.replace(/\/\/# sourceMappingURL=.*/g, '').trimEnd()}\n//# sourceMappingURL=${mapFile}\n`
await writeFile(mapPath, JSON.stringify(map))

if (writeTarget) {
await writeFile(writeTarget, updated)
} else if (writeInPlace) {
await writeFile(file, updated)
}
}

if (!flags.dryRun && !flags.list && !writeTarget && !writeInPlace) {
io.stdout.write(finalOutput)
}

results.push({ filePath: file, changed, diagnostics })
Expand Down Expand Up @@ -664,8 +755,20 @@ const runCli = async ({
stdout = defaultStdout,
stderr = defaultStderr,
}: CliOptions = {}) => {
const logger = makeLogger(stdout, stderr)
const {
argv: normalizedArgv,
sourceMapInline,
invalidSourceMapValue,
} = normalizeSourceMapArgv(argv)

if (invalidSourceMapValue) {
logger.error(`Invalid --source-map value: ${invalidSourceMapValue}`)
return 2
}

const { values, positionals } = parseArgs({
args: argv,
args: normalizedArgv,
allowPositionals: true,
options: Object.fromEntries(
optionsTable.map(opt => [
Expand All @@ -678,8 +781,6 @@ const runCli = async ({
),
})

const logger = makeLogger(stdout, stderr)

if (values.help) {
stdout.write(buildHelp(stdout.isTTY ?? false))
return 0
Expand All @@ -694,6 +795,7 @@ const runCli = async ({
}

const moduleOpts = toModuleOptions(values)
if (sourceMapInline) moduleOpts.sourceMap = true
const cwd = moduleOpts.cwd ?? process.cwd()
const allowStdout = positionals.length <= 1
const fromStdin = positionals.length === 0 || positionals.includes('-')
Expand All @@ -710,6 +812,11 @@ const runCli = async ({
const summary = Boolean(values.summary)
const json = Boolean(values.json)

if (sourceMapInline && (outDir || inPlace)) {
logger.error('Inline source maps are only supported when writing to stdout')
return 2
}

if (outDir && inPlace) {
logger.error('Choose either --out-dir or --in-place, not both')
return 2
Expand Down Expand Up @@ -769,6 +876,7 @@ const runCli = async ({
outDir,
inPlace,
allowStdout,
sourceMapInline,
},
)

Expand Down
34 changes: 19 additions & 15 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,12 +419,13 @@ const detectDualPackageHazards = async (params: {
}
}

/**
* Node added support for import.meta.main.
* Added in: v24.2.0, v22.18.0
* @see https://nodejs.org/api/esm.html#importmetamain
*/
const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => {
function format(
src: string,
ast: ParseResult,
opts: FormatterOptions & { sourceMap: true },
): Promise<MagicString>
function format(src: string, ast: ParseResult, opts: FormatterOptions): Promise<string>
async function format(src: string, ast: ParseResult, opts: FormatterOptions) {
const code = new MagicString(src)
const exportsMeta = {
hasExportsBeenReassigned: false,
Expand Down Expand Up @@ -706,19 +707,22 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
}

if (opts.target === 'commonjs' && fullTransform && containsTopLevelAwait) {
const body = code.toString()

if (opts.topLevelAwait === 'wrap') {
const tlaPromise = `const __tla = (async () => {\n${body}\nreturn module.exports;\n})();\n`
const setPromise = `const __setTla = target => {\n if (!target) return;\n const type = typeof target;\n if (type !== 'object' && type !== 'function') return;\n target.__tla = __tla;\n};\n`
const attach = `__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n`
return `${tlaPromise}${setPromise}${attach}`
code.prepend('const __tla = (async () => {\n')
code.append('\nreturn module.exports;\n})();\n')
code.append(
'const __setTla = target => {\n if (!target) return;\n const type = typeof target;\n if (type !== "object" && type !== "function") return;\n target.__tla = __tla;\n};\n',
)
code.append(
'__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n',
)
} else {
code.prepend(';(async () => {\n')
code.append('\n})();\n')
}

return `;(async () => {\n${body}\n})();\n`
}

return code.toString()
return opts.sourceMap ? code : code.toString()
}

export { format, collectDualPackageUsage, dualPackageHazardDiagnostics }
Loading