WORK-622
ID:WORK-622Status:done

A rune can be defined by a composition template

The mechanism at the centre of SPEC-145. A definition is frontmatter (the input declaration: tag, attributes, content model, schema, registers) plus a Markdoc body (the output template). Slot names are the join between them.

This item builds the definition → PluginRune path and the template's render. It covers only the vocabulary the two slices need:

Priority:highComplexity:complexMilestone:v0.40.0Source:SPEC-145

Criteria completion

Criteria completion: 17 of 17 (100%) checked; history from Oct 8 to Oct 80%25%50%75%100%Oct 8Oct 8
Branches 3
History 7
  1. 4bf1f1d
    • ☑ A composed rune has a generated, block-less `RuneConfig`: its root carries no `rf-*` class and no `data-rune-fields`, and its modifiers, universal attributes and meta blocks render (D2a)
    by bjornolofandersson
  2. 91574fd
    Content editedby Claude
    plan: WORK-630 and WORK-622 done; criteria checked, pr set (#685)
  3. e7b6e75
    • ☑ A rune can be defined by a Markdoc template that places named slots into other runes
    • ☑ Authored content reaches a named slot as a node list, not a string
    • ☑ The template renders at the rune's transform, after field resolution, at the call site {% ref "SPEC-143" /%} substitutes at — not at preprocess (D4)
    • ☑ Runes placed by a template are transformed normally, verified by composing from a behaviour-driven primitive and confirming its markup and behavior binding are unchanged (D4)
    • ☑ `emitAttributes`' existing `$heading`, `$field` and `'$a|$b'` forms all work from a composition template, asserted per form (D4a)
    • ☑ `{% slot name="x" %}…{% /slot %}` renders its fallback content when `x` is empty
    • ☑ `sections` is placed with an ordinary `{% slot name="sections" each %}`; no special form exists (D26)
    • ☑ The emitted tree carries the composed rune's `data-rune`; the primitives' markers remain but do not claim the rune's identity (D3)
    • ☑ Slot substitution sets the ownership marker {% ref "SPEC-146" /%} defines, on both slot-placed and template-placed nodes (D10)
    • ☑ A slot emits no element; every top-level node it places carries `data-slot` in the rendered HTML, `data-owner` does not, and the composed rune's contract lists its slot names (D10c)
    • ☑ A composed rune whose `registers` source is a placed node registers the same id and data as one whose source is an attribute, read from the field bag in Phase 2; `findRef` and the strip timing are unchanged (D10b)
    • ☑ An author's nested rune inside a slot is never retyped as part of the composed entity (D10)
    • ☑ A composed rune nested inside another cannot reach the inner one's names, and vice versa (D10)
    • ☑ `extractTitle`, breadcrumb resolution and the cross-page registry read the composed rune, not its primitives
    • ☑ A plugin rune entry carrying a template and no `transform` passes `validatePlugin`, and one carrying both is rejected naming the rune ({% ref "SPEC-153" /%} D11)
    • ☑ `refrakt contracts` derives a composed rune's structure by expansion (D6)
    by bjornolofandersson
  4. 5174eb4
    Content editedby Claude
    plan: WORK-623 done; WORK-622 criteria checked, pr set (#683)
  5. 1f4aee2
    Content editedby Claude
    Composed runes: a rune defined by a composition template (WORK-622, WORK
  6. 481b6b4
    Created (ready)by bjornolofandersson
  7. b3823f6
    Content editedby Claude
    plan: draft v0.40.0 — The first composed rune

Blocked by

  • WORK-620
  • WORK-621

Acceptance Criteria

  • A rune can be defined by a Markdoc template that places named slots into other runes
  • Authored content reaches a named slot as a node list, not a string
  • The template renders at the rune's transform, after field resolution, at the call site SPEC-143 substitutes at — not at preprocess (D4)
  • Runes placed by a template are transformed normally, verified by composing from a behaviour-driven primitive and confirming its markup and behavior binding are unchanged (D4)
  • emitAttributes' existing $heading, $field and '$a|$b' forms all work from a composition template, asserted per form (D4a)
  • {% slot name="x" %}…{% /slot %} renders its fallback content when x is empty
  • sections is placed with an ordinary {% slot name="sections" each %}; no special form exists (D26)
  • The emitted tree carries the composed rune's data-rune; the primitives' markers remain but do not claim the rune's identity (D3)
  • A composed rune has a generated, block-less RuneConfig: its root carries no rf-* class and no data-rune-fields, and its modifiers, universal attributes and meta blocks render (D2a)
  • Slot substitution sets the ownership marker SPEC-146 defines, on both slot-placed and template-placed nodes (D10)
  • A slot emits no element; every top-level node it places carries data-slot in the rendered HTML, data-owner does not, and the composed rune's contract lists its slot names (D10c)
  • A composed rune whose registers source is a placed node registers the same id and data as one whose source is an attribute, read from the field bag in Phase 2; findRef and the strip timing are unchanged (D10b)
  • An author's nested rune inside a slot is never retyped as part of the composed entity (D10)
  • A composed rune nested inside another cannot reach the inner one's names, and vice versa (D10)
  • extractTitle, breadcrumb resolution and the cross-page registry read the composed rune, not its primitives
  • A plugin rune entry carrying a template and no transform passes validatePlugin, and one carrying both is rejected naming the rune (SPEC-153 D11)
  • refrakt contracts derives a composed rune's structure by expansion (D6)

Resolution

Completed: 2026-10-08

Branch: claude/v040-composition-templates (#683); claude/v040-metablock (#685) PR: refrakt-md/refrakt#683, refrakt-md/refrakt#685

What was done

  • #683 built the mechanism. Its PR description has the full record:
    • packages/runes/src/lib/composition.ts: the template vocabulary (slot with fallback and each, $each, $attrs, Markdoc's if), rendered at the rune's transform after field resolution; data-owner / data-slot markers; D10b's copy of node-sourced registers values into the field bag; the generated block-less config.
    • packages/runes/src/composed-rune.ts: defineComposedRune and composedPluginRune.
    • PluginRune.template, with validatePlugin accepting exactly one emit path.
    • Contracts list a composed rune's slot names and its expansion.
  • #685 (WORK-630) closed the remaining criterion: meta blocks render.
    • A template places a declared block with {% metablock %}, which the engine fills through the same block renderer layout uses.
    • Attributes a metaFields entry reads are modifiers of the generated config.
    • composition-metablock.test.ts asserts the whole D2a criterion together: the root has no rf-* class and no data-rune-fields, and its modifiers, universal attributes and meta block render.

Findings (from #683; recorded there in full)

  • Template-placed nodes get data-owner only, not data-slot, and only at the template's top level. Fallback content is the template's own.
  • {% slot name="x" each %} is not valid Markdoc; each is normalised to each=true inside slot tags.
  • D12's parent check compares data-rune, not the tag name.
  • extractTitle exists only as the plan plugin's private helper. The criterion is asserted on the shared consumers: outer data-rune, the registers participant, breadcrumb auto, and JSON-LD.
  • The editor's community-tags bundle reads entry.transform only. The editor half is deferred.
  • D10a amendment: a node carrying both markers has two namespaces.