Relationships
Implemented by 14
Decisions 4
Related 56
Branches 12
History 1
1f4aee2Created (accepted)
Summary
A rune defined not by declaring its own output but by placing its authored content into slots of existing primitive runes, with a Markdoc template. It has no BEM block and ships no CSS: its appearance is whatever the installed theme already gives the primitives it is built from. It does carry its own identity — a data-rune, a schema.org row, and optionally a SPEC-144 registration — so the tree it produces means the domain type, not the primitives.
This is the second of two authoring tiers. SPEC-143 covers a genuinely new atom that needs its own block and styles; this covers a domain type that is structurally a familiar shape, which is most of them.
Background — why this is the pressure valve
A hosted refrakt cannot ship a plugin per domain, so users define runes. The temptation at every expressive gap is a richer language in the definition file; ADR-036 rules that out and names composition as the alternative. This spec is that alternative.
These are not two authoring tiers a user chooses between. They share their whole declaration half — a composed rune's frontmatter declares attributes, content, schema, metaFields, blocks and registers, which is SPEC-143's and SPEC-144's vocabulary in full. They differ only in what emits, and ADR-037 assigns the two emit paths to different audiences:
| Declared emit path (SPEC-143) | Composed emit path (this spec) | |
|---|---|---|
| Audience | Internal — first-party runes shedding transforms | Users — the one way to author a rune outside this repo |
| Output | Own block, own BEM classes | The primitives' blocks |
| CSS | Ships with the theme | None — inherits whatever the theme gives the primitives |
| Arrangement decided by | the theme, via layout | the rune author, in the template |
| Contract | Derived from config, as today | Derived by expanding the composition |
The arrangement row is why the split lands this way. A declared rune hands arrangement to the theme, which is full ADR-028 portability and exactly what work, character and the rest need. A user running one site with one theme cannot cash that benefit, and pays for it in the CSS column: "a declaratively-defined rune renders unstyled" is the sharpest authoring barrier SPEC-143 identifies, and composition dissolves rather than solves it — there is no new block to style. Hence users get this path, and only this path.
The mechanism mostly exists
Two existing mechanisms supply most of it, and it matters which one does what.
AST substitution is a solved shape. {% include %} (packages/runes/src/tags/include.ts, SPEC-129) clones a file's parsed AST and substitutes variables bindings into it. That is the substitution technique a template needs — cloning a parsed tree and filling named holes — and it is already written.
But the timing is a rune's transform, not preprocess. include splices during preprocess so that data and snippet inside the pasted file get preprocessed; a composition cannot use that timing, because at preprocess the content model has not resolved and no slot has a value (D4). A template renders where transform would have, and calls Markdoc.transform on its result so the primitives inside it are transformed normally — exactly as character.ts already does with resolved.items. No new pipeline stage either way.
Three things are missing.
1. Content slots, not just scalar variables. variables={q: "rune:card"} binds strings. Composition needs to place authored subtrees into named positions — the body a user wrote under {% character %} has to land inside the template's {% card %}. That is substitution over node lists rather than over strings.
2. An outer identity. After the splice the tree is the primitives': it carries data-rune="card", and the cross-page pipeline, extractTitle, breadcrumbs and the editor all see a card. The composition needs a wrapper carrying the composed rune's own data-rune, with the inner runes demoted to implementation detail.
3. Cycle detection. A composes B composes A. Cheap to detect, easy to omit.
SEO — the retyping works, the name resolution does not
An earlier draft of this section claimed SEO was "the solved part" of composition. That was wrong in a way worth recording rather than quietly fixing, because the half-truth is seductive: the retyping mechanism does exist and does fit, but name resolution does not cross a composition boundary, and without that the retyping has nothing to retype. D9, D10 and D11 are what the corrected reading requires.
What does fit — retyping
applySchemaTable (packages/runes/src/lib/index.ts:463-500) retypes children from the parent's row, and says why in its own comments:
Children are retyped by the parent, never by themselves (D9). Markdoc transforms bottom-up, so by now the children carry their own
typeofand this rewrites them — and only the parent can supply thepropertythat nests them.
A parent's row.children is keyed by child name, and a SCHEMA_TYPE_EXPLICIT marker distinguishes a type the author stated — which survives — from one that came from the child's fallback row, which the parent may overrule, because "the parent is the better authority on what an unlabelled child is".
That is exactly composition's requirement. A composed character declares Person and child rows keyed by slot name; the card and deflist primitives it is built from contribute structure while the outer rune owns the claim. And the explicit marker means composition cannot silently steal a type an author stated by hand.
The identity problem is the same problem one layer up. The fix for SEO is already "the parent is the authority"; item 2 above is that principle applied to data-rune and cross-page registration rather than to typeof. Extending an existing principle is a much better position than inventing one.
What does not fit — reaching the values
A schema row maps names to schema.org properties, and two different resolvers turn a name into nodes. They behave oppositely, and neither is composition-ready:
| Resolver | Used by | Boundary | Matches |
|---|---|---|---|
findAllByName (schema-table.ts:235) | properties via stamp, text via findByName | Stops at any nested data-rune | data-name, data-field (+ kebab) |
findChildren (:528) | children — retyping child runes | Full subtree, no guard | data-name, data-field, and data-rune |
findAllByName's own doc comment describes the bug the guard exists to prevent, and it is precisely the bug composition would reintroduce:
The search stops at another rune's node. ADR-008's flat namespace is unique per rune, so the same name means different things in a parent and in a child it contains:
characternames its title spanname, and so does everycharacter-sectioninside it. Reaching across that boundary published a character whosenamewas the character plus each of its section headings.
So for properties, the problem is not ambiguity — it is unreachability, and it is confined to properties whose source is a node rather than an attribute. Take a table mapping a node, as castMemberSchema does ({ name: 'name', role: 'jobTitle', portrait: 'image' }), and a template that parks the portrait inside {% card %}:
<article data-rune="member" data-rune-fields='{"name":"Veshra"}'> <div data-rune="card"> <!-- boundary --> <img data-name="portrait" data-owner="member" src="v.jpg"> </div> </article>
findAllByName(root, 'portrait') visits the <article> (exempt, it is top), then hits div[data-rune="card"] and returns. Nothing inside the card is ever visited. stamp falls back to the field bag (:309-312), which holds no image because an image is a node and not a scalar. Measured against today's applier, with the same table over both tree shapes:
// composed — portrait slot-placed inside {% card %} { "@type": "Person", "name": "Veshra", "@context": "https://schema.org" } // declared — portrait at the rune's own root { "@type": "Person", "image": "v.jpg", "name": "Veshra", "@context": "https://schema.org" }