Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Templates and single-file components

roots-template (package roots.template, sources in src/template/) compiles

  • single-file components (.vue, .roots): a Vue 3 <template>, <script setup> in TypeScript, JavaScript or Roots Lisp, an optional plain <script>, and <style>;
  • HTML templates on their own;
  • Lisp-authored components (defcomponent, macro set "roots.ui").

The output is a plain ES module that follows CONTRACT.md §2–§3: the template becomes a render(_ctx) function that builds the tree once through the roots/runtime helpers and wires fine-grained updates. There is no virtual DOM, no innerHTML, no eval. JavaScript and TypeScript are parsed by roots.parse (parser.md): expression rewriting, type stripping and binding analysis.

(roots.template:compile-sfc source :filename "src/TodoList.vue" :target :web)
;; → (values js-module css-string warnings)

Contents #

  1. API
  2. Template syntax
  3. What a template compiles to
  4. Single-file components
  5. Lisp-authored components
  6. Diagnostics
  7. Security
  8. Not supported in 0.1
  9. Notes for the runtime and build

1. API #

FunctionResult
(compile-sfc source &key filename target)(values js css warnings). target is a build target keyword (:web :term …) or nil; it is bound as roots.js:*target* for Lisp scripts, turns <style> into a warning for :term, and selects the helpers the render code calls: a DOM target (:web :pwa :ext :ios :android) gets the lean helpers of §3, :term and nil the general ones only.
(compile-template source &key filename bindings (name "render") target)(values js warnings): a standalone function render(_ctx) { … }. target as for compile-sfc. bindings describes the script bindings: a list of names, an alist (name . kind) or a hash table (kinds: :ref :reactive :const :maybe-ref :let :function :class :import :props :prop :emit).
(parse-template source &key filename start end)List of nodes: element (element-tag, element-attrs, element-children), text-node (text-node-value, decoded), interpolation (interpolation-expression). attr-name, attr-value, node-start, node-end (offsets).
(parse-sfc source &key filename)An sfc structure: sfc-template, sfc-script, sfc-script-setup, sfc-styles (lists of sfc-block: sfc-block-type, sfc-block-attrs, sfc-block-content, sfc-block-start, sfc-block-end, (sfc-block-attr block name)).
*host-primitives*("Box" "Text" "Button" "Input" "Checkbox" "Image" "Link" "Spacer") (CONTRACT §4).

Errors signal template-error, a subclass of roots.js:js-compile-error (so the CLI reports it like any compile error). (template-error-diagnostic e) returns a diagnostic.

Warnings are diagnostic structures in source order. A diagnostic has diagnostic-severity (:error/:warning), diagnostic-code, diagnostic-message, diagnostic-file, diagnostic-line, diagnostic-column (1-based) and diagnostic-hint. (format-diagnostic d &key (frame t)) prints it:

src/App.vue:12:6: error C211: v-html is not supported: templates never insert HTML (escape by default)
  12 |     <div v-html="raw"></div>
     |          ^^^^^^
  hint: render text with {{ }} (always escaped); build markup from elements

2. Template syntax #

Roots templates are a documented subset of Vue 3 template syntax (FEEDBACK FW-4): everything below means what it means in Vue; anything else is a coded error, never a silent change of meaning.

Parsing #

  • Elements, attributes (quoted, unquoted, valueless), self-closing tags (<Foo /> for any element), void elements (<br>, <img>, …), <template> wrappers.
  • {{ expression }} interpolation; the first }} ends it.
  • Comments are dropped. <!DOCTYPE> and CDATA are errors.
  • Entities: &amp; &lt; &gt; &quot; &apos; &nbsp;, about 240 common named references, and numeric &#123; / &#x1F600;. Unknown names stay as written. Attribute values and interpolations are decoded too.
  • <textarea> and <title> hold text (interpolation allowed, tags not); <script> and <style> are rejected inside templates (C208).
  • v-pre keeps an element's content literal (no interpolation, no directives). The security rules still apply under it: <script>/<style> (C208), inline handlers and javascript: URLs (C224).
  • Tag names keep their case: Box, TodoItem and div are different tags.

Whitespace #

Vue's condense rules, made terminal-aware (the rules of vuecli's src/compiler-options.ts):

TextResult
whitespace containing a line break, between elementsremoved
a line break (with its indentation) at the start/end of an elementremoved
a line break (with its indentation) inside textone space
spaces without a line breakkept as written (● label stays)
&nbsp;never whitespace
inside <pre> and <textarea>kept (one leading line break dropped, as in HTML)

<template #slot> siblings do not count as neighbours, so default-slot text next to them is trimmed like the first/last child of an element.

Tags #

