4
\$\begingroup\$

This is my third attempt to create a easy to use html builder because I wasn't really satisfied with the first one that wasn't extendable at all and even the one using dynamics wasn't much better. It was difficult to use due to everything being dynamic and had to be casted everywhere but what's worse it was hard to find the bug if something went wrong.

This time I tried to do it the functional way. You validate how well I did or not.


Based on your suggestions I now use an interface for the base definition:

public interface IMarkupElement : ICollection<object>
{
    string Name { get; }
    IDictionary<string, string> Attributes { get; }
    IMarkupElement Parent { get; set; }
    int Depth { get; }
}

and derive two types from it. The actual markup-element

[DebuggerDisplay("{DebuggerDisplay,nq}")]
public class MarkupElement : IMarkupElement
{
    private readonly List<object> _content = new List<object>();

    public MarkupElement(string name, IEnumerable<object> content)
    {
        Name = name.NonEmptyOrNull() ?? throw new ArgumentNullException(nameof(name));
        Add(content ?? throw new ArgumentNullException(nameof(content)));
        Attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
    }

    private string DebuggerDisplay => $"<{Name} attribute-count=\"{Attributes.Count}\" children-count=\"{Count}\">";

    #region IMarkupElement

    public static IMarkupElement Builder => new NullMarkupElement();
    public string Name { get; }
    public IDictionary<string, string> Attributes { get; }
    public IMarkupElement Parent { get; set; }

    public int Depth
    {
        get
        {
            var parent = Parent;
            var depth = parent != null ? 1 : 0;
            while ((parent = parent?.Parent) != null) depth++;
            return depth;
        }
    }

    #endregion

    #region ICollection<object>

    public int Count => _content.Count;
    public bool IsReadOnly => false;
    public void Add(object item)
    {
        if (item == null) return;

        switch (item)
        {
            case IMarkupElement e: e.Parent = this; break;
            case IEnumerable<object> items when !(item is string): Add(items); break;
        }
        _content.Add(item);
    }
    public bool Contains(object item) => _content.Contains(item);
    public bool Remove(object item) => _content.Remove(item);
    public void Clear() => _content.Clear();
    void ICollection<object>.CopyTo(object[] array, int arrayIndex) => _content.CopyTo(array, arrayIndex);
    public IEnumerator<object> GetEnumerator() => _content.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    #endregion       

    public void Add(IEnumerable<object> items)
    {
        foreach (var item in items) Add(item);
    }
}

and a null-markup-element that is used only as a builder. I'm not happy with this one but I didn't have any better idea.

public class NullMarkupElement : IMarkupElement
{
    private const string Message = "This markup element is only for building other elements from scratch.";
    public string Name => throw new InvalidOperationException(Message);
    public IDictionary<string, string> Attributes => throw new InvalidOperationException(Message);
    public IMarkupElement Parent
    {
        get => throw new InvalidOperationException(Message);
        set => throw new InvalidOperationException(Message);
    }
    public int Depth => throw new InvalidOperationException(Message);
    public int Count => throw new InvalidOperationException(Message);
    public bool IsReadOnly => throw new InvalidOperationException(Message);
    public void Add(object item) => throw new InvalidOperationException(Message);
    public void Clear() => throw new InvalidOperationException(Message);
    public bool Contains(object item) => throw new InvalidOperationException(Message);
    public void CopyTo(object[] array, int arrayIndex) => throw new InvalidOperationException(Message);
    public IEnumerator<object> GetEnumerator() => throw new InvalidOperationException(Message);
    public bool Remove(object item) => throw new InvalidOperationException(Message);
    IEnumerator IEnumerable.GetEnumerator() => throw new InvalidOperationException(Message);
}

To make this actually work there is a set of extensions for creating elements and adding attributs within general extensions:

public static class MarkupElementExtensions
{
    public static IMarkupElement createElement(this IMarkupElement @this, string name, params object[] content)
    {
        var element = new MarkupElement(name, content);
        if (!(@this is NullMarkupElement)) @this.Add(element);
        return element;
    }

