SPEC-070
Setting up your dashboard 0 entities found · 9/32 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.

claude/spec-collection-rune View source
Branches 2
claude/spec-collection-rune current draft
claude/v0.16.0 draftmain draft
History 11
  1. e9c8492
    Content editedby bjornolofandersson
  2. 98758e2
    Content editedby bjornolofandersson
  3. b4c584b
    Content editedby bjornolofandersson
  4. 2d8bbb1
    Content editedby bjornolofandersson
  5. bdf42f2
    Content editedby bjornolofandersson
  6. 015ca13
    Content editedby bjornolofandersson
  7. adcce44
    Content editedby bjornolofandersson
  8. 1b71b76
    Content editedby bjornolofandersson
  9. 8b61d75
    Content editedby bjornolofandersson
  10. 97ee3db
    Content editedby bjornolofandersson
  11. 0703ac5
    Created (draft)by bjornolofandersson

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 %}