Template syntax

Interpolation

Any JS expression between {{ }}:

<span>{{ user.name }}</span>
<span>{{ price * quantity }} €</span>

Expressions may span several lines — both here and in every directive (:if, :each, :class, @event, …):

<span>{{ items
  .filter(item => item.active)
  .length }}</span>

An expression that throws renders as nothing rather than tearing down the render, because it is re-evaluated constantly — once per effect run, once per :each item. It doesn't stay a secret, though: it reaches the console as an error, once per expression. That includes a value that simply hasn't loaded yet, so write {{ user?.name }} (or gate the element with :if) when you expect one to be late. See debugging a script.

Whitespace

A template is HTML, and its whitespace is HTML's: it reaches the DOM as written, and CSS decides what it's worth. Two elements on separate lines are separated by a space when they render inline — the same space you'd get from the same markup in an .html file — and by nothing when they're block or flex children. If you don't want the space, close the tags against each other (</span><span>) as you would anywhere else.

The one exception is the indentation between the branches of an :if/:elseif/:else chain, which is dropped: only one branch is ever in the DOM, so there's nothing for it to be a space between.

:attr — one dynamic attribute

:<name>="expr" binds a single attribute, reactively:

<button :disabled="isSaving">Save</button>
<a :href="`/users/${user.id}`">profile</a>
<div :aria-expanded="open"></div>

The value rule

value boolean attribute (disabled, checked, readonly, …) any other attribute
null / undefined removed removed
false, 0, "" removed written ("false", "0", "")
truthy written as "" written as String(value)

A boolean attribute counts as present, whatever its value — disabled="false" disables — so anything falsy removes it and a truthy value writes nothing. :disabled="items.length" enables the button on an empty list, which is what it reads like.

Everything else keeps false and 0, because absent and "false" are different things: aria-expanded="false" is meaningful ARIA, and so is a data- flag.

The boolean names are HTML's list: allowfullscreen, async, autofocus, autoplay, checked, controls, default, defer, disabled, formnovalidate, inert, ismap, itemscope, loop, multiple, muted, nomodule, novalidate, open, playsinline, readonly, required, reversed, selected.

:class — reactive classes

Adds classes on top of the static class attribute — it never replaces it. The expression may be a string, an object, or an array:

<button class="btn" :class="{ 'btn-active': active }">go</button>
<div :class="theme"></div>
<div :class="[theme, { active }]"></div>

For the common case of one class gated by one condition, :class.<name>="expr" is a shorthand for :class="{ <name>: expr }" — it toggles the single class <name> on when expr is truthy:

<div class="drop" :class.active="dropping"></div>

Write it kebab-case (:class.is-active) or camelCase (:class.isActive) — the two are the same name (see name casing). It coexists with :class and with other :class.<name> on the same element; the sets union.

Only classes the binding added are ever removed: the static list survives every re-run, even when the expression names one of its classes and then drops it (class="btn" :class="{ btn: cond }" keeps btn when cond goes false).

:class is the only writer of the class attribute: the name is reserved, so :class is never a plain attribute binding. On a nested-component tag it is ignored, like :text/:html.

:value / :checked / :selected — form state

These write the DOM property, not the attribute. The difference matters on form controls: the attribute is only the control's default and detaches the moment the user interacts, so an input whose value attribute is rewritten stops following it once something has been typed in. The property directives keep driving it:

<input :value="name" @input="name = $event.target.value">
<input type="checkbox" :checked="agreed" @change="agreed = $event.target.checked">
<select :value="lang">
  <option value="en">en</option>
  <option value="es">es</option>
</select>

:text / :html — content

Set an element's content directly from an expression, instead of interpolating inside its children.

<span :text="user.name"></span>
<div :html="markdownToHtml(post.body)"></div>

:html.allowed — destination policy

The sanitizer always blocks executable URLs (javascript:, data:), but by default it doesn't care where a link or image points. :html.allowed adds that restriction, per element — different zones of one page can trust different destinations, which is the one thing a page-wide Content-Security-Policy can't express:

<div :html="body" :html.allowed="'*.germade.dev'"></div>
<div :html="body" :html.allowed="['*.germade.dev', '*.germade.es']"></div>
<div :html="body" :html.allowed="url => url.hostname.endsWith('.germade.dev')"></div>

