Skip to content
Damask

The service table

Classes that assemble themselves from conditions, data that expands from one value, and attributes that know they are HTML.


The centre of the page is a table of services — one row each, striped, with rows that miss their availability target called out. Almost all of the work in it happens in attributes.

The data first. Status gains a machine-readable form, and Service joins it in src/model.rs:

Rust
impl Status {
    /// Machine-readable form, for CSS class suffixes and `data-` values.
    pub fn slug(self) -> &'static str {
        match self {
            Status::Healthy => "healthy",
            Status::Degraded => "degraded",
            Status::Down => "down",
        }
    }
}

/// Latency at or above this is called out in the table.
const SLOW_MS: u32 = 300;

/// One service in the fleet.
pub struct Service {
    pub name: String,
    pub owner: String,
    pub status: Status,
    /// Availability over the trailing window, as a percentage (`99.982`).
    pub uptime_pct: f64,
    /// p95 response time in milliseconds.
    pub latency_ms: u32,
    pub version: String,
}

impl Service {
    pub fn uptime(&self) -> String {
        format!("{:.3}%", self.uptime_pct)
    }

    pub fn latency(&self) -> String {
        format!("{} ms", self.latency_ms)
    }

    /// Whether p95 latency is bad enough to flag even when the service is up.
    pub fn is_slow(&self) -> bool {
        self.latency_ms >= SLOW_MS
    }

    /// Services that are down, or up but missing their availability target.
    pub fn breaches_slo(&self, target_pct: f64) -> bool {
        self.status == Status::Down || self.uptime_pct < target_pct
    }
}

The problem: assembling a class

Rows want two conditional names: alt on every other row, breach on rows below target. Written in Rust that is a small pile of string handling:

Rust
impl ServiceTable<'_> {
    fn row_class(&self, service: &Service, index: usize) -> String {
        let mut classes = String::new();
        if index % 2 == 1 {
            classes.push_str("alt");
        }
        if service.breaches_slo(self.slo_target) {
            if !classes.is_empty() {
                classes.push(' ');
            }
            classes.push_str("breach");
        }
        classes
    }
}

Half of that method is about the space between the names, and when both conditions are false it returns "" — which, dropped into class={…}, renders class="": an attribute that says nothing, on every second row.

The fix: @tokens

{@tokens(…)} takes names paired with conditions, as many as the element needs — on class here, though nothing about the helper is class’s:

Damask
<tr class={@tokens("alt": i % 2 == 1, "breach": svc.breaches_slo(self.slo_target))}>

Each name appears when its condition holds. They are joined with the space you did not have to write, and when nothing holds the attribute is omitted entirely — not emitted empty. Delete row_class; nothing else needs it.

That last property is why this beats a helper returning a String. The helper cannot decline to be an attribute; @tokens can.

Unconditional names go in the same call, as plain entries — a string, an Option of one, a list of them. The badge at the end of this chapter is one:

Damask
<span class={@tokens("badge", self.status.slug())}>

Names are deduplicated across the whole call and keep their first-mention order.

The table

Rust
// src/service_table.rs
use damask::Component;

use crate::model::Service;

