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:
| Rune | Cardinality | Output |
|---|
{% ref %} | one | a link |
{% expand %} | one | inlined 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 #}
/%}
| Attribute | Type | Default | Meaning |
|---|
type | string | required | Entity type to list. Comma-separated for multiple types ("spec,decision"). |
filter | string | — | Space-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. |
sort | string | — | Entity data field to sort by. Unset preserves registration order. |
group | string | — | Group into sections by a data field. |
limit | number | — | Cap rendered count, applied post-sort, pre-group (same semantics as backlog's limit). |
item-template | string | — | Path/name of a markdoc partial used as the per-item template (the reusable alternative to an inline body). Mutually exclusive with an inline body. |
layout | table | cards | list | grid | list | Built-in presentation for the generic-data path. Ignored when a body template (inline or item-template) is present. |
fields | string | — | Comma-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 (sourceUrl → canonicalUrl → 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.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 format → parse) 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)
| Layout | Renders | Field use |
|---|
list | compact title (+ optional one-line description), each a link | title only |
cards | a card per entity, generic chrome | optional projected fields |
grid | card grid | optional projected fields |
table | one row per entity; columns from fields (shorthand) or heading-delimited column templates | see 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 %}