Skip to content

Commit a543ad1

Browse files
Brooooooklynclaude
andcommitted
feat: integrate oxc_transformer for TypeScript-to-JS post-processing
Non-Angular class decorators (e.g., @UntilDestroy()) were left as raw decorator syntax in the compiled output because the Angular compiler only strips Angular-specific decorators. Since the Vite plugin's transform hook replaces default TS→JS processing, these decorators never get lowered, causing parse errors downstream. Add an optional `typescript` transform step that runs oxc_transformer + oxc_codegen after Angular compilation to strip TypeScript types and lower decorators. Supports auto-discovery of tsconfig.json via oxc_resolver, explicit tsconfig paths, or pre-resolved options. Closes #44 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 55ab818 commit a543ad1

File tree

13 files changed

+767
-18
lines changed

13 files changed

+767
-18
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ oxc_parser = "0.110"
9696
oxc_semantic = "0.110"
9797
oxc_span = "0.110"
9898
oxc_sourcemap = "6.0.1"
99+
oxc_transformer = "0.110"
100+
oxc_codegen = "0.110"
99101

100102
# Internal
101103
oxc_angular_compiler = { path = "crates/oxc_angular_compiler" }

crates/oxc_angular_compiler/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ oxc_parser = { workspace = true }
2323
oxc_semantic = { workspace = true }
2424
oxc_span = { workspace = true }
2525
oxc_sourcemap = { workspace = true }
26+
oxc_transformer = { workspace = true }
27+
oxc_codegen = { workspace = true }
2628
miette = { workspace = true }
2729
rustc-hash = { workspace = true }
2830
indexmap = { workspace = true }
29-
oxc_resolver = { version = "11", optional = true }
31+
oxc_resolver = { version = "11" }
3032
pathdiff = { version = "0.2", optional = true }
3133

3234
[features]
3335
default = []
34-
cross_file_elision = ["oxc_resolver", "pathdiff"]
36+
cross_file_elision = ["pathdiff"]
3537

3638
[dev-dependencies]
3739
insta = { workspace = true, features = ["glob"] }