TagCompiles to
a setup binding (import TodoItem …, const Row = …)$.component(_ctx.TodoItem, …)
a host primitive (Box Text Button Input Checkbox Image Link Spacer)$.element("Box"), or part of a static template (below; not Input, Checkbox, Link)
lowercase (div, span, input)$.element("div"), or part of a static template (below)
kebab-case matching a binding (<todo-item> with TodoItem imported)component
other kebab-case (<x-widget>)$.element (a custom element) + warning C250
other capitalized (<Unknown>)$.resolveComponent(_ctx, "Unknown") + warning C250
dotted (<Form.Field>)_ctx.Form.Field
<component :is="view">$.dynamicComponent(() => _ctx.view, props, slots)
<component is="Card">like <Card>
<slot>$.slot(_ctx, name, propsGetters, fallback)
<template>its children (a single node, or $.fragment(...))
Teleport, KeepAlive, Transition, TransitionGroup, Suspenseerror C220

Expressions #

Template expressions are JavaScript (TypeScript when the component's script is lang="ts"), rewritten by roots.parse:rewrite-expression:

  • free identifiers read the instance: count → _ctx.count (setup bindings first, refs unwrapped; then props; then $slots, $attrs, $emit, $el, $props);
  • these globals stay global: Math Date JSON Number String Boolean Array Object parseInt parseFloat isNaN isFinite undefined NaN Infinity console window document navigator;
  • v-for aliases and destructured slot props are read through .value: todo → todo.value (the index and key aliases and slot props are refs; the item alias is the list's record of the item, whose .value is tracked like a ref's but read-only, and isRef is false for it);
  • names bound inside the expression (arrow parameters, $event) are local.

Directives #

SyntaxMeaning
:name="e", v-bind:name, :name (same-name shorthand, :id = :id="id"), .camelreactive attribute / prop
class + :class, style + :stylemerged (string, array or object, as in Vue)
v-bind="obj"every property of obj (keys taken once, values reactive)
@event="handler", v-on:eventlistener (see below)
modifiers .stop .prevent .self .once, keys .enter .esc (.escape) .space .up .down .left .right .tab .deletepassed to $.on
v-if / v-else-if / v-else (also on <template>)$.ifBlock, nested for v-else-if
v-for="x in xs", (x, i) of xs, n in 10, (value, key, index) in obj, { id, name } in users, [a, b] in pairs$.forBlock
:key in a v-for (on the element, or on <template v-for> or its single child)the key function
:key outside v-forthe element is re-created when the key changes
v-show (elements only)$.show
v-model on Input, Checkbox, input, textarea, select; .trim .number .lazy$.model
v-model / v-model:title on a componentmodelValue + onUpdate:modelValue (title + onUpdate:title)
v-slot / #name / #name="props" / #default="{ item }"slots (see below)
ref="name"$.templateRef(node, _ctx, "name")
v-text="e"the element's text
v-cloakignored (it has no meaning without a page-load flash)

Event handlers (CONTRACT §2): a method path (inc, store.save) becomes (...a) => _ctx.inc(...a); a function ((e) => go(e)) is used as it is; anything else is an inline statement, ($event) => { _ctx.count++; }. On components, @select="h" becomes the prop onSelect: h and @update:model-value becomes "onUpdate:modelValue".

v-for item functions receive refs ((item, index), objects: (value, key, index)) so a keyed list updates items in place. Destructured aliases become small computeds:

$.forBlock(() => _ctx.users, (_item0) => {
  const id = $.computed(() => _item0.value.id);
  const name = $.computed(() => _item0.value.name);
  …
}, ({ id, name }) => id);

v-if and v-for on the same element is an error (C215): in Vue 3 v-if runs first and cannot see the alias, the most common source of confusion. Filter with a computed, or put the v-if on a wrapping <template>.

Slots. Children of a component that are not <template #…> form the default slot. Slot functions return a node or a Block. v-slot="props" binds the slot props object (props.item stays reactive); a destructuring pattern binds computeds, like v-for. <slot name="footer" :count="n" title="x">fallback</slot> passes getters: { count: () => _ctx.n, title: () => "x" }.

Props of components are camelized (auto-focus → autoFocus; data-* and aria-* stay). Static values are passed as values (a valueless attribute is true), dynamic ones as getters, events as handlers. Static plus dynamic class becomes class: () => ["static", dynamic].


3. What a template compiles to #

<Box class="flex-col gap-1">
  <Text>You have {{ todos.length }} todos</Text>
  <TodoItem v-for="(todo, i) in todos" :key="todo.id" :todo="todo" @toggle="toggle(todo)" />
</Box>
const _tpl0 = /* @__PURE__ */ $.template(["Box", 0, 1, ["Text", 0, 0, 0]]);
…
render(_ctx) {
  const _c1 = $.clone(_tpl0), n2 = _c1[0], n3 = _c1[1];
  $.setAttr(n2, "class", "flex-col gap-1");
  $.bindText(n3, () => "You have " + $.toDisplayString(_ctx.todos.length) + " todos");
  const b4 = $.forBlock(() => _ctx.todos, (todo, i) => $.component(_ctx.TodoItem, { todo: () => todo.value, onToggle: ($event) => { _ctx.toggle(todo.value); } }, {}), (todo, i) => todo.id);
  $.append(n2, b4);
  return n2;
},

Elements are named nK, blocks and components bK; per element the order is: create, attributes and listeners (source order), children, v-model, v-show, ref. Several roots return $.fragment(...), an empty template $.fragment(). An element that is not part of a static template (a lone element, an Input, a Link…) is made with $.element(tag) and its children are added with $.append.

Text runs. Adjacent text and interpolations are one text: You have {{ todos.length }} todos is a text run of three parts, and it compiles to one text node kept by one render effect, whose getter concatenates the parts: static text verbatim (whitespace included) as string literals, each interpolation through $.toDisplayString on its own, so null, undefined and objects print as they do alone. A lone {{ x }} is a run of one part and keeps the bare getter () => _ctx.x (the helper applies toDisplayString). Where the run is all its element holds, the element hosts it, $.bindText(n, getter) on the element (no text node is made by the render code; on the DOM the first run writes textContent); beside elements, blocks or components it is one $.dynamicText(getter) in the $.append list, or one collected text of a static template. Static text with no interpolation next to it stays $.text("…"). Two consequences: such an element has one text node where each part used to have its own, and an interpolation that throws takes its whole run with it (nothing is shown when it is the first run, the text stays as it was on an update), where it used to blank its own part only; the error is routed as before. An element does not host its text where the backend keeps nodes of its own inside it (a Checkbox; an element with a label, static or bound, or with v-bind="obj"; a Button that may be the root element of its component, onto which a label can fall through): its run is a text node of its own, $.append(n, $.dynamicText(getter)).

Static templates. A subtree of at least two template elements (plain lowercase elements and the host primitives Box, Text, Button, Image and Spacer) is not built node by node: its static structure becomes a template, hoisted to module level, that the render code clones (one cloneNode on the DOM) before binding what is dynamic:

<tr :class="{ danger: row.id === selected }"><td class="id">{{ row.id }}</td><td><a @click="select(row)">{{ row.label }}</a></td></tr>
const _tpl1 = /* @__PURE__ */ $.template(["tr", 0, 1, ["td", { class: "id" }, 0, 0], ["td", 0, 0, ["a", 0, 1, 0]]]);
…
  const _c2 = $.clone(_tpl1), n3 = _c2[0], n4 = _c2[1], n5 = _c2[2], n6 = _c2[3];
  $.bindClass(n3, null, () => ({ danger: row.value.id === _ctx.selected }));
  $.bindText(n4, () => row.value.id);
  $.on(n5, "click", ($event) => { _ctx.select(row.value); });
  $.bindText(n6, () => row.value.label);
  return n3;

A spec element is [tag, staticAttrs | 0, collected, …children]; a child is static text, 0 (a text run), ["#", "text"] (a collected text node with its initial text) or an element. $.clone returns the collected nodes in document order (the root first): those with bindings, listeners, refs, text runs, and the places where blocks and components are inserted ($.insert before the next node, $.append at the end). A text run that is all its element holds is represented by the element itself: $.clone puts the element in that slot, $.bindText writes its text content directly (no empty text node is cloned, no text node exists while the text is empty), and the compiler promises that nothing else is inserted into such an element at run time (an element that other code puts into it, through a template ref, is tolerated: the text stays the element's first child that is not an element) — where a block or a component sits beside the run (or the element cannot host its text, see above) it emits ["#", ""] instead, so the insertion has a text node to go before. A run that is one interpolation showing the key of the keyed v-for it is in ({{ row.id }} under :key="row.id": the same expression, reading the item alias and nothing else) is written once, $.setText(n4, row.value.id) || $.bindText(n4, () => row.value.id), instead of bound, because a kept row only ever receives an item with the same key, so that text cannot change while the node lives. That holds for a primitive key; for an object used as key (whose text, JSON, follows the object's state) $.setText writes nothing and returns false, and the $.bindText after it binds the text. This holds for an element made with $.element too; a run of several parts is always bound, also when one of them is the key; and inside a slot that binds slot props (v-slot="{ row }") nothing is taken for the key, since a slot prop can shadow the names the key uses. Only state-free things go into a spec: not form fields (input, textarea, select, option), not the primitives Input, Checkbox and Link ($.element gives them state: the v-model kind, router state, a Checkbox's inner nodes; they are holes), not attributes backends set as properties (value, checked, selected, muted, label…), and class/style only where nothing merges into them: not next to :class, :style or v-show, and not on the root, which may be the root element of the component and receive fallthrough attrs. The one root that never is: what a v-for item function returns (the element with the v-for, the children of a <template v-for>, the branches of a v-if there). Its class and style are in the spec, so a list row of primitives costs one clone and its bindings:

<Box v-for="item in items" :key="item.id" class="row"><Text class="t" :class="{ done: item.done }">{{ item.text }}</Text><Button class="btn" @click="toggle(item.id)">Toggle</Button></Box>
const _tpl0 = /* @__PURE__ */ $.template(["Box", { class: "row" }, 1, ["Text", 0, 1, 0], ["Button", { class: "btn" }, 1, "Toggle"]]);
…
  return $.forBlock(() => _ctx.items, (item) => {
    const _c1 = $.clone(_tpl0), n2 = _c1[0], n3 = _c1[1], n4 = _c1[2], n5 = _c1[3];
    $.bindClass(n3, "t", () => ((item.value.done) ? "done" : ""));
    $.bindText(n4, () => item.value.text);
    $.on(n5, "click", ($event) => { _ctx.toggle(item.value.id); });
    return n2;
  }, (item) => item.id);

The body of a slot written inside a v-for keeps the root rule (it can become the root of the child component). Elements with v-bind="obj", v-text, v-model, v-for, v-if or :key are holes. A copied Box or Spacer keeps the base class its backend gives it (r-box, r-spacer on the DOM) when a class is bound or falls through later: $.clone hands the collected ones to the backend's adopt (CONTRACT §5). Backends without cloneNode (the terminal) get the same nodes built by the normal helpers. The hoisted template, like the $.defineComponent({…}) call below, is marked /* @__PURE__ */: it only builds data, so a bundler drops it with the component when nothing imports the module (CONTRACT §2).

Lean helpers (DOM targets). Everything above is what the terminal target gets, and what compile-sfc emits without a target. For a DOM target (:web, :pwa, :ext, :ios, :android) the compiler uses what it knows about a node to call helpers that test nothing (CONTRACT §3, "Lean helpers"). The benchmark row

<tr v-for="row in rows" :key="row.id" :class="{ danger: isSelected(row.id) }">
  <td class="col-md-1">{{ row.id }}</td>
  <td><a @click="select(row.id)">{{ row.label.value }}</a></td>
  <td><a @click="remove(row.id)"><span class="x"></span></a></td>
</tr>

compiles for the terminal to $.bindClass(n2, null, …), $.setText(n3, …) || $.bindText(n3, …), $.on(n4, "click", ($event) => { … }) … }, (row) => row.id), and for a DOM target to

