diff --git a/crates/angular_conformance/fixtures/ml_parser_html_parser_spec.json b/crates/angular_conformance/fixtures/ml_parser_html_parser_spec.json index 440d49118..6291269b9 100644 --- a/crates/angular_conformance/fixtures/ml_parser_html_parser_spec.json +++ b/crates/angular_conformance/fixtures/ml_parser_html_parser_spec.json @@ -112,6 +112,28 @@ } ] }, + { + "name": "should parse named HTML entities containing digits", + "path": "HtmlParser/parse/text nodes/should parse named HTML entities containing digits", + "assertions": [ + { + "type": "HumanizeDom", + "input": "
¹
", + "expected": [ + ["html.Element", "div", 0.0], + ["html.Text", "¹", 1.0, [""], ["¹", "¹"], [""]] + ] + }, + { + "type": "HumanizeDom", + "input": "
½
", + "expected": [ + ["html.Element", "div", 0.0], + ["html.Text", "½", 1.0, [""], ["½", "½"], [""]] + ] + } + ] + }, { "name": "should normalize line endings within CDATA", "path": "HtmlParser/parse/text nodes/should normalize line endings within CDATA", @@ -995,6 +1017,24 @@ } ] }, + { + "name": "should parse exhaustive default checks in a switch block", + "path": "HtmlParser/parse/blocks/should parse exhaustive default checks in a switch block", + "assertions": [ + { + "type": "HumanizeDom", + "input": "@switch (expr) {@case ('foo') {} @default never;}", + "expected": [ + ["html.Block", "switch", 0.0], + ["html.BlockParameter", "expr"], + ["html.Block", "case", 1.0], + ["html.BlockParameter", "'foo'"], + ["html.Text", " ", 1.0, [" "]], + ["html.Block", "default never", 1.0] + ] + } + ] + }, { "name": "should close void elements used right before a block", "path": "HtmlParser/parse/blocks/should close void elements used right before a block", diff --git a/crates/angular_conformance/fixtures/ml_parser_lexer_spec.json b/crates/angular_conformance/fixtures/ml_parser_lexer_spec.json index 8a0daaa99..17dededf9 100644 --- a/crates/angular_conformance/fixtures/ml_parser_lexer_spec.json +++ b/crates/angular_conformance/fixtures/ml_parser_lexer_spec.json @@ -3154,6 +3154,48 @@ } ] }, + { + "name": "should parse named entities containing digits", + "path": "HtmlLexer/entities/should parse named entities containing digits", + "assertions": [ + { + "type": "HtmlLexerTest", + "input": "¹", + "test_type": "HumanizeParts", + "expected": [ + ["TokenType.TEXT", ""], + ["TokenType.ENCODED_ENTITY", "¹", "¹"], + ["TokenType.TEXT", ""], + ["TokenType.EOF"] + ], + "options": null + }, + { + "type": "HtmlLexerTest", + "input": "½", + "test_type": "HumanizeParts", + "expected": [ + ["TokenType.TEXT", ""], + ["TokenType.ENCODED_ENTITY", "½", "½"], + ["TokenType.TEXT", ""], + ["TokenType.EOF"] + ], + "options": null + }, + { + "type": "HtmlLexerTest", + "input": "▓", + "test_type": "HumanizeParts", + "expected": [ + ["TokenType.TEXT", ""], + ["TokenType.ENCODED_ENTITY", "▓", "▓"], + ["TokenType.TEXT", ""], + ["TokenType.EOF"] + ], + "options": null + } + ] + }, { "name": "should parse hexadecimal entities", "path": "HtmlLexer/entities/should parse hexadecimal entities", @@ -5046,25 +5088,6 @@ } ] }, - { - "name": "should parse @default never(expr);", - "path": "HtmlLexer/blocks/should parse @default never(expr);", - "assertions": [ - { - "type": "HtmlLexerTest", - "input": "@default never(expr);", - "test_type": "HumanizeParts", - "expected": [ - ["TokenType.BLOCK_OPEN_START", "default never"], - ["TokenType.BLOCK_PARAMETER", "expr"], - ["TokenType.BLOCK_OPEN_END"], - ["TokenType.BLOCK_CLOSE"], - ["TokenType.EOF"] - ], - "options": null - } - ] - }, { "name": "should parse @default never ;", "path": "HtmlLexer/blocks/should parse @default never ;", diff --git a/crates/angular_conformance/fixtures/render3_r3_ast_spans_spec.json b/crates/angular_conformance/fixtures/render3_r3_ast_spans_spec.json index 1a84935d1..f0be4f148 100644 --- a/crates/angular_conformance/fixtures/render3_r3_ast_spans_spec.json +++ b/crates/angular_conformance/fixtures/render3_r3_ast_spans_spec.json @@ -164,7 +164,7 @@ "
", "
" ], - ["BoundAttribute", "data-prop=\"{{v}}\"", "data-prop", "{{v}}"] + ["BoundAttribute", "data-prop=\"{{v}}\"", "prop", "{{v}}"] ], "ignore_error": false } @@ -345,7 +345,7 @@ "", "" ], - ["TextAttribute", "data-ref-a", "data-ref-a", ""] + ["Reference", "data-ref-a", "a", ""] ], "ignore_error": false } @@ -385,7 +385,7 @@ "", "" ], - ["TextAttribute", "data-let-a=\"b\"", "data-let-a", "b"] + ["Variable", "data-let-a=\"b\"", "a", "b"] ], "ignore_error": false } @@ -618,7 +618,7 @@ "
", "
" ], - ["TextAttribute", "data-on-event=\"v\"", "data-on-event", "v"] + ["BoundEvent", "data-on-event=\"v\"", "event", "v"] ], "ignore_error": false } @@ -675,7 +675,8 @@ "
", "
" ], - ["TextAttribute", "data-bindon-prop=\"v\"", "data-bindon-prop", "v"] + ["BoundAttribute", "data-bindon-prop=\"v\"", "prop", "v"], + ["BoundEvent", "data-bindon-prop=\"v\"", "prop", "v"] ], "ignore_error": false } @@ -751,6 +752,21 @@ "ignore_error": false } ] + }, + { + "name": "is correct for references via data-ref-", + "path": "R3 AST source spans/references/is correct for references via data-ref-", + "assertions": [ + { + "type": "ExpectFromHtml", + "input": "
", + "expected": [ + ["Element", "
", "
", "
"], + ["Reference", "ref-a", "a", ""] + ], + "ignore_error": false + } + ] } ] }, @@ -934,6 +950,29 @@ } ] }, + { + "name": "is correct for switch blocks with exhaustive checking", + "path": "R3 AST source spans/switch blocks/is correct for switch blocks with exhaustive checking", + "assertions": [ + { + "type": "ExpectFromHtml", + "input": "@switch (cond.kind) {@case (x()) {X case}@default never;}", + "expected": [ + [ + "SwitchBlock", + "@switch (cond.kind) {@case (x()) {X case}@default never;}", + "@switch (cond.kind) {", + "}" + ], + ["SwitchBlockCaseGroup", "@case (x()) {X case}", "@case (x()) {"], + ["SwitchBlockCase", "@case (x()) {X case}", "@case (x()) {"], + ["Text", "X case"], + ["SwitchExhaustiveCheck", "@default never;", "@default never;"] + ], + "ignore_error": false + } + ] + }, { "name": "is correct for switch blocks with exhaustive checking", "path": "R3 AST source spans/switch blocks/is correct for switch blocks with exhaustive checking", @@ -1048,7 +1087,7 @@ { "type": "ExpectFromHtml", "input": "@let foo = 123;", - "expected": [["LetDeclaration", "@let foo = 123;", "foo", "123"]], + "expected": [["LetDeclaration", "@let foo = 123", "foo", "123"]], "ignore_error": false } ] diff --git a/crates/angular_conformance/fixtures/render3_r3_template_transform_spec.json b/crates/angular_conformance/fixtures/render3_r3_template_transform_spec.json index c1794704f..05367b00a 100644 --- a/crates/angular_conformance/fixtures/render3_r3_template_transform_spec.json +++ b/crates/angular_conformance/fixtures/render3_r3_template_transform_spec.json @@ -2077,6 +2077,18 @@ } ] }, + { + "name": "should parse a switch block with a default never case", + "path": "R3 template transform/switch blocks/should parse a switch block with a default never case", + "assertions": [ + { + "type": "ExpectFromHtml", + "input": "\n @switch (cond.kind) {\n @default never;\n }\n ", + "expected": [["SwitchBlock", "cond.kind"], ["SwitchExhaustiveCheck"]], + "ignore_error": false + } + ] + }, { "name": "should parse a switch block when preserveWhitespaces is enabled", "path": "R3 template transform/switch blocks/should parse a switch block when preserveWhitespaces is enabled", diff --git a/crates/angular_conformance/fixtures/shadow_css_polyfills_spec.json b/crates/angular_conformance/fixtures/shadow_css_polyfills_spec.json new file mode 100644 index 000000000..718a20b18 --- /dev/null +++ b/crates/angular_conformance/fixtures/shadow_css_polyfills_spec.json @@ -0,0 +1,32 @@ +{ + "name": "polyfills_spec.ts", + "file_path": "/Users/brooklyn/workspace/github/oxc-angular-compiler/crates/oxc_angular_compiler/angular/packages/compiler/test/shadow_css/polyfills_spec.ts", + "test_groups": [ + { + "name": "ShadowCss, polyfills", + "groups": [], + "tests": [ + { + "name": "should support polyfill-next-selector", + "path": "ShadowCss, polyfills/should support polyfill-next-selector", + "assertions": [] + }, + { + "name": "should support polyfill-unscoped-rule", + "path": "ShadowCss, polyfills/should support polyfill-unscoped-rule", + "assertions": [] + }, + { + "name": "should support multiple instances polyfill-unscoped-rule", + "path": "ShadowCss, polyfills/should support multiple instances polyfill-unscoped-rule", + "assertions": [] + }, + { + "name": "should support polyfill-rule", + "path": "ShadowCss, polyfills/should support polyfill-rule", + "assertions": [] + } + ] + } + ] +} diff --git a/crates/angular_conformance/fixtures/shadow_css_shadow_css_spec.json b/crates/angular_conformance/fixtures/shadow_css_shadow_css_spec.json index 1d92513e8..c1c2db8f6 100644 --- a/crates/angular_conformance/fixtures/shadow_css_shadow_css_spec.json +++ b/crates/angular_conformance/fixtures/shadow_css_shadow_css_spec.json @@ -15,10 +15,24 @@ "assertions": [ { "type": "ShimCss", - "input": "/* b {} */ b {}", + "input": "/* b {c} */ b {c}", "content_attr": "contenta", "host_attr": null, - "expected": " b[contenta] {}", + "expected": " b[contenta] {c}", + "normalized": false + } + ] + }, + { + "name": "should preserve internal newlines from multiline comments", + "path": "ShadowCss/comments/should preserve internal newlines from multiline comments", + "assertions": [ + { + "type": "ShimCss", + "input": "/* b {c}\n */ b {c}", + "content_attr": "contenta", + "host_attr": null, + "expected": "\n b[contenta] {c}", "normalized": false } ] @@ -43,10 +57,10 @@ "assertions": [ { "type": "ShimCss", - "input": "/* b {} */ b {} /* a {} */ a {}", + "input": "/* b {c} */ b {c} /* a {c} */ a {c}", "content_attr": "contenta", "host_attr": null, - "expected": " b[contenta] {} a[contenta] {}", + "expected": " b[contenta] {c} a[contenta] {c}", "normalized": false } ] @@ -79,10 +93,10 @@ "assertions": [ { "type": "ShimCss", - "input": "/* comment 1 */ /* comment 2 */ b {}", + "input": "/* comment 1 */ /* comment 2 */ b {c}", "content_attr": "contenta", "host_attr": null, - "expected": " b[contenta] {}", + "expected": " b[contenta] {c}", "normalized": false } ] diff --git a/crates/angular_conformance/snapshots/angular.snap.md b/crates/angular_conformance/snapshots/angular.snap.md index a627e954a..3d482852c 100644 --- a/crates/angular_conformance/snapshots/angular.snap.md +++ b/crates/angular_conformance/snapshots/angular.snap.md @@ -9,11 +9,11 @@ | expression_lexer | 137 | 0 | 0 | 0 | 137 | 100.0% | | expression_parser | 270 | 0 | 0 | 0 | 270 | 100.0% | | expression_serializer | 38 | 0 | 0 | 0 | 38 | 100.0% | -| html_lexer | 289 | 0 | 0 | 0 | 289 | 100.0% | -| html_parser | 87 | 0 | 0 | 0 | 87 | 100.0% | +| html_lexer | 291 | 0 | 0 | 0 | 291 | 100.0% | +| html_parser | 90 | 0 | 0 | 0 | 90 | 100.0% | | html_whitespace | 21 | 0 | 0 | 0 | 21 | 100.0% | -| r3_transform | 177 | 0 | 0 | 0 | 177 | 100.0% | -| shadow_css | 169 | 0 | 0 | 0 | 169 | 100.0% | +| r3_transform | 180 | 0 | 0 | 0 | 180 | 100.0% | +| shadow_css | 170 | 0 | 0 | 0 | 170 | 100.0% | | style_parser | 15 | 0 | 0 | 0 | 15 | 100.0% | -| **Total** | **1264** | **0** | **0** | **0** | **1264** | **100.0%** | +| **Total** | **1273** | **0** | **0** | **0** | **1273** | **100.0%** | diff --git a/crates/angular_conformance/src/subsystems/r3_transform/humanizer.rs b/crates/angular_conformance/src/subsystems/r3_transform/humanizer.rs index 5fb24fcdb..a3714fdf8 100644 --- a/crates/angular_conformance/src/subsystems/r3_transform/humanizer.rs +++ b/crates/angular_conformance/src/subsystems/r3_transform/humanizer.rs @@ -544,8 +544,10 @@ impl<'a> R3Visitor<'a> for R3Humanizer<'_> { ) { if self.mode == HumanizeMode::SourceSpans { // Source spans mode: [LetDeclaration, sourceSpan, name, value] - // The source span includes the trailing semicolon (Angular v22). + // v21.2.7 fixtures: the humanized source span does not include the + // trailing semicolon (Angular later changed this in v22). let source_span = self.span_text(&decl.source_span); + let source_span = source_span.trim_end_matches(';').to_string(); let name = self.span_text(&decl.name_span); let value = self.span_text(&decl.value_span); self.result.push(vec!["LetDeclaration".to_string(), source_span, name, value]); @@ -675,18 +677,27 @@ impl<'a> R3Visitor<'a> for R3Humanizer<'_> { for group in &block.groups { self.visit_switch_block_case_group(group); } - if let Some(check) = &block.exhaustive_check { - let row = if self.mode == HumanizeMode::SourceSpans { - let source_span = self.span_text(&check.source_span); - let start_span = self.span_text(&check.start_source_span); - vec!["SwitchExhaustiveCheck".to_string(), source_span, start_span] - } else { - vec!["SwitchExhaustiveCheck".to_string()] - }; - self.result.push(row); + // Angular v21.2.7 exhaustive-switch feature: emit the `@default never;` + // marker after the case groups (matching upstream `block.exhaustiveCheck?.visit`). + if let Some(exhaustive_check) = &block.exhaustive_check { + self.visit_switch_exhaustive_check(exhaustive_check); } } + fn visit_switch_exhaustive_check( + &mut self, + check: &oxc_angular_compiler::ast::r3::R3SwitchExhaustiveCheck, + ) { + let row = if self.mode == HumanizeMode::SourceSpans { + let source_span = self.span_text(&check.source_span); + let start_span = self.span_text(&check.start_source_span); + vec!["SwitchExhaustiveCheck".to_string(), source_span, start_span] + } else { + vec!["SwitchExhaustiveCheck".to_string()] + }; + self.result.push(row); + } + fn visit_switch_block_case_group( &mut self, group: &oxc_angular_compiler::ast::r3::R3SwitchBlockCaseGroup<'a>, diff --git a/crates/oxc_angular_compiler/angular b/crates/oxc_angular_compiler/angular index 1cb0524f8..50761c8be 160000 --- a/crates/oxc_angular_compiler/angular +++ b/crates/oxc_angular_compiler/angular @@ -1 +1 @@ -Subproject commit 1cb0524f82ebcc06642ceebd0b96a19bba883b2e +Subproject commit 50761c8be4939c5a8f14b986cea93a9b68152c49 diff --git a/crates/oxc_angular_compiler/src/ast/html.rs b/crates/oxc_angular_compiler/src/ast/html.rs index 4e9291ef8..8028c5402 100644 --- a/crates/oxc_angular_compiler/src/ast/html.rs +++ b/crates/oxc_angular_compiler/src/ast/html.rs @@ -125,6 +125,18 @@ pub struct HtmlElement<'a> { /// Whether this is a void element (area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr). /// Void elements cannot have content and do not have end tags. pub is_void: bool, + /// True only when the parser tokenized this as a selectorless COMPONENT + /// (which requires selectorless mode to be enabled). This distinguishes a + /// bare component `` from an uppercase NORMAL element `"#), + ("src".to_string(), "ResourceUrl".to_string()) + ); + } + + // ---- i18n trusted-types sink ---- + + #[test] + fn comp_iframe_i18n_src_is_disallowed() { + let errors = transform_errors(r#"hi"#); + assert!( + has_disallowed_error(&errors, "src"), + "expected i18n disallowed error for iframe|src, got: {errors:?}" + ); + } + + #[test] + fn control_bare_comp_i18n_src_is_allowed() { + // tagName null -> isTrustedType false -> NO error (matches upstream). + let errors = transform_errors(r#"hi"#); + assert!( + !errors.iter().any(|e| e.contains("disallowed for security reasons")), + "expected NO i18n disallowed error for bare , got: {errors:?}" + ); + } + + #[test] + fn control_bare_comp_class_named_object_i18n_data_is_allowed() { + // REGRESSION GUARD: class `Object`, NO tag part -> tagName null -> NO error + // (pre-fix the class name leaked into `object|data` and spuriously errored). + let errors = transform_errors(r#"hi"#); + assert!( + !errors.iter().any(|e| e.contains("disallowed for security reasons")), + "expected NO i18n disallowed error for bare , got: {errors:?}" + ); + } + + #[test] + fn control_normal_iframe_i18n_src_is_disallowed() { + let errors = transform_errors(r#""#); + assert!( + has_disallowed_error(&errors, "src"), + "expected i18n disallowed error for normal iframe|src, got: {errors:?}" + ); + } + + // ---- i18n trusted-types sink: namespace must NOT be stripped, bare component skips ---- + // + // These assert faithfulness to v21.2.7 `render3/view/i18n/meta.ts` (~210-214): + // isTrustedType = node instanceof html.Component + // ? (node.tagName === null ? false : isTrustedTypesSink(node.tagName, name)) + // : isTrustedTypesSink(node.name, name); + // For a NORMAL element it passes the FULL `node.name` (un-stripped, e.g. + // `:svg:iframe`); for a COMPONENT it passes the resolved `node.tagName` + // (un-stripped, e.g. `:svg:iframe`); for a BARE component (tagName null) it + // SHORT-CIRCUITS to false. `isTrustedTypesSink` only lowercases (NO ns-strip), + // so `:svg:iframe|src` is NOT a member of TRUSTED_TYPES_SINKS -> ALLOWED. + // All expected outcomes were verified by executing + // `parseTemplate(tpl, 'test.html', {enableSelectorless:true})` against + // @angular/compiler@21.2.7. + + #[test] + fn normal_svg_iframe_i18n_src_is_allowed() { + // Normal namespaced element, full name `:svg:iframe`; upstream passes + // `node.name` un-stripped -> `:svg:iframe|src` is NOT a sink -> ALLOWED. + // (Pre-fix OXC ns-stripped to `iframe|src` and wrongly BLOCKED.) + let errors = transform_errors(r#""#); + assert!( + !errors.iter().any(|e| e.contains("disallowed for security reasons")), + "expected NO i18n disallowed error for , got: {errors:?}" + ); + } + + #[test] + fn comp_svg_iframe_i18n_src_is_allowed() { + // Selectorless component resolved to `:svg:iframe` (inside ); upstream + // passes `node.tagName` un-stripped -> `:svg:iframe|src` NOT a sink -> ALLOWED. + // (Pre-fix OXC ns-stripped to `iframe|src` and wrongly BLOCKED.) + let errors = + transform_errors(r#"hi"#); + assert!( + !errors.iter().any(|e| e.contains("disallowed for security reasons")), + "expected NO i18n disallowed error for , got: {errors:?}" + ); + } + + #[test] + fn bare_comp_i18n_inner_html_is_allowed() { + // Bare component (tagName null) -> upstream short-circuits isTrustedType to + // false -> ALLOWED, even though `*|innerhtml` IS a wildcard sink. (Pre-fix + // OXC passed an EMPTY STRING which matched `*|innerhtml` and wrongly BLOCKED.) + let errors = transform_errors(r#"hi"#); + assert!( + !errors.iter().any(|e| e.contains("disallowed for security reasons")), + "expected NO i18n disallowed error for bare , got: {errors:?}" + ); + } +} + +// ============================================================================ +// DEFAULT (non-selectorless) parse mode: an UPPERCASE-leading HTML tag like +// `
", "TestCmp"); + assert!( + !js.contains("ɵɵvalidateAttribute"), + "unexpected ɵɵvalidateAttribute for
, got:\n{js}" + ); + assert!(js.contains("ɵɵattribute"), "expected ɵɵattribute for
, got:\n{js}"); +}