Skip to content

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 str tag, an ordered Vec<(&'static str, AttrVal)> of attributes, and an ordered Vec<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).

rust
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).

text
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 Text node.
  • ( expr ) interpolates any value implementing IntoNode (see next section).
  • [ expr ] inserts raw, unescaped markup (impl Into<Cow>) — use only for trusted content.
  • . ident appends a class; # ident sets an id; both expand to .attr(...) with stringify!'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 output

Response

<div class="card" id="main"><h1>Title</h1><p>Body text with &lt;unsafe&gt;</p><ul><li>one</li><li>two</li></ul><br /></div>

Output

The "<unsafe>" literal is escaped to &lt;unsafe&gt;, 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: &&amp;, <&lt;, >&gt;, "&quot;, '&#39;. 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.

InputRendered
text("<script>alert('xss')</script>")&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
el("div").attr("title", "\" onclick=\"evil()\")<div title="&quot; onclick=&quot;evil()"></div>
el("a").attr("href", "/q?a=1&b=2")<a href="/q?a=1&amp;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.