Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 29 additions & 9 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ pub struct TransformOptions {
/// `Full` (default) targets applications; `Partial` targets library
/// builds, where consumers run the linker to expand declarations.
pub compilation_mode: crate::CompilationMode,

/// Angular's `enableI18nLegacyMessageIdFormat` compiler option: write each i18n
/// message's legacy ids in its `$localize` metadata
/// (`:@@custom␟<sha1>␟<decimal>:text`), for translation files keyed by legacy ids.
///
/// Default: `false`, as in the `tsconfig.json` that `ng new` generates. ngtsc itself
/// defaults to `true` when the option is not set.
pub enable_i18n_legacy_message_id_format: bool,
}

/// Input for host metadata when passed via TransformOptions.
Expand Down Expand Up @@ -281,6 +289,7 @@ impl Default for TransformOptions {
emit_class_metadata: true,
minify_component_styles: false,
compilation_mode: crate::CompilationMode::Full,
enable_i18n_legacy_message_id_format: false,
}
}
}
Expand Down Expand Up @@ -5419,12 +5428,13 @@ fn compile_component_full<'a>(

// Stage 1.5: Remove whitespace if not preserving
// This must happen before R3 transform, matching Angular's pipeline
let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes);
let nodes = if parse_options.preserve_whitespaces {
&html_result.nodes
untrimmed
} else {
// Apply whitespace removal with preserveSignificantWhitespace=true
// (Angular uses true for template compilation, false for message extraction)
let processed = remove_whitespaces(allocator, &html_result.nodes, true);
let processed = remove_whitespaces(allocator, untrimmed, true);
// We need to leak this to get a reference with the right lifetime
// since we're in a function that returns Result
allocator.alloc(processed) as &_
Expand All @@ -5434,8 +5444,10 @@ fn compile_component_full<'a>(
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options)
.with_untrimmed_nodes(untrimmed);
let r3_result = transformer.transform(nodes);

if !r3_result.errors.is_empty() {
Expand Down Expand Up @@ -6088,6 +6100,7 @@ pub fn compile_component_template<'a>(
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: None,
..R3TransformOptions::default()
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);
Expand Down Expand Up @@ -6178,19 +6191,22 @@ pub fn compile_template_to_js_with_options<'a>(
}

// Stage 1.5: Remove whitespace if not preserving (default behavior)
let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes);
let nodes = if parse_options.preserve_whitespaces {
&html_result.nodes
untrimmed
} else {
let processed = remove_whitespaces(allocator, &html_result.nodes, true);
let processed = remove_whitespaces(allocator, untrimmed, true);
allocator.alloc(processed) as &_
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options)
.with_untrimmed_nodes(untrimmed);
let r3_result = transformer.transform(nodes);

if !r3_result.errors.is_empty() {
Expand Down Expand Up @@ -6361,19 +6377,22 @@ pub fn compile_template_for_hmr<'a>(
}

// Stage 1.5: Remove whitespace if not preserving
let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes);
let nodes = if parse_options.preserve_whitespaces {
&html_result.nodes
untrimmed
} else {
let processed = remove_whitespaces(allocator, &html_result.nodes, true);
let processed = remove_whitespaces(allocator, untrimmed, true);
allocator.alloc(processed) as &_
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
enable_i18n_legacy_message_id_format: options.enable_i18n_legacy_message_id_format,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options)
.with_untrimmed_nodes(untrimmed);
let r3_result = transformer.transform(nodes);