#[derive(Component)]
pub struct ServiceTable<'a> {
    pub services: &'a [Service],
    pub slo_target: f64,
}
Damask
<!-- src/service_table.dmk -->
<table>
  <thead>
    <tr><th>Service</th><th>Status</th><th>Uptime</th><th>p95</th><th>Version</th></tr>
  </thead>
  <tbody>
    {#for (i, svc) in self.services.iter().enumerate()}
      <tr class={@tokens("alt": i % 2 == 1, "breach": svc.breaches_slo(self.slo_target))}>
        <td><div class="svc">{svc.name}</div><div class="owner">{svc.owner}</div></td>
        <td>{svc.status}</td>
        <td>{svc.uptime()}</td>
        <td data-slow={svc.is_slow()}>{svc.latency()}</td>
        <td class="ver">{svc.version}</td>
      </tr>
    {/for}
  </tbody>
</table>

An attribute asks its value

data-slow={svc.is_slow()} is a bool, and it renders a bare data-slow or nothing at all. It never renders data-slow="false", which would be a disaster: a CSS rule matching [data-slow] matches the attribute’s presence, so every fast service would be flagged.

That is the general rule. On an HTML element, attr={expr} does not stringify the value — it asks the value’s type how an attribute should appear:

Value type Renders
bool a bare attr when true, nothing when false
Option<T> nothing when None, otherwise as T
&str, String, Cow<str>, numbers, char attr="escaped"
Damask
<input name="email"
       required={self.required}
       placeholder={self.hint.clone()}
       maxlength={self.limit}>

With required: false, hint: None and limit: Some(80), that renders <input name="email" maxlength="80">.

Warning

Never write disabled="{self.locked}". A quoted value always produces the attribute, and in HTML presence is what disables a control — so disabled="false" is a disabled control. Use a bool and no quotes.

There is no blanket Display impl behind this, deliberately: it would collide with the bool and Option impls, which are the point. A type of your own either implements Attr or reaches the template as a string.

Quoted values do interpolate, and the interpolated parts are escaped by the same policy as { … }:

Damask
<tr title="row {i} of {self.services.len()}">

Reach for the quoted form when you are building a string, and for { … } when you are passing a value.

Directives

A directive, class:name={cond}, adds or removes one name and takes precedence over whatever the helper produced. It is the last attribute whose name means anything. A bare class:name is always on. Control, in the repository’s examples/showcase, puts every form in one tag:

Damask
<input disabled={self.disabled}
       placeholder={self.placeholder}
       class={@tokens(self.extra, "base", "invalid": self.invalid)}
       class:compact={self.compact}
       class:base={!self.invalid}/>

base is in the helper and also under a directive, and the directive wins — an invalid control loses base however the list was assembled.

Caution

A directive puts the class name in the attribute name, where Tailwind and other CSS scanners do not look — the rule gets compiled out of your stylesheet. When a class must be discoverable by a scanner, put it in the helper, where names are ordinary strings: class={@tokens("animate-pulse": self.busy)}.

The problem: a row full of data-

Rows are going to be clickable, which means handing the client-side controller everything it needs to know about the service. Written out, that is four attributes agreeing with each other by hand:

Damask
<tr data-controller="service-row"
    data-service={svc.name.clone()}
    data-status={svc.status.slug()}
    data-slow={svc.is_slow()}>

It works, and it does not scale. The names repeat their prefix, the set has to be retyped on every element that wants it, and a group that should travel together — “everything the controller needs” — is four separate facts the template holds apart. If the controller grows a fifth, every row template has to hear about it.

The fix: @attrs

{@attrs(…)} is the other helper, and it goes the other way. Where @tokens assembles one value from parts, this expands one set into a run of attributes — the thing a Rails view does with data: { … }. It writes the same four:

Damask
<tr data={@attrs(controller: "service-row",
                 service: svc.name.clone(),
                 status: svc.status.slug(),
                 slow: svc.is_slow())}>

Each key becomes data-<key>, written verbatim — user_id is data-user_id, not data-user-id. Values answer the same question attr={…} does, one level down: slow is a bool, so a fast service gets no data-slow at all, exactly as the longhand did.

The point, though, is that a value is now the unit. Give the group a name and the template stops holding it apart:

Rust
impl Service {
    /// Everything the row controller needs, as one group.
    pub fn hooks(&self) -> Vec<(&'static str, String)> {
        vec![
            ("controller", "service-row".into()),
            ("service", self.name.clone()),
            ("status", self.status.slug().into()),
        ]
    }
}
Damask
<tr data={@attrs(svc.hooks())}>

Anything implementing AttrSet fits there — a Vec of pairs, a HashMap or BTreeMap, an Option of any of them for a group that is sometimes absent, or a type of your own. Entries merge, with a later mention of a key overriding an earlier one:

Damask
<tr data={@attrs(svc.hooks(), slow: svc.is_slow())}>

That is the shape to reach for when a component takes a data map from its caller and adds its own: the caller’s group first, yours after, and yours wins.

Note

Nothing here is data’s. The prefix is whatever attribute the helper is written on, so aria={@attrs(label: self.title)} writes aria-label, and an attribute with no helper on it — data="movie.swf", data={self.url} — is the ordinary attribute it looks like.

Attributes you cannot name

{...expr} splices a prepared run of attributes — the ones whose names the template cannot write at all, like a computed data-<controller>-target:

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

AttrSpread is implemented for two things, and the difference is a security boundary. &'static str is markup the author wrote, emitted verbatim; the lifetime is what keeps a request-derived value out, since a string built from a form field cannot be 'static. [(K, V)] and Vec<(K, V)> are a map, escaped on the way out, and that is where anything derived from state belongs.

A name that could break out of its own attribute — one holding a space, or an = — is dropped rather than escaped, because escaping a name does not make it safe. The same check guards every data key.

A tag that writes an attribute itself and also spreads a set holding it writes it once, from the tag — a duplicate attribute is not valid HTML, and the browser would settle it by a rule you were not thinking about.

helm needs none of this, and most components never will. Attributes, Token lists and Attribute groups have the exhaustive rules. Spreading onto a component is a different thing and belongs to the next chapter — see Attributes the component never heard of.

Back to the badge

The badge from chapter two can now carry its status in the markup instead of in its text:

Damask
<!-- src/status_badge.dmk -->
<span class={@tokens("badge", self.status.slug())} data-status={self.status.slug()}>{self.status}</span>

Running it

Point main at the table:

Rust
use crate::model::{Service, Status};
use crate::service_table::ServiceTable;

fn main() {
    let services = vec![
        Service {
            name: "edge-router".into(),
            owner: "platform".into(),
            status: Status::Healthy,
            uptime_pct: 99.995,
            latency_ms: 42,
            version: "v2.14.0".into(),
        },
        Service {
            name: "checkout-api".into(),
            owner: "payments".into(),
            status: Status::Degraded,
            uptime_pct: 99.812,
            latency_ms: 380,
            version: "v5.1.2".into(),
        },
    ];

    let table = ServiceTable {
        services: &services,
        slo_target: 99.9,
    };
    println!("{}", table.render());
}
Shell
$ cargo run

    <tr>
      <td><div class="svc">edge-router</div><div class="owner">platform</div></td>
      <td>Healthy</td>
      <td>99.995%</td>
      <td>42 ms</td>
      <td class="ver">v2.14.0</td>
    </tr>
    <tr class="alt breach">
      <td><div class="svc">checkout-api</div><div class="owner">payments</div></td>
      <td>Degraded</td>
      <td>99.812%</td>
      <td data-slow>380 ms</td>
      <td class="ver">v5.1.2</td>
    </tr>

Three attributes are missing from that output, and all three are missing on purpose: no class on the first row, no data-slow on the fast one. Nothing in the template had to say so.

The status cell is still bare text, though — and there is a StatusBadge sitting in the crate that renders exactly that cell. Putting it there is the next chapter.