Rune authoring›Output Contract

Output Contract

Rune schemas produce structured output that the identity transform engine consumes. This page covers the contract between those two layers — what createComponentRenderable expects, how meta tags carry configuration, and how the engine config turns it all into styled HTML.

createComponentRenderable

The function that produces the output Tag for a rune:

import { createComponentRenderable } from '../lib/index.js';

return createComponentRenderable({
  rune: 'hint',
  tag: 'section',
  property: 'contentSection',
  properties: {
    hintType,
  },
  refs: {
    body: children.tag('div'),
  },
  children: [hintType, children.next()],
});

Parameters

createComponentRenderable takes a single object containing the rune's identity and the transform result:

FieldTypeDescription
runestringRune name in kebab-case (e.g. 'hint', 'accordion-item'). Becomes data-rune on the root, which the engine uses to look up the rune config.
tagstringHTML tag name for the root element ('section', 'article', 'div', etc.)
childrenRenderableTreeNodesContent children — the actual output
propertiesRecord<string, Tag | RenderableNodeCursor>Metadata tags consumed by the engine (each gets data-field="kebab-name")
refsRecord<string, Tag | RenderableNodeCursor>Named structural elements (each gets data-name="key")
schemaRecord<string, Tag | RenderableNodeCursor> (optional)Schema.org property mappings — sets RDFa property on referenced tags
propertystring (optional)Semantic role for the root tag (e.g., 'contentSection') — becomes data-field
schemaOrgType / typeofstring (optional)Schema.org type (e.g. 'FAQPage') — only needed for structured-data runes
idstring (optional)HTML id attribute
classstring (optional)CSS class to add

What it does

  1. Sets data-field="kebab-name" on each properties entry — marks tags as metadata carriers
  2. Sets data-name="key" on each refs entry — labels structural elements for BEM
  3. Sets property="key" on each schema entry — RDFa mapping for Schema.org consumers
  4. Creates the root tag with data-rune="kebab-name" — engine lookup key (plus typeof when schemaOrgType is provided)

Properties vs Refs

The rule

One question decides the map, and you can answer it without reading the engine:

Does this node survive into the rendered HTML?

  • Yes, and someone might want to address it → refs. It gets data-name and a BEM element class.
  • No — it exists only to carry a value → properties. The value reaches the field bag; the carrier disappears.

properties is values only. refs is everything that stays.

The mechanism behind it:

AspectPropertiesRefs
Attribute setdata-field="kebab-name"data-name="key"
PurposeCarry a value for modifiersLabel a structural element
Engine readsValue from meta tag contentElement for BEM class
After transformMeta tag removed from outputElement stays, gets BEM class
ExamplehintType meta with content "warning"body div wrapping content

Properties flow: rune emits <meta data-field="hint-type" content="warning"> -> engine reads it -> adds rf-hint--warning class + data-hint-type="warning" attribute on the root -> removes the meta tag.

Refs flow: rune emits <div data-name="body"> -> engine reads it -> adds rf-hint__body class -> element stays in output.

What the catalog emits today

The row above that says "meta tag removed from output" describes only one of the things data-field currently does. Most data-field attributes in the catalog are on elements that survive — <li data-field="item">, <span data-field="name">, <time data-field="date">, <a data-field="url">. Render the catalog and count for yourself:

npx refrakt inspect <rune> --site main

Only <meta> carriers reach the field bag. The discriminator in createComponentRenderable is literally n.name === 'meta', not which map you chose — so a non-meta placed in properties behaves like a ref that forgot its BEM class. Nothing warns you; the rune just renders less styleable HTML.

Write new runes against the rule, not against the catalog. Existing runes are being migrated; until then, expect to see data-field on elements that stay.

The one documented exception: postprocess sentinels

A meta tag kept deliberately so a phase-4 postProcess() hook can find it later is not a consumed modifier — it must survive the engine, not be stripped by it. The collection, aggregate, blog, backlog, relationships and plan-* runes all do this.

This is a genuinely different job from both maps above. It stays in properties today and is called out here rather than left implicit: if you are writing a sentinel, you are taking the exception knowingly, not discovering it by accident.

Schema-relevant nodes

Three idioms recur once a rune also publishes structured data. They follow from the rule rather than competing with it:

The node is…MapWhy
A value-only source — a computed string with no visible carrierpropertiesNothing survives, so there is nothing to address.
A node the schema stamps in place — an element that gets typeof/property added to itrefsIt survives, so it needs a name. The schema annotates the node the ref already gives you.
Both — visible and value-bearing, like <time data-field="date">refs, with a named entryIt survives, so the rule puts it in refs; give it a name so the schema mapping can reach it by that name rather than by attribute.

The third case is the common one and the one worth getting right. Name it in refs, and its value reaches the schema by the mapping stamping the node in place — not by emitting a duplicate meta beside it.

