|
4 | 4 | //! containing Angular components into compiled JavaScript. |
5 | 5 |
|
6 | 6 | use std::collections::HashMap; |
| 7 | +use std::path::{Path, PathBuf}; |
7 | 8 |
|
8 | 9 | use oxc_allocator::{Allocator, Vec as OxcVec}; |
9 | 10 | use oxc_ast::ast::{ |
10 | 11 | Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, ImportOrExportKind, |
11 | 12 | Statement, |
12 | 13 | }; |
| 14 | +use oxc_codegen::Codegen; |
13 | 15 | use oxc_diagnostics::OxcDiagnostic; |
14 | 16 | use oxc_parser::Parser; |
| 17 | +use oxc_resolver::{ |
| 18 | + ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions, TsconfigReferences, |
| 19 | +}; |
| 20 | +use oxc_semantic::SemanticBuilder; |
15 | 21 | 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 | +}; |
16 | 27 | use rustc_hash::FxHashMap; |
17 | 28 |
|
18 | 29 | #[cfg(feature = "cross_file_elision")] |
@@ -65,6 +76,28 @@ use crate::pipeline::ingest::{ |
65 | 76 | use crate::transform::HtmlToR3Transform; |
66 | 77 | use crate::transform::html_to_r3::TransformOptions as R3TransformOptions; |
67 | 78 |
|
| 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 | + |
68 | 101 | /// Options for Angular file transformation. |
69 | 102 | #[derive(Debug, Clone)] |
70 | 103 | pub struct TransformOptions { |
@@ -171,6 +204,11 @@ pub struct TransformOptions { |
171 | 204 | /// |
172 | 205 | /// Default: false (metadata is dev-only and usually stripped in production) |
173 | 206 | 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>, |
174 | 212 | } |
175 | 213 |
|
176 | 214 | /// Input for host metadata when passed via TransformOptions. |
@@ -220,6 +258,8 @@ impl Default for TransformOptions { |
220 | 258 | resolved_imports: None, |
221 | 259 | // Class metadata for TestBed support (disabled by default) |
222 | 260 | emit_class_metadata: false, |
| 261 | + // TypeScript transform (disabled by default) |
| 262 | + typescript: None, |
223 | 263 | } |
224 | 264 | } |
225 | 265 | } |
@@ -1313,7 +1353,16 @@ pub fn transform_angular_file( |
1313 | 1353 | if let Some(id) = &class.id { |
1314 | 1354 | let name = id.name.to_string(); |
1315 | 1355 | 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)); |
1317 | 1366 | } |
1318 | 1367 | } |
1319 | 1368 | } |
@@ -1360,13 +1409,197 @@ pub fn transform_angular_file( |
1360 | 1409 | } |
1361 | 1410 | } |
1362 | 1411 |
|
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 | + |
1364 | 1427 | // Note: source maps not supported with string manipulation approach |
1365 | 1428 | result.map = None; |
1366 | 1429 |
|
1367 | 1430 | result |
1368 | 1431 | } |
1369 | 1432 |
|
| 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 | + |
1370 | 1603 | /// Result of full component compilation including ɵcmp/ɵfac. |
1371 | 1604 | struct FullCompilationResult { |
1372 | 1605 | /// Compiled template function as JavaScript. |
|
0 commit comments