    public static IMarkupElement attr(this IMarkupElement @this, string name, string value) => @this.Tee(e => e.Attributes[name] = value);
}

and a few ones for creating elements - now the user can add his own elements in his project:

public static class Html
{
    public static IMarkupElement h1(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h1), content);
    public static IMarkupElement h2(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h2), content);
    public static IMarkupElement h3(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h3), content);
    public static IMarkupElement h4(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h4), content);
    public static IMarkupElement h5(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h5), content);
    public static IMarkupElement h6(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(h6), content);

    public static IMarkupElement p(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(p), content);

    public static IMarkupElement div(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(div), content);
    public static IMarkupElement span(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(span), content);

    public static IMarkupElement table(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(table), content);
    public static IMarkupElement thead(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(thead), content);
    public static IMarkupElement tbody(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(tbody), content);
    public static IMarkupElement tfoot(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(tfoot), content);
    public static IMarkupElement th(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(th), content);
    public static IMarkupElement tr(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(tr), content);
    public static IMarkupElement td(this IMarkupElement @this, params object[] content) => @this.createElement(nameof(td), content);

    public static IMarkupElement id(this IMarkupElement @this, string id) => @this.attr("id", id);
    public static IMarkupElement style(this IMarkupElement @this, params string[] css) => @this.attr("style", string.Join("; ", css.Select(c => $"{c.Trim().TrimEnd(';')};")));
}

There have also been a few changes to the rendering engine that is now able to sanitize/escape/encode values.

public interface ISanitizer
{
    string Sanitize(object value, IFormatProvider formatProvider);
}

public interface IMarkupRenderer
{
    string Render(IMarkupElement markupElement);
}

public abstract class MarkupRenderer : IMarkupRenderer
{
    private readonly IMarkupFormatting _formatting;
    private readonly ISanitizer _sanitizer;
    private readonly IFormatProvider _formatProvider;

    protected MarkupRenderer(IMarkupFormatting formatting, ISanitizer sanitizer, IFormatProvider formatProvider)
    {
        _formatting = formatting ?? throw new ArgumentNullException(nameof(formatting));
        _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer));
        _formatProvider = formatProvider ?? throw new ArgumentNullException(nameof(formatProvider));
    }

    protected MarkupRenderer(IMarkupFormatting formatting, ISanitizer sanitizer)
        : this(formatting, sanitizer, CultureInfo.InvariantCulture)
    { }

    #region IMarkupRenderer

    public string Render(IMarkupElement markupElement)
    {
        if (markupElement is NullMarkupElement) throw new InvalidOperationException("Cannot render markup element builder.");

        var content = (markupElement ?? throw new ArgumentNullException(nameof(markupElement))).Aggregate(
            new StringBuilder(),
            (result, next) => result.Append(
                next is IMarkupElement e
                    ? Render(e)
                    : _sanitizer.Sanitize(next, _formatProvider)
            )
        )
        .ToString();

        var indent = markupElement.Parent != null;
        var placeOpeningTagOnNewLine = _formatting[markupElement.Name].HasFlag(MarkupFormattingOptions.PlaceOpeningTagOnNewLine);
        var placeClosingTagOnNewLine = _formatting[markupElement.Name].HasFlag(MarkupFormattingOptions.PlaceClosingTagOnNewLine);
        var hasClosingTag = _formatting[markupElement.Name].HasFlag(MarkupFormattingOptions.IsVoid) == false;
        var indentString = IndentString(_formatting.IndentWidth, markupElement.Depth);

        var html =
            new StringBuilder()
                .Append(IndentTag(placeOpeningTagOnNewLine, indent, indentString))
                .Append(RenderOpeningTag(markupElement.Name, markupElement.Attributes))
                .AppendWhen(() => hasClosingTag, sb => sb
                    .Append(content)
                    .Append(IndentTag(placeClosingTagOnNewLine, indent, indentString))
                    .Append(RenderClosingTag(markupElement.Name)));

        return html.ToString();
    }

    #endregion

    private static string IndentTag(bool newLine, bool indent, string indentString)
    {
        return
            new StringBuilder()
                .AppendWhen(() => newLine, sb => sb.AppendLine())
                .AppendWhen(() => newLine && indent, sb => sb.Append(indentString))
                .ToString();
    }

    private static string RenderOpeningTag(string tag, IEnumerable<KeyValuePair<string, string>> attributes)
    {
        return
            new StringBuilder()
                .Append($"<{tag}")
                .AppendWhen(
                    () => RenderAttributes(attributes).ToList(),
                    attributeStrings => attributeStrings.Any(),
                    (sb, attributeStrings) => sb.Append($" {(string.Join(" ", attributeStrings))}"))
                .Append(">").ToString();
    }

    private static IEnumerable<string> RenderAttributes(IEnumerable<KeyValuePair<string, string>> attributes)
    {
        return attributes.Select(attr => $"{attr.Key}=\"{attr.Value}\"");
    }

    private static string RenderClosingTag(string tag)
    {
        return $"</{tag}>";
    }

    private static string IndentString(int indentWidth, int depth)
    {
        return new string(' ', indentWidth * depth);
    }
}

