damask 0.11.0

Damask: React-like, compile-time components for Rust.
Documentation

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 damask::Component;

// greeting.rs  (paired with greeting.dmk)
#[derive(Component)]
pub struct Greeting {
    pub name: String,
}
<!-- greeting.dmk -->
Hello {self.name}!
assert_eq!(Greeting { name: "Ada".into() }.render(), "Hello Ada!");
// `{ … }` HTML-escapes:
assert_eq!(Greeting { name: "<b>".into() }.render(), "Hello &lt;b&gt;!");

Quickstart

  1. Add the dependency (no build script, Rust ≥ 1.88):

    [dependencies]
    damask = "0.5"
    
  2. Create a component as two files that share a basename, in the same directory — button.rs and button.dmk.

  3. 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
<ul>
{#for item in &self.items}
  <li>{item}</li>
{/for}
</ul>

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:

<input title="row {self.n}" disabled={self.locked} placeholder={self.hint}/>

disabled appears only when locked, because in HTML the presence of the attribute is what disables the control — disabled="false" disables it too.

@tokens — one value from many parts

{@tokens(…)} builds a space-separated value, on whatever attribute it is written. A class: directive still overrules a class built this way:

<div class={@tokens(self.extra, "base", "is-open": self.open)} class:base={!self.bare}>
<a rel={@tokens("noopener", external: self.away)}>

A positional entry is a name, a list of them, or an Option — a literal None is dropped at compile time (a bare None has no type to infer). A name: cond entry is there while cond holds; the name is a bare identifier or, for anything an identifier cannot spell, a string: "md:px-3": cond. 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, write it in a helper, whose names are ordinary strings: class={@tokens("animate-pulse": cond)}.

@attrs — many attributes from one

{@attrs(…)} expands into a run of <name>-* attributes, the way a Rails view does with data: { … } — and, since the prefix is the attribute it was written on, the same helper serves aria and anything else shaped that way:

<div data={@attrs(self.hooks(), controller: "modal", index: self.i)}
     aria={@attrs(label: self.title)}>

A key becomes <name>-<key> verbatim — user_id is data-user_id — and is a bare identifier or a string, checked when the template compiles, because a name is not something escaping can make safe. A positional entry contributes whole pairs and a later one overrides an earlier, keeping the first mention's position. 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. AttrSet is implemented for pair lists, HashMap, BTreeMap, Attrs, Option of any of them, and whatever you implement it for.

No attribute is special. data="movie.swf" and data={self.url} are the ordinary attribute they look like, on <object> as anywhere else; it is the helper that expands, not the name.

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:

<input {...self.wiring} {...&self.data}/>

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. A tag that writes an attribute itself and also spreads a set holding it writes it once, from the tag.

A component takes attributes it never declared the same way, given one field to put them in:

#[derive(Component, Default)]
#[component(default)]
pub struct Hidden {
    #[prop(rest)]
    pub attrs: Attrs,
}
<input type="hidden" {...self.attrs}>       <!-- hidden.dmk -->

<Hidden data-cover-target="input" autofocus/>

Which of a tag's attributes are props is settled when it compiles: a declared prop wins, and everything else goes to the bag. Without a #[prop(rest)] field a component still refuses an attribute it does not name, so a misspelled prop is a build failure rather than something rendered into the page.

<div>
  {use crate::widgets::Frame}        <!-- import, scoped to this <div> -->
  <Frame title={self.heading.clone()}>
    <p>{self.body}</p>                          <!-- fills the default slot -->
    <span slot="footer">© {self.year}</span>
    <a slot="footer" href="/about">About</a>
  </Frame>
</div>

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 damask::Component;

#[derive(Component)]
pub struct Frame {
    pub title: String,
}
<!-- frame.dmk -->
<section><h2>{self.title}</h2><slot/><footer><slot name="footer">© anon</slot></footer></section>

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 -->
<Frame title={self.title.clone()}>
  <slot/>                                   <!-- forward the default slot -->
  <slot name="footer" slot="footer"/>       <!-- forward "footer" -->
</Frame>

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 -->
<div class="dialog">
  <h2>{self.title}</h2>
  {#if slots.has_default()}<p class="body"><slot/></p>{/if}
  {#if slots.has("actions")}<footer>{@render slots.get("actions")}</footer>{/if}
</div>

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)}<li>{label}</li>{/snippet}
<ul>{#for label in &self.labels}{@render item(label)}{/for}</ul>

Slots can also be filled from Rust, with render_with:

use damask::{fragment, Component, Renderer, Slot, Slots, DEFAULT_SLOT};

let body = fragment(|r: &mut dyn Renderer| r.write_raw("<p>hi</p>"));
Layout.render_with(Slots::new(&[Slot::new(DEFAULT_SLOT, &body)]));

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 damask::Component;

// profile.rs  (paired with profile.dmk)
#[derive(Component)]
pub struct Profile<'a> {
    pub store: &'a Store,
    pub id: UserId,
}
<!-- profile.dmk -->
<p>{self.store.load_name(self.id).await}</p>

Such a component implements AsyncComponent / AsyncRender instead of Component / Render, so it renders with .render_async().await:

use damask::AsyncComponent;

let html = Profile { store: &store, id }.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 .await cannot 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 .await in 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

cargo test --workspace          # runtime, macro, parser, LSP, examples, trybuild
cargo clippy --workspace --all-targets -- -D warnings

The Tree-sitter grammar lives in crates/tree-sitter-damask/grammar, with the Rust binding the website reads it through in the crate around it. Zed reads the same directory: extension.toml pins a revision of this repository and points path at it, so a grammar change is one commit — and reaches editors when that commit is pushed and the rev is bumped to name 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.