const _sel7 = ($s) => (($s) ? "danger" : "");                                   // module level
…
  const _h8 = $.rowHandler((row, $event) => { _ctx.select(row.value.id); }), _h9 = $.rowHandler((row, $event) => { _ctx.remove(row.value.id); });
  return $.forBlock(() => _ctx.rows, (row) => {
    const _c1 = $.clone(_tpl0), n2 = _c1[0], n3 = _c1[1], n4 = _c1[2], n5 = _c1[3], n6 = _c1[4];
    $.bindSelected(n2, _ctx.isSelected, row.value.id, _sel7) || $.bindClassEl(n2, () => ((_ctx.isSelected(row.value.id)) ? "danger" : ""));
    $.setElText(n3, row.value.id) || $.bindElText(n3, () => row.value.id);
    $.onRow(n4, "click", _h8, row);
    $.bindElText(n5, () => row.value.label.value);
    $.onRow(n6, "click", _h9, row);
    return n2;
  }, (row) => row.id, true);

The rules, each a fact the compiler has and the general helper would test at run time:

  • A node of a static template is a plain element. Templates hold lowercase elements and the primitives Box, Text, Button, Image, Spacer only (never a component, a Checkbox, an Input or a Link). Such a node gets $.bindClassEl(n, getter) for a :class without a static class beside it, $.onEl(n, event, handler) for a handler without modifiers, and $.setElText / $.bindElText where it hosts its text (the run is all the element holds). A text node of a template (a run beside other children) keeps $.bindText, and a node made by $.element keeps every general helper.
  • A handler with modifiers compiles to $.onWith(n, event, handler, ["stop", …]) on any node, so $.on is called without modifiers only (an app that uses none does not ship the modifier code).
  • Handlers of a v-for item are hoisted. A handler of a static-template node inside an item function is made once per render of the list, beside the $.forBlock, as $.rowHandler((row, $event) => { … }), and registered per row with $.onRow(n, event, handler, row): the row (the item function's first argument, the list's record) is passed to it, where $.on took a closure per row. A method name becomes (row, ...a) => _ctx.m(...a), a function expression (row, ...$args) => (fn)(...$args). A handler stays a closure per row ($.onEl) when it reads something else the item function binds: the index or key alias ((row, i) in rows and remove(i)), or a name of a destructured alias ({ id } in rows and select(id)). A nested v-for hoists into the outer item function, so its handlers may read the outer row. The body of a slot written inside a v-for hoists nothing (it sees slot props). What the handler does and when it runs are unchanged.
  • single. $.forBlock(list, renderItem, key, true) says every item is exactly one host element (the element with the v-for, or the one element in a <template v-for>; not a component, a v-if, several nodes); without a :key the third argument is undefined.
  • A class that asks a selector about the key. In a keyed v-for whose key reads the item alias and nothing else, a :class (no static class beside it) that contains one call name(key) — name declared in <script setup> as const name = createSelector(…) with createSelector an import, the argument the key expression — and reads nothing else compiles to $.bindSelected(n, _ctx.name, key, _selN) || $.bindClassEl(n, getter). _selN is the class as a function of the call's result, hoisted to module level. When name is a createSelector result the runtime keeps the class from the selector's own key table (no render effect per row, written in the render flush like every binding) and returns true; for any other function it returns false and the class is bound as usual. The selector is read once, when the row is made, which is why the declaration must show that the name is the selector itself and stays it. Not taken, and bound as a class effect that reads the callee on every run: a selector that reaches the component any other way (an import, a prop, inject, the result of a composable — each may be a ref holding a selector, which _ctx.name unwraps and which can get another selector later; write const isSelected = createSelector(…) in the component that renders the list to get the binding), a let, a plain function; another binding in the class ({ on: isSelected(row.id), big: large }), another argument than the key, a method of the row, two calls.

