diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index af5e02fa4..cdd0e3683 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -21,7 +21,7 @@ use crate::util::{ParseError, ParseLocation, ParseSourceFile, ParseSourceSpan}; use super::entities::decode_entities_in_string; use super::lexer::{HtmlLexer, HtmlToken, HtmlTokenType}; -use super::tags::{get_html_tag_definition, is_void_element}; +use super::tags::{get_html_tag_definition, is_void_element, split_ns_name}; /// Result of parsing an HTML template. pub struct HtmlParseResult<'a> { @@ -298,6 +298,44 @@ impl<'a> HtmlParser<'a> { idx } + /// Whether a tag without a prefix inside the open elements takes a namespace from them, as + /// Angular's `_getElementFullName` names it: `` in `` is `:svg:area`. The closest + /// element (blocks are skipped) passes down its own prefix or implicit one, or the one it took + /// from its own parent, unless it stops inheritance (`foreignObject`). A selectorless component + /// passes down its host tag (``, `ng-component` without one), as + /// `_getPrefix` reads a component's `tagName`. + fn inherits_namespace(&self) -> bool { + let elements = self.container_stack.iter().rev().filter_map(|container| match container { + ContainerIndex::Element(idx) => Some(&self.elements[*idx]), + ContainerIndex::Block(_) => None, + }); + for element in elements { + let (prefix, local_name) = if element.is_component { + ( + element.component_prefix.as_ref().map(|prefix| prefix.as_str()), + element.component_tag_name.as_ref().map_or("ng-component", |tag| tag.as_str()), + ) + } else { + split_ns_name(element.name.as_str()) + }; + let tag_def = get_html_tag_definition(local_name); + if tag_def.prevent_namespace_inheritance { + return false; + } + if prefix.is_some() || tag_def.implicit_namespace_prefix.is_some() { + return true; + } + } + false + } + + /// Whether a void name is an HTML void element here: not in a namespace, by its own prefix or + /// one it inherits (`` in `` is `:svg:area`, which has children and an end tag). + /// Void names have no implicit namespace. + fn is_html_void(&self, local_name: &str, has_prefix: bool) -> bool { + get_html_tag_definition(local_name).is_void && !has_prefix && !self.inherits_namespace() + } + /// Pops a block from the container stack and returns it as an HtmlNode. fn pop_block_container(&mut self, end_span: Option) -> Option> { // Find the topmost block in the stack @@ -375,8 +413,11 @@ impl<'a> HtmlParser<'a> { // be implicitly closed silently. Anything else means this end tag is // jumping past a still-open element, which is what Angular's reference // parser flags via `unexpectedCloseTagDetected`. + // An open element with a void name is in a namespace (an HTML void element + // is never pushed), so Angular gives it the default definition. let elem_name = self.elements[idx].name.as_str().to_lowercase(); - if !get_html_tag_definition(&elem_name).closed_by_parent { + let tag_def = get_html_tag_definition(&elem_name); + if !tag_def.closed_by_parent || tag_def.is_void { unexpected_close_detected = true; } // Get the element without end_span (implicitly closed) @@ -678,7 +719,7 @@ impl<'a> HtmlParser<'a> { // For self-closing elements, end_span equals start_span // For void elements or regular elements, end_span is set later or stays None let end_span = if is_self_closing { Some(start_span) } else { None }; - let is_void = is_void_element(&tag_name); + let is_void = self.is_html_void(&local_name, has_ns_prefix); let element = HtmlElement { name: Ident::from_in(tag_name.clone(), &self.allocator), @@ -727,9 +768,9 @@ impl<'a> HtmlParser<'a> { }; let end_span = self.make_span(start, end); - // Check if this is a void element - void elements don't have end tags - let tag_def = get_html_tag_definition(&local_name); - if tag_def.is_void { + // Void elements don't have end tags. The open elements pass their namespace down, as to + // the start tag. + if self.is_html_void(&local_name, tag_name.starts_with(':')) { let err = self.make_error( start, format!("Void elements do not have end tags \"{}\"", local_name), @@ -1837,9 +1878,11 @@ impl<'a> HtmlParser<'a> { /// Checks if the current element should be auto-closed when a new element is opened. /// Uses the tag definitions from tags.rs to match Angular's behavior exactly. +/// An open element with a void name is in a namespace (an HTML void element is never pushed): +/// `:svg:area` has the default definition, which no child closes. fn should_auto_close(current_tag: &str, new_tag: &str) -> bool { let tag_def = get_html_tag_definition(current_tag); - tag_def.is_closed_by_child(new_tag) + !tag_def.is_void && tag_def.is_closed_by_child(new_tag) } #[cfg(test)] 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 007c8e063..213000a34 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -4618,7 +4618,8 @@ impl<'a> HtmlToR3Transform<'a> { use indexmap::IndexMap; let tag_name = element.name.as_str(); - let is_void = crate::parser::html::is_void_element(tag_name); + // The parser's value: `:svg:area` is no void element (i18n_parser.ts reads the full name). + let is_void = element.is_void; // Collect attributes for placeholder name generation let mut attrs: IndexMap = element diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index 2f1e64c69..65663ae87 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -3138,3 +3138,114 @@ mod visitor_tests { assert!(result.iter().filter(|n| n.node_type() == Some("Attribute")).count() == 2); } } + +// ============================================================================ +// Void and self-closing elements in a foreign namespace +// ============================================================================ +// +// Angular names an element without a prefix in its parent's namespace (`_getElementFullName`), so +// `` inside `` is `:svg:area`, which is no void element. `foreignObject` stops the +// inheritance. Each case matches ngc 22.2. + +mod foreign_void_elements { + use super::*; + + #[test] + fn takes_an_end_tag_for_an_html_void_name_in_a_foreign_namespace() { + for html in [ + "", + "

