Skip to content
Merged
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
57 changes: 50 additions & 7 deletions crates/oxc_angular_compiler/src/parser/html/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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: `<area>` in `<svg>` 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 (`<MyComp:foreignObject>`, `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 (`<area>` in `<svg>` 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<Span>) -> Option<HtmlNode<'a>> {
// Find the topmost block in the stack
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)]
Expand Down
3 changes: 2 additions & 1 deletion crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = element
Expand Down
111 changes: 111 additions & 0 deletions crates/oxc_angular_compiler/tests/html_parser_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<area>` inside `<svg>` 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 [
"<svg><area></area></svg>",
"<svg><br></br></svg>",
"<svg><g><area></area></g></svg>",
"<math><br></br></math>",
"<svg>@if (x) {<area></area>}</svg>",
"<svg:area></svg:area>",
"<svg:g><area></area></svg:g>",
] {
assert_eq!(parse_errors(html), Vec::<String>::new(), "{html}");
}
}

#[test]
fn keeps_the_children_of_an_html_void_name_in_a_foreign_namespace() {
let (nodes, errors) = parse_with_errors("<svg><area>t</area></svg>");
assert_eq!(errors, Vec::<String>::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 [
(
"<svg><area><g></g></area></svg>",
vec![element("svg", 0), element("area", 1), element("g", 2)],
),
(
"<math><br><mi>x</mi></br></math>",
vec![element("math", 0), element("br", 1), element("mi", 2), text("x", 3)],
),
(
"<svg><br><br></br></br></svg>",
vec![element("svg", 0), element("br", 1), element("br", 2)],
),
] {
let (nodes, errors) = parse_with_errors(html);
assert_eq!(errors, Vec::<String>::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: `</svg>` skips an open element, as upstream.
let (nodes, errors) = parse_with_errors("<svg><img><circle/></svg>");
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<String> {
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 [
"<svg><MyComp:foreignObject><input></MyComp:foreignObject></svg>",
"<svg><MyComp:foreignObject><div><input></div></MyComp:foreignObject></svg>",
"<svg><MyComp><area></area></MyComp></svg>",
"<MyComp:svg:g><area></area></MyComp:svg:g>",
"<svg><MyComp:g><br><g></g></br></MyComp:g></svg>",
] {
assert_eq!(selectorless_errors(html), Vec::<String>::new(), "{html}");
}
assert_eq!(
selectorless_errors("<MyComp><area></area></MyComp>"),
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("<svg><foreignObject><area></area></foreignObject></svg>"),
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("<area></area>"),
vec!["Void elements do not have end tags \"area\"".to_string()]
);
}
}
11 changes: 11 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<svg><g i18n>a<area>b</area>c</g></svg>"#);
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�",
Expand Down
Loading