I came to the conclusion that the previous framework was too complicated and not easily extendable so I tried it again but this time with dynamics.
It's much much shorter and I think it's much easier to extend it now. Now it's also much easier to write another renderer that creates i.e. a XDocument/XElement.
Core
The main part of the new framework is the MarkupBuilder
that is derived from the DynamicObject
. It provides all the basic functionality for creating elements.
public class MarkupBuilder : DynamicObject, IEnumerable<object>
{
private readonly List<IMarkupBuilderExtension> _extensions = new List<UserQuery.IMarkupBuilderExtension>();
private MarkupBuilder(MarkupBuilder markupBuilder, string tag)
{
_extensions = markupBuilder._extensions;
MarkupSchema = new MarkupSchema(markupBuilder.MarkupSchema);
Tag = tag;
Attributes = new List<MarkupAttribute>();
Content = new List<object>();
}
public MarkupBuilder(MarkupSchema markupSchema = null)
{
MarkupSchema = new MarkupSchema(markupSchema ?? new MarkupSchema());
Attributes = new List<MarkupAttribute>();
Content = new List<object>();
}
public IEnumerable<IMarkupBuilderExtension> Extensions => _extensions.AsReadOnly();
public MarkupSchema MarkupSchema { get; }
public string Tag { get; }
public List<MarkupAttribute> Attributes { get; }
public List<object> Content { get; }
public MarkupBuilder Parent { get; private set; }
private int Depth
{
get
{
var depth = 0;
var parent = Parent;
while (parent != null)
{
depth++;
parent = parent.Parent;
}
return depth;
}
}
public MarkupBuilder Register<T>() where T : IMarkupBuilderExtension, new()
{
_extensions.Add(new T());
return this;
}
// supports object initializer
public void Add(object content)
{
if (content != null)
{
Content.Add(content);
var htmlElement = content as MarkupBuilder;
if (htmlElement != null)
{
htmlElement.Parent = this;
}
}
}
public MarkupBuilder AddRange(params object[] content)
{
foreach (var item in content)
{
Add(item);
}
return this;
}
public IEnumerator<object> GetEnumerator()
{
return Content.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
// --- DynamicObject members
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
foreach (var extension in Extensions)
{
if (extension.TryGetMember(this, binder, out result))
{
return true;
}
}
result = new MarkupBuilder(this, binder.Name);
return true;
}
public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{
foreach (var extension in Extensions)
{
if (extension.TryInvokeMember(this, binder, args, out result))
{
return true;
}
}
if (MarkupSchema.Tags.Any() && !MarkupSchema.Tags.ContainsKey(binder.Name))
{
throw new NotSupportedException($"Method '{binder.Name}' is not supported.");
}
result = new MarkupBuilder(this, binder.Name).AddRange(args);
return true;
}
}
It can be customized with the MarkupSchema
a very simple schema that defines which tags/attributes are allowed and how to format the markup later:
public class MarkupSchema
{
public MarkupSchema()
{
Tags = new Dictionary<string, MarkupFormattingOptions>();
GlobalAttributes = new HashSet<string>();
IndentWidth = 4;
}
internal MarkupSchema(MarkupSchema other)
{
Tags = new Dictionary<string, MarkupFormattingOptions>(other.Tags);
GlobalAttributes = new HashSet<string>(other.GlobalAttributes);
IndentWidth = other.IndentWidth;
}
public Dictionary<string, MarkupFormattingOptions> Tags { get; set; }
public HashSet<string> GlobalAttributes { get; set; }
public int IndentWidth { get; set; }
// Creates a Html schema.
public static MarkupSchema Html => new MarkupSchema
{
Tags =
{
["body"] = MarkupFormattingOptions.PlaceClosingTagOnNewLine,
["br"] = MarkupFormattingOptions.IsVoid,
["span"] = MarkupFormattingOptions.None,
["p"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h1"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h2"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h3"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h4"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h5"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["h6"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
["ul"] = MarkupFormattingOptions.PlaceBothTagsOnNewLine,
["ol"] = MarkupFormattingOptions.PlaceBothTagsOnNewLine,
["li"] = MarkupFormattingOptions.PlaceOpeningTagOnNewLine,
// ...
},
GlobalAttributes = { "style" }
};
public bool TagHasFormattingOptions(string tagName, MarkupFormattingOptions options)
{
var tagFormattingOptions = MarkupFormattingOptions.None;
return Tags.TryGetValue(tagName, out tagFormattingOptions) ? tagFormattingOptions.HasFlag(options) : false;
}
}
Unfortunatelly the normal C# extensions does not work with a dynamic object so to be able to extend it I created a different system of extensions. Their base is the IMarkupBuilderExtension
interface:
public interface IMarkupBuilderExtension
{
bool TryGetMember(MarkupBuilder element, GetMemberBinder binder, out object result);
bool TryInvokeMember(MarkupBuilder element, InvokeMemberBinder binder, object[] args, out object result);
}
Here are two examples of such extensions. One is for adding css and other for any other attribute:
public class cssExtension : IMarkupBuilderExtension
{
public bool TryGetMember(MarkupBuilder builder, GetMemberBinder binder, out object result)
{
result = null;
return false;
}
public bool TryInvokeMember(MarkupBuilder builder, InvokeMemberBinder binder, object[] args, out object result)
{
if (binder.Name == "css")
{
builder.Attributes.Add(new MarkupAttribute("style") { Value = (string)args[0] });
result = builder;
return true;
}
result = null;
return false;
}
}
public class attrExtension : IMarkupBuilderExtension
{
public bool TryGetMember(MarkupBuilder builder, GetMemberBinder binder, out object result)
{
result = null;
return false;
}
public bool TryInvokeMember(MarkupBuilder builder, InvokeMemberBinder binder, object[] args, out object result)
{
if (binder.Name == "attr")
{
result = new MarkupAttribute((string)args[0]) { Value = (string)args[1] };
return true;
}
result = null;
return false;
}
}
Rendering
I moved the rendering part into a separate class which is now the MarkupRenderer
:
public class MarkupRenderer
{
public static string RenderMarkup(MarkupBuilder builder)
{
return RenderMarkup(builder, builder.MarkupSchema);
}
private static string RenderMarkup(object value, MarkupSchema markupSchema)
{
var markupBuilder = value as MarkupBuilder;
if (markupBuilder == null)
{
return value == null ? string.Empty : (string)value;
}
var content = markupBuilder.Content.Aggregate(
new StringBuilder(),
(sb, next) => sb.Append(MarkupRenderer.RenderMarkup(next, markupSchema))).ToString();
var isEmpty = string.IsNullOrEmpty(content);
var html = new StringBuilder();
if (markupBuilder.Parent != null && markupSchema.TagHasFormattingOptions(markupBuilder.Tag, MarkupFormattingOptions.PlaceOpeningTagOnNewLine))
{
html.AppendLine().Append(IndentString(markupSchema.IndentWidth));
}
html.Append(CreateOpeningElement(markupBuilder));
// if (IsVoid)
// {
// return html.ToString();
// }
if (!isEmpty)
{
html.Append(content);
}
if (!isEmpty && markupSchema.TagHasFormattingOptions(markupBuilder.Tag, MarkupFormattingOptions.PlaceClosingTagOnNewLine))
{
html.AppendLine();
if (markupBuilder.Parent != null) { html.Append(IndentString(markupSchema.IndentWidth)); }
}
html.Append(CreateClosingElement(markupBuilder));
return html.ToString();
}
private static string IndentString(int indentWidth)
{
return new string(' ', indentWidth);
}
private static string CreateOpeningElement(MarkupBuilder markupBuilder)
{
var attributes = CreateAttributesString(markupBuilder);
var html = new StringBuilder()
.Append("<").Append(markupBuilder.Tag)
.Append(string.IsNullOrEmpty(attributes) ? string.Empty : " ")
.Append(attributes)
//.Append(IsVoid ? "/" : string.Empty)
.Append(">")
.ToString();
return html;
}
private static string CreateAttributesString(MarkupBuilder markupBuilder)
{
return string.Join(" ", markupBuilder.Attributes);
}
private static string CreateClosingElement(MarkupBuilder markupBuilder)
{
return markupBuilder.MarkupSchema.TagHasFormattingOptions(markupBuilder.Tag, MarkupFormattingOptions.IsVoid)
? string.Empty
: new StringBuilder()
.Append("</")
.Append(markupBuilder.Tag)
.Append(">")
.ToString();
}
}
There are also two more supporting types.
One is the formatting options enum:
[Flags]
public enum MarkupFormattingOptions
{
None = 0,
PlaceOpeningTagOnNewLine = 1,
PlaceClosingTagOnNewLine = 2,
PlaceBothTagsOnNewLine =
PlaceOpeningTagOnNewLine |
PlaceClosingTagOnNewLine,
IsVoid = 4,
CloseEmptyTag = 8
}
The other is a markup attribute:
public class MarkupAttribute
{
public MarkupAttribute(string name)
{
Name = name;
}
public string Name { get; private set; }
public string Value { get; set; }
public override string ToString()
{
return string.Format("{0}=\"{1}\"", Name, Value);
}
}
Usage/Examples
dynamic html =
new MarkupBuilder(MarkupSchema.Html)
.Register<cssExtension>()
.Register<attrExtension>();
var body = html.body
(
html.p("foo"),
html.p
(
"bar",
html.span("quux").css("blah"),
html.br,
"baz"
)
);
MarkupRenderer.RenderMarkup(body as MarkupBuilder).Dump();
Output:
<body>
<p>foo</p>
<p>bar<span style="blah">quux</span><br>baz</p>
</body>
UserQuery.IMarkupBuilderExtension
is defined just before the extensions example. theUserQuery
is a copy/paste error from LINQPad where I was refactoring/simplifying the code for the question.UserQuery
shouldn't be there. \$\endgroup\$