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.2" -
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 |
{#each E as p} / {#each E as p, i} …{/each} |
loop |
{#snippet name(params)}…{/snippet} |
define a reusable fragment |
{#each &self.items as item}
{item}
{/each}
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.
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 }.
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}
{#each &self.labels as label}{@render item(label)}{/each}
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.
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.
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) |
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. The Zed extension pins it by revision in extension.toml.
License
MIT.