JST docs

JST is for HTML-first apps where the server and browser can share one UI contract: HTML. A server can return pages or fragments; the browser can run those same components with client-side interactivity, no JSON view-model layer, no virtual DOM, and no build step.

Short Version

Most web apps turn database state into backend models, then DTOs, then frontend models, then components, then HTML. JST asks whether the backend and browser can both speak the final format directly.

A JST component is HTML plus JavaScript. The browser registers it as a real custom element. A server can emit that same component syntax in an ordinary HTML response.

Start with resourceful URLs and full-page navigation. Add fragment swaps only where they make a workflow better: inline validation, load-more feeds, modal bodies, expanding rows, or shipping a component definition the page did not know yet.

Use JST When

You want the backend and frontend to share the same UI representation: HTML. JST fits static sites, SSR apps, HATEOAS apps, internal tools, progressively enhanced pages, quick prototypes including a single HTML file opened straight from disk, and apps whose state lives in the DOM, the URL, or the server rather than a client store.

Avoid JST When

You need a large client-owned SPA, a big catalogue of ready-made third-party components today, fine-grained reactive state graphs, or TypeScript-first component authoring.

Install

JST has three install stories: file:// Mode (open a single file from disk), Dev Mode (serve over HTTP with visible errors), and Prod Mode (precompile for production and strict CSP).

file:// Mode

jst.global.js (min: jst.global.min.js) is the whole runtime as one classic script: no modules, no server. Copy an example, generate a single HTML file, double-click it from disk, and it runs. It exposes window.JST.

<!-- Copy jst.global.min.js next to your HTML file... -->
<script src="jst.global.min.js"></script>

<!-- ...or load it from the CDN, pinned to a release tag. -->
<script src="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.global.min.js"></script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Dev Mode

Serve over HTTP, load jst.js as an ES module, enable dev: true, and write templates inline. The CDN works here too: the runtime's own imports resolve against the CDN URL, so one pinned import is the whole install.

<!-- Dev mode: serve over http://, then load the module runtime. -->
<script type="module" src="/jst.js"></script>

<script type="module">
  import { configure } from '/jst.js';
  configure({ dev: true });
</script>

<!-- No local copy at all: import straight from the CDN. -->
<script type="module">
  import { configure } from 'https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.js';
  configure({ dev: true });
</script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Prod Mode

Precompile templates into a module for strict CSP and lower startup cost. Runtime behavior still goes through the normal JST renderer. The runtime can come from the CDN here too: point --runtime at the pinned URL and the generated module imports it itself, so the templates file is the only script tag you add. For multi-file apps, see Bundling many components below.

# Production mode: precompile templates for strict CSP.
node tools/precompile.mjs components.html --out dist/templates.js --runtime ../jst.js

<script type="module" src="/jst.js"></script>
<script type="module" src="/dist/templates.js"></script>

# Or serve the runtime from the CDN: the templates module imports it directly,
# so the templates file is the only script tag on the page.
node tools/precompile.mjs components.html --out dist/templates.js --runtime https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.runtime.js

<script type="module" src="/dist/templates.js"></script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Bundling many components

Point the precompiler at every file that contains templates, server views included; it writes one module that registers them all. Pair it with the runtime-only build, which drops the in-browser compiler (about 40% smaller). For classic no-module pages, --global emits a plain script that registers through window.JST.

