Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
bcad997
test(i18n): add failing tests for i18n attribute and ICU messages
ashley-hunter Sep 22, 2026
2bedf6b
fix(i18n): use the attribute value as the text of i18n-<attr> messages
ashley-hunter Sep 22, 2026
8fd4bb6
test(i18n): add failing test for tag placeholders inside an ICU
ashley-hunter Sep 22, 2026
8d30bc8
fix(i18n): emit ICU messages and their post-processing like Angular
ashley-hunter Sep 22, 2026
235ff95
test(i18n): add failing test for two ICUs in one message
ashley-hunter Sep 22, 2026
e07e350
fix(i18n): give each ICU in a message its own placeholder name
ashley-hunter Sep 22, 2026
caf5acf
test(i18n): add failing tests from Angular's i18n compliance scenarios
ashley-hunter Sep 22, 2026
c86b867
fix(i18n): keep original placeholder names in $localize messages
ashley-hunter Sep 22, 2026
f49658f
fix(i18n): only create i18n-<attr> messages for attributes with a value
ashley-hunter Sep 22, 2026
cae1fc3
fix(i18n): honour custom placeholder names for interpolations
ashley-hunter Sep 22, 2026
1e3f308
fix(i18n): keep whitespace-only text in i18n messages
ashley-hunter Sep 22, 2026
6c9bf22
fix(i18n): resolve element and block placeholders once per view, in o…
ashley-hunter Sep 22, 2026
6bd2896
fix(i18n): take block placeholder names from the i18n message
ashley-hunter Sep 22, 2026
879273d
test(i18n): add failing tests for the remaining compliance divergences
ashley-hunter Sep 22, 2026
c8ce594
fix(i18n): serialize void elements as a single placeholder
ashley-hunter Sep 22, 2026
a55229f
fix(i18n): accept whitespace in custom placeholder name comments
ashley-hunter Sep 22, 2026
0a7464d
fix(i18n): escape $localize metadata blocks in the raw strings
ashley-hunter Sep 22, 2026
7e34af2
fix(i18n): name nested tag placeholders children first
ashley-hunter Sep 22, 2026
2d0128e
fix(i18n): take nested ICU VAR names from the ICU's message
ashley-hunter Sep 22, 2026
3bba3e5
fix(i18n): trim ICU var names when spaces surround the ICU type
ashley-hunter Sep 22, 2026
e2fe06a
fix(i18n): bind interpolations in element markup inside ICUs
ashley-hunter Sep 22, 2026
0f4208f
fix(i18n): give <ng-content> inside i18n blocks its placeholder values
ashley-hunter Sep 22, 2026
0afef53
fix(i18n): keep placeholder names for text that is only an interpolation
ashley-hunter Sep 22, 2026
e33f9d0
fix(i18n): compile ICUs outside i18n blocks
ashley-hunter Sep 22, 2026
b1ae492
test(i18n): add failing test for SVG element placeholder names
ashley-hunter Sep 22, 2026
c4bbb29
fix(i18n): name element placeholders with their namespace
ashley-hunter Sep 22, 2026
80c3b52
test(i18n): add failing test for whitespace around a root-level ICU
ashley-hunter Sep 22, 2026
cb559f2
fix(i18n): drop whitespace around ICUs at the template root
ashley-hunter Sep 22, 2026
56480b4
test(i18n): add failing test for &ngsp; in i18n messages
ashley-hunter Sep 22, 2026
94856e1
fix(i18n): build text messages from tokens like Angular
ashley-hunter Sep 22, 2026
e3c6842
refactor(i18n): address clippy lints in the i18n fixes
ashley-hunter Sep 22, 2026
98a4f77
docs: note that legacy i18n message IDs are not supported
ashley-hunter Sep 22, 2026
b444737
fix(compare): compare static definitions emitted as class fields
ashley-hunter Sep 22, 2026
99e9c9a
test(i18n): add failing tests found by the compare tool's i18n fixtures
ashley-hunter Sep 22, 2026
b793154
fix(i18n): use the bindings marker for i18n attributes on ng-template
ashley-hunter Sep 22, 2026
789ce1b
fix: advance to i18nAttributes and conditional branch slots
ashley-hunter Sep 22, 2026
3aa7275
fix(i18n): name ICU sub-messages on their $localize placeholders
ashley-hunter Sep 22, 2026
34cd87e
fix(compare): keep whitespace in static fields for the semantic check
ashley-hunter Sep 22, 2026
7097dac
test(compare): add i18n fixtures from Angular's own i18n tests
ashley-hunter Sep 22, 2026
685ef8c
docs(compare): document known differences and the i18n fixture generator
ashley-hunter Sep 22, 2026
d62ba1e
refactor(i18n): address clippy lints in the i18n fixes
ashley-hunter Sep 22, 2026
8d99ca4
refactor(i18n): tidy the placeholder-name plumbing
ashley-hunter Sep 22, 2026
bb98372
test(i18n): add failing test for literal placeholder-like text
ashley-hunter Sep 22, 2026
44b6a59
fix(i18n): keep text that looks like a placeholder marker
ashley-hunter Sep 22, 2026
30aa853
fix(i18n): clear the sole-ICU message after visiting an element's chi…
ashley-hunter Sep 22, 2026
b69f7e9
refactor(compare): type the static field comparison like the rest of …
ashley-hunter Sep 22, 2026
7084fad
test(compare): regenerate i18n fixtures from the Angular submodule
ashley-hunter Sep 22, 2026
6852ba0
perf(i18n): avoid allocating a full element name per element
ashley-hunter Sep 22, 2026
6a1c66c
test(compare): scope known differences to the fields they document
ashley-hunter Sep 22, 2026
2efc1a3
test(compare): stop recording an i18n fixture flag nothing reads
ashley-hunter Sep 22, 2026
af148cb
docs: correct the option names in the README examples
ashley-hunter Sep 22, 2026
804766d
Merge remote-tracking branch 'upstream/main' into t3code/fix-angular-…
ashley-hunter Sep 22, 2026
498fc37
test(compare): refresh the known differences for Angular 22.1.7
ashley-hunter Sep 22, 2026
b5b7e6e
test(compare): drop a known-difference reason with no remaining users
ashley-hunter Sep 22, 2026
d2d6f58
chore: drop the handoff note that was committed by mistake
ashley-hunter Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
99 changes: 5 additions & 94 deletions crates/oxc_angular_compiler/src/ast/r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
99 changes: 82 additions & 17 deletions crates/oxc_angular_compiler/src/i18n/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> =
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::<Vec<_>>().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(
Expand All @@ -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<String> = 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(" "))
}
Expand All @@ -459,29 +525,29 @@ 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::<Vec<_>>().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::<String>();
format!("{}{children}{}", self.format_ph(&ph.start_name), self.format_ph(&ph.close_name))
}

fn visit_placeholder(
&mut self,
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(
&mut self,
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(
Expand All @@ -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::<Vec<_>>().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::<String>();
format!("{}{children}{}", self.format_ph(&ph.start_name), self.format_ph(&ph.close_name))
}
}

Expand Down Expand Up @@ -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}!");
}
}
Loading
Loading