Class objects. On elements, :class="{ active: isActive, 'is-open': open }" with plain keys compiles to the class string itself, () => ((_ctx.isActive) ? "active" : "") + ((_ctx.open) ? " is-open" : ""), which is what normalizeClass makes of the object, without allocating it on every run. Spread, computed or repeated keys keep the object.

The golden tests in src/template/tests/codegen.lisp show the output for every feature, those in src/template/tests/lean.lisp the DOM targets' output.


4. Single-file components #

<script setup lang="ts">
import { ref } from 'vue'
import TodoItem from './TodoItem.vue'
const props = defineProps<{ title?: string }>()
const emit = defineEmits<{ (e: 'select', id: number): void }>()
const todos = ref<{ id: number; text: string }[]>([])
</script>

<template>…</template>

<style>.done { opacity: 0.5 }</style>

becomes

import * as $ from "roots/runtime";
import { ref } from "roots/runtime";
import TodoItem from "./TodoItem.js";

export default /* @__PURE__ */ $.defineComponent({
  name: "TodoList",
  props: { title: { type: String, required: false } },
  emits: ["select"],
  setup(__props, __setup) {
    const props = __props;
    const emit = __setup.emit;
    const todos = ref([]);
    return { ref, TodoItem, props, emit, todos };
  },
  render(_ctx) { … },
});

