use oxc::allocator::Allocator;
use oxc::codegen::Codegen;
use oxc::parser::Parser;
use oxc::semantic::SemanticBuilder;
use oxc::span::SourceType;
use oxc::transformer::{TransformOptions, Transformer, TypeScriptOptions};
use std::path::Path;
pub fn transpile(source: &str, path: &Path) -> Result<String, TsError> {
transpile_inner(source, path, true)
}
pub fn transpile_no_source_map(source: &str, path: &Path) -> Result<String, TsError> {
transpile_inner(source, path, false)
}
fn transpile_inner(source: &str, path: &Path, emit_source_map: bool) -> Result<String, TsError> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("tsx"))
{
return Err(TsError::JsxNotSupported);
}
let source_type = SourceType::from_path(path).map_err(|e| TsError::BadExtension {
path: path.display().to_string(),
inner: e.to_string(),
})?;
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type).parse();
if !parsed.errors.is_empty() {
return Err(TsError::Parse {
path: path.display().to_string(),
errors: parsed
.errors
.iter()
.map(|e| format!("{e:?}"))
.collect::<Vec<_>>()
.join("\n"),
});
}
let mut program = parsed.program;
let opts = TransformOptions {
typescript: TypeScriptOptions {
only_remove_type_imports: true,
..Default::default()
},
..Default::default()
};
let scoping = SemanticBuilder::new()
.with_excess_capacity(2.0)
.build(&program)
.semantic
.into_scoping();
Transformer::new(&allocator, path, &opts).build_with_scoping(scoping, &mut program);
use oxc::codegen::CodegenOptions;
let codegen = Codegen::new()
.with_options(CodegenOptions {
source_map_path: emit_source_map.then(|| path.to_path_buf()),
..Default::default()
})
.build(&program);
let mut code = codegen.code;
if emit_source_map && let Some(map) = codegen.map.as_ref() {
code.push_str("\n//# sourceMappingURL=");
code.push_str(&map.to_data_url());
code.push('\n');
}
crate::esm::rewrite_esm_to_cjs(&code, path).map_err(|e| TsError::Parse {
path: path.display().to_string(),
errors: e,
})
}
pub fn lower_esm_js(source: &str, path: &Path) -> Result<String, TsError> {
crate::esm::rewrite_esm_to_cjs(source, path).map_err(|e| TsError::Parse {
path: path.display().to_string(),
errors: e,
})
}
#[derive(Debug, thiserror::Error)]
pub enum TsError {
#[error(
".tsx (JSX) is not supported by the strip-types transpiler; enable a JSX-aware feature or pre-transpile with an external tool"
)]
JsxNotSupported,
#[error("{path}: TypeScript parse error:\n{errors}")]
Parse { path: String, errors: String },
#[error("{path}: unable to resolve source type from extension ({inner})")]
BadExtension { path: String, inner: String },
}
pub fn is_typescript(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("ts" | "mts" | "cts" | "tsx")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transpile_emits_inline_source_map() {
let p = Path::new("a.ts");
let out = transpile("const x: number = 1", p).unwrap();
assert!(out.contains("const x = 1"), "types stripped: {out}");
assert!(
out.contains("//# sourceMappingURL="),
"default transpile keeps the inline source map: {out}"
);
}
#[test]
fn transpile_no_source_map_strips_types_without_the_map() {
let p = Path::new("a.ts");
let out = transpile_no_source_map("const x: number = 1", p).unwrap();
assert!(out.contains("const x = 1"), "types stripped: {out}");
assert!(
!out.contains("sourceMappingURL"),
"no inline source map in the REPL variant: {out}"
);
}
#[test]
fn no_source_map_variant_handles_a_bare_expression() {
let p = Path::new("<repl>.ts");
let out = transpile_no_source_map("1 as number", p).unwrap();
assert!(out.contains('1'), "value kept: {out}");
assert!(!out.contains("as number"), "cast stripped: {out}");
assert!(!out.contains("sourceMappingURL"), "no map: {out}");
}
}