From de58da6b17669710864ad7e1b0d7d8303407fcd3 Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Sun, 25 Oct 2026 09:26:13 +0100 Subject: [PATCH] fix(i18n): emit legacy message ids under `enableI18nLegacyMessageIdFormat` Add the option (TransformOptions, NAPI and Vite plugin; off by default, as in the tsconfig `ng new` generates). Legacy ids are computed on the template before whitespace removal, like Angular's first I18nMetaVisitor pass, and ICU cases are now Containers so ICU digests match ngc. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 38 ++++-- .../oxc_angular_compiler/src/i18n/parser.rs | 32 ++--- crates/oxc_angular_compiler/src/ir/ops.rs | 2 + .../pipeline/phases/extract_i18n_messages.rs | 4 + .../pipeline/phases/i18n_const_collection.rs | 16 ++- .../src/transform/html_to_r3.rs | 129 +++++++++++++++--- .../tests/integration_test.rs | 90 ++++++++++++ napi/angular-compiler/README.md | 9 +- napi/angular-compiler/index.d.ts | 9 ++ napi/angular-compiler/src/lib.rs | 12 ++ napi/angular-compiler/vite-plugin/index.ts | 12 ++ 11 files changed, 295 insertions(+), 58 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 9bca18f5e..b71c3790a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -226,6 +226,14 @@ pub struct TransformOptions { /// `Full` (default) targets applications; `Partial` targets library /// builds, where consumers run the linker to expand declarations. pub compilation_mode: crate::CompilationMode, + + /// Angular's `enableI18nLegacyMessageIdFormat` compiler option: write each i18n + /// message's legacy ids in its `$localize` metadata + /// (`:@@custom␟␟:text`), for translation files keyed by legacy ids. + /// + /// Default: `false`, as in the `tsconfig.json` that `ng new` generates. ngtsc itself + /// defaults to `true` when the option is not set. + pub enable_i18n_legacy_message_id_format: bool, } /// Input for host metadata when passed via TransformOptions. @@ -281,6 +289,7 @@ impl Default for TransformOptions { emit_class_metadata: true, minify_component_styles: false, compilation_mode: crate::CompilationMode::Full, + enable_i18n_legacy_message_id_format: false, } } } @@ -5419,12 +5428,13 @@ fn compile_component_full<'a>( // Stage 1.5: Remove whitespace if not preserving // This must happen before R3 transform, matching Angular's pipeline + let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes); let nodes = if parse_options.preserve_whitespaces { - &html_result.nodes + untrimmed } else { // Apply whitespace removal with preserveSignificantWhitespace=true // (Angular uses true for template compilation, false for message extraction) - let processed = remove_whitespaces(allocator, &html_result.nodes, true); + let processed = remove_whitespaces(allocator, untrimmed, true); // We need to leak this to get a reference with the right lifetime // since we're in a function that returns Result allocator.alloc(processed) as &_ @@ -5434,8 +5444,10 @@ fn compile_component_full<'a>( let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, angular_version: options.angular_version, + enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format, }; - let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); + let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options) + .with_untrimmed_nodes(untrimmed); let r3_result = transformer.transform(nodes); if !r3_result.errors.is_empty() { @@ -6088,6 +6100,7 @@ pub fn compile_component_template<'a>( let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, angular_version: None, + ..R3TransformOptions::default() }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -6178,10 +6191,11 @@ pub fn compile_template_to_js_with_options<'a>( } // Stage 1.5: Remove whitespace if not preserving (default behavior) + let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes); let nodes = if parse_options.preserve_whitespaces { - &html_result.nodes + untrimmed } else { - let processed = remove_whitespaces(allocator, &html_result.nodes, true); + let processed = remove_whitespaces(allocator, untrimmed, true); allocator.alloc(processed) as &_ }; @@ -6189,8 +6203,10 @@ pub fn compile_template_to_js_with_options<'a>( let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, angular_version: options.angular_version, + enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format, }; - let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); + let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options) + .with_untrimmed_nodes(untrimmed); let r3_result = transformer.transform(nodes); if !r3_result.errors.is_empty() { @@ -6361,10 +6377,11 @@ pub fn compile_template_for_hmr<'a>( } // Stage 1.5: Remove whitespace if not preserving + let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes); let nodes = if parse_options.preserve_whitespaces { - &html_result.nodes + untrimmed } else { - let processed = remove_whitespaces(allocator, &html_result.nodes, true); + let processed = remove_whitespaces(allocator, untrimmed, true); allocator.alloc(processed) as &_ }; @@ -6372,8 +6389,10 @@ pub fn compile_template_for_hmr<'a>( let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, angular_version: options.angular_version, + enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format, }; - let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); + let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options) + .with_untrimmed_nodes(untrimmed); let r3_result = transformer.transform(nodes); if !r3_result.errors.is_empty() { @@ -7108,6 +7127,7 @@ pub fn compile_template_for_linker<'a>( let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, angular_version, + ..R3TransformOptions::default() }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); diff --git a/crates/oxc_angular_compiler/src/i18n/parser.rs b/crates/oxc_angular_compiler/src/i18n/parser.rs index 7383511a8..591341736 100644 --- a/crates/oxc_angular_compiler/src/i18n/parser.rs +++ b/crates/oxc_angular_compiler/src/i18n/parser.rs @@ -488,28 +488,16 @@ impl I18nMessageFactory { // Build ICU cases (ordered for consistent serialization) let mut cases: IndexMap = IndexMap::default(); for case in &expansion.cases { - let mut case_nodes = self.visit_all(&case.expansion, context, visit_fn); - let case_node = if case_nodes.len() == 1 { - case_nodes.pop().unwrap_or_else(|| { - let case_span = ParseSourceSpan::from_offsets( - &context.source_file, - case.expansion_span.start, - case.expansion_span.end, - None, - None, - ); - Node::Container(Container::new(Vec::new(), case_span)) - }) - } else { - let case_span = ParseSourceSpan::from_offsets( - &context.source_file, - case.expansion_span.start, - case.expansion_span.end, - None, - None, - ); - Node::Container(Container::new(case_nodes, case_span)) - }; + // Angular wraps every case in a Container, which digests serialize as `[...]`. + let case_nodes = self.visit_all(&case.expansion, context, visit_fn); + let case_span = ParseSourceSpan::from_offsets( + &context.source_file, + case.expansion_span.start, + case.expansion_span.end, + None, + None, + ); + let case_node = Node::Container(Container::new(case_nodes, case_span)); cases.insert(case.value.to_string(), case_node); } diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index ba0ad6a4d..bcbdc4779 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -1149,6 +1149,8 @@ pub struct I18nMessageOp<'a> { pub message_string: Option>, /// For an ICU sub-message, its `$localize` id, written on the parent's ICU placeholder. pub associated_message_id: Option>, + /// Legacy message ids, written in the `$localize` metadata block. + pub legacy_ids: Vec<'a, Ident<'a>>, /// Whether message needs postprocessing (has params with multiple values). pub needs_postprocessing: bool, /// Sub-messages. diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/extract_i18n_messages.rs b/crates/oxc_angular_compiler/src/pipeline/phases/extract_i18n_messages.rs index 628b89ed7..1e2f75f37 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/extract_i18n_messages.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/extract_i18n_messages.rs @@ -135,6 +135,10 @@ pub fn extract_i18n_messages(job: &mut ComponentCompilationJob<'_>) { message_string: metadata.and_then(|m| m.message_string.clone()), associated_message_id: metadata .and_then(|m| m.associated_message_id), + legacy_ids: oxc_allocator::Vec::from_iter_in( + metadata.into_iter().flat_map(|m| m.legacy_ids.iter().copied()), + &allocator, + ), needs_postprocessing, sub_messages: oxc_allocator::Vec::new_in(&allocator), }); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs index 0502a8148..4a30599a6 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs @@ -99,6 +99,7 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { .associated_message_id .as_ref() .map(ToString::to_string), + legacy_ids: msg_op.legacy_ids.iter().map(ToString::to_string).collect(), needs_postprocessing: msg_op.needs_postprocessing, sub_messages: msg_op.sub_messages.iter().copied().collect(), }, @@ -451,6 +452,7 @@ struct MessageInfo { message_id: Option, message_string: Option, associated_message_id: Option, + legacy_ids: Vec, needs_postprocessing: bool, sub_messages: Vec, } @@ -568,6 +570,7 @@ fn collect_message<'a>( msg_info.description.clone(), msg_info.meaning.clone(), msg_info.custom_id.clone(), + &msg_info.legacy_ids, &associated_message_ids, ); @@ -699,6 +702,7 @@ fn create_localize_expression<'a>( description: Option, meaning: Option, custom_id: Option, + legacy_ids: &[String], associated_message_ids: &FxHashMap, ) -> OutputExpression<'a> { // Parse message_string to extract text parts and placeholder names in order @@ -718,9 +722,9 @@ fn create_localize_expression<'a>( params.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); // First message part: includes metadata block + first text segment - // Format: ":meaning|description@@customId:text" + // Format: ":meaning|description@@customId␟legacyId:text" let first_text = text_parts.first().map(|s| s.as_str()).unwrap_or(""); - push_part(serialize_i18n_head(first_text, &meaning, &description, &custom_id)); + push_part(serialize_i18n_head(first_text, &meaning, &description, &custom_id, legacy_ids)); // Subsequent parts: ":PLACEHOLDER_NAME:text" for (i, placeholder) in placeholder_order.iter().enumerate() { @@ -739,7 +743,7 @@ fn create_localize_expression<'a>( // Text part after this placeholder let text_part = text_parts.get(i + 1).map(|s| s.as_str()).unwrap_or(""); // Angular's serializeI18nTemplatePart names an ICU placeholder's sub-message when the - // sub-message has no legacy ids (always, in Oxc): `:ICU@@:`. + // sub-message has no legacy ids: `:ICU@@:`. let meta_block = match associated_message_ids.get(placeholder) { Some(id) => format!("{formatted_name}@@{id}"), None => formatted_name.clone(), @@ -825,12 +829,13 @@ fn parse_message_string(message: &str) -> (Vec, Vec) { } /// Serialize the i18n head (first message part) with its metadata block -/// (`meaning|description@@customId`), as Angular's `serializeI18nHead`. +/// (`meaning|description@@customId␟legacyId…`), as Angular's `serializeI18nHead`. fn serialize_i18n_head( text: &str, meaning: &Option, description: &Option, custom_id: &Option, + legacy_ids: &[String], ) -> (String, String) { let mut meta_block = description.clone().unwrap_or_default(); if let Some(meaning) = meaning.as_deref().filter(|m| !m.is_empty()) { @@ -839,6 +844,9 @@ fn serialize_i18n_head( if let Some(id) = custom_id.as_deref().filter(|id| !id.is_empty()) { meta_block = format!("{meta_block}@@{id}"); } + for legacy_id in legacy_ids { + meta_block = format!("{meta_block}\u{241F}{legacy_id}"); + } create_cooked_raw_string(&meta_block, text) } 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 0712e1f99..0dd7e5884 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -106,6 +106,9 @@ pub struct TransformOptions { pub collect_comment_nodes: bool, /// Angular version being compiled. `None` uses the latest (v22) security schema. pub angular_version: Option, + /// Angular's `enableI18nLegacyMessageIdFormat`: give each i18n message its legacy ids + /// (`computeDigest`, `computeDecimalDigest`), which `$localize` metadata then carries. + pub enable_i18n_legacy_message_id_format: bool, } /// Inserts or updates a var entry in an ordered Vec, preserving first-insertion order. @@ -185,6 +188,14 @@ pub struct HtmlToR3Transform<'a> { icu_names: IcuPlaceholderNames, /// Full names (`:svg:svg`) of the enclosing elements, for i18n placeholder names. element_full_names: std::vec::Vec, + /// Whether i18n messages get legacy ids (`enableI18nLegacyMessageIdFormat`). + legacy_message_ids: bool, + /// The children of each element as parsed, before whitespace removal, keyed by the + /// element's start offset. Angular computes legacy ids on the untrimmed template. + untrimmed_children: FxHashMap]>, + /// Whether an ICU visited now is a direct child of an i18n element's message (Angular's + /// `currentMessage` in `I18nMetaVisitor.visitExpansion`), not inside a nested element. + in_i18n_message: bool, /// The template as a source file for i18n messages, built on first use and shared by /// every message so the template text is copied once. i18n_source_file: std::cell::OnceCell>, @@ -241,10 +252,49 @@ impl<'a> HtmlToR3Transform<'a> { message_names: MessagePlaceholderNames::default(), icu_names: IcuPlaceholderNames::default(), element_full_names: std::vec::Vec::new(), + legacy_message_ids: options.enable_i18n_legacy_message_id_format, + untrimmed_children: FxHashMap::default(), + in_i18n_message: false, i18n_source_file: std::cell::OnceCell::new(), } } + /// Gives the transformer the template as parsed, before whitespace removal: legacy message + /// ids are computed on it, as Angular's first `I18nMetaVisitor` pass does. + #[must_use] + pub fn with_untrimmed_nodes(mut self, nodes: &'a [HtmlNode<'a>]) -> Self { + fn index<'a>(map: &mut FxHashMap]>, nodes: &'a [HtmlNode<'a>]) { + for node in nodes { + match node { + HtmlNode::Element(element) => { + map.insert(element.span.start, &element.children); + index(map, &element.children); + } + HtmlNode::Component(component) => index(map, &component.children), + HtmlNode::Block(block) => index(map, &block.children), + _ => {} + } + } + } + if self.legacy_message_ids { + index(&mut self.untrimmed_children, nodes); + } + self + } + + /// Angular's `_setLegacyIds`: `[computeDigest(message), computeDecimalDigest(message)]`. + fn legacy_ids(&self, message: &crate::i18n::ast::Message) -> Vec<'a, Ident<'a>> { + let mut ids = Vec::new_in(&self.allocator); + if self.legacy_message_ids { + ids.push(Ident::from_in(crate::i18n::compute_digest(message).as_str(), self.allocator)); + ids.push(Ident::from_in( + crate::i18n::compute_decimal_digest(message).as_str(), + self.allocator, + )); + } + ids + } + /// Returns the template source file that i18n messages point into. fn i18n_source_file(&self) -> std::sync::Arc { std::sync::Arc::clone(self.i18n_source_file.get_or_init(|| { @@ -500,41 +550,61 @@ impl<'a> HtmlToR3Transform<'a> { // Generate i18n message string from HTML children BEFORE transforming to R3. // This is needed because the message text comes from the original HTML content. // Ported from Angular's I18nMetaVisitor._generateI18nMessage in i18n/meta.ts. - let i18n_message_string = if let Some(attr) = i18n_attr { + let (i18n_message_string, legacy_ids) = if let Some(attr) = i18n_attr { if element.children.is_empty() { - String::new() + (String::new(), Vec::new_in(&self.allocator)) } else { // Use I18nMessageFactory to convert children to i18n AST and serialize let factory = I18nMessageFactory::new(false, true); let source_file = self.i18n_source_file(); let meta = parse_i18n_meta(attr.value.as_str()); + let element_name = self.element_i18n_name(&resolved_name, host_tag.as_deref()); let message = factory.create_message( &element.children, - Some(self.element_i18n_name(&resolved_name, host_tag.as_deref())), + Some(element_name), Some(meta.meaning), Some(meta.description), Some(meta.custom_id), None, - source_file, + std::sync::Arc::clone(&source_file), ); collect_placeholder_names(&message.nodes, &mut self.message_names); - message.serialize() + // Legacy ids are computed before whitespace removal. + let legacy_ids = match self.untrimmed_children.get(&element.span.start) { + Some(children) if self.legacy_message_ids => { + self.legacy_ids(&factory.create_message( + children, + Some(element_name), + Some(meta.meaning), + Some(meta.description), + Some(meta.custom_id), + None, + source_file, + )) + } + _ => self.legacy_ids(&message), + }; + (message.serialize(), legacy_ids) } } else { - String::new() + (String::new(), Vec::new_in(&self.allocator)) }; // Now create the i18n metadata with the generated message string let i18n_meta = if let Some(attr) = i18n_attr { // Element has its own i18n attribute - parse it as a Message with message string let instance_id = self.allocate_i18n_message_instance_id(); - Some(parse_i18n_meta_with_message( + let mut meta = parse_i18n_meta_with_message( &self.allocator, attr.value.as_str(), instance_id, &i18n_message_string, - )) + ); + if let I18nMeta::Message(message) = &mut meta { + message.legacy_ids = legacy_ids; + } + Some(meta) } else if self.i18n_depth > 0 { // Element is inside an i18n block - create a TagPlaceholder for it. // This matches TypeScript's behavior where child elements inside i18n blocks @@ -569,7 +639,9 @@ impl<'a> HtmlToR3Transform<'a> { // Visit children self.element_full_names .push(self.element_i18n_name(&resolved_name, host_tag.as_deref()).to_string()); + let in_i18n_message = std::mem::replace(&mut self.in_i18n_message, has_i18n); let children = self.visit_children(&element.children); + self.in_i18n_message = in_i18n_message; self.element_full_names.pop(); // Consumed by the ICU child, if any; do not leak it to a later ICU. self.sole_icu_message = None; @@ -817,7 +889,9 @@ impl<'a> HtmlToR3Transform<'a> { } // Visit children + let in_i18n_message = std::mem::replace(&mut self.in_i18n_message, has_i18n); let children = self.visit_children(&component.children); + self.in_i18n_message = in_i18n_message; // Decrement non_bindable depth if we incremented it if has_non_bindable { @@ -1430,7 +1504,7 @@ impl<'a> HtmlToR3Transform<'a> { // child of an i18n element shares that element's message, so it is the message rather // than a sub-message of it. let source_file = self.i18n_source_file(); - let icu_message = I18nMessageFactory::new(false, true).create_icu_message( + let mut icu_message = I18nMessageFactory::new(false, true).create_icu_message( expansion, self.element_full_names.last().map(String::as_str), source_file, @@ -1441,6 +1515,16 @@ impl<'a> HtmlToR3Transform<'a> { &crate::i18n::ast::serialize_message_for_id(&icu_message.nodes), "", ); + let (meta_value, instance_id, is_sub_message) = match self.sole_icu_message.take() { + Some((meta_value, instance_id)) => (meta_value, instance_id, false), + None => (String::new(), self.allocate_i18n_message_instance_id(), true), + }; + // Whitespace removal leaves an ICU's content as is, so its legacy ids are those of the + // first pass. A sole ICU is its element's message and takes the element's meaning. + if !is_sub_message { + icu_message.meaning = parse_i18n_meta(&meta_value).meaning.to_string(); + } + let legacy_ids = self.legacy_ids(&icu_message); // Angular builds the ICU's placeholders from the message's placeholders: `VAR_*` become // vars, interpolations become bound text, and element markup becomes plain text. let (vars, others): (std::vec::Vec<_>, std::vec::Vec<_>) = @@ -1456,19 +1540,20 @@ impl<'a> HtmlToR3Transform<'a> { .map(|(name, placeholder)| (placeholder.text, name)) .collect(); tags.sort_by(|a, b| a.0.cmp(&b.0)); - let (meta_value, instance_id, is_sub_message) = match self.sole_icu_message.take() { - Some((meta_value, instance_id)) => (meta_value, instance_id, false), - None => (String::new(), self.allocate_i18n_message_instance_id(), true), - }; let I18nMeta::Message(mut i18n_message) = parse_i18n_meta_with_message(self.allocator, &meta_value, instance_id, &message_string) else { unreachable!("parse_i18n_meta_with_message always returns a Message") }; - if is_sub_message { + // Angular's `serializeI18nTemplatePart` names the sub-message on the parent's ICU + // placeholder only when the sub-message has no legacy ids. The parent's + // `placeholderToMessage` holds the meta visitor's message, with legacy ids, only for + // an ICU visited with the i18n element's message as `currentMessage`. + if is_sub_message && (legacy_ids.is_empty() || !self.in_i18n_message) { i18n_message.associated_message_id = Ident::from_in(associated_message_id.as_str(), self.allocator); } + i18n_message.legacy_ids = legacy_ids; // The pipeline identifies the ICU by a message holding a single IcuPlaceholder. let icu_type_upper = expansion.expansion_type.as_str().to_uppercase(); @@ -3187,7 +3272,8 @@ impl<'a> HtmlToR3Transform<'a> { continue; }; let instance_id = self.allocate_i18n_message_instance_id(); - let (message_string, nodes) = self.create_attribute_i18n_message(target); + let (message_string, nodes, mut legacy_message) = + self.create_attribute_i18n_message(target); let mut meta = parse_i18n_meta_with_message( self.allocator, attr.value.as_str(), @@ -3196,6 +3282,8 @@ impl<'a> HtmlToR3Transform<'a> { ); if let I18nMeta::Message(message) = &mut meta { message.nodes = nodes; + legacy_message.meaning = message.meaning.to_string(); + message.legacy_ids = self.legacy_ids(&legacy_message); } i18n_attrs_meta.insert(target_attr, meta); } @@ -3448,13 +3536,14 @@ impl<'a> HtmlToR3Transform<'a> { /// 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)`. + /// Returns the serialized message, its top-level nodes and the message itself (for its + /// legacy ids). 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>>) { + ) -> (String, Vec<'a, I18nNode<'a>>, crate::i18n::ast::Message) { let message = I18nMessageFactory::new(false, true) .create_attribute_message(attr, self.i18n_source_file()); let span = |s: &crate::util::ParseSourceSpan| Span::new(s.start.offset, s.end.offset); @@ -3481,7 +3570,7 @@ impl<'a> HtmlToR3Transform<'a> { node => push(node), } } - (message.serialize(), nodes) + (message.serialize(), nodes, message) } /// Normalizes an attribute name by stripping the data- prefix (case-insensitive). diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index a62d46fd3..1fbebb75a 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5812,6 +5812,96 @@ fn test_i18n_meta_is_trimmed_once() { assert_contains(&js, r#"$localize `:m | d @@ custom:Custom`"#); } +/// Compiles `template` with `enableI18nLegacyMessageIdFormat` set to `legacy_ids`. +fn compile_with_legacy_ids(template: &str, legacy_ids: bool) -> String { + let allocator = Allocator::default(); + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'test', template: `{template}` }})\n\ + export class FooCmp {{ age = 1; }}\n" + ); + let options = ComponentTransformOptions { + enable_i18n_legacy_message_id_format: legacy_ids, + ..Default::default() + }; + let result = transform_angular_file(&allocator, "test.ts", &source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + result.code +} + +/// Ports of ngtsc_spec.ts "should render legacy ids when `enableI18nLegacyMessageIdFormat` is +/// not false" and "should render custom id and legacy ids if `enableI18nLegacyMessageIdFormat` +/// is not false": `$localize` metadata carries `computeDigest` and `computeDecimalDigest`. +#[test] +fn test_i18n_legacy_message_ids() { + let js = compile_with_legacy_ids("
Some text
", true); + assert_contains( + &js, + "`:\u{241F}5dbba0a3da8dff890e20cf76eb075d58900fbcd3\u{241F}8321000940098097247:Some text`", + ); + let js = compile_with_legacy_ids(r#"
Some text
"#, true); + assert_contains( + &js, + ":@@custom\u{241F}5dbba0a3da8dff890e20cf76eb075d58900fbcd3\u{241F}8321000940098097247:Some text", + ); + // Port of "should not render legacy ids when `enableI18nLegacyMessageIdFormat` is set to + // false" (the option's default here). + let js = compile_with_legacy_ids("
Some text
", false); + assert!(!js.contains(":Some text"), "No metadata block expected:\n{js}"); +} + +/// Port of ngtsc_spec.ts "should also render legacy ids for ICUs when normal messages are using +/// legacy ids". An ICU case is a Container in the digest (`10 {[ten]}`), and a sub-message with +/// legacy ids is not named on its parent's placeholder (`:ICU:`, not `:ICU@@:`). +#[test] +fn test_i18n_legacy_message_ids_for_icus() { + let js = compile_with_legacy_ids( + r#"
Some text {age, plural, 10 {ten} other {other}}
"#, + true, + ); + assert_contains( + &js, + ":\u{241F}720ba589d043a0497ac721ff972f41db0c919efb\u{241F}3221232817843005870:{VAR_PLURAL, plural, 10 {ten} other {other}}", + ); + assert_contains( + &js, + ":@@custom\u{241F}dcb6170595f5d548a3d00937e87d11858f51ad04\u{241F}7419139165339437596:Some text ${i18n_0}:ICU:`", + ); +} + +/// Legacy ids are computed on the template before whitespace removal (Angular's first +/// `I18nMetaVisitor` pass), and attribute messages get them too. An ICU inside a nested element +/// keeps `@@` on its placeholder: Angular's meta visitor has no `currentMessage` there. +/// Expected output taken from Angular 22.2 `ngc` with `enableI18nLegacyMessageIdFormat: true`. +#[test] +fn test_i18n_legacy_message_ids_untrimmed_attribute_and_nested_icu() { + let js = compile_with_legacy_ids( + "
\n Hello {{ age }} bold \n
", + true, + ); + assert_contains( + &js, + ":m|d\u{241F}3e8c275a028ed08a56597c20302086a73b3c2444\u{241F}734253441491849243:Hi`", + ); + let js = compile_with_legacy_ids( + "
\n Hello {{ age }} bold \n {age, plural, =1 { one } other {more}}\n
", + true, + ); + assert_contains( + &js, + ":\u{241F}6f97a687705293112816b7887ddf53335836fca9\u{241F}424462909704063534: Hello ", + ); + let js = compile_with_legacy_ids( + "
A {age, plural, 10 {ten} other {other}}
", + true, + ); + assert_contains( + &js, + ":\u{241F}fdcdea1a7f33029e14caa37c9d251f13df6a7a2a\u{241F}4036101079907737726:A ", + ); + assert_contains(&js, ":ICU@@5870918674674044418:"); +} + /// `consts` with i18n initializers is an arrow function with a block body, and a message is /// a native `$localize` tagged template. Angular 22.2 `ngc` (ES2022): /// consts: () => { let i18n_0; ... i18n_0 = $localize `:@@close:Close`; } return [...]; } diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 5723c6225..f93c6f980 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -169,6 +169,7 @@ interface TransformOptions { // i18n (note the capital N, as the N-API bindings generate it) i18NUseExternalIds?: boolean + enableI18nLegacyMessageIdFormat?: boolean // Final component style output minifyComponentStyles?: boolean @@ -306,9 +307,11 @@ The Vite plugin consists of these sub-plugins: - External message IDs - File-based naming -Not supported: legacy message IDs (Angular's `enableI18nLegacyMessageIdFormat`) and -`i18nNormalizeLineEndingsInICUs`. `$localize` messages carry no legacy IDs, so translations -keyed only by legacy IDs are not applied; translations keyed by the current message IDs are. +- Legacy message IDs: `enableI18nLegacyMessageIdFormat: true` (also a Vite plugin option) makes + `$localize` messages carry them, for translation files keyed by legacy IDs. Off by default, as + in the `tsconfig.json` that `ng new` generates. + +Not supported: `i18nNormalizeLineEndingsInICUs`. ## Platform Support diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 18a416639..ca5dfa701 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -1059,6 +1059,15 @@ export interface TransformOptions { * >= v22, legacy when the version is unknown). */ legacyOptionalChaining?: boolean + /** + * The `enableI18nLegacyMessageIdFormat` Angular compiler option + * (`angularCompilerOptions.enableI18nLegacyMessageIdFormat` in `tsconfig.json`). + * + * When `true`, `$localize` messages carry their legacy ids + * (`:@@custom␟␟:text`), for translation files keyed by legacy ids. + * Defaults to `false`, as in the `tsconfig.json` that `ng new` generates. + */ + enableI18nLegacyMessageIdFormat?: boolean /** The CSS selector that identifies this component in a template. */ selector?: string /** diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 178130ec9..8072cf805 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -160,6 +160,15 @@ pub struct TransformOptions { /// >= v22, legacy when the version is unknown). pub legacy_optional_chaining: Option, + /// The `enableI18nLegacyMessageIdFormat` Angular compiler option + /// (`angularCompilerOptions.enableI18nLegacyMessageIdFormat` in `tsconfig.json`). + /// + /// When `true`, `$localize` messages carry their legacy ids + /// (`:@@custom␟␟:text`), for translation files keyed by legacy ids. + /// Defaults to `false`, as in the `tsconfig.json` that `ng new` generates. + #[napi(js_name = "enableI18nLegacyMessageIdFormat")] + pub enable_i18n_legacy_message_id_format: Option, + // Component metadata fields for full compilation testing // These mirror Angular's @Component decorator options. /// The CSS selector that identifies this component in a template. @@ -264,6 +273,9 @@ impl From for RustTransformOptions { i18n_use_external_ids: options.i18n_use_external_ids.unwrap_or(true), angular_version: options.angular_version.map(Into::into), legacy_optional_chaining: options.legacy_optional_chaining, + enable_i18n_legacy_message_id_format: options + .enable_i18n_legacy_message_id_format + .unwrap_or(false), // Component metadata overrides selector: options.selector, standalone: options.standalone, diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index e022d15b7..84753b002 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -141,6 +141,16 @@ export interface PluginOptions { * ``` */ compilationMode?: 'full' | 'partial' + + /** + * Angular's `enableI18nLegacyMessageIdFormat` compiler option: `$localize` + * messages carry their legacy ids (`:@@custom␟␟:text`). + * + * Enable it when the translation files are keyed by legacy message ids + * (extracted with the legacy format). Default: `false`, as in the + * `tsconfig.json` that `ng new` generates. + */ + enableI18nLegacyMessageIdFormat?: boolean } // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler @@ -293,6 +303,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, compilationMode: options.compilationMode ?? 'full', + enableI18nLegacyMessageIdFormat: options.enableI18nLegacyMessageIdFormat ?? false, tsconfig: options.tsconfig, } @@ -552,6 +563,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { minifyComponentStyles: getMinifyComponentStyles(context), emitClassMetadata: pluginOptions.emitClassMetadata, compilationMode: pluginOptions.compilationMode, + enableI18nLegacyMessageIdFormat: pluginOptions.enableI18nLegacyMessageIdFormat, // Evaluate decorator metadata imported from other files the way // ngtsc's program-wide checker does (`inputs: INPUTS`, `@Input(OPTS)`, // `templateUrl: TEMPLATE_URL`). Read files land in