Templating-lang sections
By @lucasdicioccio, 1231 words, 10 code snippets, 3 links, 0images.
templating-lang is a small
template language: a jq-flavoured expression language for the data half, a
HAML-like block syntax for structure, and a deliberately tiny set of builtins.
KitchenSink embeds its Haskell implementation, templating-hs, as a second
section pre-processor next to Dhall-sections. A
playground hosted on this site lets you try the language
itself, outside of any KitchenSink section.
Both backends do the same job and both remain supported. Templating-lang is,
however, the direction of travel: dhall and dhall-json are notoriously
awkward to build and they have repeatedly held KitchenSink back from moving to
newer GHC versions, whereas templating-hs needs nothing beyond megaparsec
and aeson. Expect Dhall-sections to be deprecated eventually; nothing is
being removed today.
Two section formats
Where Dhall has one format, templating-lang has two, because the language distinguishes producing data from producing a document.
.templating sections are rooted at an expression and evaluate to a JSON
value. That value is the same {format, contents} contract Dhall-sections
answer with, so the section can rewrite itself to json, cmark or html.
This is the format to reach for as a drop-in replacement for a Dhall section.
.templating-doc sections are rooted at an element — .div(...), .ul(...) —
and evaluate to a document tree, which KitchenSink renders to HTML. There is no
{format, contents} envelope here: the result is always HTML.
The two roots can never be confused: an element root always starts with . and
no expression form does. The format token in the section header nevertheless
says which mode you meant, so a mistake is a parse error rather than a
surprise.
The $ctx object
Templating-lang always has the input context in scope as $ctx. KitchenSink
fills it with the very same information the kitchensink object carries in
Dhall-sections:
{ file : Text -- the source file path of this cmark file
, sectionNum : Integer -- this section's number in the file, from zero
, datasets : object -- dataset cells declared *earlier* in this file
, vars : object -- the --var name=value pairs given on the command line
}
So kitchensink.datasets.my-name becomes $ctx.datasets.my-name, and
kitchensink.file becomes $ctx.file.
Like Dhall-sections, templating sections are pre-processors: they are evaluated once, at load time, and only see datasets declared before them in the file. Unlike Dhall, there are no imports — local or networked — so a template cannot pull in a shared library of helpers. That is a real gap against Dhall, and the reason the two coexist.
Examples
The rest of this page is its own fixture: every output below is produced by a templating section in this very file.
A dataset to work from
=base:dataset.json crew
{"members": [{"name": "Alice", "posts": 22}
,{"name": "Bob", "posts": 7}
]
}
Generating a dataset
A .templating section returning format = "json" in a dataset cell declares a
new dataset, visible to every later section — the same way a hand-written
=base:dataset.json cell is.
=base:dataset.templating crew-summary
@members=$ctx.datasets.crew.members
@n=cardinality($members)
{ "format": "json"
, "contents": { "count": $n
, "names": map($members, (m) => $m.name)
}
}
Note that $n stays a JSON number and names stays a JSON array: expression
mode never stringifies its result.
Rendering a section in CommonMark
Reading back the dataset the previous section generated:
=base:main-content.templating
@summary=$ctx.datasets.crew-summary
{ "format": "cmark"
, "contents": [ "::: output"
, "__generated from a templating section__"
, ""
, "file=`$ctx.file`, section=`$ctx.sectionNum`"
, ""
, "`$summary.count` crew member(s)"
, ":::"
]
}
Each element of contents is one line. format may also be "html", in which
case the lines are used verbatim, or "json", in which case contents is an
arbitrary JSON value rather than a list of lines.
generated from a templating section
file=./website-src/sections-templating.cmark, section=12
2 crew member(s)
Rendering a document tree
The same data, but built structurally rather than as CommonMark lines:
=base:main-content.templating-doc
@members=$ctx.datasets.crew.members
.div(class: "output",
.h3("Crew"),
.ul(map($members, (m) => .li("`$m.name` wrote `$m.posts` post(s)"))))
Attributes come first in an element’s argument list, then children;
map(collection, (item) => node) expands to one child per item.
Crew
- Alice wrote 22 post(s)
- Bob wrote 7 post(s)
Wiring action(...) to JavaScript
action(eventType, key, payload) is the language’s interactivity hook. In a
browser host it binds to a real event handler; in a statically-produced page
there is no dispatcher to bind to, so KitchenSink writes the evaluated action
onto the element as three data attributes and leaves the dispatching to you.
=base:main-content.templating-doc
@members=$ctx.datasets.crew.members
.div(class: "output",
.p(id: "action-log", "no action yet — click a name"),
.ul(map($members, (m) =>
.li(.button(action("on-click", "select-member", {"name": $m.name, "posts": $m.posts}),
$m.name)))))
Each button comes out carrying its own payload:
<button data-ks-action-event="on-click"
data-ks-action-key="select-member"
data-ks-action-payload="{"name":"Alice","posts":22}"
>Alice</button>
The payload is escaped on the way out, as any attribute value is, so
el.dataset.ksActionPayload hands back the original JSON text and JSON.parse
is all that is needed. Note that posts is still the number 22 there: the
payload goes through the expression half of the language, which does not
stringify, unlike the class attribute next to it.
no action yet — click a name
A dispatcher is then one query and one switch: look up elements by
data-ks-action-event, and branch on data-ks-action-key — the key is the
contract between the template and the page, and an unrecognised one should be
ignored rather than crash the page.
(function () {
function dispatch(key, payload) {
var log = document.getElementById('action-log');
switch (key) {
case 'select-member':
log.textContent = payload.name + ' wrote ' + payload.posts + ' post(s)';
break;
default:
log.textContent = 'unhandled action: ' + key;
}
}
document.querySelectorAll('[data-ks-action-event="on-click"]').forEach(function (el) {
el.addEventListener('click', function () {
dispatch(el.dataset.ksActionKey, JSON.parse(el.dataset.ksActionPayload));
});
});
})();That snippet is live on this page: the buttons above are wired by it, in a plain
<script> block in a =base:main-content.cmark section. A real page would
rather serve it as a .js file next to the article and reference it with
<script src>; inline here only so that one file shows the whole loop.
Neither the event type nor the key is a fixed vocabulary: the language requires
both to evaluate to a string and passes them through untouched, so
action("on-hover", …) or a computed action($ctx.eventName, …) reach the page
just as well. That makes the two attributes a pair of dispatch dimensions —
which browser event to bind, and what to do when it fires — and both are yours
to define. The selector above happens to bind clicks only.
Using partial templates
Templating-lang support partial templates and KitchenSink exposes them using a specific section close to datasets.
The icon tag next to library.templating-lib correspond to the name given to
the library in further imports.
The context passed to imported libraries is entirely determined by the caller, KitchenSink does not add all the fancy datasets, they have to be passed from the call site.
A limitation today is that import must refer to a library defined above the evaluated section. In future release we will support libraries defined in other files.
=base:library.templating-lib icon
.span(class: "icon",
.a(href: $ctx.href,
title: $ctx.title,
.img(height:16, width:16, src:$ctx.src)
))
We can then import this icon template and apply it.
=base:main-content.templating-doc
@icon=partial-import("icon",{"href":"", "title": "demo-templated-icon"})
@icon1=$icon({"src": "/images/favicon.png"})
@icon2=$icon({"src": "/images/features-001-targetsizes-timeseries.png"})
@icon3=$icon({"src": "/images/features-002-dot-demo.dot.png"})
.div(class: "output",
.p("Below are multiple ", .em("icons"), "."),
$icon1.rendered,
$icon2.rendered,
$icon3.rendered
)
Notes and limitations
Text interpolation goes through the language’s display rules: a string
interpolates raw, an integral number drops its .0, and anything else falls
back to a compact JSON encoding. In .templating-doc mode attribute values are
display strings too — that mode produces a document, not data — which is
exactly why expression mode exists for JSON payloads.
String literals have no escape sequences: a " or a backtick simply cannot
appear inside one, since the backtick is the interpolation delimiter. So a
template can emit a <script> or an HTML attribute containing quotes only
awkwardly — prefer single quotes in generated JavaScript, or keep such content
out of the template entirely, as the section above does.
Attribute names are validated before rendering: the language permits keys the DOM rejects, and those are reported as an error rather than written out as malformed markup.
The JSON (expression-rooted) mode exists only in templating-hs, not in the
PureScript implementation of the language, so templates using it are not
portable to a browser host.