# Every template in views/ and components/, one output module.
node tools/precompile.mjs views/*.html components/*.html --out dist/templates.js --runtime /jst.runtime.js

<script type="module" src="/jst.runtime.js"></script>
<script type="module" src="/dist/templates.js"></script>

# Classic pages: --global output, paired with jst.runtime.global.js.
node tools/precompile.mjs components.html --out dist/templates.global.js --global
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Component library (opt-in)

Two stylesheets and one fragment of component definitions. jst-layout.css is theme variables, a classless base, and the layout primitives (zero JavaScript); jst-components.css styles the component patterns; jst-components.html defines the five JST components (<jst-palette> <jst-tabs> <jst-toaster> <jst-combobox> <jst-table>). Minified copies (.min.css) sit next to the sources. See it live: the component gallery (every component in its own frame, re-skinnable to eleven framework looks, Astryx included) and the layout primitives (zero JavaScript). For what JST deliberately does not author, see the interop proofs: three chart libraries inside JST and Web Awesome components inside JST. Three more example pages round out the coverage audit: platform recipes, the patterns JST deliberately does not ship because the platform already covers them, shown as working native demos; template recipes, copyable JST templates for the handful of patterns where a component earns its keep; and server-driven interactions the HATEOAS way, the interactions that tempt a JSON endpoint — drag-and-drop reorder, inline edit, undo, job polling — done as forms, links and rendered HTML fragments, each with its round-trip shown on the wire.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-layout.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-components.min.css">

<!-- Runtime: the definitions auto-register on arrival. -->
<jst-include src="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-components.html"></jst-include>

# Strict CSP: precompile the same fragment instead.
node tools/precompile.mjs jst-components.html --out dist/jst-components.js --runtime /jst.runtime.js
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Write Your First Component

A component is a <script type="jst"> block. JST registers it as a real custom element via customElements.define(): the template's name becomes the tag name, usable anywhere plain HTML works and inspectable in DevTools like any other element. The attributes list declares the values available inside the template.

<script type="jst" name="hello-name" attributes="name count">
  <p>Hello, <strong>$(name)</strong>.</p>
  <button onclick="el.count = (el.count || 0) + 1">
    Clicked $(count || 0) times
  </button>
</script>

<hello-name name="JST" count="0"></hello-name>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

That exact template is registered on this page. Here it is running:

Live

Template Syntax

The colors in code examples match this table. The template language is intentionally small: HTML stays HTML, and the dynamic parts are JavaScript.

SyntaxMeaning
$(expr)Evaluate JavaScript and HTML-escape the result.
$identifierShorthand for a single identifier interpolation.
$ statementRun a JavaScript statement line.
${ ... }Run a JavaScript block.
.property="$(expr)"Set a JavaScript property on a rendered element.
on<event>="…statements…"Add an event listener. The value is a plain function body (event in scope, this is the element), exactly like a native inline handler.
jst-key="$(id)"Preserve DOM identity in lists.
jst-model="title"Local form shorthand: read/write el.title.
$(slot())Project the component's original children.
<script type="jst" name="todo-list" attributes="items filter">
  $ const visible = (items || []).filter(item => {
  $   if (filter === 'done') return item.done;
  $   if (filter === 'active') return !item.done;
  $   return true;
  $ });

  <ul>
    $ visible.forEach(item => {
      <li jst-key="$(item.id)">$(item.text)</li>
    $ })
  </ul>
</script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Handlers & Behaviour

An on<event> attribute value is a function body, exactly like a native inline handler: event is in scope and this is the element. In plain body HTML the browser compiles it; in a JST template the JST compiler does, and template values (item, el, your declared attributes) stay usable inside the handler.

<form onsubmit="event.preventDefault(); save()">
<input oninput="if (changed(event)) debounce(event, 300, () => search(this.value))">
<div onkeydown="keys(event, { Enter: () => open(item), Escape: () => close() })">
<p onreveal="el.emit('seen')">lazy region</p>
<button onclick.outside="el.open = false">menu</button>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

JST provides helper functions for the patterns modern web apps keep needing: waiting for typing to pause, rate-limiting noisy events, reacting only to real changes, keyboard shortcuts. They are in scope inside every template handler body, and available as JST.fn.* (or module imports) everywhere else. This is the full list:

HelperPattern it serves
debounce(event, ms, fn)Run fn once the events go quiet for ms: typeahead search, autosave.
throttle(event, ms)Rate-limit: returns true at most once per ms. Guard scroll and resize work with if (throttle(event, 500)) ….
changed(event)Compares event.target.value with the value it saw on the previous event for that control, starting from the control's initial value. Returns true only when they differ, so events that edited nothing (arrow keys, Shift) are skipped in active search.
keys(event, map)Keyboard shortcuts: keys(event, { Enter: () => save(), 'Meta+k': () => palette() }).
commands(event, map)Invoker Commands routing: oncommand="commands(event, { '--save': save, '--revert': revert })" dispatches on event.command; unmatched commands are ignored.

Our helpers are ordinary functions, so you can add your own and use them the same way: define typeahead once in your app's JS, then call it from any attribute: oninput="typeahead(event)".

One quirk worth knowing. In a native inline handler the browser resolves undotted names against the element itself, so writing value can silently mean this.value. JST handlers do not recreate that scope chain: always write this.value. This is deliberate: the element's hundreds of DOM properties would otherwise shadow your template values.

How handlers interact with Content-Security-Policy (who compiles what, and what a strict policy changes) is covered in Handlers and CSP under Security And Production.

Data Flow

In raw top-level HTML, attributes carry the data (JSON arrays and objects included) and property assignment carries what JSON cannot: functions, class instances, DOM nodes. Inside a JST template, a dot-prefixed attribute assigns a JavaScript property (.item="$(item)") and on<event> attaches a listener.

Raw HTML + page JavaScript

<todo-list id="todos"
  items='[{"id":1,"text":"Write docs","done":false}]'
  ontoggle="this.items = this.items.map(item =>
    item.id === event.detail.id ? { ...item, done: !item.done } : item)">
</todo-list>

<script>
  // Updating later, or passing values JSON can't carry (functions,
  // class instances, DOM nodes): assign the property.
  document.getElementById('todos').items = loadedItems;
</script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Inside a JST template

<script type="jst" name="todo-list" attributes="items">
  $ (items || []).forEach(item => {
    <todo-item
      jst-key="$(item.id)"
      .item="$(item)"
      ontoggle="el.emit('toggle', event.detail)">
    </todo-item>
  $ })
</script>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.
Dot-prefixed attributes are JST template syntax (a single $(...) expression, compiled only inside <script type="jst">). on<event> means the same thing everywhere: a function body. What varies is who compiles it: the JST compiler inside a template (render scope, strict-CSP safe), or the browser in raw top-level HTML (native inline handler, relaxed CSP). A $(...) form inside a handler body is a compile error.

State, Forms, Lists

State Updates

Assigning a declared property publishes component state. Objects and arrays render even when the reference is the same, because assigning the same reference is treated as an explicit publish signal.

counter.count = counter.count + 1;

list.items.push(nextItem);
list.items = list.items;

list.items = [...list.items, nextItem]; // immutable-style update also works
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Forms

For parent/server-owned state, keep the boundary explicit. For local draft state, use jst-model.

<!-- Parent-owned value: explicit attributes-down, events-up. -->
<input
  .value="$(title)"
  oninput="el.emit('title-change', this.value)">

<!-- Local draft value: reads and writes el.title. -->
<input jst-model="title">
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Slots

Slots project the custom element's original light-DOM children. Light DOM means the nodes are ordinary page DOM, not hidden inside a shadow root.

<script type="jst" name="app-panel" attributes="title">
  <section>
    <h2>$(title)</h2>
    <div class="body">$(slot())</div>
    <footer>$(slot('footer', ''))</footer>
  </section>
</script>

<app-panel title="Hello">
  <p>Main content</p>
  <button slot="footer">Save</button>
</app-panel>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Theming JST

Every JST stylesheet is variables first: override the --jst-* custom properties and the look re-themes while the structure stays put. This very page is the proof. Its palette, spacing, radius, and text size all derive from those variables, so the knob panel in the bottom-right corner re-themes what you are reading right now. Open it and move something.

The CSS-variable contract

A small set of roles, not a bag of one-off values:

VariableRole
--jst-bg, --jst-surfaceThe page background and the raised surface that sits on it (cards, panels, menus).
--jst-fg, --jst-mutedBody text and its quieter companion for labels, hints, and metadata.
--jst-borderHairlines and control outlines.
--jst-accentOne accent. The shade ramp --jst-accent-400/600/700 derives from it with relative color syntax, so overriding the accent alone moves the ramp. Native controls pick it up through accent-color.
--jst-ok, --jst-warn, --jst-errorStatus roles for validation, alerts, badges, and toasts, each with a matching -fg for text on that color.
--jst-space, --jst-ratioOne base step and one ratio derive the whole modular scale, --jst-space-3xs through --jst-space-2xl. Raise the base to loosen a whole app at once.
--jst-radius (+ -s, -l), --jst-font-size, --jst-measureCorner radius and its derived steps — -s for chips and small inner elements, -l for containers (cards, dialogs, popovers, toasts) — plus base text size and the max line length that keeps prose readable. Override --jst-radius and all three scale together.
--jst-motion-fast, --jst-motion, --jst-motion-slow, --jst-easeOne motion personality in three durations — fast for hovers and toggles, the base step for dismissals, drawers and accordions, slow for large, page-level movement — plus the shared easing. A skin re-times the whole UI by overriding these.
--jst-neutral-h, --jst-neutral-cThe hue and chroma of the neutral grey family. Set these two and all five neutral roles (--jst-bg, --jst-surface, --jst-fg, --jst-muted, --jst-border) re-temperature at once, cool to warm, in both light and dark.

How to theme

Override the variables wherever you need them: at :root for the whole document, or on any element to re-theme just that subtree. data-scheme="light" or "dark" on <html> (or a subtree) is the entire dark-mode toggle, because the role variables are built on light-dark(). The framework theme skins in jst-components.css (data-theme="bootstrap|shadcn|pico|…") are nothing but custom-property overrides, which is why the same markup re-skins to another framework's look.

/* Whole document: one accent, looser spacing, softer corners */
:root {
  --jst-accent: oklch(0.55 0.18 20);
  --jst-space: 0.8rem;
  --jst-radius: 0.5rem;
}

