Skip to content

Quill docs

CSS compilation (style! macro)

Quill compiles CSS from Rust. You describe a set of rules either with the style!{} macro or the StyleBlock/Rule builder, and you get back a StyleBlock value that you render to a CSS string with to_css() (compact, for inlining into <head>) or to_css_pretty() (multi-line, for dev/snapshots). The output is deterministic and minimally escaped so an interpolated value can never break out of its { ... } block or out of the surrounding <style> element. This is the thin-slice CSS layer in the qquill-style crate: zero third-party dependencies, std only. This page covers the data model, both authoring paths, the exact ordering/dedup rules, the escaping policy, and the rendered output shape.

The data model: StyleBlock and Rule#

A StyleBlock is an ordered list of Rules. A Rule is one selector plus a set of declarations. The two types give you two complementary ordering guarantees that make output deterministic regardless of how you authored it.

Rule selector order within a block
Rules render in insertion order, because selector order is author-meaningful in CSS (the cascade depends on it).
Declaration order within a rule
Declarations are stored in a BTreeMap<String, String>, so they emit in alphabetical property order regardless of insertion order, and a repeated property within the same rule is deduplicated last-write-wins.
Empty rules
A rule with no declarations renders to the empty string — it never emits selector{}.

Authoring with the builder API#

StyleBlock::new() starts an empty block; .rule(rule) appends a rule. Rule::new(selector) starts a rule (the selector is escaped immediately); .decl(property, value) adds or overwrites a declaration (both property and value are escaped). Both are builder-style and return self. Finally to_css() or the free function compile(&block) produces the compact string.

rust
use qquill_style::{StyleBlock, Rule};

let block = StyleBlock::new().rule(
    Rule::new(".card")
        .decl("padding", "12px")
        .decl("color", "#111"),
);

// Declarations are sorted alphabetically: color before padding.
assert_eq!(block.to_css(), ".card{color:#111;padding:12px}");

Authoring with the style! macro#

style!{} is a macro_rules macro (no syn/quote) that produces the same StyleBlock as the builder, so the two compile to identical CSS. Each rule is "<selector>" { "<prop>": "<value>"; ... }. Selectors, property names, and values are all string literals — this is what lets hyphenated property names like "background-color" work (a macro_rules ident could not capture the hyphen).

Request

use qquill_style::style;

let block = style! {
    ".card" {
        "padding": "12px";
        "color": "#111";
    }
    "a:hover" {
        "color": "blue";
    }
};

let css = block.to_css();

Response

.card{color:#111;padding:12px}a:hover{color:blue}

Output

Within each rule, declarations are alphabetized (color before padding); between rules, insertion order is preserved (.card before a:hover).

Compact vs pretty output#

to_css() produces a single line with no superfluous whitespace, suitable for inlining into the SSR <head> in production. to_css_pretty() produces multi-line, readable output for development and snapshot tests; it skips empty rules without leaving stray blank lines.

Request

use qquill_style::{StyleBlock, Rule};

let block = StyleBlock::new().rule(
    Rule::new(".card")
        .decl("padding", "12px")
        .decl("color", "#111"),
);

let pretty = block.to_css_pretty();

Response

.card {
  color: #111;
  padding: 12px;
}

Output

Two spaces of indent per declaration, alphabetical order, a trailing newline after the closing brace.

Determinism and dedup#

Because declarations live in a BTreeMap, two blocks built with the same declarations in different insertion orders compile to the same CSS, and a repeated property keeps only the last value.

AuthoredCompiled CSS
Rule .x with z-index:1; color:red; margin:0 (any order).x{color:red;margin:0;z-index:1}
Rule .y with color:red then color:blue.y{color:blue} (last write wins)
Block with .b then .a (each one decl).b{color:blue}.a{color:red} (selector order kept)
Rule .empty with no declarations (empty string — no selector{})
:root with --q-brand:#0af:root{--q-brand:#0af} (custom property preserved)

Escaping: how injection is neutralized#

The threat model is values and selectors interpolated from Rust strings (and later, dynamic data). The compiled CSS is inlined as raw text inside a <style> element, so a value must never close its declaration block, open a CSS comment, inject a new rule, or break out into HTML. Rather than faithfully CSS-escape, this thin slice STRIPS the dangerous characters — a stripped value is always safe to inline and the output stays deterministic. Three different filters apply depending on context.

Value escaping (escape_value)
First removes any /* ... */ comment spans (and dangling /* or */ markers), then strips { } ; \ < and ASCII control chars, then trims. > is KEPT (harmless in a value). < is stripped specifically so a value can never carry a </style> HTML breakout.
Property-name escaping (escape_property)
Keeps only [A-Za-z0-9-_]; everything else is dropped. So a property can never inject a value or a new declaration. (This also preserves custom-property names like --q-brand.)
Selector escaping (escape_selector)
Strips comment markers and { } ; \ < but KEEPS useful CSS punctuation including the combinators > + ~ * and . # : [] , (). So a selector can never open or close a rule body or start an HTML breakout.

Request

use qquill_style::{StyleBlock, Rule};

let block = StyleBlock::new().rule(
    Rule::new(".q")
        .decl("color", "red} body{display:none} /* x */"),
);

let css = block.to_css();

Response

.q{color:red bodydisplay:none}

Output

Exactly one brace pair survives (the rule's own). The injected braces and semicolons are stripped, the /* x */ comment is removed entirely, and the inert text remains as a defanged value.

What is NOT in this slice#

The thin slice compiles one co-located style block to one CSS string. The following are explicitly later-phase concerns and are not implemented here:

  • Atomic content-hashed class dedup and route-graph tree-shaking.
  • Typed token interpolation such as token(color.brand.primary) -> var(--q-...). (Tokens live in the separate qquill-theme crate; see Design tokens & theming.)
  • @property, @layer, multi-state variants, and container queries.
  • A real lexer/parser/AST for .qss source files.