Batch 3: Convert Runes Using sections Resolver
Context
The sections resolver pattern was implemented in the previous step (commit fb923af). It provides heading-based splitting with emitTag, headingExtract, and heading auto-detection. This batch converts the 8 runes that are now unblocked by the sections resolver, replacing their imperative Model subclasses with declarative createContentModelSchema().
5 runes are deferred due to complexity beyond what sections provides: tabs (image extraction from heading AST), bento (tiered sizing logic), steps (SplitLayoutModel base + complex step creation), nav (auto/manual/grouped modes), storyboard (image-based splitting).
Phase 0: Dynamic contentModel Support
Problem: Accordion, reveal, timeline, changelog, and pricing all have a headingLevel attribute that determines which heading level to split on. The contentModel field in ContentModelSchemaOptions is currently static — it needs to support being a function of the resolved attributes so sectionHeading can be dynamic.
Files:
packages/runes/src/lib/index.ts— line 308 and lines 382-384
Change:
// ContentModelSchemaOptions.contentModel type (line 308):
contentModel: ContentModel | ((attrs: Record<string, any>) => ContentModel);
// In createContentModelSchema transform (lines 382-384):
const resolvedModel = typeof options.contentModel === 'function'
? options.contentModel(attrs)
: options.contentModel;
const { content, tintNode, bgNode } = resolveContentModel(node.children, resolvedModel);
Group A: Simple emitTag Conversions
Accordion (packages/runes/src/tags/accordion.ts)
Current: AccordionModel + AccordionItemModel, headingsToList() → Ast.Node('tag', { name }, body, 'accordion-item'), @group routing.
Convert to:
accordion-itemschema: keepcreateSchema(AccordionItemModel)as-is (it's a child rune with its own transform)accordionschema:createContentModelSchemawith:base: undefined(no base model needed)attributes:headingLevel,multiplecontentModel: (attrs) => ({ type: 'sections', sectionHeading: attrs.headingLevel ? 'heading:'+attrs.headingLevel : 'heading', emitTag: 'accordion-item', emitAttributes: { name: '$heading' }, fields: [{ name: 'header', match: 'heading|paragraph', optional: true, greedy: true }], sectionModel: { type: 'sequence', fields: [{ name: 'body', match: 'any', optional: true, greedy: true }] } })transform: receives{ header, sections }, usespageSectionPropertiesfor header, transforms sections viaMarkdoc.transform, filters for AccordionItem typeof
Test: packages/runes/test/tags/accordion.test.ts — existing tests must pass unchanged
Reveal (packages/runes/src/tags/reveal.ts)
Current: Identical pattern to accordion. RevealModel + RevealStepModel.
Convert to: Same approach as accordion but with emitTag: 'reveal-step', emitAttributes: { name: '$heading' }.
Test: packages/runes/test/tags/reveal.test.ts
Group B: headingExtract / Manual Parsing
Timeline (runes/business/src/tags/timeline.ts)
Current: TimelineModel + TimelineEntryModel. Heading parsed with DATE_LABEL_PATTERN = /^(.+?)\s*[-–—:]\s*(.+)$/.
Convert to: createContentModelSchema with:
contentModel: (attrs) => ({ type: 'sections', sectionHeading: ..., emitTag: 'timeline-entry', emitAttributes: { date: '$date', label: '$label' }, headingExtract: { fields: [{ name: 'date', match: 'text', pattern: /^(.+?)\s*[-–—:]\s*/, optional: true }, { name: 'label', match: 'text', pattern: 'remainder' }] }, sectionModel: { type: 'sequence', fields: [{ name: 'body', match: 'any', optional: true, greedy: true }] } })- TimelineEntryModel child rune: keep
createSchema(TimelineEntryModel)
Test: runes/business/test/tags/timeline.test.ts
Changelog (runes/docs/src/tags/changelog.ts)
Current: ChangelogModel + ChangelogReleaseModel. Heading parsed with VERSION_DATE_PATTERN. Fallback: when no separator, version=full heading text, date=''.
Convert to: Use non-emitTag sections approach since the heading parsing has a fallback that doesn't fit headingExtract cleanly:
contentModel: (attrs) => ({ type: 'sections', sectionHeading: ..., sectionModel: { type: 'sequence', fields: [{ name: 'body', match: 'any', optional: true, greedy: true }] } })transform: manually parse$headingtext from each section with the existing pattern+fallback logic, then build the renderable
Test: runes/docs/test/tags/changelog.test.ts
Pricing (runes/marketing/src/tags/pricing.ts)
Current: PricingModel + TierModel. Selective conversion — only headings matching NAME_PRICE_PATTERN become tiers. Non-matching headings stay as-is. Level auto-detection from first matching heading.
Convert to: Use non-emitTag sections approach since selective conversion isn't supported by emitTag:
contentModel: (attrs) => ({ type: 'sections', sectionHeading: ..., sectionModel: { type: 'sequence', fields: [{ name: 'body', match: 'any', optional: true, greedy: true }] }, fields: [{ name: 'header', match: 'heading|paragraph', optional: true, greedy: true }] })transform: iterate resolved sections, test each heading against pattern, selectively create tier tags or keep as-is
Test: runes/marketing/test/tags/pricing.test.ts
Group C: Named Sections (Storytelling)
Character (runes/storytelling/src/tags/character.ts)
Current: CharacterModel + StorySectionModel. headingsToList → section tags. Conditional output: sections exist → refs: { portrait, sections }; else → refs: { portrait, body }. Portrait image from preamble.
Convert to: createContentModelSchema with:
attributes:name(required),role,status,aliases,tagscontentModel: (attrs) => ({ type: 'sections', sectionHeading: ..., emitTag: 'character-section', emitAttributes: { name: '$heading' }, fields: [{ name: 'portrait', match: 'image', optional: true }, { name: 'body', match: 'any', optional: true, greedy: true }], sectionModel: { type: 'sequence', fields: [{ name: 'body', match: 'any', optional: true, greedy: true }] } })transform: check section count for conditional output
Test: runes/storytelling/test/tags/character.test.ts
Realm (runes/storytelling/src/tags/realm.ts)
Current: Same pattern as character. RealmSectionModel. Scene image instead of portrait.
Convert to: Same approach as character with emitTag: 'realm-section'.
Test: runes/storytelling/test/tags/realm.test.ts
Faction (runes/storytelling/src/tags/faction.ts)
Current: Same pattern, simpler (no image extraction). FactionSectionModel.
Convert to: Same approach with emitTag: 'faction-section'.
Test: runes/storytelling/test/tags/faction.test.ts
Execution Order
- Phase 0: Dynamic contentModel in
createContentModelSchema+ test - Group A: Accordion + Reveal (simplest, proves the pattern)
- Group C: Character + Realm + Faction (similar pattern, done together)
- Group B: Timeline, Changelog, Pricing (most complex)
Each conversion: modify schema file → run that rune's tests → verify pass.
Key Utilities to Reuse
createContentModelSchema()frompackages/runes/src/lib/index.ts:332pageSectionProperties()frompackages/runes/src/tags/common.tsRenderableNodeCursorfrompackages/runes/src/tags/common.tscreateComponentRenderable()frompackages/runes/src/lib/component.tsasNodes()frompackages/runes/src/lib/index.ts:18resolveContentModel()/resolveSections()frompackages/runes/src/lib/resolver.ts
Verification
npm run build— zero TypeScript errorsnpm test— all 1376+ tests pass- Each rune's existing test file passes without modification (the conversion must be behaviorally identical)