Quill docs
View layer & authoring (view! macro)
The view layer is how you describe HTML in Quill. You build a tree of Node values — either with the ergonomic view!{} macro or with a plain builder API — and a single streaming renderer walks that tree into an HTML string, escaping every piece of text and attribute value for you as it goes. Everything here lives in the qquill-view crate, has zero third-party dependencies (std only), and is fully deterministic: the same tree always renders to the same bytes. This page covers the Node enum end-to-end, the full view! grammar, the equivalent builder calls, the IntoNode trait used for dynamic interpolation, how the renderer and escaper behave, and the Phase-3 island/slot nodes.
The Node tree#
Every view is a value of the Node enum. The tree deliberately borrows where it can: element tags and attribute *names* are &'static str (they come from authored markup, never user data), while text and attribute *values* are Cow<'static, str> so you can pass a borrowed literal with no allocation or an owned dynamic String when you need it. There are six variants.
- Node::Element { tag, attrs, children }
- An element with a
&'static strtag, an orderedVec<(&'static str, AttrVal)>of attributes, and an orderedVec<Node>of children. Attribute order is preserved exactly as inserted, so rendering is deterministic (no map reordering). - Node::Text(Cow<'static, str>)
- Text content. HTML-escaped at render time (the five characters & < > " ').
- Node::Raw(Cow<'static, str>)
- Trusted raw markup. NOT escaped — by using it you assert the string is safe.
- Node::Fragment(Vec<Node>)
- A transparent grouping with no wrapper element; its children are flattened in order into the parent.
- Node::Island { instance_id, kind, trigger, props, fallback }
- A Phase-3 interactive subtree: it renders a static server fallback inside a boundary element and ships an escaped props sidecar for client hydration. Covered in the Islands section below.
- Node::Slot { name, children }
- A named hole a parent island can target on the client. On the server it renders its default children inside a
<template data-q-slot="...">marker. Server-inert.
Attribute values are an AttrVal enum with two shapes. AttrVal::Str(Cow) renders as name="value" with the value HTML-escaped. AttrVal::Bool(true) renders as a bare attribute name (e.g. disabled, checked); AttrVal::Bool(false) is omitted entirely. AttrVal has From impls for &'static str, String, Cow<'static, str>, and bool, so you rarely construct it by hand.
The builder API#
The builder API is a first-class, fully-supported equal to the macro — the macro lowers to exactly these calls and produces byte-identical trees. Four free functions create nodes, and chaining methods configure elements.
- el(tag: &'static str) -> Node
- Start an element. Equivalent to
Node::element(tag). - text(s: impl Into<Cow<'static, str>>) -> Node
- An escaped text node.
- raw(s: impl Into<Cow<'static, str>>) -> Node
- A raw (trusted, unescaped) node.
- fragment(nodes: impl IntoIterator<Item = Node>) -> Node
- A flattened group with no wrapper.
On an element you chain: .attr(name, value) appends an attribute (appending, not replacing — setting the same name twice emits both, in order); .class(value) and .id(value) are convenience wrappers over .attr("class", ..) / .attr("id", ..); .child(node) appends one child; .children(iter) appends several. Calling these on a non-element node is a silent no-op (the tree is a builder, not a validator).
use qquill_view::{el, text, render};
let node = el("div")
.class("card")
.child(el("h1").child(text("Title")))
.child(el("p").child(text("body")));
assert_eq!(
render(&node),
"<div class=\"card\"><h1>Title</h1><p>body</p></div>"
);The view! macro grammar#
view!{} is a macro_rules macro (no syn/quote) that gives you an HTML-like authoring syntax lowering directly to the builder API. Multiple top-level nodes are wrapped in a Fragment; a single top-level node is returned as-is (not wrapped).
view! { <node>* }
node :=
<tag-ident> <attr-spec>* { <node>* } // element with children
| <tag-ident> <attr-spec>* ; // empty / void element
| <string-literal> // escaped text
| ( <expr> ) // dynamic child: any impl IntoNode
| [ <expr> ] // raw (trusted, unescaped) markup
attr-spec :=
. <ident> // .card => class="card" (appended)
| # <ident> // #main => id="main"
| <attr-name> = <value-tt> // name = value (value is ONE token tree)- A bare string literal becomes an escaped
Textnode. ( expr )interpolates any value implementingIntoNode(see next section).[ expr ]inserts raw, unescaped markup (impl Into<Cow>) — use only for trusted content.. identappends a class;# identsets an id; both expand to.attr(...)withstringify!'d names.- An element ends with either a
{ ... }body block or a;terminator (for empty/void elements).
Worked example: macro and builder are identical#
This is the canonical equivalence the test suite asserts: the macro form and the hand-written builder form compare equal as trees and render to the same bytes. Note that the "<unsafe>" literal is escaped, proving text safety is automatic.
Request
use qquill_view::{view, el, text, render};
let title = "Title";
let m = view! {
div .card #main {
h1 { (title) }
p { "Body text with " "<unsafe>" }
ul {
li { "one" }
li { "two" }
}
br;
}
};
// The byte-identical builder equivalent:
let b = el("div")
.attr("class", "card")
.attr("id", "main")
.child(el("h1").child(text("Title")))
.child(el("p").child(text("Body text with ")).child(text("<unsafe>")))
.child(el("ul")
.child(el("li").child(text("one")))
.child(el("li").child(text("two"))))
.child(el("br"));
assert_eq!(m, b); // equal trees
assert_eq!(render(&m), render(&b)); // equal outputResponse
<div class="card" id="main"><h1>Title</h1><p>Body text with <unsafe></p><ul><li>one</li><li>two</li></ul><br /></div>Output
The "<unsafe>" literal is escaped to <unsafe>, and the void element <br> self-closes as <br />.IntoNode: interpolating dynamic values#
The ( expr ) arm of the macro calls IntoNode::into_node on its expression, so you can drop many Rust values directly into markup. The trait is implemented for Node (returns itself), the string-likes &'static str / String / Cow<'static, str> (all become escaped Text), and the scalar types i8..i128, isize, u8..u128, usize, and bool (rendered via to_string() as escaped text).
Request
use qquill_view::{view, el, text, render, IntoNode};
// scalars
assert_eq!(render(&42i32.into_node()), "42");
assert_eq!(render(&true.into_node()), "true");
// a nested Node passed through (expr)
let inner = el("span").child(text("S"));
let node = view! { div { (inner) } };
assert_eq!(render(&node), "<div><span>S</span></div>");Response
<div><span>S</span></div>Rendering and the escaper#
There are three entry points. render(&node) -> String returns owned HTML. render_bytes(&node) -> Vec<u8> returns the same as UTF-8 bytes. render_into(&node, &mut String) appends into an existing buffer — it does NOT clear it first, so you can compose multiple trees or reuse a pooled buffer across requests (you clear it yourself between uses).
Text and attribute values both run through one hand-written escaper that replaces five characters: & → &, < → <, > → >, " → ", ' → '. The escaper scans once and copies verbatim runs between escapes (fast path for no-special-char strings) and is UTF-8 safe (the five specials are ASCII, so byte-index slicing never splits a multi-byte char). Raw nodes bypass escaping entirely.
| Input | Rendered |
|---|---|
| text("<script>alert('xss')</script>") | <script>alert('xss')</script> |
| el("div").attr("title", "\" onclick=\"evil()\") | <div title="" onclick="evil()"></div> |
| el("a").attr("href", "/q?a=1&b=2") | <a href="/q?a=1&b=2"></a> |
| raw("<b>bold</b> & <i>x</i>") | <b>bold</b> & <i>x</i> (unescaped) |
Void elements (area base br col embed hr img input link meta param source track wbr) emit /> with no children and no closing tag, even if children were attached via the builder. All other empty elements render with both an open and close tag, e.g. el("div") → <div></div>. Fragments flatten with no wrapper, recursively, so fragment([text("a"), el("b").child(text("B")), text("c")]) renders a<b>B</b>c.
Islands and slots (Phase 3)#
An island is an interactive subtree. Build one with island(instance_id, kind, trigger, props, fallback). The instance_id must be unique within a page; kind selects the client behavior; trigger is when to hydrate; props is a JSON string you serialize (escaped for you at render time); fallback is the correct static HTML for no-JS users. Trigger is Load, Visible, Interaction, or Idle, rendered to the data-q-trigger attribute values "load" | "visible" | "interaction" | "idle".
On render, the island emits ONLY its fallback wrapped in a <div> boundary carrying data-q-island / data-q-kind / data-q-trigger. The props sidecar (<script type="application/qq" data-q-for=...>) is NOT emitted inline — it is collected separately via collect_island_sidecars(&node) so the host (the DMS) can place all sidecars once per page (typically at the end of <body>). Helper functions page_has_islands(&node), collect_island_kinds(&node) (distinct kinds in document order), and escape_script_json(...) round out the API. A page with no islands renders byte-identically to a plain tree and produces an empty sidecar string (zero JS).
Request
use qquill_view::{island, el, text, render, collect_island_sidecars};
use qquill_view::node::Trigger;
let n = island(
"counter-1",
"counter",
Trigger::Visible,
"{\"count\":0}",
el("button").class("c").child(text("0")),
);
let html = render(&n);
let sidecar = collect_island_sidecars(&n);Response
// html (boundary + fallback only):
<div data-q-island="counter-1" data-q-kind="counter" data-q-trigger="visible"><button class="c">0</button></div>
// sidecar (collected separately, JSON script-escaped):
<script type="application/qq" data-q-for="counter-1">{"count":0}</script>Output
The boundary render contains no `application/qq` and no `data-q-for`; those live only in the separately collected sidecar string.