File: Rendering\TagBuilder.cs
Web Access
Project: src\src\Mvc\Mvc.ViewFeatures\src\Microsoft.AspNetCore.Mvc.ViewFeatures.csproj (Microsoft.AspNetCore.Mvc.ViewFeatures)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable enable
 
using System.Buffers;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
 
namespace Microsoft.AspNetCore.Mvc.Rendering;
 
/// <summary>
/// Contains methods and properties that are used to create HTML elements. This class is often used to write HTML
/// helpers and tag helpers.
/// </summary>
[DebuggerDisplay("{DebuggerToString()}")]
public class TagBuilder : IHtmlContent
{
    // Note '.' is valid according to the HTML 4.01 specification. Disallowed here
    // to avoid confusion with CSS class selectors or when using jQuery.
    private static readonly SearchValues<char> _html401IdChars =
        SearchValues.Create("-0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");
 
    private AttributeDictionary? _attributes;
    private HtmlContentBuilder? _innerHtml;
 
    /// <summary>
    /// Creates a new HTML tag that has the specified tag name.
    /// </summary>
    /// <param name="tagName">An HTML tag name.</param>
    public TagBuilder(string tagName)
    {
        ArgumentException.ThrowIfNullOrEmpty(tagName);
 
        TagName = tagName;
    }
 
    /// <summary>
    /// Creates a copy of the HTML tag passed as <paramref name="tagBuilder"/>.
    /// </summary>
    /// <param name="tagBuilder">Tag to copy.</param>
    public TagBuilder(TagBuilder tagBuilder)
    {
        if (tagBuilder == null)
        {
            throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(tagBuilder));
        }
 
        if (tagBuilder._attributes != null)
        {
            foreach (var tag in tagBuilder._attributes)
            {
                Attributes.Add(tag);
            }
        }
 
        if (tagBuilder._innerHtml != null)
        {
            tagBuilder.InnerHtml.CopyTo(InnerHtml);
        }
 
