diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 1e413b990..dd1785b63 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -417,29 +417,28 @@ fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExp }) } -/// Extract a string property value from an object expression. +/// Extract a static string from an expression. /// Handles both regular string literals (`"..."`) and template literals with no expressions (`` `...` ``). +fn as_static_string<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { + match expr { + Expression::StringLiteral(lit) => Some(lit.value.as_str()), + Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => { + tl.quasis.first()?.value.cooked.as_deref() + } + _ => None, + } +} + +/// Extract a string property value from an object expression. fn get_string_property<'a>(obj: &'a ObjectExpression<'a>, name: &str) -> Option<&'a str> { - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop - && matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) + obj.properties.iter().find_map(|prop| match prop { + ObjectPropertyKind::ObjectProperty(prop) + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) => { - match &prop.value { - Expression::StringLiteral(lit) => { - return Some(lit.value.as_str()); - } - Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => { - if let Some(quasi) = tl.quasis.first() - && let Some(cooked) = &quasi.value.cooked - { - return Some(cooked.as_str()); - } - } - _ => {} - } + as_static_string(&prop.value) } - } - None + _ => None, + }) } /// Extract the source text of a property value from an object expression. @@ -626,14 +625,13 @@ fn is_property_false(obj: &ObjectExpression<'_>, name: &str) -> bool { }) } -/// Check if a property exists and its value is a specific string literal. +/// Check if a property exists and its value is a specific static string +/// (string literal or template literal with no substitutions). fn is_property_string(obj: &ObjectExpression<'_>, name: &str, value: &str) -> bool { obj.properties.iter().any(|prop| { - matches!(prop, - ObjectPropertyKind::ObjectProperty(p) - if matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) - && matches!(&p.value, Expression::StringLiteral(s) if s.value == value) - ) + let ObjectPropertyKind::ObjectProperty(p) = prop else { return false }; + matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) + && as_static_string(&p.value) == Some(value) }) } @@ -849,10 +847,17 @@ fn build_host_attrs(host_obj: &ObjectExpression<'_>, source: &str) -> String { PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value_span = p.value.span(); - let value = &source[value_span.start as usize..value_span.end as usize]; - attrs.push(format!("\"{key}\"")); - attrs.push(value.to_string()); + // Static strings (including template literals a bundler may have + // emitted) are emitted cooked and quoted like Angular's output. + let value = as_static_string(&p.value).map_or_else( + || { + let span = p.value.span(); + source[span.start as usize..span.end as usize].to_string() + }, + |s| crate::output::emitter::escape_string(s, false), + ); + attrs.push(crate::output::emitter::escape_string(&key, false)); + attrs.push(value); } } } @@ -1345,17 +1350,27 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source let quoted_key = quote_key(&key); + // Simple string: propertyName: "publicName" → keep as is. The value may + // arrive as a template literal when a bundler rewrote the string. + if let Some(public_name) = as_static_string(&p.value) { + entries.push(format!( + "{quoted_key}: {}", + crate::output::emitter::escape_string(public_name, false) + )); + continue; + } + match &p.value { - // Simple string: propertyName: "publicName" → keep as is - Expression::StringLiteral(lit) => { - entries.push(format!("{quoted_key}: \"{}\"", lit.value.as_str())); - } // Array: check if it's declaration format [publicName, classPropertyName] // and convert to definition format [InputFlags, publicName, classPropertyName] Expression::ArrayExpression(arr) => { let val = &source[p.value.span().start as usize..p.value.span().end as usize]; - let first_is_string = - matches!(arr.elements.first(), Some(ArrayExpressionElement::StringLiteral(_))); + let first_is_string = arr + .elements + .first() + .and_then(ArrayExpressionElement::as_expression) + .and_then(as_static_string) + .is_some(); if !first_is_string { // Already in definition format or unknown, keep as is. @@ -1378,8 +1393,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source } // Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc. Expression::ObjectExpression(obj) => { - let public_name = get_string_property(obj, "publicName").unwrap_or(&key); - let declared_name = get_string_property(obj, "classPropertyName").unwrap_or(&key); + let public_name = crate::output::emitter::escape_string( + get_string_property(obj, "publicName").unwrap_or(&key), + false, + ); + let declared_name = crate::output::emitter::escape_string( + get_string_property(obj, "classPropertyName").unwrap_or(&key), + false, + ); let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); // Angular emits `transformFunction: null` for signal inputs without @@ -1400,15 +1421,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source if flags == 0 && transform.is_none() && public_name == declared_name { // Simple case: no flags, no transform, names match - entries.push(format!("{quoted_key}: \"{public_name}\"")); + entries.push(format!("{quoted_key}: {public_name}")); } else if let Some(transform_fn) = transform { entries.push(format!( - "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]" + "{quoted_key}: [{flags}, {public_name}, {declared_name}, {transform_fn}]" )); } else { - entries.push(format!( - "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]" - )); + entries + .push(format!("{quoted_key}: [{flags}, {public_name}, {declared_name}]")); } } // Unknown format, keep as is @@ -1557,11 +1577,8 @@ fn extract_host_metadata_input( PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value = match &p.value { - Expression::StringLiteral(s) => s.value.to_string(), - _ => continue, - }; - input.properties.push((key, value)); + let Some(value) = as_static_string(&p.value) else { continue }; + input.properties.push((key, value.to_string())); } } @@ -1573,11 +1590,8 @@ fn extract_host_metadata_input( PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value = match &p.value { - Expression::StringLiteral(s) => s.value.to_string(), - _ => continue, - }; - input.listeners.push((key, value)); + let Some(value) = as_static_string(&p.value) else { continue }; + input.listeners.push((key, value.to_string())); } } @@ -2036,12 +2050,10 @@ fn link_component( if let Some(styles_arr) = get_array_property(meta, "styles") { let mut scoped_styles: Vec = Vec::new(); for el in &styles_arr.elements { - let expr = match el { - ArrayExpressionElement::SpreadElement(_) => continue, - _ => el.to_expression(), - }; - if let Expression::StringLiteral(s) = expr { - let style = s.value.as_str(); + // `as_expression` returns None for spreads and elisions (e.g. `[, "a"]`), + // where `to_expression` would panic. + let Some(expr) = el.as_expression() else { continue }; + if let Some(style) = as_static_string(expr) { if is_emulated { let scoped = crate::styles::shim_css_text(style, "_ngcontent-%COMP%", "_nghost-%COMP%"); diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 129eb40e4..e408eac1e 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -121,3 +121,134 @@ MyControl.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21 let result = link(&allocator, code, "test.mjs"); insta::assert_snapshot!(result.code); } + +/// Helper to build a ɵɵngDeclareComponent source with a given `styles` array. +fn make_component_source_with_styles(styles: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class MyCmp {{}} +MyCmp.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "17.0.0", type: MyCmp, selector: "my-cmp", template: "
", styles: [{styles}] }});"# + ) +} + +#[test] +fn test_link_styles_template_literal_matches_string_literal() { + for (template_styles, string_styles) in [ + ("`.a { color: red }`", r#"".a { color: red }""#), + (r#"`.a { content: "\\f101\n" }`"#, r#"".a { content: \"\\f101\n\" }""#), + ] { + let allocator = Allocator::default(); + let template = + link(&allocator, &make_component_source_with_styles(template_styles), "test.mjs"); + let string = + link(&allocator, &make_component_source_with_styles(string_styles), "test.mjs"); + assert!(template.code.contains("styles: ["), "styles dropped:\n{}", template.code); + assert_eq!(template.code, string.code); + } +} + +#[test] +fn test_link_host_bindings_template_literal_matches_string_literal() { + let make = |prop: &str, listener: &str| { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ properties: {{ "id": {prop} }}, listeners: {{ "click": {listener} }} }} }});"# + ) + }; + let allocator = Allocator::default(); + let template = link(&allocator, &make("`this.dirId`", "`onClick($event)`"), "test.mjs"); + let string = link(&allocator, &make(r#""this.dirId""#, r#""onClick($event)""#), "test.mjs"); + assert!(template.code.contains("dirId"), "host property dropped:\n{}", template.code); + assert!(template.code.contains("onClick"), "host listener dropped:\n{}", template.code); + assert_eq!(template.code, string.code); +} + +/// Static `host.attributes` values rewritten as template literals keep their +/// cooked value and emit byte-identical hostAttrs to the quoted form. +#[test] +fn test_link_host_attributes_template_literal_matches_string_literal() { + let make = |value: &str| { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ attributes: {{ "aria-label": {value} }} }} }});"# + ) + }; + let allocator = Allocator::default(); + let template = link(&allocator, &make(r#"`say "hi"`"#), "test.mjs"); + let string = link(&allocator, &make(r#""say \"hi\"""#), "test.mjs"); + assert!( + template.code.contains(r#"hostAttrs: ["aria-label", "say \"hi\""]"#), + "host attribute dropped or unescaped:\n{}", + template.code + ); + assert_eq!(template.code, string.code); +} + +/// `inputs` values rewritten as template literals are treated like string +/// literals. For the aliased array form `[publicName, classPropertyName]` this +/// preserves the declaration-format detection — otherwise the array would be +/// passed through verbatim and the runtime would read the public name as +/// `InputFlags`. Array elements keep their original source spelling, so only +/// the simple and object forms are asserted byte-identical. +#[test] +fn test_link_inputs_template_literal_matches_string_literal() { + // Simple value form: same emitted output either way. + let allocator = Allocator::default(); + let template = link(&allocator, &make_directive_source(r#"prop: `publicProp`"#), "test.mjs"); + let string = link(&allocator, &make_directive_source(r#"prop: "publicProp""#), "test.mjs"); + assert_eq!(template.code, string.code); + + // Aliased array form: the first template-literal element must be recognised + // as a string so the [0, ...] flags element is inserted. + let allocator = Allocator::default(); + let template = + link(&allocator, &make_directive_source(r#"prop: [`publicProp`, `prop`]"#), "test.mjs"); + assert!( + template.code.contains(r#"prop: [0, `publicProp`, `prop`]"#), + "aliased input lost its InputFlags element:\n{}", + template.code + ); + let string = + link(&allocator, &make_directive_source(r#"prop: ["publicProp", "prop"]"#), "test.mjs"); + assert!( + string.code.contains(r#"prop: [0, "publicProp", "prop"]"#), + "quoted input changed behaviour:\n{}", + string.code + ); + + // Object format: publicName/classPropertyName may also be template literals. + let allocator = Allocator::default(); + let template = link( + &allocator, + &make_directive_source( + r#"prop: { classPropertyName: `prop`, publicName: `publicProp`, isRequired: false, isSignal: false }"#, + ), + "test.mjs", + ); + let string = link( + &allocator, + &make_directive_source( + r#"prop: { classPropertyName: "prop", publicName: "publicProp", isRequired: false, isSignal: false }"#, + ), + "test.mjs", + ); + assert_eq!(template.code, string.code); +} + +/// `deps: "invalid"` rewritten as a template literal still selects the +/// ɵɵinvalidFactory path instead of falling through to `new Type()`. +#[test] +fn test_link_factory_invalid_deps_template_literal() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyService { +} +MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, deps: `invalid`, target: i0.ɵɵFactoryTarget.Injectable }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("ɵɵinvalidFactory"), "deps not invalid:\n{}", result.code); +}