From bcad99786d2a22a307d9756ca3f38de531147d71 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Tue, 22 Sep 2026 16:58:03 +0100 Subject: [PATCH 01/54] test(i18n): add failing tests for i18n attribute and ICU messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expected output is taken from Angular 22.1.5 ngc. - i18n- messages (plain, interpolated, and alongside an i18n element body) currently lose their text: goog.getMsg("") and $localize`:@@id:`. - An element whose only child is an ICU currently emits an extra empty sub-message referenced as {"icu": "�i18n_N�"}, splits the ICU's inner {{count}} into a $localize substitution, and emits no i18nPostprocess call, so the runtime fails to parse the ICU. - An ICU next to other content currently gets an empty sub-message instead of the ICU text. --- .../tests/integration_test.rs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 79960280c..c0f134646 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -4291,6 +4291,182 @@ fn test_i18n_expression_ordering_icu_plural_with_pipe() { ); } +/// Compiles a component with the given template and returns its `consts` function and +/// template function, with line breaks collapsed and the `__makeTemplateObject` helper +/// shortened to `__tpl`, so assertions do not depend on the emitter's line wrapping. +fn compile_i18n_component(template: &str) -> String { + let allocator = Allocator::default(); + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'app-repro', template: `{template}` }})\n\ + export class Repro {{ count = 3; gender = 'male'; name = 'Bob'; }}\n" + ); + let result = transform_angular_file(&allocator, "repro.ts", &source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + let code = result.code.replace( + r#"(this&&this.__makeTemplateObject||function(e,t){return Object.defineProperty?Object.defineProperty(e,"raw",{value:t}):e.raw=t,e})"#, + "__tpl", + ); + let start = code.find("consts:").expect("component should have consts"); + let end = code[start..].find("encapsulation").map_or(code.len(), |e| start + e); + let mut out = String::new(); + for line in code[start..end].lines() { + out.push_str(line.trim_start()); + } + out +} + +/// Asserts that `haystack` contains `needle`, printing the compiled output on failure. +#[track_caller] +fn assert_contains(haystack: &str, needle: &str) { + assert!(haystack.contains(needle), "Expected to find:\n{needle}\nin:\n{haystack}"); +} + +/// An `i18n-` message takes the attribute's own value as its text. +/// Expected output taken from Angular 22.1.5 `ngc`: +/// goog.getMsg("Close") / $localize `:@@close:Close` +#[test] +fn test_i18n_attribute_message_uses_attribute_value() { + let js = compile_i18n_component(r#"
"#); + assert_contains(&js, r#"goog.getMsg("Close")"#); + assert_contains(&js, r#"__tpl([":@@close:Close"], [":@@close:Close"])"#); + assert_contains(&js, r#"return [["title",i18n_0]]"#); +} + +/// An interpolated `i18n-` keeps its text and gets an INTERPOLATION placeholder. +/// Angular 22.1.5: goog.getMsg("Hi {$interpolation}!", { "interpolation": "�0�" }, ...) +/// $localize `:@@hi:Hi ${"�0�"}:INTERPOLATION:!` +#[test] +fn test_i18n_interpolated_attribute_message_uses_attribute_value() { + let js = compile_i18n_component(r#"
"#); + assert_contains( + &js, + "goog.getMsg(\"Hi {$interpolation}!\",{\"interpolation\":\"\u{FFFD}0\u{FFFD}\"}", + ); + assert_contains( + &js, + "__tpl([\":@@hi:Hi \", \":INTERPOLATION:!\"], [\":@@hi:Hi \", \":INTERPOLATION:!\"]), \"\u{FFFD}0\u{FFFD}\")", + ); +} + +/// `i18n-` alongside an `i18n` element body: both messages keep their text. +/// Angular 22.1.5: i18n_0 = "Close" (@@ttl), i18n_1 = "Body text" (@@body), +/// return [i18n_1, ["title", i18n_0]] +#[test] +fn test_i18n_attribute_and_element_body_on_same_element() { + let js = compile_i18n_component( + r#"
Body text
"#, + ); + assert_contains(&js, r#"goog.getMsg("Close")"#); + assert_contains(&js, r#"__tpl([":@@ttl:Close"], [":@@ttl:Close"])"#); + assert_contains(&js, r#"goog.getMsg("Body text")"#); + assert_contains(&js, r#"__tpl([":@@body:Body text"], [":@@body:Body text"])"#); + assert_contains(&js, r#"return [i18n_1,["title",i18n_0]]"#); +} + +/// An element whose only child is an ICU: the ICU *is* the message. Angular 22.1.5 emits no +/// sub-message, keeps `{INTERPOLATION}` as literal ICU text, and post-processes the message +/// to map the ICU's placeholders to runtime slots. +#[test] +fn test_i18n_sole_icu_is_the_message() { + let js = compile_i18n_component( + r#"{count, plural, =1 {one item} other {{{count}} items}}
"#, + ); + // Only two messages: the title attribute and the ICU. + assert!(!js.contains("i18n_2"), "ICU must not produce a separate sub-message:\n{js}"); + assert!(!js.contains(r#""icu""#), "ICU must not be referenced as a placeholder:\n{js}"); + assert_contains( + &js, + r#"goog.getMsg("{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}")"#, + ); + assert_contains( + &js, + r#"__tpl([":@@items:{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}"], [":@@items:{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}"]))"#, + ); + // Post-processing runs after the Closure/$localize branch, for both modes. + assert_contains( + &js, + "}(i18n_1 = i0.ɵɵi18nPostprocess(i18n_1,{\"INTERPOLATION\":\"\u{FFFD}1\u{FFFD}\",\"VAR_PLURAL\":\"\u{FFFD}0\u{FFFD}\"}));", + ); + assert_contains(&js, r#"return [i18n_1,["title",i18n_0]]"#); + assert_contains(&js, "i0.ɵɵi18nExp(ctx.count)(ctx.count);"); +} + +/// A sole `select` ICU. Angular 22.1.5: +/// goog.getMsg("{VAR_SELECT, select, male {he} female {she} other {they}}") +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_SELECT": "�0�" }); +#[test] +fn test_i18n_sole_select_icu_is_the_message() { + let js = compile_i18n_component( + r#"{gender, select, male {he} female {she} other {they}}"#, + ); + assert!(!js.contains("i18n_1"), "ICU must not produce a separate sub-message:\n{js}"); + assert_contains( + &js, + r#"goog.getMsg("{VAR_SELECT, select, male {he} female {she} other {they}}")"#, + ); + assert_contains(&js, r#"__tpl(["{VAR_SELECT, select, male {he} female {she} other {they}}"]"#); + assert_contains( + &js, + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}));", + ); + assert_contains(&js, "return [i18n_0]"); +} + +/// A sole ICU with no interpolation still needs its VAR_PLURAL mapped. Angular 22.1.5: +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_PLURAL": "�0�" }); +#[test] +fn test_i18n_sole_icu_without_interpolation() { + let js = compile_i18n_component( + r#"{count, plural, =1 {one item} other {many items}}"#, + ); + assert!(!js.contains("i18n_1"), "ICU must not produce a separate sub-message:\n{js}"); + assert_contains( + &js, + r#"goog.getMsg("{VAR_PLURAL, plural, =1 {one item} other {many items}}")"#, + ); + assert_contains( + &js, + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_PLURAL\":\"\u{FFFD}0\u{FFFD}\"}));", + ); + assert_contains(&js, "return [i18n_0]"); +} + +/// An ICU next to text and an interpolation is a real sub-message, referenced from the root +/// message through the `icu` placeholder by variable. Angular 22.1.5: +/// i18n_0 = "{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}" +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "INTERPOLATION": "�2�", "VAR_PLURAL": "�1�" }); +/// goog.getMsg("Hello {$interpolation}! {$icu}", { "icu": i18n_0, "interpolation": "�0�" }, ...) +/// $localize `Hello ${"�0�"}:INTERPOLATION:! ${i18n_0}:ICU:` +#[test] +fn test_i18n_icu_with_sibling_content_is_sub_message() { + let js = compile_i18n_component( + r#"Hello {{name}}! {count, plural, =1 {one item} other {{{count}} items}}"#, + ); + assert_contains( + &js, + r#"goog.getMsg("{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}")"#, + ); + assert_contains( + &js, + r#"__tpl(["{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}"]"#, + ); + assert_contains( + &js, + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"INTERPOLATION\":\"\u{FFFD}2\u{FFFD}\",\"VAR_PLURAL\":\"\u{FFFD}1\u{FFFD}\"}));", + ); + assert_contains( + &js, + "goog.getMsg(\"Hello {$interpolation}! {$icu}\",{\"icu\":i18n_0,\"interpolation\":\"\u{FFFD}0\u{FFFD}\"}", + ); + assert_contains( + &js, + "__tpl([\"Hello \", \":INTERPOLATION:! \", \":ICU:\"], [\"Hello \", \":INTERPOLATION:! \", \":ICU:\"]), \"\u{FFFD}0\u{FFFD}\", i18n_0)", + ); + assert_contains(&js, "return [i18n_1]"); + assert_contains(&js, "i0.ɵɵi18nExp(ctx.name)(ctx.count)(ctx.count);"); +} + #[test] fn test_nested_if_listener_ctx_reference() { // Test: nested @if where a listener in the inner @if accesses component properties. From 2bedf6baf0b6860c7feb966e5a7bc6035c652a26 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Tue, 22 Sep 2026 17:00:15 +0100 Subject: [PATCH 02/54] fix(i18n): use the attribute value as the text of i18n- messages Messages for i18n- were built from the i18n- metadata alone (meaning, description, @@id), so their text was always empty: goog.getMsg("") and $localize`:@@close:`. At runtime the attribute rendered as an empty string in the source locale. Angular's I18nMetaVisitor builds these messages with _generateI18nMessage([attr], meta) whenever the attribute has a value. Do the same through I18nMessageFactory, and keep the message's text and interpolation placeholder nodes. Ingest now reads interpolation placeholder names from those nodes, matching Angular's Object.keys(message.placeholders), so an interpolated attribute gets its INTERPOLATION param instead of "". --- .../oxc_angular_compiler/src/i18n/parser.rs | 22 ++++++ .../src/pipeline/ingest.rs | 27 ++++--- .../src/transform/html_to_r3.rs | 73 +++++++++++++++---- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/crates/oxc_angular_compiler/src/i18n/parser.rs b/crates/oxc_angular_compiler/src/i18n/parser.rs index af1a742fb..95467622f 100644 --- a/crates/oxc_angular_compiler/src/i18n/parser.rs +++ b/crates/oxc_angular_compiler/src/i18n/parser.rs @@ -103,6 +103,28 @@ impl I18nMessageFactory { ) } + /// Converts a single attribute to an i18n Message. + /// + /// Equivalent to Angular's `createI18nMessage([attr], ...)`, without having to wrap the + /// attribute in an `HtmlNode`. Metadata (meaning, description, id) is left empty because + /// callers only need the message text. + pub fn create_attribute_message( + &self, + attribute: &HtmlAttribute<'_>, + source_file: Arc, + ) -> Message { + let mut context = I18nVisitorContext::new(source_file); + let nodes = self.visit_attribute(attribute, &mut context).into_iter().collect(); + Message::new( + nodes, + context.placeholders, + context.placeholder_to_message, + String::new(), + String::new(), + String::new(), + ) + } + /// Visits all HTML nodes and converts them to i18n nodes. fn visit_all( &self, diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 00723b51d..442eb5311 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -612,19 +612,6 @@ fn convert_binary_op( } } -/// Converts an interpolation expression to an IR interpolation, storing inner expressions. -/// -/// This is needed because interpolations contain inner expressions that need to be -/// resolved during name resolution. By converting to IR Interpolation, the inner -/// expressions become visible to the expression transformer. -fn convert_interpolation_to_ir<'a>( - job: &mut ComponentCompilationJob<'a>, - expr: AngularExpression<'a>, -) -> Box<'a, IrExpression<'a>> { - let allocator = job.allocator; - convert_interpolation_to_ir_with_i18n_placeholders(job, expr, Vec::new_in(allocator)) -} - /// Converts an Angular expression to IR, handling interpolations with i18n placeholders. /// /// This is used for bound text inside i18n blocks where the i18n metadata contains @@ -1537,7 +1524,19 @@ fn ingest_binding_owned<'a>( // For interpolated attributes (e.g., title="{{ 'text' | i18n }}"), use // convert_interpolation_to_ir to properly extract pipes from the interpolation. let expression = if matches!(&input.value, AngularExpression::Interpolation(_)) { - convert_interpolation_to_ir(job, input.value) + // Angular: `Object.keys(asMessage(i18nMeta)?.placeholders ?? {})`, i.e. the message's + // unique placeholder names in first-seen order. + let mut i18n_placeholders: Vec<'_, Ident<'_>> = Vec::new_in(allocator); + if let Some(I18nMeta::Message(message)) = &input.i18n { + for node in message.nodes.iter() { + if let I18nNode::Placeholder(ph) = node { + if !i18n_placeholders.contains(&ph.name) { + i18n_placeholders.push(ph.name.clone()); + } + } + } + } + convert_interpolation_to_ir_with_i18n_placeholders(job, input.value, i18n_placeholders) } else { convert_ast_to_ir(job, input.value) }; diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 8d03c96cf..4082e4de4 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -2882,7 +2882,23 @@ impl<'a> HtmlToR3Transform<'a> { let name = attr.name.as_str(); if let Some(target_attr) = name.strip_prefix("i18n-") { let instance_id = self.allocate_i18n_message_instance_id(); - let meta = parse_i18n_meta(self.allocator, attr.value.as_str(), instance_id); + // The attribute's own value is the message text. Angular's I18nMetaVisitor + // builds it with `_generateI18nMessage([attr], meta)` when `attr.value` is set. + let target = + attrs.iter().find(|a| a.name.as_str() == target_attr && !a.value.is_empty()); + let (message_string, nodes) = match target { + Some(target) => self.create_attribute_i18n_message(target), + None => (String::new(), Vec::new_in(self.allocator)), + }; + let mut meta = parse_i18n_meta_with_message( + self.allocator, + attr.value.as_str(), + instance_id, + &message_string, + ); + if let I18nMeta::Message(message) = &mut meta { + message.nodes = nodes; + } i18n_attrs_meta.insert(target_attr, meta); } } @@ -3128,6 +3144,46 @@ impl<'a> HtmlToR3Transform<'a> { (attributes, inputs, outputs, references, variables, template_attr_info) } + /// Builds the i18n message for a translated attribute from the attribute's value. + /// + /// Returns the serialized message and its top-level nodes. An attribute message only + /// contains text and interpolation placeholders; the placeholder nodes give ingest the + /// names Angular reads from `Object.keys(message.placeholders)`. + fn create_attribute_i18n_message( + &self, + attr: &HtmlAttribute<'a>, + ) -> (String, Vec<'a, I18nNode<'a>>) { + let source_file = + std::sync::Arc::new(crate::util::ParseSourceFile::new(self.source_text, "