        TagName = tagBuilder.TagName;
        TagRenderMode = tagBuilder.TagRenderMode;
    }
 
    /// <summary>
    /// Gets the set of attributes that will be written to the tag.
    /// </summary>
    public AttributeDictionary Attributes
    {
        get
        {
            // Perf: Avoid allocating `_attributes` if possible
            if (_attributes == null)
            {
                _attributes = new AttributeDictionary();
            }
 
            return _attributes;
        }
    }
 
    /// <summary>
    /// Gets the inner HTML content of the element.
    /// </summary>
    public IHtmlContentBuilder InnerHtml
    {
        get
        {
            if (_innerHtml == null)
            {
                _innerHtml = new HtmlContentBuilder();
            }
 
            return _innerHtml;
        }
    }
 
    /// <summary>
    /// Gets an indication <see cref="InnerHtml"/> is not empty.
    /// </summary>
    public bool HasInnerHtml => _innerHtml?.Count > 0;
 
    /// <summary>
    /// Gets the tag name for this tag.
    /// </summary>
    public string TagName { get; }
 
    /// <summary>
    /// The <see cref="Rendering.TagRenderMode"/> with which the tag is written.
    /// </summary>
    /// <remarks>Defaults to <see cref="TagRenderMode.Normal"/>.</remarks>
    public TagRenderMode TagRenderMode { get; set; } = TagRenderMode.Normal;
 
    /// <summary>
    /// Adds a CSS class to the list of CSS classes in the tag.
    /// If there are already CSS classes on the tag then a space character and the new class will be appended to
    /// the existing list.
    /// </summary>
    /// <param name="value">The CSS class name to add.</param>
    public void AddCssClass(string value)
    {
        if (Attributes.TryGetValue("class", out var currentValue))
        {
            Attributes["class"] = currentValue + " " + value;
        }
        else
        {
            Attributes["class"] = value;
        }
    }
 
    /// <summary>
    /// Returns a valid HTML 4.01 "id" attribute value for an element with the given <paramref name="name"/>.
    /// </summary>
    /// <param name="name">
    /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
    /// </param>
    /// <param name="invalidCharReplacement">
    /// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
    /// <paramref name="name"/>.
    /// </param>
    /// <returns>
    /// Valid HTML 4.01 "id" attribute value for an element with the given <paramref name="name"/>.
    /// </returns>
    /// <remarks>
    /// Valid "id" attributes are defined in <see href="https://www.w3.org/TR/html401/types.html#type-id"/>.
    /// </remarks>
    public static string CreateSanitizedId(string? name, string invalidCharReplacement)
    {
        ArgumentNullException.ThrowIfNull(invalidCharReplacement);
 
        if (string.IsNullOrEmpty(name))
        {
            return string.Empty;
        }
 
        // If there are no invalid characters in the string, then we don't have to create the buffer.
        var indexOfInvalidCharacter = name.AsSpan(1).IndexOfAnyExcept(_html401IdChars);
        var firstChar = name[0];
        var startsWithAsciiLetter = char.IsAsciiLetter(firstChar);
        if (startsWithAsciiLetter && indexOfInvalidCharacter < 0)
        {
            return name;
        }
 
        if (!startsWithAsciiLetter)
        {
            // The first character must be a letter according to the HTML 4.01 specification.
            firstChar = 'z';
        }
 
        var stringBuffer = new StringBuilder(name.Length);
        stringBuffer.Append(firstChar);
        var remainingName = name.AsSpan(1);
 
        // Copy values until an invalid character found. Replace the invalid character with the replacement string
        // and search for the next invalid character.
        while (remainingName.Length > 0)
        {
            if (indexOfInvalidCharacter < 0)
            {
                stringBuffer.Append(remainingName);
                break;
            }
 
            stringBuffer.Append(remainingName.Slice(0, indexOfInvalidCharacter));
            stringBuffer.Append(invalidCharReplacement);
            remainingName = remainingName.Slice(indexOfInvalidCharacter + 1);
            indexOfInvalidCharacter = remainingName.IndexOfAnyExcept(_html401IdChars);
        }
        return stringBuffer.ToString();
    }
 
    /// <summary>
    /// Adds a valid HTML 4.01 "id" attribute for an element with the given <paramref name="name"/>. Does
    /// nothing if <see cref="Attributes"/> already contains an "id" attribute or the <paramref name="name"/>
    /// is <c>null</c> or empty.
    /// </summary>
    /// <param name="name">
    /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
    /// </param>
    /// <param name="invalidCharReplacement">
    /// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
    /// <paramref name="name"/>.
    /// </param>
    /// <seealso cref="CreateSanitizedId(string, string)"/>
    public void GenerateId(string name, string invalidCharReplacement)
    {
        ArgumentNullException.ThrowIfNull(invalidCharReplacement);
 
        if (string.IsNullOrEmpty(name))
        {
            return;
        }
 
        if (!Attributes.ContainsKey("id"))
        {
            var sanitizedId = CreateSanitizedId(name, invalidCharReplacement);
 
            // Duplicate check for null or empty to cover the corner case where name contains only invalid
            // characters and invalidCharReplacement is empty.
            if (!string.IsNullOrEmpty(sanitizedId))
            {
                Attributes["id"] = sanitizedId;
            }
        }
    }
 
    private void AppendAttributes(TextWriter writer, HtmlEncoder encoder)
    {
        // Perf: Avoid allocating enumerator for `_attributes` if possible
        if (_attributes != null && _attributes.Count > 0)
        {
            foreach (var attribute in Attributes)
            {
                var key = attribute.Key;
                if (string.Equals(key, "id", StringComparison.OrdinalIgnoreCase) &&
                    string.IsNullOrEmpty(attribute.Value))
                {
                    continue;
                }
 
                writer.Write(" ");
                writer.Write(key);
                writer.Write("=\"");
                if (attribute.Value != null)
                {
                    encoder.Encode(writer, attribute.Value);
                }
 
                writer.Write("\"");
            }
        }
    }
 
    /// <summary>
    /// Merge an attribute.
    /// </summary>
    /// <param name="key">The attribute key.</param>
    /// <param name="value">The attribute value.</param>
    public void MergeAttribute(string key, string? value)
    {
        MergeAttribute(key, value, replaceExisting: false);
    }
 
    /// <summary>
    /// Merge an attribute.
    /// </summary>
    /// <param name="key">The attribute key.</param>
    /// <param name="value">The attribute value.</param>
    /// <param name="replaceExisting">Whether to replace an existing value.</param>
    public void MergeAttribute(string key, string? value, bool replaceExisting)
    {
        ArgumentException.ThrowIfNullOrEmpty(key);
 
        if (replaceExisting || !Attributes.ContainsKey(key))
        {
            Attributes[key] = value;
        }
    }
 
    /// <summary>
    /// Merge an attribute dictionary.
    /// </summary>
    /// <typeparam name="TKey">The key type.</typeparam>
    /// <typeparam name="TValue">The value type.</typeparam>
    /// <param name="attributes">The attributes.</param>
    public void MergeAttributes<TKey, TValue>(IDictionary<TKey, TValue?> attributes)
    {
        MergeAttributes(attributes, replaceExisting: false);
    }
 
    /// <summary>
    /// Merge an attribute dictionary.
    /// </summary>
    /// <typeparam name="TKey">The key type.</typeparam>
    /// <typeparam name="TValue">The value type.</typeparam>
    /// <param name="attributes">The attributes.</param>
    /// <param name="replaceExisting">Whether to replace existing attributes.</param>
    public void MergeAttributes<TKey, TValue>(IDictionary<TKey, TValue?> attributes, bool replaceExisting)
    {
        // Perf: Avoid allocating enumerator for `attributes` if possible
        if (attributes != null && attributes.Count > 0)
        {
            foreach (var entry in attributes)
            {
                var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture)!;
                var value = Convert.ToString(entry.Value, CultureInfo.InvariantCulture);
                MergeAttribute(key, value, replaceExisting);
            }
        }
    }
 
    /// <inheritdoc />
    public void WriteTo(TextWriter writer, HtmlEncoder encoder)
    {
        ArgumentNullException.ThrowIfNull(writer);
        ArgumentNullException.ThrowIfNull(encoder);
 
        WriteTo(this, writer, encoder, TagRenderMode);
    }
 
    /// <summary>
    /// Returns an <see cref="IHtmlContent"/> that renders the body.
    /// </summary>
    /// <returns>An <see cref="IHtmlContent"/> that renders the body.</returns>
    public IHtmlContent? RenderBody() => _innerHtml;
 
    /// <summary>
    /// Returns an <see cref="IHtmlContent"/> that renders the start tag.
    /// </summary>
    /// <returns>An <see cref="IHtmlContent"/> that renders the start tag.</returns>
    public IHtmlContent RenderStartTag() => new RenderTagHtmlContent(this, TagRenderMode.StartTag);
 
    /// <summary>
    /// Returns an <see cref="IHtmlContent"/> that renders the end tag.
    /// </summary>
    /// <returns>An <see cref="IHtmlContent"/> that renders the end tag.</returns>
    public IHtmlContent RenderEndTag() => new RenderTagHtmlContent(this, TagRenderMode.EndTag);
 
    /// <summary>
    /// Returns an <see cref="IHtmlContent"/> that renders the self-closing tag.
    /// </summary>
    /// <returns>An <see cref="IHtmlContent"/> that renders the self-closing tag.</returns>
    public IHtmlContent RenderSelfClosingTag() => new RenderTagHtmlContent(this, TagRenderMode.SelfClosing);
 
    private static void WriteTo(
        TagBuilder tagBuilder,
        TextWriter writer,
        HtmlEncoder encoder,
        TagRenderMode tagRenderMode)
    {
        switch (tagRenderMode)
        {
            case TagRenderMode.StartTag:
                writer.Write("<");
                writer.Write(tagBuilder.TagName);
                tagBuilder.AppendAttributes(writer, encoder);
                writer.Write(">");
                break;
            case TagRenderMode.EndTag:
                writer.Write("</");
                writer.Write(tagBuilder.TagName);
                writer.Write(">");
                break;
            case TagRenderMode.SelfClosing:
                writer.Write("<");
                writer.Write(tagBuilder.TagName);
                tagBuilder.AppendAttributes(writer, encoder);
                writer.Write(" />");
                break;
            default:
                writer.Write("<");
                writer.Write(tagBuilder.TagName);
                tagBuilder.AppendAttributes(writer, encoder);
                writer.Write(">");
                tagBuilder._innerHtml?.WriteTo(writer, encoder);
                writer.Write("</");
                writer.Write(tagBuilder.TagName);
                writer.Write(">");
                break;
        }
    }
 
    private string DebuggerToString()
    {
        using (var writer = new StringWriter())
        {
            WriteTo(writer, HtmlEncoder.Default);
            return writer.ToString();
        }
    }
 
    private sealed class RenderTagHtmlContent : IHtmlContent
    {
        private readonly TagBuilder _tagBuilder;
        private readonly TagRenderMode _tagRenderMode;
 
        public RenderTagHtmlContent(TagBuilder tagBuilder, TagRenderMode tagRenderMode)
        {
            _tagBuilder = tagBuilder;
            _tagRenderMode = tagRenderMode;
        }
 
        public void WriteTo(TextWriter writer, HtmlEncoder encoder)
        {
            TagBuilder.WriteTo(_tagBuilder, writer, encoder, _tagRenderMode);
        }
    }
}