Blocks #

  • one <template> (required, HTML only), at most one <script setup> and one plain <script> (same lang), any number of <style> blocks (plain CSS, returned concatenated as the second value). Custom blocks (<docs>) are ignored with a warning. src= is not supported.
  • lang: ts, js (default) or lisp (<script setup> only).
  • name comes from the file name: TodoList.vue → TodoList, todo-list.vue → TodoList.

<script setup> #

  • Imports are hoisted to the module. Specifiers of .vue .roots .lisp .ts .mts .tsx files become .js (relative paths kept); 'vue' becomes 'roots/runtime', whose API is Vue-shaped. Other bare specifiers are left for the build to resolve or reject.
  • Everything else is the body of setup(__props, __setup), types stripped (positions preserved). Every top-level binding, imports included, is returned for the template.
  • export is an error (C304); top-level await is an error (C312, needs Suspense).

Compiler macros #

SourceResult
defineProps(['a', 'b']), defineProps({ a: String, … })props: with that literal (TS as casts stripped)
defineProps<{ a: string; b?: number }>(), or an interface/type in the same fileprops: { a: { type: String, required: true }, b: { type: Number, required: false } }
withDefaults(defineProps<…>(), { b: 1 })adds default:
const props = defineProps(…)const props = __props;
const { a, b = 1, c: alias } = defineProps(…)reactive props destructure: defaults go to props:, every use of a/alias reads __props.a/__props.c; shadowed names are left alone
defineEmits(['a']), defineEmits({ a: validator })emits: with that literal
defineEmits<{ (e: 'a', id: number): void; (e: 'b' | 'c'): void }>(), defineEmits<{ a: [id: number] }>()emits: ["a", "b", "c"]
const emit = defineEmits(…)const emit = __setup.emit;
const model = defineModel<string>(opts?), defineModel('title', opts?)prop modelValue/title (with opts), event update:…, and const model = $.computed({ get: () => __props.modelValue, set: (value) => __setup.emit("update:modelValue", value) });
defineExpose(obj)__setup.expose(obj);
const slots = defineSlots()const slots = __setup.slots;
defineOptions(…)error C314

