diff --git a/README.md b/README.md index b10b1d32b..da75ba793 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,10 @@ interface TransformOptions { hmr?: boolean // Enable cross-file type elision - enableCrossFileElision?: boolean + crossFileElision?: boolean - // i18n configuration - i18nNormalizeLineEndingsInIcus?: boolean - i18nUseExternalIds?: boolean + // i18n configuration (note the capital N, as the N-API bindings generate it) + i18NUseExternalIds?: boolean // Style encapsulation mode // 'emulated' | 'none' | 'shadow-dom' diff --git a/crates/oxc_angular_compiler/src/ast/r3.rs b/crates/oxc_angular_compiler/src/ast/r3.rs index 5b887557e..feccf481d 100644 --- a/crates/oxc_angular_compiler/src/ast/r3.rs +++ b/crates/oxc_angular_compiler/src/ast/r3.rs @@ -10,7 +10,6 @@ use oxc_span::Span; use oxc_str::Ident; use crate::ast::expression::{ASTWithSource, AngularExpression, BindingType, ParsedEventType}; -use crate::i18n::serializer::format_i18n_placeholder_name; // ============================================================================ // i18n Metadata @@ -56,6 +55,10 @@ pub struct I18nMessage<'a> { /// The serialized message string for goog.getMsg and $localize. /// Contains the message text with placeholder markers like "{$interpolation}". pub message_string: Ident<'a>, + /// For an ICU sub-message, its `$localize` id (Angular's + /// `computeMsgId(message.messageString, message.meaning)`), written on the parent message's + /// ICU placeholder. Empty otherwise. + pub associated_message_id: Ident<'a>, } /// i18n AST node. @@ -214,6 +217,7 @@ impl<'a> I18nMessage<'a> { id: self.id.clone(), legacy_ids, message_string: self.message_string.clone(), + associated_message_id: self.associated_message_id, } } } @@ -339,99 +343,6 @@ impl<'a> I18nBlockPlaceholder<'a> { } } -// ============================================================================ -// i18n Message Serialization -// ============================================================================ - -/// Serialize i18n nodes to the $localize / goog.getMsg message format. -/// -/// This produces a message string with placeholder markers like "{$interpolation}" -/// for expression placeholders and "{$startTag}/{$closeTag}" for element boundaries. -/// -/// Ported from Angular's `serialize_message` in `i18n/i18n_ast.ts`. -pub fn serialize_i18n_nodes(nodes: &[I18nNode<'_>]) -> String { - let mut result = String::new(); - for node in nodes { - serialize_i18n_node(node, &mut result); - } - result -} - -/// Serialize a single i18n node to the message string format. -/// -/// Placeholder names are formatted to camelCase for goog.getMsg compatibility. -/// For example: `INTERPOLATION` -> `{$interpolation}`, `START_TAG_DIV` -> `{$startTagDiv}` -fn serialize_i18n_node(node: &I18nNode<'_>, result: &mut String) { - match node { - I18nNode::Text(text) => { - result.push_str(text.value.as_str()); - } - I18nNode::Container(container) => { - for child in container.children.iter() { - serialize_i18n_node(child, result); - } - } - I18nNode::Icu(icu) => { - serialize_i18n_icu(icu, result); - } - I18nNode::TagPlaceholder(ph) => { - let start_name = format_i18n_placeholder_name(ph.start_name.as_str(), true); - let close_name = format_i18n_placeholder_name(ph.close_name.as_str(), true); - result.push_str(&format!("{{${start_name}}}")); - for child in ph.children.iter() { - serialize_i18n_node(child, result); - } - result.push_str(&format!("{{${close_name}}}")); - } - I18nNode::Placeholder(ph) => { - let name = format_i18n_placeholder_name(ph.name.as_str(), true); - result.push_str(&format!("{{${name}}}")); - } - I18nNode::IcuPlaceholder(ph) => { - let name = format_i18n_placeholder_name(ph.name.as_str(), true); - result.push_str(&format!("{{${name}}}")); - } - I18nNode::BlockPlaceholder(ph) => { - let start_name = format_i18n_placeholder_name(ph.start_name.as_str(), true); - let close_name = format_i18n_placeholder_name(ph.close_name.as_str(), true); - result.push_str(&format!("{{${start_name}}}")); - for child in ph.children.iter() { - serialize_i18n_node(child, result); - } - result.push_str(&format!("{{${close_name}}}")); - } - } -} - -/// Serialize an ICU expression to the message string format. -fn serialize_i18n_icu(icu: &I18nIcu<'_>, result: &mut String) { - // Use expression_placeholder if available, otherwise use expression directly - let expr = - icu.expression_placeholder.as_ref().map_or_else(|| icu.expression.as_str(), |p| p.as_str()); - - result.push('{'); - result.push_str(expr); - result.push_str(", "); - result.push_str(icu.icu_type.as_str()); - result.push_str(", "); - - // Serialize cases - must be sorted for deterministic output - let mut cases: std::vec::Vec<_> = icu.cases.iter().collect(); - cases.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); - - for (i, (key, value)) in cases.iter().enumerate() { - if i > 0 { - result.push(' '); - } - result.push_str(key.as_str()); - result.push_str(" {"); - serialize_i18n_node(value, result); - result.push('}'); - } - - result.push('}'); -} - // ============================================================================ // Core Node Enum // ============================================================================ diff --git a/crates/oxc_angular_compiler/src/i18n/ast.rs b/crates/oxc_angular_compiler/src/i18n/ast.rs index ed8336b06..ca5b91125 100644 --- a/crates/oxc_angular_compiler/src/i18n/ast.rs +++ b/crates/oxc_angular_compiler/src/i18n/ast.rs @@ -413,22 +413,86 @@ pub trait Visitor { // Serialization // ============================================================================ +/// Serialize message nodes as Angular's `serializeMessage` does for `Message.messageString`. +/// +/// This is the text from which `$localize` message ids are computed (`computeMsgId`). +/// Placeholders are `{$NAME}` everywhere, including inside ICUs, and a void tag keeps its empty +/// close placeholder. This differs from the string used for code generation. +pub fn serialize_message_for_id(nodes: &[Node]) -> String { + struct MessageStringVisitor; + impl Visitor for MessageStringVisitor { + type Context = (); + type Result = String; + fn visit_text(&mut self, text: &Text, (): &mut ()) -> String { + text.value.clone() + } + fn visit_container(&mut self, container: &Container, ctx: &mut ()) -> String { + container.children.iter().map(|c| c.visit(self, ctx)).collect() + } + fn visit_icu(&mut self, icu: &Icu, ctx: &mut ()) -> String { + let cases: Vec = + icu.cases.iter().map(|(k, v)| format!("{k} {{{}}}", v.visit(self, ctx))).collect(); + let placeholder = icu.expression_placeholder.as_deref().unwrap_or_default(); + format!("{{{placeholder}, {}, {}}}", icu.icu_type, cases.join(" ")) + } + fn visit_tag_placeholder(&mut self, ph: &TagPlaceholder, ctx: &mut ()) -> String { + let children: String = ph.children.iter().map(|c| c.visit(self, ctx)).collect(); + format!("{{${}}}{children}{{${}}}", ph.start_name, ph.close_name) + } + fn visit_placeholder(&mut self, ph: &Placeholder, (): &mut ()) -> String { + format!("{{${}}}", ph.name) + } + fn visit_icu_placeholder(&mut self, ph: &IcuPlaceholder, (): &mut ()) -> String { + format!("{{${}}}", ph.name) + } + fn visit_block_placeholder(&mut self, ph: &BlockPlaceholder, ctx: &mut ()) -> String { + let children: String = ph.children.iter().map(|c| c.visit(self, ctx)).collect(); + format!("{{${}}}{children}{{${}}}", ph.start_name, ph.close_name) + } + } + nodes.iter().map(|n| n.visit(&mut MessageStringVisitor, &mut ())).collect() +} + /// Serialize the message to the $localize backtick string format. fn serialize_message(nodes: &[Node]) -> String { - let mut visitor = LocalizeMessageStringVisitor; + let mut visitor = LocalizeMessageStringVisitor { in_icu: false }; let mut ctx = (); nodes.iter().map(|n| n.visit(&mut visitor, &mut ctx)).collect::>().join("") } -/// Visitor that serializes i18n nodes to $localize format. -struct LocalizeMessageStringVisitor; +/// Visitor that serializes i18n nodes to the message string stored for code generation. +/// +/// Placeholders are written as `{$NAME}` markers with their original names: `$localize` uses +/// the name as-is and `goog.getMsg` its camelCase form, as Angular's two serializers do. +/// Inside an ICU, Angular's `IcuSerializerVisitor` keeps them as literal `{UPPER_CASE}` ICU +/// text that is resolved by `ɵɵi18nPostprocess` at runtime. +struct LocalizeMessageStringVisitor { + in_icu: bool, +} + +impl LocalizeMessageStringVisitor { + fn format_ph(&self, name: &str) -> String { + if self.in_icu { + format!("{{{}}}", format_i18n_placeholder_name(name, false)) + } else { + format!("{{${name}}}") + } + } +} + +/// Escapes message text so that code generation can tell `{$NAME}` placeholder markers from +/// text that looks like one, such as `{$notAPlaceholder}`: `{\` becomes `{\\` and `{$` +/// becomes `{\$`. +pub fn escape_placeholder_markers(text: &str) -> String { + text.replace("{\\", "{\\\\").replace("{$", "{\\$") +} impl Visitor for LocalizeMessageStringVisitor { type Context = (); type Result = String; fn visit_text(&mut self, text: &Text, _context: &mut Self::Context) -> Self::Result { - text.value.clone() + escape_placeholder_markers(&text.value) } fn visit_container( @@ -445,11 +509,13 @@ impl Visitor for LocalizeMessageStringVisitor { } fn visit_icu(&mut self, icu: &Icu, context: &mut Self::Context) -> Self::Result { + let was_in_icu = std::mem::replace(&mut self.in_icu, true); let cases: Vec = icu .cases .iter() .map(|(k, v)| format!("{} {{{}}}", k, v.visit(self, context))) .collect(); + self.in_icu = was_in_icu; let expr_placeholder = icu.expression_placeholder.as_deref().unwrap_or(&icu.expression); format!("{{{}, {}, {}}}", expr_placeholder, icu.icu_type, cases.join(" ")) } @@ -459,11 +525,13 @@ impl Visitor for LocalizeMessageStringVisitor { ph: &TagPlaceholder, context: &mut Self::Context, ) -> Self::Result { + // A void element is a single placeholder, as in Angular's serializers. + if ph.is_void { + return self.format_ph(&ph.start_name); + } let children: String = - ph.children.iter().map(|child| child.visit(self, context)).collect::>().join(""); - let start_name = format_i18n_placeholder_name(&ph.start_name, true); - let close_name = format_i18n_placeholder_name(&ph.close_name, true); - format!("{{${start_name}}}{children}{{${close_name}}}") + ph.children.iter().map(|child| child.visit(self, context)).collect::(); + format!("{}{children}{}", self.format_ph(&ph.start_name), self.format_ph(&ph.close_name)) } fn visit_placeholder( @@ -471,8 +539,7 @@ impl Visitor for LocalizeMessageStringVisitor { ph: &Placeholder, _context: &mut Self::Context, ) -> Self::Result { - let name = format_i18n_placeholder_name(&ph.name, true); - format!("{{${name}}}") + self.format_ph(&ph.name) } fn visit_icu_placeholder( @@ -480,8 +547,7 @@ impl Visitor for LocalizeMessageStringVisitor { ph: &IcuPlaceholder, _context: &mut Self::Context, ) -> Self::Result { - let name = format_i18n_placeholder_name(&ph.name, true); - format!("{{${name}}}") + self.format_ph(&ph.name) } fn visit_block_placeholder( @@ -490,10 +556,8 @@ impl Visitor for LocalizeMessageStringVisitor { context: &mut Self::Context, ) -> Self::Result { let children: String = - ph.children.iter().map(|child| child.visit(self, context)).collect::>().join(""); - let start_name = format_i18n_placeholder_name(&ph.start_name, true); - let close_name = format_i18n_placeholder_name(&ph.close_name, true); - format!("{{${start_name}}}{children}{{${close_name}}}") + ph.children.iter().map(|child| child.visit(self, context)).collect::(); + format!("{}{children}{}", self.format_ph(&ph.start_name), self.format_ph(&ph.close_name)) } } @@ -701,6 +765,7 @@ mod tests { String::new(), String::new(), ); - assert_eq!(msg.message_string, "Hello {$interpolation}!"); + // Original placeholder names; goog.getMsg converts them to camelCase. + assert_eq!(msg.message_string, "Hello {$INTERPOLATION}!"); } } diff --git a/crates/oxc_angular_compiler/src/i18n/parser.rs b/crates/oxc_angular_compiler/src/i18n/parser.rs index ce9bbd834..7383511a8 100644 --- a/crates/oxc_angular_compiler/src/i18n/parser.rs +++ b/crates/oxc_angular_compiler/src/i18n/parser.rs @@ -4,6 +4,7 @@ //! //! Ported from Angular's `i18n/i18n_parser.ts`. +use std::borrow::Cow; use std::sync::Arc; use indexmap::IndexMap; @@ -37,6 +38,9 @@ pub struct I18nVisitorContext { pub placeholder_to_message: FxHashMap, /// Source file for span conversion. pub source_file: Arc, + /// Full name (`:svg:svg`) of the element whose children are being visited, from which + /// child elements inherit their namespace. + pub parent_element: Option, } impl I18nVisitorContext { @@ -48,6 +52,7 @@ impl I18nVisitorContext { placeholders: FxHashMap::default(), placeholder_to_message: FxHashMap::default(), source_file, + parent_element: None, } } } @@ -76,9 +81,14 @@ impl I18nMessageFactory { } /// Converts HTML nodes to an i18n Message. + /// + /// `parent_element` is the full name (such as `:svg:text`) of the element whose children + /// these nodes are, from which child elements inherit their namespace. + #[expect(clippy::too_many_arguments)] pub fn create_message( &self, nodes: &[HtmlNode<'_>], + parent_element: Option<&str>, meaning: Option<&str>, description: Option<&str>, custom_id: Option<&str>, @@ -86,6 +96,7 @@ impl I18nMessageFactory { source_file: Arc, ) -> Message { let mut context = I18nVisitorContext::new(source_file); + context.parent_element = parent_element.map(str::to_string); let visit_fn = visit_node_fn.unwrap_or(noop_visit_node); // Check if this is a single ICU expression @@ -103,6 +114,53 @@ 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(), + ) + } + + /// Converts a single ICU expansion to an i18n Message. + /// + /// Equivalent to Angular's `createI18nMessage([expansion], ...)`: the message consists of + /// the ICU itself, with `VAR_*` and interpolation placeholders registered inside it. + pub fn create_icu_message( + &self, + expansion: &HtmlExpansion<'_>, + parent_element: Option<&str>, + source_file: Arc, + ) -> Message { + let mut context = I18nVisitorContext::new(source_file); + context.parent_element = parent_element.map(str::to_string); + context.is_icu = true; + let nodes = + self.visit_expansion(expansion, &mut context, noop_visit_node).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, @@ -145,12 +203,17 @@ impl I18nMessageFactory { /// Visits a text node and extracts interpolations. fn visit_text(&self, text: &HtmlText<'_>, context: &mut I18nVisitorContext) -> Option { - let value = text.value.as_str(); - - // Skip empty text unless configured to retain - if value.trim().is_empty() && !self.retain_empty_tokens { - return None; + // Angular: `text.tokens.length === 1 ? new Text(text.value) : _visitTextWithInterpolation`. + // Building from tokens keeps entity tokens as decoded, e.g. `&ngsp;` as U+E500, which + // whitespace processing only replaces in plain text tokens. + if text.tokens.len() > 1 { + return Some(self.visit_text_with_interpolation_tokens( + &text.tokens, + text.span, + context, + )); } + let value = text.value.as_str(); // Check if text contains interpolations if value.contains("{{") && value.contains("}}") { @@ -398,8 +461,8 @@ impl I18nMessageFactory { } } - // Return result based on what we found - if has_interpolation && nodes.len() > 1 { + // Angular: a Container whenever there is an interpolation, otherwise the single node. + if has_interpolation { Node::Container(Container::new(nodes, overall_span)) } else if nodes.len() == 1 { nodes @@ -545,7 +608,9 @@ impl I18nMessageFactory { context: &mut I18nVisitorContext, visit_fn: VisitNodeFn, ) -> Option { - let tag_name = element.name.as_str(); + // Angular names placeholders from the element's full name (`:svg:circle`). + let full_name = element_full_name(element.name.as_str(), context.parent_element.as_deref()); + let tag_name: &str = &full_name; let is_void = is_void_element(tag_name); // Convert element attributes to an IndexMap for placeholder registry (ordered for consistent serialization) @@ -561,6 +626,11 @@ impl I18nMessageFactory { } } + // Visit children first: Angular names nested tags before their parent. + let parent = context.parent_element.replace(full_name.to_string()); + let children = self.visit_all(&element.children, context, visit_fn); + context.parent_element = parent; + // Generate placeholder names for the tag let start_name = context.placeholder_registry.get_start_tag_placeholder_name(tag_name, &attrs, is_void); @@ -570,9 +640,6 @@ impl I18nMessageFactory { context.placeholder_registry.get_close_tag_placeholder_name(tag_name) }; - // Visit children - let children = self.visit_all(&element.children, context, visit_fn); - let source_span = ParseSourceSpan::from_offsets( &context.source_file, element.span.start, @@ -794,38 +861,45 @@ pub fn create_i18n_message_factory( I18nMessageFactory::new(retain_empty_tokens, preserve_expression_whitespace) } +/// An element's full name with its namespace prefix: explicit (`:xhtml:div`), implicit for the +/// tag (`:svg:svg`) or inherited from its parent (`:svg:circle`), as Angular's HTML parser +/// computes it in `_getElementFullName`. +pub(crate) fn element_full_name<'a>(name: &'a str, parent: Option<&str>) -> Cow<'a, str> { + use crate::parser::html::{get_html_tag_definition, get_ns_prefix, split_ns_name}; + if name.starts_with(':') { + return Cow::Borrowed(name); + } + let mut prefix = get_html_tag_definition(name).implicit_namespace_prefix; + if prefix.is_none() + && let Some(parent) = parent.filter(|parent| parent.starts_with(':')) + && !get_html_tag_definition(split_ns_name(parent).1).prevent_namespace_inheritance + { + prefix = get_ns_prefix(parent); + } + match prefix { + Some(prefix) => Cow::Owned(format!(":{prefix}:{name}")), + None => Cow::Borrowed(name), + } +} + /// Extracts a custom placeholder name from an expression if present. -/// Looks for comments like `// i18n(ph="CUSTOM_NAME")` in the expression. -/// -/// Supported formats: -/// - `/* i18n(ph="NAME") */` - block comment format -/// - `// i18n(ph="NAME")` - line comment format (at the end) +/// Looks for a trailing comment like `// i18n(ph="CUSTOM_NAME")` in the expression, allowing +/// whitespace between the parts (`// i18n(ph = 'name')`), as Angular's `_CUSTOM_PH_EXP` does. /// /// Returns `Some(name)` if a custom placeholder name is found, `None` otherwise. -fn extract_placeholder_name(expression: &str) -> Option { - // Look for block comment format: /* i18n(ph="NAME") */ - if let Some(start) = expression.find("i18n(ph=") { - let rest = &expression[start + 8..]; // Skip "i18n(ph=" - - // Determine quote type (single or double) - let (quote, rest) = if rest.starts_with('"') { - ('"', &rest[1..]) - } else if rest.starts_with('\'') { - ('\'', &rest[1..]) - } else { - return None; - }; - - // Find the closing quote - if let Some(end) = rest.find(quote) { - let name = &rest[..end]; - if !name.is_empty() { - return Some(name.to_string()); - } - } - } - - None +pub(crate) fn extract_placeholder_name(expression: &str) -> Option { + // Angular: /\/\/[\s\S]*i18n[\s\S]*\([\s\S]*ph[\s\S]*=[\s\S]*("|')([\s\S]*?)\1[\s\S]*\)/ + let rest = &expression[expression.find("//")?..]; + let rest = &rest[rest.find("i18n")?..]; + let rest = &rest[rest.find('(')?..]; + let rest = &rest[rest.find("ph")?..]; + let rest = &rest[rest.find('=')? + 1..]; + let start = rest.find(['"', '\''])?; + let quote = rest[start..].chars().next()?; + let body = &rest[start + 1..]; + let end = body.find(quote)?; + let name = &body[..end]; + (!name.is_empty() && body[end + 1..].contains(')')).then(|| name.to_string()) } #[cfg(test)] @@ -841,7 +915,7 @@ mod tests { fn test_create_simple_message() { let factory = create_i18n_message_factory(false, false); let source_file = Arc::new(ParseSourceFile::new("", "")); - let message = factory.create_message(&[], None, None, None, None, source_file); + let message = factory.create_message(&[], None, None, None, None, None, source_file); assert!(message.nodes.is_empty()); } @@ -866,7 +940,7 @@ mod tests { let nodes = vec![HtmlNode::Text(Box::new_in(text, &&allocator))]; let source_file = Arc::new(ParseSourceFile::new("Hello {{name}}!", "")); - let message = factory.create_message(&nodes, None, None, None, None, source_file); + let message = factory.create_message(&nodes, None, None, None, None, None, source_file); // Should have one Container with Text, Placeholder, Text inside assert_eq!(message.nodes.len(), 1); @@ -897,7 +971,7 @@ mod tests { let nodes = vec![HtmlNode::Text(Box::new_in(text, &&allocator))]; let source_file = Arc::new(ParseSourceFile::new("Hello World", "")); - let message = factory.create_message(&nodes, None, None, None, None, source_file); + let message = factory.create_message(&nodes, None, None, None, None, None, source_file); // Should have one Text node assert_eq!(message.nodes.len(), 1); @@ -918,7 +992,7 @@ mod tests { let nodes = vec![HtmlNode::Text(Box::new_in(text, &&allocator))]; let source_file = Arc::new(ParseSourceFile::new("{{greeting}} {{name}}!", "")); - let message = factory.create_message(&nodes, None, None, None, None, source_file); + let message = factory.create_message(&nodes, None, None, None, None, None, source_file); // Should have Container with multiple placeholders assert_eq!(message.nodes.len(), 1); diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index 1fd451457..8cc9313cc 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -1128,6 +1128,8 @@ pub struct I18nMessageOp<'a> { /// The serialized message string for goog.getMsg and $localize. /// Contains the message text with placeholder markers like "{$interpolation}". pub message_string: Option>, + /// For an ICU sub-message, its `$localize` id, written on the parent's ICU placeholder. + pub associated_message_id: Option>, /// Whether message needs postprocessing (has params with multiple values). pub needs_postprocessing: bool, /// Sub-messages. diff --git a/crates/oxc_angular_compiler/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index eee7f799f..ff3a57ac7 100644 --- a/crates/oxc_angular_compiler/src/output/ast.rs +++ b/crates/oxc_angular_compiler/src/output/ast.rs @@ -806,8 +806,11 @@ pub struct LocalizedStringExpr<'a> { pub meaning: Option>, /// Custom message ID. pub custom_id: Option>, - /// Message parts. + /// Message parts (cooked strings). pub message_parts: Vec<'a, Ident<'a>>, + /// Raw strings of the message parts, with the escapes `$localize` reads to find the end of + /// each metadata block (Angular's `createCookedRawString`). + pub raw_message_parts: Vec<'a, Ident<'a>>, /// Placeholder names. pub placeholder_names: Vec<'a, Ident<'a>>, /// Interpolated expressions. @@ -1395,6 +1398,9 @@ impl<'a> OutputExpression<'a> { for part in e.message_parts.iter() { message_parts.push(part.clone()); } + let mut raw_message_parts = + Vec::with_capacity_in(e.raw_message_parts.len(), &allocator); + raw_message_parts.extend(e.raw_message_parts.iter().copied()); let mut placeholder_names = Vec::with_capacity_in(e.placeholder_names.len(), &allocator); for name in e.placeholder_names.iter() { @@ -1410,6 +1416,7 @@ impl<'a> OutputExpression<'a> { meaning: e.meaning.clone(), custom_id: e.custom_id.clone(), message_parts, + raw_message_parts, placeholder_names, expressions, source_span: e.source_span, diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index ce77f5104..545731069 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -1056,9 +1056,9 @@ impl JsEmitter { } ctx.print("], "); - // Raw strings (same as cooked for i18n) + // Raw strings ctx.print("["); - for (i, part) in expr.message_parts.iter().enumerate() { + for (i, part) in expr.raw_message_parts.iter().enumerate() { if i > 0 { ctx.print(", "); } @@ -2479,6 +2479,11 @@ mod tests { description: None, meaning: None, custom_id: None, + raw_message_parts: { + let mut raw = oxc_allocator::Vec::new_in(&&alloc); + raw.extend(message_parts.iter().copied()); + raw + }, message_parts, placeholder_names, expressions, @@ -2518,6 +2523,11 @@ mod tests { description: None, meaning: None, custom_id: None, + raw_message_parts: { + let mut raw = oxc_allocator::Vec::new_in(&&alloc); + raw.extend(message_parts.iter().copied()); + raw + }, message_parts, placeholder_names, expressions, diff --git a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs index 2640b286d..fd41e149c 100644 --- a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs +++ b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs @@ -726,16 +726,25 @@ impl<'a> WhitespaceVisitor<'a> { } } -/// Remove whitespace from parsed HTML nodes. -/// -/// This is the main entry point for whitespace processing. +/// Remove whitespace from parsed template nodes, as Angular's `parseTemplate` does when +/// `preserveWhitespaces` is off. pub fn remove_whitespaces<'a>( allocator: &'a Allocator, nodes: &[HtmlNode<'a>], preserve_significant_whitespace: bool, ) -> Vec<'a, HtmlNode<'a>> { let mut visitor = WhitespaceVisitor::new(allocator, preserve_significant_whitespace); - visitor.visit_all(nodes) + // Angular's parseTemplate visits the root nodes with `visitAll`, not + // `visitAllWithSiblings`, so root-level text has no sibling context: whitespace next to a + // root-level ICU is removed, while inside elements it is kept. + let mut result = Vec::with_capacity_in(nodes.len(), &allocator); + let no_siblings = SiblingContext { prev: None, next: None }; + for node in nodes { + if let Some(new_node) = visitor.visit_node(node, &no_siblings) { + result.push(new_node); + } + } + result } #[cfg(test)] diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index 4f11478fb..29787255e 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -14,6 +14,7 @@ use oxc_str::Ident; use rustc_hash::{FxBuildHasher, FxHashMap}; use crate::AngularVersion; +use crate::ast::r3::I18nMessage; use crate::ir::enums::CompatibilityMode; use crate::ir::list::{CreateOpList, UpdateOpList}; use crate::ir::ops::XrefId; @@ -99,6 +100,27 @@ pub struct I18nMessageMetadata<'a> { /// The serialized message string for goog.getMsg and $localize. /// Contains the message text with placeholder markers like "{$interpolation}". pub message_string: Option>, + /// For an ICU sub-message, its `$localize` id, written on the parent message's ICU + /// placeholder (`:ICU@@:`). + pub associated_message_id: Option>, +} + +impl<'a> I18nMessageMetadata<'a> { + /// Builds the metadata of an i18n message from the template. + pub fn from_message(allocator: &'a Allocator, message: &I18nMessage<'a>) -> Self { + let non_empty = |value: &Ident<'a>| (!value.is_empty()).then_some(*value); + let mut legacy_ids = Vec::new_in(&allocator); + legacy_ids.extend(message.legacy_ids.iter().copied()); + Self { + message_id: non_empty(&message.id), + custom_id: non_empty(&message.custom_id), + meaning: non_empty(&message.meaning), + description: non_empty(&message.description), + legacy_ids, + message_string: non_empty(&message.message_string), + associated_message_id: non_empty(&message.associated_message_id), + } + } } /// A complete compilation job for a single component template. diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 2e920a755..f0208ddd5 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -25,10 +25,10 @@ use super::compilation::{ use super::conversion::prefix_with_namespace; use crate::ast::expression::{AngularExpression, ParsedEventType}; use crate::ast::r3::{ - I18nIcuPlaceholder, I18nMeta, I18nNode, R3BoundAttribute, R3BoundEvent, R3BoundText, R3Content, - R3DeferredBlock, R3Element, R3ForLoopBlock, R3Icu, R3IcuPlaceholder, R3IfBlock, - R3LetDeclaration, R3Node, R3SwitchBlock, R3Template, R3TemplateAttr, R3Text, R3TextAttribute, - SecurityContext, + I18nIcuPlaceholder, I18nMessage, I18nMeta, I18nNode, R3BoundAttribute, R3BoundEvent, + R3BoundText, R3Content, R3DeferredBlock, R3Element, R3ForLoopBlock, R3Icu, R3IcuPlaceholder, + R3IfBlock, R3LetDeclaration, R3Node, R3SwitchBlock, R3Template, R3TemplateAttr, R3Text, + R3TextAttribute, SecurityContext, }; use crate::ir::enums::{ BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind, @@ -675,19 +675,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 @@ -974,6 +961,19 @@ fn get_single_icu_placeholder<'a, 'b>( None } +/// Records an i18n message's metadata for later phases, keyed by its instance id, and returns +/// that id. Metadata already recorded for the same message is kept. +fn record_i18n_message_metadata<'a>( + job: &mut ComponentCompilationJob<'a>, + message: &I18nMessage<'a>, +) -> u32 { + let allocator = job.allocator; + job.i18n_message_metadata + .entry(message.instance_id) + .or_insert_with(|| I18nMessageMetadata::from_message(allocator, message)); + message.instance_id +} + /// Ingests an ICU expression node (plural, select, selectordinal). /// /// Creates IcuStartOp and IcuEndOp to bracket the ICU expression, @@ -995,6 +995,13 @@ fn ingest_icu<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, icu: } }; + // The ICU's own message (Angular: `createIcuStartOp(xref, icu.i18n, ...)`). When it is the + // same message as the enclosing i18n block, the ICU is the message rather than a sub-message. + let message = match &icu.i18n { + Some(I18nMeta::Message(message)) => Some(record_i18n_message_metadata(job, message)), + _ => None, + }; + let xref = job.allocate_xref_id(); // Create IcuStartOp @@ -1002,7 +1009,7 @@ fn ingest_icu<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, icu: base: CreateOpBase { source_span: Some(icu.source_span), ..Default::default() }, xref, context: None, // Will be set by create_i18n_contexts phase - message: None, // Will be set by phases + message, icu_placeholder: Some(icu_placeholder_name), }); @@ -1242,35 +1249,7 @@ fn ingest_element<'a>( let instance_id = message.instance_id; // Store i18n message metadata keyed by instance_id - let mut legacy_ids = Vec::new_in(&allocator); - for id in message.legacy_ids.iter() { - legacy_ids.push(id.clone()); - } - - let metadata = I18nMessageMetadata { - message_id: if message.id.is_empty() { None } else { Some(message.id.clone()) }, - custom_id: if message.custom_id.is_empty() { - None - } else { - Some(message.custom_id.clone()) - }, - meaning: if message.meaning.is_empty() { - None - } else { - Some(message.meaning.clone()) - }, - description: if message.description.is_empty() { - None - } else { - Some(message.description.clone()) - }, - legacy_ids, - message_string: if message.message_string.is_empty() { - None - } else { - Some(message.message_string.clone()) - }, - }; + let metadata = I18nMessageMetadata::from_message(allocator, message); job.i18n_message_metadata.insert(instance_id, metadata); // Create I18nStartOp @@ -1411,35 +1390,7 @@ fn ingest_static_attributes_with_i18n<'a>( // Store i18n message metadata for later phases (only if not already stored) if !job.i18n_message_metadata.contains_key(&instance_id) { - let mut legacy_ids = Vec::new_in(&allocator); - for id in message.legacy_ids.iter() { - legacy_ids.push(id.clone()); - } - - let metadata = I18nMessageMetadata { - message_id: if message.id.is_empty() { None } else { Some(message.id.clone()) }, - custom_id: if message.custom_id.is_empty() { - None - } else { - Some(message.custom_id.clone()) - }, - meaning: if message.meaning.is_empty() { - None - } else { - Some(message.meaning.clone()) - }, - description: if message.description.is_empty() { - None - } else { - Some(message.description.clone()) - }, - legacy_ids, - message_string: if message.message_string.is_empty() { - None - } else { - Some(message.message_string.clone()) - }, - }; + let metadata = I18nMessageMetadata::from_message(allocator, message); job.i18n_message_metadata.insert(instance_id, metadata); } @@ -1628,7 +1579,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 { + if let I18nNode::Placeholder(ph) = node + && !i18n_placeholders.contains(&ph.name) + { + i18n_placeholders.push(ph.name); + } + } + } + convert_interpolation_to_ir_with_i18n_placeholders(job, input.value, i18n_placeholders) } else { convert_ast_to_ir(job, input.value) }; @@ -1644,35 +1607,7 @@ fn ingest_binding_owned<'a>( // Store i18n message metadata for later phases (keyed by instance_id) if !job.i18n_message_metadata.contains_key(&instance_id) { - let mut legacy_ids = Vec::new_in(&allocator); - for id in message.legacy_ids.iter() { - legacy_ids.push(id.clone()); - } - - let metadata = I18nMessageMetadata { - message_id: if message.id.is_empty() { None } else { Some(message.id.clone()) }, - custom_id: if message.custom_id.is_empty() { - None - } else { - Some(message.custom_id.clone()) - }, - meaning: if message.meaning.is_empty() { - None - } else { - Some(message.meaning.clone()) - }, - description: if message.description.is_empty() { - None - } else { - Some(message.description.clone()) - }, - legacy_ids, - message_string: if message.message_string.is_empty() { - None - } else { - Some(message.message_string.clone()) - }, - }; + let metadata = I18nMessageMetadata::from_message(allocator, message); job.i18n_message_metadata.insert(instance_id, metadata); } @@ -1953,38 +1888,7 @@ fn ingest_template<'a>( if let Some(I18nMeta::Message(ref message)) = template.i18n { let instance_id = message.instance_id; // Clone legacy_ids using the allocator - let mut legacy_ids = Vec::new_in(&allocator); - for id in message.legacy_ids.iter() { - legacy_ids.push(id.clone()); - } - - Some(( - instance_id, - I18nMessageMetadata { - message_id: if message.id.is_empty() { None } else { Some(message.id.clone()) }, - custom_id: if message.custom_id.is_empty() { - None - } else { - Some(message.custom_id.clone()) - }, - meaning: if message.meaning.is_empty() { - None - } else { - Some(message.meaning.clone()) - }, - description: if message.description.is_empty() { - None - } else { - Some(message.description.clone()) - }, - legacy_ids, - message_string: if message.message_string.is_empty() { - None - } else { - Some(message.message_string.clone()) - }, - }, - )) + Some((instance_id, I18nMessageMetadata::from_message(allocator, message))) } else { None } @@ -2374,12 +2278,21 @@ fn ingest_content<'a>( None }; + // Angular: createProjectionOp(id, content.selector, content.i18n, ...) + let i18n_placeholder = match &content.i18n { + Some(I18nMeta::Node(I18nNode::TagPlaceholder(tag))) => Some(I18nPlaceholder::new( + tag.start_name, + if tag.is_void { None } else { Some(tag.close_name) }, + )), + _ => None, + }; + let op = CreateOp::Projection(ProjectionOp { base: CreateOpBase { source_span: Some(content.source_span), ..Default::default() }, xref, slot: None, projection_slot_index: 0, // Will be set during projection phase - i18n_placeholder: None, + i18n_placeholder, selector: Some(content.selector.clone()), fallback, fallback_i18n_placeholder: None, @@ -4437,35 +4350,7 @@ fn ingest_control_flow_insertion_point<'a, 'b>( // Store i18n message metadata for later phases (only if not already stored) if !job.i18n_message_metadata.contains_key(&instance_id) { - let mut legacy_ids = Vec::new_in(&allocator); - for id in message.legacy_ids.iter() { - legacy_ids.push(id.clone()); - } - - let metadata = I18nMessageMetadata { - message_id: if message.id.is_empty() { None } else { Some(message.id.clone()) }, - custom_id: if message.custom_id.is_empty() { - None - } else { - Some(message.custom_id.clone()) - }, - meaning: if message.meaning.is_empty() { - None - } else { - Some(message.meaning.clone()) - }, - description: if message.description.is_empty() { - None - } else { - Some(message.description.clone()) - }, - legacy_ids, - message_string: if message.message_string.is_empty() { - None - } else { - Some(message.message_string.clone()) - }, - }; + let metadata = I18nMessageMetadata::from_message(allocator, message); job.i18n_message_metadata.insert(instance_id, metadata); } @@ -4597,6 +4482,7 @@ mod tests { id: Ident::from(""), legacy_ids: Vec::new_in(&&allocator), message_string: Ident::from(""), + associated_message_id: Ident::from(""), }); let result = convert_i18n_meta_to_placeholder( diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs b/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs index 898eeccfe..9d47ccca5 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs @@ -11,7 +11,7 @@ use std::ptr::NonNull; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::ast::r3::SecurityContext; use crate::ir::enums::BindingKind; @@ -59,6 +59,8 @@ fn process_view_attributes<'a>( // Collect pointers to ops that should be removed (extractable text attributes) let mut ops_to_remove: Vec>> = Vec::new(); + // Template ops in this view: a binding targeting one has a non-null `templateKind`. + let mut template_xrefs: FxHashSet = FxHashSet::default(); // IMPORTANT: Process create operations FIRST (for listeners) // This matches Angular's unit.ops() which iterates create ops before update ops. @@ -73,6 +75,9 @@ fn process_view_attributes<'a>( }; for op in view.create.iter() { + if let CreateOp::Template(template) = op { + template_xrefs.insert(template.xref); + } match op { CreateOp::Listener(listener) => { if !listener.is_animation_listener { @@ -197,6 +202,7 @@ fn process_view_attributes<'a>( // kind because the runtime uses domProperty, not i18nAttributes. let binding_kind = if prop_op.i18n_message.is_some() && prop_op.binding_kind != BindingKind::Template + && !template_xrefs.contains(&prop_op.target) && matches!(*prop_op.expression, IrExpression::Interpolation(_)) { BindingKind::I18n diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs index c179fb96f..3767b3897 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs @@ -778,6 +778,8 @@ fn clone_expression<'a>( for part in loc.message_parts.iter() { message_parts.push(part.clone()); } + let mut raw_message_parts = oxc_allocator::Vec::new_in(&allocator); + raw_message_parts.extend(loc.raw_message_parts.iter().copied()); let mut placeholder_names = oxc_allocator::Vec::new_in(&allocator); for name in loc.placeholder_names.iter() { placeholder_names.push(name.clone()); @@ -792,6 +794,7 @@ fn clone_expression<'a>( meaning: loc.meaning.clone(), custom_id: loc.custom_id.clone(), message_parts, + raw_message_parts, placeholder_names, expressions, source_span: loc.source_span, 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 e7046034b..628b89ed7 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 @@ -133,6 +133,8 @@ pub fn extract_i18n_messages(job: &mut ComponentCompilationJob<'_>) { meaning: metadata.and_then(|m| m.meaning.clone()), description: metadata.and_then(|m| m.description.clone()), message_string: metadata.and_then(|m| m.message_string.clone()), + associated_message_id: metadata + .and_then(|m| m.associated_message_id), needs_postprocessing, sub_messages: oxc_allocator::Vec::new_in(&allocator), }); @@ -393,8 +395,7 @@ pub fn format_param_values(values: &[I18nParamValue]) -> Option { /// with expression placeholders. /// /// For example, if strings = ["Hello ", "!"] and expression_placeholders has one -/// entry with value 0, the output would be "Hello ${�0�}!" where the expression -/// placeholder is formatted using format_value. +/// entry with value 0, the output would be "Hello \u{FFFD}0\u{FFFD}!". /// /// Ported from Angular's `formatIcuPlaceholder` function. pub fn format_icu_placeholder( @@ -405,8 +406,7 @@ pub fn format_icu_placeholder( for (i, s) in strings.iter().enumerate() { result.push_str(s.as_str()); if let Some(expr_value) = expression_placeholders.get(i) { - // Format as ${value} where value is the formatted expression placeholder - result.push_str(&format!("${{{}}}", format_value(expr_value))); + result.push_str(&format_value(expr_value)); } } result diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs b/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs index 90bf14df1..3c47eaf0b 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs @@ -13,7 +13,7 @@ use oxc_diagnostics::OxcDiagnostic; use rustc_hash::FxHashMap; use crate::ir::expression::IrExpression; -use crate::ir::ops::{AdvanceOp, CreateOp, SlotId, UpdateOp, UpdateOpBase, XrefId}; +use crate::ir::ops::{AdvanceOp, CreateOp, I18nSlotHandle, SlotId, UpdateOp, UpdateOpBase, XrefId}; use crate::output::ast::{OutputExpression, OutputStatement}; use crate::pipeline::compilation::ComponentCompilationJob; @@ -58,6 +58,12 @@ fn build_slot_map<'a>(create_ops: &crate::ir::list::CreateOpList<'a>) -> FxHashM CreateOp::DeclareLet(let_op) => (Some(let_op.xref), let_op.slot), CreateOp::I18nStart(i18n) => (Some(i18n.xref), i18n.slot), CreateOp::I18n(i18n) => (Some(i18n.xref), i18n.slot), + // Angular maps every op that consumes a slot, including these. + CreateOp::ConditionalBranch(branch) => (Some(branch.xref), branch.slot), + CreateOp::I18nAttributes(attrs) => match attrs.handle { + I18nSlotHandle::Single(slot) => (Some(attrs.xref), Some(slot)), + I18nSlotHandle::Range(start, _) => (Some(attrs.xref), Some(start)), + }, _ => (None, None), }; diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs index c667726aa..7db6cea01 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs @@ -37,6 +37,51 @@ use crate::output::ast::{ TypeofExpr, }; +/// The value of an i18n message param. +/// +/// Most params are string literals, but Angular passes an ICU sub-message by its variable +/// (`{ "icu": i18n_0 }`), and a placeholder shared by several sub-messages as an array of +/// their variables for `ɵɵi18nPostprocess`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum I18nParamExpr { + /// A string literal, e.g. `"\uFFFD0\uFFFD"`. + Literal(String), + /// A reference to another message's variable, e.g. `i18n_0`. + Var(String), + /// An array of message variables, e.g. `[i18n_0, i18n_1]`. + Vars(Vec), +} + +impl I18nParamExpr { + /// Builds the output expression for this value. + pub fn to_expr<'a>(&self, allocator: &'a oxc_allocator::Allocator) -> OutputExpression<'a> { + let read_var = |name: &str| { + OutputExpression::ReadVar(AllocBox::new_in( + ReadVarExpr { name: Ident::from(allocator.alloc_str(name)), source_span: None }, + &allocator, + )) + }; + match self { + Self::Literal(value) => OutputExpression::Literal(AllocBox::new_in( + LiteralExpr { + value: LiteralValue::String(Ident::from(allocator.alloc_str(value))), + source_span: None, + }, + &allocator, + )), + Self::Var(name) => read_var(name), + Self::Vars(names) => { + let mut entries = AllocVec::new_in(&allocator); + entries.extend(names.iter().map(|name| read_var(name))); + OutputExpression::LiteralArray(AllocBox::new_in( + crate::output::ast::LiteralArrayExpr { entries, source_span: None }, + &allocator, + )) + } + } + } +} + /// Name of the global variable that is used to determine if we use Closure translations. const NG_I18N_CLOSURE_MODE: &str = "ngI18nClosureMode"; @@ -171,7 +216,7 @@ pub fn create_goog_get_msg_statements<'a>( i18n_var_name: &Ident<'a>, closure_var_name: &Ident<'a>, message_string: &str, - params: &[(String, String)], + params: &[(String, I18nParamExpr)], meta: Option<&I18nMessageMeta<'a>>, ) -> AllocVec<'a, OutputStatement<'a>> { let mut statements = AllocVec::new_in(&allocator); @@ -193,16 +238,9 @@ pub fn create_goog_get_msg_statements<'a>( // Format placeholder name to camelCase for Closure let formatted_name = format_i18n_placeholder_name(name, true); let key_str = allocator.alloc_str(&formatted_name); - let value_str = allocator.alloc_str(value); entries.push(LiteralMapEntry::new( Ident::from(key_str), - OutputExpression::Literal(AllocBox::new_in( - LiteralExpr { - value: LiteralValue::String(Ident::from(value_str)), - source_span: None, - }, - &allocator, - )), + value.to_expr(allocator), true, )); } @@ -336,7 +374,7 @@ pub fn create_translation_declaration<'a>( i18n_var_name: Ident<'a>, closure_var_name: Ident<'a>, message_for_closure: &str, - params: &[(String, String)], + params: &[(String, I18nParamExpr)], localized_expr: OutputExpression<'a>, meta: Option<&I18nMessageMeta<'a>>, ) -> AllocVec<'a, OutputStatement<'a>> { 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 b9f45a0f2..47c0d75eb 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 @@ -20,7 +20,7 @@ use crate::output::ast::{ }; use crate::pipeline::compilation::{ComponentCompilationJob, ConstValue}; use crate::pipeline::phases::i18n_closure::{ - I18nMessageMeta, create_translation_declaration, generate_closure_var_name, + I18nMessageMeta, I18nParamExpr, create_translation_declaration, generate_closure_var_name, generate_file_based_i18n_suffix, generate_i18n_var_name, }; use crate::r3::Identifiers; @@ -60,7 +60,11 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { // I18n Message Xref -> I18n Message Op info let mut messages: FxHashMap = FxHashMap::default(); // I18n Context Xref -> Params - let mut params_by_context: FxHashMap> = FxHashMap::default(); + let mut params_by_context: FxHashMap> = + FxHashMap::default(); + // I18n Context Xref -> Post-processing params + let mut postprocessing_params_by_context: FxHashMap> = + FxHashMap::default(); // Collect info from all views for view in job.all_views() { @@ -91,6 +95,10 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { custom_id: msg_op.custom_id.as_ref().map(|a| a.to_string()), message_id: msg_op.message_id.as_ref().map(|a| a.to_string()), message_string: msg_op.message_string.as_ref().map(|a| a.to_string()), + associated_message_id: msg_op + .associated_message_id + .as_ref() + .map(ToString::to_string), needs_postprocessing: msg_op.needs_postprocessing, sub_messages: msg_op.sub_messages.iter().copied().collect(), }, @@ -98,8 +106,20 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { } CreateOp::I18nContext(ctx_op) => { // Collect formatted params from context - let formatted = format_context_params(&ctx_op.params); + let formatted = format_context_params(&ctx_op.params) + .into_iter() + .map(|(name, value)| (name, I18nParamExpr::Literal(value))) + .collect(); params_by_context.insert(ctx_op.xref, formatted); + + // Angular's createI18nMessage formats the context's post-processing params, + // then extractI18nMessages sets each ICU placeholder's literal over them. + let mut postprocessing: FxHashMap = + format_context_params(&ctx_op.postprocessing_params).into_iter().collect(); + for (name, value) in &ctx_op.icu_placeholder_literals { + postprocessing.insert(name.to_string(), value.to_string()); + } + postprocessing_params_by_context.insert(ctx_op.xref, postprocessing); } _ => {} } @@ -173,6 +193,7 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { &allocator, &messages, ¶ms_by_context, + &postprocessing_params_by_context, &msg_info, &file_based_i18n_suffix, job.i18n_use_external_ids, @@ -429,6 +450,7 @@ struct MessageInfo { custom_id: Option, message_id: Option, message_string: Option, + associated_message_id: Option, needs_postprocessing: bool, sub_messages: Vec, } @@ -444,7 +466,8 @@ struct I18nExpressionInfo { fn collect_message<'a>( allocator: &'a oxc_allocator::Allocator, messages: &FxHashMap, - params_by_context: &FxHashMap>, + params_by_context: &FxHashMap>, + postprocessing_params_by_context: &FxHashMap>, msg_info: &MessageInfo, file_suffix: &str, use_external_ids: bool, @@ -454,12 +477,15 @@ fn collect_message<'a>( // Recursively collect sub-messages first let mut sub_message_placeholders: FxHashMap> = FxHashMap::default(); + // Placeholder -> `$localize` id of its sub-message. + let mut associated_message_ids: FxHashMap = FxHashMap::default(); for &sub_msg_xref in &msg_info.sub_messages { if let Some(sub_msg) = messages.get(&sub_msg_xref) { let (sub_var_name, sub_statements) = collect_message( &allocator, messages, params_by_context, + postprocessing_params_by_context, sub_msg, file_suffix, use_external_ids, @@ -469,6 +495,9 @@ fn collect_message<'a>( if let Some(ref placeholder) = sub_msg.message_placeholder { sub_message_placeholders.entry(placeholder.clone()).or_default().push(sub_var_name); + if let Some(id) = &sub_msg.associated_message_id { + associated_message_ids.insert(placeholder.clone(), id.clone()); + } } } } @@ -480,16 +509,21 @@ fn collect_message<'a>( .cloned() .unwrap_or_default(); - // Build postprocessing params from sub-message placeholders (for ICU post-processing) - let postprocessing_params: Vec<(String, Vec)> = sub_message_placeholders - .iter() - .filter(|(_, vars)| vars.len() > 1) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let mut postprocessing_params: Vec<(String, I18nParamExpr)> = msg_info + .i18n_context + .and_then(|ctx| postprocessing_params_by_context.get(&ctx)) + .map(|params| { + params + .iter() + .map(|(name, value)| (name.clone(), I18nParamExpr::Literal(value.clone()))) + .collect() + }) + .unwrap_or_default(); // Build params with sub-message values let mut params = base_params; - add_sub_message_params(&mut params, &sub_message_placeholders, msg_info.needs_postprocessing); + add_sub_message_params(&mut params, &mut postprocessing_params, &sub_message_placeholders); + postprocessing_params.sort_by(|a, b| a.0.cmp(&b.0)); // Sort params for consistency params.sort_by(|a, b| a.0.cmp(&b.0)); @@ -519,28 +553,22 @@ fn collect_message<'a>( None }; - // Serialize message for goog.getMsg format - // Use stored message_string if available, otherwise fallback to generating from params - let message_for_closure = + // The stored message string names placeholders as `{$NAME}`; goog.getMsg uses camelCase. + let message_string = msg_info.message_string.clone().unwrap_or_else(|| generate_message_from_params(¶ms)); + let message_for_closure = to_get_msg_string(&message_string); // Create $localize expression let localized_expr = create_localize_expression( - &allocator, - &message_for_closure, + allocator, + &message_string, ¶ms, msg_info.description.clone(), msg_info.meaning.clone(), msg_info.custom_id.clone(), + &associated_message_ids, ); - // Wrap with postprocess if needed - let localized_expr = if msg_info.needs_postprocessing || !postprocessing_params.is_empty() { - wrap_with_postprocess(allocator, localized_expr, &postprocessing_params) - } else { - localized_expr - }; - // Generate dual-mode translation declaration let i18n_var_atom = Ident::from(allocator.alloc_str(&i18n_var_name)); let closure_var_atom = Ident::from(allocator.alloc_str(&closure_var_name)); @@ -557,36 +585,81 @@ fn collect_message<'a>( all_statements.extend(statements); + // Angular applies post-processing after both the Closure and $localize branches: + // `i18n_X = ɵɵi18nPostprocess(i18n_X, params?)`. + if msg_info.needs_postprocessing || !postprocessing_params.is_empty() { + let read_var = || { + OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: i18n_var_atom, source_span: None }, + &allocator, + )) + }; + let postprocess = wrap_with_postprocess(allocator, read_var(), &postprocessing_params); + let assignment = OutputExpression::BinaryOperator(oxc_allocator::Box::new_in( + crate::output::ast::BinaryOperatorExpr { + operator: crate::output::ast::BinaryOperator::Assign, + lhs: oxc_allocator::Box::new_in(read_var(), &allocator), + rhs: oxc_allocator::Box::new_in(postprocess, &allocator), + source_span: None, + }, + &allocator, + )); + all_statements.push(OutputStatement::Expression(oxc_allocator::Box::new_in( + crate::output::ast::ExpressionStatement { expr: assignment, source_span: None }, + &allocator, + ))); + } + (i18n_var_name, all_statements) } /// Add sub-message placeholder values to the params. +/// +/// Ported from Angular's `addSubMessageParams`: a single sub-message is passed by its +/// variable; several sub-messages sharing a placeholder are mapped at post-processing time. fn add_sub_message_params( - params: &mut Vec<(String, String)>, + params: &mut Vec<(String, I18nParamExpr)>, + postprocessing_params: &mut Vec<(String, I18nParamExpr)>, sub_message_placeholders: &FxHashMap>, - _needs_postprocessing: bool, ) { for (placeholder, sub_vars) in sub_message_placeholders { - if sub_vars.len() == 1 { - // Single sub-message: use its variable directly - // The value will be the variable reference (handled at runtime) - params.push((placeholder.clone(), format!("{ESCAPE}{}{ESCAPE}", sub_vars[0]))); + if let [sub_var] = sub_vars.as_slice() { + params.push((placeholder.clone(), I18nParamExpr::Var(sub_var.clone()))); } else { - // Multiple sub-messages: create ICU mapping placeholder for post-processing params.push(( placeholder.clone(), - format!("{ESCAPE}{I18N_ICU_MAPPING_PREFIX}{placeholder}{ESCAPE}"), + I18nParamExpr::Literal(format!( + "{ESCAPE}{I18N_ICU_MAPPING_PREFIX}{placeholder}{ESCAPE}" + )), )); + postprocessing_params.retain(|(name, _)| name != placeholder); + postprocessing_params + .push((placeholder.clone(), I18nParamExpr::Vars(sub_vars.clone()))); } } } /// Generate a message string from params (fallback when message AST is not available). -fn generate_message_from_params(params: &[(String, String)]) -> String { +fn generate_message_from_params(params: &[(String, I18nParamExpr)]) -> String { let mut result = String::new(); for (name, _value) in params { - let formatted_name = format_i18n_placeholder_name(name, true); - result.push_str(&format!("{{${formatted_name}}}")); + result.push_str("{$"); + result.push_str(name); + result.push('}'); + } + result +} + +/// Converts a stored message string to goog.getMsg format by writing each `{$NAME}` placeholder +/// in camelCase, as Angular's `GetMsgSerializerVisitor` does. +fn to_get_msg_string(message: &str) -> String { + let (text_parts, placeholders) = parse_message_string(message); + let mut result = text_parts.first().cloned().unwrap_or_default(); + for (i, placeholder) in placeholders.iter().enumerate() { + result.push_str("{$"); + result.push_str(&format_i18n_placeholder_name(placeholder, true)); + result.push('}'); + result.push_str(text_parts.get(i + 1).map_or("", String::as_str)); } result } @@ -619,28 +692,32 @@ fn format_context_params( fn create_localize_expression<'a>( allocator: &'a oxc_allocator::Allocator, message_string: &str, - params: &[(String, String)], + params: &[(String, I18nParamExpr)], description: Option, meaning: Option, custom_id: Option, + associated_message_ids: &FxHashMap, ) -> OutputExpression<'a> { // Parse message_string to extract text parts and placeholder names in order let (text_parts, placeholder_order) = parse_message_string(message_string); let mut message_parts = ArenaVec::new_in(&allocator); + let mut raw_message_parts = ArenaVec::new_in(&allocator); let mut placeholder_names = ArenaVec::new_in(&allocator); let mut expressions = ArenaVec::new_in(&allocator); + let mut push_part = |(cooked, raw): (String, String)| { + message_parts.push(Ident::from(allocator.alloc_str(&cooked))); + raw_message_parts.push(Ident::from(allocator.alloc_str(&raw))); + }; // Build a map from placeholder name to value for quick lookup - let params_map: FxHashMap = + let params_map: FxHashMap = params.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); // First message part: includes metadata block + first text segment // Format: ":meaning|description@@customId:text" let first_text = text_parts.first().map(|s| s.as_str()).unwrap_or(""); - let head_cooked = serialize_i18n_head(first_text, &meaning, &description, &custom_id); - let head_str = allocator.alloc_str(&head_cooked); - message_parts.push(Ident::from(head_str)); + push_part(serialize_i18n_head(first_text, &meaning, &description, &custom_id)); // Subsequent parts: ":PLACEHOLDER_NAME:text" for (i, placeholder) in placeholder_order.iter().enumerate() { @@ -654,21 +731,17 @@ fn create_localize_expression<'a>( // uses camelCase (from format_i18n_placeholder_name with use_camel_case=true). // We need to find the matching param key. let value = find_param_value(¶ms_map, placeholder); - let value_str = allocator.alloc_str(&value); - let literal_expr = OutputExpression::Literal(oxc_allocator::Box::new_in( - crate::output::ast::LiteralExpr { - value: LiteralValue::String(Ident::from(value_str)), - source_span: None, - }, - &allocator, - )); - expressions.push(literal_expr); + expressions.push(value.to_expr(allocator)); // Text part after this placeholder let text_part = text_parts.get(i + 1).map(|s| s.as_str()).unwrap_or(""); - let part_cooked = serialize_i18n_template_part(&formatted_name, text_part); - let part_str = allocator.alloc_str(&part_cooked); - message_parts.push(Ident::from(part_str)); + // Angular's serializeI18nTemplatePart names an ICU placeholder's sub-message when the + // sub-message has no legacy ids (always, in Oxc): `:ICU@@:`. + let meta_block = match associated_message_ids.get(placeholder) { + Some(id) => format!("{formatted_name}@@{id}"), + None => formatted_name.clone(), + }; + push_part(create_cooked_raw_string(&meta_block, text_part)); } // Store metadata for potential future use (JSDoc generation in emitter) @@ -691,6 +764,7 @@ fn create_localize_expression<'a>( meaning: meaning_atom, custom_id: custom_id_atom, message_parts, + raw_message_parts, placeholder_names, expressions, source_span: None, @@ -710,6 +784,15 @@ fn parse_message_string(message: &str) -> (Vec, Vec) { let mut chars = message.chars().peekable(); while let Some(ch) = chars.next() { + // Text that looks like a marker is escaped by the message serializer. + if ch == '{' && chars.peek() == Some(&'\\') { + chars.next(); + current_text.push('{'); + if let Some(escaped) = chars.next() { + current_text.push(escaped); + } + continue; + } if ch == '{' && chars.peek() == Some(&'$') { // Start of placeholder: {$NAME} text_parts.push(current_text); @@ -738,91 +821,66 @@ fn parse_message_string(message: &str) -> (Vec, Vec) { (text_parts, placeholders) } -/// Serialize the i18n head (first message part) with metadata. -/// -/// Format: ":meaning|description@@customId:text" -/// - meaning and description are separated by | -/// - customId is prefixed with @@ -/// - If there's no metadata, just return the text (with starting colon escaped if needed) +/// Serialize the i18n head (first message part) with its metadata block +/// (`meaning|description@@customId`), as Angular's `serializeI18nHead`. fn serialize_i18n_head( text: &str, meaning: &Option, description: &Option, custom_id: &Option, -) -> String { - let mut meta_block = String::new(); - - // Build meta block: meaning|description@@customId - if let Some(m) = meaning { - meta_block.push_str(m); +) -> (String, String) { + let mut meta_block = description.clone().unwrap_or_default(); + if let Some(meaning) = meaning.as_deref().filter(|m| !m.is_empty()) { + meta_block = format!("{meaning}|{meta_block}"); } - if meaning.is_some() || description.is_some() { - if meaning.is_some() { - meta_block.push('|'); - } - if let Some(d) = description { - meta_block.push_str(d); - } - } - if let Some(id) = custom_id { - meta_block.push_str("@@"); - meta_block.push_str(id); + if let Some(id) = custom_id.as_deref().filter(|id| !id.is_empty()) { + meta_block = format!("{meta_block}@@{id}"); } + create_cooked_raw_string(&meta_block, text) +} +/// Returns the cooked and raw strings of a `$localize` message part with its metadata block, +/// as Angular's `createCookedRawString`. Only the raw string carries escapes: `$localize` reads +/// it to tell an escaped `\:` from the `:` that ends the metadata block. +fn create_cooked_raw_string(meta_block: &str, message_part: &str) -> (String, String) { + let escape_slashes = |s: &str| s.replace('\\', "\\\\"); + let escape_for_template_literal = |s: &str| s.replace('`', "\\`").replace("${", "$\\{"); if meta_block.is_empty() { - // No metadata - just return text (escape starting colon if needed) - if text.starts_with(':') { format!("\\:{}", &text[1..]) } else { text.to_string() } + let raw = escape_slashes(message_part); + let raw = match raw.strip_prefix(':') { + Some(rest) => format!("\\:{rest}"), + None => raw, + }; + (message_part.to_string(), escape_for_template_literal(&raw)) } else { - // With metadata: :meta:text - format!(":{}:{}", meta_block, text) + let raw = format!( + ":{}:{}", + escape_slashes(meta_block).replace(':', "\\:"), + escape_slashes(message_part) + ); + (format!(":{meta_block}:{message_part}"), escape_for_template_literal(&raw)) } } -/// Serialize an i18n template part (after first part). -/// -/// Format: ":PLACEHOLDER_NAME:text" -fn serialize_i18n_template_part(placeholder_name: &str, text: &str) -> String { - format!(":{}:{}", placeholder_name, text) -} - -/// Find the parameter value for a placeholder name from the message string. -/// -/// The message_string uses camelCase placeholder names (e.g., `interpolation`), -/// but the params_map is keyed by the original placeholder names (e.g., `INTERPOLATION`). -/// This function tries to find the matching param key by comparing the formatted names. -fn find_param_value(params_map: &FxHashMap, placeholder_name: &str) -> String { - // First try direct lookup - if let Some(value) = params_map.get(placeholder_name) { - return value.clone(); - } - - // Try UPPERCASE lookup first since that's the most common format - let uppercase_name = format_i18n_placeholder_name(placeholder_name, false); - if let Some(value) = params_map.get(&uppercase_name) { - return value.clone(); - } - - // Try to find a key that matches when formatted to camelCase - for (key, value) in params_map { - let formatted_key = format_i18n_placeholder_name(key, true); - if formatted_key == placeholder_name { - return value.clone(); - } - } - - // Fallback to empty string if no match found - String::new() +/// Find the parameter value for a placeholder, by its name in the message (Angular: +/// `params[ph.text]`). +fn find_param_value( + params_map: &FxHashMap, + placeholder_name: &str, +) -> I18nParamExpr { + params_map + .get(placeholder_name) + .cloned() + .unwrap_or_else(|| I18nParamExpr::Literal(String::new())) } /// Wrap an i18n expression with i18nPostprocess for ICU message handling. fn wrap_with_postprocess<'a>( allocator: &'a oxc_allocator::Allocator, expr: OutputExpression<'a>, - postprocessing_params: &[(String, Vec)], + postprocessing_params: &[(String, I18nParamExpr)], ) -> OutputExpression<'a> { - use crate::output::ast::{ - InvokeFunctionExpr, LiteralArrayExpr, LiteralMapEntry, LiteralMapExpr, - }; + use crate::output::ast::{InvokeFunctionExpr, LiteralMapEntry, LiteralMapExpr}; // Create ɵɵi18nPostprocess function reference (i0.ɵɵi18nPostprocess) let fn_var = OutputExpression::ReadProp(oxc_allocator::Box::new_in( @@ -848,27 +906,11 @@ fn wrap_with_postprocess<'a>( // Add postprocessing params if any if !postprocessing_params.is_empty() { let mut entries = ArenaVec::new_in(&allocator); - for (placeholder, var_names) in postprocessing_params { - // Format placeholder name + for (placeholder, value) in postprocessing_params { let formatted_name = format_i18n_placeholder_name(placeholder, false); - let key_str = allocator.alloc_str(&formatted_name); - - // Create array of variable references - let mut var_refs = ArenaVec::new_in(&allocator); - for var_name in var_names { - let var_str = allocator.alloc_str(var_name); - var_refs.push(OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: Ident::from(var_str), source_span: None }, - &allocator, - ))); - } - entries.push(LiteralMapEntry::new( - Ident::from(key_str), - OutputExpression::LiteralArray(oxc_allocator::Box::new_in( - LiteralArrayExpr { entries: var_refs, source_span: None }, - &allocator, - )), + Ident::from(allocator.alloc_str(&formatted_name)), + value.to_expr(allocator), true, )); } @@ -945,7 +987,10 @@ mod tests { &&allocator, )); - let params = vec![("ICU_0".to_string(), vec!["i18n_1".to_string(), "i18n_2".to_string()])]; + let params = vec![( + "ICU_0".to_string(), + I18nParamExpr::Vars(vec!["i18n_1".to_string(), "i18n_2".to_string()]), + )]; let result = wrap_with_postprocess(&allocator, input_expr, ¶ms); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_i18n_element_placeholders.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_i18n_element_placeholders.rs index 3d0258b30..2919cc344 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_i18n_element_placeholders.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_i18n_element_placeholders.rs @@ -45,14 +45,9 @@ pub fn resolve_i18n_element_placeholders(job: &mut ComponentCompilationJob<'_>) } } - // Process placeholders for root view + // Child views are reached through the ops that create them, as in Angular, so each view + // is resolved exactly once and with its enclosing structural directive. resolve_placeholders_for_view(job, job.root.xref, &i18n_contexts, &elements, None); - - // Process placeholders for other views - let view_xrefs: Vec = job.views.keys().copied().collect(); - for view_xref in view_xrefs { - resolve_placeholders_for_view(job, view_xref, &i18n_contexts, &elements, None); - } } /// Information about an element for placeholder resolution. @@ -92,7 +87,6 @@ fn resolve_placeholders_for_view<'a>( // Collect operations and context info in first pass let mut operations: Vec = Vec::new(); - let mut child_views_to_process: Vec<(XrefId, Option)> = Vec::new(); { let view = if view_xref.0 == 0 { Some(&job.root) } else { job.view(view_xref) }; @@ -187,175 +181,67 @@ fn resolve_placeholders_for_view<'a>( // Handle fallback view if let Some(fallback_xref) = proj_op.fallback { - if let Some(ref fallback_placeholder) = - proj_op.fallback_i18n_placeholder - { - if let Some(ref ops) = current_ops { - // Record template start/end for fallback view - if let Some(slot) = proj_op.slot { - operations.push(OpInfo::TemplateStart { - view_xref: fallback_xref, - slot: slot.0, - start_name: fallback_placeholder.start_name.clone(), - context_xref: ops.i18n_context_xref, - sub_template_index: ops.sub_template_index, - pending_structural, - has_close_name: fallback_placeholder - .close_name - .is_some(), - }); - if let Some(close_name) = &fallback_placeholder.close_name { - operations.push(OpInfo::TemplateEnd { - view_xref: fallback_xref, - slot: slot.0, - close_name: close_name.clone(), - context_xref: ops.i18n_context_xref, - pending_structural, - }); - } - } - } - } - child_views_to_process.push((fallback_xref, None)); - } - } - CreateOp::Template(template_op) => { - let template_view_xref = template_op.xref; - if template_op.i18n_placeholder.is_none() { - // No i18n placeholder, just recurse - child_views_to_process.push((template_view_xref, None)); - } else if let Some(ref placeholder) = template_op.i18n_placeholder { - if let Some(ref ops) = current_ops { - if template_op.template_kind == TemplateKind::Structural { - // Structural directive - pass as pending - if let Some(slot) = template_op.slot { - child_views_to_process.push(( - template_view_xref, - Some(PendingStructuralDirective { slot }), - )); - } - } else { - // Non-structural template - record start and end - if let Some(slot) = template_op.slot { - operations.push(OpInfo::TemplateStart { - view_xref: template_view_xref, - slot: slot.0, - start_name: placeholder.start_name.clone(), - context_xref: ops.i18n_context_xref, - sub_template_index: ops.sub_template_index, - pending_structural, - has_close_name: placeholder.close_name.is_some(), - }); - child_views_to_process.push((template_view_xref, None)); - if let Some(close_name) = &placeholder.close_name { - operations.push(OpInfo::TemplateEnd { - view_xref: template_view_xref, - slot: slot.0, - close_name: close_name.clone(), - context_xref: ops.i18n_context_xref, - pending_structural, - }); - } - } - pending_structural = None; - } - } + record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + fallback_xref, + proj_op.slot, + proj_op.fallback_i18n_placeholder.as_ref(), + TemplateKind::Block, + ); } } - CreateOp::Conditional(cond_op) => { - let cond_view_xref = cond_op.xref; - if cond_op.i18n_placeholder.is_none() { - child_views_to_process.push((cond_view_xref, None)); - } else if let Some(ref placeholder) = cond_op.i18n_placeholder { - if let Some(ref ops) = current_ops { - // Record conditional start/end - if let Some(slot) = cond_op.slot { - operations.push(OpInfo::TemplateStart { - view_xref: cond_view_xref, - slot: slot.0, - start_name: placeholder.start_name.clone(), - context_xref: ops.i18n_context_xref, - sub_template_index: ops.sub_template_index, - pending_structural, - has_close_name: placeholder.close_name.is_some(), - }); - child_views_to_process.push((cond_view_xref, None)); - if let Some(close_name) = &placeholder.close_name { - operations.push(OpInfo::TemplateEnd { - view_xref: cond_view_xref, - slot: slot.0, - close_name: close_name.clone(), - context_xref: ops.i18n_context_xref, - pending_structural, - }); - } - } - pending_structural = None; - } - } - } - CreateOp::RepeaterCreate(rep_op) => { + CreateOp::Template(op) => record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + op.xref, + op.slot, + op.i18n_placeholder.as_ref(), + op.template_kind, + ), + CreateOp::Conditional(op) => record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + op.xref, + op.slot, + op.i18n_placeholder.as_ref(), + op.template_kind, + ), + CreateOp::ConditionalBranch(op) => record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + op.xref, + op.slot, + op.i18n_placeholder.as_ref(), + op.template_kind, + ), + CreateOp::RepeaterCreate(op) => { // RepeaterCreate has 3 slots: op itself, @for template, @empty template - let for_slot = rep_op.slot.map(|s| s.0 + 1).unwrap_or(0); - let for_view_xref = rep_op.body_view; - - if rep_op.i18n_placeholder.is_none() { - child_views_to_process.push((for_view_xref, None)); - } else if let Some(ref placeholder) = rep_op.i18n_placeholder { - if let Some(ref ops) = current_ops { - // Record @for template start/end - operations.push(OpInfo::TemplateStart { - view_xref: for_view_xref, - slot: for_slot, - start_name: placeholder.start_name.clone(), - context_xref: ops.i18n_context_xref, - sub_template_index: ops.sub_template_index, - pending_structural: None, - has_close_name: placeholder.close_name.is_some(), - }); - child_views_to_process.push((for_view_xref, None)); - if let Some(close_name) = &placeholder.close_name { - operations.push(OpInfo::TemplateEnd { - view_xref: for_view_xref, - slot: for_slot, - close_name: close_name.clone(), - context_xref: ops.i18n_context_xref, - pending_structural: None, - }); - } - } - } - - // Handle @empty template if present - if let Some(empty_view_xref) = rep_op.empty_view { - let empty_slot = rep_op.slot.map(|s| s.0 + 2).unwrap_or(0); - if rep_op.empty_i18n_placeholder.is_none() { - child_views_to_process.push((empty_view_xref, None)); - } else if let Some(ref empty_placeholder) = - rep_op.empty_i18n_placeholder - { - if let Some(ref ops) = current_ops { - operations.push(OpInfo::TemplateStart { - view_xref: empty_view_xref, - slot: empty_slot, - start_name: empty_placeholder.start_name.clone(), - context_xref: ops.i18n_context_xref, - sub_template_index: ops.sub_template_index, - pending_structural: None, - has_close_name: empty_placeholder.close_name.is_some(), - }); - child_views_to_process.push((empty_view_xref, None)); - if let Some(close_name) = &empty_placeholder.close_name { - operations.push(OpInfo::TemplateEnd { - view_xref: empty_view_xref, - slot: empty_slot, - close_name: close_name.clone(), - context_xref: ops.i18n_context_xref, - pending_structural: None, - }); - } - } - } + let for_slot = op.slot.map(|s| SlotId(s.0 + 1)); + record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + op.body_view, + for_slot, + op.i18n_placeholder.as_ref(), + TemplateKind::Block, + ); + if let Some(empty_view) = op.empty_view { + let empty_slot = op.slot.map(|s| SlotId(s.0 + 2)); + record_template( + &mut operations, + current_ops.as_ref(), + &mut pending_structural, + empty_view, + empty_slot, + op.empty_i18n_placeholder.as_ref(), + TemplateKind::Block, + ); } } _ => {} @@ -483,18 +369,28 @@ fn resolve_placeholders_for_view<'a>( &allocator, ); } + OpInfo::Recurse { view_xref, pending_structural } => { + resolve_placeholders_for_view( + job, + view_xref, + i18n_contexts, + elements, + pending_structural, + ); + } OpInfo::TemplateEnd { view_xref, slot, close_name, context_xref, + sub_template_index, pending_structural, } => { let flags = I18nParamValueFlags::TEMPLATE_TAG.with(I18nParamValueFlags::CLOSE_TAG); // Record template close with proper sub-template index let template_sub_index = - get_sub_template_index_for_template_tag(job, None, view_xref); + get_sub_template_index_for_template_tag(job, sub_template_index, view_xref); let param_value = I18nParamValue::new( I18nParamValueContent::Slot(slot), template_sub_index, @@ -512,7 +408,7 @@ fn resolve_placeholders_for_view<'a>( if let Some(structural) = pending_structural { let structural_value = I18nParamValue::new( I18nParamValueContent::Slot(structural.slot.0), - None, // Use current block's sub-template index + sub_template_index, flags, ); add_param_to_context( @@ -526,16 +422,51 @@ fn resolve_placeholders_for_view<'a>( } } } +} - // Recursively process child views - for (child_view_xref, child_pending_structural) in child_views_to_process { - resolve_placeholders_for_view( - job, - child_view_xref, - i18n_contexts, - elements, - child_pending_structural, - ); +/// Records the placeholders of an op that creates a child view (`recordTemplateStart`, the +/// recursion, and `recordTemplateClose` in Angular), in that order. +fn record_template<'a>( + operations: &mut Vec>, + current_ops: Option<&CurrentI18nOps>, + pending_structural: &mut Option, + view_xref: XrefId, + slot: Option, + placeholder: Option<&I18nPlaceholder<'a>>, + template_kind: TemplateKind, +) { + let (Some(placeholder), Some(ops), Some(slot)) = (placeholder, current_ops, slot) else { + operations.push(OpInfo::Recurse { view_xref, pending_structural: None }); + return; + }; + if template_kind == TemplateKind::Structural { + // The template's element records the combined value. + operations.push(OpInfo::Recurse { + view_xref, + pending_structural: Some(PendingStructuralDirective { slot }), + }); + return; + } + let pending = pending_structural.take(); + operations.push(OpInfo::TemplateStart { + view_xref, + slot: slot.0, + start_name: placeholder.start_name, + context_xref: ops.i18n_context_xref, + sub_template_index: ops.sub_template_index, + pending_structural: pending, + has_close_name: placeholder.close_name.is_some(), + }); + operations.push(OpInfo::Recurse { view_xref, pending_structural: None }); + if let Some(close_name) = &placeholder.close_name { + operations.push(OpInfo::TemplateEnd { + view_xref, + slot: slot.0, + close_name: *close_name, + context_xref: ops.i18n_context_xref, + sub_template_index: ops.sub_template_index, + pending_structural: pending, + }); } } @@ -596,8 +527,11 @@ enum OpInfo<'a> { /// The close placeholder name (e.g., "CLOSE_BLOCK_IF"). close_name: Ident<'a>, context_xref: XrefId, + sub_template_index: Option, pending_structural: Option, }, + /// Resolve a child view, in op order. + Recurse { view_xref: XrefId, pending_structural: Option }, } /// Add a param value to an i18n context's params map. 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 a70c31055..b8aaff1bd 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -24,7 +24,7 @@ use crate::ast::r3::{ R3BoundText, R3Comment, R3Component, R3Content, R3DeferredBlock, R3Directive, R3Element, R3ForLoopBlock, R3Icu, R3IcuPlaceholder, R3IfBlock, R3IfBlockBranch, R3LetDeclaration, R3Node, R3ParseResult, R3Reference, R3SwitchBlock, R3Template, R3TemplateAttr, R3Text, R3TextAttribute, - R3Variable, SecurityContext, serialize_i18n_nodes, + R3Variable, SecurityContext, }; use crate::i18n::parser::I18nMessageFactory; use crate::i18n::placeholder::PlaceholderRegistry; @@ -173,6 +173,39 @@ pub struct HtmlToR3Transform<'a> { /// (e.g., by `ingestControlFlowInsertionPoint` and `ingestStaticAttributes`), both /// can share the same i18n context. i18n_message_instance_counter: u32, + /// The `i18n` attribute value and message instance id of an element whose only child is + /// an ICU. That ICU is the element's message rather than a sub-message, so it reuses the + /// element's message (Angular's I18nMetaVisitor: `currentMessage || meta`). + sole_icu_message: Option<(String, u32)>, + /// Placeholder names from the enclosing i18n message. + message_names: MessagePlaceholderNames, + /// Placeholder names from the message of the ICU being visited. + icu_names: IcuPlaceholderNames, + /// Full names (`:svg:svg`) of the enclosing elements, for i18n placeholder names. + element_full_names: std::vec::Vec, +} + +/// Placeholder names taken from an i18n message, keyed by source offset, so that the r3 AST +/// uses the same names as the message: Angular's placeholder registry numbers them per message. +#[derive(Default)] +struct MessagePlaceholderNames { + /// ICU placeholder names (`ICU`, `ICU_1`, ...), keyed by the ICU's start offset. + icus: FxHashMap, + /// Block start and close names (`START_BLOCK_IF`, `CLOSE_BLOCK_IF`, ...), keyed by the + /// block's start offset. + blocks: FxHashMap, + /// Tag start and close names, keyed by the element's start offset. + tags: FxHashMap, +} + +/// Placeholder names taken from the message of the ICU being visited. +#[derive(Default)] +struct IcuPlaceholderNames { + /// `VAR_*` names of the ICU and its nested ICUs, keyed by the start offset of each switch + /// expression. + vars: FxHashMap, + /// Interpolation placeholder names, keyed by their interpolation text (`{{count}}`). + interpolations: FxHashMap, } impl<'a> HtmlToR3Transform<'a> { @@ -199,6 +232,10 @@ impl<'a> HtmlToR3Transform<'a> { i18n_placeholder_registry: PlaceholderRegistry::new(), i18n_message_instance_counter: 0, angular_version: options.angular_version, + sole_icu_message: None, + message_names: MessagePlaceholderNames::default(), + icu_names: IcuPlaceholderNames::default(), + element_full_names: std::vec::Vec::new(), } } @@ -463,12 +500,14 @@ impl<'a> HtmlToR3Transform<'a> { let message = factory.create_message( &element.children, + Some(self.element_i18n_name(&resolved_name, host_tag.as_deref())), meaning, description, custom_id, None, source_file, ); + collect_placeholder_names(&message.nodes, &mut self.message_names); message.serialize() } } else { @@ -495,6 +534,12 @@ impl<'a> HtmlToR3Transform<'a> { None }; + if let (Some(attr), Some(I18nMeta::Message(message))) = (i18n_attr, &i18n_meta) + && matches!(element.children.as_slice(), [HtmlNode::Expansion(_)]) + { + self.sole_icu_message = Some((attr.value.to_string(), message.instance_id)); + } + // Increment non_bindable depth if this element has ngNonBindable if has_non_bindable { self.non_bindable_depth += 1; @@ -511,7 +556,12 @@ impl<'a> HtmlToR3Transform<'a> { } // Visit children + self.element_full_names + .push(self.element_i18n_name(&resolved_name, host_tag.as_deref()).to_string()); let children = self.visit_children(&element.children); + 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; // Decrement non_bindable depth if we incremented it if has_non_bindable { @@ -571,7 +621,8 @@ impl<'a> HtmlToR3Transform<'a> { source_span: element.span, start_source_span: element.start_span, end_source_span: element.end_span, - i18n: None, + // Inside an i18n block, is a tag placeholder like any element. + i18n: i18n_meta, }; let mut result = R3Node::Content(Box::new_in(content, &self.allocator)); @@ -810,6 +861,12 @@ impl<'a> HtmlToR3Transform<'a> { Some(result) } + /// The name Angular's i18n placeholders are built from: the resolved element name + /// (`:svg:circle`), or the host tag for a selectorless component. + fn element_i18n_name<'n>(&self, resolved_name: &'n str, host_tag: Option<&'n str>) -> &'n str { + if resolved_name.is_empty() { host_tag.unwrap_or("") } else { resolved_name } + } + /// Prefix inherited from the innermost open element (`""` at the root). fn current_prefix(&self) -> &str { self.namespace_stack.last().map_or("", String::as_str) @@ -1320,6 +1377,17 @@ impl<'a> HtmlToR3Transform<'a> { /// - Third call with "VAR_PLURAL" returns "VAR_PLURAL_2" /// /// Ported from Angular's `placeholder.ts:96-98` and `_generateUniqueName:151-161`. + /// The `VAR_*` name of the ICU whose switch expression starts at `start`, from the ICU's + /// message, or a newly generated one. + fn icu_var_name(&mut self, start: u32, base_name: &str) -> String { + match self.icu_names.vars.remove(&start) { + // Angular's visitExpansion trims the key: `{count, select , ...}` is named + // "VAR_SELECT " in the message but the var is VAR_SELECT. + Some(name) => name.trim().to_string(), + None => self.generate_unique_icu_placeholder(base_name), + } + } + fn generate_unique_icu_placeholder(&mut self, base_name: &str) -> String { let count = self.icu_placeholder_counts.entry(base_name.to_string()).or_insert(0); let result = @@ -1344,62 +1412,85 @@ impl<'a> HtmlToR3Transform<'a> { /// /// Ported from Angular's r3_template_transform.ts:301-337 fn visit_expansion(&mut self, expansion: &HtmlExpansion<'a>) -> Option> { - // Do not generate Icu if it was created outside of i18n block/element in a template - // Reference: r3_template_transform.ts:301-306 - let in_i18n_context = expansion.in_i18n_block || self.i18n_depth > 0; - if !in_i18n_context { - return None; - } + // Every ICU gets an i18n message, including ICUs outside an i18n block: Angular's + // I18nMetaVisitor visits all expansions, and the wrap_icus phase later gives such an ICU + // its own i18n block. // Reset ICU placeholder counters for this new top-level ICU. // This ensures unique placeholder names within each ICU context. // Ported from Angular's PlaceholderRegistry usage in i18n_parser.ts. self.reset_icu_placeholder_counts(); - // Create i18n metadata with proper ICU placeholder - // This matches Angular's behavior where expansion.i18n is a Message - // containing a single IcuPlaceholder node - let icu_type_upper = expansion.expansion_type.as_str().to_uppercase(); - let base_name = format!("VAR_{icu_type_upper}"); - let expression_placeholder = - Ident::from_in(&self.generate_unique_icu_placeholder(&base_name), &self.allocator); - let icu_placeholder_name = Ident::from_in("ICU", &self.allocator); - - // Create the I18nIcu for the i18n metadata - // The cases are empty here since they're parsed separately into R3Icu.placeholders - let i18n_icu = I18nIcu { - expression: expansion.switch_value, - icu_type: expansion.expansion_type, - cases: HashMap::new_in(&self.allocator), - source_span: expansion.span, - expression_placeholder: Some(expression_placeholder), + // The ICU's message is built from the expansion itself (Angular's I18nMetaVisitor: + // `_generateI18nMessage([expansion], currentMessage || meta)`). An ICU that is the only + // 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 = + std::sync::Arc::new(crate::util::ParseSourceFile::new(self.source_text, "