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>`
  1. ${state} is live. It updates whenever the state changes.
  2. ${() => expr} is live. It re-runs when any state it reads changes.
  3. ${plainValue} is a snapshot, evaluated once. Plain strings are always text, never HTML.
  4. 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* and bind attributes, see Events and Two-way binding.