SPEC-070
Setting up your dashboard 0 entities found · 9/34 branches scanned
ID:SPEC-070Status:draft

Collection rune

A generic core rune that renders a list, table, or grid of registry entities — the plural counterpart to {% ref %} (one entity → a link) and {% expand %} (one entity → inlined content). Where those consume a single entity, collection queries the registry for many and projects them into a chosen layout, with filter / sort / group / limit and declarative field selection.

Generic over entity type: works for plan specs, storytelling characters, places events, design tokens, commerce products, externally-registered CMS / database rows — anything in the EntityRegistry. Replaces hand-maintained lists that mirror structured data with a live query, the same way {% backlog %} does for plan content — but for every entity type, core and plugin alike.

Problem

The registry is refrakt's substrate for structured, addressable content. Three families of rune consume it for a single entity — but nothing consumes it for many:

RuneCardinalityOutput
{% ref %}onea link
{% expand %}oneinlined content
many(missing)

The only plural lister today is {% backlog %} (in @refrakt-md/plan), and it's hardcoded to plan entity types (work/bug/spec/decision/milestone) with plan-specific card chrome. Every other domain that registers entities has no listing surface:

  • Storytelling can register characters, realms, factions — but can't render "all characters in realm X".
  • Places registers events and venues — but can't render "events this month".
  • Design could register tokens — but can't render a token reference table.
  • External-data plugins (SPEC-069: Notion, Airtable, SQL) register rows — but for tabular sources, the listing is the primary view. Without a generic lister, an Airtable integration can only produce a route per row, never the inline table/grid that's the whole point of a spreadsheet source.

Today the options are: hand-maintain a markdown list that drifts from the data; write a bespoke listing rune per plugin (as plan did with backlog); or give every row its own page just so {% blog folder %} can list them. All three are worse than a generic query.

{% collection %} is the missing third member of the registry-consumer family: many entities, projected into a layout, from a live query.

Design Principles

Plural counterpart to ref / expand. Same registry substrate, same lookup vocabulary. An author who knows {% ref %} and {% expand %} understands {% collection %} as "the same thing, for a list". The three compose: a collection of cards each linking via the entity's resolved URL; a collection inside a {% drawer %}; a collection filtered by the same field:value syntax backlog and xref patterns already use.

Query engine, not a renderer. collection's real value is the query (which entities, filtered/sorted/grouped/limited). Per-item rendering is a separate concern with exactly two inputs: a built-in layout (generic field projection — right for a price table, wrong for a storefront gallery) or a body template (markdoc with $item bound, which can compose anything — including invoking a purpose-built card rune). collection never hard-codes domain card design; that lives in the template, or in a card rune the template invokes.

Card runes are plain presentational runes — $item is just a bound variable. Deliberate cards (product-card, article-card) that need loops, computed values, interactivity, or schema.org structured data are runes with ordinary attributes — they know nothing about $item, the registry, or collection. The body template wires entity fields into a card's attributes: {% product-card title=$item.data.title price=$item.data.price href=$item.url /%}. So $item is a bound variable (like $page / $file), not a card ABI; collection's entire render job is "bind $item, transform the template", and a card rune stays a self-contained component usable standalone with hand-authored data ({% product-card title="Widget" price="$20" /%}). The verbose field-mapping lives once in an item-template partial (see Display control), so decoupling doesn't cost verbosity in practice. There is no item= attribute and no card contract for the rune to implement.

Zero-config baseline always works. {% collection type="character" /%} with no other attributes renders each entity's title as a link to its resolved URL. No knowledge of the entity's fields required. Everything past that (built-in layouts, field projection, body template) is opt-in sophistication.

Listers are query-engine + item-card; the existing ones become presets. Once collection exists, {% backlog %} and {% blog %} are revealed as special cases — query + a body template invoking a domain card (work-card / article-card). They stay as convenience wrappers (back-compat + nice defaults) but the powerful, composable form is collection with a template. backlog reduces almost fully (its aggregations stay bespoke); blog reduces cleanly once "folder" is expressed as a url prefix filter rather than a special axis. The refactor is decoupled from collection's launch (see Sequencing).

Build-time, registry-driven, no manual maintenance. Like backlog, the list is resolved from the registry during the cross-page pipeline. Add an entity anywhere — a new plan file, a new CMS row, a new character — and every collection that matches picks it up on the next build. No list to maintain.

Authoring Surface

Attributes

