Repository navigation
03 Templates
A template is html with data-tpl-* attributes. Rendering walks the fragment, evaluates each command's expression against a stack of data overlays, and returns a DocumentFragment.
// from a <template> matched by selector (throws if it is not one)
const t1 = ftl.Template.fromSelector('#my-template');
// from an HTMLTemplateElement
const t2 = ftl.Template.fromTemplate(document.querySelector('#my-template'));
// from a string
const t3 = ftl.Template.fromHtml('<h1>{{ title }}</h1>');
// from a DocumentFragment
const t4 = ftl.Template.fromFragment(fragment);Every factory accepts (source, modules?, ...data). Templates bound to the registry's scope are available through ftl.Templates (Templates.fromSelector(...) etc.), and inside a component through this.template(name).
A template renders in a scope: the modules an expression calls into, and a data stack a bare identifier resolves against, innermost first. withOverlay(...data) pushes a layer, withModule(name, value) merges one module, and withEvaluator(evaluator) replaces the scope wholesale, which is how a registry binds a compiled template to its own modules and data. Every one of them returns a new Template: nothing is mutated, so one compiled markup can be rendered against any number of scopes.
flowchart BT
base["template data / registry overlay"] --- withv["data-tpl-with value / withOverlay(...)"]
withv --- item["data-tpl-each item (self)"]
Symbols resolve from the top down: self is exactly the top overlay, so inside data-tpl-each="items" with no var, {{ self }} is the current item.
const out = ftl.Template
.fromHtml('<p>{{ user.name }}</p>')
.withOverlay({ user: { name: 'ada' } })
.render(); // DocumentFragmentRender targets:
| Method | Effect |
|---|---|
render() |
returns a new DocumentFragment
|
renderTo(el) |
replaces el's children |
appendTo(el) |
appends to el
|
renderToSelector(sel) / appendToSelector(sel)
|
same, on the first match |
Templates are immutable value objects: every with* returns a new template, so a captured template can be safely rendered with different overlays (this is how data-tpl-each renders one copy per item).
Inside text nodes:
| Syntax | Inserts |
|---|---|
{{ expression }} |
a text node (safe by default) |
{{{ expression }}} |
raw html, the trusted-markup opt-in |
{{{{ expression }}}} |
a DOM node as-is (e.g. a rendered fragment) |
<p>Hello {{ name }}, legal: {{{ legalHtml }}}, signed {{{{ signatureNode }}}}</p>Multiple interpolations can share a text node. A { not followed by another { is literal text, and a backslash writes the character after it as it is: \{{ x }} shows the braces, \\ a backslash, and a text ending in a lone backslash does not parse. A quoted string inside an expression has no escapes. {{{ }}} accepts any value: non-strings are stringified, and null/undefined render nothing in all three forms.
{{{{ }}}} inserts the node itself, not a copy, so the node moves: rendering it a second time, in another data-tpl-each row or another render, takes it away from where it was. A node shown in several places is passed as a fresh copy each time (node.cloneNode(true)).
Commands are data-tpl-* attributes, evaluated in this order on each element:
flowchart TD
A["1. data-tpl-if, on the outer scope"] --> B["2. data-tpl-with, pushing its value"]
B --> C["3. data-tpl-each, pushing each item"]
C --> D["4. data-tpl-when, on the overlaid scope"]
D --> E["5-8. class-append, attr-append, remove,<br/>verbatim"]
E --> F["9. generic data-tpl-attribute"]
Everything after with/each runs inside the pushed overlay; everything before it runs against the outer scope. That is the whole if/when distinction. Once a command removes the element, the remaining commands are skipped.
Both remove the element when the expression is falsy. They differ in when they run: if runs before with/each, evaluated against the outer scope, so it can cancel the iteration; when runs after, on each rendered copy, against the overlaid item:
data = { items: [{ v: 1, on: true }, { v: 2, on: false }] }<!-- guards the whole list against outer.show -->
<ul data-tpl-if="show" data-tpl-each="items" data-tpl-var="item">
<!-- guards each copy against item.on -->
<li data-tpl-when="item.on">{{ item.v }}</li>
</ul>Re-renders the element with the expression's value pushed on the data stack:
<div data-tpl-with="parent.nested">
<p>{{ text }}</p> <!-- the value itself is the context -->
</div>
<div data-tpl-with="parent.nested" data-tpl-var="nested">
<p>{{ nested.text }}</p> <!-- named: reachable from any nested context -->
</div>Without data-tpl-var the value itself is the context; with it, the value is bound to a name (and stays reachable from nested contexts).
Renders one copy of the element per item, each with the item pushed on the stack (bare, or named through data-tpl-var). The expression must be iterable, or a keyed collection:
<div data-tpl-each="a">{{ v }}</div>
<!-- with data = { a: [{v: 1}, {v: 2}, {v: 3}] } --><div>1</div><div>2</div><div>3</div>Keyed collections iterate as { key, value } entries: a Map in its own order, a plain object (Object.prototype or null prototype, carrying no iterator of its own) in Object.entries order. Anything else non-iterable, a class instance included, is an error: an array that arrived as something unexpected must fail loudly, not render its properties.
<li data-tpl-each="languages">{{ key }}: {{ value }}</li>
<!-- with data = { languages: { it: 'Italiano', en: 'English' } } --><li>it: Italiano</li><li>en: English</li>data-tpl-stat names an iteration stat available next to the item: { index, count, size, first, last, even, odd } (index 0-based, count 1-based, parity from the index). The fields stay grouped under the declared name, so they can never collide with data, and an item property sharing that very name wins, as data always does. The stat observes the iteration without changing how the collection is consumed: the size is read only where the collection already knows it (arrays, keyed collections, anything carrying length or size), and an iterator or generator is never consumed to learn it: its size and last read null, since such a source has no size. Where last matters, hand the template an array instead: spread the generator in the page code that builds the data ([...gen]), there is no spread in the expression language.
<li data-tpl-each="items" data-tpl-var="item" data-tpl-stat="s" data-tpl-class-append="s.odd ? 'striped' : null">
{{ s.count }}/{{ s.size }} {{ item.name }}{{ s.last ? '' : ',' }}
</li>Append classes or attributes without overwriting:
<li data-tpl-class-append="done ? 'strike muted' : null" data-tpl-attr-append="[['data-ref', id]]">class-append accepts a string (split on spaces) or an array; attr-append accepts a [name, value] tuple or a list of tuples. A nullish result appends nothing, which is what the ternary's else branch is for: the expression language has no one-armed ternary, and a ? b alone is a parse error. A tuple whose value is null or undefined appends nothing either.
| Value | Effect |
|---|---|
"tag" |
unwraps: children replace the element |
"body" |
empties the element |
"all" |
removes element and children |
<div data-tpl-remove="tag"><p>paragraph</p></div> <!-- <p>paragraph</p> -->Replaces the element with a deep clone of itself, as authored: everything inside is left alone, no commands, no interpolation. Useful around user content that may contain {{ }}.
Any other data-tpl-* attribute evaluates its expression and sets the result on the unprefixed attribute (data-tpl-href="url" sets href):
<a data-tpl-href="link.url" data-tpl-aria-label="link.hint">{{ link.text }}</a>Special values:
| Expression result | Rendered attribute |
|---|---|
true / false
|
boolean attribute present / removed (data-tpl-readonly="canEdit ? false : true") |
null / undefined
|
attribute left untouched (a value already present in the markup stays; false is what removes it) |
| anything else | setAttribute(name, String(value)) |
A failing expression throws ftl.RenderError: the thrown error names the node it failed on at e.node, the live node in the fragment being built, and a failure inside a nested render chains its inner frames through cause. One render never fails silently:
try {
template.withOverlay({}).render();
} catch (e) {
console.assert(e instanceof ftl.RenderError);
}ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project