the html-renderer adds its own sanitizer:

public class HtmlSanitizer : ISanitizer
{
    public string Sanitize(object value, IFormatProvider formatProvider)
    {
        return System.Web.HttpUtility.HtmlEncode(string.Format(formatProvider, "{0}", value));
    }
}

public class HtmlRenderer : MarkupRenderer
{
    public HtmlRenderer(IMarkupFormatting formatting, ISanitizer sanitizer, IFormatProvider formatProvider)
        : base(formatting, sanitizer, formatProvider)
    { }

    public HtmlRenderer(IMarkupFormatting formatting, ISanitizer sanitizer)
        : this(formatting, sanitizer, CultureInfo.InvariantCulture)
    { }

    public HtmlRenderer(IMarkupFormatting formatting)
        : this(formatting, new HtmlSanitizer(), CultureInfo.InvariantCulture)
    { }
}

There also two helper extensions supporting the string-builder:

public static class StringBuilderExtensions
{
    public static StringBuilder AppendWhen(this StringBuilder @this, Func<bool> predicate, Func<StringBuilder, StringBuilder> append)
    {
        return predicate() ? append(@this) : @this;
    }

    public static StringBuilder AppendWhen<T>(this StringBuilder @this, Func<T> getValue, Func<T, bool> predicate, Func<StringBuilder, T, StringBuilder> append)
    {
        var value = getValue();
        return predicate(value) ? append(@this, value) : @this;
    }
}

public static class FunctionalExtensions
{
    public static T Tee<T>(this T @this, Action<T> tee)
    {
        tee(@this);
        return @this;
    }
}

Example

var html = MarkupElement.Builder;
var table = html.table();
table
    .tbody(
        html.tr(html.td("foo"), html.td("bar")),
        html.tr(html.td("foo"), html.td("bar")))
    .style("font-size: 2em;");

var htmlRenderer = new HtmlRenderer(new HtmlFormatting());
var result = htmlRenderer.Render(table);

result

<table>
    <tbody style="font-size: 2em;">
        <tr>
            <td>foo</td>
            <td>bar</td>
        </tr>
        <tr>
            <td>foo</td>
            <td>bar</td>
        </tr>
    </tbody>
</table>
\$\endgroup\$
5
  • \$\begingroup\$ What's @this.Tee(...) ? \$\endgroup\$
    – Nikita B
    Mar 28, 2017 at 9:21
  • 1
    \$\begingroup\$ @NikitaB this is the last of the extensions, just above the example, it allows to apply something to an item and return it at the same time. tee I got the idea from a video about functional programming on pluralsight but I don't have the link to it right now. \$\endgroup\$
    – t3chb0t
    Mar 28, 2017 at 9:25
  • \$\begingroup\$ Before I build the code to answer your questions I see that IMarkupFormatting is missing. Could you paste it in? Thanks. \$\endgroup\$ May 31, 2018 at 8:26
  • \$\begingroup\$ Oh, sorry. I would paste it but the code has already changed several times and I'm no longer sure if the current version would still match the code in the question. The interface is very simple. It's inherited from IDictionary<string, MarkupFormattingOptions> and it has only one property int IndentWidth. \$\endgroup\$
    – t3chb0t
    May 31, 2018 at 8:32
  • \$\begingroup\$ If you're curious, you can take a look at my current repository here but I'd rather not change the question since it's probably no longer compatible. \$\endgroup\$
    – t3chb0t
    May 31, 2018 at 8:35

