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>
@this.Tee(...)
? \$\endgroup\$IDictionary<string, MarkupFormattingOptions>
and it has only one propertyint IndentWidth
. \$\endgroup\$