TypeScript types map to runtime checks like Vue: string/literals → String, number → Number, boolean → Boolean, T[]/Array<T>/tuples → Array, function types and methods → Function, object types/Record/Partial/interfaces → Object, Date Map Set Promise RegExp Error as themselves, unions → [A, B] (null/undefined dropped), anything else (any, unknown, generics, imported types) → null. A props type that is not an object type written in the file is an error (C307).

Macros must be called at the top level (C306); their arguments are compiled outside setup(), so they cannot use its local variables (C305).

Plain <script> #

Module-level code (named exports allowed, export default is C315). Its top-level bindings are visible to the template too.

<script setup lang="lisp"> #

The script is compiled by the Roots Lisp compiler (roots.js:compile-js-string, with roots.js:*target* bound) and then goes through the same pipeline: (import …) forms are hoisted, top-level defvar/defparameter/defconstant/defun/define-class names and imports are the bindings (named by the Lisp→JS rules: *count* → COUNT), and the macros work on the compiled calls: (defvar props (define-props (list "title"))). Lisp compile errors are C310.

<script setup lang="lisp">
(import "roots/runtime" ref)
(defvar count (ref 0))
(defun inc () (incf (@ count value)))
</script>
<template><Button label="+" @click="inc" /><Text>{{ count }}</Text></template>

The same idea as a running example, next to its TypeScript twin:

A counterstatic previewdemos/counter-lisp.vue
0twice that is 0
counter-lisp.vue
<script setup lang="lisp">
(import "roots/runtime" ref computed)

(defvar count (ref 0))
(defvar doubled (computed (lambda () (* 2 (@ count value)))))
(defun more () (incf (@ count value)))
(defun less () (decf (@ count value)))
</script>

<template>
  <Box class="counter-lisp flex-row items-center gap-3">
    <Button label="−" aria-label="One less" @click="less" />
    <Text class="counter-lisp-value">{{ count }}</Text>
    <Button label="+" aria-label="One more" @click="more" />
    <Text class="counter-lisp-note">twice that is {{ doubled }}</Text>
  </Box>
</template>

