Hacking Teacup: Double Macchiato

Teacup (HTML templating in CoffeeScript) is a fantastic DSL (domain specific language). Less "specific" than Jade, but it's a programming language, so the potential for extending is exciting! I've been writing "helpers" to abstract HTML details, make my templates a higher level DSL.

Teacup limitations

  1. But, although Teacup has nice facilities for extendibility, it's restricted by its rendering architecture: a single pass, depth first recursion, immediately rendering CS→HTML. This limitation obviously prevents post-processing of nested contents by wrappers higher up the call stack, given, of course, we don't want to manipulate/parse rendered HTML — that would smell wrong, and require a complex and slow full parser… which really misses the point.
  2. Two use cases I'm sorely missing: patching any nested content to, eg, provide defaults, and freely composing helpers, eg:
    # Allow wrapping immediate calls:
    mockup initially_hidden screen '#home'
    # But also support wrapping callbacks:
    mockup ->initially_hidden ->screen '#home'
    (First form calls inner, then wrapper; second form — reversed order.)
  3. Thus, Teacup must be hacked!
  4. Took some contemplation, brewing, stewing… to arrive at this clear, obvious solution: keep existing API/DSL, make two passes, monkeypatch first pass to generate a DOM-like tree of objects, then stringify it to HTML on second pass.

History

  1. Developed as a patch for Teacup, first, then tried to make it a Teacup plugin, to monkeypatch it at runtime using its "use" API. Eventually, though, decided to radically clean the source, rewrite it from scratch, and renamed "Double Macchiato".

DSL

Dreamcode! (Stolen from Hoodie. ;o)

Examples:

  1. Boilerplate:
    module.exports=renderable (params)->
    	boilerplate -> # Rely on useful defaults...
    where "boilerplate" can manipulate (as JSON, not HTML!) the results of the nested template function, to fill in defaults, instead of:
    renderable ->
    	doctype 5
    	html ->
    		head -> # Pile of "boilerplate": charset, viewport...
    		body -> #...
    
  2. Inject inlined CSS (style="display:none") to anything — or things:
    initially_hidden anything #...
    or
    initially_hidden ->
    	any #...
    	thing #...

Side effects

How to avoid having to explicitly accumulate rendered HTML?

  1. Side effects: all tag functions append their results to a shared (global) buffer, htmlOut.
  2. Essentially, Teacup templates consist of tag functions and contents callbacks. "Tag" (ie HTML elements) functions are wrappers around:
    tag:(tagName,args...)->
    	{attrs,contents}=@normalizeArgs args
    	@raw "<#{tagName}#{@renderAttrs attrs}>"
    	@renderContents contents
    	@raw "</#{tagName}>"
    
    and variations thereof.
  3. Contents callbacks are plain CoffeeScript functions! Which is the reason for the internal buffer trick — avoids having to concatenate results explicitly:
    raw:(s)->
    	return unless s?
    	@htmlOut += s
    	null

Two passes and API compatibility

  1. We need a two pass mechanism: first render into attributes and sub-contents (hashes and arrays, respectively), accumulate into internal tree, then convert entire tree to HTML.
  2. Trying to keep to Teacup's API, so need to rewrite/monkeypatch specific functions.
  3. tag (and rawTag, selfClosingTag…) must change: replace the HTML rendering buffering side effect trick with "buffering" a tree of objects.
  4. render and renderable need to make two passes instead of one.
  5. Second pass: render_html to recursively convert entire tree to an HTML string.
  6. component? Other APIs?

Reverse engineering

From the outside in — interface to internals.

  1. Usecases:
    initially_hidden div '#event',->
    	h2 '.name','Eventful!'
    	console.log 'NOP' # No operation. Not even side effects.
    	iconify '.fa-diamond','Iconified'
    	wrapper ->
    		p '.blurb'
    		'Really?'
  2. div, h2 and p are "tags", anonymous functions wrapping tag:
    for t in merge_elements 'regular','obsolete'
    	do (t)->
    		Teacup::[t]=(args...)->@tag t,args...
    
    They simply call tag, prepending their own name to any arguments they received, eg, tag 'div','#event',callback.
  3. div with callback, h2 with constant, p with… nothing.
  4. Our tag method needs to change.
  5. initially_hidden, iconify and wrapper are the corresponding syntactic styles of post-processing helpers: wrap a returned value, expression, or callback? Here be dragons.
  6. tag will call raw to buffer HTML strings, and renderContents via the "contents" callback, which in turn mostly just runs that callback — in the context of Teacup, with apply, so any tag functions used in it will have that same side effect.

Straight solution: rewrite Teacup

  1. @htmlOut and resetBuffer: I renamed it swap_buffer, and the buffer became @children; same logic, essentially.
  2. render: (template, args...) ->
    	(@renderable template)(args...)
    
    Yeah, that's weird, could instead switch them around, make renderable a factory returning render closures…
  3. renderable: (template) ->
    	teacup=@
    	return (args...)->
    		teacup.children=[]
    		template.apply @,args
    		return teacup.render_children teacup.children
    
  4. render_children:(ch)->
    	if Array.isArray ch
    		(@render_html e for e in ch).join ''
    	else
    		ch
  5. render_html:(e)->
    	if typeof e is 'string' then e
    	else if e.type is 'selfClosingTag' then "<#{e.element}#{@renderAttrs e.attributes} />"
    	else "<#{e.element}#{@renderAttrs e.attributes}>#{@render_children e.contents}"
    (But, drop the trailing slash! XHTML is dead!)
  6. tag: (tagName,args...)->
    	{attrs,contents}=@normalizeArgs args
    	@children.push element:tagName,attributes:attrs,contents:@renderContents contents
  7. selfClosingTag requires a variation:
    @children.push element:tag,attributes:attrs,type:'selfClosingTag'
    Et cetera.

Helpers

All this headache was for my growing collection of Teacup and Stylus helpers (yet to be published, real soon now).

This two passes hack is a bit radical. Will it interoperate with other helpers/plugins? Could it be a separate plugin? Is this still Teacup?

And what will the community say?


--
The real world is a special case