Skip to content

Commit 0433589

Browse files
feat: source map support. (#47)
1 parent 916cb79 commit 0433589

12 files changed

Lines changed: 320 additions & 38 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type ModuleOptions = {
114114
target: 'module' | 'commonjs'
115115
sourceType?: 'auto' | 'module' | 'commonjs'
116116
transformSyntax?: boolean | 'globals-only'
117+
sourceMap?: boolean
117118
liveBindings?: 'strict' | 'loose' | 'off'
118119
appendJsExtension?: 'off' | 'relative-only' | 'all'
119120
appendDirectoryIndex?: string | false
@@ -167,6 +168,7 @@ type ModuleOptions = {
167168
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
168169
- `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.
169170
- `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.
171+
- `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.
170172
- `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths.
171173
172174
> [!NOTE]

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Short and long forms are supported.
7575
| -d | --cjs-default | Default interop (module-exports \| auto \| none) |
7676
| -e | --idiomatic-exports | Emit idiomatic exports when safe (off \| safe \| aggressive) |
7777
| -m | --import-meta-prelude | Emit import.meta prelude (off \| auto \| on) |
78+
| | --source-map | Emit a source map (sidecar); use --source-map=inline for stdout |
7879
| -n | --nested-require-strategy | Rewrite nested require (create-require \| dynamic-import) |
7980
| -R | --require-main-strategy | Detect main (import-meta-main \| realpath) |
8081
| -l | --live-bindings | Live binding strategy (strict \| loose \| off) |

docs/roadmap.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ Status: draft
1414

1515
## Tooling & Diagnostics
1616

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

2019
## Potential Breaking Changes (flag/document clearly)

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.4.0",
3+
"version": "1.5.0-rc.0",
44
"description": "Bidirectional transform for ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/cli.ts

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
stderr as defaultStderr,
66
} from 'node:process'
77
import { parseArgs } from 'node:util'
8-
import { readFile, mkdir } from 'node:fs/promises'
9-
import { dirname, resolve, relative, join } from 'node:path'
8+
import { readFile, mkdir, writeFile } from 'node:fs/promises'
9+
import { dirname, resolve, relative, join, basename } from 'node:path'
1010
import { glob } from 'glob'
1111

1212
import type { TemplateLiteral } from '@oxc-project/types'
@@ -41,6 +41,7 @@ const defaultOptions: ModuleOptions = {
4141
idiomaticExports: 'safe',
4242
importMetaPrelude: 'auto',
4343
topLevelAwait: 'error',
44+
sourceMap: false,
4445
cwd: undefined,
4546
out: undefined,
4647
inPlace: false,
@@ -248,6 +249,12 @@ const optionsTable = [
248249
type: 'string',
249250
desc: 'Emit import.meta prelude (off|auto|on)',
250251
},
252+
{
253+
long: 'source-map',
254+
short: undefined,
255+
type: 'boolean',
256+
desc: 'Emit a source map alongside transformed output (use --source-map=inline for stdout)',
257+
},
251258
{
252259
long: 'nested-require-strategy',
253260
short: 'n',
@@ -448,6 +455,7 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
448455
values['live-bindings'] as string | undefined,
449456
['strict', 'loose', 'off'] as const,
450457
) ?? defaultOptions.liveBindings,
458+
sourceMap: Boolean(values['source-map']),
451459
cwd: values.cwd ? resolve(String(values.cwd)) : defaultOptions.cwd,
452460
}
453461

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

473+
const normalizeSourceMapArgv = (argv: string[]) => {
474+
let sourceMapInline = false
475+
let invalidSourceMapValue: string | null = null
476+
const normalized: string[] = []
477+
const recordInvalid = (value: string) => {
478+
if (!invalidSourceMapValue) invalidSourceMapValue = value
479+
}
480+
481+
for (let i = 0; i < argv.length; i += 1) {
482+
const arg = argv[i]
483+
484+
if (arg === '--source-map') {
485+
const next = argv[i + 1]
486+
if (next === 'inline') {
487+
sourceMapInline = true
488+
normalized.push('--source-map')
489+
i += 1
490+
continue
491+
}
492+
if (next === 'true' || next === 'false') {
493+
normalized.push(`--source-map=${next}`)
494+
i += 1
495+
continue
496+
}
497+
}
498+
499+
if (arg.startsWith('--source-map=')) {
500+
const value = arg.slice('--source-map='.length)
501+
if (value === 'inline') {
502+
sourceMapInline = true
503+
normalized.push('--source-map')
504+
continue
505+
}
506+
if (value === 'true' || value === 'false') {
507+
normalized.push(arg)
508+
continue
509+
}
510+
recordInvalid(value)
511+
continue
512+
}
513+
514+
if (arg === '--source-map' && argv[i + 1] && argv[i + 1].startsWith('--')) {
515+
normalized.push('--source-map')
516+
continue
517+
}
518+
519+
normalized.push(arg)
520+
}
521+
522+
return { argv: normalized, sourceMapInline, invalidSourceMapValue }
523+
}
524+
465525
const expandFiles = async (patterns: string[], cwd: string, ignore?: string[]) => {
466526
const files = new Set<string>()
467527
for (const pattern of patterns) {
@@ -577,6 +637,7 @@ const runFiles = async (
577637
outDir?: string
578638
inPlace: boolean
579639
allowStdout: boolean
640+
sourceMapInline: boolean
580641
},
581642
) => {
582643
const results: FileResult[] = []
@@ -604,8 +665,11 @@ const runFiles = async (
604665
hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard,
605666
}
606667

668+
const allowWrites = !flags.dryRun && !flags.list
669+
const writeInPlace = allowWrites && flags.inPlace
607670
let writeTarget: string | undefined
608-
if (!flags.dryRun && !flags.list) {
671+
672+
if (allowWrites) {
609673
if (flags.inPlace) {
610674
perFileOpts.inPlace = true
611675
} else if (outPath) {
@@ -618,8 +682,16 @@ const runFiles = async (
618682
}
619683
}
620684

621-
const output = await transform(file, perFileOpts)
685+
if (moduleOpts.sourceMap && (writeTarget || writeInPlace)) {
686+
perFileOpts.out = undefined
687+
perFileOpts.inPlace = false
688+
}
689+
690+
const transformed = await transform(file, perFileOpts)
691+
const output = typeof transformed === 'string' ? transformed : transformed.code
692+
const map = typeof transformed === 'string' ? null : transformed.map
622693
const changed = output !== original
694+
let finalOutput = output
623695

624696
if (projectHazards) {
625697
const extras = projectHazards.get(file)
@@ -630,8 +702,27 @@ const runFiles = async (
630702
logger.info(file)
631703
}
632704

633-
if (!flags.dryRun && !flags.list && !writeTarget && !perFileOpts.inPlace) {
634-
io.stdout.write(output)
705+
if (map && flags.sourceMapInline && !writeTarget && !writeInPlace) {
706+
const mapUri = Buffer.from(JSON.stringify(map)).toString('base64')
707+
finalOutput = `${output.replace(/\/\/# sourceMappingURL=.*/g, '').trimEnd()}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${mapUri}\n`
708+
} else if (map && (writeTarget || writeInPlace)) {
709+
const target = writeTarget ?? file
710+
const mapPath = `${target}.map`
711+
const mapFile = basename(mapPath)
712+
map.file = basename(target)
713+
714+
const updated = `${output.replace(/\/\/# sourceMappingURL=.*/g, '').trimEnd()}\n//# sourceMappingURL=${mapFile}\n`
715+
await writeFile(mapPath, JSON.stringify(map))
716+
717+
if (writeTarget) {
718+
await writeFile(writeTarget, updated)
719+
} else if (writeInPlace) {
720+
await writeFile(file, updated)
721+
}
722+
}
723+
724+
if (!flags.dryRun && !flags.list && !writeTarget && !writeInPlace) {
725+
io.stdout.write(finalOutput)
635726
}
636727

637728
results.push({ filePath: file, changed, diagnostics })
@@ -664,8 +755,20 @@ const runCli = async ({
664755
stdout = defaultStdout,
665756
stderr = defaultStderr,
666757
}: CliOptions = {}) => {
758+
const logger = makeLogger(stdout, stderr)
759+
const {
760+
argv: normalizedArgv,
761+
sourceMapInline,
762+
invalidSourceMapValue,
763+
} = normalizeSourceMapArgv(argv)
764+
765+
if (invalidSourceMapValue) {
766+
logger.error(`Invalid --source-map value: ${invalidSourceMapValue}`)
767+
return 2
768+
}
769+
667770
const { values, positionals } = parseArgs({
668-
args: argv,
771+
args: normalizedArgv,
669772
allowPositionals: true,
670773
options: Object.fromEntries(
671774
optionsTable.map(opt => [
@@ -678,8 +781,6 @@ const runCli = async ({
678781
),
679782
})
680783

681-
const logger = makeLogger(stdout, stderr)
682-
683784
if (values.help) {
684785
stdout.write(buildHelp(stdout.isTTY ?? false))
685786
return 0
@@ -694,6 +795,7 @@ const runCli = async ({
694795
}
695796

696797
const moduleOpts = toModuleOptions(values)
798+
if (sourceMapInline) moduleOpts.sourceMap = true
697799
const cwd = moduleOpts.cwd ?? process.cwd()
698800
const allowStdout = positionals.length <= 1
699801
const fromStdin = positionals.length === 0 || positionals.includes('-')
@@ -710,6 +812,11 @@ const runCli = async ({
710812
const summary = Boolean(values.summary)
711813
const json = Boolean(values.json)
712814

815+
if (sourceMapInline && (outDir || inPlace)) {
816+
logger.error('Inline source maps are only supported when writing to stdout')
817+
return 2
818+
}
819+
713820
if (outDir && inPlace) {
714821
logger.error('Choose either --out-dir or --in-place, not both')
715822
return 2
@@ -769,6 +876,7 @@ const runCli = async ({
769876
outDir,
770877
inPlace,
771878
allowStdout,
879+
sourceMapInline,
772880
},
773881
)
774882

src/format.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,13 @@ const detectDualPackageHazards = async (params: {
419419
}
420420
}
421421

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

708709
if (opts.target === 'commonjs' && fullTransform && containsTopLevelAwait) {
709-
const body = code.toString()
710-
711710
if (opts.topLevelAwait === 'wrap') {
712-
const tlaPromise = `const __tla = (async () => {\n${body}\nreturn module.exports;\n})();\n`
713-
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`
714-
const attach = `__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n`
715-
return `${tlaPromise}${setPromise}${attach}`
711+
code.prepend('const __tla = (async () => {\n')
712+
code.append('\nreturn module.exports;\n})();\n')
713+
code.append(
714+
'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',
715+
)
716+
code.append(
717+
'__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n',
718+
)
719+
} else {
720+
code.prepend(';(async () => {\n')
721+
code.append('\n})();\n')
716722
}
717-
718-
return `;(async () => {\n${body}\n})();\n`
719723
}
720724

721-
return code.toString()
725+
return opts.sourceMap ? code : code.toString()
722726
}
723727

724728
export { format, collectDualPackageUsage, dualPackageHazardDiagnostics }

0 commit comments

Comments
 (0)