crates/oxc_angular_compiler/src/component/mod.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ pub use metadata::{
3535
pub use namespace_registry::NamespaceRegistry;
3636
pub use transform::{
3737
CompiledComponent, HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap,
38-
LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput,
39-
TransformOptions, TransformResult, build_import_map, compile_component_template,
40-
compile_for_hmr, compile_host_bindings_for_linker, compile_template_for_hmr,
41-
compile_template_for_linker, compile_template_to_js, compile_template_to_js_with_options,
42-
transform_angular_file,
38+
LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, ResolvedTypeScriptOptions,
39+
TemplateCompileOutput, TransformOptions, TransformResult, TypeScriptOption, build_import_map,
40+
compile_component_template, compile_for_hmr, compile_host_bindings_for_linker,
41+
compile_template_for_hmr, compile_template_for_linker, compile_template_to_js,
42+
compile_template_to_js_with_options, transform_angular_file,
4343
};

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@
44
//! containing Angular components into compiled JavaScript.
55
66
use std::collections::HashMap;
7+
use std::path::{Path, PathBuf};
78

89
use oxc_allocator::{Allocator, Vec as OxcVec};
910
use oxc_ast::ast::{
1011
Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, ImportOrExportKind,
1112
Statement,
1213
};
14+
use oxc_codegen::Codegen;
1315
use oxc_diagnostics::OxcDiagnostic;
1416
use oxc_parser::Parser;
17+
use oxc_resolver::{ResolveOptions, Resolver, TsconfigDiscovery};
18+
use oxc_semantic::SemanticBuilder;
1519
use oxc_span::{Atom, SourceType, Span};
20+
use oxc_transformer::{
21+
DecoratorOptions, HelperLoaderMode, HelperLoaderOptions,
22+
TransformOptions as OxcTransformOptions, Transformer as OxcTransformer,
23+
TypeScriptOptions as OxcTypeScriptOptions,
24+
};
1625
use rustc_hash::FxHashMap;
1726

1827
#[cfg(feature = "cross_file_elision")]
@@ -65,6 +74,28 @@ use crate::pipeline::ingest::{
6574
use crate::transform::HtmlToR3Transform;
6675
use crate::transform::html_to_r3::TransformOptions as R3TransformOptions;
6776

77+
/// How to resolve TypeScript transform options.
78+
#[derive(Debug, Clone)]
79+
pub enum TypeScriptOption {
80+
/// Auto-discover nearest tsconfig.json from the source file.
81+
Auto,
82+
/// Use explicit tsconfig path.
83+
TsConfigPath(PathBuf),
84+
/// Use pre-resolved options (for testing/NAPI).
85+
Resolved(ResolvedTypeScriptOptions),
86+
}
87+
88+
/// Pre-resolved TypeScript transform options.
89+
#[derive(Debug, Clone)]
90+
pub struct ResolvedTypeScriptOptions {
91+
/// Use legacy (experimental) decorators.
92+
pub experimental_decorators: bool,
93+
/// Emit decorator metadata for reflection.
94+
pub emit_decorator_metadata: bool,
95+
/// Only remove type-only imports (verbatimModuleSyntax).
96+
pub only_remove_type_imports: bool,
97+
}
98+
6899
/// Options for Angular file transformation.
69100
#[derive(Debug, Clone)]
70101
pub struct TransformOptions {
@@ -171,6 +202,11 @@ pub struct TransformOptions {
171202
///
172203
/// Default: false (metadata is dev-only and usually stripped in production)
173204
pub emit_class_metadata: bool,
205+
206+
/// TypeScript-to-JavaScript transformation.
207+
/// When `Some`, runs oxc_transformer after Angular transforms to strip types
208+
/// and lower decorators. Reads tsconfig.json to derive decorator and TS options.
209+
pub typescript: Option<TypeScriptOption>,
174210
}
175211

176212
/// Input for host metadata when passed via TransformOptions.
@@ -220,6 +256,8 @@ impl Default for TransformOptions {
220256
resolved_imports: None,
221257
// Class metadata for TestBed support (disabled by default)
222258
emit_class_metadata: false,
259+
// TypeScript transform (disabled by default)
260+
typescript: None,
223261
}
224262
}
225263
}
@@ -1313,7 +1351,16 @@ pub fn transform_angular_file(
13131351
if let Some(id) = &class.id {
13141352
let name = id.name.to_string();
13151353
if class_definitions.contains_key(&name) {
1316-
class_positions.push((name, stmt_start, class.body.span.end));
1354+
// Account for non-Angular decorators that precede the class.
1355+
// Decorators like @Log(...) appear before `export class` in source,
1356+
// so we must insert decls_before_class before those decorators.
1357+
let effective_start = class
1358+
.decorators
1359+
.iter()
1360+
.map(|d| d.span.start)
1361+
.min()
1362+
.map_or(stmt_start, |dec_start| dec_start.min(stmt_start));
1363+
class_positions.push((name, effective_start, class.body.span.end));
13171364
}
13181365
}
13191366
}
@@ -1360,13 +1407,171 @@ pub fn transform_angular_file(
13601407
}
13611408
}
13621409

1363-
result.code = final_code;
1410+
// Apply TypeScript transform if requested
1411+
if let Some(ts_option) = &options.typescript {
1412+
match apply_typescript_transform(&final_code, path, ts_option) {
1413+
Ok(transformed) => {
1414+
result.code = transformed;
1415+
}
1416+
Err(diags) => {
1417+
result.diagnostics.extend(diags);
1418+
result.code = final_code;
1419+
}
1420+
}
1421+
} else {
1422+
result.code = final_code;
1423+
}
1424+
13641425
// Note: source maps not supported with string manipulation approach
13651426
result.map = None;
13661427