{% collection
   type="product"              {# entity type(s) to list — required #}
   filter="category:tools"     {# field:value pairs; AND across fields, OR within a field #}
   sort="price"                {# sort by an entity data field #}
   group="category"            {# group into sections by a field #}
   limit=20                    {# cap the rendered count (post-sort, pre-group) #}
   layout="table"              {# table | cards | list | grid #}
   fields="name,price,stock"   {# which data fields to project, in order #}
/%}
AttributeTypeDefaultMeaning
typestringrequiredEntity type to list. Comma-separated for multiple types ("spec,decision").
filterstringSpace-separated field:value clauses. Supports exact, glob, and regex matching — so "folder membership" is just a url prefix match, not a special axis (see Field-match grammar). Same-field clauses OR; different fields AND.
sortstringEntity data field to sort by. Unset preserves registration order.
groupstringGroup into sections by a data field.
limitnumberCap rendered count, applied post-sort, pre-group (same semantics as backlog's limit).
item-templatestringPath/name of a markdoc partial used as the per-item template (the reusable alternative to an inline body). Mutually exclusive with an inline body.
layouttable | cards | list | gridlistBuilt-in presentation for the generic-data path. Ignored when a body template (inline or item-template) is present.
fieldsstringComma-separated data field names to project into the built-in layout. Required for table; optional enrichment for cards/grid; ignored by list and when a body template is present.

A per-item rune (product-card etc.) is not its own attribute — invoke it inside the body template: {% collection type="product" %}{% product-card /%}{% /collection %}. See Display control.

Display control — generic data vs. domain presentation

collection's real value is the query (which entities, filtered/sorted/grouped/limited). Rendering spans a spectrum from zero-config to fully domain-specific, and the right level depends on whether you're displaying generic data or a deliberate domain gallery:

1. Zero-config — directory of links.

{% collection type="character" /%}

Each entity renders as its title (data.title / data.name) linking to its resolved URL (sourceUrlcanonicalUrl → pattern, same chain as xref). Works for any entity type with no knowledge of its fields. The always-works baseline.

2. Built-in layouts + field projection — generic data display.

{% collection type="product" layout="table" fields="name,price,stock" sort="price" /%}

Projects named data fields into a built-in layout (table columns, labeled card rows). This is the path for generic data — price tables, directories, comparison matrices, reference lists — where functional-but-plain is exactly right. fields is the dumb shorthand (raw values, humanized headers); for a table that needs labels, formatting, or combined columns, the body uses heading-delimited column templates (see Built-in layouts). It is not the answer for a rich domain gallery (see the body template); a product catalog rendered as generic projected cards reads as bland data, not a storefront.

3. Body template — custom rendering ($item bound; inline or partial).

The single custom-render path. The rune body is the per-item template — real Markdoc with $item variable references and native {% if %} — rendered once per entity with $item bound to that entity. "An item can be anything", composed inline from the entity's fields:

{% collection type="product" sort="price" %}
## {% $item.data.title %}
![{% $item.data.title %}]({% $item.data.image %})
**{% $item.data.price %}** {% if $item.data.onSale %}{% badge %}Sale{% /badge %}{% /if %}
{% /collection %}

Because the body is just markdoc, it can invoke any rune — including a purpose-built card rune — and that is how you get a deliberate domain card. The template wires entity fields into the card's plain attributes:

{% collection type="product" sort="price" limit=12 %}
{% product-card title=$item.data.title price=$item.data.price image=$item.data.image href=$item.url /%}
{% /collection %}

product-card is an ordinary presentational rune — it takes title / price / etc. as attributes and knows nothing about $item or the registry, so it's equally usable standalone with hand-authored data ({% product-card title="Widget" price="$20" /%}). The template, not the rune, reads $item. And because the body is a full template, you can wrap or augment the card — follow {% product-card … /%} with a conditional {% badge %}, etc.

The explicit field mapping is verbose; the fix is to write it once in a partial and reuse it:

{% collection type="product" item-template="cards:product.md" sort="price" /%}

where cards/product.md contains the {% product-card title=$item.data.title … /%} mapping. Same mechanism — the source is a partial (loaded via the existing partial + file-roots machinery) instead of the inline body — so you get the decoupled pure card rune and a terse per-collection invocation.

So custom rendering is one concept — a per-item markdoc template — with two sources (inline body, or a partial). $item is a bound variable the template consumes; card runes are ordinary runes the template feeds attributes to. The built-in layout (level 2) remains the zero-template path for generic data.

The query engine / renderer split is the core of the design: collection owns the query; per-item markup comes from a built-in layout (generic) or a body template (custom), and the template composes whatever — plain markdoc, conditionals, and card-rune invocations. It's the same split that lets {% backlog %} and {% blog %} become presets (see Relationship to existing runes).

Per-item template mechanism

The body-template form hinges on one fact about the pipeline and one pre-transform capture step.

Variables resolve at transform time, not parse time. Markdoc.parse("{% $item.title %}") produces an unresolved Variable AST node; it becomes a value only when Markdoc.transform(ast, config) looks it up in config.variables. The body template exploits this: parse the template once, transform it once per entity with $item bound.

The capture must happen before the page transform — not in the schema (confirmed by prototype; see the resolved open question). The intuitive approach — "the schema receives its body as raw AST and stashes it via Markdoc.format(resolved.body)" — does not work: by the time a rune's transform runs, Markdoc has already walked the body and resolved its inline $item interpolations to undefined (because $item isn't bound during the page transform). The source is gone at that point — Markdoc.format on the body throws (undefined.replace), and re-transforming the already-walked AST per entity yields null for every field. So capture must operate on pristine, pre-resolution nodes:

loader (pre-transform):   walk parsed AST → for each deferBody rune,
                          Markdoc.format(its children) → source string,
                          stash on an attribute, then EMPTY the body
                          (so the page transform never resolves $item)
schema transform:         read the stashed source emit it in the sentinel
postProcess (per entity): Markdoc.parse(stashed) → transform(ast, { …embedConfig, variables: { item: entity } })

Markdoc.format round-trips pristine AST back to source ({% $item.title %} stays {% $item.title %}, not resolved), so a plain string crosses the serialization boundary and postProcess re-parses + transforms it per entity. The reparse is mandatory, not an optimization — reusing the captured AST nodes (skipping formatparse) resolves every variable to null; only a fresh parse-per-entity binds $item correctly. Parse-once-cache + transform-per-item handles efficiency. A card rune invoked inside the template transforms normally as part of that per-entity pass, reading $item from the same bound variables.

Two small core additions this requires. (1) A deferBody flag on the rune's catalog entry, so the loader knows which runes' bodies to capture-and-clear before the page transform (there's no preprocess plugin hook today). (2) The pre-transform capture pass itself in the content loader. Neither is large, but both are more than "do it inside the schema" — they are the real cost of the inline form, and it's in scope. The item-template partial form needs none of this: a partial is loaded from a file as source, never enters the page transform, so there's nothing to capture — parse it, transform per entity. Inline and partial converge at postProcess (both become "a source string, transformed per entity with $item bound"); they differ only in where the source comes from. So if the inline path ever proves too invasive, partial-template + card runes still delivers custom rendering with no novel mechanism — the fallback is intact, not the plan.

The constraint — no loops in templates. Markdoc has conditionals ({% if %}) but no native loop, by design (it's a content language; iteration is a developer concern expressed as a tag). collection iterating entities is fine — that's collection's resolver code, not template syntax. But iterating an array field within one item — each variant of a product, each tag as a separate element — can't be expressed in a template. That case is exactly what a card rune is for: its transform iterates freely. So the line is principled, not a limitation: flat composition → template; per-item iteration/logic/interactivity/structured-data → a card rune (invoked in the template).

Built-in layouts (the level-2 path)

LayoutRendersField use
listcompact title (+ optional one-line description), each a linktitle only
cardsa card per entity, generic chromeoptional projected fields
gridcard gridoptional projected fields
tableone row per entity; columns from fields (shorthand) or heading-delimited column templatessee below

These are the generic presentations. For deliberate domain cards, use a body template that invokes a card rune (level 3) — the built-in cards/grid are intentionally plain so they don't masquerade as a designed gallery. An item is never rendered via full {% expand %} by default (too heavy for a list, many entities aren't embeddable).

The body means different things per layout. For box layouts (list / cards / grid) the body is the per-item template (level 3). For table the body is a set of column definitions. In both, an empty body falls back to fields — the dumb shorthand. So fields is the zero-body shortcut and a body buys control, in either family. (Consequence: a body authored for cards isn't portable to table by flipping the attribute — the two families interpret it differently. That's inherent to tables aligning columns rather than arranging boxes.)

fields — the dumb shorthand. fields="name,price,stock" projects those data fields as columns (table) or labeled rows (cards/grid). Headers are the humanized field key (unit_price → "Unit Price"); values use default per-type stringification (string/number as-is, ISO date as-is, boolean → Yes/No, array → comma-join, missing → empty). No formatting, no combining — the moment you need either, use heading-delimited columns. (There is deliberately no key=Label micro-syntax on fields: custom labels are a reason to use the heading form, keeping fields dead-simple.)

Heading-delimited columns — the rich table path. A table collection's body uses the sections content model (sectionHeading: 'heading', as changelog does): each heading is a column separator + header label, and the markdoc under it is that column's per-cell template with $item bound. collection owns the <table> / <thead> and row alignment; the heading sequence defines the columns and their order.

{% collection type="product" layout="table" sort="price" %}
## Product
[{% $item.data.title %}]({% $item.url %})

## Price
{% currency($item.data.price, $item.data.currency) %}

## Stock
{% if $item.data.stock %}{% $item.data.stock %} in stock{% else %}Out{% /if %}
{% /collection %}