<!-- Just this subtree, with its own dark scheme -->
<section data-scheme="dark" style="--jst-accent: oklch(0.7 0.15 150)">…</section>
Hover over the codeOverride the variables at the root or scoped to a subtree; data-scheme is the dark-mode toggle.

The cascade layer

Every rule JST ships lives in the jst cascade layer (@layer jst.theme, jst.base, jst.primitives, jst.components). Any unlayered app rule outranks it regardless of specificity, so overriding a default is a plain selector, never a specificity fight, and one property drops out locally with revert-layer. The knob panel leans on exactly this: it writes the variables as inline styles on <html>, and an inline style beats the jst.theme layer, so a knob always wins over the shipped default.

This page is themed by these variables. Open the knobs in the bottom-right and move them: accent, spacing, ratio, radius, text size, and light/dark. To see the components those same variables style, open every component on one page.

HATEOAS Fragments

JST's unusual feature is that a server response can define and use a component in the same HTML fragment. When inserted into a trusted auto-register root, the template registers and the element upgrades.

<!-- Server response body -->
<script type="jst" name="app-notice" attributes="message level">
  <aside class="notice $(level)">$(message)</aside>
</script>

<app-notice level="info" message="Saved"></app-notice>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Use ordinary links and forms for whole-resource navigation: GET /orders/4471, GET /orders/4471/edit, POST /orders/4471/items, PATCH /orders/4471/items/8. Fragment swaps are an enhancement for places where partial updates are worth the extra moving parts.