Do not style off the schema channel. CSS selects the BEM element class (.rf-lore__title), never [property="headline"]. A rune's appearance must not depend on its structured-data output, or correcting the schema becomes a visual regression. This is enforced by a test.

Component override mapping

When a rune has a registered component override, the renderer automatically maps properties and refs to a framework-native interface:

Output contractComponent receivesExample
Properties (meta tags with data-field)Scalar string propsprepTime="15 min"
Refs (elements with data-name)Named snippets/slots{@render ingredients?.()}
Everything elseDefault children slot{@render children?.()}

This means the properties and refs you define in createComponentRenderable directly determine the component author's API. Use refrakt inspect <rune> --interface to see the resulting interface.

Meta tags

Meta tags are the bridge between rune schemas and the engine. They carry configuration values without producing visible output.

// In the rune's transform():
const hintType = new Tag('meta', { content: attrs.type ?? 'note' });

return createComponentRenderable({
  rune: 'hint',
  tag: 'section',
  properties: {
    hintType,    // gets data-field="hint-type" added
  },
  children: [hintType, children.next()],
  //         ^^^^^^^^ must be in children array too
});

The engine:

  1. Finds <meta data-field="hint-type" content="warning">
  2. Reads the value "warning"
  3. Adds modifier class rf-hint--warning
  4. Stores data-hint-type="warning" on root
  5. Removes the meta tag from the output

Meta tags must appear in both properties and children. The properties entry adds the data-field attribute; the children array ensures the tag is in the tree for the engine to find.

The data-rune marker

The root tag gets data-rune="kebab-name":

<section data-rune="hint" data-field="content-section">

The identity transform engine uses data-rune to look up the rune config (keyed by the matching typeName in packages/runes/src/config.ts), then applies BEM classes, modifiers, and structure. The Renderer outputs the transformed tree as generic HTML.

Runes that contribute Schema.org structured data also emit a typeof attribute (e.g. typeof="FAQPage") — set via schemaOrgType on createComponentRenderable. Most runes don't need it.


Engine config

The engine config in packages/runes/src/config.ts declaratively describes how to enhance each rune's output. Here's the full interface:

block

BEM block name, without prefix. Combined with the theme prefix to form the CSS class:

Hint: {
  block: 'hint',    // -> .rf-hint
}

modifiers

Maps modifier names to their sources. The engine reads the value, adds a BEM modifier class, and stores the value as a data attribute:

modifiers: {
  hintType: { source: 'meta', default: 'note' },
}
// Input:  <meta data-field="hint-type" content="warning">
// Output: class="rf-hint rf-hint--warning" data-hint-type="warning"

Sources:

  • 'meta' — reads from a <meta data-field="name"> child tag (most common)
  • 'attribute' — reads from a root element attribute

structure

Injects structural elements that don't exist in the rune's output. Keyed by data-name:

structure: {
  header: {
    tag: 'div',
    before: true,                // insert before existing children
    children: [
      { tag: 'span', ref: 'icon', icon: { group: 'hint', variant: 'hintType' } },
      { tag: 'span', ref: 'title', metaText: 'hintType' },
    ],
  },
},

StructureEntry options:

OptionDescription
tagHTML element to create
refOverride data-name (defaults to the structure key)
beforeInsert before existing children
childrenNested structure entries
iconInject SVG icon: { group, variant }
metaTextInject text from a modifier value
conditionOnly inject if the named modifier has a truthy value
conditionAnyOnly inject if any of the named modifiers has a value
transformTransform metaText: 'duration', 'uppercase', 'capitalize'
textPrefix / textSuffixStatic text around metaText
labelEmits <span data-meta-label> child for styled labels
labelHiddenMakes label visually hidden (sr-only) but accessible
metaTypeEmits data-meta-type: 'status', 'category', 'quantity', 'temporal', 'tag', 'id'
metaRankEmits data-meta-rank: 'primary', 'secondary'
sentimentMapMaps modifier values to data-meta-sentiment: 'positive', 'negative', 'caution', 'neutral'
attrsExtra attributes, can reference modifiers

contentWrapper

Wraps content children (non-structural) in a container:

contentWrapper: { tag: 'div', ref: 'content' },
// Wraps children in <div data-name="content" class="rf-recipe__content">

autoLabel

Maps child tag names or property values to data-name attributes:

autoLabel: { summary: 'header' },
// <summary> -> <summary data-name="header" class="rf-details__header">

contextModifiers

Adds BEM modifiers when the rune is nested inside a parent rune:

contextModifiers: { 'Hero': 'in-hero', 'Feature': 'in-feature' },
// When inside a Hero: class adds "rf-hint--in-hero"

styles

Maps modifier values to CSS custom properties as inline styles:

