MarkupBuilder

A tag stack that makes a mismatched close unrepresentable.

Updated 2 min read

MarkupBuilder keeps a stack of open elements, escapes every value, validates every name and closes whatever is left open when you are done.

MethodEffect
open(tag) / open(tag, name, value, ...)Writes the tag, pushes it
close()Closes the innermost open element
el(tag, content) / el(tag, content, name, value, ...)Open, escaped text, close
text(value)Escaped; any type with a : string cast
raw(html)Unescaped; the caller owns safety
comment(text)An HTML comment
depth()Number of elements currently open
done()Closes anything still open, returns the string

Why close takes no argument

The stack already holds the tag name, so a mismatched close is impossible to write. There is no close("div") that can drift out of sync with the open("section") above it.

Void elements — img, input, br, meta, link and the rest — are never pushed, so they need no close at all.

Compile-time and run-time guards

An odd number of attribute arguments is a compile-time error:

h.open("a", "href");  // compilerError: attributes are name/value pairs

A tag or attribute name outside [A-Za-z][A-Za-z0-9_:-]* is dropped and logged rather than emitted, because such a name came from somewhere it should not have.

Escaping, and the one hole

text and el escape &, <, >, " and ' — in attribute values as well as in text nodes. raw does not escape, and exists for composing already-safe fragments: the output of another MarkupBuilder, or the return value of island.

Passing request data to raw is a cross-site scripting bug. If a string reached you from a request, from a file or from a database, it belongs in text, or it belongs in a sanitiser you can point at.

Scoping and conditional classes

defer scopes an element to a Chapel block, and classList builds a conditional class attribute from name/condition pairs:

{
  h.open("tr", "class", classList("warn", node.degraded, "down", node.offline));
  defer h.close();
  h.el("td", node.name);
}

Composing fragments

Because done() returns a string, a page can build its parts independently and assemble them:

proc page(ctx: Context, ref meta: PageMeta): string {
  var h = new MarkupBuilder();
  h.raw(breadcrumbs(doc.urlPath));
  h.open("article");
  h.raw(doc.html);
  return h.done();
}

Each raw here takes markup produced by another builder, which is exactly what it is for.