10
\$\begingroup\$

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>
\$\endgroup\$
7
  • \$\begingroup\$ I guess my comments below is invalid as your code as changed \$\endgroup\$
    – Tolani
    Aug 1, 2016 at 1:21
  • \$\begingroup\$ @TolaniJaiye-Tikolo I've updated it slightly because there were no comments yet at that point. I wouldn't have done it if I already have seen any. I can restore the previous version. \$\endgroup\$
    – t3chb0t
    Aug 1, 2016 at 3:37
  • \$\begingroup\$ It's alright.. I wasn't refreshing my screen for a while and hence I didn't see the changes- I can work with the new one. No worries. \$\endgroup\$
    – Tolani
    Aug 1, 2016 at 6:21
  • \$\begingroup\$ Mate, where have you got UserQuery.IMarkupBuilderExtension defined? I can't seem to find it. \$\endgroup\$
    – Tolani
    Aug 1, 2016 at 23:21
  • \$\begingroup\$ @TolaniJaiye-Tikolo the UserQuery.IMarkupBuilderExtensionis defined just before the extensions example. the UserQuery is a copy/paste error from LINQPad where I was refactoring/simplifying the code for the question. UserQuery shouldn't be there. \$\endgroup\$
    – t3chb0t
    Aug 2, 2016 at 3:45

2 Answers 2

5
\$\begingroup\$

This looks really weird:

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;
    }

what is the point of casting MarkupBuilder to object and then casting it back?


As I'm sure you know, classes' and methods' names should start with capital letter and blah blah as per C# naming convention. Why do you insist on using camel case?


You should make your api more consistent:

public void Add(object content)

public MarkupBuilder AddRange(params object[] content)

either return MarkupBuilder from both methods, or make both methods void.


As for your design, my main concern is that MarkupBuilder appears to have two distinct responsibilities. First - constructing an html element using extensions and dynamic methods. Second - holding the actual result of construction: a strongly typed data. I think you should have two separate classes for that. So the usage would look like this:

var body = 
         //describe the markup
         html.body(html.p("foo"))
         //return strongly typed non-dynamic and, possibly, immutable object, that holds actual data
         .Build();

You know, similar to how StringBuilder works, where you call ToString() once you are done, and get rid of all the extra overhead.

P.S. Personally, I find dynamic objects useful as part of internal implementation, for which you write unit tests and leave it alone. I would not use dynamics as part of public API. Yes the code is shorter. But at the same time it is more complex and is more error prone. It's really easy to get dynamic objects wrong, and there is no compiler to tell you that "hey, this, html.spun method you have just called, looks suspicions, did you mean span?" So i would rather implement a bunch of extra classes once (as you did in your previous implementation), than hunt dynamic-related bugs for the rest of my life. :)

\$\endgroup\$
3
  • \$\begingroup\$ This looks really weird - I thought the same and was experimenting with different interfaces so I actually rewrote it and now it uses an interface and only a single recursive method. MarkupBuilder appears to have two distinct responsibilities - I started with inheritance and separated concerns but it didn't work with the DynamicObject and it couldn't find some methods. I need to defend this design. The previous one couldn't be extended by the user and this one is super easy to extend. I'll post the refactored code with some more examples later. \$\endgroup\$
    – t3chb0t
    Aug 3, 2016 at 10:10
  • \$\begingroup\$ I don't have time to implement a schema validation but all tags and attributes could be validated so there would be no bug hunt. On the other side it's just html so it's really easy to spot a bug in the markup. \$\endgroup\$
    – t3chb0t
    Aug 3, 2016 at 10:11
  • \$\begingroup\$ Add vs AddRange - the rationale behind this was that the Add as void is required by the object initializer for IEnumerable<T> types and the AddRange should return the object so that I don't have to use a helper variables and can chain it aka fluent API. I admit this isn't consistant but I didn't find another way to solve it. \$\endgroup\$
    – t3chb0t
    Aug 3, 2016 at 10:43
2
\$\begingroup\$

To be honest, there isn't enough to refactor here as your code looks clean but I notice in your constructors you've this:

MarkupSchema = new MarkupSchema(markupSchema ?? new MarkupSchema());

You could just do this:

MarkupSchema = markupSchema ?? new MarkupSchema();

Your two constructors are doing similar stuff and the first one is private, you could just do this

public MarkupBuilder(MarkupSchema markupSchema = null)
: this(markupSchema,string.Empty)
    {
    }

Your code can be extended for XML documents as well. All you need is to ensure xmlRenderer includes <?xml version="1.0" encoding="UTF-8"?> to the output and a root element with other contents as child elements.

\$\endgroup\$
1
  • \$\begingroup\$ Thank you for your comments ;-) no refactoring is also a good sign ;-] as to the constructor. The rationale behind it is that theMarkupSchemaisn't immutable so I use a private copy constructor and a private property so that the user cannot change the values after creating the builder. Or he could be the changes would have no effect on the schema anymore. I didn't know how else I could prevent it and passing those huge arrays aka schema definitions didn't sound like a good idea either. \$\endgroup\$
    – t3chb0t
    Aug 2, 2016 at 3:49

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.