13671428
result
13681429
}
13691430

1431+
/// Resolve `TypeScriptOption` into `ResolvedTypeScriptOptions` by reading tsconfig.json.
1432+
fn resolve_typescript_options(
1433+
file_path: &str,
1434+
ts_option: &TypeScriptOption,
1435+
) -> Result<ResolvedTypeScriptOptions, Vec<OxcDiagnostic>> {
1436+
match ts_option {
1437+
TypeScriptOption::Resolved(resolved) => Ok(resolved.clone()),
1438+
TypeScriptOption::Auto | TypeScriptOption::TsConfigPath(_) => {
1439+
let resolver = Resolver::new(ResolveOptions {
1440+
tsconfig: Some(TsconfigDiscovery::Auto),
1441+
..ResolveOptions::default()
1442+
});
1443+
1444+
let tsconfig_lookup_path = match ts_option {
1445+
TypeScriptOption::TsConfigPath(p) => p.clone(),
1446+
_ => PathBuf::from(file_path),
1447+
};
1448+
1449+
match resolver.find_tsconfig(&tsconfig_lookup_path) {
1450+
Ok(Some(tsconfig)) => {
1451+
let co = &tsconfig.compiler_options;
1452+
Ok(ResolvedTypeScriptOptions {
1453+
experimental_decorators: co.experimental_decorators.unwrap_or(false),
1454+
emit_decorator_metadata: co.emit_decorator_metadata.unwrap_or(false),
1455+
only_remove_type_imports: co.verbatim_module_syntax.unwrap_or(false),
1456+
})
1457+
}
1458+
Ok(None) => {
1459+
// No tsconfig found, use defaults
1460+
Ok(ResolvedTypeScriptOptions {
1461+
experimental_decorators: false,
1462+
emit_decorator_metadata: false,
1463+
only_remove_type_imports: false,
1464+
})
1465+
}
1466+
Err(e) => {
1467+
Err(vec![OxcDiagnostic::error(format!("Failed to resolve tsconfig: {e}"))])
1468+
}
1469+
}
1470+
}
1471+
}
1472+
}
1473+
1474+
/// Apply TypeScript transformation to the final code string.
1475+
///
1476+
/// This re-parses the code, runs `oxc_transformer` to strip TypeScript types
1477+
/// and lower decorators, then re-emits via `oxc_codegen`.
1478+
fn apply_typescript_transform(
1479+
code: &str,
1480+
file_path: &str,
1481+
ts_option: &TypeScriptOption,
1482+
) -> Result<String, Vec<OxcDiagnostic>> {
1483+
let resolved = resolve_typescript_options(file_path, ts_option)?;
1484+
1485+
let allocator = Allocator::default();
1486+
let source_type = SourceType::from_path(file_path).unwrap_or_default();
1487+
let parser_ret = Parser::new(&allocator, code, source_type).parse();
1488+
1489+
if !parser_ret.errors.is_empty() {
1490+
return Err(parser_ret
1491+
.errors
1492+
.into_iter()
1493+
.map(|e| OxcDiagnostic::error(e.to_string()))
1494+
.collect());
1495+
}
1496+
1497+
let mut program = parser_ret.program;
1498+
1499+
// Build semantic info for the transformer
1500+
let semantic_ret = SemanticBuilder::new().build(&program);
1501+
if !semantic_ret.errors.is_empty() {
1502+
return Err(semantic_ret
1503+
.errors
1504+
.into_iter()
1505+
.map(|e| OxcDiagnostic::error(e.to_string()))
1506+
.collect());
1507+
}
1508+
1509+
let scoping = semantic_ret.semantic.into_scoping();
1510+
1511+
// Map resolved options to oxc_transformer options.
1512+
// Use External helper mode to emit `babelHelpers.decorate(...)` instead of
1513+
// importing from `@oxc-project/runtime` (which may not be installed).
1514+
let transform_options = OxcTransformOptions {
1515+
typescript: OxcTypeScriptOptions {
1516+
only_remove_type_imports: resolved.only_remove_type_imports,
1517+
..OxcTypeScriptOptions::default()
1518+
},
1519+
decorator: DecoratorOptions {
1520+
legacy: resolved.experimental_decorators,
1521+
emit_decorator_metadata: resolved.emit_decorator_metadata,
1522+
},
1523+
helper_loader: HelperLoaderOptions {
1524+
mode: HelperLoaderMode::External,
1525+
..HelperLoaderOptions::default()
1526+
},
1527+
..OxcTransformOptions::default()
1528+
};
1529+
1530+
let path = Path::new(file_path);
1531+
let transformer = OxcTransformer::new(&allocator, path, &transform_options);
1532+
let transform_ret = transformer.build_with_scoping(scoping, &mut program);
1533+
1534+
if !transform_ret.errors.is_empty() {
1535+
return Err(transform_ret
1536+
.errors
1537+
.into_iter()
1538+
.map(|e| OxcDiagnostic::error(e.to_string()))
1539+
.collect());
1540+
}
1541+
1542+
let codegen_ret = Codegen::new().build(&program);
1543+
let mut code = codegen_ret.code;
1544+
1545+
// If the output references babelHelpers (from External helper mode),
1546+
// inject a minimal polyfill. Must go AFTER imports to be valid ESM.
1547+
if code.contains("babelHelpers.decorate") {
1548+
let helper = "var babelHelpers = { decorate(decorators, target) { \
1549+
for (var i = decorators.length - 1; i >= 0; i--) { \
1550+
target = decorators[i](target) || target; } return target; } };\n";
1551+
// Find the end of the last import statement to insert after it.
1552+
let insert_pos = find_after_last_import(&code);
1553+
code.insert_str(insert_pos, helper);
1554+
}
1555+
1556+
Ok(code)
1557+
}
1558+
1559+
/// Find the byte offset right after the last `import` statement in the code.
1560+
/// Falls back to position 0 if no imports found.
1561+
fn find_after_last_import(code: &str) -> usize {
1562+
// Find lines starting with "import " — the codegen output is clean and predictable.
1563+
let mut last_import_end = 0;
1564+
let mut pos = 0;
1565+
for line in code.lines() {
1566+
let line_end = pos + line.len() + 1; // +1 for newline
1567+
if line.starts_with("import ") {
1568+
last_import_end = line_end.min(code.len());
1569+
}
1570+
pos = line_end;
1571+
}
1572+
last_import_end
1573+
}
1574+
13701575
/// Result of full component compilation including ɵcmp/ɵfac.
13711576
struct FullCompilationResult {
13721577
/// Compiled template function as JavaScript.

crates/oxc_angular_compiler/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ pub use transform::{HtmlToR3Transform, html_to_r3::html_ast_to_r3_ast};
5858
pub use component::{
5959
AngularVersion, ChangeDetectionStrategy, CompiledComponent, ComponentMetadata,
6060
HmrTemplateCompileOutput, HostMetadata, HostMetadataInput, ImportInfo, ImportMap,
61-
NamespaceRegistry, ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult,
62-
ViewEncapsulation, build_import_map, compile_component_template, compile_for_hmr,
63-
compile_template_for_hmr, compile_template_to_js, compile_template_to_js_with_options,
64-
extract_component_metadata, transform_angular_file,
61+
NamespaceRegistry, ResolvedResources, ResolvedTypeScriptOptions, TemplateCompileOutput,
62+
TransformOptions, TransformResult, TypeScriptOption, ViewEncapsulation, build_import_map,
63+
compile_component_template, compile_for_hmr, compile_template_for_hmr, compile_template_to_js,
64+
compile_template_to_js_with_options, extract_component_metadata, transform_angular_file,
6565
};
6666

6767
// Re-export cross-file elision types when feature is enabled

0 commit comments

Comments
 (0)