The value is an expression, like every : attribute: a comma-separated string or array of host patterns, or a predicate (url: URL, tag, attr) => boolean called with the URL already resolved against the page (so relative URLs are judged as the same-origin destinations they are). A rejected href/src is stripped; the element and its text stay.

Pattern grammar — host[:port]:

Without .allowed, :html keeps its default: protocol check only, any destination. For a page-wide floor, set a Content-Security-Policy — the two compose, and the stricter one wins.

:if / :elseif / :else — conditionals

Consecutive siblings form one chain; only the active branch is in the DOM.

<div :if="score > 8">great</div>
<div :elseif="score > 4">ok</div>
<div :else>bad</div>

A chain is one :if, any number of :elseif, at most one :else, on adjacent sibling elements — only whitespace may sit between them, and the :else closes the chain. Break that and the branch renders unconditionally, because :elseif/:else mean nothing on their own. The chains are checked when the component is parsed, so a broken one is reported once per definition — before any data exists, and whether or not the branch it sits in ever renders:

<div :if="a">A</div>
<hr />
<div :else>B</div>            <!-- :else on <div> continues no :if: the <hr> broke the chain -->

<div :if="a">A</div>
<div :else>B</div>
<div :else>C</div>            <!-- a second :else: the chain already ended -->

<div :if="a" :else>A</div>    <!-- two on one element: only :if applies -->

A :each element between the branches breaks the chain like any other element, and :if/:elseif/:else on a :each element are ignored (with their own warning) — filter the list expression instead.

:each / :key — lists

<li :each="user in users" :key="user.id">{{ $index }}: {{ user.name }}</li>

The list is diffed by key: unchanged items keep their DOM (and state) when the array is reordered, filtered or extended. Without :key, position is used — fine for append-only lists, wasteful for reordering. $index is available inside each item.

A second binding names the array index — handy where nested loops would shadow $index — and plain objects iterate as their entries, the second binding being the property key (parens optional):

<li :each="item, i in items">{{ i }}: {{ item.name }}</li>
<li :each="(value, key) in labels">{{ key }} = {{ value }}</li>

Objects diff by property key out of the box: adding, changing or deleting a key touches only that entry. Anything that is neither an array nor a plain object renders nothing.

:with — narrowed scope

Evaluates to an object whose properties become directly addressable inside the element; anything else still resolves from the outer scope:

<div :each="item in items">
  <div>{{ item.name }}</div>
  <div :with="item">
    Another way to get: {{ name }}
    Items total: {{ items.length }}
  </div>
</div>

@event — listeners

<button @click="onClick"></button>
<form @submit.prevent="$event => onSubmit($event)"></form>
<button @click="count = count + 1">clicked {{ count }} times</button>

The attribute value is evaluated on every event with $event in scope; if it evaluates to a function, that function is called with the event. So all three styles work: a handler reference, an inline arrow, or an inline statement that mutates reactive data.

They also fail the same way: an exception thrown while handling the event reaches the console with its stack, whichever style raised it. The one exception is a name that resolves nowhere (@click="handleClik"), which is reported once as a warning instead — see debugging a script.

Modifiers (chainable, e.g. @click.stop.once):

modifier effect
.prevent event.preventDefault()
.stop event.stopPropagation()
.self only fire when event.target is the element itself
.once listener runs at most once
.capture listen in the capture phase

@event on a component tag

A component tag renders as comment anchors — there is no element to listen on — so @event there subscribes to that child's $emit channel instead:

<Stepper @changed="last = $event.detail" />

It hears exactly what that child emits: not a grandchild's emits (those arrive only as an explicit re-emit), and not native DOM events from the child's inner DOM — a native submit bubbles past the tag's anchors to real ancestors, so it's still a wrapping element's to catch (<div @submit.prevent=…><LoginForm/></div>, which hears $emits too, since they bubble). Modifiers on this channel: .prevent flips the child's $emit(...) return to false — a "the parent vetoed" signal; .stop keeps the emit off the DOM entirely, so wrapping elements never hear it; .once unsubscribes after one call; .self and .capture have no meaning here and are ignored.

:model — two-way component binding

Props flow down, events flow up; :model wires both at once. The model's name rides the modifier (an expression-valued modifier, like :html.allowed), and one tag can carry several:

<LoginForm :model.uname="uname" :model.password="password" />
<EmailField :model="email" />

Each :model[.name]="expr" is two bindings:

The child's whole side is one call, straight from the template if it's simple enough:

<!-- EmailField.html -->
<input :value="model" @input="$updateModel($event.target.value)">

:props — spreading an object as props

Passes an object's own properties to a child as props, instead of naming each one. ...expr is sugar for it:

<SdkInfo :props="sdk" />                 <!-- name, version, arch, … as props -->
<SdkInfo ...sdk />                        <!-- same thing -->
<SdkInfo ...sdk :arch="'arm64'" />        <!-- spread, then override one -->

Nested components

A tag matching a PascalCase scope variable renders as a child component. Components reach the scope through render data, :setup props, or an await import(...) in the setup script:

<script :setup="{ user, NestedComponent }">
  const ImportedComponent = await import('/components/foobar.html')
</script>

<div>
  <NestedComponent :user :title="'Hardcoded title'" />
  <ImportedComponent :user="user" />
</div>
<!-- /components/foobar.html -->
<script :setup="{ user }"></script>
<div>User: {{ user.firstName }}</div>

A component renders in a box of its own

Every instance renders inside an element named after the component — one root, several, or none:

<div class="w">
  <c79-user-card data-c79-box data-jq79="1a2b3c">…the instance's DOM…</c79-user-card>
</div>

A component used inside an <svg> has to root its own template at <svg>:

<svg viewBox="0 0 100 100"><Dot /></svg>

<template name="Dot"><svg x="10" y="10"><circle cx="8" cy="8" r="8" /></svg></template>
<template name="Bare"><circle cx="8" cy="8" r="8" /></template>   <!-- draws nothing -->

A component's template is parsed on its own, so a bare <circle> at its root is an HTML element with an SVG name — it lands in the tree and never draws, wherever the tag was written. jq79 says so — once per usage site, naming the component and the fix — rather than leaving you a blank diagram. A root of its own <svg> is a foreign context of its own, and everything inside it is in the SVG namespace as usual.

What it costs: a child or sibling combinator in global CSS stops crossing the boundary (.grid > .item no longer matches an .item a component rendered), and a parent's :empty stops matching when a child renders nothing. Scoped rules lose nothing — they could never reach across that boundary anyway.

A tag that names no component throws

<UserCrad /> renders no markup, no styles, no children and no script. Once every setup script has settled nothing can arrive to fill it, so jq79 throws rather than leave a hole in the page:

jq79: <UserCrad> is not defined - no component of that name is in scope, and
nothing renders here. Import it in a :setup script, declare it as a prop, or add
a <template name="UserCrad"> to this file. In scope: UserCard, Button.

Only a tag you capitalized is judged this way — that's the spelling that claims a component, and it is never valid HTML. <my-widget>, <svg> and a plain typo like <lable> render as they always have. An HTML element written in caps is judged like anything else you capitalized: <DIV> is a component claim that resolves to nothing, and it throws instead of rendering a div.

It is also only about a name that resolves to nothing. A component variable that is undefined still waits quietly — that's how an await import(...) lands — and a name holding something that isn't a component stays a console.error, because you can still write the right value into it:

jq79.data.Card = await Component79.fetch('/Card.html')  // renders now

The one case where a name legitimately arrives after the first render is a factory script that calls await $mounted() before returning its components. jq79 knows that script is still running and waits for it.

<slot> — content projection

A component's tag children render inside it, where it wrote a <slot>:

<!-- Card.html -->
<section>
  <header><slot.header>Untitled</slot.header></header>
  <slot />
  <footer :if="$slots.footer"><slot.footer /></footer>
</section>
<Card>
  <template :slot.header><h2>{{ title }}</h2></template>

  <p>{{ body }}</p>
</Card>

The dot marks the named variant on both sides, like :model.<name> and :class.<name>:

written means
<slot /> the default hole
<slot.header-bar>…</slot.header-bar> a named hole, with fallback content
<slot.row :item="item" /> a hole that passes props to the content
a component tag's own children content for the default slot
:slot="{ item }" on a component tag names the default content binds
<template :slot.row="{ item }"> content for a named slot, and its names

Names are camelCase where read, either casing where written: <slot.header-bar> and <slot.headerBar> are one slot, matched by :slot.header-bar or :slot.headerBar, asked about as $slots.headerBar.

Scoped slots

A <slot>'s attributes are props for the content — which is what makes this composition rather than decoration:

