From 4356c3883a972c66fefbb13474852ed4f95ac595 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Mar 2026 11:25:56 +0800 Subject: [PATCH 1/4] feat: generate .d.ts Ivy type declarations for Angular library builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `dts_declarations` field to `TransformResult` that generates the static type declarations (ɵfac, ɵcmp, ɵdir, ɵpipe, ɵmod, ɵinj, ɵprov) needed in `.d.ts` files for Angular library consumers to perform template type-checking. This enables build tools like tsdown to post-process `.d.ts` output and inject Ivy declarations, solving the "Component imports must be standalone" error when publishing Angular libraries compiled with Oxc. Supports all Angular decorator types: @Component, @Directive, @Pipe, @NgModule, and @Injectable, including input/output maps, signal inputs, exportAs, host directives, and constructor @Attribute() dependencies. - Close #86 Co-Authored-By: Claude Opus 4.6 --- .../src/component/transform.rs | 59 ++ crates/oxc_angular_compiler/src/dts.rs | 666 ++++++++++++++++++ crates/oxc_angular_compiler/src/lib.rs | 7 + .../tests/integration_test.rs | 432 ++++++++++++ napi/angular-compiler/index.d.ts | 29 + napi/angular-compiler/src/lib.rs | 31 + 6 files changed, 1224 insertions(+) create mode 100644 crates/oxc_angular_compiler/src/dts.rs diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 954ad387b..3b57de1b2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -38,6 +38,7 @@ use crate::directive::{ extract_content_queries, extract_directive_metadata, extract_view_queries, find_directive_decorator_span, generate_directive_definitions, }; +use crate::dts; use crate::injectable::{ extract_injectable_metadata, find_injectable_decorator_span, generate_injectable_definition_from_decorator, @@ -276,6 +277,20 @@ pub struct TransformResult { /// Number of components found in the file. pub component_count: usize, + + /// `.d.ts` type declarations for Angular classes. + /// + /// Each entry contains the class name and the static member declarations + /// that should be injected into the corresponding `.d.ts` class body. + /// This enables library builds to include proper Ivy type declarations + /// for template type-checking by consumers. + /// + /// The declarations use `i0` as the namespace alias for `@angular/core`. + /// Consumers must ensure their `.d.ts` files include: + /// ```typescript + /// import * as i0 from "@angular/core"; + /// ``` + pub dts_declarations: Vec, } impl TransformResult { @@ -1565,6 +1580,11 @@ pub fn transform_angular_file( // Signal-based queries (contentChild(), contentChildren()) are also detected here let content_queries = extract_content_queries(allocator, class); + // Collect content query property names for .d.ts generation + // (before content_queries is moved into compile_component_full) + let content_query_names: Vec = + content_queries.iter().map(|q| q.property_name.to_string()).collect(); + // 4. Compile the template and generate ɵcmp/ɵfac // Pass the shared pool index to ensure unique constant names // Pass the file-level namespace registry to ensure consistent namespace assignments @@ -1736,6 +1756,20 @@ pub fn transform_angular_file( result.dependencies.push(style_url.to_string()); } + // Generate .d.ts type declaration for this component + let type_argument_count = class + .type_parameters + .as_ref() + .map_or(0, |tp| tp.params.len() as u32); + let has_injectable = + extract_injectable_metadata(allocator, class).is_some(); + result.dts_declarations.push(dts::generate_component_dts( + &metadata, + type_argument_count, + &content_query_names, + has_injectable, + )); + result.component_count += 1; } Err(diags) => { @@ -1830,6 +1864,12 @@ pub fn transform_angular_file( } } + // Generate .d.ts type declaration for this directive + let has_injectable = extract_injectable_metadata(allocator, class).is_some(); + result + .dts_declarations + .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -1896,6 +1936,13 @@ pub fn transform_angular_file( } } + // Generate .d.ts type declaration for this pipe + let has_injectable = + extract_injectable_metadata(allocator, class).is_some(); + result + .dts_declarations + .push(dts::generate_pipe_dts(&pipe_metadata, has_injectable)); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -1977,6 +2024,13 @@ pub fn transform_angular_file( external_decls.push_str(&emitter.emit_statement(stmt)); } + // Generate .d.ts type declaration for this NgModule + let has_injectable = + extract_injectable_metadata(allocator, class).is_some(); + result + .dts_declarations + .push(dts::generate_ng_module_dts(&ng_module_metadata, has_injectable)); + // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push(( class_name.clone(), @@ -2028,6 +2082,11 @@ pub fn transform_angular_file( emitter.emit_expression(&definition.prov_definition) ); + // Generate .d.ts type declaration for this injectable + result + .dts_declarations + .push(dts::generate_injectable_dts(&injectable_metadata)); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs new file mode 100644 index 000000000..2302e869d --- /dev/null +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -0,0 +1,666 @@ +//! Angular `.d.ts` type declaration generation. +//! +//! This module generates the static type declarations that should be added +//! to `.d.ts` files for Angular library builds. These declarations enable +//! Angular's template type-checking system to work with pre-compiled libraries. +//! +//! The generated declarations use `i0` as the namespace alias for `@angular/core`, +//! matching Angular's convention. Consumers must ensure their `.d.ts` files include: +//! ```typescript +//! import * as i0 from "@angular/core"; +//! ``` +//! +//! Reference: Angular's `IvyDeclarationDtsTransform` in +//! `packages/compiler-cli/src/ngtsc/transform/src/declaration.ts` + +use crate::component::{ComponentMetadata, HostDirectiveMetadata, R3DependencyMetadata}; +use crate::directive::{R3DirectiveMetadata, R3InputMetadata}; +use crate::injectable::InjectableMetadata; +use crate::ng_module::NgModuleMetadata; +use crate::pipe::PipeMetadata; + +/// A `.d.ts` type declaration for an Angular class. +/// +/// Contains the class name and the static member declarations +/// that should be injected into the corresponding `.d.ts` class. +#[derive(Debug, Clone, Default)] +pub struct DtsDeclaration { + /// The name of the class. + pub class_name: String, + /// The static member declarations to add to the class body in `.d.ts`. + /// This is a newline-separated string of `static` property declarations. + /// + /// Example: + /// ```text + /// static ɵfac: i0.ɵɵFactoryDeclaration; + /// static ɵcmp: i0.ɵɵComponentDeclaration; + /// ``` + pub members: String, +} + +// ============================================================================= +// Component Declarations +// ============================================================================= + +/// Generate `.d.ts` declarations for a `@Component` class. +/// +/// Produces: +/// - `static ɵfac: i0.ɵɵFactoryDeclaration;` +/// - `static ɵcmp: i0.ɵɵComponentDeclaration;` +pub fn generate_component_dts( + metadata: &ComponentMetadata, + type_argument_count: u32, + content_query_names: &[String], + has_injectable: bool, +) -> DtsDeclaration { + let class_name = metadata.class_name.as_str(); + let type_with_params = type_with_parameters(class_name, type_argument_count); + + // ɵfac declaration + let ctor_deps_type = generate_ctor_deps_type_from_component_deps( + metadata.constructor_deps.as_ref().map(|v| v.as_slice() as &[R3DependencyMetadata]), + ); + let fac = + format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, {ctor_deps_type}>;"); + + // ɵcmp declaration + let selector = match &metadata.selector { + Some(s) => { + // Remove newlines from selector (matching Angular TS behavior) + let cleaned = s.as_str().replace('\n', ""); + format!("\"{}\"", escape_dts_string(&cleaned)) + } + None => "never".to_string(), + }; + + let export_as = if metadata.export_as.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .export_as + .iter() + .map(|e| format!("\"{}\"", escape_dts_string(e.as_str()))) + .collect::>() + .join(", ") + ) + }; + + let input_map = generate_input_map_type(&metadata.inputs); + let output_map = generate_output_map_type(&metadata.outputs); + + let query_fields = if content_query_names.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + content_query_names + .iter() + .map(|name| format!("\"{}\"", escape_dts_string(name))) + .collect::>() + .join(", ") + ) + }; + + // NgContentSelectors: would require template analysis; use never for now + let ng_content_selectors = "never".to_string(); + + let is_standalone = if metadata.standalone { "true" } else { "false" }; + + let host_directives = if metadata.host_directives.is_empty() { + "never".to_string() + } else { + generate_host_directives_type_from_component(&metadata.host_directives) + }; + + let mut type_params = vec![ + type_with_params.clone(), + selector, + export_as, + input_map, + output_map, + query_fields, + ng_content_selectors, + is_standalone.to_string(), + host_directives, + ]; + + if metadata.is_signal { + type_params.push("true".to_string()); + } + + let cmp = format!("static ɵcmp: i0.ɵɵComponentDeclaration<{}>;", type_params.join(", ")); + + let mut members = format!("{fac}\n{cmp}"); + + // Add ɵprov if @Injectable is also present + if has_injectable { + members + .push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;")); + } + + DtsDeclaration { class_name: class_name.to_string(), members } +} + +// ============================================================================= +// Directive Declarations +// ============================================================================= + +/// Generate `.d.ts` declarations for a `@Directive` class. +/// +/// Produces: +/// - `static ɵfac: i0.ɵɵFactoryDeclaration;` +/// - `static ɵdir: i0.ɵɵDirectiveDeclaration;` +pub fn generate_directive_dts( + metadata: &R3DirectiveMetadata, + has_injectable: bool, +) -> DtsDeclaration { + let class_name = metadata.name.as_str(); + let type_with_params = type_with_parameters(class_name, metadata.type_argument_count); + + // ɵfac declaration + let ctor_deps_type = + generate_ctor_deps_type_from_factory_deps(metadata.deps.as_ref().map(|v| v.as_slice())); + let fac = + format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, {ctor_deps_type}>;"); + + // ɵdir declaration + let selector = match &metadata.selector { + Some(s) => { + let cleaned = s.as_str().replace('\n', ""); + format!("\"{}\"", escape_dts_string(&cleaned)) + } + None => "never".to_string(), + }; + + let export_as = if metadata.export_as.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .export_as + .iter() + .map(|e| format!("\"{}\"", escape_dts_string(e.as_str()))) + .collect::>() + .join(", ") + ) + }; + + let input_map = generate_input_map_type(&metadata.inputs); + let output_map = generate_output_map_type(&metadata.outputs); + + let query_fields = if metadata.queries.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .queries + .iter() + .map(|q| format!("\"{}\"", escape_dts_string(q.property_name.as_str()))) + .collect::>() + .join(", ") + ) + }; + + // NgContentSelectors is always `never` for directives + let ng_content_selectors = "never"; + + let is_standalone = if metadata.is_standalone { "true" } else { "false" }; + + let host_directives = if metadata.host_directives.is_empty() { + "never".to_string() + } else { + generate_host_directives_type_from_directive(&metadata.host_directives) + }; + + let mut type_params = vec![ + type_with_params.clone(), + selector, + export_as, + input_map, + output_map, + query_fields, + ng_content_selectors.to_string(), + is_standalone.to_string(), + host_directives, + ]; + + if metadata.is_signal { + type_params.push("true".to_string()); + } + + let dir = format!("static ɵdir: i0.ɵɵDirectiveDeclaration<{}>;", type_params.join(", ")); + + let mut members = format!("{fac}\n{dir}"); + + if has_injectable { + members + .push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;")); + } + + DtsDeclaration { class_name: class_name.to_string(), members } +} + +// ============================================================================= +// Pipe Declarations +// ============================================================================= + +/// Generate `.d.ts` declarations for a `@Pipe` class. +/// +/// Produces: +/// - `static ɵfac: i0.ɵɵFactoryDeclaration;` +/// - `static ɵpipe: i0.ɵɵPipeDeclaration;` +pub fn generate_pipe_dts(metadata: &PipeMetadata, has_injectable: bool) -> DtsDeclaration { + let class_name = metadata.class_name.as_str(); + // Pipes don't have type parameters in practice + let type_with_params = class_name.to_string(); + + // ɵfac declaration + let ctor_deps_type = + generate_ctor_deps_type_from_factory_deps(metadata.deps.as_ref().map(|v| v.as_slice())); + let fac = + format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, {ctor_deps_type}>;"); + + // ɵpipe declaration + let pipe_name = match &metadata.pipe_name { + Some(name) => format!("\"{}\"", escape_dts_string(name.as_str())), + None => "\"\"".to_string(), + }; + + let is_standalone = if metadata.standalone { "true" } else { "false" }; + + let pipe = format!( + "static ɵpipe: i0.ɵɵPipeDeclaration<{type_with_params}, {pipe_name}, {is_standalone}>;" + ); + + let mut members = format!("{fac}\n{pipe}"); + + if has_injectable { + members + .push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;")); + } + + DtsDeclaration { class_name: class_name.to_string(), members } +} + +// ============================================================================= +// NgModule Declarations +// ============================================================================= + +/// Generate `.d.ts` declarations for a `@NgModule` class. +/// +/// Produces: +/// - `static ɵfac: i0.ɵɵFactoryDeclaration;` +/// - `static ɵmod: i0.ɵɵNgModuleDeclaration;` +/// - `static ɵinj: i0.ɵɵInjectorDeclaration;` +pub fn generate_ng_module_dts(metadata: &NgModuleMetadata, has_injectable: bool) -> DtsDeclaration { + let class_name = metadata.class_name.as_str(); + let type_with_params = class_name.to_string(); + + // ɵfac declaration + let ctor_deps_type = + generate_ctor_deps_type_from_factory_deps(metadata.deps.as_ref().map(|v| v.as_slice())); + let fac = + format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, {ctor_deps_type}>;"); + + // ɵmod declaration - uses typeof references for declarations/imports/exports + let declarations_type = if metadata.declarations.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .declarations + .iter() + .map(|d| format!("typeof {}", d.as_str())) + .collect::>() + .join(", ") + ) + }; + + let imports_type = if metadata.imports.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .imports + .iter() + .map(|i| format!("typeof {}", i.as_str())) + .collect::>() + .join(", ") + ) + }; + + let exports_type = if metadata.exports.is_empty() { + "never".to_string() + } else { + format!( + "[{}]", + metadata + .exports + .iter() + .map(|e| format!("typeof {}", e.as_str())) + .collect::>() + .join(", ") + ) + }; + + let mod_decl = format!( + "static ɵmod: i0.ɵɵNgModuleDeclaration<{type_with_params}, {declarations_type}, {imports_type}, {exports_type}>;" + ); + + // ɵinj declaration + let inj = format!("static ɵinj: i0.ɵɵInjectorDeclaration<{type_with_params}>;"); + + let mut members = format!("{fac}\n{mod_decl}\n{inj}"); + + if has_injectable { + members + .push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;")); + } + + DtsDeclaration { class_name: class_name.to_string(), members } +} + +// ============================================================================= +// Injectable Declarations +// ============================================================================= + +/// Generate `.d.ts` declarations for a standalone `@Injectable` class. +/// +/// Produces: +/// - `static ɵfac: i0.ɵɵFactoryDeclaration;` +/// - `static ɵprov: i0.ɵɵInjectableDeclaration;` +pub fn generate_injectable_dts(metadata: &InjectableMetadata) -> DtsDeclaration { + let class_name = metadata.class_name.as_str(); + let type_with_params = class_name.to_string(); + + // ɵfac declaration + let ctor_deps_type = + generate_ctor_deps_type_from_factory_deps(metadata.deps.as_ref().map(|v| v.as_slice())); + let fac = + format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, {ctor_deps_type}>;"); + + // ɵprov declaration + let prov = format!("static ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;"); + + let members = format!("{fac}\n{prov}"); + + DtsDeclaration { class_name: class_name.to_string(), members } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Generate the type parameter `T` with any generic params filled as `any`. +/// +/// For `class Foo`, produces `Foo`. +/// For `class Foo`, produces `Foo`. +fn type_with_parameters(class_name: &str, count: u32) -> String { + if count == 0 { + class_name.to_string() + } else { + let params: Vec<&str> = (0..count).map(|_| "any").collect(); + format!("{}<{}>", class_name, params.join(", ")) + } +} + +/// Generate the constructor deps type parameter for `ɵɵFactoryDeclaration`. +/// +/// Returns `never` if there are no `@Attribute()` dependencies. +/// Returns a tuple type like `[null, "attrName", null]` if there are attribute deps. +fn generate_ctor_deps_type_from_component_deps(deps: Option<&[R3DependencyMetadata]>) -> String { + match deps { + None => "never".to_string(), + Some(deps) => { + let has_attributes = deps.iter().any(|d| d.attribute_name.is_some()); + if !has_attributes { + "never".to_string() + } else { + let entries: Vec = deps + .iter() + .map(|d| match &d.attribute_name { + Some(name) => format!("\"{}\"", escape_dts_string(name.as_str())), + None => "null".to_string(), + }) + .collect(); + format!("[{}]", entries.join(", ")) + } + } + } +} + +/// Generate the constructor deps type from directive/pipe/ngmodule/injectable deps. +/// +/// Uses the factory module's `R3DependencyMetadata` which is used by directives, +/// pipes, NgModules, and injectables. +fn generate_ctor_deps_type_from_factory_deps( + deps: Option<&[crate::factory::R3DependencyMetadata]>, +) -> String { + match deps { + None => "never".to_string(), + Some(deps) => { + // The factory R3DependencyMetadata uses `attribute_name_type` (an OutputExpression) + // for @Attribute() dependencies. If any dep has it set, we emit a tuple type. + let has_attributes = deps.iter().any(|d| d.attribute_name_type.is_some()); + if !has_attributes { + "never".to_string() + } else { + let entries: Vec = deps + .iter() + .map(|d| { + if d.attribute_name_type.is_some() { + // @Attribute deps get a string type in the tuple + "string".to_string() + } else { + "null".to_string() + } + }) + .collect(); + format!("[{}]", entries.join(", ")) + } + } + } +} + +/// Generate the input map type for `ɵɵComponentDeclaration` / `ɵɵDirectiveDeclaration`. +/// +/// Produces a TypeScript object literal type like: +/// ```text +/// { "name": { "alias": "name"; "required": false; }; "value": { "alias": "aliasedValue"; "required": true; "isSignal": true; }; } +/// ``` +fn generate_input_map_type(inputs: &[R3InputMetadata]) -> String { + if inputs.is_empty() { + return "{}".to_string(); + } + + let entries: Vec = inputs + .iter() + .map(|input| { + let key = escape_dts_string(input.class_property_name.as_str()); + let alias = escape_dts_string(input.binding_property_name.as_str()); + let required = if input.required { "true" } else { "false" }; + + let mut props = format!("\"alias\": \"{alias}\"; \"required\": {required};"); + if input.is_signal { + props.push_str(" \"isSignal\": true;"); + } + + format!("\"{key}\": {{ {props} }};") + }) + .collect(); + + format!("{{ {} }}", entries.join(" ")) +} + +/// Generate the output map type. +/// +/// Produces: `{ "clicked": "clicked"; "valueChanged": "onChange"; }` +fn generate_output_map_type(outputs: &[(oxc_span::Atom, oxc_span::Atom)]) -> String { + if outputs.is_empty() { + return "{}".to_string(); + } + + let entries: Vec = outputs + .iter() + .map(|(class_name, binding_name)| { + format!( + "\"{}\": \"{}\";", + escape_dts_string(class_name.as_str()), + escape_dts_string(binding_name.as_str()) + ) + }) + .collect(); + + format!("{{ {} }}", entries.join(" ")) +} + +/// Generate the host directives type from component host directives. +fn generate_host_directives_type_from_component( + host_directives: &[HostDirectiveMetadata], +) -> String { + let entries: Vec = host_directives + .iter() + .map(|hd| { + let directive = format!("typeof {}", hd.directive.as_str()); + let inputs = if hd.inputs.is_empty() { + "{}".to_string() + } else { + let input_entries: Vec = hd + .inputs + .iter() + .map(|(public, internal)| { + format!( + "\"{}\": \"{}\"", + escape_dts_string(public.as_str()), + escape_dts_string(internal.as_str()) + ) + }) + .collect(); + format!("{{ {} }}", input_entries.join("; ")) + }; + let outputs = if hd.outputs.is_empty() { + "{}".to_string() + } else { + let output_entries: Vec = hd + .outputs + .iter() + .map(|(public, internal)| { + format!( + "\"{}\": \"{}\"", + escape_dts_string(public.as_str()), + escape_dts_string(internal.as_str()) + ) + }) + .collect(); + format!("{{ {} }}", output_entries.join("; ")) + }; + format!("{{ directive: {directive}; inputs: {inputs}; outputs: {outputs}; }}") + }) + .collect(); + + format!("[{}]", entries.join(", ")) +} + +/// Generate the host directives type from directive host directives. +fn generate_host_directives_type_from_directive( + host_directives: &[crate::directive::R3HostDirectiveMetadata], +) -> String { + let entries: Vec = host_directives + .iter() + .map(|hd| { + // Extract the directive name from the OutputExpression + let directive_name = extract_directive_name_from_expr(&hd.directive); + let directive = format!("typeof {directive_name}"); + let inputs = if hd.inputs.is_empty() { + "{}".to_string() + } else { + let input_entries: Vec = hd + .inputs + .iter() + .map(|(public, internal)| { + format!( + "\"{}\": \"{}\"", + escape_dts_string(public.as_str()), + escape_dts_string(internal.as_str()) + ) + }) + .collect(); + format!("{{ {} }}", input_entries.join("; ")) + }; + let outputs = if hd.outputs.is_empty() { + "{}".to_string() + } else { + let output_entries: Vec = hd + .outputs + .iter() + .map(|(public, internal)| { + format!( + "\"{}\": \"{}\"", + escape_dts_string(public.as_str()), + escape_dts_string(internal.as_str()) + ) + }) + .collect(); + format!("{{ {} }}", output_entries.join("; ")) + }; + format!("{{ directive: {directive}; inputs: {inputs}; outputs: {outputs}; }}") + }) + .collect(); + + format!("[{}]", entries.join(", ")) +} + +/// Extract a directive name from an `OutputExpression`. +fn extract_directive_name_from_expr(expr: &crate::output::ast::OutputExpression) -> String { + match expr { + crate::output::ast::OutputExpression::ReadVar(read_var) => { + read_var.name.as_str().to_string() + } + _ => "unknown".to_string(), + } +} + +/// Escape a string for use in a TypeScript `.d.ts` string literal type. +fn escape_dts_string(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_type_with_parameters_no_params() { + assert_eq!(type_with_parameters("MyComponent", 0), "MyComponent"); + } + + #[test] + fn test_type_with_parameters_with_params() { + assert_eq!(type_with_parameters("MyComponent", 2), "MyComponent"); + } + + #[test] + fn test_escape_dts_string() { + assert_eq!(escape_dts_string("hello"), "hello"); + assert_eq!(escape_dts_string(r#"he"llo"#), r#"he\"llo"#); + assert_eq!(escape_dts_string(r"he\llo"), r"he\\llo"); + } + + #[test] + fn test_generate_input_map_type_empty() { + let inputs: Vec = vec![]; + assert_eq!(generate_input_map_type(&inputs), "{}"); + } + + #[test] + fn test_generate_output_map_type_empty() { + let outputs: Vec<(oxc_span::Atom, oxc_span::Atom)> = vec![]; + assert_eq!(generate_output_map_type(&outputs), "{}"); + } +} diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 3e58b3e14..2c28b0590 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -31,6 +31,7 @@ pub mod class_debug_info; pub mod class_metadata; pub mod component; pub mod directive; +pub mod dts; pub mod factory; pub mod hmr; pub mod i18n; @@ -135,6 +136,12 @@ pub use class_metadata::{ compile_opaque_async_class_metadata, }; +// Re-export dts types +pub use dts::{ + DtsDeclaration, generate_component_dts, generate_directive_dts, generate_injectable_dts, + generate_ng_module_dts, generate_pipe_dts, +}; + // Re-export linker types pub use linker::{LinkResult, link}; diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 16c93e0dd..a082b3448 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -6503,3 +6503,435 @@ fn test_sourcemap_no_angular_classes() { "Should return a source map even for files with no Angular classes when sourcemap: true" ); } + +// ============================================================================= +// .d.ts Declaration Generation Tests (Issue #86) +// ============================================================================= + +#[test] +fn test_dts_component_basic() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-hello', + standalone: true, + template: '

