From 81a81b2a2efe57762dc18f2dd1cbb25b5ac02198 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 9 Oct 2026 00:15:35 +0100 Subject: [PATCH 1/2] fix(parser): keep capitalised element names as elements A template containing an element whose name starts with a capital letter compiled to an empty template with no error: @Component({ selector: 'x', template: 'hi' }) gave `errors: []` and `decls: 0`. Some such templates never finished compiling at all. Angular's parser is case-sensitive for element names and keeps them as written; a tag definition is looked up by the lowercased name only to learn how a known HTML element behaves. Four places here disagreed. - html_to_r3.rs: an element whose name began with a capital or `_` was turned into a selectorless component node, whatever the parse mode. Only the selectorless lexer mode produces those, the compile pipeline never enables it, and ingest discards them, as Angular's does, so the element and its whole subtree vanished. The element's own `is_component` flag, set by the lexer, now decides. - parser.rs: an open element with an optional end tag (`
  • `, `

    `, ``) was found to need closing by its lowercased name, then looked up on the stack by that lowercased name, never found, and never popped: `

    ` looped forever. It is now looked up by the name it was opened with. - lexer.rs: the closing tag of a raw-text element was emitted with a lowercased name, so `` closes the element named "STYLE". fn scan_raw_text_with_tag_close(&mut self, tag_name: &str, consume_entities: bool) { let token_type = if consume_entities { HtmlTokenType::EscapableRawText } else { HtmlTokenType::RawText }; @@ -1783,12 +1792,12 @@ impl<'a> HtmlLexer<'a> { { self.advance(); } - let close_tag_name = - self.input[close_tag_start as usize..self.index as usize].to_lowercase(); + let close_tag_name = &self.input[close_tag_start as usize..self.index as usize]; + let closes_tag = close_tag_name.eq_ignore_ascii_case(tag_name); self.skip_whitespace(); - if close_tag_name == tag_name && self.peek() == '>' { + if closes_tag && self.peek() == '>' { // Found the closing tag - emit any accumulated content if consume_entities { // For escapable raw text, Angular ALWAYS emits a text token, even if empty. @@ -3076,8 +3085,13 @@ impl<'a> HtmlLexer<'a> { // Handle `<` - only stop if it's a valid tag start if ch == '<' { let next = self.peek_at(1); - // Valid tag start: `/` (close tag), `!` (comment/doctype/cdata), or letter/underscore - if next == '/' || next == '!' || next.is_ascii_alphabetic() || next == '_' { + // Valid tag start: `/` (close tag), `!` (comment/doctype/cdata), or a letter. + // An underscore starts a name only for a selectorless component. + if next == '/' + || next == '!' + || next.is_ascii_alphabetic() + || (next == '_' && self.selectorless_enabled) + { break; } // Otherwise, `<` is just text, continue diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index bc09e3950..724c95f06 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -446,9 +446,11 @@ impl<'a> HtmlParser<'a> { fn auto_close_element_if_needed(&mut self, new_tag: &str) { // Keep closing elements while the current one should be auto-closed loop { + // The name as written: tag definitions are looked up case-insensitively, but + // the element is found again by its own name, which keeps its case (`
  • `). let current_tag = if let Some(&container_idx) = self.container_stack.last() { if let ContainerIndex::Element(elem_idx) = container_idx { - self.elements[elem_idx].name.as_str().to_lowercase() + self.elements[elem_idx].name.to_string() } else { break; } diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 10d95cc2b..836d831c6 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -851,8 +851,21 @@ fn ingest_node<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, nod R3Node::IfBlockBranch(_) => { // If branches are handled by the parent if block } - R3Node::Component(_) | R3Node::Directive(_) | R3Node::HostElement(_) => { - // Components, directives, and host elements are resolved during binding/type checking + R3Node::Component(component) => { + // Only a selectorless parse produces these, and the pipeline cannot compile + // them. Angular's ingest skips them without a word; reporting the node keeps + // a template from compiling to less than it contains. + job.diagnostics.push( + OxcDiagnostic::error(format!( + "Selectorless component <{}> cannot be compiled: selectorless components \ + are not supported.", + component.component_name + )) + .with_label(component.source_span), + ); + } + R3Node::Directive(_) | R3Node::HostElement(_) => { + // Directives and host elements are resolved during binding/type checking } } } 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 7db5076ed..5468f0a10 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -434,7 +434,9 @@ impl<'a> HtmlToR3Transform<'a> { } return None; } - if raw_name == "link" { + // Like `script` and `style` above, the preparser lowercases the name before + // it looks for `link` and `ng-content` (`preparseElement`). + if raw_name.eq_ignore_ascii_case("link") { // Collect stylesheet URLs if let Some(href) = self.get_stylesheet_href(element) { self.style_urls.push(href); @@ -588,7 +590,7 @@ impl<'a> HtmlToR3Transform<'a> { // Check for ng-content // Reference: r3_template_transform.ts lines 191-204 // Children are passed directly without extra whitespace filtering - if raw_name == "ng-content" { + if raw_name.eq_ignore_ascii_case("ng-content") { let selector = self.get_ng_content_selector(element); self.ng_content_selectors.push(selector); @@ -684,24 +686,10 @@ impl<'a> HtmlToR3Transform<'a> { let name = Ident::from_in(resolved_name.as_str(), &self.allocator); - // Check if this is a component (uppercase first letter or underscore) - let first_char = raw_name.chars().next().unwrap_or('a'); - let is_component = first_char.is_ascii_uppercase() || first_char == '_'; - - let mut result = if is_component { - // Parsed components already checked the host tag. An uppercase element - // from a non-selectorless parse (``) still uses the element name. - if !element.is_component { - let tag_name_lower = raw_name.to_ascii_lowercase(); - if UNSUPPORTED_SELECTORLESS_TAGS.contains(&tag_name_lower.as_str()) { - self.report_error( - &format!("Tag name \"{raw_name}\" cannot be used as a component tag"), - element.start_span, - ); - return None; - } - } - + // Only the lexer decides that a tag is a selectorless component, and only when + // selectorless parsing is enabled. An element name is otherwise kept as written, + // whatever its case: `` is an element named "View". + let mut result = if element.is_component { // Validate selectorless references self.validate_selectorless_references(&references); diff --git a/crates/oxc_angular_compiler/tests/element_name_case_test.rs b/crates/oxc_angular_compiler/tests/element_name_case_test.rs new file mode 100644 index 000000000..3f2a473ea --- /dev/null +++ b/crates/oxc_angular_compiler/tests/element_name_case_test.rs @@ -0,0 +1,264 @@ +//! Element names are case-sensitive and kept as written. `` is an element named +//! "View": whether a component matches it is decided later, by selector. +//! +//! An element whose name starts with a capital letter used to be taken for a +//! selectorless component, a node kind that only the selectorless lexer mode produces +//! and that ingest discards, so `hi` compiled to an empty +//! template with no error. +//! +//! The expected output is ngtsc 22.2.1's for the same template, in this emitter's +//! spelling. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + TransformOptions, + output::emitter::JsEmitter, + parser::html::HtmlParser, + pipeline::{emit::compile_template, ingest::ingest_component}, + transform::html_to_r3::{HtmlToR3Transform, TransformOptions as HtmlTransformOptions}, + transform_angular_file, +}; +use oxc_str::Ident; + +/// The whitespace-free definition of the component compiled from `template`. +fn definition(template: &str) -> String { + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'x', template: `{template}` }})\n\ + export class X {{ t = ''; on = true; items: any[] = []; go(..._a: any[]) {{}} }}\n" + ); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "/x/a.ts", + &source, + Some(&TransformOptions::default()), + None, + ); + assert!(result.diagnostics.is_empty(), "`{template}`: {:?}", result.diagnostics); + let compact: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + let start = compact.find("i0.ɵɵdefineComponent(").expect("definition"); + let end = compact[start..].find("(()=>{").map_or(compact.len(), |i| start + i); + // Embedded view functions are declared before the class. + let declarations = compact[..start].find("functionX_").map_or("", |i| &compact[i..start]); + let declarations = declarations.split("exportclassX{").next().unwrap_or_default(); + format!("{declarations}{}", &compact[start..end]) +} + +#[track_caller] +fn assert_compiles_to(template: &str, expected: &[&str]) { + let definition = definition(template); + for part in expected { + assert!(definition.contains(part), "`{template}` should contain `{part}`:\n{definition}"); + } +} + +/// The reported template. +#[test] +fn capitalised_root_element_with_a_lowercase_child() { + assert_compiles_to( + "hi", + &[ + "decls:3,vars:0,", + r#"i0.ɵɵelementStart(0,"View")(1,"text");i0.ɵɵtext(2,"hi");i0.ɵɵelementEnd()();"#, + ], + ); +} + +#[test] +fn capitalised_element_nested_in_a_lowercase_one() { + assert_compiles_to( + "
    ab
    ", + &[ + "decls:6,vars:0,", + r#"i0.ɵɵelementStart(0,"div")(1,"View")(2,"span");i0.ɵɵtext(3,"a");i0.ɵɵelementEnd();i0.ɵɵelementStart(4,"Text");i0.ɵɵtext(5,"b");"#, + ], + ); +} + +#[test] +fn mixed_case_names_are_kept_as_written() { + assert_compiles_to( + "abc", + &[ + "decls:6,vars:0,", + r#"i0.ɵɵelementStart(0,"myWidget");"#, + r#"i0.ɵɵelementStart(2,"My-Widget");"#, + r#"i0.ɵɵelementStart(4,"X");"#, + ], + ); +} + +#[test] +fn capitalised_element_with_attributes_bindings_listeners_and_a_reference() { + assert_compiles_to( + r#"{{ t }}"#, + &[ + "decls:3,vars:4,", + r#"consts:[["ref",""],["id","b",1,"a",3,"press","title"]]"#, + r#"i0.ɵɵelementStart(0,"View",1,0);"#, + r#"i0.ɵɵlistener("press",functionX_Template_View_press_0_listener(){"#, + r#"i0.ɵɵclassProp("x",ctx.on);i0.ɵɵproperty("title",ctx.t);"#, + ], + ); +} + +#[test] +fn self_closing_capitalised_elements() { + assert_compiles_to( + r#"
    "#, + &[ + "decls:4,vars:1,", + r#"i0.ɵɵelement(0,"View")(1,"Row",0);i0.ɵɵelementStart(2,"div");i0.ɵɵelement(3,"Cell");i0.ɵɵelementEnd();"#, + ], + ); +} + +#[test] +fn capitalised_elements_inside_control_flow_and_templates() { + assert_compiles_to( + "@if (on) { a } @for (i of items; track i) { {{ i }} } \ + cd", + &[ + r#"functionX_Conditional_0_Template(rf,ctx){if((rf&1)){i0.ɵɵelementStart(0,"View");i0.ɵɵtext(1,"a");i0.ɵɵelementEnd();}}"#, + r#"functionX_For_2_Template(rf,ctx){if((rf&1)){i0.ɵɵelementStart(0,"Row");i0.ɵɵtext(1);i0.ɵɵelementEnd();}"#, + r#"functionX_ng_template_3_Template(rf,ctx){if((rf&1)){i0.ɵɵelementStart(0,"Cell");i0.ɵɵtext(1,"c");i0.ɵɵelementEnd();}}"#, + r#"functionX_Star_4_Template(rf,ctx){if((rf&1)){i0.ɵɵelementStart(0,"Star");i0.ɵɵtext(1,"d");i0.ɵɵelementEnd();}}"#, + r#"i0.ɵɵconditionalCreate(0,X_Conditional_0_Template,2,0,"View");"#, + r#"i0.ɵɵrepeaterCreate(1,X_For_2_Template,2,1,"Row",null,i0.ɵɵrepeaterTrackByIdentity);"#, + r#"i0.ɵɵtemplate(3,X_ng_template_3_Template,2,0,"ng-template")(4,X_Star_4_Template,2,0,"Star",0);"#, + ], + ); +} + +/// A known HTML element keeps its spelling but still behaves like the element it names: +/// void, optional end tag, security context. +#[test] +fn html_elements_written_in_capitals() { + assert_compiles_to( + r#"
    a
    "#, + &[r#"i0.ɵɵelementStart(0,"DIV",0);"#, r#"i0.ɵɵelementStart(2,"BUTTON",1);"#], + ); + assert_compiles_to( + "
    • d
    • e

    p

    q", + &[ + "decls:9,vars:0,", + r#"i0.ɵɵelementStart(0,"UL")(1,"LI");i0.ɵɵtext(2,"d");i0.ɵɵelementEnd();i0.ɵɵelementStart(3,"LI");i0.ɵɵtext(4,"e");i0.ɵɵelementEnd()();"#, + r#"i0.ɵɵelementStart(5,"P");i0.ɵɵtext(6,"p");i0.ɵɵelementEnd();i0.ɵɵelementStart(7,"P");"#, + ], + ); + assert_compiles_to( + r#"
    after"#, + &[ + "decls:4,vars:2,", + r#"i0.ɵɵelement(0,"INPUT",0)(1,"BR")(2,"Img",1);i0.ɵɵtext(3,"after");"#, + r#"i0.ɵɵproperty("src",ctx.t,i0.ɵɵsanitizeUrl);"#, + ], + ); + assert_compiles_to( + r#"a"#, + &[ + r#"i0.ɵɵproperty("href",ctx.t,i0.ɵɵsanitizeUrl);"#, + r#"i0.ɵɵproperty("src",ctx.t,i0.ɵɵsanitizeResourceUrl);"#, + ], + ); +} + +/// `

    a

    ", + &[ + "decls:2,vars:0,", + r#"i0.ɵɵelementStart(0,"p");i0.ɵɵtext(1,"a");i0.ɵɵelementEnd();"#, + r#"styles:["p[_ngcontent-%COMP%]{}"]"#, + ], + ); + assert_compiles_to( + "{{ t }}", + &[ + "decls:4,vars:2,", + r#"i0.ɵɵelementStart(0,"TEXTAREA");i0.ɵɵtext(1);i0.ɵɵelementEnd();i0.ɵɵelementStart(2,"Title");"#, + ], + ); +} + +/// The preparser lowercases the name before it looks for `ng-content`, so any spelling +/// projects content. `ng-container` and `ng-template` are matched as written, so in +/// capitals they are ordinary elements. +#[test] +fn angular_element_names_written_in_capitals() { + assert_compiles_to( + r#""#, + &[ + "ngContentSelectors:_c1,decls:2,vars:0,", + "i0.ɵɵprojectionDef(_c0);i0.ɵɵprojection(0);i0.ɵɵprojection(1,1);", + ], + ); + assert_compiles_to( + "ab", + &[ + "decls:4,vars:0,", + r#"i0.ɵɵelementStart(0,"NG-CONTAINER");i0.ɵɵtext(1,"a");i0.ɵɵelementEnd();i0.ɵɵelementStart(2,"Ng-Template");"#, + ], + ); +} + +/// Names inside `` are legitimately mixed case, and a capitalised `` still +/// puts its children in the svg namespace. +#[test] +fn namespaced_elements() { + assert_compiles_to( + r#"
    x
    "#, + &[ + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelementStart(0,"svg");i0.ɵɵelement(1,"Circle",0);i0.ɵɵelementStart(2,"foreignObject");i0.ɵɵnamespaceHTML();i0.ɵɵelementStart(3,"DIV");"#, + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelement(5,"linearGradient");i0.ɵɵelementEnd();i0.ɵɵelement(6,"Circle");"#, + ], + ); + assert_compiles_to( + "x", + &[ + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelementStart(0,"SVG");i0.ɵɵelement(1,"rect");i0.ɵɵelementEnd();"#, + r#"i0.ɵɵnamespaceMathML();i0.ɵɵelementStart(2,"MATH")(3,"mi");"#, + ], + ); +} + +#[test] +fn lowercase_elements_are_unchanged() { + assert_compiles_to( + r#"hia"#, + &[ + "decls:5,vars:1,", + r#"i0.ɵɵelementStart(0,"view")(1,"text");i0.ɵɵtext(2,"hi");i0.ɵɵelementEnd()();i0.ɵɵelementStart(3,"my-widget",0);"#, + ], + ); +} + +/// Only a selectorless parse produces component nodes. The pipeline cannot compile +/// them, and says so rather than emitting a template without them. +#[test] +fn selectorless_component_node_is_reported() { + let template = "

    a

    b"; + let allocator = Allocator::default(); + let html = HtmlParser::with_selectorless(&allocator, template, "test.html").parse(); + assert!(html.errors.is_empty(), "{:?}", html.errors); + let r3 = HtmlToR3Transform::new(&allocator, template, HtmlTransformOptions::default()) + .transform(&html.nodes); + assert!(r3.errors.is_empty(), "{:?}", r3.errors); + let mut job = ingest_component(&allocator, Ident::from("X"), r3.nodes); + let result = compile_template(&mut job); + + let messages: Vec<&str> = job.diagnostics.iter().map(|d| &*d.message).collect(); + assert_eq!( + messages, + [ + "Selectorless component cannot be compiled: selectorless components are not supported." + ] + ); + // The rest of the template is still compiled. + let emitted = JsEmitter::new().emit_statements(&result.template_fn.statements); + assert!(emitted.contains(r#"i0.ɵɵelementStart(0,"p")"#), "{emitted}"); +} diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index 0abdd2e7d..025be922a 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -1416,6 +1416,86 @@ mod case_sensitivity { assert!(!errors.is_empty()); assert!(errors[0].contains("Unexpected closing tag")); } + + #[test] + fn should_match_closing_tags_case_sensitive() { + // TS: it('should match closing tags case sensitive', ...) + let allocator = Allocator::default(); + let result = HtmlParser::new(&allocator, "

    ", "TestComp").parse(); + let errors: Vec<_> = result + .errors + .iter() + .map(|e| (e.msg.split('.').next().unwrap_or_default(), e.span.start.offset)) + .collect(); + assert_eq!( + errors, + vec![("Unexpected closing tag \"p\"", 8), ("Unexpected closing tag \"dIv\"", 12)] + ); + } + + /// An element with an optional end tag is closed by the next one whatever its case. + /// The open element used to be looked up by its lowercased name, never found, and + /// the parser looped forever. + #[test] + fn should_close_capitalised_elements_with_optional_end_tags() { + assert_eq!( + parse_and_humanize("
    • a
    • b
    "), + vec![element("UL", 0), element("LI", 1), text("a", 2), element("LI", 1), text("b", 2),] + ); + assert_eq!( + parse_and_humanize("

    a

    b"), + vec![element("P", 0), text("a", 1), element("P", 0), text("b", 1)] + ); + assert_eq!( + parse_and_humanize("ab"), + vec![element("Tr", 0), element("Td", 1), text("a", 2), element("Td", 1), text("b", 2),] + ); + // The two spellings close each other. + assert_eq!( + parse_and_humanize("

  • a
  • b
  • c"), + vec![ + element("li", 0), + text("a", 1), + element("LI", 0), + text("b", 1), + element("Li", 0), + text("c", 1), + ] + ); + } + + /// `"), + vec![element("STYLE", 0), text("p > a{}", 1)] + ); + assert_eq!( + parse_and_humanize(""), + vec![element("Script", 0), text("if (a < b) {}", 1)] + ); + assert_eq!( + parse_and_humanize(""), + vec![element("TEXTAREA", 0), text("x", 1)] + ); + assert_eq!( + parse_and_humanize("a

    b

    "), + vec![element("Title", 0), text("a", 1), element("p", 0), text("b", 1)] + ); + } + + /// A tag name starts with a letter. `<_x>` is text, so its closing tag closes nothing. + #[test] + fn should_not_start_a_tag_with_an_underscore() { + assert_eq!(parse_and_humanize("a <_b c"), vec![text("a <_b c", 0)]); + let (nodes, errors) = parse_with_errors("<_under>c"); + assert_eq!(nodes, vec![text("<_under>c", 0)]); + assert_eq!(errors.len(), 1); + assert!(errors[0].starts_with("Unexpected closing tag \"_under\"."), "{errors:?}"); + } } // ============================================================================ diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts new file mode 100644 index 000000000..b32d58c77 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts @@ -0,0 +1,128 @@ +/** + * Element names are case-sensitive and kept as written: `` is an element + * named "View", not a component reference to resolve later. Known HTML elements + * written in capitals keep their spelling too, while still behaving like the + * element they name (void, raw text, implied namespace). + */ +import type { Fixture } from '../types.js' + +const MEMBERS = ` + t = ''; + on = true; + items: any[] = []; + go(..._args: any[]) {} +` + +function fixture(name: string, description: string, template: string, extra = ''): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}Component` + return { + name, + category: 'templates', + description, + className, + type: 'full-transform', + sourceCode: ` +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +${extra} +@Component({ + selector: 'app-${name}', + standalone: true, + schemas: [CUSTOM_ELEMENTS_SCHEMA],${extra ? '\n imports: [ViewCmp],' : ''} + template: \`${template}\`, +}) +export class ${className} {${MEMBERS}} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + fixture( + 'case-root', + 'Capitalised element at the root with a lowercase child', + 'hi', + ), + fixture( + 'case-nested', + 'Capitalised element nested in a lowercase element', + '
    ab
    ', + ), + fixture( + 'case-mixed', + 'Mixed-case element names', + 'abdef', + ), + fixture( + 'case-attributes', + 'Capitalised element with attributes, bindings, listeners and a reference', + '{{ t }}', + ), + fixture( + 'case-matches-component', + 'Capitalised element matching a component selector in imports', + 'projected', + `import { input, output } from '@angular/core'; + +@Component({ selector: 'View', standalone: true, template: '' }) +export class ViewCmp { + label = input(''); + press = output(); +} +`, + ), + fixture( + 'case-self-closing', + 'Self-closing capitalised elements', + '
    ', + ), + fixture( + 'case-control-flow', + 'Capitalised elements inside @if, @for and ng-template', + '@if (on) { a } @else { b } @for (i of items; track i) { {{ i }} } @empty { } cd', + ), + fixture( + 'case-html-elements', + 'Known HTML elements written in capitals', + '
    a
    b
    • d
    • e

    p

    q', + ), + fixture( + 'case-html-void-elements', + 'Void HTML elements written in capitals', + '


    after', + ), + fixture( + 'case-html-security', + 'Security-sensitive bindings on HTML elements written in capitals', + 'a
    ', + ), + fixture( + 'case-svg', + 'Mixed-case names inside and outside svg', + '
    x
    y
    x', + ), + fixture( + 'case-ng-names', + 'ng-content matches in any case; ng-container and ng-template only as written', + 'abc', + ), + fixture( + 'case-raw-text', + 'Raw text elements written in capitals', + '{{ t }}

    after

    ', + ), + fixture( + 'case-optional-end-tags', + 'Capitalised elements with optional end tags', + '
    • a
    • b
    ab
    c

    p

    q', + ), + fixture( + 'case-i18n', + 'Capitalised element with i18n', + 'hello there', + ), + fixture( + 'case-lowercase-unchanged', + 'Lowercase elements, custom and standard', + 'hia

    b
    ', + ), +] diff --git a/napi/angular-compiler/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 6d6bba6a9..e3a83b682 100644 --- a/napi/angular-compiler/test/transform.test.ts +++ b/napi/angular-compiler/test/transform.test.ts @@ -611,3 +611,30 @@ describe('object spread in template bindings', () => { expect(result.code).toContain('ctx.b') }) }) + +describe('capitalised element names', () => { + it('compiles as an element instead of dropping the template', async () => { + const source = `import { Component } from '@angular/core'; +@Component({ selector: 'x', template: 'hi' }) +export class X {}` + + const result = await transformAngularFile(source, '/x/a.ts', {}) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(/decls:\s*3/) + expect(result.code).toMatch(/ɵɵelementStart\(0,\s*"View"\)\(1,\s*"text"\)/) + expect(result.code).toMatch(/ɵɵtext\(2,\s*"hi"\)/) + }) + + it('compiles capitalised elements with optional end tags without hanging', async () => { + const source = `import { Component } from '@angular/core'; +@Component({ selector: 'x', template: '
    • a
    • b
    ' }) +export class X {}` + + const result = await transformAngularFile(source, '/x/a.ts', {}) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(/decls:\s*5/) + expect(result.code).toMatch(/ɵɵelementStart\(0,\s*"UL"\)\(1,\s*"LI"\)/) + }) +}) From 97ba7cd9eb9510757a7c0684614aad1551d2df59 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 16:43:03 +0800 Subject: [PATCH 2/2] fix(parser): keep upstream preparser semantics for case-insensitive names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on top of the capitalised-element-names change: - `preparseElement` matches `link`, `ng-content`, `rel`, `href` and `select` case-insensitively on the namespace-stripped local name. A resolvable stylesheet `` is collected into `styleUrls` and dropped from the tree; inside `ngNonBindable` it is still dropped but its URL is discarded (`NonBindableVisitor`), and an unresolvable stylesheet link outside `ngNonBindable` stays an ordinary element. `ng-template` keeps case-sensitive local-name matching like `isNgTemplate`. - Collected template link URLs are now actually used: `compile_component_full` resolves them through `resolved_resources`, appends their contents to `metadata.styles`, reports unresolved URLs as non-fatal COMPONENT_RESOURCE_NOT_FOUND diagnostics, and returns them as watch dependencies (`template_style_urls`). - Raw-text closes: the selectorless component path now matches its close tag case-insensitively like `_consumeRawTextWithTagClose`, and a namespaced raw-text element emits the close token with the opener's `[prefix, name]` parts so `` closes ``. - `R3Element.is_void` uses the parser's tag-definition lookup (`element.is_void`), which is case-insensitive like `getHtmlTagDefinition`, instead of a case-sensitive `matches!` table. - `scan_text` no longer treats `<_` mid-text as a component start — upstream `_isTagStart` has no `_` case; a component is only recognized at a token boundary. Verified: cargo test -p oxc_angular_compiler --all-features (0 failures), angular_conformance 1264/1264, fixture compare 1074 fixtures 0 mismatches, vitest transform.test.ts 30/30. Known pre-existing divergences found during review, left as-is (they predate this change — verified against git diff 7f11595..81a81b2 — and warrant their own PR): - `auto_close_element_if_needed` pops containers in a loop; upstream `_pushContainer` pops at most one (`` nests upstream, flattens here). It also passes the component class name where upstream checks `component.tagName`. - `` and `` look up the local name's tag definition and are treated as void; upstream uses the merged full name. - `ComponentClose` matches by class name, not `component.fullName` (`` closes `` here; upstream errors with a "did you mean" hint). - ``/`` inside `ngNonBindable` still produce Content/Template nodes; upstream `NonBindableVisitor` emits verbatim elements. `` closes the element named "STYLE". - fn scan_raw_text_with_tag_close(&mut self, tag_name: &str, consume_entities: bool) { + /// `tag_name` is the opening tag's local name as written. The closing tag is + /// matched against it ignoring case — upstream uses `closingTagName = + /// parts[1]` for ordinary elements — and the close token is emitted with the + /// opening tag's `[prefix, name]` parts, so `` closes the element + /// named `:svg:script`. + fn scan_raw_text_with_tag_close( + &mut self, + prefix: &str, + tag_name: &str, + consume_entities: bool, + ) { let token_type = if consume_entities { HtmlTokenType::EscapableRawText } else { HtmlTokenType::RawText }; @@ -1824,11 +1831,12 @@ impl<'a> HtmlLexer<'a> { } } - // Emit the closing tag + // Emit the closing tag with the opening tag's parts, like + // upstream's `_endToken(openToken.parts)`. self.advance(); // consume > self.tokens.push(HtmlToken::with_prefix_name( HtmlTokenType::TagClose, - "", + prefix, tag_name, saved_index, self.index, @@ -2019,7 +2027,9 @@ impl<'a> HtmlLexer<'a> { self.skip_whitespace(); - if close_name == expected_close && self.peek() == '>' { + // Upstream `_consumeRawTextWithTagClose` matches the close with + // `_attemptStrCaseInsensitive` for components too. + if close_name.eq_ignore_ascii_case(&expected_close) && self.peek() == '>' { // Found the closing tag - emit content let content = &self.input[content_start as usize..saved_index as usize]; let normalized = normalize_line_endings(content); @@ -3086,12 +3096,10 @@ impl<'a> HtmlLexer<'a> { if ch == '<' { let next = self.peek_at(1); // Valid tag start: `/` (close tag), `!` (comment/doctype/cdata), or a letter. - // An underscore starts a name only for a selectorless component. - if next == '/' - || next == '!' - || next.is_ascii_alphabetic() - || (next == '_' && self.selectorless_enabled) - { + // Upstream `_isTagStart` has no `_` case, so mid-text `<_C` stays + // text even in selectorless mode; a component only tokenizes at a + // token boundary (the dispatch check in `scan_next_token`). + if next == '/' || next == '!' || next.is_ascii_alphabetic() { break; } // Otherwise, `<` is just text, continue 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 5468f0a10..c5437fbe9 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -437,14 +437,24 @@ impl<'a> HtmlToR3Transform<'a> { // Like `script` and `style` above, the preparser lowercases the name before // it looks for `link` and `ng-content` (`preparseElement`). if raw_name.eq_ignore_ascii_case("link") { - // Collect stylesheet URLs + // A stylesheet `` with a resolvable URL is compiled into + // `styles` and dropped from the tree (`r3_template_transform.ts` + // returns `null`). Inside ngNonBindable the preparser still drops + // stylesheet links (`NonBindableVisitor`), but the URL is discarded + // because that visitor never reaches `styleUrls`. if let Some(href) = self.get_stylesheet_href(element) { - self.style_urls.push(href); + if self.non_bindable_depth == 0 { + self.style_urls.push(href); + } + return None; } - // Filter out inside ngNonBindable elements + // Inside ngNonBindable a stylesheet `` is dropped even when + // its URL is unresolvable or missing — `NonBindableVisitor` checks + // only `type === STYLESHEET`, not `isStyleUrlResolvable`. if self.non_bindable_depth > 0 && self.is_stylesheet_link(element) { return None; } + // Every other `` stays an element. } } @@ -457,7 +467,8 @@ impl<'a> HtmlToR3Transform<'a> { &element.attrs, &security_name, i18n_element_name, - raw_name == "ng-template", + // `isNgTemplate` sees the namespace-stripped local name. + split_ns_name(raw_name).1 == "ng-template", ); // `child_prefix` is what children inherit; `foreignObject` already @@ -590,7 +601,9 @@ impl<'a> HtmlToR3Transform<'a> { // Check for ng-content // Reference: r3_template_transform.ts lines 191-204 // Children are passed directly without extra whitespace filtering - if raw_name.eq_ignore_ascii_case("ng-content") { + // `preparseElement` lowercases the name and `isNgContent` reads the local + // name, so namespaced and capitalised spellings both count. + if split_ns_name(raw_name).1.eq_ignore_ascii_case("ng-content") { let selector = self.get_ng_content_selector(element); self.ng_content_selectors.push(selector); @@ -641,8 +654,10 @@ impl<'a> HtmlToR3Transform<'a> { return Some(result); } - // Check for ng-template - if raw_name == "ng-template" { + // Check for ng-template. `isNgTemplate` reads the local name without + // lowering it: a namespaced `` is a template, but a + // capitalised `` is an ordinary element. + if split_ns_name(raw_name).1 == "ng-template" { let name = Ident::from_in(resolved_name.as_str(), &self.allocator); let template = R3Template { tag_name: Some(name), @@ -742,7 +757,10 @@ impl<'a> HtmlToR3Transform<'a> { source_span: element.span, start_source_span: element.start_span, end_source_span: element.end_span, - is_void: self.is_void_element(resolved_name.as_str()), + // `element.isVoid` in TypeScript is the parser's own tag-definition + // lookup; `HtmlElement.is_void` already holds it and is + // case-insensitive (`
    ` is void upstream). + is_void: element.is_void, i18n: i18n_meta, }; R3Node::Element(Box::new_in(r3_element, &self.allocator)) @@ -5004,63 +5022,54 @@ impl<'a> HtmlToR3Transform<'a> { /// Gets the stylesheet href from a link element. /// Only returns resolvable URLs per Angular's `isStyleUrlResolvable`. /// Reference: style_url_resolver.ts lines 12-18 + /// + /// `preparseElement` lowercases attribute names and keeps the last + /// `rel`/`href` when they repeat; the rel value itself is matched as + /// written (`relAttr == 'stylesheet'`). fn get_stylesheet_href(&self, element: &HtmlElement<'a>) -> Option> { - let mut is_stylesheet = false; + let mut rel = None; let mut href = None; for attr in &element.attrs { - if attr.name.as_str() == "rel" { - is_stylesheet = attr.value.as_str() == "stylesheet"; - } - if attr.name.as_str() == "href" { - let href_value = attr.value.as_str(); - // Only include resolvable URLs - if is_style_url_resolvable(href_value) { - href = Some(attr.value); - } + if attr.name.as_str().eq_ignore_ascii_case("rel") { + rel = Some(attr.value.as_str()); + } else if attr.name.as_str().eq_ignore_ascii_case("href") { + href = Some(attr.value); } } - if is_stylesheet { href } else { None } + if rel == Some("stylesheet") && href.is_some_and(|h| is_style_url_resolvable(h.as_str())) { + href + } else { + None + } } - /// Checks if an element is a ``. + /// Checks whether the preparser would classify the element as + /// `PreparsedElementType.STYLESHEET`: `rel == "stylesheet"` on the last + /// case-insensitively-named `rel` attribute, with no resolvability check. + /// Only used inside ngNonBindable, where `NonBindableVisitor` drops every + /// stylesheet link regardless of its URL. fn is_stylesheet_link(&self, element: &HtmlElement<'a>) -> bool { element .attrs .iter() - .any(|attr| attr.name.as_str() == "rel" && attr.value.as_str() == "stylesheet") + .rev() + .find(|attr| attr.name.as_str().eq_ignore_ascii_case("rel")) + .is_some_and(|attr| attr.value.as_str() == "stylesheet") } /// Gets the ng-content selector. + /// `preparseElement` lowercases the attribute name, keeps the last + /// `select`, and defaults a missing or empty one to `*`. fn get_ng_content_selector(&self, element: &HtmlElement<'a>) -> Ident<'a> { + let mut selector = Ident::from("*"); for attr in &element.attrs { - if attr.name.as_str() == "select" && !attr.value.is_empty() { - return attr.value; + if attr.name.as_str().eq_ignore_ascii_case("select") { + selector = attr.value; } } - Ident::from("*") - } - - /// Checks if an element is a void element. - fn is_void_element(&self, name: &str) -> bool { - matches!( - name, - "area" - | "base" - | "br" - | "col" - | "embed" - | "hr" - | "img" - | "input" - | "link" - | "meta" - | "param" - | "source" - | "track" - | "wbr" - ) + if selector.is_empty() { Ident::from("*") } else { selector } } } diff --git a/crates/oxc_angular_compiler/tests/element_name_case_test.rs b/crates/oxc_angular_compiler/tests/element_name_case_test.rs index 3f2a473ea..b0012f43a 100644 --- a/crates/oxc_angular_compiler/tests/element_name_case_test.rs +++ b/crates/oxc_angular_compiler/tests/element_name_case_test.rs @@ -262,3 +262,135 @@ fn selectorless_component_node_is_reported() { let emitted = JsEmitter::new().emit_statements(&result.template_fn.statements); assert!(emitted.contains(r#"i0.ɵɵelementStart(0,"p")"#), "{emitted}"); } + +/// A template stylesheet `` compiles its resolvable URL into the +/// component's styles and disappears from the emitted template, like the +/// preparser classifying it STYLESHEET and the transform returning `null`. +/// Attribute names are matched case-insensitively and in any order, because +/// `preparseElement` lowercases them. +#[test] +fn capitalised_stylesheet_links() { + use std::collections::HashMap; + + use oxc_angular_compiler::ResolvedResources; + + let cases = [ + // Uppercase tag and attributes, `HREF` before `REL`. + "", + // Mixed case tag, lowercase attributes. + "", + ]; + for template in cases { + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'x', template: `{template}` }})\n\ + export class X {{}}" + ); + let allocator = Allocator::default(); + let mut styles = HashMap::new(); + styles.insert("./theme.css".to_string(), vec![".link-style{color:red}".to_string()]); + let resources = ResolvedResources { styles, ..Default::default() }; + let result = transform_angular_file( + &allocator, + "/x/a.ts", + &source, + Some(&TransformOptions::default()), + Some(&resources), + ); + assert!(result.diagnostics.is_empty(), "`{template}`: {:?}", result.diagnostics); + // The collected stylesheet joins the component's styles… + assert!( + result.code.contains("link-style"), + "`{template}` should compile the linked stylesheet:\n{}", + result.code + ); + // …and the link element is dropped from the template. + let compact: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + !compact.contains("LINK") || !compact.contains("ɵɵelement"), + "`{template}` should not emit a link element:\n{compact}" + ); + } +} + +/// A resolvable stylesheet `` missing from the provided resources is a +/// COMPONENT_RESOURCE_NOT_FOUND diagnostic like a missing `styleUrl`. +#[test] +fn missing_stylesheet_link_is_a_resource_error() { + use std::collections::HashMap; + + use oxc_angular_compiler::ResolvedResources; + + let source = "import { Component } from '@angular/core';\n\ + @Component({ selector: 'x', template: `` })\n\ + export class X {}"; + let allocator = Allocator::default(); + let resources = ResolvedResources { styles: HashMap::new(), ..Default::default() }; + let result = transform_angular_file( + &allocator, + "/x/a.ts", + source, + Some(&TransformOptions::default()), + Some(&resources), + ); + let messages: Vec = result.diagnostics.iter().map(|d| d.message.to_string()).collect(); + assert!( + messages.iter().any(|m| m.contains("style URL './theme.css' could not be resolved")), + "{messages:?}" + ); +} + +/// Inside `ngNonBindable` a stylesheet `` is still dropped +/// (`NonBindableVisitor` checks `type === STYLESHEET`), but its URL is not +/// recorded — and an unresolvable or missing href does not save it, because +/// `isStyleUrlResolvable` is not consulted there. Outside `ngNonBindable` an +/// unresolvable stylesheet link stays an ordinary element. +#[test] +fn stylesheet_links_inside_ng_non_bindable() { + let non_bindable_div = r#"i0.ɵɵelementStart(0,"div");i0.ɵɵdisableBindings();i0.ɵɵtext(1,"a");i0.ɵɵenableBindings();"#; + assert_compiles_to( + r#"
    a
    "#, + &[non_bindable_div], + ); + assert_compiles_to( + r#"
    a
    "#, + &[non_bindable_div], + ); + assert_compiles_to(r#"
    a
    "#, &[non_bindable_div]); + // Outside ngNonBindable an unresolvable stylesheet link stays an element, + // and so does a link without `rel="stylesheet"`. + assert_compiles_to( + r#""#, + &[r#"i0.ɵɵelement(0,"link",0)"#], + ); + assert_compiles_to( + r#""#, + &[r#"i0.ɵɵelement(0,"link",0)"#], + ); +} + +/// `isNgContent` reads the namespace-stripped local name lowercased, so both +/// a namespaced `` and a capitalised `` project +/// content, and its `select` attribute is matched case-insensitively. +#[test] +fn namespaced_and_capitalised_ng_content() { + assert_compiles_to( + "", + &[ + "ngContentSelectors:_c1,", + "i0.ɵɵprojectionDef(_c0);i0.ɵɵprojection(0);i0.ɵɵprojection(1,1);", + ], + ); +} + +/// `isNgTemplate` reads the namespace-stripped local name as written, so a +/// namespaced `` is a template while a capitalised +/// `` is an ordinary element. +#[test] +fn namespaced_ng_template_vs_capitalised() { + assert_compiles_to("

    x

    ", &["decls:"]); + assert_compiles_to( + "a", + &[r#"i0.ɵɵelementStart(0,"NG-TEMPLATE");i0.ɵɵtext(1,"a")"#], + ); +} diff --git a/crates/oxc_angular_compiler/tests/html_lexer_test.rs b/crates/oxc_angular_compiler/tests/html_lexer_test.rs index ad777c3ab..8a1202c16 100644 --- a/crates/oxc_angular_compiler/tests/html_lexer_test.rs +++ b/crates/oxc_angular_compiler/tests/html_lexer_test.rs @@ -3020,3 +3020,69 @@ mod regular_text { ); } } + +// ============================================================================ +// Raw-text Closing Tag Tests +// ============================================================================ + +mod raw_text_close { + use super::*; + + /// Upstream `_consumeRawTextWithTagClose` matches the close with + /// `_attemptStrCaseInsensitive(parts[1])`, the local name — `` + /// closes ``, and the close token keeps the opener's + /// `[prefix, name]` parts (`_endToken(openToken.parts)`). + #[test] + fn raw_text_close_uses_opening_tag_parts() { + let result = tokenize_and_humanize_parts("let x;"); + assert_eq!( + result, + vec![ + (HtmlTokenType::TagOpenStart, vec!["svg".to_string(), "script".to_string()]), + (HtmlTokenType::TagOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["let x;".to_string()]), + (HtmlTokenType::TagClose, vec!["svg".to_string(), "script".to_string()]), + (HtmlTokenType::Eof, vec![]), + ] + ); + } + + #[test] + fn raw_text_close_is_case_insensitive() { + let result = tokenize_and_humanize_parts(""); + assert_eq!( + result, + vec![ + (HtmlTokenType::TagOpenStart, vec![String::new(), "STYLE".to_string()]), + (HtmlTokenType::TagOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["a".to_string()]), + (HtmlTokenType::TagClose, vec![String::new(), "STYLE".to_string()]), + (HtmlTokenType::Eof, vec![]), + ] + ); + } + + /// The component raw-text close is matched case-insensitively too + /// (`_consumeRawTextWithTagClose` is shared between elements and + /// selectorless components). + #[test] + fn component_raw_text_close_is_case_insensitive() { + let result = tokenize_selectorless_and_humanize_parts("a"); + assert_eq!( + result, + vec![ + ( + HtmlTokenType::ComponentOpenStart, + vec!["MyComp".to_string(), String::new(), "style".to_string()] + ), + (HtmlTokenType::ComponentOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["a".to_string()]), + ( + HtmlTokenType::ComponentClose, + vec!["MyComp".to_string(), String::new(), "style".to_string()] + ), + (HtmlTokenType::Eof, vec![]), + ] + ); + } +}