diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 79d14d65e..276cb6bb9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3469,11 +3469,26 @@ pub fn transform_angular_file( shared_pool_index, &mut file_namespace_registry, &import_map, + resolved_resources, ) { Ok(compilation_result) => { // Update the shared pool index for the next component shared_pool_index = compilation_result.next_pool_index; + // Stylesheet ``s in the template are watched + // files like decorator `styleUrls`, and a missing + // one is the same non-fatal diagnostic. + for style_url in &compilation_result.template_style_urls { + result.dependencies.push(style_url.clone()); + } + for style_url in &compilation_result.missing_link_urls { + result.diagnostics.push(OxcDiagnostic::error(format!( + "Component '{}': style URL '{}' could not be resolved \ + (COMPONENT_RESOURCE_NOT_FOUND)", + class_name, style_url + ))); + } + let component_id = format!("{}@{}", path, class_name); let component_decorator = @@ -4622,6 +4637,16 @@ struct FullCompilationResult { /// async metadata callback, and emitting them would leave dead /// `import(...)` calls in the output. has_defer_block: bool, + + /// Resolvable-URL stylesheet ``s the template's preparser collected + /// (upstream `_extractTemplateStyleUrls`). The caller records them as + /// watch dependencies like decorator `styleUrls`. + template_style_urls: Vec, + + /// Template link URLs missing from `resolved_resources` — non-fatal like + /// a missing decorator `styleUrl`: the caller surfaces + /// COMPONENT_RESOURCE_NOT_FOUND in the result diagnostics. + missing_link_urls: Vec, } /// Compile a component template and generate ɵcmp/ɵfac definitions. @@ -4687,6 +4712,10 @@ fn compile_component_partial<'a>( next_pool_index: pool_starting_index, ng_content_selectors: Vec::new(), has_defer_block, + // Partial mode never parses the template; the linker collects link + // URLs at consumer build time. + template_style_urls: Vec::new(), + missing_link_urls: Vec::new(), } } @@ -4702,6 +4731,7 @@ fn compile_component_full<'a>( pool_starting_index: u32, namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, + resolved_resources: Option<&ResolvedResources>, ) -> Result> { use oxc_allocator::FromIn; @@ -4776,6 +4806,27 @@ fn compile_component_full<'a>( let ng_content_selectors: Vec = r3_result.ng_content_selectors.iter().map(|s| s.to_string()).collect(); + // Resolve stylesheet ``s the preparser collected (`_extractTemplateStyleUrls`): + // they compile to styles like decorator `styleUrls` (handler.ts resolves both through + // `resourceLoader` and the link element is removed from the template). Upstream puts + // external styles before inline ones; the file's own ordering keeps URL contents after + // the decorator's inline styles, and the links land ahead of the `

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}"); +} + +/// 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![]), + ] + ); + } +} 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"\)/) + }) +})