if !r3_result.errors.is_empty() {
Expand Down Expand Up @@ -7108,6 +7127,7 @@ pub fn compile_template_for_linker<'a>(
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version,
..R3TransformOptions::default()
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);
Expand Down
32 changes: 10 additions & 22 deletions crates/oxc_angular_compiler/src/i18n/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,28 +488,16 @@ impl I18nMessageFactory {
// Build ICU cases (ordered for consistent serialization)
let mut cases: IndexMap<String, Node> = IndexMap::default();
for case in &expansion.cases {
let mut case_nodes = self.visit_all(&case.expansion, context, visit_fn);
let case_node = if case_nodes.len() == 1 {
case_nodes.pop().unwrap_or_else(|| {
let case_span = ParseSourceSpan::from_offsets(
&context.source_file,
case.expansion_span.start,
case.expansion_span.end,
None,
None,
);
Node::Container(Container::new(Vec::new(), case_span))
})
} else {
let case_span = ParseSourceSpan::from_offsets(
&context.source_file,
case.expansion_span.start,
case.expansion_span.end,
None,
None,
);
Node::Container(Container::new(case_nodes, case_span))
};
// Angular wraps every case in a Container, which digests serialize as `[...]`.
let case_nodes = self.visit_all(&case.expansion, context, visit_fn);
let case_span = ParseSourceSpan::from_offsets(
&context.source_file,
case.expansion_span.start,
case.expansion_span.end,
None,
None,
);
let case_node = Node::Container(Container::new(case_nodes, case_span));
cases.insert(case.value.to_string(), case_node);
}

Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_angular_compiler/src/ir/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,8 @@ pub struct I18nMessageOp<'a> {
pub message_string: Option<Ident<'a>>,
/// For an ICU sub-message, its `$localize` id, written on the parent's ICU placeholder.
pub associated_message_id: Option<Ident<'a>>,
/// Legacy message ids, written in the `$localize` metadata block.
pub legacy_ids: Vec<'a, Ident<'a>>,
/// Whether message needs postprocessing (has params with multiple values).
pub needs_postprocessing: bool,
/// Sub-messages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ pub fn extract_i18n_messages(job: &mut ComponentCompilationJob<'_>) {
message_string: metadata.and_then(|m| m.message_string.clone()),
associated_message_id: metadata
.and_then(|m| m.associated_message_id),
legacy_ids: oxc_allocator::Vec::from_iter_in(
metadata.into_iter().flat_map(|m| m.legacy_ids.iter().copied()),
&allocator,
),
needs_postprocessing,
sub_messages: oxc_allocator::Vec::new_in(&allocator),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) {
.associated_message_id
.as_ref()
.map(ToString::to_string),
legacy_ids: msg_op.legacy_ids.iter().map(ToString::to_string).collect(),
needs_postprocessing: msg_op.needs_postprocessing,
sub_messages: msg_op.sub_messages.iter().copied().collect(),
},
Expand Down Expand Up @@ -451,6 +452,7 @@ struct MessageInfo {
message_id: Option<String>,
message_string: Option<String>,
associated_message_id: Option<String>,
legacy_ids: Vec<String>,
needs_postprocessing: bool,
sub_messages: Vec<XrefId>,
}
Expand Down Expand Up @@ -568,6 +570,7 @@ fn collect_message<'a>(
msg_info.description.clone(),
msg_info.meaning.clone(),
msg_info.custom_id.clone(),
&msg_info.legacy_ids,
&associated_message_ids,
);

Expand Down Expand Up @@ -699,6 +702,7 @@ fn create_localize_expression<'a>(
description: Option<String>,
meaning: Option<String>,
custom_id: Option<String>,
legacy_ids: &[String],
associated_message_ids: &FxHashMap<String, String>,
) -> OutputExpression<'a> {
// Parse message_string to extract text parts and placeholder names in order
Expand All @@ -718,9 +722,9 @@ fn create_localize_expression<'a>(
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();

// First message part: includes metadata block + first text segment
// Format: ":meaning|description@@customId:text"
// Format: ":meaning|description@@customId␟legacyId:text"
let first_text = text_parts.first().map(|s| s.as_str()).unwrap_or("");
push_part(serialize_i18n_head(first_text, &meaning, &description, &custom_id));
push_part(serialize_i18n_head(first_text, &meaning, &description, &custom_id, legacy_ids));

// Subsequent parts: ":PLACEHOLDER_NAME:text"
for (i, placeholder) in placeholder_order.iter().enumerate() {
Expand All @@ -739,7 +743,7 @@ fn create_localize_expression<'a>(
// Text part after this placeholder
let text_part = text_parts.get(i + 1).map(|s| s.as_str()).unwrap_or("");
// Angular's serializeI18nTemplatePart names an ICU placeholder's sub-message when the
// sub-message has no legacy ids (always, in Oxc): `:ICU@@<id>:`.
// sub-message has no legacy ids: `:ICU@@<id>:`.
let meta_block = match associated_message_ids.get(placeholder) {
Some(id) => format!("{formatted_name}@@{id}"),
None => formatted_name.clone(),
Expand Down Expand Up @@ -825,12 +829,13 @@ fn parse_message_string(message: &str) -> (Vec<String>, Vec<String>) {
}

/// Serialize the i18n head (first message part) with its metadata block
/// (`meaning|description@@customId`), as Angular's `serializeI18nHead`.
/// (`meaning|description@@customId␟legacyId…`), as Angular's `serializeI18nHead`.
fn serialize_i18n_head(
text: &str,
meaning: &Option<String>,
description: &Option<String>,
custom_id: &Option<String>,
legacy_ids: &[String],
) -> (String, String) {
let mut meta_block = description.clone().unwrap_or_default();
if let Some(meaning) = meaning.as_deref().filter(|m| !m.is_empty()) {
Expand All @@ -839,6 +844,9 @@ fn serialize_i18n_head(
if let Some(id) = custom_id.as_deref().filter(|id| !id.is_empty()) {
meta_block = format!("{meta_block}@@{id}");
}
for legacy_id in legacy_ids {
meta_block = format!("{meta_block}\u{241F}{legacy_id}");
}
create_cooked_raw_string(&meta_block, text)
}

Expand Down
Loading