Hello

' +}) +export class HelloComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "hello.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + // Should have exactly one dts declaration + assert_eq!(result.dts_declarations.len(), 1, "Should have one dts declaration"); + + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "HelloComponent"); + + // Should contain ɵfac declaration + assert!( + decl.members.contains("static ɵfac: i0.ɵɵFactoryDeclaration;"), + "Should contain ɵfac declaration. Got:\n{}", + decl.members + ); + + // Should contain ɵcmp declaration with correct selector + assert!( + decl.members + .contains("static ɵcmp: i0.ɵɵComponentDeclaration;"), + "Should include standalone=true. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_component_with_inputs_outputs() { + let allocator = Allocator::default(); + let source = r#" +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'app-user', + standalone: true, + template: '

{{name}}

' +}) +export class UserComponent { + @Input() name: string = ''; + @Input({ required: true, alias: 'userId' }) id!: number; + @Output() clicked = new EventEmitter(); +} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "user.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "UserComponent"); + + // Should contain input map with proper metadata + assert!( + decl.members.contains(r#""name": { "alias": "name"; "required": false; }"#), + "Should contain name input metadata. Got:\n{}", + decl.members + ); + assert!( + decl.members.contains(r#""id": { "alias": "userId"; "required": true; }"#), + "Should contain id input metadata with alias. Got:\n{}", + decl.members + ); + + // Should contain output map + assert!( + decl.members.contains(r#""clicked": "clicked""#), + "Should contain output metadata. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_component_non_standalone() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-legacy', + standalone: false, + template: '

