damask 0.2.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.2"
    
  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
{#each E as p} / {#each E as p, i} …{/each} loop
{#snippet name(params)}…{/snippet} define a reusable fragment
<ul>
{#each &self.items as item}
  <li>{item}</li>
{/each}
</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.

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.

Class lists

class takes three further forms, and a class: directive overrules them all:

<div class=[self.extra, "base", { "is-open": self.open }] class:base={!self.bare}>

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:

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

<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>{#each &self.labels as label}{@render item(label)}{/each}</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.

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

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

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.