diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 742e5242a..a5c271d43 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -18,7 +18,8 @@ use super::metadata::{ }; use super::transform::ImportMap; use crate::directive::{ - extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, + StringConsts, extract_host_bindings, extract_host_listeners, extract_input_metadata, + extract_output_metadata, }; use crate::output::oxc_converter::convert_oxc_expression; @@ -52,6 +53,7 @@ pub fn extract_component_metadata<'a>( implicit_standalone: bool, import_map: &ImportMap<'a>, source_text: Option<&'a str>, + consts: &StringConsts<'a>, ) -> Option> { // Get the class name let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); @@ -85,22 +87,22 @@ pub fn extract_component_metadata<'a>( // Parse each property in the config object for prop in &config_obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let key_name = get_property_key_name(&prop.key)?; + let key_name = get_property_key_name(&prop.key, consts)?; match key_name.as_str() { "selector" => { - metadata.selector = extract_string_value(&prop.value); + metadata.selector = extract_string_value(&prop.value, consts); } "template" => { - metadata.template = extract_string_value(&prop.value); + metadata.template = extract_string_value(&prop.value, consts); } "templateUrl" => { - metadata.template_url = extract_string_value(&prop.value); + metadata.template_url = extract_string_value(&prop.value, consts); } "styles" => { if let Some(styles) = extract_string_array(allocator, &prop.value) { metadata.styles = styles; - } else if let Some(style) = extract_string_value(&prop.value) { + } else if let Some(style) = extract_string_value(&prop.value, consts) { // Single style string (legacy support) metadata.styles.push(style); } @@ -108,7 +110,7 @@ pub fn extract_component_metadata<'a>( "styleUrls" | "styleUrl" => { if let Some(urls) = extract_string_array(allocator, &prop.value) { metadata.style_urls = urls; - } else if let Some(url) = extract_string_value(&prop.value) { + } else if let Some(url) = extract_string_value(&prop.value, consts) { metadata.style_urls.push(url); } } @@ -125,7 +127,7 @@ pub fn extract_component_metadata<'a>( metadata.change_detection = extract_change_detection(&prop.value); } "host" => { - metadata.host = extract_host_metadata(allocator, &prop.value); + metadata.host = extract_host_metadata(allocator, &prop.value, consts); } "imports" => { // For standalone components, we need: @@ -137,7 +139,7 @@ pub fn extract_component_metadata<'a>( } "exportAs" => { // exportAs can be comma-separated: "foo, bar" - if let Some(export_as) = extract_string_value(&prop.value) { + if let Some(export_as) = extract_string_value(&prop.value, consts) { for part in export_as.as_str().split(',') { let trimmed = part.trim(); if !trimmed.is_empty() { @@ -175,7 +177,7 @@ pub fn extract_component_metadata<'a>( // Extract host directives array // Handles both simple identifiers and complex objects with inputs/outputs metadata.host_directives = - extract_host_directives(allocator, &prop.value, import_map); + extract_host_directives(allocator, &prop.value, import_map, consts); } "signals" => { // Extract signals flag (true if component uses signal-based inputs) @@ -341,16 +343,28 @@ fn is_component_call(callee: &Expression<'_>) -> bool { } /// Get the name of a property key as a string. -fn get_property_key_name<'a>(key: &PropertyKey<'a>) -> Option> { +/// +/// Resolves same-file `const` identifiers in computed keys (`[FOO]: bar`) so the +/// emitted component metadata matches the official Angular compiler's output. +fn get_property_key_name<'a>( + key: &PropertyKey<'a>, + consts: &StringConsts<'a>, +) -> Option> { match key { PropertyKey::StaticIdentifier(id) => Some(id.name.clone().into()), PropertyKey::StringLiteral(lit) => Some(lit.value.clone().into()), + // Computed identifier reference: `[FOO]: bar` — resolve against same-file consts. + PropertyKey::Identifier(id) => consts.get(id.name.as_str()).cloned(), _ => None, } } /// Extract a string value from an expression. -fn extract_string_value<'a>(expr: &Expression<'a>) -> Option> { +/// +/// Resolves same-file `const` identifier references in value position +/// (`host: { type: FOO }`) so the emitted metadata matches the official +/// Angular compiler's output. +fn extract_string_value<'a>(expr: &Expression<'a>, consts: &StringConsts<'a>) -> Option> { match expr { Expression::StringLiteral(lit) => Some(lit.value.clone().into()), Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { @@ -359,6 +373,7 @@ fn extract_string_value<'a>(expr: &Expression<'a>) -> Option> { // Angular evaluates template literals, so we need cooked, not raw tpl.quasis.first().and_then(|q| q.value.cooked.clone().map(Into::into)) } + Expression::Identifier(id) => consts.get(id.name.as_str()).cloned(), _ => None, } } @@ -474,6 +489,7 @@ fn extract_change_detection(expr: &Expression<'_>) -> ChangeDetectionStrategy { fn extract_host_metadata<'a>( allocator: &'a Allocator, expr: &Expression<'a>, + consts: &StringConsts<'a>, ) -> Option> { let Expression::ObjectExpression(obj) = expr else { return None; @@ -489,10 +505,10 @@ fn extract_host_metadata<'a>( for prop in &obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { + let Some(key_name) = get_property_key_name(&prop.key, consts) else { continue; }; - let Some(value) = extract_string_value(&prop.value) else { + let Some(value) = extract_string_value(&prop.value, consts) else { continue; }; @@ -539,6 +555,7 @@ fn extract_host_directives<'a>( allocator: &'a Allocator, expr: &Expression<'a>, import_map: &ImportMap<'a>, + consts: &StringConsts<'a>, ) -> Vec<'a, HostDirectiveMetadata<'a>> { let mut result = Vec::new_in(allocator); @@ -547,7 +564,7 @@ fn extract_host_directives<'a>( }; for element in &arr.elements { - if let Some(meta) = extract_single_host_directive(allocator, element, import_map) { + if let Some(meta) = extract_single_host_directive(allocator, element, import_map, consts) { result.push(meta); } } @@ -565,6 +582,7 @@ fn extract_single_host_directive<'a>( allocator: &'a Allocator, element: &ArrayExpressionElement<'a>, import_map: &ImportMap<'a>, + consts: &StringConsts<'a>, ) -> Option> { match element { // Simple identifier: TooltipDirective @@ -587,7 +605,7 @@ fn extract_single_host_directive<'a>( for prop in &obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { + let Some(key_name) = get_property_key_name(&prop.key, consts) else { continue; }; @@ -1149,6 +1167,7 @@ mod tests { // Build import map from the program body let import_map = build_import_map(&allocator, &parser_ret.program.body, None); + let consts = crate::directive::collect_string_consts(&parser_ret.program); // Find the first class declaration (handles plain, export default, and export named) let mut found_metadata = None; @@ -1173,6 +1192,7 @@ mod tests { implicit_standalone, &import_map, Some(code), + &consts, ) { found_metadata = Some(metadata); break; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 03bb106aa..dc548416b 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -36,6 +36,7 @@ use crate::class_metadata::{ R3ClassMetadata, build_ctor_params_metadata, build_decorator_metadata_array, build_prop_decorators_metadata, compile_class_metadata, }; +use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, extract_content_queries, extract_directive_metadata, extract_view_queries, @@ -1825,6 +1826,11 @@ pub fn transform_angular_file( let import_map = build_import_map(allocator, &parser_ret.program.body, options.resolved_imports.as_ref()); + // Collect file-scope string consts so decorator metadata can resolve identifier + // references (e.g. `host: { [ATTR_NAME]: '' }`) the same way the official + // Angular compiler does. + let string_consts = collect_string_consts(&parser_ret.program); + #[cfg(feature = "cross_file_elision")] let mut import_map = build_import_map(allocator, &parser_ret.program.body, options.resolved_imports.as_ref()); @@ -1872,6 +1878,7 @@ pub fn transform_angular_file( implicit_standalone, &import_map, Some(source), + &string_consts, ) { // 3. Resolve external styles and merge into metadata resolve_styles(allocator, &mut metadata, resolved_resources); @@ -2106,9 +2113,13 @@ pub fn transform_angular_file( // definitions. This prevents Angular's JIT runtime from processing // the directive and creating conflicting property definitions (like // ɵfac getters) that interfere with the AOT-compiled assignments. - if let Some(mut directive_metadata) = - extract_directive_metadata(allocator, class, implicit_standalone, Some(source)) - { + if let Some(mut directive_metadata) = extract_directive_metadata( + allocator, + class, + implicit_standalone, + Some(source), + &string_consts, + ) { // Track decorator span for removal if let Some(span) = find_directive_decorator_span(class) { decorator_spans_to_remove.push(span); diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 5cdf83b7f..5a0c5359c 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -3,10 +3,13 @@ //! This module provides utilities for finding and extracting metadata from //! `@Directive({...})` decorators on TypeScript class declarations. +use std::collections::HashMap; + use oxc_allocator::{Allocator, Box, Vec}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Class, ClassElement, Decorator, Expression, - MethodDefinitionKind, ObjectPropertyKind, PropertyKey, + Argument, ArrayExpressionElement, BindingPattern, Class, ClassElement, Declaration, Decorator, + Expression, MethodDefinitionKind, ObjectPropertyKind, Program, PropertyKey, Statement, + VariableDeclarationKind, }; use oxc_span::Span; use oxc_str::Ident; @@ -79,6 +82,7 @@ pub fn extract_directive_metadata<'a>( class: &'a Class<'a>, implicit_standalone: bool, source_text: Option<&'a str>, + consts: &StringConsts<'a>, ) -> Option> { // Get the class name let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); @@ -116,13 +120,13 @@ pub fn extract_directive_metadata<'a>( if let Some(config_obj) = config_obj { for prop in &config_obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { + let Some(key_name) = get_property_key_name(&prop.key, consts) else { continue; }; match key_name.as_str() { "selector" => { - if let Some(selector) = extract_string_value(&prop.value) { + if let Some(selector) = extract_string_value(&prop.value, consts) { builder = builder.selector(selector); } } @@ -132,7 +136,7 @@ pub fn extract_directive_metadata<'a>( } } "exportAs" => { - if let Some(export_as) = extract_string_value(&prop.value) { + if let Some(export_as) = extract_string_value(&prop.value, consts) { // exportAs can be comma-separated: "foo, bar" for part in export_as.as_str().split(',') { let trimmed = part.trim(); @@ -151,10 +155,11 @@ pub fn extract_directive_metadata<'a>( } } "host" => { - host_from_decorator = extract_host_metadata(allocator, &prop.value); + host_from_decorator = extract_host_metadata(allocator, &prop.value, consts); } "hostDirectives" => { - let host_directives = extract_host_directives(allocator, &prop.value); + let host_directives = + extract_host_directives(allocator, &prop.value, consts); for hd in host_directives { builder = builder.add_host_directive(hd); } @@ -439,24 +444,86 @@ fn has_ng_on_changes_method(class: &Class<'_>) -> bool { }) } +/// File-scope map of `const NAME = "value"` declarations. +/// +/// Used to resolve identifier references inside decorator metadata — primarily +/// `host: { [ATTR_NAME]: '' }` or `host: { type: VALUE }` patterns — to match +/// the official Angular compiler's compile-time constant folding. +/// +/// Only literal string values (string literals and single-quasi template literals) +/// are captured; computed initializers and cross-file imports are out of scope. +pub type StringConsts<'a> = HashMap<&'a str, Ident<'a>>; + +/// Walk the top-level statements of a program and collect string-valued `const` +/// declarations. +/// +/// Matches both bare `const X = '...'` and `export const X = '...'`. Reassignment +/// kinds (`let`/`var`) are skipped — only `const` is safe to fold. +pub fn collect_string_consts<'a>(program: &Program<'a>) -> StringConsts<'a> { + let mut map = StringConsts::default(); + for stmt in &program.body { + let decl = match stmt { + Statement::VariableDeclaration(d) => d.as_ref(), + Statement::ExportNamedDeclaration(e) => match &e.declaration { + Some(Declaration::VariableDeclaration(d)) => d.as_ref(), + _ => continue, + }, + _ => continue, + }; + if !matches!(decl.kind, VariableDeclarationKind::Const) { + continue; + } + for vd in &decl.declarations { + let BindingPattern::BindingIdentifier(id) = &vd.id else { + continue; + }; + let Some(init) = &vd.init else { continue }; + if let Some(value) = literal_string_from_expression(init) { + map.insert(id.name.as_str(), value); + } + } + } + map +} + +/// Extract a literal string value (`'foo'` or `` `foo` ``) from an expression. +/// Returns `None` for anything that isn't a plain string at compile time. +fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option> { + match expr { + Expression::StringLiteral(lit) => Some(lit.value.clone().into()), + Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { + tpl.quasis.first().and_then(|q| q.value.cooked.clone().map(Into::into)) + } + _ => None, + } +} + /// Get the name of a property key as a string. -fn get_property_key_name<'a>(key: &PropertyKey<'a>) -> Option> { +/// +/// Resolves same-file `const` identifiers in computed keys (`[FOO]: bar`) so the +/// emitted directive metadata matches the official Angular compiler's output. +fn get_property_key_name<'a>( + key: &PropertyKey<'a>, + consts: &StringConsts<'a>, +) -> Option> { match key { PropertyKey::StaticIdentifier(id) => Some(id.name.clone().into()), PropertyKey::StringLiteral(lit) => Some(lit.value.clone().into()), + // Computed identifier reference: `[FOO]: bar` — resolve against same-file consts. + PropertyKey::Identifier(id) => consts.get(id.name.as_str()).cloned(), _ => None, } } /// Extract a string value from an expression. -fn extract_string_value<'a>(expr: &Expression<'a>) -> Option> { +/// +/// Resolves same-file `const` identifier references in value position +/// (`host: { type: FOO }`) so the emitted metadata matches the official +/// Angular compiler's output. +fn extract_string_value<'a>(expr: &Expression<'a>, consts: &StringConsts<'a>) -> Option> { match expr { - Expression::StringLiteral(lit) => Some(lit.value.clone().into()), - Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { - // Simple template literal with no expressions - tpl.quasis.first().and_then(|q| q.value.cooked.clone().map(Into::into)) - } - _ => None, + Expression::Identifier(id) => consts.get(id.name.as_str()).cloned(), + _ => literal_string_from_expression(expr), } } @@ -474,6 +541,7 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { fn extract_host_metadata<'a>( allocator: &'a Allocator, expr: &Expression<'a>, + consts: &StringConsts<'a>, ) -> Option> { let Expression::ObjectExpression(obj) = expr else { return None; @@ -483,10 +551,10 @@ fn extract_host_metadata<'a>( for prop in &obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { + let Some(key_name) = get_property_key_name(&prop.key, consts) else { continue; }; - let Some(value) = extract_string_value(&prop.value) else { + let Some(value) = extract_string_value(&prop.value, consts) else { continue; }; @@ -532,6 +600,7 @@ fn extract_host_metadata<'a>( fn extract_host_directives<'a>( allocator: &'a Allocator, expr: &Expression<'a>, + consts: &StringConsts<'a>, ) -> Vec<'a, R3HostDirectiveMetadata<'a>> { let mut result = Vec::new_in(allocator); @@ -540,7 +609,7 @@ fn extract_host_directives<'a>( }; for element in &arr.elements { - if let Some(meta) = extract_single_host_directive(allocator, element) { + if let Some(meta) = extract_single_host_directive(allocator, element, consts) { result.push(meta); } } @@ -552,6 +621,7 @@ fn extract_host_directives<'a>( fn extract_single_host_directive<'a>( allocator: &'a Allocator, element: &ArrayExpressionElement<'a>, + consts: &StringConsts<'a>, ) -> Option> { match element { // Simple identifier: TooltipDirective @@ -571,7 +641,7 @@ fn extract_single_host_directive<'a>( for prop in &obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { + let Some(key_name) = get_property_key_name(&prop.key, consts) else { continue; }; @@ -874,6 +944,7 @@ mod tests { let allocator = Allocator::default(); let source_type = SourceType::tsx(); let parser_ret = Parser::new(&allocator, code, source_type).parse(); + let consts = collect_string_consts(&parser_ret.program); let mut found_metadata = None; for stmt in &parser_ret.program.body { @@ -891,9 +962,13 @@ mod tests { }; if let Some(class) = class { - if let Some(metadata) = - extract_directive_metadata(&allocator, class, implicit_standalone, Some(code)) - { + if let Some(metadata) = extract_directive_metadata( + &allocator, + class, + implicit_standalone, + Some(code), + &consts, + ) { found_metadata = Some(metadata); break; } @@ -1060,6 +1135,77 @@ mod tests { }); } + // Identifier resolution in host: { } — match the official Angular compiler, + // which folds same-file `const` references at compile time and emits hostAttrs. + + #[test] + fn test_extract_directive_host_computed_key_identifier() { + let code = r#" + const ATTR = 'data-foo'; + @Directive({ selector: '[d]', host: { [ATTR]: '' } }) + class D {} + "#; + assert_directive_metadata(code, |meta| { + assert_eq!(meta.host.attributes.len(), 1); + assert_eq!(meta.host.attributes[0].0.as_str(), "data-foo"); + }); + } + + #[test] + fn test_extract_directive_host_value_identifier() { + let code = r#" + const VAL = 'submit'; + @Directive({ selector: '[d]', host: { type: VAL } }) + class D {} + "#; + assert_directive_metadata(code, |meta| { + assert_eq!(meta.host.attributes.len(), 1); + assert_eq!(meta.host.attributes[0].0.as_str(), "type"); + }); + } + + #[test] + fn test_extract_directive_host_template_literal_const() { + let code = r#" + const ATTR = `data-foo`; + @Directive({ selector: '[d]', host: { [ATTR]: '' } }) + class D {} + "#; + assert_directive_metadata(code, |meta| { + assert_eq!(meta.host.attributes.len(), 1); + assert_eq!(meta.host.attributes[0].0.as_str(), "data-foo"); + }); + } + + #[test] + fn test_extract_directive_host_unknown_identifier_dropped() { + // Unresolved identifier (no matching const) is still dropped — current behavior. + let code = r#" + @Directive({ selector: '[d]', host: { [UNKNOWN]: '' } }) + class D {} + "#; + assert_directive_metadata(code, |meta| { + assert_eq!(meta.host.attributes.len(), 0); + }); + } + + #[test] + fn test_extract_directive_host_exported_const_identifier() { + // `export const` (not just `const`) in the same file must also be resolved. + let code = r#" + export const MARKER_ATTR = 'data-marker'; + @Directive({ + selector: '[marker]', + host: { [MARKER_ATTR]: '' } + }) + class MarkerDirective {} + "#; + assert_directive_metadata(code, |meta| { + assert_eq!(meta.host.attributes.len(), 1); + assert_eq!(meta.host.attributes[0].0.as_str(), "data-marker"); + }); + } + #[test] fn test_extract_directive_host_class_attr() { let code = r#" diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index abce967da..8b379337b 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -24,7 +24,9 @@ pub use compiler::{ DirectiveCompileResult, compile_directive, compile_directive_from_metadata, create_inputs_literal, create_outputs_literal, }; -pub use decorator::{extract_directive_metadata, find_directive_decorator_span}; +pub use decorator::{ + StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, +}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 2c28b0590..9a87a1a27 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -96,10 +96,10 @@ pub use factory::{ pub use directive::{ DirectiveCompileResult, DirectiveDefinitions, QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, - R3QueryMetadata, compile_directive, compile_directive_from_metadata, extract_content_queries, - extract_directive_metadata, extract_host_bindings, extract_host_listeners, - extract_input_metadata, extract_output_metadata, extract_view_queries, - find_directive_decorator_span, generate_directive_definitions, + R3QueryMetadata, StringConsts, collect_string_consts, compile_directive, + compile_directive_from_metadata, extract_content_queries, extract_directive_metadata, + extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, + extract_view_queries, find_directive_decorator_span, generate_directive_definitions, }; // Re-export injectable types diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 8f9fbd561..84a741019 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -9861,3 +9861,82 @@ export class TestComponent { ); insta::assert_snapshot!("output_from_observable_mixed_with_output", result.code); } + +/// Host attribute key referencing a same-file `const` must emit `hostAttrs` in +/// `ɵɵdefineDirective`, matching the official Angular compiler. +#[test] +fn host_attribute_identifier_key_emits_host_attrs() { + let allocator = Allocator::default(); + let source = r#" +import { Directive } from '@angular/core'; + +export const MARKER_ATTR = 'data-marker'; + +@Directive({ + selector: '[marker]', + host: { [MARKER_ATTR]: '' }, +}) +export class MarkerDirective {} +"#; + let result = transform_angular_file(&allocator, "marker.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + assert!( + normalized.contains(r#"hostAttrs:["data-marker",""]"#), + "Expected hostAttrs:[\"data-marker\",\"\"] in directive definition.\nCode:\n{}", + result.code + ); +} + +/// Host attribute value referencing a same-file `const` must resolve and emit +/// `hostAttrs` with the resolved string. +#[test] +fn host_attribute_identifier_value_emits_host_attrs() { + let allocator = Allocator::default(); + let source = r#" +import { Directive } from '@angular/core'; + +const BTN_TYPE = 'submit'; + +@Directive({ + selector: '[d]', + host: { type: BTN_TYPE }, +}) +export class D {} +"#; + let result = transform_angular_file(&allocator, "d.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + assert!( + normalized.contains(r#"hostAttrs:["type","submit"]"#), + "Expected hostAttrs:[\"type\",\"submit\"] in directive definition.\nCode:\n{}", + result.code + ); +} + +/// An unresolved identifier in a computed host key must be silently dropped — +/// matching existing behavior for any unrecognized host metadata. +#[test] +fn host_attribute_unknown_identifier_dropped() { + let allocator = Allocator::default(); + let source = r#" +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[d]', + host: { [UNRESOLVED]: '' }, +}) +export class D {} +"#; + let result = transform_angular_file(&allocator, "d.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + assert!( + !normalized.contains("hostAttrs:"), + "Unresolved identifier must not produce hostAttrs entry.\nCode:\n{}", + result.code + ); +} diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 01063978f..cf2caba9d 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -734,7 +734,9 @@ pub struct ComponentUrls { /// /// A `ComponentUrls` containing all template and style URLs found. pub fn extract_component_urls_sync(source: String, filename: String) -> ComponentUrls { - use oxc_angular_compiler::{build_import_map, extract_component_metadata}; + use oxc_angular_compiler::{ + build_import_map, collect_string_consts, extract_component_metadata, + }; use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; @@ -747,6 +749,7 @@ pub fn extract_component_urls_sync(source: String, filename: String) -> Componen // Build import map for component metadata extraction let import_map = build_import_map(&allocator, &program.body, None); + let string_consts = collect_string_consts(program); let mut template_urls = Vec::new(); let mut style_urls = Vec::new(); @@ -769,9 +772,14 @@ pub fn extract_component_urls_sync(source: String, filename: String) -> Componen if let Some(class) = class { // Extract metadata from @Component decorator // Use implicit_standalone=true (v19+ default) since it doesn't affect URL extraction - if let Some(metadata) = - extract_component_metadata(&allocator, class, true, &import_map, Some(&source)) - { + if let Some(metadata) = extract_component_metadata( + &allocator, + class, + true, + &import_map, + Some(&source), + &string_consts, + ) { // Collect template URL if let Some(template_url) = &metadata.template_url { template_urls.push(template_url.to_string()); @@ -1489,9 +1497,9 @@ pub fn extract_component_metadata_sync( use oxc_angular_compiler::output::emitter::JsEmitter; use oxc_angular_compiler::{ ChangeDetectionStrategy as RustChangeDetection, QueryPredicate, - ViewEncapsulation as RustViewEncapsulation, build_import_map, extract_component_metadata, - extract_content_queries, extract_input_metadata, extract_output_metadata, - extract_view_queries, + ViewEncapsulation as RustViewEncapsulation, build_import_map, collect_string_consts, + extract_component_metadata, extract_content_queries, extract_input_metadata, + extract_output_metadata, extract_view_queries, }; use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement}; use oxc_parser::Parser; @@ -1507,6 +1515,7 @@ pub fn extract_component_metadata_sync( // Build import map for component metadata extraction let import_map = build_import_map(&allocator, &program.body, None); + let string_consts = collect_string_consts(program); let mut results = Vec::new(); let emitter = JsEmitter::new(); @@ -1534,6 +1543,7 @@ pub fn extract_component_metadata_sync( implicit_standalone, &import_map, Some(&source), + &string_consts, ) { // Convert encapsulation to string let encapsulation = match metadata.encapsulation {