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 #
- API
- Template syntax
- What a template compiles to
- Single-file components
- Lisp-authored components
- Diagnostics
- Security
- Not supported in 0.1
- Notes for the runtime and build
1. API #
| Function | Result |
|---|---|
(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 elements2. 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:
& < > " ' , about 240 common named references, and numeric{/😀. 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-prekeeps an element's content literal (no interpolation, no directives). The security rules still apply under it:<script>/<style>(C208), inline handlers andjavascript:URLs (C224).- Tag names keep their case:
Box,TodoItemanddivare different tags.
Whitespace #
Vue's condense rules, made terminal-aware (the rules of vuecli's src/compiler-options.ts):
| Text | Result |
|---|---|
| whitespace containing a line break, between elements | removed |
| a line break (with its indentation) at the start/end of an element | removed |
| a line break (with its indentation) inside text | one space |
| spaces without a line break | kept as written (● label stays) |
| 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 #
| Tag | Compiles 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, Suspense | error 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.valueis tracked like a ref's but read-only, andisRefis false for it); - names bound inside the expression (arrow parameters,
$event) are local.
Directives #
| Syntax | Meaning |
|---|---|
:name="e", v-bind:name, :name (same-name shorthand, :id = :id="id"), .camel | reactive attribute / prop |
class + :class, style + :style | merged (string, array or object, as in Vue) |
v-bind="obj" | every property of obj (keys taken once, values reactive) |
@event="handler", v-on:event | listener (see below) |
modifiers .stop .prevent .self .once, keys .enter .esc (.escape) .space .up .down .left .right .tab .delete | passed 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-for | the 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 component | modelValue + 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-cloak | ignored (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,Spaceronly (never a component, aCheckbox, anInputor aLink). Such a node gets$.bindClassEl(n, getter)for a:classwithout a staticclassbeside it,$.onEl(n, event, handler)for a handler without modifiers, and$.setElText/$.bindElTextwhere 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$.elementkeeps every general helper. - A handler with modifiers compiles to
$.onWith(n, event, handler, ["stop", …])on any node, so$.onis called without modifiers only (an app that uses none does not ship the modifier code). - Handlers of a
v-foritem 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$.ontook 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 rowsandremove(i)), or a name of a destructured alias ({ id } in rowsandselect(id)). A nestedv-forhoists into the outer item function, so its handlers may read the outer row. The body of a slot written inside av-forhoists 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 thev-for, or the one element in a<template v-for>; not a component, av-if, several nodes); without a:keythe third argument isundefined.- A class that asks a selector about the key. In a keyed
v-forwhose key reads the item alias and nothing else, a:class(no staticclassbeside it) that contains one callname(key)—namedeclared in<script setup>asconst name = createSelector(…)withcreateSelectoran import, the argument the key expression — and reads nothing else compiles to$.bindSelected(n, _ctx.name, key, _selN) || $.bindClassEl(n, getter)._selNis the class as a function of the call's result, hoisted to module level. Whennameis acreateSelectorresult 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.nameunwraps and which can get another selector later; writeconst isSelected = createSelector(…)in the component that renders the list to get the binding), alet, 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>(samelang), 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) orlisp(<script setup>only).namecomes 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 .tsxfiles 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. exportis an error (C304); top-levelawaitis an error (C312, needs Suspense).
Compiler macros #
| Source | Result |
|---|---|
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 file | props: { 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:
<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><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.
| Form | Meaning |
|---|---|
(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", 42 | static text |
| any other form or symbol | dynamic 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 form | static / $.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 handler | listener; 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 x | sets 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 component | named 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) #
| Code | Meaning |
|---|---|
| C201 | element missing its end tag |
| C202 | end tag without an open element |
| C203 | malformed tag, <!…> declaration |
| C204 | unterminated comment |
| C205 | unterminated {{ |
| C206 | duplicate attribute |
| C207 | malformed attribute or value |
| C208 | <script> / <style> inside a template (also under v-pre and as <component is="style">) |
| C210 | invalid or empty expression (the position points into it) |
| C211 | v-html, innerHTML, outerHTML, srcdoc (any case: :srcDoc, :SRCDOC, :src-doc.camel): HTML insertion |
| C212 | unsupported directive (v-once, v-memo, custom directives), v-text misuse |
| C213 | v-else/v-else-if without v-if |
| C214 | invalid v-for |
| C215 | v-if and v-for on the same element |
| C216 | invalid v-model target (not assignable, a v-for alias, a prop, a const, wrong element) |
| C217 | unknown modifier |
| C218 | slot misuse (v-slot outside a component, mixed or duplicate slots, Vue 2 slot=) |
| C219 | dynamic arguments, v-on="obj", dynamic or conditional slots |
| C220 | unsupported built-in component |
| C221 | <component> without :is |
| C222 | v-show on a component or <template> |
| C223 | dynamic or invalid ref |
| C224 | inline event handler attribute (onclick=, any case), javascript:/vbscript: URL in a URL attribute, static srcdoc |
| C230 | invalid Lisp template form |
| C231 | invalid defcomponent |
| C250 | warning: unknown component / custom element |
| C251 | warning: x.value on a ref in a template (templates unwrap refs) |
| C253 | assignment to a prop in an event handler |
| C254 | assignment 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) #
| Code | Meaning |
|---|---|
| C301 | missing <template>, unterminated block, invalid script tokens |
| C302 | duplicate block, text outside blocks; warning: custom block ignored |
| C303 | unsupported lang or src; TypeScript that cannot be stripped |
| C304 | export in <script setup> |
| C305 | a macro argument uses a local variable of setup |
| C306 | compiler macro misuse (not top level, twice, both type and runtime arguments, bad destructure) |
| C307 | unresolvable props/emits type |
| C308 | <style scoped> / <style module>; warning: <style> on the terminal target |
| C309 | reserved name ($, _ctx, __props, __setup, _spread) |
| C310 | Lisp script compile error |
| C311 | eval, the Function constructor, with, string timers |
| C312 | top-level await in <script setup> |
| C313 | <script> and <script setup> in different languages |
| C314 | defineOptions() |
| C315 | export default in a plain <script> |
| C390 | warning from the Lisp compiler (e.g. a deprecated macro version) |
Reactivity footguns (FW-1) #
| Code | Pattern | Fix-it |
|---|---|---|
| C350 | const { a } = reactive({…}) or = state (a reactive() binding) | toRefs(state), or read state.a |
| C351 | const { title } = props (the defineProps() object) | destructure defineProps() directly (compiled to stay reactive), or toRefs(props) |
| C352 | state = … where state is reactive() | change its properties, or use a ref |
| C353 | a ref used without .value in an operator (n + 1, n++, n > 3, !n) | n.value |
| C251 | count.value in a template | count |
| C253 / C216 | writing a prop | emit an event, or defineModel() |
| C254 / C216 | writing 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 withcreateElement/setProp, with its own checks, then copies).v-htmland bindings ofinnerHTML,outerHTML,srcdocare errors (C211). - No code from strings: inline event handler attributes (
onclick="…") and staticjavascript:/vbscript:URLs inhref src action formaction xlink:href poster cite data toare 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 script:), do not hide it. Scripts may not useeval, theFunctionconstructor,with, or string timers (C311); the compiled module is checked too. All of this also keeps CSPscript-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,:SRCDOCand:src-doc.camelaresrcdoc;HREFishref;:onClickis an event attribute. <script>/<style>elements inside templates are errors (C208): written as tags, underv-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 ofv-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:
| binding | what untrusted input can do | do 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 refused | check 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 other | spread 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 link | map 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 app | check 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
onXkeys, which are handlers (as the runtime implements). A valueless attribute passestrue. - The build target decides the helpers: the general ones for
:termand 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 showsonToggle: () => _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 onObject.prototypeis no attribute) (thetrue: the element may hold a key already, so a firstnullorfalsestill 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_spreadhelper turns the object into getters and handlers. Keys are read once.:keyoutside v-for compiles to a one-item$.forBlock(() => [key], …).- Lisp components return their render function from
setup; it is called with_ctx. defineModeluses$.computed({ get, set })(no local value when the parent does not bindv-model)..lispfiles go through the build's Lisp frontend; their import specifiers are not rewritten by this package (SFC imports are).