Repository navigation
feat: add deno transpile subcommand - #32691
Conversation
Add a new `deno transpile` subcommand that transpiles TypeScript/JSX/TSX files to JavaScript using deno_ast. Supports single and multiple file transpilation with configurable source map output (none/inline/separate). Co-Authored-By: Claude Opus 4.6 <[email protected]>
When `--declaration` is passed, invoke TSC with `emitDeclarationOnly` to generate .d.ts type declaration files alongside the transpiled JS. Changes: - Extend TSC `op_emit` to capture .d.ts files instead of panicking - Add `emitted_files` field to TSC Response to propagate declaration output - Call `program.emit()` in the JS compiler when declaration mode is active - Build module graph and invoke TSC from the transpile tool Co-Authored-By: Claude Opus 4.6 <[email protected]>
9 test cases covering: - Basic transpile to stdout - Output to file with -o flag - Multiple files with --outdir - Inline and separate source map modes - .d.ts declaration generation with --declaration - Error on -o with multiple files - Skipping non-emittable .js files - TSX transpilation Co-Authored-By: Claude Opus 4.6 <[email protected]>
| Ok(()) | ||
| } | ||
|
|
||
| fn transpile_parse( |
- Add arg parsing unit tests for transpile subcommand - Create CliFactory at top of transpile() and use it throughout - Use sys.with_paths_in_errors() for filesystem ops with better error context - Add create_request_npm_state() factory method to reduce duplication - Use deno_path_util::url_to_file_path instead of specifier.to_file_path() - Add error context to all filesystem operations and compute_output_path Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Preserve relative directory structure in --outdir instead of flattening - Fix outdir spec test to work on Windows (backslash path separators) - Remove unnecessary canonicalize_path call - Clarify --declaration behavior in help text Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Make --output and --outdir mutually exclusive via clap conflicts_with - Error when --declaration used without -o/--outdir - Make type-check errors from --declaration hard errors (exit code 1) - Map .mts->.mjs, .cts->.cjs instead of always .js - Error on --source-map separate when outputting to stdout - Gate .d.ts capture in op_emit_inner behind capture_emitted_files flag - Use BTreeMap for emitted_files for deterministic output order - Error when all input files are skipped (nothing transpiled) - Fix strip_prefix(cwd) producing bad paths for files outside cwd - Add tests for new error cases Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Use deno.json compilerOptions for transpilation (JSX, decorators, etc.) instead of hardcoded defaults - Fix .d.ts placement with -o flag: place next to output file, not source - Use TranspileResult::into_source() instead of redundant match arms - Use relative filename for source map file field instead of file:// URL - Add experimental warning matching deno bundle pattern - Add spec tests for --declaration --outdir and --declaration -o subdir - Extract long inline test expectations to .out files
fibibot
left a comment
There was a problem hiding this comment.
Picked this up after @dsherret's CHANGES_REQUESTED. The arg-parse tests have been added and most of the path-handling/error-context items addressed; the open architectural item (use TypeChecker rather than calling tsc::exec directly from a tool) is still pending and dsherret offered to refactor — defer to him and you on that.
A handful of substance bugs I caught while reading the new code:
Bugs
Source-map filename uses with_extension("js.map") — wrong for .cts/.mts
let map_path = output_path.with_extension("js.map");Path::with_extension replaces only the last extension component:
out.js→out.js.map✓helpers.cjs→helpers.js.map✗ (should behelpers.cjs.map, and overwrites whatever else might be there)helpers.mjs→helpers.js.map✗
Also affects the source-map ↔ output-file linkage, since the emitted .js/.cjs/.mjs will reference helpers.js.map (which doesn't exist). Pre-existing extension should be appended: output_path.with_extension(format!("{ext}.map")) where ext is what js_extension_for_media_type returned for the input's media type.
The --outdir test only uses .ts inputs (main.ts / helpers.ts), so .cts/.mts inputs aren't covered anywhere — would catch this.
EmitOptions::source_map_file set to the input basename
source_map_file: Some(
file_path.file_name().unwrap_or_default().to_string_lossy().into_owned()
),source_map_file controls the file field in the emitted source map JSON, which per the spec should be the generated file (the .js output), not the original .ts input. As written, the source map says it describes main.ts, but it actually describes main.js — debuggers and other consumers will key off the wrong filename. Use output_path.file_name() (or js_filename) instead.
source_map: source_map_option clobbers compilerOptions.sourceMap from deno.json
let emit_options = EmitOptions {
source_map: source_map_option,
source_map_file: Some(...),
..transpile_and_emit_options.emit.clone()
};source_map_option is SourceMapOption::None by default (when --source-map isn't passed). That always overrides whatever the user set in deno.json compilerOptions.sourceMap / inlineSourceMap. So deno transpile main.ts silently ignores deno.json's source-map config. If the intent is "CLI flag wins when present, otherwise fall through to deno.json," the merge should be conditional:
let emit_options = if matches!(transpile_flags.source_map, SourceMapMode::None) {
transpile_and_emit_options.emit.clone() // honor deno.json
} else {
EmitOptions { source_map: source_map_option, ..transpile_and_emit_options.emit.clone() }
};(Or document explicitly that --source-map is required to enable maps from this CLI.)
emit_declarations uses only root_names[0]'s compiler options for all files
let first_specifier = &root_names[0].0;
let base_compiler_options = compiler_options_resolver
.for_specifier(first_specifier)
.compiler_options_for_lib(...)?;If transpile a.ts b.ts is invoked across two folders with different deno.json scopes, only a.ts's compiler options are used to type-check both files. The non-declaration loop already does the right thing (for_specifier per-file). For declarations, since the whole graph is type-checked under one TSC pass, you'd at minimum want to error out if the root_names span multiple compiler-options scopes — silent application of the first scope's options across the rest is the kind of bug that surfaces only in monorepos.
Smaller nits
tests/specs/transpile/__test__.jsoncdoesn't cover.mts/.ctsinput — would have caught the.js.mapextension bug.tests/specs/transpile/declaration_emit.outdoesn't constrain the order ofEmit out.jsvsEmit main.d.ts. The current code emits them in two separate phases (JS first, then declarations), but consider asserting the order to lock in the user-facing console output.tests/specs/transpile/error_separate_sourcemap_stdout.out— the error wording "Cannot use --source-map separate when outputting to stdout. Use --output or --outdir, or use --source-map inline instead." is a clear actionable message; nice.- Declaration code path captures
.d.ts.mapfiles inop_emit_inner(name.ends_with(".d.ts.map")) butemitDeclarationOnly: trueplus nodeclarationMap: truemeans TSC won't emit.d.ts.map. Capture is harmless but unused. SetdeclarationMapif you want to pass through the user's setting. let response = tsc::exec(...)inemit_declarationsignorescli_options.type_check_mode()and forcesTypeCheckMode::All. For--declarationthis is correct (declarations require type-checking) but worth a comment so future readers don't get confused by the apparent flag override.
Architectural
dsherret already flagged that calling tsc::exec directly from a tool is too low-level. This revision exposes TypeChecker::create_request_npm_state as a partial fix — but the bulk of the request setup (config merging via JSON Value mutation, hash_data construction, root_names assembly) is still inline in transpile.rs. If dsherret takes the refactor he offered (move declaration emission into TypeChecker), most of the boilerplate in emit_declarations collapses, and the capture_emitted_files plumbing stops being a Request field — it becomes the difference between two TypeChecker methods.
I'd hold this PR for that refactor before merging — the current shape locks in tsc as a public-ish surface from cli/tools/.
CI
Fully green on the visible jobs. COMMENT rather than approval given the open dsherret thread + the source-map bugs above.
- Fix source map filename for .cts/.mts: use actual output extension (e.g. .mjs.map, .cjs.map) instead of always .js.map - Fix source_map_file to reference the output file (e.g. out.js) instead of the input file (e.g. main.ts) - Validate that all files share the same compiler options scope when generating declarations - Add comment explaining TypeCheckMode::All for declaration emit - Add .mts/.cts test coverage for transpilation and source maps
# Conflicts: # cli/args/flags.rs
Move the low-level tsc::exec setup for declaration emission into a TypeChecker::emit_declarations() method. This encapsulates compiler options merging, hash computation, JSX config resolution, and npm state setup, so transpile.rs only calls one high-level method. Addresses dsherret's review feedback about tsc::exec being too low-level to call from a tool.
fibibot
left a comment
There was a problem hiding this comment.
Every substantive item from my prior review is fixed in the new commits, and dsherret's architectural concern (calling tsc::exec directly from a tool) is fully addressed.
Fixes for my prior bugs
Source-map filename now uses output basename
// Compute the output path/filename so source_map_file references the
// generated file (e.g. "out.js"), not the input (e.g. "main.ts").
let maybe_output_path = if !is_stdout_mode { Some(...) } else { None };
let source_map_filename = if let Some(output_path) = &maybe_output_path {
output_path.file_name().unwrap_or_default().to_string_lossy().into_owned()
} else {
// In stdout mode, use the input-derived JS filename
let ext = js_extension_for_media_type(*media_type);
file_path.with_extension(ext).file_name().unwrap_or_default().to_string_lossy().into_owned()
};The existing check_map.ts now asserts out.js (was previously asserting just version 3). ✓
with_extension("js.map") ↔ .cts/.mts fixed
let ext = output_path.extension().unwrap_or_default().to_string_lossy();
let map_path = output_path.with_extension(format!("{ext}.map"));So out.cjs → out.cjs.map, out.mjs → out.mjs.map, out.js → out.js.map. ✓ Plus new test fixtures cts_source_map_separate and mts_source_map_separate lock this in:
"args": "transpile helper.cts -o out.cjs --source-map separate",
"output": "[WILDCARD]Emit [WILDCARD]out.cjs\nEmit [WILDCARD]out.cjs.map\n"And check_cts_map.ts / check_mts_map.ts verify the source map's file field references the correct output filename. ✓
emit_declarations multi-scope check
Now bails with a clear error rather than silently applying the first specifier's options:
if key != first_key {
anyhow::bail!(
"Cannot generate declarations for files with different compiler option scopes.\n\
'{}' uses scope '{}' but '{}' uses scope '{}'.\n\
Separate these files into different invocations.",
...
);
}✓ Better than my suggestion (an error message that tells the user what to do), not just an internal-state validity check.
Architectural fix — TypeChecker::emit_declarations
The boilerplate that was inline in transpile.rs (config merging via JSON Value mutation, hash_data construction, tsc::exec call) has moved into a new TypeChecker::emit_declarations method:
pub fn emit_declarations(
&self,
graph: Arc<ModuleGraph>,
root_names: Vec<(ModuleSpecifier, MediaType)>,
lib: TsTypeLib,
) -> Result<EmitDeclarationsResult, CheckError>transpile.rs now calls type_checker.emit_declarations(graph, root_names, lib)? and consumes a clean EmitDeclarationsResult { diagnostics, emitted_files }. The 60+ lines of inline TSC plumbing collapse to a single call. tsc::exec is no longer reachable from cli/tools/transpile.rs. ✓
This is exactly the shape dsherret asked for in the original CHANGES_REQUESTED. The new method's // Declaration emit requires full type-checking regardless of the user's type_check_mode setting. comment also addresses my non-blocking note about why TypeCheckMode::All is forced.
Smaller items
Still open from prior review
declaration_emit.outstill doesn't constrain the order ofEmit out.jsvsEmit main.d.ts. Cosmetic — won't block..d.ts.mapcapture inop_emit_innerstill unused (nodeclarationMap: trueset). Cosmetic.
New things worth a look
- The
source_map_filenamecomputation logic now lives inline in two branches (stdout vs file mode). For stdout, it computes the would-be output filename from the input — slightly awkward since stdout mode never writes a file. Thefilefield in the inline-source-map JSON would still claimmain.jsfortranspile main.ts --source-map inline. That's actually correct behavior (the inline source map describes the JS that's about to land at stdout, which would conventionally be saved asmain.js), so the asymmetric handling is fine. Just noting since I had to think about it.
CI
134 pass, 2 skipping, 0 fail. ✓
Verdict
Substance is now clean and complete. dsherret hasn't re-reviewed since the architectural refactor (182c056) — his original CHANGES_REQUESTED was on the inline tsc::exec shape, which is now fully resolved by the new TypeChecker::emit_declarations method. Worth pinging him for re-review since this is exactly the shape he offered to do, and he should be the one to confirm the new TypeChecker surface is what he had in mind.
Holding to COMMENT rather than approving — deno transpile is a new public CLI subcommand and the architectural refactor is in dsherret's domain, so his sign-off is the gate. From a substance-correctness standpoint, the previously-flagged bugs are all gone and CI is green.
deno transpile subcommanddeno transpile subcommand
Add
deno transpile-- a standalone subcommand for convertingTypeScript, JSX, and TSX files to JavaScript. This is a build
tool companion to
deno compileanddeno bundle, giving usersa first-class way to produce .js output without external tooling.
Currently marked as experimental.
Usage
Input/output extension mapping
.ts.js.js.map.tsx.js.js.map.jsx.js.js.map.mts.mjs.mjs.map.cts.cjs.cjs.mapFeatures
-o), or directory (--outdir)--source-map.d.tsdeclaration generation via--declarationcompilerOptionsfromdeno.jsonfor JSXtransform, decorators, etc.
filefield correctly references the outputfilename, not the input
Declaration generation uses a new
TypeChecker::emit_declarationsmethod that encapsulates thetsc interaction, keeping the tool code clean.
Changes
cli/args/flags.rs:TranspileFlags,SourceMapMode, clapsubcommand definition
cli/tools/transpile.rs: transpilation viadeno_ast,declaration generation, output path computation, source map
handling
cli/tsc/js.rs,cli/tsc/mod.rs: extendop_emittocapture
.d.tsfiles, addTypeChecker::emit_declarationscli/tsc/99_main_compiler.js: callprogram.emit()whendeclaration/emitDeclarationOnlyis setcli/factory.rs,cli/lib.rs,cli/tools/mod.rs: wiringtests/specs/transpile/: 18 spec tests