Patterns & Best Practices

This page documents the canonical patterns used across the rune library. Follow these when writing new runes or reviewing existing ones.

Heading-based splitting

Many runes use headings as structural boundaries — each heading starts a new step, tab, accordion item, etc. Heading level is determined automatically, not by the content author.

Auto-detect

Runes that split on headings auto-detect the level from the first heading in the content. Use a sections content model with sectionHeading: 'heading':

contentModel: {
  type: 'sections',
  sectionHeading: 'heading',  // auto-detect level from first heading
  emitTag: 'my-item',
  emitAttributes: { name: '$heading' },
},

This works because content authors typically use a consistent heading level within a rune. It also handles AI-generated content seamlessly.

Fixed level

Some runes hardcode a specific heading level as part of their design contract — for example, Bento always splits at h2, and Symbol uses h3 for groups and h4 for members:

const converted = headingsToList({ level: 2 })(nodes);

Header + body group split

The standard pattern for runes with a title area uses a delimited content model with header fields before the body:

contentModel: {
  type: 'sequence',
  fields: [
    { name: 'header', match: 'heading|paragraph', optional: true, greedy: true },
    { name: 'body', match: 'any', optional: true, greedy: true },
  ],
},

Use pageSectionProperties(header) for consistent extraction of eyebrow, headline, image, and blurb:

transform(resolved, attrs, config) {
  const header = new RenderableNodeCursor(
    Markdoc.transform(asNodes(resolved.header), config) as RenderableTreeNode[],
  );

  return createComponentRenderable(schema.MyRune, {
    tag: 'section',
    property: 'contentSection',
    refs: {
      ...pageSectionProperties(header),
      // adds: eyebrow, headline, image, blurb
    },
    children: [...],
  });
},

This pattern gives you:

  • Eyebrow — the first heading when there are 2+ headings (small label above the main heading)
  • Headline — the main heading
  • Image — first image in the header area
  • Blurb — first paragraph

Child item runes

When a rune has repeating items (Steps, Accordion, Tabs), create a separate schema for the child:

// Child — separate schema
export const accordionItem = createContentModelSchema({
  attributes: {
    name: { type: String, required: true },
  },
  contentModel: {
    type: 'sequence',
    fields: [{ name: 'body', match: 'any', optional: true, greedy: true }],
  },
  transform(resolved, attrs, config) {
    return createComponentRenderable(schema.AccordionItem, {
      tag: 'details',
      // ...
    });
  },
});

// Parent — uses sections content model to emit child tags
export const accordion = createContentModelSchema({
  attributes: {},
  contentModel: () => ({
    type: 'sections',
    sectionHeading: 'heading',
    emitTag: 'accordion-item',
    emitAttributes: { name: '$heading' },
    // ...
  }),
  transform(resolved, attrs, config) { /* ... */ },
});

Don't inline item logic in the parent's transform(). Separate item schemas:

  • Get their own typeof marker for engine config
  • Can have their own attributes and groups
  • Are reusable as explicit child tags: {% accordion-item name="..." %}
  • Can be registered independently in the schema registry

Choosing the interactivity path

PathWhen to useExamples
Identity transform + CSSLayout, styling, structural decorationGrid, Hint, Recipe, Feature, Hero
Behaviors libraryProgressive enhancement of native HTMLAccordion, Tabs, DataTable, Form, Reveal
postTransform hooksData rendering, custom elementsChart, Map, Diagram, Comparison, Sandbox

Default to CSS. About 75% of runes need nothing beyond the identity transform and CSS. If you can achieve the interaction with :target, :checked, sibling selectors, or scroll-driven effects, you don't need JavaScript.

Use behaviors for progressive enhancement. The behaviors library adds ARIA attributes, keyboard navigation, and event listeners to existing HTML. The rune still works without JavaScript — it just gets enhanced.

Use postTransform hooks when you need to generate complex HTML structure from metadata, produce custom element tags for client-side initialization, or integrate with external libraries. These hooks run during the identity transform and keep rendering framework-agnostic.


Modifier naming

Use camelCase for modifier names in code. The engine auto-converts to kebab-case for data attributes:

// In the rune:
properties: { hintType }

// Engine outputs:
// class="rf-hint--warning"
// data-hint-type="warning"

Use semantic names that describe the variant's purpose, not its visual treatment:

// Good
difficulty: 'medium'
hintType: 'warning'
align: 'center'

// Bad
color: 'yellow'
style: 'bordered'
size: 'large'

Don't hardcode structure in transform

Rune schemas should produce semantic output — meta tags with values and content in named containers. The engine config should inject visual structure.

Before
// Hardcoding visual structure in the runetransform() {  const icon = new Tag('span', { class: 'icon' });  const title = new Tag('span', {}, [this.type]);  const header = new Tag('div', {}, [icon, title]);  return new Tag('section', {}, [header, body]);      }
After
// Semantic output, visual structure in engine configtransform() {  const hintType = new Tag('meta', { content: this.type });  return createComponentRenderable(schema.Hint, {    tag: 'section',    properties: { hintType },    refs: { body: children.tag('div') },    children: [hintType, children.next()],  });}

The engine config then handles the visual structure declaratively:

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

This separation means:

  • Rune output is framework-agnostic (works with any renderer)
  • Themes can override structure without forking the rune
  • Icons, badges, and meta displays are configurable per-theme

Field ordering

Sequence fields consume nodes in declaration order. Each field matches the next eligible node from where the previous field stopped.

contentModel: {
  type: 'sequence',
  fields: [
    { name: 'header', match: 'heading|paragraph', greedy: true },  // headings + paragraphs first
    { name: 'ingredients', match: 'list' },                         // then lists
    { name: 'body', match: 'any', optional: true, greedy: true },   // remaining content
  ],
},

Design field match patterns to be mutually exclusive when possible. If two fields can match the same node type, the first one wins.

Use delimited content models (HR delimiter) when content has explicit visual breaks:

contentModel: {
  type: 'delimited',
  delimiter: 'hr',
  zones: [
    { name: 'main', type: 'sequence', fields: [...] },     // before first ---
    { name: 'showcase', type: 'sequence', fields: [...] },  // after first ---
  ],
},

Content boundaries

A rune's content model should include exactly what it reinterprets — content that gains new semantic meaning by being inside the rune. Content that would render identically whether inside or outside the rune belongs on the page, not in the rune.

Three tests

  1. Does the rune give this content new meaning? A list inside {% recipe %} becomes ingredients — with data-name="ingredient", schema.org markup, and dedicated styling. That's reinterpretation. A paragraph that's just a paragraph is pass-through.
  2. Does it map to a structured data field? If schema.org/Recipe has no story field, a narrative paragraph doesn't belong inside the recipe rune.
  3. Is the rune still portable without it? A recipe card should work on an index page, a blog post, or a sidebar. Narrative content couples it to a single page context.

What goes where

RuneInside (reinterpreted)Outside (page content)
RecipeIngredients, steps, tips, metadata, headline, blurb, mediaStory, editorial, famous quotes
PlaylistTracks, cover art, name, descriptionAlbum review, discovery story
CharacterStats, abilities, portrait, nameChapter featuring the character
EventDate, venue, schedule, locationBlog post about why to attend

Page composition

A recipe blog post demonstrates the pattern — the rune is a semantic island within surrounding page content:

# My Summer in Tuscany

That long story about grandma's kitchen
goes here, as regular page content...

{% recipe prepTime="PT15M" cookTime="PT30M" servings=4 difficulty="easy" %}
# Classic Pasta Carbonara

A rich and creamy Italian pasta dish.

- 400g spaghetti
- 200g pancetta
- 4 egg yolks

1. Cook pasta in salted boiling water
2. Fry pancetta until crispy
3. Whisk egg yolks with grated cheese

> Never add eggs directly to a hot pan.
{% /recipe %}

More thoughts on the trip, or a second
recipe rune, or any other page content.

Everything inside the {% recipe %} tags is reinterpreted: the heading becomes the recipe name, the list becomes ingredients, the ordered list becomes steps, the blockquote becomes a chef's tip. Everything outside is regular Markdown rendered as-is.


Testing

Every rune should have a test file at packages/runes/test/{name}.test.ts.

Test helpers

import { describe, it, expect } from 'vitest';
import { parse, findTag, findAllTags } from './helpers.js';
  • parse(markdown) — runs the full Markdoc parse + transform pipeline
  • findTag(node, predicate) — finds the first matching Tag in a tree
  • findAllTags(node, predicate) — finds all matching Tags

What to test

Basic output structure:

it('should produce the expected structure', () => {
  const result = parse(`{% hint type="warning" %}
Be careful.
{% /hint %}`);

  const hint = findTag(result as any, t => t.attributes.typeof === 'Hint');
  expect(hint).toBeDefined();
  expect(hint!.name).toBe('section');
});

Auto-detection behavior (headings are automatically detected):

it('should auto-detect h2 heading level', () => {
  const result = parse(`{% steps %}
## Step One
Content.

## Step Two
Content.
{% /steps %}`);

  const steps = findTag(result as any, t => t.attributes.typeof === 'Steps');
  const items = findAllTags(steps!, t => t.attributes.typeof === 'Step');
  expect(items.length).toBe(2);
});

Child tags still work (for runes that support both heading syntax and explicit child tags):

it('should support explicit child tags', () => {
  const result = parse(`{% accordion %}
{% accordion-item name="Item One" %}
Content.
{% /accordion-item %}
{% /accordion %}`);

  const items = findAllTags(acc!, t => t.attributes.typeof === 'AccordionItem');
  expect(items.length).toBe(1);
});

Run tests

# All tests
npm test

# Single file
npx vitest run packages/runes/test/mystep.test.ts

# Watch mode
npm run test:watch