Registry›Aggregate

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:

  • collection projects items — entities.
  • relationships projects edges — graph links.
  • aggregate projects 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:

ZoneWhen rendered$item
preambleonce, above the breakdown, only when the query is non-emptytotals projection — { count, value, percent, total }
templateonce per group — the rune's main outputper-group projection — { key, count, value, percent, total, shown }
fallbackonce, in place of the breakdown, only when the query is emptyall 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:

FieldPreambleTemplateFallbackMeaning
count✓✓0entities in this zone's context (preamble: the whole primary set; template: this group)
value✓✓0entities 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✓✓0the all-groups total (distinct entities) — equal to count in the preamble, constant across template iterations
key—✓''the group field value (template only)
shown—✓0post-limit group count (template only)

The two count-shaped fields read differently:

  • count is the in-context denominator — the entire primary set in the preamble, the current group's count in the template. It's the natural max for a progress bar.
  • total is 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>

Done so far604/669

<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. Accepts key (the group value; honors domain ordering when the group field has one), count, value, or percent; 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>
Work by status
StatusCount
Blocked1
In Progress5
Review1
Ready33
Pending14
Draft9
Done604
Cancelled2
<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, or pie.
  • chart-title — rendered as the chart caption.
  • Add a value sub-filter to chart a second series (the achieved count per group) beside the totals.
  • An empty query renders the empty fallback, 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 $item bound to all zeros.
  • Otherwise the empty attribute 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

AttributeTypeRequiredDescription
typestring—Entity type(s), comma-separated.
filterstring—field:value clauses defining the primary set being measured (SPEC-070 grammar).
valuestring—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.
groupstring—Group-by field; omit to render the body once with totals.
sortstring—Sort groups by `key`, `count`, `value`, or `percent` (prefix `-` for descending). Honors SPEC-072 domain-aware ordering when the group field has one.
limitnumber—Cap the number of groups (after sort).
emptystring—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-titlestring—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.

AttributeTypeRequiredDescription
bgstring—Background preset applied to this block
bg-fromstring—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-tostring—Gradient end colour — a semantic token name
bg-viastring—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.

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

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

AttributeTypeRequiredDescription
reveal"none" | "fade" | "slide" | "scale" | "blur"—Scroll-reveal entrance character (none|fade|slide|scale|blur); the theme owns the choreography
staggerboolean—Cascade this block's items in as it reveals (no-op on single-child runes)
spacing

Block-level rhythm override.

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

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

AttributeTypeRequiredDescription
tintstring—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.

AttributeTypeRequiredDescription
width"compact" | "narrow" | "content" | "wide" | "full"—Maximum width constraint for this block
Not availableWhy
dropcap, readingthis rune declares no prose body
framethis rune declares neither a `frameTarget` nor a media section
prominencethis 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.