<style>
.counter-lisp { flex-wrap: wrap; }
.counter-lisp-value { min-width: 2ch; text-align: center; font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.counter-lisp-note { color: var(--roots-muted); }
</style>
A counterstatic previewdemos/counter.vue
0twice that is 0
counter.vue
<script setup lang="ts">
import { computed, ref } from "roots/runtime"

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <Box class="counter flex-row items-center gap-3">
    <Button label="−" aria-label="One less" @click="count--" />
    <Text class="counter-value">{{ count }}</Text>
    <Button label="+" aria-label="One more" @click="count++" />
    <Text class="counter-note">twice that is {{ doubled }}</Text>
  </Box>
</template>

<style>
.counter { flex-wrap: wrap; }
.counter-value { min-width: 2ch; text-align: center; font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.counter-note { color: var(--roots-muted); }
</style>

5. Lisp-authored components #

A .lisp file can define components with the versioned macro set "roots.ui" 0.1.0. Macro sets are opt-in, so defcomponent means nothing in a file without use-macros.

(use-macros "roots.ui" "^0.1")
(import "roots/runtime" ref)
(import-default TodoItem "./TodoItem.vue")

(defcomponent TodoList (:props ((title :type String :default "Todos")) :emits (select))
  (setup (props ctx)
    (defvar todos (ref (list)))
    (defvar draft (ref ""))
    (defun add ()
      (vector-push-extend (create :id (Date.now) :title (@ draft value)) (@ todos value))
      (setf (@ draft value) "")))
  (template
    (:Box :class "flex-col gap-1"
      (:Text (@ props title) ": " (@ todos value length))
      (:Input :model.trim draft :placeholder "New todo" :on-keydown.enter add)
      (for (todo (@ todos value) :key (@ todo id))
        (:TodoItem :todo todo :on-toggle ((@ ctx emit) "select" (@ todo id))))
      (when (= (@ todos value length) 0)
        (:Text "Nothing to do")))))

(export-default TodoList)

defcomponent defines const TodoList = /* @__PURE__ */ $.defineComponent({ … }) (export it with export-default or export; the mark lets a bundler drop an unused component) and imports roots/runtime as $ once per file (do not import $ yourself). setup returns the render function, so the template is a closure over setup's variables: they are plain Lisp values, and refs are read with (@ x value), as everywhere in Lisp. The render function receives _ctx.

FormMeaning
(defcomponent Name (:props … :emits … :name "X") (setup (props ctx) body…) (template node…)):props is a list of names or (name :type T :default v :required t :validator f) (names camelized); :emits names or strings; setup is optional (props/ctx are then the parameter names)
"text", 42static text
any other form or symboldynamic text: $.dynamicText(() => form)
(:Tag :attr value … child …)an element or component; attributes are the leading keyword/value pairs
(:div …), (:Box …)host element (lowercase or host primitive)
(:TodoItem …)component: the variable TodoItem
(:component :is form …)dynamic component
:class "a b" / :class formstatic / $.bindClass (strings, arrays, objects) — same for :style
:name "literal" (string, number, t, nil) / :name form$.setAttr / $.bindAttr; component props: value / getter
:on-click handler, :on-keydown.enter.prevent handlerlistener; a symbol or (lambda …) is the handler, any other form runs as a statement with $event bound
:model x (x a ref) or :model (@ obj field) (a place); .trim .number .lazy$.model; on components modelValue, and :model.title x for v-model:title
:show form$.show
:ref xsets the ref x to the node
(if test then [else]), (when test …), (unless test …), (cond (test …) …)$.ifBlock
(for (item source :key form :index i) …)$.forBlock; inside, item and i read the current item and index (read through .value underneath, substituted for you; the index is a ref, the item the list's record); :key sees the plain item
(fill name (prop …) node…) inside a componentnamed slot; prop reads the slot prop
(slot [name] :prop value … fallback…)slot outlet
(progn node…)a fragment

Template errors in Lisp components are compile errors with the same codes (C211, C216, C217, C221, C224) plus C230 (invalid template form) and C231 (invalid defcomponent).

The Lisp forms compile to the general helpers on every target ($.bindText, $.bindClass, $.forBlock without single, a closure per handler): the lean helpers of DOM targets (below) are emitted for .vue templates only, <script setup lang="lisp"> included. One thing is shared: an event with modifiers (:on-keydown.enter) compiles to $.onWith, so a Lisp-authored app without modifiers ships on alone. ($.on written by hand with a fourth argument still works and keeps both.)


6. Diagnostics #

Errors stop the compilation; warnings are returned. Every one has a code, the position and, where possible, a fix-it hint.

Templates (C2xx) #

CodeMeaning
C201element missing its end tag
C202end tag without an open element
C203malformed tag, <!…> declaration
C204unterminated comment
C205unterminated {{
C206duplicate attribute
C207malformed attribute or value
C208<script> / <style> inside a template (also under v-pre and as <component is="style">)
C210invalid or empty expression (the position points into it)
C211v-html, innerHTML, outerHTML, srcdoc (any case: :srcDoc, :SRCDOC, :src-doc.camel): HTML insertion
C212unsupported directive (v-once, v-memo, custom directives), v-text misuse
C213v-else/v-else-if without v-if
C214invalid v-for
C215v-if and v-for on the same element
C216invalid v-model target (not assignable, a v-for alias, a prop, a const, wrong element)
C217unknown modifier
C218slot misuse (v-slot outside a component, mixed or duplicate slots, Vue 2 slot=)
C219dynamic arguments, v-on="obj", dynamic or conditional slots
C220unsupported built-in component
C221<component> without :is
C222v-show on a component or <template>
C223dynamic or invalid ref
C224inline event handler attribute (onclick=, any case), javascript:/vbscript: URL in a URL attribute, static srcdoc
C230invalid Lisp template form
C231invalid defcomponent
C250warning: unknown component / custom element
C251warning: x.value on a ref in a template (templates unwrap refs)
C253assignment to a prop in an event handler
C254assignment to a v-for alias or a slot prop (n++, row = other): the alias is read-only; a property of the item (row.done = true) is writable

Single-file components (C3xx) #

CodeMeaning
C301missing <template>, unterminated block, invalid script tokens
C302duplicate block, text outside blocks; warning: custom block ignored
C303unsupported lang or src; TypeScript that cannot be stripped
C304export in <script setup>
C305a macro argument uses a local variable of setup
C306compiler macro misuse (not top level, twice, both type and runtime arguments, bad destructure)
C307unresolvable props/emits type
C308<style scoped> / <style module>; warning: <style> on the terminal target
C309reserved name ($, _ctx, __props, __setup, _spread)
C310Lisp script compile error
C311eval, the Function constructor, with, string timers
C312top-level await in <script setup>
C313<script> and <script setup> in different languages
C314defineOptions()
C315export default in a plain <script>
C390warning from the Lisp compiler (e.g. a deprecated macro version)

Reactivity footguns (FW-1) #

CodePatternFix-it
C350const { a } = reactive({…}) or = state (a reactive() binding)toRefs(state), or read state.a
C351const { title } = props (the defineProps() object)destructure defineProps() directly (compiled to stay reactive), or toRefs(props)
C352state = … where state is reactive()change its properties, or use a ref
C353a ref used without .value in an operator (n + 1, n++, n > 3, !n)n.value
C251count.value in a templatecount
C253 / C216writing a propemit an event, or defineModel()
C254 / C216writing a v-for alias (@click="n++", v-model="row")change the list's source (counts[i]++ with the index alias, or a function), or a property of the item

7. Security #

  • Escaped by construction (FW-5): text and interpolations only become text nodes ($.text, $.dynamicText, $.bindText); the compiler never generates HTML strings (static templates are data: tags, attributes and text that the backend creates with createElement/setProp, with its own checks, then copies). v-html and bindings of innerHTML, outerHTML, srcdoc are errors (C211).
  • No code from strings: inline event handler attributes (onclick="…") and static javascript:/vbscript: URLs in href src action formaction xlink:href poster cite data to are errors (C224). The URL is read as a browser reads it: leading control characters and spaces, and a tab or newline inside the scheme (java&#10;script:), do not hide it. Scripts may not use eval, the Function constructor, with, or string timers (C311); the compiled module is checked too. All of this also keeps CSP script-src 'self' working.
  • Names in any case. The DOM lower-cases the attribute names of HTML elements, so the rules above compare names without regard to case, and on the name that is emitted: :srcDoc, :SRCDOC and :src-doc.camel are srcdoc; HREF is href; :onClick is an event attribute.
  • <script> / <style> elements inside templates are errors (C208): written as tags, under v-pre, or as <component is="style">.
  • The compiler checks what it can see; the backend checks the rest. A name or value that only exists at run time (:href="url", the keys of v-bind="obj", fallthrough attributes, the string of <component :is>) is checked by the DOM backend with the same rules (R303, R304, R306, R307, R308: docs/runtime.md §6).

What takes trusted input only #

Escaping protects text. These bindings are safe from script injection, but what they are given still decides what the page shows or where it leads, so give them values the app made or validated, never raw data from a URL, an API or a user:

bindingwhat untrusted input can dodo instead
:style="string"any declarations: cover the page (position:fixed; inset:0), hide or move controls, request a url(…) (the CSP limits it to the app's origin; an image may also be a data: or blob: URL, which makes no request)bind an object with the properties you mean: :style="{ color: c }"
:class="string"any class of the stylesheet (a utility class that hides, overlays or resizes)bind an object or pick from a fixed set
:href, :src, :action…any http(s): URL: only javascript:/vbscript: (and data: documents in frames) are refusedcheck the origin or scheme in app code
v-bind="obj"the keys become attributes (elements) or props, attributes and listeners (components). Dangerous names are refused at run time, but style, class, href, target, id, name, form… are attributes like any otherspread objects the app builds; pick the keys you allow from data
<component :is="string">any element of that name with the props and slot content written there (a registered component first). Tags that act on the whole document are refused (R306): script style iframe frame frameset object embed base meta linkmap data to components: :is="blocks[type]"
<Link :to> / router.push(x)always an in-app location (a URL of another origin loses its origin; a path that would lead out is made a plain path), but any route of the appcheck the route for redirects taken from a URL (?next=)

8. Not supported in 0.1 #

Each is a coded error with a suggested rewrite: v-html, v-once, v-memo, custom directives, dynamic arguments (:[name], @[name], #[name]), v-on="obj", function and dynamic refs (:ref), Teleport/KeepAlive/Transition/TransitionGroup/Suspense, conditional or repeated <template #slot>, v-bind="obj" on <slot>, modifiers on component events and component v-model, event modifiers beyond the contract list (.ctrl, .capture …: check $event in the handler), <style scoped|module|lang>, src= blocks, <template lang>, defineOptions, top-level await, rest properties in a props destructure, a plain <script lang="lisp">.


9. Notes for the runtime and build #

  • Props objects: every function value is a getter except onX keys, which are handlers (as the runtime implements). A valueless attribute passes true.
  • The build target decides the helpers: the general ones for :term and without a target, the lean ones of §3 for DOM targets. The lean helpers do for a static-template node what the general ones do, so a module compiled for a DOM target still runs on any backend (the testing backend in unit tests); it is only not what the terminal build emits.
  • Inline handlers compile to ($event) => { … } (CONTRACT §2 rule). The illustration in CONTRACT §2 shows onToggle: () => _ctx.toggle(todo.value); both are handlers.
  • v-for key functions receive the plain item (and index / key), not refs.
  • v-bind="obj" on elements: for (const $k of Object.keys(obj ?? {})) $.bindAttr(n, $k, () => obj[$k], true) (own keys only: a key on Object.prototype is no attribute) (the true: the element may hold a key already, so a first null or false still removes it; a named binding after the object, or after a static attribute of its own name, gets it too, $.bindAttr(n, "title", () => _ctx.t, true)); on components a render-local _spread helper turns the object into getters and handlers. Keys are read once.
  • :key outside v-for compiles to a one-item $.forBlock(() => [key], …).
  • Lisp components return their render function from setup; it is called with _ctx.
  • defineModel uses $.computed({ get, set }) (no local value when the parent does not bind v-model).
  • .lisp files go through the build's Lisp frontend; their import specifiers are not rewritten by this package (SFC imports are).