// Simple form: modifier value -> CSS custom property
styles: { columns: '--bento-columns' },
// -> style="--bento-columns: 4"

// Template form: modifier value interpolated into template
styles: {
  columns: { prop: 'grid-template-columns', template: 'repeat({}, 1fr)' }
},
// -> style="grid-template-columns: repeat(3, 1fr)"

staticModifiers

Modifier classes always applied, regardless of content:

staticModifiers: ['featured'],
// -> class always includes "rf-tier--featured"

postTransform

Programmatic escape hatch for logic that can't be expressed declaratively:

postTransform: (node, { modifiers, parentType }) => {
  // Modify the node after all declarative processing
  return node;
},

Use sparingly. If you need this, consider whether the logic belongs in the rune schema instead.

editHints

Declares how named sections (data-name elements) should behave when clicked in the block editor. Keys are data-name values; values are edit modes.

Recipe: {
  block: 'recipe',
  modifiers: { ... },
  structure: { ... },
  editHints: {
    headline: 'inline',
    eyebrow: 'inline',
    blurb: 'inline',
    ingredient: 'inline',
    step: 'inline',
    media: 'image',
  },
},

Edit modes:

ModeBehaviorTypical usage
inlineContenteditable with formatting toolbar (bold, italic, code, link)headline, eyebrow, blurb, list items
linkURL + display text fieldsaction buttons, CTA links
codeCode editor popoverterminal commands, code snippets
imageFile picker + alt text editormedia, cover images
iconIcon picker gallery with searchfeature icons, decorative icons
noneNot directly editable — click falls through to rune edit paneldecorative elements

Only elements with a data-name attribute can be editable. The data-name comes from either refs in createComponentRenderable or autoLabel in the engine config.

The editor resolves hints at click time from the engine config — no extra attributes appear in the rendered HTML.

Structure tab: When a rune has a declarative content model, the editor's structure tab shows the model's fields as a tree. Filled fields show content previews, empty optional fields show an add button, and empty required fields are highlighted. The content model's template and description field values are surfaced in this UI.

Universal theming dimensions

These fields enable generic cross-rune styling via data attributes. See Universal Theming Dimensions for the complete system and CSS patterns.

Recipe: {
  block: 'recipe',
  defaultDensity: 'full',       // → data-density="full"
  sections: { meta: 'header', title: 'title', content: 'body' },  // → data-section
  mediaSlots: { media: 'cover' },  // → data-media
  checklist: true,              // → data-checked on list items
  sequence: 'numbered',         // → data-sequence on <ol> elements
}
FieldAttribute emittedDescription
defaultDensitydata-densityDetail level: 'full', 'compact', 'minimal'
sectionsdata-sectionMaps ref names to section roles: 'header', 'title', 'description', 'body', 'footer', 'media', 'preamble'
mediaSlotsdata-mediaMaps ref names to media types: 'portrait', 'cover', 'thumbnail', 'hero', 'icon'
checklistdata-checkedDetects [x]/[ ]/[>]/[-] markers on <li> elements
sequencedata-sequenceOrdered list style: 'numbered', 'connected', 'plain'
sequenceDirectiondata-sequence-directionDirection from modifier: { fromModifier: 'direction', default: 'vertical' }

Metadata dimensions (metaType, metaRank, sentimentMap) are declared on individual StructureEntry children, not on the RuneConfig. See the StructureEntry options table above.


Putting it together

Here's the full flow for a Hint with type="warning":

Rune transform():
  <section data-rune="hint" data-field="content-section">
    <meta data-field="hint-type" content="warning">
    <div data-name="body">
      <p>Be careful about this.</p>
    </div>
  </section>

Engine identity transform:
  1. Look up config

    Reads data-rune="hint" and finds the Hint config (matched by typeName).

  2. Read modifier

    Reads <meta data-field="hint-type" content="warning">.

  3. Add modifier class

    Adds class: rf-hint rf-hint--warning.

  4. Set data attribute

    Adds data-hint-type="warning" to the root element.

  5. Inject structure

    Injects structure.header before children:

    <div data-name="header" class="rf-hint__header">
      <span data-name="icon" class="rf-hint__icon" />
      <span data-name="title" class="rf-hint__title">warning</span>
    </div>
    
  6. Add BEM element class

    Adds rf-hint__body to the body ref.

  7. Clean up

    Removes the consumed meta tag from the output.


Final output:
  <section data-rune="hint" data-field="content-section" class="rf-hint rf-hint--warning" data-hint-type="warning">
    <div data-name="header" class="rf-hint__header">
      <span data-name="icon" class="rf-hint__icon"></span>
      <span data-name="title" class="rf-hint__title">warning</span>
    </div>
    <div data-name="body" class="rf-hint__body">
      <p>Be careful about this.</p>
    </div>
  </section>