diff --git a/crates/oxc_transformer/src/lib.rs b/crates/oxc_transformer/src/lib.rs index 5da9a284ffa5e..749ca37b8222f 100644 --- a/crates/oxc_transformer/src/lib.rs +++ b/crates/oxc_transformer/src/lib.rs @@ -48,7 +48,7 @@ pub use crate::{ es2015::{ArrowFunctionsOptions, ES2015Options}, options::{BabelOptions, TransformOptions}, react::{ReactJsxRuntime, ReactOptions, ReactRefreshOptions}, - typescript::TypeScriptOptions, + typescript::{RewriteExtensionsMode, TypeScriptOptions}, }; use crate::{ context::{Ctx, TransformCtx}, @@ -383,6 +383,30 @@ impl<'a> Traverse<'a> for Transformer<'a> { self.x3_es2015.enter_variable_declarator(node, ctx); } + fn enter_import_declaration( + &mut self, + node: &mut ImportDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + self.x0_typescript.enter_import_declaration(node, ctx); + } + + fn enter_export_all_declaration( + &mut self, + node: &mut ExportAllDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + self.x0_typescript.enter_export_all_declaration(node, ctx); + } + + fn enter_export_named_declaration( + &mut self, + node: &mut ExportNamedDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + self.x0_typescript.enter_export_named_declaration(node, ctx); + } + fn enter_ts_export_assignment( &mut self, export_assignment: &mut TSExportAssignment<'a>, diff --git a/crates/oxc_transformer/src/options/transformer.rs b/crates/oxc_transformer/src/options/transformer.rs index b244d6d4671cf..aaea129daf0d3 100644 --- a/crates/oxc_transformer/src/options/transformer.rs +++ b/crates/oxc_transformer/src/options/transformer.rs @@ -196,12 +196,23 @@ impl TransformOptions { }); transformer_options.typescript = { - let plugin_name = "transform-typescript"; - from_value::(get_plugin_options(plugin_name, options)) + let preset_name = "typescript"; + if options.has_preset("typescript") { + from_value::( + get_preset_options("typescript", options).unwrap_or_else(|| json!({})), + ) .unwrap_or_else(|err| { - report_error(plugin_name, &err, false, &mut errors); + report_error(preset_name, &err, true, &mut errors); TypeScriptOptions::default() }) + } else { + let plugin_name = "transform-typescript"; + from_value::(get_plugin_options(plugin_name, options)) + .unwrap_or_else(|err| { + report_error(plugin_name, &err, false, &mut errors); + TypeScriptOptions::default() + }) + } }; transformer_options.assumptions = if options.assumptions.is_null() { diff --git a/crates/oxc_transformer/src/typescript/mod.rs b/crates/oxc_transformer/src/typescript/mod.rs index e32f594696f7a..b66e04b0f1a64 100644 --- a/crates/oxc_transformer/src/typescript/mod.rs +++ b/crates/oxc_transformer/src/typescript/mod.rs @@ -4,14 +4,16 @@ mod r#enum; mod module; mod namespace; mod options; +mod rewrite_extensions; use std::rc::Rc; use oxc_allocator::Vec; use oxc_ast::ast::*; -use oxc_traverse::TraverseCtx; +use oxc_traverse::{Traverse, TraverseCtx}; +use rewrite_extensions::TypeScriptRewriteExtensions; -pub use self::options::TypeScriptOptions; +pub use self::options::{RewriteExtensionsMode, TypeScriptOptions}; use self::{annotations::TypeScriptAnnotations, r#enum::TypeScriptEnum}; use crate::context::Ctx; @@ -43,6 +45,7 @@ pub struct TypeScript<'a> { annotations: TypeScriptAnnotations<'a>, r#enum: TypeScriptEnum<'a>, + rewrite_extensions: TypeScriptRewriteExtensions, } impl<'a> TypeScript<'a> { @@ -52,12 +55,47 @@ impl<'a> TypeScript<'a> { Self { annotations: TypeScriptAnnotations::new(Rc::clone(&options), Rc::clone(&ctx)), r#enum: TypeScriptEnum::new(Rc::clone(&ctx)), + rewrite_extensions: TypeScriptRewriteExtensions::new( + options.rewrite_import_extensions.clone().unwrap_or_default(), + ), options, ctx, } } } +impl<'a> Traverse<'a> for TypeScript<'a> { + fn enter_import_declaration( + &mut self, + node: &mut ImportDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if self.options.rewrite_import_extensions.is_some() { + self.rewrite_extensions.enter_import_declaration(node, ctx); + } + } + + fn enter_export_all_declaration( + &mut self, + node: &mut ExportAllDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if self.options.rewrite_import_extensions.is_some() { + self.rewrite_extensions.enter_export_all_declaration(node, ctx); + } + } + + fn enter_export_named_declaration( + &mut self, + node: &mut ExportNamedDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if self.options.rewrite_import_extensions.is_some() { + self.rewrite_extensions.enter_export_named_declaration(node, ctx); + } + } +} + // Transforms impl<'a> TypeScript<'a> { pub fn transform_program(&self, program: &mut Program<'a>, ctx: &mut TraverseCtx) { diff --git a/crates/oxc_transformer/src/typescript/options.rs b/crates/oxc_transformer/src/typescript/options.rs index d9c2a6cd4aec6..656fa1dcb4eed 100644 --- a/crates/oxc_transformer/src/typescript/options.rs +++ b/crates/oxc_transformer/src/typescript/options.rs @@ -1,6 +1,9 @@ -use std::borrow::Cow; +use std::{borrow::Cow, fmt}; -use serde::Deserialize; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, +}; use crate::context::TransformCtx; @@ -45,6 +48,16 @@ pub struct TypeScriptOptions { /// Unused. pub optimize_const_enums: bool, + + // Preset options + /// Modifies extensions in import and export declarations. + /// + /// This option, when used together with TypeScript's [`allowImportingTsExtension`](https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions) option, + /// allows to write complete relative specifiers in import declarations while using the same extension used by the source files. + /// + /// When set to `true`, same as [`RewriteExtensionsMode::Rewrite`]. Defaults to `false` (do nothing). + #[serde(deserialize_with = "deserialize_rewrite_import_extensions")] + pub rewrite_import_extensions: Option, } impl TypeScriptOptions { @@ -93,6 +106,63 @@ impl Default for TypeScriptOptions { allow_namespaces: default_as_true(), allow_declare_fields: default_as_true(), optimize_const_enums: false, + rewrite_import_extensions: None, } } } + +#[derive(Debug, Clone, Default)] +pub enum RewriteExtensionsMode { + /// Rewrite `.ts`/`.mts`/`.cts` extensions in import/export declarations to `.js`/`.mjs`/`.cjs`. + #[default] + Rewrite, + /// Remove `.ts`/`.mts`/`.cts`/`.tsx` extensions in import/export declarations. + Remove, +} + +impl RewriteExtensionsMode { + pub fn is_remove(&self) -> bool { + matches!(self, Self::Remove) + } +} + +pub fn deserialize_rewrite_import_extensions<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct RewriteExtensionsModeVisitor; + + impl<'de> Visitor<'de> for RewriteExtensionsModeVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("true, false, \"rewrite\", or \"remove\"") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + if value { + Ok(Some(RewriteExtensionsMode::Rewrite)) + } else { + Ok(None) + } + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "rewrite" => Ok(Some(RewriteExtensionsMode::Rewrite)), + "remove" => Ok(Some(RewriteExtensionsMode::Remove)), + _ => Err(E::custom(format!("Expected RewriteExtensionsMode is either \"rewrite\" or \"remove\" but found: {value}"))), + } + } + } + + deserializer.deserialize_any(RewriteExtensionsModeVisitor) +} diff --git a/crates/oxc_transformer/src/typescript/rewrite_extensions.rs b/crates/oxc_transformer/src/typescript/rewrite_extensions.rs new file mode 100644 index 0000000000000..77549ba007c04 --- /dev/null +++ b/crates/oxc_transformer/src/typescript/rewrite_extensions.rs @@ -0,0 +1,89 @@ +//! Rewrite import extensions +//! +//! This plugin is used to rewrite/remove extensions from import/export source. +//! It is only handled source that contains `/` or `\` in the source. +//! +//! Based on Babel's [plugin-rewrite-ts-imports](https://github.com/babel/babel/blob/3bcfee232506a4cebe410f02042fb0f0adeeb0b1/packages/babel-preset-typescript/src/plugin-rewrite-ts-imports.ts) + +use oxc_ast::ast::{ + ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, StringLiteral, +}; +use oxc_traverse::{Traverse, TraverseCtx}; + +use super::options::RewriteExtensionsMode; + +pub struct TypeScriptRewriteExtensions { + mode: RewriteExtensionsMode, +} + +impl TypeScriptRewriteExtensions { + pub fn new(mode: RewriteExtensionsMode) -> Self { + Self { mode } + } + + pub fn rewrite_extensions<'a>( + &self, + source: &mut StringLiteral<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + let value = source.value.as_str(); + if !value.contains(|c| c == '/' || c == '\\') { + return; + } + + let Some((_, extension)) = value.rsplit_once('.') else { return }; + + let replace = match extension { + "mts" => "mjs", + "cts" => "cjs", + "ts" | "tsx" => "js", + _ => return, // do not rewrite or remove other unknown extensions + }; + + let value = value.trim_end_matches(extension); + source.value = if self.mode.is_remove() { + ctx.ast.atom(value.trim_end_matches('.')) + } else { + let mut value = value.to_string(); + value.push_str(replace); + ctx.ast.atom(&value) + }; + } +} + +impl<'a> Traverse<'a> for TypeScriptRewriteExtensions { + fn enter_import_declaration( + &mut self, + node: &mut ImportDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if node.import_kind.is_type() { + return; + } + self.rewrite_extensions(&mut node.source, ctx); + } + + fn enter_export_named_declaration( + &mut self, + node: &mut ExportNamedDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if node.export_kind.is_type() { + return; + } + if let Some(source) = node.source.as_mut() { + self.rewrite_extensions(source, ctx); + } + } + + fn enter_export_all_declaration( + &mut self, + node: &mut ExportAllDeclaration<'a>, + ctx: &mut TraverseCtx<'a>, + ) { + if node.export_kind.is_type() { + return; + } + self.rewrite_extensions(&mut node.source, ctx); + } +} diff --git a/napi/transform/index.d.ts b/napi/transform/index.d.ts index da8fbd21ad41e..938b9fcf0fa4b 100644 --- a/napi/transform/index.d.ts +++ b/napi/transform/index.d.ts @@ -18,7 +18,7 @@ export interface Es2015BindingOptions { } /** TypeScript Isolated Declarations for Standalone DTS Emit */ -function isolatedDeclaration(filename: string, sourceText: string, options: IsolatedDeclarationsOptions): IsolatedDeclarationsResult +export declare function isolatedDeclaration(filename: string, sourceText: string, options: IsolatedDeclarationsOptions): IsolatedDeclarationsResult export interface IsolatedDeclarationsOptions { sourcemap: boolean @@ -136,7 +136,7 @@ export interface SourceMap { * @returns an object containing the transformed code, source maps, and any * errors that occurred during parsing or transformation. */ -function transform(filename: string, sourceText: string, options?: TransformOptions | undefined | null): TransformResult +export declare function transform(filename: string, sourceText: string, options?: TransformOptions | undefined | null): TransformResult /** * Options for transforming a JavaScript or TypeScript file. @@ -230,5 +230,16 @@ export interface TypeScriptBindingOptions { * @default false */ declaration?: boolean + /** + * Rewrite or remove TypeScript import/export declaration extensions. + * + * - When set to `rewrite`, it will change `.ts`, `.mts`, `.cts` extensions to `.js`, `.mjs`, `.cjs` respectively. + * - When set to `remove`, it will remove the extensions entirely. + * - When set to `true`, it's equivalent to `rewrite`. + * - When set to `false` or omitted, no changes will be made to the extensions. + * + * @default false + */ + rewriteImportExtensions?: 'rewrite' | 'remove' | boolean } diff --git a/napi/transform/src/options.rs b/napi/transform/src/options.rs index b7bbcdb512c82..37c2fb556163f 100644 --- a/napi/transform/src/options.rs +++ b/napi/transform/src/options.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use napi::Either; use napi_derive::napi; use oxc_transformer::{ - ArrowFunctionsOptions, ES2015Options, ReactJsxRuntime, ReactOptions, TypeScriptOptions, + ArrowFunctionsOptions, ES2015Options, ReactJsxRuntime, ReactOptions, RewriteExtensionsMode, + TypeScriptOptions, }; #[napi(object)] @@ -22,6 +24,16 @@ pub struct TypeScriptBindingOptions { /// /// @default false pub declaration: Option, + /// Rewrite or remove TypeScript import/export declaration extensions. + /// + /// - When set to `rewrite`, it will change `.ts`, `.mts`, `.cts` extensions to `.js`, `.mjs`, `.cjs` respectively. + /// - When set to `remove`, it will remove `.ts`/`.mts`/`.cts`/`.tsx` extension entirely. + /// - When set to `true`, it's equivalent to `rewrite`. + /// - When set to `false` or omitted, no changes will be made to the extensions. + /// + /// @default false + #[napi(ts_type = "'rewrite' | 'remove' | boolean")] + pub rewrite_import_extensions: Option>, } impl From for TypeScriptOptions { @@ -36,6 +48,22 @@ impl From for TypeScriptOptions { allow_namespaces: options.allow_namespaces.unwrap_or(ops.allow_namespaces), allow_declare_fields: options.allow_declare_fields.unwrap_or(ops.allow_declare_fields), optimize_const_enums: false, + rewrite_import_extensions: options.rewrite_import_extensions.and_then(|value| { + match value { + Either::A(v) => { + if v { + Some(RewriteExtensionsMode::Rewrite) + } else { + None + } + } + Either::B(v) => match v.as_str() { + "rewrite" => Some(RewriteExtensionsMode::Rewrite), + "remove" => Some(RewriteExtensionsMode::Remove), + _ => None, + }, + } + }), } } } diff --git a/tasks/transform_conformance/babel.snap.md b/tasks/transform_conformance/babel.snap.md index fb18ba6fc1254..8a419d58a5dcf 100644 --- a/tasks/transform_conformance/babel.snap.md +++ b/tasks/transform_conformance/babel.snap.md @@ -1,6 +1,6 @@ commit: 3bcfee23 -Passed: 309/1021 +Passed: 310/1021 # All Passed: * babel-plugin-transform-optional-catch-binding @@ -2101,7 +2101,7 @@ failed to resolve query: failed to parse the rest of input: ...'' -# babel-preset-typescript (4/10) +# babel-preset-typescript (5/10) * jsx-compat/ts-invalid/input.ts x Expected `>` but found `/` ,-[tasks/coverage/babel/packages/babel-preset-typescript/test/fixtures/jsx-compat/ts-invalid/input.ts:1:7] @@ -2141,9 +2141,6 @@ failed to resolve query: failed to parse the rest of input: ...'' | rebuilt : SymbolId(0): SymbolFlags(FunctionScopedVariable) -* opts/rewriteImportExtensions/input.ts - - # babel-plugin-transform-typescript (41/152) * cast/as-expression/input.ts diff --git a/tasks/transform_conformance/oxc.snap.md b/tasks/transform_conformance/oxc.snap.md index 2755bc28fb1f9..b2f771354835c 100644 --- a/tasks/transform_conformance/oxc.snap.md +++ b/tasks/transform_conformance/oxc.snap.md @@ -1,9 +1,10 @@ commit: 3bcfee23 -Passed: 10/39 +Passed: 11/40 # All Passed: * babel-plugin-transform-optional-catch-binding +* babel-preset-typescript # babel-plugin-transform-nullish-coalescing-operator (0/1) diff --git a/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/input.ts b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/input.ts new file mode 100644 index 0000000000000..b3589f2e8d283 --- /dev/null +++ b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/input.ts @@ -0,0 +1,11 @@ +import "./a.ts"; +import "./a.mts"; +import "./a.cts"; +import "./react.tsx"; +// .mtsx and .ctsx are not valid and should not be transformed. +import "./react.mtsx"; +import "./react.ctsx"; +import "a-package/file.ts"; +// Bare import, it's either a node package or remapped by an import map +import "soundcloud.ts"; +import "ipaddr.js"; diff --git a/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/options.json b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/options.json new file mode 100644 index 0000000000000..6e7f6363478ca --- /dev/null +++ b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/options.json @@ -0,0 +1,4 @@ +{ + "sourceType": "module", + "presets": [["typescript", { "rewriteImportExtensions": "remove" }]] +} diff --git a/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/output.js b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/output.js new file mode 100644 index 0000000000000..0e2d8f559d2a0 --- /dev/null +++ b/tasks/transform_conformance/tests/babel-preset-typescript/test/fixtures/removeImportExtensions/output.js @@ -0,0 +1,11 @@ +import "./a"; +import "./a"; +import "./a"; +import "./react"; +// .mtsx and .ctsx are not valid and should not be transformed. +import "./react.mtsx"; +import "./react.ctsx"; +import "a-package/file"; +// Bare import, it's either a node package or remapped by an import map +import "soundcloud.ts"; +import "ipaddr.js";