Legacy

' +}) +export class LegacyComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "legacy.component.ts", source, &options, None); + assert!(!result.has_errors()); + + let decl = &result.dts_declarations[0]; + // Should include standalone: false + assert!( + decl.members.contains("false, never>;"), + "Should include standalone=false. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_component_with_export_as() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-tooltip', + standalone: true, + exportAs: 'tooltip', + template: '' +}) +export class TooltipComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "tooltip.component.ts", source, &options, None); + assert!(!result.has_errors()); + + let decl = &result.dts_declarations[0]; + assert!( + decl.members.contains(r#"["tooltip"]"#), + "Should contain exportAs array. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_directive() { + let allocator = Allocator::default(); + let source = r#" +import { Directive, Input, Output, EventEmitter } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, + exportAs: 'highlight' +}) +export class HighlightDirective { + @Input() color: string = 'yellow'; + @Output() highlighted = new EventEmitter(); +} +"#; + + let options = ComponentTransformOptions::default(); + let result = + transform_angular_file(&allocator, "highlight.directive.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + assert_eq!(result.dts_declarations.len(), 1); + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "HighlightDirective"); + + // Should have ɵfac + assert!( + decl.members.contains("static ɵfac: i0.ɵɵFactoryDeclaration;"), + "Should contain ɵfac. Got:\n{}", + decl.members + ); + + // Should have ɵdir (not ɵcmp) + assert!( + decl.members.contains( + "static ɵdir: i0.ɵɵDirectiveDeclaration;"), + "Should contain ɵfac. Got:\n{}", + decl.members + ); + + // Should have ɵpipe with correct name and standalone + assert!( + decl.members + .contains(r#"static ɵpipe: i0.ɵɵPipeDeclaration;"#), + "Should contain ɵpipe declaration. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_ng_module() { + let allocator = Allocator::default(); + let source = r#" +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [MyComponent], + imports: [CommonModule], + exports: [MyComponent] +}) +export class MyModule {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "my.module.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + assert_eq!(result.dts_declarations.len(), 1); + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "MyModule"); + + // Should have ɵfac + assert!( + decl.members.contains("static ɵfac: i0.ɵɵFactoryDeclaration;"), + "Should contain ɵfac. Got:\n{}", + decl.members + ); + + // Should have ɵmod with declarations, imports, exports + assert!( + decl.members.contains("static ɵmod: i0.ɵɵNgModuleDeclaration;"), + "Should contain ɵinj. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_injectable() { + let allocator = Allocator::default(); + let source = r#" +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class DataService { + getData() { return []; } +} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "data.service.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + assert_eq!(result.dts_declarations.len(), 1); + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "DataService"); + + // Should have ɵfac + assert!( + decl.members.contains("static ɵfac: i0.ɵɵFactoryDeclaration;"), + "Should contain ɵfac. Got:\n{}", + decl.members + ); + + // Should have ɵprov + assert!( + decl.members.contains("static ɵprov: i0.ɵɵInjectableDeclaration;"), + "Should contain ɵprov. Got:\n{}", + decl.members + ); +} + +#[test] +fn test_dts_multiple_classes_in_file() { + let allocator = Allocator::default(); + let source = r#" +import { Component, Injectable, Pipe, PipeTransform } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class MyService {} + +@Pipe({ name: 'myPipe', standalone: true }) +export class MyPipe implements PipeTransform { + transform(v: any) { return v; } +} + +@Component({ + selector: 'app-multi', + standalone: true, + template: '

{{value | myPipe}}

' +}) +export class MultiComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "multi.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + // Should have declarations for all 3 classes + assert_eq!( + result.dts_declarations.len(), + 3, + "Should have 3 dts declarations. Got: {:?}", + result.dts_declarations.iter().map(|d| &d.class_name).collect::>() + ); + + let class_names: Vec<&str> = + result.dts_declarations.iter().map(|d| d.class_name.as_str()).collect(); + assert!(class_names.contains(&"MyService"), "Should have MyService"); + assert!(class_names.contains(&"MyPipe"), "Should have MyPipe"); + assert!(class_names.contains(&"MultiComponent"), "Should have MultiComponent"); +} + +#[test] +fn test_dts_no_declarations_for_plain_class() { + let allocator = Allocator::default(); + let source = r#" +export class PlainClass { + doStuff() { return 42; } +} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "plain.ts", source, &options, None); + + // Should have no dts declarations for plain classes + assert!( + result.dts_declarations.is_empty(), + "Should have no dts declarations for plain classes" + ); +} + +#[test] +fn test_dts_component_with_signal_input() { + let allocator = Allocator::default(); + let source = r#" +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-signal', + standalone: true, + template: '

{{name()}}

' +}) +export class SignalComponent { + name = input('default'); + required = input.required(); +} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "signal.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); + + assert_eq!(result.dts_declarations.len(), 1); + let decl = &result.dts_declarations[0]; + + // Signal inputs should have isSignal: true in the input map + assert!( + decl.members.contains(r#""isSignal": true"#), + "Signal inputs should have isSignal: true. Got:\n{}", + decl.members + ); +} diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index d00654182..0d1d2a6cb 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -185,6 +185,22 @@ export interface DependencyMetadata { skipSelf?: boolean } +/** + * A `.d.ts` type declaration for an Angular class. + * + * Contains the class name and the static member declarations + * that should be injected into the corresponding `.d.ts` class body. + */ +export interface DtsDeclaration { + /** The name of the class. */ + className: string + /** + * The static member declarations to add to the class body in `.d.ts`. + * Newline-separated `static` property declarations. + */ + members: string +} + /** * Encapsulate CSS styles for a component using attribute selectors. * @@ -839,6 +855,19 @@ export interface TransformResult { errors: Array /** Compilation warnings. */ warnings: Array + /** + * `.d.ts` type declarations for Angular classes. + * + * Each entry contains the class name and the static member declarations + * that should be injected into the corresponding `.d.ts` class body. + * This enables library builds to include proper Ivy type declarations + * for template type-checking by consumers. + * + * The declarations use `i0` as the namespace alias for `@angular/core`. + * Consumers must ensure their `.d.ts` files include: + * `import * as i0 from "@angular/core";` + */ + dtsDeclarations: Array } export interface Comment { type: 'Line' | 'Block' diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index a51a7a049..818984c9c 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -272,6 +272,20 @@ pub struct TemplateCompileResult { pub errors: Vec, } +/// A `.d.ts` type declaration for an Angular class. +/// +/// Contains the class name and the static member declarations +/// that should be injected into the corresponding `.d.ts` class body. +#[derive(Default)] +#[napi(object)] +pub struct DtsDeclaration { + /// The name of the class. + pub class_name: String, + /// The static member declarations to add to the class body in `.d.ts`. + /// Newline-separated `static` property declarations. + pub members: String, +} + /// Result of transforming an Angular file. #[derive(Default)] #[napi(object)] @@ -298,6 +312,18 @@ pub struct TransformResult { /// Compilation warnings. pub warnings: Vec, + + /// `.d.ts` type declarations for Angular classes. + /// + /// Each entry contains the class name and the static member declarations + /// that should be injected into the corresponding `.d.ts` class body. + /// This enables library builds to include proper Ivy type declarations + /// for template type-checking by consumers. + /// + /// The declarations use `i0` as the namespace alias for `@angular/core`. + /// Consumers must ensure their `.d.ts` files include: + /// `import * as i0 from "@angular/core";` + pub dts_declarations: Vec, } /// Compile an Angular template to JavaScript. @@ -1042,6 +1068,11 @@ impl Task for TransformAngularFileTask { style_updates: result.style_updates, errors, warnings: vec![], + dts_declarations: result + .dts_declarations + .into_iter() + .map(|d| DtsDeclaration { class_name: d.class_name, members: d.members }) + .collect(), }) } From c6baba33a4e857d11be3cf06e4d9f2e082b5cf7e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Mar 2026 12:06:37 +0800 Subject: [PATCH 2/4] fix: align .d.ts declarations with Angular TS compiler - Fix factory CtorDeps to emit object literal types with attribute/optional/host/self/skipSelf - Emit `null` instead of `""` for pipe names when not specified - Add type_argument_count support for generic Injectable/Pipe/NgModule - Handle control character escaping (\n, \r, \t) in dts strings - Surface ng-content selectors from template compilation pipeline - Generate ngAcceptInputType_* fields for inputs with transforms - Add comprehensive tests for all dts generation scenarios Fix https://github.com/voidzero-dev/oxc-angular-compiler/issues/86 Co-Authored-By: Claude Opus 4.6 --- .../src/component/transform.rs | 38 +- crates/oxc_angular_compiler/src/dts.rs | 175 ++++++-- .../tests/integration_test.rs | 423 ++++++++++++++++++ 3 files changed, 592 insertions(+), 44 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 3b57de1b2..5af5f27b6 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1768,6 +1768,7 @@ pub fn transform_angular_file( type_argument_count, &content_query_names, has_injectable, + &compilation_result.ng_content_selectors, )); result.component_count += 1; @@ -1937,11 +1938,15 @@ pub fn transform_angular_file( } // Generate .d.ts type declaration for this pipe + let type_argument_count = + class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); let has_injectable = extract_injectable_metadata(allocator, class).is_some(); - result - .dts_declarations - .push(dts::generate_pipe_dts(&pipe_metadata, has_injectable)); + result.dts_declarations.push(dts::generate_pipe_dts( + &pipe_metadata, + type_argument_count, + has_injectable, + )); class_positions.push(( class_name.clone(), @@ -2025,11 +2030,15 @@ pub fn transform_angular_file( } // Generate .d.ts type declaration for this NgModule + let type_argument_count = + class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); let has_injectable = extract_injectable_metadata(allocator, class).is_some(); - result - .dts_declarations - .push(dts::generate_ng_module_dts(&ng_module_metadata, has_injectable)); + result.dts_declarations.push(dts::generate_ng_module_dts( + &ng_module_metadata, + type_argument_count, + has_injectable, + )); // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push(( @@ -2083,9 +2092,12 @@ pub fn transform_angular_file( ); // Generate .d.ts type declaration for this injectable - result - .dts_declarations - .push(dts::generate_injectable_dts(&injectable_metadata)); + let type_argument_count = + class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); + result.dts_declarations.push(dts::generate_injectable_dts( + &injectable_metadata, + type_argument_count, + )); class_positions.push(( class_name.clone(), @@ -2231,6 +2243,9 @@ struct FullCompilationResult { /// The next constant pool index to use for the next component. /// This is used to share pool state across multiple components in the same file. next_pool_index: u32, + + /// The ng-content selectors found in the template (e.g., `["*", ".header"]`). + ng_content_selectors: Vec, } /// Compile a component template and generate ɵcmp/ɵfac definitions. @@ -2305,6 +2320,10 @@ fn compile_component_full<'a>( return Err(diagnostics); } + // Capture ng-content selectors from the R3 AST for .d.ts generation + let ng_content_selectors: Vec = + r3_result.ng_content_selectors.iter().map(|s| s.to_string()).collect(); + // Merge inline template styles into component metadata // These are styles from