Tramaj sections

By @lucasdicioccio, 1864 words, 12 code snippets, 2 links, 0images.

tramaj — formerly “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, tramaj-hs, as a section pre-processor. A playground hosted on this site lets you try the language itself, outside of any KitchenSink section.

Tramaj replaced Dhall as the section pre-processor: dhall and dhall-json were notoriously awkward to build and had repeatedly held KitchenSink back from moving to newer GHC versions, whereas tramaj-hs needs nothing beyond megaparsec and aeson. A .dhall section is now rejected at load time with an error naming the file; rewrite it as a tramaj-json or tramaj-doc section.

Two section formats

Where Dhall has one format, tramaj sections come in two, because the language distinguishes producing data from producing a document — though in the language itself that distinction is no longer syntactic (a single grammar and evaluator decide dynamically what a program’s root evaluated to); it is KitchenSink’s two section formats below that still enforce which one a given section must produce.

.tramaj-json 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, html or css. This is the format to reach for as a drop-in replacement for a Dhall section.

.tramaj-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

Tramaj always has the input context in scope as $ctx. KitchenSink fills it with the information the kitchensink object carried in the former Dhall-sections:

{ file : Text        -- the source file path of this cmark file
, sectionNum : Integer  -- this section's number in the file, from zero
, pathPrefix : Text  -- the site's configured basePath (see kitchen-sink.json), or "" at the domain root
, datasets : object  -- dataset cells declared *earlier* in this file
, vars : object      -- the --var name=value pairs given on the command line
}

pathPrefix is what lets a .tramaj-json main-css section (returning format = "css") adapt its @import URLs – or any .tramaj-json/ .tramaj-doc section adapt a root-relative link – to a site hosted under a subpath, e.g. "@import \"$ctx.pathPrefix/css/dev.css\";" instead of hardcoding /css/dev.css.

When migrating a Dhall section, kitchensink.datasets.my-name becomes $ctx.datasets.my-name, and kitchensink.file becomes $ctx.file.

Templating sections are pre-processors: they are evaluated once, at load time, and only see datasets declared before them in the file. There are no networked imports, but tramaj does have local imports of other sections in the same file — see “Using partial templates” below.

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 .tramaj-json 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.tramaj-json 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.tramaj-json
@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; "css", likewise verbatim but rewriting the section to CSS (handy for a main-css section that needs $ctx.pathPrefix in its @import URLs); or "json", in which case contents is an arbitrary JSON value rather than a list of lines.

generated from a templating section

file=/opt/rundir/ks-multisite/services/kitchensink.dyn.dicioccio.fr/src/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.tramaj-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.tramaj-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="{&quot;name&quot;:&quot;Alice&quot;,&quot;posts&quot;: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

Tramaj supports importing one program from another, and KitchenSink exposes that using a specific section close to datasets.

The icon tag next to library.tramaj-lib corresponds to the name given to the library in further imports.

The context passed to imported libraries is entirely determined by the caller: a library’s $ctx is the parameter object the importer supplies, KitchenSink does not add any of its own datasets to it.

Imports can be partially applied: import("icon", {"href": ""}) wires up the library without running it, and calling the result with more parameters — $icon({"src": "..."}) — adds to what was already supplied. The import only actually runs once a field (.rendered or .vals) is read off it, so one wired-up import can be reused across a map with each iteration adding its own parameter. There is no partial-import builtin any more: partiality is not a separate construct, just what happens when a parameter is left out.

Within one file, import must still refer to a library section defined above the section that imports it — a .tramaj-json/.tramaj-doc section evaluates immediately, against whatever the file has accumulated so far. Cross-file libraries (below) do not have that restriction.

=base:library.tramaj-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.tramaj-doc
@icon=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
)

Below are multiple icons.

Libraries shared across files

A library does not have to live in the article that imports it. Any source file with the .cmark-tramaj extension is loaded before every article, contributes its library.tramaj-lib sections to one shared library table, and produces no target of its own — it never becomes a page. .cmark-tramaj is a distinct extension rather than a naming convention layered on .cmark on purpose: an ordinary article can never be mistaken for a library file, or vice versa, whatever it happens to be named.

Every name such a file registers is nested under the file’s own basename: a library called icon declared in widgets.cmark-tramaj is only ever reachable as import("widgets/icon", {…}). That is also why cross-file name collisions are not a KitchenSink concern — two files can’t share a basename in the same directory, so their libraries can’t share a prefix either. A collision can still happen within one file (two library.tramaj-lib sections given the same name there), and that is a load-time error naming the offending file.

=base:library.tramaj-lib badge
.span(class: "output", "badge: `$ctx.text`")

living in this site’s shared.cmark-tramaj — a real file next to this one, not a code sample — is importable from any article, this one included, as shared/badge:

=base:main-content.tramaj-doc
import("shared/badge", {"text": "hello from another file"}).rendered
badge: hello from another file

One thing is deliberately not required here: a fixed load order between library files. A library’s body is parsed when its file loads but not evaluated — the same as an in-article library.tramaj-lib section never runs where it is declared — so which .cmark-tramaj file KitchenSink processes first cannot matter for whether an import resolves: by the time anything actually imports and reads .rendered off it, every library file has already contributed to the shared table. Two library files may freely import each other’s libraries in either direction, and KitchenSink runs no cycle detector of its own over any of this: a library that, directly or through a chain of imports, ends up importing itself is caught by tramaj itself, as an ordinary EvalError (ImportCycle) raised the first time evaluation actually re-enters it — not a hang.

A library file is still a KitchenSink section file, with a library.tramaj-lib header per library — there is no support yet for a “bare” file containing just a program body and nothing else — and imports still only resolve by name within the same site, never across a networked location.

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 .tramaj-doc mode an element’s node is left as-is — its text and attribute values keep whatever type they evaluated to (a number stays a number) — and it is KitchenSink’s HTML renderer, not the language, that turns them into display strings on the way out.

String literals do have escape sequences now: \n, \t, \r, \\, \", \`, \0 and a braced \u{1F600} for an arbitrary code point, so a " or a backtick can appear inside a literal when escaped — the backtick stays the interpolation delimiter otherwise. A template can therefore emit a <script> or an HTML attribute containing quotes directly; the dispatcher example above still prefers single quotes in the generated JavaScript purely for readability.

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.

Both .tramaj-json and .tramaj-doc sections are backed by the same grammar and evaluator now — tramaj-hs no longer has a separate expression-only mode, so which one a program produces follows from what its root evaluated to rather than from which parser ran. KitchenSink still keeps the two section formats distinct, coercing the result to what each one expects (see “Two section formats” above).