jst-nav: enhance links and forms

The opt-in jst-nav.js library does exactly one thing: it upgrades elements that already act (a link navigates, a form submits) so their native action fetches a fragment instead of loading a whole page. The URL and verb come from the element's own href / action / method; adding any effect attribute opts it in, and it degrades to normal navigation with JS off.

<a href="/page/2" jst-target="#list">next</a>
<form action="/orders" method="post" jst-target="#list" jst-swap="beforeend">…</form>

<!-- A region whose content lives at a URL is a COMPONENT, not a decorated div -->
<jst-include src="/comments" when="visible"></jst-include>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Other effect attributes: jst-select (pull a subtree out of a full-page response), jst-push-url / jst-replace-url (history), jst-confirm, jst-transition (bare only: wrap the swap in a View Transition, whatever the jst-swap mode, the general form of the jst-swap="transition" alias, which stays innerHTML-only), jst-boost (boost every descendant link/form of a container), jst-target-4xx/5xx/error (route error responses), plus out-of-band swaps via jst-swap-oob in the response. Routed error responses land with jst-swap-4xx/5xx/error (default innerHTML): a form that re-renders itself on validation failure targets its own id with jst-swap-4xx="outerHTML", so the response contains the form's real root. Cancelable lifecycle events (jst:before-request, jst:before-swap) bubble from the element; a swap whose target is gone by write time emits jst:swap-missed instead of succeeding silently; jst:response-error carries the response body as detail.text, so a toast can show the server's message directly. jst-confirm asks through window.confirm by default; apps that ban browser dialogs assign JST.nav.confirm = (message, el) => boolean | Promise<boolean> and the attribute drives their inline UI instead.

