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; tracking started on Oct 8, no incremental history yet0%25%50%75%100%Oct 8Oct 11

Tracking started Oct 8 — check back for trends.

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.