diff --git a/Cargo.lock b/Cargo.lock index aaa2900d9..284e17f58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -29,6 +35,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64-simd" version = "0.8.0" @@ -48,6 +60,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -92,6 +113,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -134,6 +164,34 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.6.3" @@ -150,6 +208,16 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -188,6 +256,18 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -231,6 +311,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -344,6 +434,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -616,6 +716,16 @@ dependencies = [ "libmimalloc-sys2", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "napi" version = "3.8.2" @@ -738,6 +848,20 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "oxc-browserslist" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb7a1163a5501f935f8722d839b576491b749c695e7a066aa0b8df988b806df" +dependencies = [ + "flate2", + "postcard", + "rustc-hash", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "oxc-miette" version = "2.7.0" @@ -787,12 +911,14 @@ dependencies = [ "oxc-miette", "oxc_allocator", "oxc_ast", + "oxc_codegen", "oxc_diagnostics", "oxc_parser", "oxc_resolver", "oxc_semantic", "oxc_sourcemap", "oxc_span", + "oxc_transformer", "pathdiff", "rustc-hash", "tempfile", @@ -872,11 +998,48 @@ dependencies = [ "oxc_syntax", ] +[[package]] +name = "oxc_codegen" +version = "0.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abfd3d146e6e0d340c183aa0e98f29ab1bba876c282350e5e06ab9d6f536eacd" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_compat" +version = "0.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7319f12eb8d4a05737a7f71642d7a97aee210488dc4041a7a452352a31ac0fe6" +dependencies = [ + "cow-utils", + "oxc-browserslist", + "oxc_syntax", + "rustc-hash", + "serde", +] + [[package]] name = "oxc_data_structures" version = "0.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a42840ce8d83a08a92823dda6189e4d97359feca24a4fa732f3256c4614bb5a4" +dependencies = [ + "ropey", +] [[package]] name = "oxc_diagnostics" @@ -1078,6 +1241,53 @@ dependencies = [ "unicode-id-start", ] +[[package]] +name = "oxc_transformer" +version = "0.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e394bc5221c9e228fc06f54b7f7a3e2d63ed135a50b8678e8485b5b49222bb5" +dependencies = [ + "base64", + "compact_str", + "indexmap", + "itoa", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", + "serde", + "serde_json", + "sha1", +] + +[[package]] +name = "oxc_traverse" +version = "0.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473bf963b351d5b744b75aee9ff6aa41d62f8ca662012b03dc315cac9f1f2e5" +dependencies = [ + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_semantic", + "oxc_span", + "oxc_syntax", + "rustc-hash", +] + [[package]] name = "papaya" version = "0.2.3" @@ -1184,6 +1394,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1269,6 +1491,16 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1387,12 +1619,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simd-json" version = "0.17.0" @@ -1456,6 +1705,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "syn" version = "2.0.114" @@ -1572,6 +1827,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -1632,6 +1893,12 @@ dependencies = [ "ryu", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vsimd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index b5cad74a4..0ec13c69b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,8 @@ oxc_parser = "0.110" oxc_semantic = "0.110" oxc_span = "0.110" oxc_sourcemap = "6.0.1" +oxc_transformer = "0.110" +oxc_codegen = "0.110" # Internal oxc_angular_compiler = { path = "crates/oxc_angular_compiler" } diff --git a/crates/oxc_angular_compiler/Cargo.toml b/crates/oxc_angular_compiler/Cargo.toml index 683d4dc40..a26fa4e3a 100644 --- a/crates/oxc_angular_compiler/Cargo.toml +++ b/crates/oxc_angular_compiler/Cargo.toml @@ -23,15 +23,17 @@ oxc_parser = { workspace = true } oxc_semantic = { workspace = true } oxc_span = { workspace = true } oxc_sourcemap = { workspace = true } +oxc_transformer = { workspace = true } +oxc_codegen = { workspace = true } miette = { workspace = true } rustc-hash = { workspace = true } indexmap = { workspace = true } -oxc_resolver = { version = "11", optional = true } +oxc_resolver = { version = "11" } pathdiff = { version = "0.2", optional = true } [features] default = [] -cross_file_elision = ["oxc_resolver", "pathdiff"] +cross_file_elision = ["pathdiff"] [dev-dependencies] insta = { workspace = true, features = ["glob"] } diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 37b5d2816..27f9ecf04 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -35,9 +35,9 @@ pub use metadata::{ pub use namespace_registry::NamespaceRegistry; pub use transform::{ CompiledComponent, HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap, - LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput, - TransformOptions, TransformResult, build_import_map, compile_component_template, - compile_for_hmr, compile_host_bindings_for_linker, compile_template_for_hmr, - compile_template_for_linker, compile_template_to_js, compile_template_to_js_with_options, - transform_angular_file, + LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, ResolvedTypeScriptOptions, + TemplateCompileOutput, TransformOptions, TransformResult, TypeScriptOption, build_import_map, + compile_component_template, compile_for_hmr, compile_host_bindings_for_linker, + compile_template_for_hmr, compile_template_for_linker, compile_template_to_js, + compile_template_to_js_with_options, transform_angular_file, }; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 0facc40b0..96938add7 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4,15 +4,26 @@ //! containing Angular components into compiled JavaScript. use std::collections::HashMap; +use std::path::{Path, PathBuf}; use oxc_allocator::{Allocator, Vec as OxcVec}; use oxc_ast::ast::{ Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, ImportOrExportKind, Statement, }; +use oxc_codegen::Codegen; use oxc_diagnostics::OxcDiagnostic; use oxc_parser::Parser; +use oxc_resolver::{ + ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions, TsconfigReferences, +}; +use oxc_semantic::SemanticBuilder; use oxc_span::{Atom, SourceType, Span}; +use oxc_transformer::{ + DecoratorOptions, HelperLoaderMode, HelperLoaderOptions, + TransformOptions as OxcTransformOptions, Transformer as OxcTransformer, + TypeScriptOptions as OxcTypeScriptOptions, +}; use rustc_hash::FxHashMap; #[cfg(feature = "cross_file_elision")] @@ -65,6 +76,28 @@ use crate::pipeline::ingest::{ use crate::transform::HtmlToR3Transform; use crate::transform::html_to_r3::TransformOptions as R3TransformOptions; +/// How to resolve TypeScript transform options. +#[derive(Debug, Clone)] +pub enum TypeScriptOption { + /// Auto-discover nearest tsconfig.json from the source file. + Auto, + /// Use explicit tsconfig path. + TsConfigPath(PathBuf), + /// Use pre-resolved options (for testing/NAPI). + Resolved(ResolvedTypeScriptOptions), +} + +/// Pre-resolved TypeScript transform options. +#[derive(Debug, Clone)] +pub struct ResolvedTypeScriptOptions { + /// Use legacy (experimental) decorators. + pub experimental_decorators: bool, + /// Emit decorator metadata for reflection. + pub emit_decorator_metadata: bool, + /// Only remove type-only imports (verbatimModuleSyntax). + pub only_remove_type_imports: bool, +} + /// Options for Angular file transformation. #[derive(Debug, Clone)] pub struct TransformOptions { @@ -171,6 +204,11 @@ pub struct TransformOptions { /// /// Default: false (metadata is dev-only and usually stripped in production) pub emit_class_metadata: bool, + + /// TypeScript-to-JavaScript transformation. + /// When `Some`, runs oxc_transformer after Angular transforms to strip types + /// and lower decorators. Reads tsconfig.json to derive decorator and TS options. + pub typescript: Option, } /// Input for host metadata when passed via TransformOptions. @@ -220,6 +258,8 @@ impl Default for TransformOptions { resolved_imports: None, // Class metadata for TestBed support (disabled by default) emit_class_metadata: false, + // TypeScript transform (disabled by default) + typescript: None, } } } @@ -1313,7 +1353,16 @@ pub fn transform_angular_file( if let Some(id) = &class.id { let name = id.name.to_string(); if class_definitions.contains_key(&name) { - class_positions.push((name, stmt_start, class.body.span.end)); + // Account for non-Angular decorators that precede the class. + // Decorators like @Log(...) appear before `export class` in source, + // so we must insert decls_before_class before those decorators. + let effective_start = class + .decorators + .iter() + .map(|d| d.span.start) + .min() + .map_or(stmt_start, |dec_start| dec_start.min(stmt_start)); + class_positions.push((name, effective_start, class.body.span.end)); } } } @@ -1360,13 +1409,197 @@ pub fn transform_angular_file( } } - result.code = final_code; + // Apply TypeScript transform if requested + if let Some(ts_option) = &options.typescript { + match apply_typescript_transform(&final_code, path, ts_option) { + Ok(transformed) => { + result.code = transformed; + } + Err(diags) => { + result.diagnostics.extend(diags); + result.code = final_code; + } + } + } else { + result.code = final_code; + } + // Note: source maps not supported with string manipulation approach result.map = None; result } +/// Resolve `TypeScriptOption` into `ResolvedTypeScriptOptions` by reading tsconfig.json. +fn resolve_typescript_options( + file_path: &str, + ts_option: &TypeScriptOption, +) -> Result> { + match ts_option { + TypeScriptOption::Resolved(resolved) => Ok(resolved.clone()), + TypeScriptOption::Auto => { + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Auto), + ..ResolveOptions::default() + }); + + match resolver.find_tsconfig(&PathBuf::from(file_path)) { + Ok(Some(tsconfig)) => { + let co = &tsconfig.compiler_options; + Ok(ResolvedTypeScriptOptions { + experimental_decorators: co.experimental_decorators.unwrap_or(false), + emit_decorator_metadata: co.emit_decorator_metadata.unwrap_or(false), + only_remove_type_imports: co.verbatim_module_syntax.unwrap_or(false), + }) + } + Ok(None) => { + // No tsconfig found, use defaults matching NAPI layer defaults + Ok(ResolvedTypeScriptOptions { + experimental_decorators: true, + emit_decorator_metadata: false, + only_remove_type_imports: true, + }) + } + Err(e) => { + Err(vec![OxcDiagnostic::error(format!("Failed to resolve tsconfig: {e}"))]) + } + } + } + TypeScriptOption::TsConfigPath(p) => { + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Manual(TsconfigOptions { + config_file: p.clone(), + references: TsconfigReferences::Auto, + })), + ..ResolveOptions::default() + }); + + match resolver.find_tsconfig(p) { + Ok(Some(tsconfig)) => { + let co = &tsconfig.compiler_options; + Ok(ResolvedTypeScriptOptions { + experimental_decorators: co.experimental_decorators.unwrap_or(false), + emit_decorator_metadata: co.emit_decorator_metadata.unwrap_or(false), + only_remove_type_imports: co.verbatim_module_syntax.unwrap_or(false), + }) + } + Ok(None) => { + // Specific tsconfig path was given but not found, use defaults + Ok(ResolvedTypeScriptOptions { + experimental_decorators: true, + emit_decorator_metadata: false, + only_remove_type_imports: true, + }) + } + Err(e) => { + Err(vec![OxcDiagnostic::error(format!("Failed to resolve tsconfig: {e}"))]) + } + } + } + } +} + +/// Apply TypeScript transformation to the final code string. +/// +/// This re-parses the code, runs `oxc_transformer` to strip TypeScript types +/// and lower decorators, then re-emits via `oxc_codegen`. +fn apply_typescript_transform( + code: &str, + file_path: &str, + ts_option: &TypeScriptOption, +) -> Result> { + let resolved = resolve_typescript_options(file_path, ts_option)?; + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(file_path).unwrap_or_default(); + let parser_ret = Parser::new(&allocator, code, source_type).parse(); + + if !parser_ret.errors.is_empty() { + return Err(parser_ret + .errors + .into_iter() + .map(|e| OxcDiagnostic::error(e.to_string())) + .collect()); + } + + let mut program = parser_ret.program; + + // Build semantic info for the transformer + let semantic_ret = SemanticBuilder::new().build(&program); + if !semantic_ret.errors.is_empty() { + return Err(semantic_ret + .errors + .into_iter() + .map(|e| OxcDiagnostic::error(e.to_string())) + .collect()); + } + + let scoping = semantic_ret.semantic.into_scoping(); + + // Map resolved options to oxc_transformer options. + // Use External helper mode to emit `babelHelpers.decorate(...)` instead of + // importing from `@oxc-project/runtime` (which may not be installed). + let transform_options = OxcTransformOptions { + typescript: OxcTypeScriptOptions { + only_remove_type_imports: resolved.only_remove_type_imports, + ..OxcTypeScriptOptions::default() + }, + decorator: DecoratorOptions { + legacy: resolved.experimental_decorators, + emit_decorator_metadata: resolved.emit_decorator_metadata, + }, + helper_loader: HelperLoaderOptions { + mode: HelperLoaderMode::External, + ..HelperLoaderOptions::default() + }, + ..OxcTransformOptions::default() + }; + + let path = Path::new(file_path); + let transformer = OxcTransformer::new(&allocator, path, &transform_options); + let transform_ret = transformer.build_with_scoping(scoping, &mut program); + + if !transform_ret.errors.is_empty() { + return Err(transform_ret + .errors + .into_iter() + .map(|e| OxcDiagnostic::error(e.to_string())) + .collect()); + } + + let codegen_ret = Codegen::new().build(&program); + let mut code = codegen_ret.code; + + // If the output references babelHelpers (from External helper mode), + // inject a minimal polyfill. Must go AFTER imports to be valid ESM. + if code.contains("babelHelpers.decorate") { + let helper = "var babelHelpers = { decorate(decorators, target) { \ + for (var i = decorators.length - 1; i >= 0; i--) { \ + target = decorators[i](target) || target; } return target; } };\n"; + // Find the end of the last import statement to insert after it. + let insert_pos = find_after_last_import(&code); + code.insert_str(insert_pos, helper); + } + + Ok(code) +} + +/// Find the byte offset right after the last `import` statement in the code. +/// Falls back to position 0 if no imports found. +fn find_after_last_import(code: &str) -> usize { + // Find lines starting with "import " — the codegen output is clean and predictable. + let mut last_import_end = 0; + let mut pos = 0; + for line in code.lines() { + let line_end = pos + line.len() + 1; // +1 for newline + if line.starts_with("import ") { + last_import_end = line_end.min(code.len()); + } + pos = line_end; + } + last_import_end +} + /// Result of full component compilation including ɵcmp/ɵfac. struct FullCompilationResult { /// Compiled template function as JavaScript. diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 3e58b3e14..18c570c53 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -58,10 +58,10 @@ pub use transform::{HtmlToR3Transform, html_to_r3::html_ast_to_r3_ast}; pub use component::{ AngularVersion, ChangeDetectionStrategy, CompiledComponent, ComponentMetadata, HmrTemplateCompileOutput, HostMetadata, HostMetadataInput, ImportInfo, ImportMap, - NamespaceRegistry, ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult, - ViewEncapsulation, build_import_map, compile_component_template, compile_for_hmr, - compile_template_for_hmr, compile_template_to_js, compile_template_to_js_with_options, - extract_component_metadata, transform_angular_file, + NamespaceRegistry, ResolvedResources, ResolvedTypeScriptOptions, TemplateCompileOutput, + TransformOptions, TransformResult, TypeScriptOption, ViewEncapsulation, build_import_map, + compile_component_template, compile_for_hmr, compile_template_for_hmr, compile_template_to_js, + compile_template_to_js_with_options, extract_component_metadata, transform_angular_file, }; // Re-export cross-file elision types when feature is enabled diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index feab4078a..9faadf47d 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5,7 +5,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::{ - AngularVersion, R3Node, ResolvedResources, TransformOptions as ComponentTransformOptions, + AngularVersion, R3Node, ResolvedResources, ResolvedTypeScriptOptions, + TransformOptions as ComponentTransformOptions, TypeScriptOption, output::ast::FunctionExpr, output::emitter::JsEmitter, parser::html::HtmlParser, @@ -5857,3 +5858,124 @@ fn test_host_binding_pure_function_declarations_emitted() { } } } + +// ============================================================================= +// TypeScript Transform Integration Tests +// ============================================================================= + +#[test] +fn typescript_transform_strips_type_annotations() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-test', + standalone: true, + template: `hello`, +}) +export class TestComponent { + title: string = 'hello'; +} +"#; + + let options = ComponentTransformOptions { + typescript: Some(TypeScriptOption::Resolved(ResolvedTypeScriptOptions { + experimental_decorators: true, + emit_decorator_metadata: false, + only_remove_type_imports: false, + })), + ..ComponentTransformOptions::default() + }; + + let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + // Type annotation should be stripped + assert!( + !result.code.contains(": string"), + "Type annotation should be stripped from output. Got:\n{}", + result.code + ); + // Angular static fields should still be present + assert!( + result.code.contains("\u{0275}fac") || result.code.contains("ɵfac"), + "Angular factory should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn typescript_transform_lowers_custom_decorator() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +function TrackChanges() { + return function(target: any) { return target; }; +} + +@TrackChanges() +@Component({ + selector: 'app-custom', + standalone: true, + template: `custom decorator`, +}) +export class CustomDecoratorComponent {} +"#; + + let options = ComponentTransformOptions { + typescript: Some(TypeScriptOption::Resolved(ResolvedTypeScriptOptions { + experimental_decorators: true, + emit_decorator_metadata: false, + only_remove_type_imports: false, + })), + ..ComponentTransformOptions::default() + }; + + let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + // The @TrackChanges decorator syntax should no longer be present as raw syntax + assert!( + !result.code.contains("@TrackChanges"), + "Custom decorator should be lowered (not present as @TrackChanges). Got:\n{}", + result.code + ); + // Angular static fields should still be present + assert!( + result.code.contains("\u{0275}cmp") || result.code.contains("ɵcmp"), + "Angular component definition should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn typescript_transform_none_preserves_typescript() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-test', + standalone: true, + template: `hello`, +}) +export class TestComponent { + title: string = 'hello'; +} +"#; + + let options = + ComponentTransformOptions { typescript: None, ..ComponentTransformOptions::default() }; + + let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + // Type annotation should still be present when typescript is None + assert!( + result.code.contains(": string"), + "Type annotation should be preserved when typescript option is None. Got:\n{}", + result.code + ); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts new file mode 100644 index 000000000..e5d7731b9 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts @@ -0,0 +1,65 @@ +/** + * Custom (non-Angular) class decorators. + * + * Tests that non-Angular decorators are properly lowered to JavaScript + * when the `typescript` transform option is enabled. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'single-custom-decorator', + category: 'edge-cases', + description: 'Component with a single custom class decorator', + className: 'CustomDecoratorComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +function TrackChanges() { + return function(target: any) { return target; }; +} + +@TrackChanges() +@Component({ + selector: 'app-custom-decorator', + standalone: true, + template: \`Custom decorator test\`, +}) +export class CustomDecoratorComponent {} + `.trim(), + expectedFeatures: ['ɵɵdefineComponent'], + }, + { + name: 'multiple-custom-decorators', + category: 'edge-cases', + description: 'Component with multiple custom class decorators', + className: 'MultiDecoratorComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +function Log(message: string) { + return function(target: any) { return target; }; +} + +function Sealed() { + return function(target: any) { + Object.seal(target); + Object.seal(target.prototype); + return target; + }; +} + +@Log('Multi decorator component') +@Sealed() +@Component({ + selector: 'app-multi-decorator', + standalone: true, + template: \`Multiple decorators\`, +}) +export class MultiDecoratorComponent {} + `.trim(), + expectedFeatures: ['ɵɵdefineComponent'], + }, +] diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index d00654182..00110fcca 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -821,6 +821,20 @@ export interface TransformOptions { * and provide the actual file paths here. */ resolvedImports?: Map + /** + * TypeScript-to-JavaScript transformation. + * - `true`: auto-discover nearest tsconfig.json + * - string: explicit tsconfig path + * - object: pre-resolved options + */ + typescript?: + | boolean + | string + | { + experimentalDecorators?: boolean + emitDecoratorMetadata?: boolean + onlyRemoveTypeImports?: boolean + } } /** Result of transforming an Angular file. */ @@ -840,6 +854,16 @@ export interface TransformResult { /** Compilation warnings. */ warnings: Array } + +/** Pre-resolved TypeScript transform options. */ +export interface TypeScriptTransformOptions { + /** Use legacy (experimental) decorators. Default: true. */ + experimentalDecorators?: boolean + /** Emit decorator metadata for reflection. Default: false. */ + emitDecoratorMetadata?: boolean + /** Only remove type-only imports (verbatimModuleSyntax). Default: true. */ + onlyRemoveTypeImports?: boolean +} export interface Comment { type: 'Line' | 'Block' value: string diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 031cac7d1..4377acdf1 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -13,14 +13,17 @@ static ALLOC: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc; use std::collections::HashMap; +use std::path::PathBuf; -use napi::{Task, bindgen_prelude::AsyncTask}; +use napi::{Either, Task, bindgen_prelude::AsyncTask}; use napi_derive::napi; use oxc_allocator::Allocator; use oxc_angular_compiler::{ AngularVersion as RustAngularVersion, ChangeDetectionStrategy as RustChangeDetectionStrategy, - HostMetadataInput as RustHostMetadataInput, TransformOptions as RustTransformOptions, + HostMetadataInput as RustHostMetadataInput, + ResolvedTypeScriptOptions as RustResolvedTypeScriptOptions, + TransformOptions as RustTransformOptions, TypeScriptOption as RustTypeScriptOption, ViewEncapsulation as RustViewEncapsulation, build_ctor_params_metadata as core_build_ctor_params_metadata, build_decorator_metadata_array as core_build_decorator_metadata_array, @@ -108,6 +111,18 @@ impl From for RustHostMetadataInput { } } +/// Pre-resolved TypeScript transform options. +#[derive(Default, Clone)] +#[napi(object)] +pub struct TypeScriptTransformOptions { + /// Use legacy (experimental) decorators. Default: true. + pub experimental_decorators: Option, + /// Emit decorator metadata for reflection. Default: false. + pub emit_decorator_metadata: Option, + /// Only remove type-only imports (verbatimModuleSyntax). Default: true. + pub only_remove_type_imports: Option, +} + /// Options for transforming an Angular component. #[derive(Default)] #[napi(object)] @@ -200,6 +215,15 @@ pub struct TransformOptions { /// and provide the actual file paths here. #[napi(ts_type = "Map")] pub resolved_imports: Option>, + + /// TypeScript-to-JavaScript transformation. + /// - `true`: auto-discover nearest tsconfig.json + /// - string: explicit tsconfig path + /// - object: pre-resolved options + #[napi( + ts_type = "boolean | string | { experimentalDecorators?: boolean, emitDecoratorMetadata?: boolean, onlyRemoveTypeImports?: boolean }" + )] + pub typescript: Option>>, } impl From for RustTransformOptions { @@ -231,6 +255,26 @@ impl From for RustTransformOptions { resolved_imports: options.resolved_imports, // Class metadata for TestBed support emit_class_metadata: options.emit_class_metadata.unwrap_or(false), + // TypeScript transform + typescript: options.typescript.and_then(|ts| match ts { + Either::A(enabled) => { + if enabled { + Some(RustTypeScriptOption::Auto) + } else { + None + } + } + Either::B(either) => Some(match either { + Either::A(path) => RustTypeScriptOption::TsConfigPath(PathBuf::from(path)), + Either::B(opts) => { + RustTypeScriptOption::Resolved(RustResolvedTypeScriptOptions { + experimental_decorators: opts.experimental_decorators.unwrap_or(true), + emit_decorator_metadata: opts.emit_decorator_metadata.unwrap_or(false), + only_remove_type_imports: opts.only_remove_type_imports.unwrap_or(true), + }) + } + }), + }), } } } diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 4376dedab..c0d30fb08 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -8,10 +8,10 @@ * - Hot Module Replacement (HMR) */ -import { watch } from 'node:fs' +import { existsSync, watch } from 'node:fs' import { readFile } from 'node:fs/promises' import { ServerResponse } from 'node:http' -import { dirname, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { createDebug } from 'obug' import type { Plugin, ResolvedConfig, ViteDevServer, Connect } from 'vite' @@ -25,7 +25,7 @@ import { transformAngularFile, extractComponentUrls, encapsulateStyle, - compileForHmrSync, + compileForHmr, type TransformOptions, type ResolvedResources, } from '#binding' @@ -67,6 +67,44 @@ export interface PluginOptions { const ANGULAR_TS_REGEX = /\.tsx?$/ const ANGULAR_COMPONENT_PREFIX = '@ng/component' +/** + * Strip JSON comments (single-line and block) and trailing commas. + * tsconfig.json files commonly use these, but JSON.parse doesn't support them. + */ +function stripJsonComments(text: string): string { + // Remove single-line comments + let result = text.replace(/\/\/.*$/gm, '') + // Remove block comments + result = result.replace(/\/\*[\s\S]*?\*\//g, '') + // Remove trailing commas before } or ] + result = result.replace(/,\s*([}\]])/g, '$1') + return result +} + +/** + * Read a tsconfig.json file and extract the TypeScript compiler options + * relevant to the Angular compiler transform: experimentalDecorators, + * emitDecoratorMetadata, and verbatimModuleSyntax (mapped to onlyRemoveTypeImports). + * + * Returns a pre-resolved options object that bypasses per-file tsconfig resolution. + */ +async function readTsCompilerOptions(tsconfigPath: string): Promise<{ + experimentalDecorators?: boolean + emitDecoratorMetadata?: boolean + onlyRemoveTypeImports?: boolean +}> { + const content = await readFile(tsconfigPath, 'utf-8') + const parsed = JSON.parse(stripJsonComments(content)) + const compilerOptions = parsed?.compilerOptions ?? {} + + return { + experimentalDecorators: compilerOptions.experimentalDecorators, + emitDecoratorMetadata: compilerOptions.emitDecoratorMetadata, + // verbatimModuleSyntax maps to onlyRemoveTypeImports in the Rust compiler + onlyRemoveTypeImports: compilerOptions.verbatimModuleSyntax, + } +} + /** * Create the Angular Vite plugin. */ @@ -102,6 +140,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { let viteServer: ViteDevServer | undefined let watchMode = false + // Pre-resolved TypeScript compiler options, populated once during setup. + // This avoids creating a new Resolver per file in the Rust compiler. + let resolvedTypescript: + | { + experimentalDecorators?: boolean + emitDecoratorMetadata?: boolean + onlyRemoveTypeImports?: boolean + } + | undefined + // Track component IDs for HMR const componentIds = new Map() @@ -196,8 +244,36 @@ export function angular(options: PluginOptions = {}): Plugin[] { // using server.watcher.unwatch(). This is more precise than static glob patterns. } }, - configResolved(config) { + async configResolved(config) { resolvedConfig = config + + // Resolve tsconfig once to avoid per-file I/O in the Rust compiler. + // When typescript option is `true` or a path string, we read the tsconfig + // and extract the relevant compiler options upfront. + try { + const tsconfigOption = options.tsconfig + let tsconfigPath: string | undefined + + if (typeof tsconfigOption === 'string') { + tsconfigPath = resolve(workspaceRoot, tsconfigOption) + } else { + // Auto-discover: look for tsconfig.json in the workspace root + const candidate = join(workspaceRoot, 'tsconfig.json') + if (existsSync(candidate)) { + tsconfigPath = candidate + } + } + + if (tsconfigPath) { + resolvedTypescript = await readTsCompilerOptions(tsconfigPath) + } + } catch (e) { + // If reading tsconfig fails, fall back to per-file resolution + console.warn( + '[oxc-angular] Failed to pre-resolve tsconfig, falling back to per-file resolution:', + (e as Error).message, + ) + } }, configureServer(server) { viteServer = server @@ -359,7 +435,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - const result = compileForHmrSync(templateContent, className, resolvedId, styles) + const result = await compileForHmr(templateContent, className, resolvedId, styles) res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') @@ -444,6 +520,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { sourcemap: pluginOptions.sourceMap, jit: pluginOptions.jit, hmr: pluginOptions.liveReload && watchMode, + // Use pre-resolved tsconfig options to avoid per-file Resolver creation. + // Falls back to the original behavior if pre-resolution failed. + typescript: resolvedTypescript ?? options.tsconfig ?? true, } const result = await transformAngularFile(code, actualId, transformOptions, resources) diff --git a/napi/playground/src/app/app.component.ts b/napi/playground/src/app/app.component.ts index 3b1ee578e..ca90c7f52 100644 --- a/napi/playground/src/app/app.component.ts +++ b/napi/playground/src/app/app.component.ts @@ -1,6 +1,9 @@ import { Component, signal } from '@angular/core' import { RouterOutlet } from '@angular/router' +import { Log } from './custom-decorator' + +@Log('App component loaded') @Component({ selector: 'app-root', imports: [RouterOutlet], diff --git a/napi/playground/src/app/custom-decorator.ts b/napi/playground/src/app/custom-decorator.ts new file mode 100644 index 000000000..8ddda02b4 --- /dev/null +++ b/napi/playground/src/app/custom-decorator.ts @@ -0,0 +1,14 @@ +export function Log(message: string) { + console.log(`[Log Decorator] Applying decorator: ${message}`) + + return function any>(target: T) { + console.log(`[Log Decorator] Decorating class: ${target.name}`) + + return class extends target { + constructor(...args: any[]) { + super(...args) + console.log(`[Log Decorator] Instance created: ${target.name} — ${message}`) + } + } + } +}