Damask — compile-time components for Rust
React-like, compile-time components for Rust. A component is a struct (its
fields are its props) paired with an HTML template that uses a { … } tag
syntax. The Component derive turns the template into a render method at
build time, so rendering is plain, allocation-light Rust — no runtime template
engine.
use Component;
// greeting.rs (paired with greeting.dmk)
<!-- greeting.dmk -->
Hello {self.name}!
assert_eq!;
// `{ … }` HTML-escapes:
assert_eq!;
Quickstart
-
Add the dependency (no build script, Rust ≥ 1.88):
[] = "0.5" -
Create a component as two files that share a basename, in the same directory —
button.rsandbutton.dmk. -
use damask::Component;,#[derive(Component)]your struct, and call.render().
The template is found automatically next to the struct (via Span::local_file),
and editing it triggers a rebuild — no build.rs, no configuration.
Template syntax
Templates are HTML with brace tags. A { … } tag holds a Rust block:
if it's an expression, its value is printed (HTML-escaped); if it's a statement
or binding, it runs and prints nothing.
| Tag | Meaning |
|---|---|
{ expr } |
print the block's value, HTML-escaped ({2+3; 10} prints 10) |
{ let x = e } / { x; } |
a binding / statement — runs, prints nothing |
{@html expr} |
print expr raw (unescaped) |
{@render expr} |
render a snippet / fragment |
{use path} |
a Rust use, scoped to the enclosing element |
{#if c}…{:else if c2}…{:else}…{/if} |
conditional |
{#for pat in E}…{/for} |
loop — a Rust for |
{#snippet name(params)}…{/snippet} |
define a reusable fragment |
{#for item in &self.items}
{item}
{/for}
Literal braces are written as expressions: {"{"}. <!-- … --> comments pass
through.
Elements, components, and slots
Lowercase tags are HTML. Capitalized tags are components — built from their
attributes and rendered. Attributes carry Rust: attr={expr}, attr="literal",
or bare attr (boolean). Omitting a required field is a compile error naming
it; a field whose type is Option<_> may be omitted and arrives as None, and
#[component(default)] on the struct makes every field skippable, filling the
omitted ones from its Default. A framework that re-exports Damask instead of
having its users depend on it names its own path with
#[component(crate = my_framework::view)], since the default ::damask resolves
only where damask is a direct dependency.
Quoted values interpolate, and on an HTML element attr={expr} asks the value's
type how to appear — a bool renders a bare attribute or none at all, an
Option renders nothing when None:
disabled appears only when locked, because in HTML the presence of the
attribute is what disables the control — disabled="false" disables it too.
Class lists
class takes three further forms, and a class: directive overrules them all:
Entries may be strings, Options of them, or a map of conditional names; a
literal None is dropped at compile time (a bare None has no type to infer).
Names are deduplicated and keep their first-mention order, and an empty result
omits the attribute.
CSS scanners and
class:. A directive puts the class name in the attribute name (class:animate-pulse), where Tailwind and friends do not look — the rule gets compiled out of your stylesheet. When a class has to be discoverable by a scanner, use the map form, whose names are ordinary strings:class={ "animate-pulse": cond }.
Data attributes
data expands one value into a run of data-* attributes, the way a Rails view
does with data: { … }, and takes the same three forms:
data=[self.base(), { "open": self.open }] <!-- list, later wins -->
data={ "controller": "modal", "index": self.i }> <!-- map -->
A key becomes data-<key> verbatim — "user_id" is data-user_id. Values
follow the Attr rules one level down, so a bool renders a bare data-open or
nothing, and an Option renders nothing when None. DataItem is implemented
for pair lists, HashMap, BTreeMap, Option of any of them, and whatever you
implement it for.
A quoted
data="…"is untouched, which is what leaves<object data="movie.swf">working. Onlydata={…}anddata=[…]expand; a dynamic<object>source is writtendata="{self.url}".
Spreading attributes
{...expr} splices a prepared run of attributes — for the ones a component
cannot name, such as a computed data-<controller>-target, or a map:
AttrSpread is implemented for &'static str (markup the author wrote — the
lifetime is what keeps a request-derived value out) and for [(K, V)] /
Vec<(K, V)>, which escapes and is where anything derived from state belongs.
{use crate::widgets::Frame} <!-- import, scoped to this <div> -->
{self.body} <!-- fills the default slot -->
© {self.year}
About
A component places its slots with <slot/>, and a caller routes content into a
named one with slot="…" on a direct child — the web-component pair. The whole
element goes in, several children may name the same slot (they land in the order
written), and the slot attribute itself is consumed rather than rendered.
Slots are not fields — a template declares as many as it likes without the struct
changing, and a <slot>'s body is the fallback rendered when the caller leaves
it unfilled:
use Component;
<!-- frame.dmk -->
{self.title}© anon
Slots are matched by name at render time, so a misspelled name fails silently
rather than at compile time — the price of keeping them off the struct.
<slot> is only ever a placeholder, so putting one where a fill goes
forwards — it resolves against this component's caller and slot= hands the
result to the child:
<!-- shell.dmk -->
<!-- forward the default slot -->
<!-- forward "footer" -->
Outside a component element slot is an ordinary attribute, so a template can
still address a browser-side custom element's shadow slots.
A template can also ask about its slots: the caller's fills are in scope as
slots, which answers what a fallback cannot — whether the markup around the
content should exist at all.
<!-- dialog.dmk -->
{self.title}
{#if slots.has_default()}{/if}
{#if slots.has("actions")}{@render slots.get("actions")}{/if}
slots.get(name) is renderable as it comes — an unfilled slot renders nothing —
so {@render} needs no guard of its own; the {#if}s above are guarding the
wrappers. has_default() / get_default() are the same pair for the default
slot.
{use} is an ordinary Rust use — import components, functions, or anything
else — and it is scoped to the HTML element that encloses it.
Snippets
Snippets are reusable fragments, defined with {#snippet} and rendered with
{@render}; parameters make them render-props:
{#snippet item(label)}{label}{/snippet}
{#for label in &self.labels}{@render item(label)}{/for}
Slots can also be filled from Rust, with render_with:
use ;
let body = fragment;
Layout.render_with;
The fills are borrowed, not owned, so slot content stays on the caller's stack and can borrow the caller's data without boxing.
Async templates
Write .await anywhere the template holds Rust — a { … } tag, an {#if} /
{#for} condition or iterable, an attribute value, a snippet body — and the
derive compiles that component to an async render path. There is nothing to add:
it is decided from the template itself, and a template with no .await anywhere
is compiled exactly as before, at no cost.
use Component;
// profile.rs (paired with profile.dmk)
<!-- profile.dmk -->
{self.store.load_name(self.id).await}
Such a component implements AsyncComponent / AsyncRender instead of
Component / Render, so it renders with .render_async().await:
use AsyncComponent;
let html = Profile .render_async.await;
Composition is free in one direction: every sync Render gets an AsyncRender
whose future has nothing left to poll, so an async template embeds a plain sync
child at no real cost. The other direction is a compile error naming the missing
Render — an async component has no sync fallback, because producing one would
mean blocking on a future inside the executor already driving the caller. Make
the enclosing template async too, or load the child's data before constructing
it.
The render future is Send, so a request handler can await one. Three bounds
buy that, one per thing held across an .await: Renderer: Send, a slot fill is
Sync (on Slot, deliberately not a Render supertrait), and
AsyncRender: Sync — automatic for a struct of data, and something a generic
component that awaits must name on the parameters it holds.
Two places
.awaitcannot go, both with a one-line rewrite the compiler spells out. A component's slot fill reaches the callee as a plain&dyn Render, so compute the value in a{ let x = … }above the tag and pass{x}in — a<slot>'s own fallback body has no such limit. And a{#snippet}that takes parameters cannot.awaitin its body, since its closure has to stay callable more than once; await at the call site instead, as{@render row(self.fetch().await)}.
Custom renderers
Renderer is the extensibility seam — it owns the output buffer and the escaping
policy. Implement it to change escaping or target a different sink; components are
compiled against &mut dyn Renderer, so any renderer drives any component. It
requires Send, which a buffer-backed renderer satisfies without saying
anything, and which is what lets an async render be awaited on a work-stealing
executor.
Workspace
| Crate / dir | Purpose |
|---|---|
damask |
the facade: traits, the HTML renderer, and the derive |
damask-macros |
the Component derive + template resolution |
damask-template |
the .dmk parser (shared by macro + LSP) |
tree-sitter-damask |
the vendored Tree-sitter grammar, for the website |
damask-lsp |
language server (diagnostics + completion) |
editors/zed |
Zed extension (highlighting + LSP) |
skills/damask |
agent skill for authoring components |
examples/showcase |
runnable example components |
examples/dashboard |
a full HTML page from 7 composed components |
Development
The Tree-sitter grammar lives in its own repository, tree-sitter-damask, because Zed clones a grammar from a repository root. It is pinned by revision in two places, which have to move together: extension.toml, which Zed clones from, and crates/tree-sitter-damask, which vendors the generated parser so the website can compile it.
The website highlights .dmk with that grammar and with the extension's own
queries in editors/zed/languages/damask — so a
snippet looks the same on the site as it does in an editor, and editing a query
changes both.
License
MIT.