Aggregate
{% aggregate %} queries the entity registry and projects numbers — counts (and, in time, sums / averages) over the same field:value grammar collection and relationships use. It's the third sibling in that family:
collectionprojects items — entities.relationshipsprojects edges — graph links.aggregateprojects numbers — counts and per-group breakdowns.
Two modes from one rune. With no body it's a single inline integer ({% aggregate type="work" filter="status:done" /%} renders one number in prose). With a body, it iterates groups and binds a per-group projection to $item — same body-zone convention as collection, with the iteration moved from entities to groups.
Because aggregate resolves in post-process — like collection and relationships — it sees the full cross-page registry. It works on file-backed pages and dynamic entity routes alike, and on every page in the site, not only the route a particular plugin generates.
Single-number form
The self-closing form emits one integer: the count of entities matching the query.
Of {% aggregate type="work" /%} work items, {% aggregate type="work" filter="status:done" /%} are done.<p>
Of
<span data-rune="aggregate">
<meta data-field="aggregate-type" content="work">
<meta data-field="aggregate-filter" content="">
<meta data-field="aggregate-value" content="">
<meta data-field="aggregate-group" content="">
<meta data-field="aggregate-sort" content="">
<meta data-field="aggregate-limit" content="">
<meta data-field="aggregate-empty" content="">
<meta data-field="aggregate-layout" content="">
<meta data-field="aggregate-chart-type" content="bar">
<meta data-field="aggregate-chart-title" content="">
<meta data-field="__aggregate-sentinel" content="true">
</span>
work items,
<span data-rune="aggregate">
<meta data-field="aggregate-type" content="work">
<meta data-field="aggregate-filter" content="status:done">
<meta data-field="aggregate-value" content="">
<meta data-field="aggregate-group" content="">
<meta data-field="aggregate-sort" content="">
<meta data-field="aggregate-limit" content="">
<meta data-field="aggregate-empty" content="">
<meta data-field="aggregate-layout" content="">
<meta data-field="aggregate-chart-type" content="bar">
<meta data-field="aggregate-chart-title" content="">
<meta data-field="__aggregate-sentinel" content="true">
</span>
are done.
</p>Of 669 work items, 604 are done.
<p>
Of
<span class="rf-aggregate" data-aggregate="count" data-count="669" data-rune="aggregate" data-density="full">669</span>
work items,
<span class="rf-aggregate" data-aggregate="count" data-count="604" data-rune="aggregate" data-density="full">604</span>
are done.
</p>Same type + filter attributes as collection (and the same field-match grammar). Multiple types compose via comma:
{% aggregate type="work,bug" filter="status:done" /%}
The output is a <span class="rf-aggregate" data-aggregate="count">N</span> — inline-safe in prose.
Body-zoned form
Give aggregate a body and it iterates — but unlike collection, the iteration runs over groups, not entities. The body splits on a top-level --- into up to three zones (same convention as card and collection), and $item binds differently in each:
| Zone | When rendered | $item |
|---|---|---|
| preamble | once, above the breakdown, only when the query is non-empty | totals projection — { count, value, percent, total } |
| template | once per group — the rune's main output | per-group projection — { key, count, value, percent, total, shown } |
| fallback | once, in place of the breakdown, only when the query is empty | all zeros |
A canonical body — totals progress bar above, one badge per status below, fallback when empty:
{% aggregate type="work" value="status:done" group="status" %}
{% progress value=$item.value max=$item.count %}Progress{% /progress %}
---
{% badge data-status=$item.key %}{% $item.count %} of {% $item.total %} {% humanize($item.key) %}{% /badge %}
---
Nothing to report.
{% /aggregate %}
The zone positioning is the same as collection: 1 zone → template, 2 → preamble + template, 3 → preamble + template + fallback. A --- inside a nested rune isn't a delimiter — only top-level rules split.
The $item projection
$item is a read-only bound variable in every zone. The keys depend on the zone:
| Field | Preamble | Template | Fallback | Meaning |
|---|---|---|---|---|
count | ✓ | ✓ | 0 | entities in this zone's context (preamble: the whole primary set; template: this group) |
value | ✓ | ✓ | 0 | entities matching both filter and value (see below); falls back to count when value is unset |
percent | ✓ | ✓ | 0 | (value / count) × 100, integer 0–100; 100 when value is unset |
total | ✓ | ✓ | 0 | the all-groups total (distinct entities) — equal to count in the preamble, constant across template iterations |
key | — | ✓ | '' | the group field value (template only) |
shown | — | ✓ | 0 | post-limit group count (template only) |
The two count-shaped fields read differently:
countis the in-context denominator — the entire primary set in the preamble, the current group's count in the template. It's the naturalmaxfor a progress bar.totalis the all-groups constant — the same number in every template iteration. Useful for share-of-total ratios in a per-group template ("3 of 12"), without a second query.
In the preamble there's no in-context vs global distinction (you're not iterating), so count === total in that zone.
The value sub-filter — driving progress ratios
The value attribute is a secondary field:value clause within the primary set defined by filter — the "achieved" subset that powers a progress-bar ratio without a second query.
{% aggregate type="work" value="status:done" %}
{% progress value=$item.value max=$item.count %}Done so far{% /progress %}
{% /aggregate %}<section data-rune="aggregate">
<meta data-field="aggregate-type" content="work">
<meta data-field="aggregate-filter" content="">
<meta data-field="aggregate-value" content="status:done">
<meta data-field="aggregate-group" content="">
<meta data-field="aggregate-sort" content="">
<meta data-field="aggregate-limit" content="">
<meta data-field="aggregate-empty" content="">
<meta data-field="aggregate-layout" content="">
<meta data-field="aggregate-chart-type" content="bar">
<meta data-field="aggregate-chart-title" content="">
<meta data-field="__aggregate-sentinel" content="true">
<meta data-field="aggregate-body" content="{% progress value=$item.value max=$item.count %}Done so far{% /progress %}
">
</section><section class="rf-aggregate" data-aggregate="breakdown" data-rune="aggregate" data-density="full">
<div data-name="items" class="rf-aggregate__items rf-aggregate__items">
<p>
<div class="rf-progress" role="progressbar" aria-valuemin="0" aria-valuenow="604" aria-valuemax="669" aria-label="Done so far" style="--rf-progress: 90%" data-rune="progress" data-density="full">
<span data-name="label" class="rf-progress__label">Done so far</span>
<span data-name="value" class="rf-progress__value">604/669</span>
<span data-name="track" class="rf-progress__track">
<span data-name="fill" class="rf-progress__fill"></span>
</span>
</div>
</p>
</div>
</section>The primary set here is "every work entity"; the achieved subset is "those whose status is done". The progress bar reads $item.value (achieved) over $item.count (primary), with $item.percent available as the rounded integer 0–100.
Without a value attribute the rune is a pure count + breakdown — no progress semantics — and $item.value falls back to count (so a template using $item.value keeps working; $item.percent reads 100).
Grouping — group
Set group and the template is rendered once per distinct value of that field, with the group projection on $item:
{% aggregate type="work" value="status:done" group="status" %}
---
{% badge data-status=$item.key %}{% $item.count %} {% humanize($item.key) %}{% /badge %}
{% /aggregate %}<section data-rune="aggregate">
<meta data-field="aggregate-type" content="work">
<meta data-field="aggregate-filter" content="">
<meta data-field="aggregate-value" content="status:done">
<meta data-field="aggregate-group" content="status">
<meta data-field="aggregate-sort" content="">
<meta data-field="aggregate-limit" content="">
<meta data-field="aggregate-empty" content="">
<meta data-field="aggregate-layout" content="">
<meta data-field="aggregate-chart-type" content="bar">
<meta data-field="aggregate-chart-title" content="">
<meta data-field="__aggregate-sentinel" content="true">
<meta data-field="aggregate-body" content="---
{% badge data-status=$item.key %}{% $item.count %} {% humanize($item.key) %}{% /badge %}
">
</section>1 Blocked
5 In Progress
1 Review
33 Ready
14 Pending
9 Draft
604 Done
2 Cancelled
<section class="rf-aggregate" data-aggregate="breakdown" data-rune="aggregate" data-density="full">
<div data-name="items" class="rf-aggregate__items rf-aggregate__items">
<div class="rf-aggregate__group" data-group="blocked" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
1
Blocked
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="in-progress" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
5
In Progress
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="review" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
1
Review
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="ready" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
33
Ready
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="pending" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
14
Pending
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="draft" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
9
Draft
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="done" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
604
Done
</span>
</p>
</div>
<div class="rf-aggregate__group" data-group="cancelled" data-block="">
<p>
<span class="rf-badge rf-badge" data-meta-sentiment="neutral" data-meta-type="tag" data-rune="badge" data-density="full">
2
Cancelled
</span>
</p>
</div>
</div>
</section>A multi-value field (tags, source, pr, any comma-separated value) fans out: an entity tagged runes, data is counted in both the runes and the data group, using the same split filter applies (each value trimmed, empty values ignored). Each group's count then means entities carrying this value, so per-group counts can sum to more than total, which still counts each entity once. Counting work items per spec is the typical case:
{% aggregate type="work" value="status:done" group="source" %}
---
{% $item.key %}: {% $item.value %}/{% $item.count %} done
{% /aggregate %}
A work item that lists two specs counts toward both.
Group order follows the same domain-aware ordering collection uses — enum fields like status come out in their declared order, not alphabetically. Across mixed types (type="work,bug"), each entity is ranked within its own type's ordering, so the groups still compose.
Omit group and the body renders once with the totals projection on $item — the ungrouped form is what you want when the body is a single composed element from a totals-only query (e.g. just a progress bar).
Sort and limit — operate on groups
{% aggregate type="work" group="status" sort="-count" limit=3 /%}
sort— orders groups. Acceptskey(the group value; honors domain ordering when the group field has one),count,value, orpercent; prefix-(or suffix-desc) for descending.limit— a positive integer cap on the number of groups, applied after sort.
In the per-group template, $item.shown is the post-limit group count — the same number in every iteration.
Chart layout — layout="chart"
Add layout="chart" and the grouped counts render as a chart instead of a body — aggregate builds the data and hands it to the chart rune's pipeline (an SVG, with a no-JS <table> fallback). One bar (or point) per group, in the same domain-aware order:
{% aggregate type="work" group="status" layout="chart" chart-title="Work by status" /%}<section data-rune="aggregate">
<meta data-field="aggregate-type" content="work">
<meta data-field="aggregate-filter" content="">
<meta data-field="aggregate-value" content="">
<meta data-field="aggregate-group" content="status">
<meta data-field="aggregate-sort" content="">
<meta data-field="aggregate-limit" content="">
<meta data-field="aggregate-empty" content="">
<meta data-field="aggregate-layout" content="chart">
<meta data-field="aggregate-chart-type" content="bar">
<meta data-field="aggregate-chart-title" content="Work by status">
<meta data-field="__aggregate-sentinel" content="true">
</section>| Status | Count |
|---|---|
| Blocked | 1 |
| In Progress | 5 |
| Review | 1 |
| Ready | 33 |
| Pending | 14 |
| Draft | 9 |
| Done | 604 |
| Cancelled | 2 |
<rf-chart data-type="bar" data-stacked="false" data-elevation="sunken" class="rf-chart" data-rune="chart" data-density="compact">
<table data-name="data" class="rf-chart__data">
<caption>Work by status</caption>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr>
<td data-meta-sentiment="negative">Blocked</td>
<td>1</td>
</tr>
<tr>
<td data-meta-sentiment="neutral">In Progress</td>
<td>5</td>
</tr>
<tr>
<td data-meta-sentiment="caution">Review</td>
<td>1</td>
</tr>
<tr>
<td data-meta-sentiment="neutral">Ready</td>
<td>33</td>
</tr>
<tr>
<td data-meta-sentiment="neutral">Pending</td>
<td>14</td>
</tr>
<tr>
<td data-meta-sentiment="neutral">Draft</td>
<td>9</td>
</tr>
<tr>
<td data-meta-sentiment="positive">Done</td>
<td>604</td>
</tr>
<tr>
<td data-meta-sentiment="caution">Cancelled</td>
<td>2</td>
</tr>
</tbody>
</table>
</rf-chart>chart-type—bar(default),line,area, orpie.chart-title— rendered as the chart caption.- Add a
valuesub-filter to chart a second series (the achieved count per group) beside the totals. - An empty query renders the
emptyfallback, never a broken chart.
No body is needed — aggregate supplies the data table. Chart appearance (palette, geometry, sentiment colouring) comes from the chart rune's --rf-chart-* theming contract.
Empty state
When the primary set is empty:
- A fallback zone (third body zone, or the leading-empty form
--- template --- fallback) wins, with$itembound to all zeros. - Otherwise the
emptyattribute is a one-line text fallback for the self-closing form. - With neither, the rune renders nothing.
{% aggregate type="work" filter="status:zzz" empty="Nothing yet." /%}
Precedence matches collection: the body fallback zone wins when both are present.
Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
type | string | — | Entity type(s), comma-separated. |
filter | string | — | field:value clauses defining the primary set being measured (SPEC-070 grammar). |
value | string | — | Optional secondary field:value clause defining the achieved subset within `filter` (e.g. value="status:done"). When set, $item.value is the count matching both; $item.percent is the ratio. Without it, the rune is a pure count + breakdown — no progress-bar semantics. |
group | string | — | Group-by field; omit to render the body once with totals. |
sort | string | — | Sort groups by `key`, `count`, `value`, or `percent` (prefix `-` for descending). Honors SPEC-072 domain-aware ordering when the group field has one. |
limit | number | — | Cap the number of groups (after sort). |
empty | string | — | Fallback text shown when the query matches no entities (self-closing form; body form uses a fallback zone). Absent → render nothing. |
layout | "chart" | — | Render mode. Default: inline integer (no body) or body-zoned template. `chart` draws the grouped counts as an SVG via the `chart` rune's pipeline instead of a body. |
chart-type | "bar" | "line" | "area" | "pie" | — | Chart visualization type when layout="chart" (default bar). |
chart-title | string | — | Title for the chart (rendered as the data table caption) when layout="chart". |
Universal attributes
bg
The background layer (SPEC-088): image, video, gradient, flat overlay wash and legibility scrim, in a single injected layer behind the rune's content.
| Attribute | Type | Required | Description |
|---|---|---|---|
bg | string | — | Background preset applied to this block |
bg-from | string | — | Gradient start colour — a semantic token name (→ var(--rf-color-*)) |
bg-gradient | "to-t" | "to-b" | "to-l" | "to-r" | "to-tr" | "to-br" | "to-bl" | "to-tl" | — | Gradient direction (bounded named set) |
bg-gradient-type | "linear" | "radial" | "conic" | — | Gradient type |
bg-to | string | — | Gradient end colour — a semantic token name |
bg-via | string | — | Optional middle gradient stop — a semantic token name |
scrim | "top" | "bottom" | "left" | "right" | "none" | — | Scrim direction (heaviest edge); presence turns the scrim on, "none" opts out of the default cover scrim |
scrim-blur | "none" | "sm" | "md" | "lg" | — | Frost scrim blur amount |
scrim-strength | "sm" | "md" | "lg" | — | Gradient scrim strength |
scrim-tone | "dark" | "light" | — | Whether the scrim darkens (for light text) or lightens (for dark text) |
scrim-type | "gradient" | "frost" | — | Scrim treatment: gradient (default) or frost (backdrop blur) |
elevation
The chrome/depth ladder (SPEC-107). The skin maps each rung to a chrome bundle by attribute, so there is no BEM class.
| Attribute | Type | Required | Description |
|---|---|---|---|
elevation | "sunken" | "flush" | "flat" | "raised" | "floating" | "overlay" | "none" | "sm" | "md" | "lg" | — | Surface depth on the SPEC-107 ladder (sunken→overlay); none/sm/md/lg are deprecated aliases |
inset
Internal padding override.
| Attribute | Type | Required | Description |
|---|---|---|---|
inset | "flush" | "tight" | "default" | "loose" | "breathe" | — | Inner padding of this block |
motion
Scroll-reveal entrance (SPEC-105). The author declares the character, the theme owns the choreography, a behaviour owns the timing.
| Attribute | Type | Required | Description |
|---|---|---|---|
reveal | "none" | "fade" | "slide" | "scale" | "blur" | — | Scroll-reveal entrance character (none|fade|slide|scale|blur); the theme owns the choreography |
stagger | boolean | — | Cascade this block's items in as it reveals (no-op on single-child runes) |
spacing
Block-level rhythm override.
| Attribute | Type | Required | Description |
|---|---|---|---|
spacing | "flush" | "tight" | "default" | "loose" | "breathe" | — | Vertical spacing above and below this block |
substrate
Generated pattern fills (SPEC-087). Markers only — the engine sets the attributes and cell/opacity custom properties, CSS draws the pattern.
| Attribute | Type | Required | Description |
|---|---|---|---|
substrate | "dots" | "grid" | "lines" | "cross" | "checker" | "none" | — | Generated surface pattern |
substrate-fill | "inherit" | "inset" | — | Surface fill the pattern sits on (full colour stays with tint) |
substrate-opacity | "sm" | "md" | "lg" | — | Pattern ink strength |
substrate-size | "sm" | "md" | "lg" | — | Pattern cell size |
substrate-target | "self" | "media" | — | Which surface the pattern fills (overrides the rune/theme default) |
tint
Per-rune colour override (SPEC-053): a named tint from the theme registry, with inline per-token overrides layered on top.
| Attribute | Type | Required | Description |
|---|---|---|---|
tint | string | — | Color tint preset applied to this block |
tint-mode | "auto" | "dark" | "light" | — | Whether the tint adapts to auto, dark, or light mode |
width
The track a block rune occupies.
| Attribute | Type | Required | Description |
|---|---|---|---|
width | "compact" | "narrow" | "content" | "wide" | "full" | — | Maximum width constraint for this block |
| Not available | Why |
|---|---|
| dropcap, reading | this rune declares no prose body |
| frame | this rune declares neither a `frameTarget` nor a media section |
| prominence | this rune has no page-section header |
Output contract
The body form:
<section class="rf-aggregate" data-rune="aggregate" data-aggregate="breakdown">
<div class="rf-aggregate__preamble" data-name="preamble">…</div>
<div class="rf-aggregate__items" data-name="items">
<div class="rf-aggregate__group" data-group="done" data-block>…</div>
<div class="rf-aggregate__group" data-group="ready" data-block>…</div>
</div>
</section>
The single-number form:
<span class="rf-aggregate" data-rune="aggregate" data-aggregate="count" data-count="42">42</span>
When the query is empty, __items is replaced by <div class="rf-aggregate__empty" data-name="empty">…</div> (rendered from the fallback zone or the empty attribute).
See also
- collection — the sibling that projects items; shares the field-match grammar and zone semantics.
- relationships — the sibling that projects edges; shares the body-zone semantics.
- progress — the typical preamble companion; feed it
value=$item.value max=$item.count. - badge — the typical per-group template companion; the chip per group.
- humanize — turn raw keys (
in-progress) into display labels ("In Progress") inside a template.