File: TagHelperOutputExtensions.cs
Web Access
Project: src\src\Mvc\Mvc.TagHelpers\src\Microsoft.AspNetCore.Mvc.TagHelpers.csproj (Microsoft.AspNetCore.Mvc.TagHelpers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Linq;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
 
namespace Microsoft.AspNetCore.Mvc.TagHelpers;
 
/// <summary>
/// Utility related extensions for <see cref="TagHelperOutput"/>.
/// </summary>
public static class TagHelperOutputExtensions
{
    private static readonly char[] SpaceChars = { '\u0020', '\u0009', '\u000A', '\u000C', '\u000D' };
 
    /// <summary>
    /// Copies a user-provided attribute from <paramref name="context"/>'s
    /// <see cref="TagHelperContext.AllAttributes"/> to <paramref name="tagHelperOutput"/>'s
    /// <see cref="TagHelperOutput.Attributes"/>.
    /// </summary>
    /// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
    /// <param name="attributeName">The name of the bound attribute.</param>
    /// <param name="context">The <see cref="TagHelperContext"/>.</param>
    /// <remarks>
    /// <para>
    /// Only copies the attribute if <paramref name="tagHelperOutput"/>'s
    /// <see cref="TagHelperOutput.Attributes"/> does not contain an attribute with the given
    /// <paramref name="attributeName"/>.
    /// </para>
    /// <para>
    /// Duplicate attributes same name in <paramref name="context"/>'s <see cref="TagHelperContext.AllAttributes"/>
    /// or <paramref name="tagHelperOutput"/>'s <see cref="TagHelperOutput.Attributes"/> may result in copied
    /// attribute order not being maintained.
    /// </para></remarks>
    public static void CopyHtmlAttribute(
        this TagHelperOutput tagHelperOutput,
        string attributeName,
        TagHelperContext context)
    {
        ArgumentNullException.ThrowIfNull(tagHelperOutput);
        ArgumentNullException.ThrowIfNull(attributeName);
        ArgumentNullException.ThrowIfNull(context);
 
        if (!tagHelperOutput.Attributes.ContainsName(attributeName))
        {
            var copiedAttribute = false;
 
            // We iterate context.AllAttributes backwards since we prioritize TagHelperOutput values occurring
            // before the current context.AllAttributes[i].
            for (var i = context.AllAttributes.Count - 1; i >= 0; i--)
            {
                // We look for the original attribute so we can restore the exact attribute name the user typed in
                // approximately the same position where the user wrote it in the Razor source.
                if (string.Equals(
                    attributeName,
                    context.AllAttributes[i].Name,
                    StringComparison.OrdinalIgnoreCase))
                {
                    CopyHtmlAttribute(i, tagHelperOutput, context);
                    copiedAttribute = true;
                }
            }
 
            if (!copiedAttribute)
            {
                throw new ArgumentException(
                    Resources.FormatTagHelperOutput_AttributeDoesNotExist(attributeName, nameof(TagHelperContext)),
                    nameof(attributeName));
            }
        }
    }
 
    /// <summary>
    /// Merges the given <paramref name="tagBuilder"/>'s <see cref="TagBuilder.Attributes"/> into the
    /// <paramref name="tagHelperOutput"/>.
    /// </summary>
    /// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
    /// <param name="tagBuilder">The <see cref="TagBuilder"/> to merge attributes from.</param>
    /// <remarks>Existing <see cref="TagHelperOutput.Attributes"/> on the given <paramref name="tagHelperOutput"/>
    /// are not overridden; "class" attributes are merged with spaces.</remarks>
    public static void MergeAttributes(this TagHelperOutput tagHelperOutput, TagBuilder tagBuilder)
    {
        ArgumentNullException.ThrowIfNull(tagHelperOutput);
        ArgumentNullException.ThrowIfNull(tagBuilder);
 
        foreach (var attribute in tagBuilder.Attributes)
        {
            if (!tagHelperOutput.Attributes.ContainsName(attribute.Key))
            {
                tagHelperOutput.Attributes.Add(attribute.Key, attribute.Value);
            }
            else if (string.Equals(attribute.Key, "class", StringComparison.OrdinalIgnoreCase))
            {
                var found = tagHelperOutput.Attributes.TryGetAttribute("class", out var classAttribute);
                Debug.Assert(found);
 
                var newAttribute = new TagHelperAttribute(
                    classAttribute.Name,
                    new ClassAttributeHtmlContent(classAttribute.Value, attribute.Value),
                    classAttribute.ValueStyle);
 
                tagHelperOutput.Attributes.SetAttribute(newAttribute);
            }
        }
    }
 
    /// <summary>
    /// Removes the given <paramref name="attributes"/> from <paramref name="tagHelperOutput"/>'s
    /// <see cref="TagHelperOutput.Attributes"/>.
    /// </summary>
    /// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
    /// <param name="attributes">Attributes to remove.</param>
    public static void RemoveRange(
        this TagHelperOutput tagHelperOutput,
        IEnumerable<TagHelperAttribute> attributes)
    {
        ArgumentNullException.ThrowIfNull(tagHelperOutput);
        ArgumentNullException.ThrowIfNull(attributes);
 
        foreach (var attribute in attributes.ToArray())
        {
            tagHelperOutput.Attributes.Remove(attribute);
        }
    }
 
    /// <summary>
    /// Adds the given <paramref name="classValue"/> to the <paramref name="tagHelperOutput"/>'s
    /// <see cref="TagHelperOutput.Attributes"/>.
    /// </summary>
    /// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
    /// <param name="classValue">The class value to add.</param>
    /// <param name="htmlEncoder">The current HTML encoder.</param>
    public static void AddClass(
        this TagHelperOutput tagHelperOutput,
        string classValue,
        HtmlEncoder htmlEncoder)
    {
        ArgumentNullException.ThrowIfNull(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.Contains(value, StringComparison.Ordinal)))
        {
            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);
        }
    }
 
    /// <summary>
    /// Removes the given <paramref name="classValue"/> from the <paramref name="tagHelperOutput"/>'s
    /// <see cref="TagHelperOutput.Attributes"/>.
    /// </summary>
    /// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
    /// <param name="classValue">The class value to remove.</param>
    /// <param name="htmlEncoder">The current HTML encoder.</param>
    public static void RemoveClass(
        this TagHelperOutput tagHelperOutput,
        string classValue,
        HtmlEncoder htmlEncoder)
    {
        ArgumentNullException.ThrowIfNull(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.Contains(value, StringComparison.Ordinal)))
        {
            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(classAttribute);
            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.Count > 0)
        {
            var joinedClasses = new HtmlString(string.Join(' ', listOfClasses));
            tagHelperOutput.Attributes.SetAttribute(classAttribute.Name, joinedClasses);
        }
        else
        {
            tagHelperOutput.Attributes.Remove(classAttribute);
        }
    }
 
    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,
        TagHelperContext context)
    {
        var allAttributes = context.AllAttributes;
        var existingAttribute = allAttributes[allAttributeIndex];
 
        // Move backwards through context.AllAttributes from the provided index until we find a familiar attribute
        // in tagHelperOutput where we can insert the copied value after the familiar one.
        for (var i = allAttributeIndex - 1; i >= 0; i--)
        {
            var previousName = allAttributes[i].Name;
            var index = IndexOfFirstMatch(previousName, tagHelperOutput.Attributes);
            if (index != -1)
            {
                tagHelperOutput.Attributes.Insert(index + 1, existingAttribute);
                return;
            }
        }
 
        // Read interface .Count once rather than per iteration
        var allAttributesCount = allAttributes.Count;
        // Move forward through context.AllAttributes from the provided index until we find a familiar attribute in
        // tagHelperOutput where we can insert the copied value.
        for (var i = allAttributeIndex + 1; i < allAttributesCount; i++)
        {
            var nextName = allAttributes[i].Name;
            var index = IndexOfFirstMatch(nextName, tagHelperOutput.Attributes);
            if (index != -1)
            {
                tagHelperOutput.Attributes.Insert(index, existingAttribute);
                return;
            }
        }
 
        // Couldn't determine the attribute's location, add it to the end.
        tagHelperOutput.Attributes.Add(existingAttribute);
    }
 
    private static int IndexOfFirstMatch(string name, TagHelperAttributeList attributes)
    {
        // Read interface .Count once rather than per iteration
        var attributesCount = attributes.Count;
        for (var i = 0; i < attributesCount; i++)
        {
            if (string.Equals(name, attributes[i].Name, StringComparison.OrdinalIgnoreCase))
            {
                return i;
            }
        }
 
        return -1;
    }
 
    private sealed class ClassAttributeHtmlContent : IHtmlContent
    {
        private readonly object _left;
        private readonly string _right;
 
        public ClassAttributeHtmlContent(object left, string right)
        {
            _left = left;
            _right = right;
        }
 
        public void WriteTo(TextWriter writer, HtmlEncoder encoder)
        {
            ArgumentNullException.ThrowIfNull(writer);
            ArgumentNullException.ThrowIfNull(encoder);
 
            // Write out "{left} {right}" in the common nothing-empty case.
            var wroteLeft = false;
            if (_left != null)
            {
                if (_left is IHtmlContent htmlContent)
                {
                    // Ignore case where htmlContent is HtmlString.Empty. At worst, will add a leading space to the
                    // generated attribute value.
                    htmlContent.WriteTo(writer, encoder);
                    wroteLeft = true;
                }
                else
                {
                    var stringValue = _left.ToString();
                    if (!string.IsNullOrEmpty(stringValue))
                    {
                        encoder.Encode(writer, stringValue);
                        wroteLeft = true;
                    }
                }
            }
 
            if (!string.IsNullOrEmpty(_right))
            {
                if (wroteLeft)
                {
                    writer.Write(' ');
                }
 
                encoder.Encode(writer, _right);
            }
        }
    }
}