", + "", + "

", + "@if (x) {}", + "", + "", + ] { + assert_eq!(parse_errors(html), Vec::::new(), "{html}"); + } + } + + #[test] + fn keeps_the_children_of_an_html_void_name_in_a_foreign_namespace() { + let (nodes, errors) = parse_with_errors("t"); + assert_eq!(errors, Vec::::new()); + assert!(nodes.contains(&element("area", 1)), "{nodes:?}"); + assert!(nodes.contains(&text("t", 2)), "{nodes:?}"); + } + + #[test] + fn keeps_the_element_children_of_an_html_void_name_in_a_foreign_namespace() { + for (html, expected) in [ + ( + "", + vec![element("svg", 0), element("area", 1), element("g", 2)], + ), + ( + "
x
", + vec![element("math", 0), element("br", 1), element("mi", 2), text("x", 3)], + ), + ( + "



", + vec![element("svg", 0), element("br", 1), element("br", 2)], + ), + ] { + let (nodes, errors) = parse_with_errors(html); + assert_eq!(errors, Vec::::new(), "{html}"); + assert_eq!(nodes, expected, "{html}"); + } + } + + #[test] + fn reports_an_html_void_name_left_open_in_a_foreign_namespace() { + // `:svg:img` needs an end tag: `` skips an open element, as upstream. + let (nodes, errors) = parse_with_errors(""); + assert_eq!(nodes, vec![element("svg", 0), element("img", 1), element("circle", 2)]); + assert_eq!( + errors, + vec![ + "Unexpected closing tag \"svg\". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags".to_string() + ] + ); + } + + /// The errors of a template in selectorless mode. + fn selectorless_errors(html: &str) -> Vec { + let allocator = Allocator::default(); + let result = HtmlParser::with_selectorless(&allocator, html, "TestComp").parse(); + result.errors.iter().map(|e| e.msg.clone()).collect() + } + + #[test] + fn reads_the_host_tag_of_a_selectorless_component() { + // A component passes down its host tag's namespace, `ng-component` without a tag. + for html in [ + "", + "
", + "", + "", + "

", + ] { + assert_eq!(selectorless_errors(html), Vec::::new(), "{html}"); + } + assert_eq!( + selectorless_errors(""), + vec!["Void elements do not have end tags \"area\"".to_string()] + ); + } + + #[test] + fn reads_html_void_elements_again_inside_foreign_object() { + assert_eq!( + parse_errors(""), + vec!["Void elements do not have end tags \"area\"".to_string()] + ); + } + + #[test] + fn still_reports_an_end_tag_for_an_html_void_element() { + assert_eq!( + parse_errors(""), + vec!["Void elements do not have end tags \"area\"".to_string()] + ); + } +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 87fda0f68..eaa9abb13 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -6373,6 +6373,17 @@ fn test_i18n_structural_directive_element_placeholders() { assert!(!js.contains("i18nPostprocess"), "No post-processing expected:\n{js}"); } +/// An HTML void name in svg is no void element, so it gets a start and a close placeholder. +/// Angular 22.2: `a{$START_TAG__SVG_AREA}b{$CLOSE_TAG__SVG_AREA}c`. +#[test] +fn test_i18n_void_name_in_svg_has_a_close_placeholder() { + let js = compile_i18n_component(r#"abc"#); + assert_contains( + &js, + "a${\"\u{FFFD}#3\u{FFFD}\"}:START_TAG__SVG_AREA:b${\"\u{FFFD}/#3\u{FFFD}\"}:CLOSE_TAG__SVG_AREA:c`", + ); +} + /// `@if`/`@else` inside an i18n block: each block gets its own start/close placeholder values. /// Angular 22.1.5: /// { "closeBlockElse": "�/*3:2�", "closeBlockIf": "�/*2:1�", "closeTagSpan": "�/#1:1�",