Skip to content

Commit fe15097

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 fe15097

File tree

13 files changed

+769
-19
lines changed

13 files changed

+769
-19
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: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,23 @@
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, TransformOptions as OxcTransformOptions,
22+
Transformer as OxcTransformer, TypeScriptOptions as OxcTypeScriptOptions,
23+
};
1624
use rustc_hash::FxHashMap;
1725

1826
#[cfg(feature = "cross_file_elision")]
@@ -65,6 +73,28 @@ use crate::pipeline::ingest::{
6573
use crate::transform::HtmlToR3Transform;
6674
use crate::transform::html_to_r3::TransformOptions as R3TransformOptions;
6775

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

176211
/// Input for host metadata when passed via TransformOptions.
@@ -220,6 +255,8 @@ impl Default for TransformOptions {
220255
resolved_imports: None,
221256
// Class metadata for TestBed support (disabled by default)
222257
emit_class_metadata: false,
258+
// TypeScript transform (disabled by default)
259+
typescript: None,
223260
}
224261
}
225262
}
@@ -1313,7 +1350,16 @@ pub fn transform_angular_file(
13131350
if let Some(id) = &class.id {
13141351
let name = id.name.to_string();
13151352
if class_definitions.contains_key(&name) {
1316-
class_positions.push((name, stmt_start, class.body.span.end));
1353+
// Account for non-Angular decorators that precede the class.
1354+
// Decorators like @Log(...) appear before `export class` in source,
1355+
// so we must insert decls_before_class before those decorators.
1356+
let effective_start = class
1357+
.decorators
1358+
.iter()
1359+
.map(|d| d.span.start)
1360+
.min()
1361+
.map_or(stmt_start, |dec_start| dec_start.min(stmt_start));
1362+
class_positions.push((name, effective_start, class.body.span.end));
13171363
}
13181364
}
13191365
}
@@ -1360,13 +1406,171 @@ pub fn transform_angular_file(
13601406
}
13611407
}
13621408

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

13671427
result
13681428
}
13691429

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

crates/oxc_angular_compiler/src/lib.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ 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,
64+
compile_template_to_js, compile_template_to_js_with_options, extract_component_metadata,
65+
transform_angular_file,
6566
};
6667

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

0 commit comments

Comments
 (0)