Every other cause (keystrokes, reveal, polling, websockets) is plain JavaScript calling swap(target, url, options), the same pipeline the attributes use. When programmatic navigation should behave exactly like a clicked link (lifecycle events for your delegated listeners, confirm, history), call navigate(url, options) instead: it drives the full enhanced-element pipeline and returns the Response.

import { swap } from './jst-nav.js';

// active search: the handler helpers pace it, swap() lands it
const typeahead = (event) => {
  if (!changed(event)) return;
  debounce(event, 300, () => swap('#results', '/search?q=' + event.target.value));
};

// polling is an interval; a websocket push is a listener
setInterval(() => swap('#status', '/job/42'), 2000);
Hover over the codeHover over parts of the code snippet to get a detailed explanation.
The line that keeps this small: jst-nav decorates elements that already act; it never invents behaviour on inert elements (that's a component's job, like <jst-include>) and never evaluates attribute strings (values are inert URLs/selectors, so server HTML can't smuggle code). There is no trigger grammar to learn. Causes are events, timers, and components: things JavaScript already has.

Server-Side Rendering Examples

These examples are for backend web frameworks that render HTML on the server. The point is not that JST needs those frameworks; it is that they can emit JST directly because the wire format is HTML.

Rails ERB

<task-row
  task-id="<%= task.id %>"
  title="<%= h task.title %>"
  done="<%= task.done.to_json %>">
</task-row>
Hover over the codeEscaped server values become ordinary JST attributes.

ASP.NET Razor

<script type="jst" name="task-row" attributes="taskId title done">
  <button onclick="el.emit('complete', taskId)">Complete</button>
</script>

<task-row task-id="@Model.Task.Id" title="@Model.Task.Title"></task-row>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Plain PHP

<task-row
  task-id="<?= htmlspecialchars($task['id']) ?>"
  title="<?= htmlspecialchars($task['title']) ?>"
  done="<?= json_encode($task['done']) ?>">
</task-row>
Hover over the codePlain PHP can output JST tags directly. Escape strings and JSON-encode booleans/numbers.

Laravel Blade

<task-row
  task-id="{{ $task->id }}"
  title="{{ $task->title }}"
  done="@json($task->done)">
</task-row>
Hover over the codeBlade escapes {{ }} output by default; @json is useful for non-string values.

Go html/template

<task-row
  task-id="{{ .Task.ID }}"
  title="{{ .Task.Title }}"
  done="{{ .Task.Done }}">
</task-row>
Hover over the codeGo's html/template context-escapes attribute values. For arrays/objects, render JSON into a script block or assign properties from page JS.

Django / Jinja-style templates

<task-row
  task-id="{{ task.id }}"
  title="{{ task.title }}"
  done="{{ task.done|yesno:'true,false' }}">
</task-row>
Hover over the codeServer-rendered Python templates can emit JST custom elements like any other HTML tag.

Rust Askama

<task-row
  task-id="{{ task.id }}"
  title="{{ task.title }}"
  done="{{ task.done }}">
</task-row>
Hover over the codeRust SSR template engines can do the same thing: output HTML tags and let the browser upgrade them.

Security And Production

Trust Boundary

  • $(expr) escapes HTML by default.
  • url(value) blocks dangerous URL schemes in URL-bearing attributes.
  • trustedHTML(value) bypasses escaping. It is the only opt-out-of-escaping helper; only use it for HTML your app produced or sanitized.
  • Fetched JST templates are code. Scope them with autoRegisterRoot and allowlist names in resolveTemplate.
<p>$(comment)</p>
<a href="$(url(userLink))">go</a>
<article>$(trustedHTML(renderedMarkdown))</article>
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Names, never code (the script-gadget rule)

jst-nav and jst-behaviors read attribute values from the live DOM, including server-rendered and swapped-in HTML, so their values are only ever inert strings: URLs, CSS selectors, names. This is a hard line, not a style choice. A framework that evaluates DOM attribute strings becomes a script gadget: on a site that deployed strict CSP precisely to neutralise injected onclick= handlers, an attacker who achieves HTML injection could ride the framework around the policy. JST refuses to be that gadget.

Handlers and CSP

In plain body HTML, native oninput="typeahead(event)" is evaluated by the browser: plain platform code that needs a relaxed CSP. Under a strict CSP, the same behaviour moves into a template (where handlers compile to addEventListener, with no nonce and no unsafe-inline) or a script. jst-lint --csp lists every native inline handler when you're ready to make that move.

Component events and synthetic events in body HTML (an onitem-selected or onreveal attribute outside a template) require JST to evaluate the attribute string, because the browser only wires its own event names. So they're opt-in and loudly named:

configure({ unsafeInlineHandlers: true });
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

How far to take strict CSP

The strict-CSP ladder: plain pages need nothing special; interactive regions become precompiled components, removing unsafe-eval; at the extreme, the whole page becomes one root component. Know when you've overcorrected: a single app-shell template is a SPA. You lose native cross-document view transitions and simple server-rendered pages for a constraint you may not actually have. If your CSP isn't strict, you don't need any of this; stop at the rung your policy requires and no further.

Configuration

import { configure } from './jst.js';

configure({
  dev: location.hostname === 'localhost',
  autoRegisterRoot: document.getElementById('trusted-fragments'),
  resolveTemplate(name) {
    if (!name.startsWith('app-')) return null;
    return `/components/${name}.html`;
  },
});
Hover over the codeHover over parts of the code snippet to get a detailed explanation.

Testing & TypeScript

Testing: components are real custom elements in light DOM. Render them into a DOM (jsdom with custom elements, or Playwright against a served page), set attributes and properties, dispatch events, and assert on innerHTML. No special test renderer. TypeScript: the repo ships hand-written .d.ts files for the runtime API, window.JST, JST.nav, and the handler helpers, so consumption is typed without a build step. Typed template expressions are a known gap.

Browser Support

JST targets modern evergreen browsers and uses platform features directly: Custom Elements v1, ES modules, MutationObserver, queueMicrotask, CustomEvent, and modern DOM APIs. Internet Explorer and very old pre-evergreen browsers are out of scope.

The default browser compiler uses new Function, which Content Security Policy treats as eval. That is a CSP concern, not a browser compatibility concern. Use precompiled mode for strict CSP.

Known Gaps

  • Formatter / tree-sitter / typed expressions: planned. VS Code diagnostics and highlighting exist now.
  • Keyed reconciliation: tested, but the most intricate part of the runtime; it should keep gaining adversarial tests.
  • Transitions: CSS-driven enter/leave works for keyed lists; richer move/FLIP behavior may come later.
  • Light DOM: deliberate. There is no style or id encapsulation, so prefer classes and derive ids from attributes.
  • Handler bodies and $-forms: a handler body cannot contain $(…) forms or lines starting with $ . Use a named function for complex handlers.

Tooling

The VS Code extension under tooling/ provides syntax highlighting, diagnostics, and a language server. Planned next: marketplace packaging, formatter support, typed expression checking, and a tree-sitter grammar.

Tooling should support the same simple model as runtime JST: attributes are declared explicitly, template expressions are JavaScript, invalid bindings fail loudly, and rich values move through properties rather than string attributes.