Skip to content

Commit 21fe4de

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 21fe4de

File tree

13 files changed

+878
-21
lines changed

13 files changed

+878
-21
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: 235 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,26 @@
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::{
18+
ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
19+
};
20+
use oxc_semantic::SemanticBuilder;
1521
use oxc_span::{Atom, SourceType, Span};
22+
use oxc_transformer::{
23+
DecoratorOptions, HelperLoaderMode, HelperLoaderOptions,
24+
TransformOptions as OxcTransformOptions, Transformer as OxcTransformer,
25+
TypeScriptOptions as OxcTypeScriptOptions,
26+
};
1627
use rustc_hash::FxHashMap;
1728

1829
#[cfg(feature = "cross_file_elision")]
@@ -65,6 +76,28 @@ use crate::pipeline::ingest::{
6576
use crate::transform::HtmlToR3Transform;
6677
use crate::transform::html_to_r3::TransformOptions as R3TransformOptions;
6778

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

176214
/// Input for host metadata when passed via TransformOptions.
@@ -220,6 +258,8 @@ impl Default for TransformOptions {
220258
resolved_imports: None,
221259
// Class metadata for TestBed support (disabled by default)
222260
emit_class_metadata: false,
261+
// TypeScript transform (disabled by default)
262+
typescript: None,
223263
}
224264
}
225265
}
@@ -1313,7 +1353,16 @@ pub fn transform_angular_file(
13131353
if let Some(id) = &class.id {
13141354
let name = id.name.to_string();
13151355
if class_definitions.contains_key(&name) {
1316-
class_positions.push((name, stmt_start, class.body.span.end));
1356+
// Account for non-Angular decorators that precede the class.
1357+
// Decorators like @Log(...) appear before `export class` in source,
1358+
// so we must insert decls_before_class before those decorators.
1359+
let effective_start = class
1360+
.decorators
1361+
.iter()
1362+
.map(|d| d.span.start)
1363+
.min()
1364+
.map_or(stmt_start, |dec_start| dec_start.min(stmt_start));
1365+
class_positions.push((name, effective_start, class.body.span.end));
13171366
}
13181367
}
13191368
}
@@ -1360,13 +1409,197 @@ pub fn transform_angular_file(
13601409
}
13611410
}
13621411

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

13671430
result
13681431
}
13691432

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