diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 75849c6923..f51ad6bac7 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -164,6 +164,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers internal static string FormatFormActionTagHelper_CannotOverrideFormAction(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8, object p9) => string.Format(CultureInfo.CurrentCulture, GetString("FormActionTagHelper_CannotOverrideFormAction"), p0, p1, p2, p3, p4, p5, p6, p7, p8, p9); + /// + /// Value cannot contain whitespace. + /// + internal static string ArgumentCannotContainHtmlSpace + { + get => GetString("ArgumentCannotContainHtmlSpace"); + } + + /// + /// Value cannot contain whitespace. + /// + internal static string FormatArgumentCannotContainHtmlSpace() + => GetString("ArgumentCannotContainHtmlSpace"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx index 9ef50b9953..7242ee001b 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -150,4 +150,7 @@ Cannot override the '{0}' attribute for <{1}>. <{1}> elements with a specified '{0}' must not have attributes starting with '{2}' or an '{3}', '{4}', '{5}', '{6}', '{7}', '{8}' or '{9}' attribute. + + Value cannot contain HTML space characters. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs index f9cc053ae8..8075b5bf0e 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs @@ -18,6 +18,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// public static class TagHelperOutputExtensions { + private static readonly char[] SpaceChars = { '\u0020', '\u0009', '\u000A', '\u000C', '\u000D' }; + /// /// Copies a user-provided attribute from 's /// to 's @@ -154,6 +156,167 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } } + /// + /// Adds the given to the 's + /// . + /// + /// The this method extends. + /// The class value to add. + /// The current HTML encoder. + public static void AddClass( + this TagHelperOutput tagHelperOutput, + string classValue, + HtmlEncoder htmlEncoder) + { + if (tagHelperOutput == null) + { + throw new ArgumentNullException(nameof(tagHelperOutput)); + } + + if (string.IsNullOrEmpty(classValue)) + { + return; + } + + var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString())).ToArray(); + + if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.IndexOf(value, StringComparison.Ordinal) >= 0)) + { + throw new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue)); + } + + if (!tagHelperOutput.Attributes.TryGetAttribute("class", out TagHelperAttribute classAttribute)) + { + tagHelperOutput.Attributes.Add("class", classValue); + } + else + { + var currentClassValue = ExtractClassValue(classAttribute, htmlEncoder); + + var encodedClassValue = htmlEncoder.Encode(classValue); + + if (string.Equals(currentClassValue, encodedClassValue, StringComparison.Ordinal)) + { + return; + } + + var arrayOfClasses = currentClassValue.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries) + .SelectMany(perhapsEncoded => perhapsEncoded.Split(encodedSpaceChars, StringSplitOptions.RemoveEmptyEntries)) + .ToArray(); + + if (arrayOfClasses.Contains(encodedClassValue, StringComparer.Ordinal)) + { + return; + } + + var newClassAttribute = new TagHelperAttribute( + classAttribute.Name, + new HtmlString($"{currentClassValue} {encodedClassValue}"), + classAttribute.ValueStyle); + + tagHelperOutput.Attributes.SetAttribute(newClassAttribute); + } + } + + /// + /// Removes the given from the 's + /// . + /// + /// The this method extends. + /// The class value to remove. + /// The current HTML encoder. + public static void RemoveClass( + this TagHelperOutput tagHelperOutput, + string classValue, + HtmlEncoder htmlEncoder) + { + if (tagHelperOutput == null) + { + throw new ArgumentNullException(nameof(tagHelperOutput)); + } + + var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString())).ToArray(); + + if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.IndexOf(value, StringComparison.Ordinal) >= 0)) + { + throw new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue)); + } + + if (!tagHelperOutput.Attributes.TryGetAttribute("class", out TagHelperAttribute classAttribute)) + { + return; + } + + var currentClassValue = ExtractClassValue(classAttribute, htmlEncoder); + + if (string.IsNullOrEmpty(currentClassValue)) + { + return; + } + + var encodedClassValue = htmlEncoder.Encode(classValue); + + if (string.Equals(currentClassValue, encodedClassValue, StringComparison.Ordinal)) + { + tagHelperOutput.Attributes.Remove(tagHelperOutput.Attributes["class"]); + return; + } + + if (!currentClassValue.Contains(encodedClassValue)) + { + return; + } + + var listOfClasses = currentClassValue.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries) + .SelectMany(perhapsEncoded => perhapsEncoded.Split(encodedSpaceChars, StringSplitOptions.RemoveEmptyEntries)) + .ToList(); + + if (!listOfClasses.Contains(encodedClassValue)) + { + return; + } + + listOfClasses.RemoveAll(x => x.Equals(encodedClassValue)); + + if (listOfClasses.Any()) + { + var joinedClasses = new HtmlString(string.Join(" ", listOfClasses)); + tagHelperOutput.Attributes.SetAttribute(classAttribute.Name, joinedClasses); + } + else + { + tagHelperOutput.Attributes.Remove(tagHelperOutput.Attributes["class"]); + } + } + + private static string ExtractClassValue( + TagHelperAttribute classAttribute, + HtmlEncoder htmlEncoder) + { + string extractedClassValue; + switch (classAttribute.Value) + { + case string valueAsString: + extractedClassValue = htmlEncoder.Encode(valueAsString); + break; + case HtmlString valueAsHtmlString: + extractedClassValue = valueAsHtmlString.Value; + break; + case IHtmlContent htmlContent: + using (var stringWriter = new StringWriter()) + { + htmlContent.WriteTo(stringWriter, htmlEncoder); + extractedClassValue = stringWriter.ToString(); + } + break; + default: + extractedClassValue = htmlEncoder.Encode(classAttribute.Value?.ToString()); + break; + } + var currentClassValue = extractedClassValue ?? string.Empty; + return currentClassValue; + } + private static void CopyHtmlAttribute( int allAttributeIndex, TagHelperOutput tagHelperOutput, diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs index d5ee84f4fc..2c1cc87c07 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs @@ -6,9 +6,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Razor.TagHelpers.Testing; using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.WebEncoders.Testing; +using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.TagHelpers @@ -968,5 +971,184 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers attribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("for")); Assert.Equal(expectedBuilderAttribute.Value, attribute.Value); } + + [Fact] + public void Single_AddClass() + { + // Arrange + var expectedValue = "class=\"HtmlEncode[[btn]]\""; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + // Act + tagHelperOutput.AddClass("btn", htmlEncoder); + + // Assert + var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class")); + Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute)); + } + + [Fact] + public void Multiple_AddClass() + { + // Arrange + var expectedValue = "class=\"HtmlEncode[[btn]] HtmlEncode[[btn-primary]]\""; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + // Act + tagHelperOutput.AddClass("btn", htmlEncoder); + tagHelperOutput.AddClass("btn-primary", htmlEncoder); + + // Assert + var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class")); + Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute)); + } + + [Fact] + public void Multiple_AddClass_RemoveClass_RemovesAllButOne() + { + // Arrange + var expectedValue = "class=\"HtmlEncode[[btn]]\""; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + tagHelperOutput.AddClass("btn", htmlEncoder); + tagHelperOutput.AddClass("btn-success", htmlEncoder); + tagHelperOutput.AddClass("btn-primary", htmlEncoder); + + // Act + tagHelperOutput.RemoveClass("btn-success", htmlEncoder); + tagHelperOutput.RemoveClass("btn-primary", htmlEncoder); + + // Assert + var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class")); + Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute)); + } + + [Fact] + public void AddClass_RemoveClass_ContainsSpace() + { + // Arrange + var classValue = "btn btn-success"; + var expected = new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue)).Message; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + // Act and Assert + var exceptionAdd = Assert.Throws(() => tagHelperOutput.AddClass(classValue, htmlEncoder)); + var exceptionRemove = Assert.Throws(() => tagHelperOutput.RemoveClass(classValue, htmlEncoder)); + Assert.Equal(expected, exceptionAdd.Message); + Assert.Equal(expected, exceptionRemove.Message); + } + + [Fact] + public void Single_RemoveClass_RemovesDuplicates_RemovesEntirely() + { + // Arrange + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn]]")); + + // Act + tagHelperOutput.RemoveClass("btn", htmlEncoder); + + // Assert + var classAttribute = tagHelperOutput.Attributes["class"]; + Assert.Null(classAttribute); + } + + [Fact] + public void Single_RemoveClass_RemovesDuplicates() + { + // Arrange + var expectedValue = "class=\"HtmlEncode[[btn-primary]]\""; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn-primary]] HtmlEncode[[btn]]")); + + // Act + tagHelperOutput.RemoveClass("btn", htmlEncoder); + + // Assert + var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class")); + Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute)); + } + + [Fact] + public void Single_RemoveClass_RemovesEntirely() + { + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]]")); + + // Act + tagHelperOutput.RemoveClass("btn", htmlEncoder); + + // Assert + var classAttribute = tagHelperOutput.Attributes["class"]; + Assert.Null(classAttribute); + } + + [Fact] + public void Single_RemoveClass() + { + // Arrange + var expectedValue = "class=\"HtmlEncode[[btn]]\""; + var htmlEncoder = new HtmlTestEncoder(); + + var tagHelperOutput = new TagHelperOutput( + tagName: "p", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( + new DefaultTagHelperContent())); + + tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn-primary]]")); + + // Act + tagHelperOutput.RemoveClass("btn-primary", htmlEncoder); + + // Assert + var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class")); + Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute)); + } } }