1 Answer 1

3
\$\begingroup\$
NonEmptyOrNull()

the name is ambiguous. Is it !empty || null or is it !(empty or null)? Easy to get it wrong. I would just use regular !String.IsNullOrEmpty() - no ambiguity here.


content ?? throw new ArgumentNullException(nameof(content))

why content can not be null? I would assume that at some point you are going to add an element with no children/content. Should I pass new IMarkupElement[0] in that case? I would rather just pass null (or pass nothing at all).


public int Depth
{
    get
    {
        var parent = Parent;
        var depth = parent != null ? 1 : 0;
        while ((parent = parent?.Parent) != null) depth++;
        return depth;
    }
}

Can't you just return Parent == null ? 0 : Parent.Depth + 1?


Add(object item)

IMHO, this method's implemntation has big wtfsurprise factor. Imagine if ArrayList were to silently drop some of the items you've added based on some undocumented criteria. I suggest you:

  1. Use Add(object) only to add single object to the collection, because that's the contract defined by ICollection<T>. Use different AddRange method to add multiple items.
  2. throw if invalid item is added. Otherwise add the item to collection.
  3. Document any weird or unexpected behavior. You might have a reason to implement Add(object) that way, but this reason is not obvious.

NullMarkupElement should not have public constructor since it is only used in specific place with specific purpose. You can also consider just straight up using ((IMarkupElement)null) as your "builder".


Overall I think this implementation would be easier to understand/maintain if you were to "sanitize" during html construction, and not during rendering. Instead of having special rules on how to render non-IMarkupElement objects, you should have special rules on how to convert those object to IMarkupElement. This way you should be able to work with strongly typed IMarkupElement : ICollection<IMarkupElement> instead of IMarkupElement : ICollection<object>.

\$\endgroup\$
6
  • \$\begingroup\$ NonEmptyOrNull() is a helper so that I can use the throw expression ;-] \$\endgroup\$
    – t3chb0t
    Mar 28, 2017 at 8:59
  • 2
    \$\begingroup\$ @t3chb0t, maybe call it NullIfEmpty or EmptyToNull then. Assuming that's what this method does \$\endgroup\$
    – Nikita B
    Mar 28, 2017 at 9:19
  • \$\begingroup\$ This is a good idea (these are all good ideas) Yes, this is exactly what it does, return a null if string is null or empty so that I can use ?? throw. One thing about the sanitazation process. I was actually doing it when build html but I kept forgetting it all the time so I thought I move it to the renderer and don't care about it anymore. \$\endgroup\$
    – t3chb0t
    Mar 28, 2017 at 9:23
  • \$\begingroup\$ @t3chb0t it should be really hard to forget, if you replace ICollection<object> with ICollection<IMarkupElement>. It just won't compile if you try to add something other than IMarkupElement to your html tree. \$\endgroup\$
    – Nikita B
    Mar 28, 2017 at 9:28
  • \$\begingroup\$ I've incorporated most of the suggestions as public int Depth => Parent?.Depth + 1 ?? 0; and public static IMarkupElement Builder => default(IMarkupElement); which is pretty cool because the entire dummy class can now be removed - however I'm not sure what to do with the sanitizer and how to change it from ICollection<object> to ICollection<IMarkupElement> because at some point I will need the object for example to store a string. Actually I think I need different types of node for all this but I'd like to keep it simple rather then implementing the html/xml specification ;-) \$\endgroup\$
    – t3chb0t
    Mar 28, 2017 at 13:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.