diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs
index 7aafc14cfe3..891454248fb 100644
--- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs
+++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs
@@ -173,6 +173,25 @@ public void Execute_MixedHtmlContent_MultipleHTMLEntities_DoesNotSetEncoded()
Assert.False(node.HasEncodedContent);
}
+ [Fact]
+ public void Execute_MixedHtmlContent_HexadecimalHTMLEntities_DoesNotSetEncoded()
+ {
+ // Arrange
+ var document = CreateDocument(@"
+
Symbols AB== @DateTime.Now
");
+ var expected = NormalizeContent("Symbols AB== ");
+
+ var documentNode = Lower(document);
+
+ // Act
+ Pass.Execute(document, documentNode);
+
+ // Assert
+ var node = documentNode.FindDescendantNodes().Single();
+ Assert.Equal(expected, GetHtmlContent(node));
+ Assert.False(node.HasEncodedContent);
+ }
+
private string NormalizeContent(string content)
{
// Normalize newlines since we are testing lengths of things.
diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs
new file mode 100644
index 00000000000..3363a8f1929
--- /dev/null
+++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs
@@ -0,0 +1,378 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Razor.Language.Legacy;
+
+public class ParserHelpersTest
+{
+ [Theory]
+ [InlineData("&", "&")]
+ [InlineData("<", "<")]
+ [InlineData(">", ">")]
+ [InlineData(""", "\"")]
+ [InlineData("'", "'")]
+ [InlineData(" ", "\u00A0")]
+ [InlineData("©", "\u00A9")]
+ [InlineData("®", "\u00AE")]
+ [InlineData("™", "\u2122")]
+ [InlineData("…", "\u2026")]
+ [InlineData("—", "\u2014")]
+ [InlineData("–", "\u2013")]
+ [InlineData("«", "\u00AB")]
+ [InlineData("»", "\u00BB")]
+ [InlineData("Á", "\u00C1")]
+ [InlineData("á", "\u00E1")]
+ [InlineData("Α", "\u0391")]
+ [InlineData("α", "\u03B1")]
+ public void TryGetHtmlEntity_ValidNamedEntities_ReturnsTrue(string input, string expectedReplacement)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+
+ [Theory]
+ [InlineData("A", "A")] // Basic decimal
+ [InlineData("a", "a")] // Basic decimal
+ [InlineData(" ", " ")] // Space character
+ [InlineData("©", "\u00A9")] // Copyright symbol
+ [InlineData("€", "\u20AC")] // Euro symbol
+ [InlineData("", "\uFFFF")] // Max BMP character
+ [InlineData("™", "\u2122")] // Trademark symbol
+ public void TryGetHtmlEntity_ValidDecimalEntities_ReturnsTrue(string input, string expectedReplacement)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+
+ [Theory]
+ [InlineData("A", "A")] // Basic hex lowercase
+ [InlineData("A", "A")] // Basic hex uppercase
+ [InlineData("a", "a")] // Basic hex lowercase
+ [InlineData("a", "a")] // Basic hex uppercase
+ [InlineData(" ", " ")] // Space character
+ [InlineData("©", "\u00A9")] // Copyright symbol
+ [InlineData("©", "\u00A9")] // Copyright symbol uppercase
+ [InlineData("€", "\u20AC")] // Euro symbol
+ [InlineData("", "\uFFFF")] // Max BMP character
+ public void TryGetHtmlEntity_ValidHexEntities_ReturnsTrue(string input, string expectedReplacement)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+
+ [Theory]
+ [InlineData("x41;", "A")] // 0x prefix lowercase
+ [InlineData("X41;", "A")] // 0x prefix uppercase
+ [InlineData("x61;", "a")] // 0x prefix lowercase
+ [InlineData("X61;", "a")] // 0x prefix uppercase
+ [InlineData("x20;", " ")] // Space character
+ [InlineData("xa9;", "\u00A9")] // Copyright symbol
+ [InlineData("XA9;", "\u00A9")] // Copyright symbol uppercase
+ public void TryGetHtmlEntity_InvalidHexEntitiesWithZeroPrefix_ReturnsTrue(string input, string expectedReplacement)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+
+ [Theory]
+ [InlineData("&")] // Just ampersand
+ [InlineData("&")] // No semicolon
+ [InlineData("&invalid;")] // Invalid named entity
+ [InlineData("¬valid;")] // Invalid named entity
+ [InlineData("&xyz123;")] // Invalid named entity
+ [InlineData("")] // Empty numeric entity
+ [InlineData("")] // Empty hex entity
+ [InlineData("x;")] // Empty 0x entity
+ [InlineData("abc;")] // Invalid decimal digits
+ [InlineData("ghi;")] // Invalid hex digits
+ [InlineData("xghi;")] // Invalid 0x hex digits
+ [InlineData("& extra")] // No semicolon with extra content
+ [InlineData("&am p;")] // Space in entity name
+ public void TryGetHtmlEntity_InvalidEntities_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Theory]
+ [InlineData("")] // Null character (below valid range)
+ [InlineData("")] // Control character (below valid range)
+ [InlineData("")] // Surrogate range start (0xD800)
+ [InlineData("")] // Surrogate range end (0xDFFF)
+ [InlineData("𐀀")] // Above BMP (0x10000)
+ [InlineData("")] // Max Unicode (0x10FFFF)
+ [InlineData("")] // Above max Unicode
+ [InlineData("")] // Hex null character
+ [InlineData("")] // Hex surrogate start
+ [InlineData("")] // Hex surrogate end
+ [InlineData("𐀀")] // Hex above BMP
+ public void TryGetHtmlEntity_OutOfRangeNumericEntities_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Fact]
+ public void TryGetHtmlEntity_EmptyInput_ReturnsFalse()
+ {
+ // Arrange
+ var content = ReadOnlyMemory.Empty;
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Theory]
+ [InlineData("a")] // Doesn't start with &
+ [InlineData("hello")] // Regular text
+ [InlineData("123")] // Numbers
+ [InlineData("")] // Empty string
+ public void TryGetHtmlEntity_NotStartingWithAmpersand_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Theory]
+ [InlineData("&extra", "&", "&")]
+ [InlineData("<more", "<", "<")]
+ [InlineData("Atext", "A", "A")]
+ [InlineData("Amore", "A", "A")]
+ [InlineData("©right", "©", "\u00A9")]
+ public void TryGetHtmlEntity_EntityWithExtraContent_ParsesOnlyEntity(string input, string expectedEntity, string expectedReplacement)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(expectedEntity, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+
+ [Theory]
+ [InlineData("&")] // Valid entity name without semicolon
+ [InlineData("<")] // Valid entity name without semicolon
+ [InlineData(">")] // Valid entity name without semicolon
+ [InlineData("©")] // Valid entity name without semicolon
+ [InlineData("A")] // Valid numeric without semicolon
+ [InlineData("A")] // Valid hex without semicolon
+ public void TryGetHtmlEntity_ValidEntityWithoutSemicolon_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Theory]
+ [InlineData("& ")] // Entity followed by space
+ [InlineData("&<")] // Multiple entities
+ [InlineData("&text")] // Entity followed by text
+ public void TryGetHtmlEntity_EntityFollowedByOtherCharacters_StopsAtSemicolon(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal("&", entity.ToString());
+ Assert.Equal("&", replacement);
+ }
+
+ [Theory]
+ [InlineData("&a;")] // Single character entity (invalid)
+ [InlineData("&ab;")] // Two character entity (invalid)
+ [InlineData("&123;")] // Numeric named entity (invalid)
+ [InlineData("&a1b;")] // Mixed alphanumeric (invalid)
+ public void TryGetHtmlEntity_ShortInvalidNamedEntities_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Theory]
+ [InlineData("-1;")] // Negative number
+ [InlineData("-1;")] // Negative hex
+ [InlineData("x-1;")] // Negative 0x hex
+ [InlineData(".5;")] // Decimal point
+ [InlineData(".5;")] // Decimal point in hex
+ public void TryGetHtmlEntity_InvalidNumericFormats_ReturnsFalse(string input)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+
+ [Fact]
+ public void TryGetHtmlEntity_LongValidEntity_ReturnsTrue()
+ {
+ // Arrange
+ var input = "∳"; // One of the longer entity names
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal("\u2233", replacement);
+ }
+
+ [Theory]
+ // Decimal boundary tests
+ [InlineData("!", "!")] // Boundary: 0x21 (valid)
+ [InlineData(" ", " ")] // Boundary: 0x20 (valid - minimum valid)
+ [InlineData("")] // Boundary: 0x1F (invalid - just below minimum)
+ [InlineData("", "\uD7FF")] // Boundary: 0xD7FF (valid, just before surrogate range)
+ [InlineData("")] // Boundary: 0xD800 (invalid - surrogate range start)
+ [InlineData("")] // Boundary: 0xDFFF (invalid - surrogate range end)
+ [InlineData("", "\uE000")] // Boundary: 0xE000 (valid, just after surrogate range)
+ [InlineData("", "\uFFFF")] // Boundary: 0xFFFF (valid - maximum valid)
+ [InlineData("𐀀")] // Boundary: 0x10000 (invalid - just above maximum)
+ // Hexadecimal boundary tests
+ [InlineData("!", "!")] // Boundary: 0x21 (valid)
+ [InlineData(" ", " ")] // Boundary: 0x20 (valid - minimum valid)
+ [InlineData("")] // Boundary: 0x1F (invalid - just below minimum)
+ [InlineData("", "\uD7FF")] // Boundary: 0xD7FF (valid, just before surrogate range)
+ [InlineData("")] // Boundary: 0xD800 (invalid - surrogate range start)
+ [InlineData("")] // Boundary: 0xDFFF (invalid - surrogate range end)
+ [InlineData("", "\uE000")] // Boundary: 0xE000 (valid, just after surrogate range)
+ [InlineData("", "\uFFFF")] // Boundary: 0xFFFF (valid - maximum valid)
+ [InlineData("𐀀")] // Boundary: 0x10000 (invalid - just above maximum)
+ public void TryGetHtmlEntity_BoundaryValues_HandledCorrectly(string input, string? expectedReplacement = null)
+ {
+ // Arrange
+ var content = input.AsMemory();
+ var shouldSucceed = expectedReplacement != null;
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ if (shouldSucceed)
+ {
+ Assert.True(result);
+ Assert.Equal(input, entity.ToString());
+ Assert.Equal(expectedReplacement, replacement);
+ }
+ else
+ {
+ Assert.False(result);
+ Assert.True(entity.IsEmpty);
+ Assert.Null(replacement);
+ }
+ }
+
+ [Theory]
+ [InlineData("&", 5)] // Full entity
+ [InlineData("&extra", 5)] // Entity with extra content
+ [InlineData("A", 5)] // Numeric entity
+ [InlineData("A", 6)] // Hex entity
+ [InlineData("x41;", 7)] // 0x hex entity
+ public void TryGetHtmlEntity_ReturnsCorrectEntityLength(string input, int expectedEntityLength)
+ {
+ // Arrange
+ var content = input.AsMemory();
+
+ // Act
+ var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(expectedEntityLength, entity.Length);
+ Assert.NotNull(replacement);
+ }
+}
diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs
index 84cb08fd29c..af80e16ace4 100644
--- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs
+++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs
@@ -1,24 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-#nullable disable
-
using System;
-using System.Collections.Generic;
-using System.Linq;
+using System.Collections.Frozen;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using Microsoft.AspNetCore.Razor.Language.Intermediate;
using Microsoft.AspNetCore.Razor.Language.Legacy;
+using Microsoft.AspNetCore.Razor.PooledObjects;
namespace Microsoft.AspNetCore.Razor.Language.Components;
-internal class ComponentMarkupEncodingPass : ComponentIntermediateNodePassBase, IRazorOptimizationPass
+internal sealed class ComponentMarkupEncodingPass(RazorLanguageVersion version) : ComponentIntermediateNodePassBase, IRazorOptimizationPass
{
- private readonly RazorLanguageVersion _version;
-
- public ComponentMarkupEncodingPass(RazorLanguageVersion version)
- {
- _version = version;
- }
+ private readonly RazorLanguageVersion _version = version;
// Runs after ComponentMarkupBlockPass
public override int Order => ComponentMarkupDiagnosticPass.DefaultOrder + 20;
@@ -40,41 +36,47 @@ protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentInte
rewriter.Visit(documentNode);
}
- private class Rewriter : IntermediateNodeWalker
+ private sealed class Rewriter(RazorLanguageVersion version) : IntermediateNodeWalker
{
- // Markup content in components are rendered in one of the following two ways,
- // AddContent - we encode it when used with prerendering and inserted into the DOM in a safe way (low perf impact)
- // AddMarkupContent - renders the content directly as markup (high perf impact)
+ // Markup content in components are rendered in one of the following two ways:
+ //
+ // 1. AddContent - we encode it when used with pre-rendering and inserted into the DOM in a safe way (low perf impact)
+ // 2. AddMarkupContent - renders the content directly as markup (high perf impact)
+ //
// Because of this, we want to use AddContent as much as possible.
//
- // We want to use AddMarkupContent to avoid aggressive encoding during prerendering.
- // Specifically, when one of the following characters are in the content,
+ // We want to use AddMarkupContent to avoid aggressive encoding during pre-rendering.
+ // Specifically, when one of the following characters are in the content:
+ //
// 1. New lines (\r, \n), tabs (\t), angle brackets (<, >) - so they get rendered as actual new lines, tabs, brackets instead of
// 2. Any character outside the ASCII range
- private static readonly char[] EncodedCharacters = new[] { '\r', '\n', '\t', '<', '>' };
+ private static readonly FrozenSet EncodedCharacters = ['\r', '\n', '\t', '<', '>'];
- private readonly bool _avoidEncodingScripts;
- private readonly Dictionary _seenEntities = new Dictionary(StringComparer.Ordinal);
+ private readonly bool _avoidEncodingScripts = version >= RazorLanguageVersion.Version_8_0;
private bool _avoidEncodingContent;
- public Rewriter(RazorLanguageVersion version)
- {
- _avoidEncodingScripts = version >= RazorLanguageVersion.Version_8_0;
- }
-
public override void VisitMarkupElement(MarkupElementIntermediateNode node)
{
// We don't want to HTML-encode literal content inside