DocsBuilding pages
Rendering & components
html! is parsed at compile time, escapes what it interpolates, and knows which expressions belong to the server and which to the browser. Components are ordinary Rust functions you can also write as tags.
The ownership split
Rahti has two expression dialects, and they never interchange.
| Syntax | Owner | Evaluated |
|---|---|---|
| @{rust_expression} | Rust, through Rahti | On the server, while the page renders. |
| {javascript_expression} | PulsePoint | In the browser, whenever its state changes. |
html! {
<section>
<h1>@{server_title}</h1>
<button onclick={setCount(count + 1)}>{count}</button>
<script>
const [count, setCount] = pp.state(@{initial_count});
</script>
</section>
}They also print differently. @{…} renders a Rust value through Display, so false renders as false. A {…} binding serializes its result the way React serializes a JSX child, so a boolean or a None seeded into the browser renders as nothing at all — a correct value with no visible output. The full table is under value rendering; read it before binding anything that is not a string or a number.
Text, roots, and attributes
Author text as quoted Rust strings. A text run is exactly one string literal, so break a long one with a backslash rather than writing two.
// Authored text is a quoted Rust string. Plain HTML-like text is accepted,
// but Rust tokenization makes punctuation and whitespace fragile.
<p>"Stable authored text"</p>
// A run is exactly one string literal. Break a long line with a backslash:
<p>
"One paragraph, written across several source lines, because the \
backslash swallows the newline and the indentation after it."
</p>An html! block produces one rooted markup value: a literal element, a single component tag, or a <>…</> fragment. A document doctype may precede the document root. Void elements are self-closing in authored markup.
<a href=@{url}>@{label}</a>
<input value={name} oninput={setName(target.value)} />
// A name is written as it appears in markup: letters, digits, `-`, and `:`
// for a namespace prefix.
<img data-user-id=@{id} aria-label="Avatar" />
<use xlink:href="#icon" />Rahti quotes dynamic attributes in the emitted HTML, and client expressions stay present for PulsePoint to bind. <script> and <style> bodies are raw text: browser braces stay literal. @{…} works inside a script — it renders through RenderJs — and is not lifted inside a style.
Whether an attribute is written
An @{…} value does not only decide what an attribute says. It decides whether the attribute appears at all.
| Value | Renders |
|---|---|
| anything ordinary | name="value" |
| Option::None | nothing — no attribute is written |
| Option::Some(v) | whatever v renders |
| true, on a boolean attribute | name="" |
| false, on a boolean attribute | nothing |
| true / false anywhere else | name="true" / name="false" |
// An `@{…}` value decides whether its attribute is written at all.
<a href=@{maybe_url}>"Profile"</a> // no href when None
<input disabled=@{is_locked} /> // no disabled when false
<span aria-hidden=@{decorative}>"Ada"</span> // aria-hidden="false" when false<!-- Some("/me"), true, true -->
<a href="/me">Profile</a>
<input disabled="">
<span aria-hidden="true">Ada</span>
<!-- None, false, false -->
<a>Profile</a>
<input>
<span aria-hidden="false">Ada</span>A boolean attribute is one HTML reads by presence, and which one that is depends on the name — decided at compile time, not by the value's type. The list is the HTML specification's:
allowfullscreen, async, autofocus, autoplay, checked, controls, default, defer, disabled, download, formnovalidate, hidden, inert, ismap, itemscope, loop, multiple, muted, nomodule, novalidate, open, playsinline, readonly, required, reversed, selected, and the three shadowroot* attributes.
None of this touches a literal. <input required> and hidden="until-found" are written exactly as typed.
Spreading attributes
@{..expr} writes a set of attributes computed in Rust — two dots, and inside the server dialect.
use crate::rahti::Attrs;
let extra = Attrs::new().set("id", "avatar").unset("stroke-width");
html! {
<svg class="size-4" stroke-width="2" @{..extra}>@{icon}</svg>
}<svg class="size-4" id="avatar">…</svg>An attribute written later replaces one written earlier, in place, so where the spread sits is what it means: after the attributes it overrides them, before them it supplies defaults. Attrs::unset removes an attribute outright, which no value can express.
// Anything implementing `Attributes` spreads: `Attrs`, pairs in an array,
// slice, `Vec` or map, and an `Option` of any of them.
<div @{..[("id", "a"), ("data-x", "1")]}>…</div>
<div @{..maybe}>…</div> // Option::None writes nothing
// Position is meaning: after the attributes it overrides them, before them
// it supplies defaults. A later write replaces an earlier one *in place*.
<svg @{..extra} class="size-4">…</svg> // class="size-4" wins, and stays first| Method | What it does |
|---|---|
| Attrs::new() | An empty set. |
| set(name, value) | Adds an attribute, or replaces one already there — in place. |
| unset(name) | Removes an attribute the tag wrote. There is no value that does this. |
| get(name) | The value, if the set carries the name. |
| iter(), names(), len(), is_empty() | Reading a set back. |
| Attrs::from([...]), collect() | Building one from pairs known at once, or computed. |
Names are validated and values escaped, the same as everywhere else. A name markup cannot carry is dropped and reported through debug_assert! — development says so, and a release build serves the page without that one attribute. A HashMap spreads in sorted order, because markup that changes between two runs of the same code cannot be reviewed, and pp-component belongs to the runtime: a spread never writes it.
A spread is for elements. A component takes typed props, so a component that forwards attributes declares one — attrs: Attrs — and the caller passes it by name. There are no implicit rest-props, because an unknown prop has to stay a compile error rather than quietly becoming an HTML attribute nobody meant to write.
Escaping and trusted markup
Rust strings and values are escaped in text and in attribute position; attributes also escape quotes. An Html value is already trusted and passes through untouched.
// Escaped on the way out — the default, and almost always what you want.
<p>@{user_supplied}</p>
// Trusted markup, rendered as written. Only for markup you produced or
// sanitised yourself.
<div>@{Html::from_raw(rendered_markdown)}</div>
// Other constructors.
Html::escape("<b>not bold</b>"); // safe text
Html::empty(); // no output at all
Html::concat(parts); // several fragments, in orderInside a <script>, primitives render as JavaScript literals, strings are JSON-quoted, None is null, and Json(&value) embeds objects and arrays. Rahti neutralises < in serialized script values so a value cannot close the script around it.
// To show a PulsePoint brace as text, write the character reference. The
// macro protects it, so the runtime renders a brace instead of binding.
<p>"{user}"</p>Components
A component is an ordinary PascalCase Rust function. A tag name starting with a capital letter is a component — no HTML tag does — and its attributes name the function's parameters.
use crate::rahti::{Html, component, html};
#[component]
pub fn Card(title: &str, children: Html) -> Html {
html! {
<section>
<h2>@{title}</h2>
<slot />
</section>
}
}<Card title="Profile">
<p>"Ada"</p>
</Card>A prop value is a Rust expression: a literal as written (title="Profile", count=3), or @{expr} for anything computed. A bare prop is the boolean shorthand — <Card wide> passes wide: true. A client {…} value is not a Rust prop: it is carried to the component as source, which is its own thing below.
// The tag form is compile-time sugar over the function call, and the call
// form is what expression contexts use — a loop, a condition, a map.
@{Html::concat(users.iter().map(|user| Card(user, Html::empty())))}Behind the tag, #[component] generates a hidden props struct sharing the function's name. The one use that imports the component imports both, and an aliased import — use lib::Button as Installed; — carries the pair along, so <Installed /> resolves.
The rules:
- use
#[component]with no arguments; - name the function in PascalCase — the capital is what makes a tag resolve to it;
- return
Html, and end branches and returns in thehtml!value; - take props as normal typed Rust arguments;
- keep one root — a literal element, a single component tag, or a fragment;
- do not stamp
pp-componentby hand.
Props you can leave out
A tag fills the props it names and takes the component's defaults for the rest — the way a React component reads { variant = "default" } out of its props. The props struct carries a Default, and html! closes every props literal with it.
#[component]
pub fn Badge(label: &str, tone: &str, wide: bool) -> Html {
html! {
<span class=@{tone} data-wide=@{wide}>@{label}</span>
}
}
// Only `label` is named. `tone` and `wide` come from the props' `Default`,
// which is `""` and `false`.
<Badge label="Rust" />The bound is on every prop type, since the struct cannot know which fields a given call site will omit. &str, bool, numbers, String, Option<T> and Html all satisfy it; a prop that genuinely has no sensible default belongs in an Option.
Client bindings on a component tag
A component runs at render, so there is nothing of the browser in it to evaluate a {…} against. What it gets instead is the source: the prop's name and the expression as written, both as &'static str. The component is then free to write them onto whatever element it renders — which is what puts <Button onclick={save()}> on the button itself, and makes a component's event props behave like React's.
use crate::rahti::{Attrs, Html, component, html};
#[component]
pub fn Button(label: &str, bindings: &[(&str, &str)]) -> Html {
// Each entry is the prop's name and the expression source, without its
// braces. Putting them back is what makes the runtime bind it.
let forwarded: Attrs = bindings
.iter()
.map(|(name, expr)| (*name, format!("{{{expr}}}")))
.collect();
html! {
<button class="btn" @{..forwarded}>@{label}</button>
}
}<Button label="Add" onclick={setCount(count + 1)} /><button pp-component="button_769d18a2" class="btn" onclick="{setCount(count + 1)}">Add</button>They do not become props of their own. All of them arrive as one bindings: &[(&str, &str)] parameter, so a component opts into the whole surface once instead of naming forty events. Braces are stripped on the way in — putting them back is what makes the runtime bind the attribute — and a component that never declares bindings is unaffected until somebody hands it a client expression, which then reports as an unknown field at the tag.
The expression is evaluated in the browser against the state in scope where the tag was written — the caller's <script>, not the component's — and it stays live: an event prop fires there, and a value prop re-renders when the state it reads changes. That holds even though the element the component rendered carries a boundary of its own.
Composition
A block may render a component tag as its whole output. A block rooted in a component tag owns no element of its own, so it takes no boundary marker and adds no reactive scope: it dissolves into the component it renders, exactly as a React wrapper that returns another component contributes no DOM.
#[component]
pub fn Spotlight(children: Html) -> Html {
html! {
<Card title="Spotlight">@{children}</Card>
}
}- a wrapper that only forwards props and children needs no element around the inner tag;
- a component that carries its own
<script>keeps an element root — or a fragment root, which is one — since a dissolved wrapper has no block for the script to live in; - a root layout keeps its literal
<html>root: a document's doctype and document element are written in the layout, with components inside them.
Fragments
A block that renders siblings may root itself in <>…</>.
#[component]
pub fn Pair(label: &str) -> Html {
html! {
<>
<dt>@{label}</dt>
<dd><slot /></dd>
</>
}
}<!--pp:pair_3f2a91c8--><dt>Role</dt><dd>Engineer</dd><!--/pp-->Comments are legal in every content context, so the served markup stays valid wherever the fragment lands — <tbody>, <ul>, <select> included — and a page without JavaScript renders the siblings bare, exactly as written. At hydration the runtime turns the pair into a live, layout-invisible boundary: the siblings participate in the parent's flex, grid or flow as direct items, and the block keeps its own state like any other.
After hydration the boundary is an element in the DOM, so a CSS child selector (.parent > *) sees it rather than the siblings. Prefer class selectors inside fragments.
Children and slots
Children are not implicit in the function: declare children: Html, then place <slot />. At the call site they are whatever sits between the tags — markup, quoted text, other component tags, @{…} values, or several siblings, joined in order.
// Children built in Rust are handed in with `@{…}`.
let rows = Html::concat(items.iter().map(|item| html! {
<li>@{item}</li>
}));
html! {
<Card title="Rows">
<ul>@{rows}</ul>
</Card>
}An empty tag pair passes Html::empty(), and so does a self-closing tag: children is a prop like any other, and Html's default is nothing at all. So <Card title="…" /> and <Card title="…"></Card> arrive at the same place, and a component that wants to render a fallback instead asks with Html::is_empty. A component that takes children and passes it on interpolates it between the next component's tags instead of writing a slot.
The reactive boundary rule
Each stamped block is a PulsePoint scope. Children written between component tags are a block of their own when they have a single root element — so a client binding written there cannot read state declared by the parent page's script. This fails silently, as literal, unbound text.
// Wrong: the binding is in the children block, the state is in the page.
<Panel title="Counter">
<p>{count}</p>
</Panel>
<script>
const [count, setCount] = pp.state(0);
</script>
// Right: state, markup and script in one block — here, a component of
// its own.
#[component]
pub fn Counter() -> Html {
html! {
<div>
<p>{count}</p>
<button onclick={setCount(count + 1)}>"Add"</button>
<script>
const [count, setCount] = pp.state(0);
</script>
</div>
}
}Keep state, functions, bindings and the script that declares them in the same block. A reusable interactive child should be its own #[component], or carry its own script inside its own root. A child fragment with several roots, or none, is not stamped — which is the escape hatch when several siblings must share one scope.
Component modules
Application components live under src/components/, nested directories included. File and directory names must be valid, non-keyword Rust module identifiers: no leading digit, no hyphens. Dotfiles and non-Rust files are ignored, and the build regenerates the module files on every build.
src/components/card.rs -> crate::components::card::Card
src/components/forms/field.rs -> crate::components::forms::field::FieldOne file may export several components, and a nested file is reached through its full generated module path.
src/components/ is the only directory the build scans for components, and — beside src/app/ — the only one Tailwind is pointed at. A component written anywhere else is registered by nothing and styled by nothing. Components do not arrive from a dependency either: there is no importer that reaches into another crate for markup, for Rust sources to scan, or for a stylesheet to append. What a page can render is what this project contains.