<!-- List.html -->
<ul>
  <li :each="item in rows" :key="item.id">
    <slot :item="item" :index="$index" />
  </li>
  <p :if="!rows.length"><slot.empty>Nothing here</slot.empty></p>
</ul>
<List :rows="rows" :slot="{ item, index }">
  {{ index }}. <b>{{ item.label }}</b> — {{ currency(item.total) }}
</List>

currency is the parent's, item is the child's.

<template name> — another component in this file

At the top level of a component file, <template name="Row"> declares another component of that file rather than markup:

<ul><Row :each="label in rows" :label="label" /></ul>

<template name="Row">
  <li class="row">{{ label }}</li>
</template>

See components for what the declared components can see, how they are exported, and how a signature decides between one of them and a prop of the same name.

SVG

SVG works like any other markup — interpolation, :attr bindings, :each, :if, events:

<svg viewBox="0 0 100 100" class="chart">
  <circle :each="p in points" :key="p.id" :cx="p.x" :cy="p.y" r="3" :fill="p.color" @click="select(p)" />
  <text x="4" y="12">{{ total }} puntos</text>
</svg>

Elements inside an <svg> are built in the SVG namespace, so they draw, their case-sensitive names survive (viewBox, preserveAspectRatio, <linearGradient>, <clipPath>), and <style scoped> reaches them. A <foreignObject> hands the namespace back, so HTML inside one is real HTML.

SVG's camelCase attribute names work bound, and either spelling reaches the same one:

<svg :viewBox="box">                        <!-- viewBox="0 0 10 10" -->
<svg :view-box="box">                       <!-- the same attribute -->
<feGaussianBlur :stdDeviation="blur" />     <!-- stdDeviation="3" -->
<circle :stroke-width="w" :strokeWidth="w"> <!-- stroke-width, which is its real name -->

You don't have to know which SVG attributes are camelCase and which are kebab — write the name however you like and jq79 resolves it against the parser's own table, the one that makes a written-out viewBox survive. Names outside that table (fill, cx, stroke-width, clip-path, and every data-* and aria-*) reach the DOM exactly as written.

There is no longer anywhere the name is yours to get right: :attrs used to pass its keys through untouched — :attrs="{ strokeWidth: w }" wrote an attribute SVG ignores, silently — and retiring it closed the last way to write a dead attribute name.

MathML

MathML works the same way, and for the same reason — the namespace is read off the parsed tree, not guessed from a list of tag names:

<math display="block">
  <mrow>
    <mi :mathcolor="color">{{ symbol }}</mi>
    <mo :each="op in ops" :key="op.id">{{ op.sign }}</mo>
  </mrow>
</math>

<mtext> and <annotation-xml encoding="text/html"> hand the namespace back to HTML, the way <foreignObject> does in SVG. Attribute names are resolved the same way as in SVG, against MathML's own table — which has one entry, definitionURL; everything else (mathcolor, linethickness, displaystyle) is lowercase and arrives as written.

One sharp edge, and it belongs to the HTML parser rather than to jq79: <annotation-xml> holds HTML only when its encoding is written out as text/html or application/xhtml+xml. Bind it and the parser never sees a value it recognizes, so HTML inside is parsed outside the <math> entirely:

<annotation-xml encoding="text/html"><p></p></annotation-xml>   <!-- fine: the <p> is inside -->
<annotation-xml :encoding="kind"><p></p></annotation-xml>       <!-- the <p> lands outside the <math> -->

Name casing

The HTML parser lowercases attribute and tag names before jq79 ever sees them, so a name written camelCase would arrive flattened (:firstName:firstname) and land under the wrong key. jq79 rewrites camelCase names to kebab-case in the raw source, before parsing, so both spellings mean the same name:

<Field :userName="who" />        <!-- same as :user-name -->
<Field :model.firstName="who" /> <!-- same as :model.first-name -->
<template :slot.headerBar></template>
<slot.headerBar />               <!-- same as <slot.header-bar> -->

Whichever you write, the name is camelCase where it's read — the child's userName prop, $slots.headerBar. (A model name is normalized on arrival, so $updateModel('first-name', v) and $updateModel('firstName', v) find the same binding.)

Kebab-case is the house style in these docs, because it's what HTML looks like. camelCase is accepted so a prop doesn't have to change shape between the template and the script that reads it.

What the rewrite does not touch: