Templates
A template is a tagged literal: html`...`. The tag parses the static
parts once per call site (the template strings identity is the cache
key) and packages this call’s values; it renders nothing. mount()
renders. Everything $ can do follows from four rules:
html`<div>
${count} <!-- 1. state: live value -->
${() => count.value * 2} <!-- 2. arrow: live expression -->
${props.start} <!-- 3. plain: one-time snapshot -->
${() => items.value.map(x => html`<li>${x}</li>`)} <!-- 4. lists/branches -->
</div>`
${state}is live. It updates whenever the state changes.${() => expr}is live. It re-runs when any state it reads changes.${plainValue}is a snapshot, evaluated once. Plain strings are always text, never HTML.- Lists and conditionals are just expressions returning views or
arrays of views:
.map()and&&, not directives.
That is the whole language. llms.txt carries it in ten lines.
Mechanics
Control flow is JavaScript. The engine never knows a loop exists: a
slot value is false | view | array, and the engine reconciles whatever
the expression returned:
${() => valid.value && html`<div>shown while valid</div>`}
${() => items.value.map(x => html`<li>${x}</li>`)}
Inside the inner template, plain snapshots (${x}) are correct: the
outer arrow re-runs and produces fresh values.
Live expressions are auto-tracked per run. Dependencies re-track on
every evaluation, so conditional reads work, and
a.value ? b.value : c.value tracks b only while a is true.
Strings are text, never markup. Slot strings become Text nodes: the
engine writes createTextNode/nodeValue, never innerHTML, so
injection by accident is impossible, including in the zero-build
deployment. The explicit opt-out for markup you trust:
import { unsafe } from 'lemonadejs';
html`<article>${unsafe(trustedHtmlFromCms)}</article>` // never on user input
Updates are surgical. Each text-position slot owns a marker node and
reconciles positionally: same kind of content at the same position keeps
its DOM; a branch that toggles off keeps exactly one hidden generation, so
show/hide costs nothing; identical values (Object.is across the slot’s
values) are detected before any DOM work. Static siblings are never
revisited; the template itself never re-runs. Lists that reorder add
key="$" on the item’s root for identity-based matching, and the
complete story is Lists.
Mistakes have codes. Mismatched and unclosed tags fail at parse with
the tag named (LJS-101, LJS-102); an expression in an illegal position
(attribute name, partial tag name) is LJS-105; a snapshot that looks
like it was meant to be live (states were read while the template was
built and a slot holds a primitive) warns LJS-202 in dev. Literal
a < b renders fine; <!-- comments --> are dropped, including
expressions inside them; template indentation never reaches the DOM.
No eval, ever. The parser is hand-written; the library contains no
eval and no new Function. Zero-build under a strict CSP.
Accepted slot values: string | number (text),
false | true | null | undefined (nothing), html`...` view, DOM
Node, or arrays of these.
In practice
The Tabs block header, all four rules in twelve lines of shipped source
(components/tabs): a list as .map(), a live per-item class, a
snapshot per item, and a conditional “add” button as &&:
<ul onclick="${onOpenEvent}" onkeydown="${onKeydown}">${() =>
items.value.map(
(item, i) =>
html`<li class="lm-tabs-tab ${() => (selected.value === i ? 'lm-tabs-selected' : '')}"
tabindex="0" role="tab" draggable="true"
aria-selected="${() => (selected.value === i ? 'true' : 'false')}"
data-icon="${item.icon || false}">${item.title || ''}</li>`
)}</ul>
${() =>
props.allowcreate!.value &&
html`<div class="lm-tabs-insert-button"
onclick="${() => create({ title: 'Untitled' }, null, true)}">add</div>`}
Inside the inner template ${item.title} is a snapshot, correct, because
the outer arrow re-runs when items changes, while the class stays live
per item because selection changes without the list changing.
The same composability is why the datagrid can put any block inside a
cell: a column’s render callback returns a string or a view, as in
render: (value, row) => html<${Rating} value=”${value}” />“, and the
engine reconciles it like any other slot. No special cell-renderer API:
rule 4 is the API.
Reference
html`...` // parse once per call site, package values; renders nothing
${state} // live value
${() => expr} // live expression (auto-tracked per run)
${plain} // snapshot; strings are TEXT, never HTML
${() => x && html``} // branch ${() => a.map(v => html``)} // list
unsafe(htmlString) // trusted markup opt-in → Node[]
- Slot positions: text content, full or partial quoted attribute value,
component tag (
<${Card}>). Anything else:LJS-105. - Parse errors:
LJS-101(mismatched close),LJS-102(unclosed). Dev tripwire:LJS-202(slot looks like a frozen snapshot). - The values feeding templates are states; see State and
computed(). For
on*andbindattributes, see Events and Two-way binding.