v0.38.0

Released October 7, 2026 · All releases

Rune transform consolidation. Rune transforms lose code that did nothing: metas emitted only to be filtered back out, an unused schema.org channel, and guards that guarded nothing, with rendered output unchanged. Two declarations now reach the author: a mixed list-and-tag field no longer drops the tags written in it, and refrakt reference shows each field's authoring grammar.

Background: · ·

Changes

  • New rune-authoring helpers in @refrakt-md/runes (WORK-602, WORK-603)
  • renderNodes(value, config) returns a RenderableNodeCursor over the transformed content. It replaces new RenderableNodeCursor(Markdoc.transform(asNodes(x), config) as RenderableTreeNode[]).
  • bodyOnly() is the content model of a rune whose children are all body. It returns a fresh object per call.
  • fieldMetas(attrs, config, spec) builds a rune's property metas from one declaration. A bare string is the default for the attribute of the same name. { from: ['attrs.x', 'file.x'], default } takes the first non-empty source. The spec is plain data and round-trips through JSON. Any root other than attrs or file is rejected.
  • groupByHeading(nodes, { initial, heading, item, other? }) is the shared walk where a heading sets the running group and list items become entries.
  • The built-in runes now use these helpers: 78 cursor constructions, 49 content models, 12 property maps (including the five plan runes' created / modified fallback to file dates) and six heading-grouped list parsers. Neither helper is mandatory. Rendered output is unchanged.
  • Breaking: remove the imperative schema.org channel from createComponentRenderable (WORK-599)
  • TransformResult.schema, TransformResult.typeof and InlineTransformResult.schemaOrgType are gone, along with the unused schemaOrgType fields on AccordionProps and AccordionItemProps.
  • SPEC-130 moved every rune to the declarative table on createContentModelSchema({ schema }). The imperative fields kept type-checking but were no longer what the appliers and the structured-data pipeline are built around, and no rune in this repository passed them. A surface that still compiles but no longer does what its docs say is worse than none.
  • Migrating a third-party plugin: move the type and property mappings into a schema table on the rune's createContentModelSchema call. See "Declaring schema.org output" in the plugin authoring guide.
  • Rendered output is unchanged for every built-in rune.
  • Keep authored tags in a mixed emitTag field, and show the list-item grammar in refrakt reference (WORK-604, WORK-606)
  • Fixes silent content loss. A field declared match: 'list|tag:x' with emitTag converted the list items and dropped every tag the author wrote (BUG-030). Authored tags now keep their place, so the field resolves to one document-ordered array of tags. A list-only field behaves as before. No built-in rune declared this combination, so rendered output is unchanged.
  • refrakt reference now shows how a list item is read (BUG-031). A field with an itemModel lists each item field: bold, italic, a link and its href, text matching a pattern, the remaining text, a nested list. Where a field has them, it also shows the template snippet ("Written as") and the tag each item becomes (emitTag). A pattern with a non-ASCII character names it, so playlist's date field now says its — is U+2014 and a typed - will not match.
  • SerializedContentField gains an itemModel projection: regex source and flags as strings, 'remainder' as a literal.
  • Stop emitting property-mapped meta tags into rune children.
  • Every rune that mapped a meta under properties and also listed it in children had it filtered straight back out by createComponentRenderable; the value already lives in the data-rune-fields bag. The emission is removed from 62 tag files and the auto-breadcrumb builder. Rendered output is unchanged: structure contracts, the structured-data baseline, and refrakt inspect across every rune and variant are identical before and after.
  • The one exception is content that is already invalid. A blog with no folder (a required attribute) no longer renders an empty <meta data-field="folder">.
  • Rune configs are now plain data, and plan migrate ids keeps the published claimant (WORK-608, WORK-607)
  • Breaking: styles[…].transform takes a name, not a function (WORK-608). Use one of the named transforms already used by meta fields and structure entries. That vocabulary now has three more entries:
  • align: an alignment keyword becomes a CSS align-* value (was resolveValign).
  • fr: "2 1" becomes 2fr 1fr (was ratioToFr).
  • gap: a gap preset becomes a spacing token (was resolveGap).
  • The type is exported as NamedTransform. Replace transform: resolveValign with transform: 'align'. The helpers stay exported. After this change postTransform is the only function a RuneConfig carries, so a rune config can cross a JSON boundary. A test checks every core and plugin rune for this. Structure contracts now record these transform names; before, JSON.stringify silently dropped them because they were functions. Rendered output is unchanged.
  • plan migrate ids --against <ref> (WORK-607, BUG-026). When two files claim one ID, the one already on the base ref keeps it, and the branch-local claimant is renumbered. Before this, the claimant that moved was picked by sorting filenames, which could renumber the entity already referenced from merged commits and published CHANGELOGs. Without --against, a collision is now refused rather than resolved by filename. Every renumber and refusal names the claimant that kept the ID and why. plan validate --against now suggests the matching migrate ids --against command.
  • Simplify rune transforms: optional slots and shared text helpers (WORK-600, WORK-601)
  • properties and refs on createComponentRenderable now accept null as well as undefined; both are skipped. The 52 ...(x ? { k: x } : {}) guards inside slot literals are now plain k: x.
  • extractText(node) (concatenated text of an AST node) is exported from @refrakt-md/runes and replaces six identical local copies. Three plugin-local text helpers on rendered trees now use the exported textContent.
  • Rendered output is unchanged.

v0.38.0 — Rune transform consolidation

v0.38.0complete

v0.38.0 — Rune transform consolidation

Across 121 runes, transform() is 3,955 lines. A measurable share of that is not logic — it is dead code, guards that guard nothing, and the same four gestures retyped. Separately, the declarative content model already states more about a rune's authoring syntax than any tool surfaces, and one field of it silently eats authored content.

This milestone removes what is provably inert, names the idioms that are merely repetitive, and closes the two gaps where a declaration exists and does not reach either the author or the runtime.

Why

Three things were established by measurement rather than argument, and each one is a deletion rather than a feature:

100 metas across 24 files are emitted into children after being mapped in properties, where createComponentRenderable filters them straight back out. 133 other metas already omit that emission and render identically. A 57/43 split on a distinction with no observable effect is not a convention.

The schema.org channel on createComponentRenderable has zero callers. SPEC-130 migrated every rune to the declarative table; the imperative surface was never removed. That also makes the point above structural rather than empirical: schemaTags can never be non-empty, so every property meta is necessarily a pure-data meta.

59 conditional spreads inside refs and properties are redundant, because the helper already skips undefined.

Alongside those, two declarations that exist and do not arrive:

A mixed list|tag:x field with emitTag drops the authored tags (BUG-030) — silent content loss, and the reason playlist reimplemented by hand what cast gets in three lines.

The reference never renders the field-level authoring grammar (BUG-031) — emitTag and template are serialized and dropped at the renderer, itemModel is not projected at all. So for a rune whose primary syntax is a Markdown list, the docs say a list is accepted and nothing about what goes in it.

What lands

Eleven items: nine in three groups, plus two riders:

  • Deletion (3) — the inert metas, the dead schema channel, the redundant guards. Output-identical; contracts --check and seo:baseline:check must report no diff.
  • Consolidation (3) — one extractText, renderNodes + bodyOnly, fieldMetas + groupByHeading.
  • Declarations that arrive (3) — the mixed-field fix, playlist adopting it, and the reference projecting the item grammar.
  • Riders (2) — BUG-026, plan migrate ids renumbering the published claimant: unrelated to the rune work, small, self-contained, and already understood. And WORK-608, replacing the three function-typed styles[…].transform helpers with the named-transform enum: surfaced by SPEC-143 (still a draft) but not waiting on it. Eleven call sites resolve to three helpers a declarative sibling already covers, and removing them leaves postTransform as the only function-typed field in RuneConfig. contracts --check must report no drift.

The acceptance test does most of the work

contracts/structures.json and contracts/seo-baseline/baseline.json already describe every rune's complete output — structure on one side, structured data on the other, both generated. For the deletion and consolidation groups the required result is no diff in either. A refactor that moves either file has changed behaviour, whatever the intent, and that is a stronger check than the unit tests for this class of change.

Sequencing inside the milestone

Two orderings are load-bearing; the rest is independent.

Removing the dead schema channel reads more clearly once the metas are out of the children, because the pureDataMetas filter has less to do. And playlist cannot adopt field-level emitTag until the resolver stops dropping authored tags.

Deliberately not here

SPEC-141 — preprocess in tree order. It rewrites packages/runes/src/snippet-pipeline.ts, which is exactly what five of v0.37.0's twelve items are doing. Two branches rewriting one file is not a schedule. It waits, wrapper removal included.

SPEC-142 — inferring the transform signature. It wants this milestone to go first: both touch ContentModelSchemaOptions and createComponentRenderable, and removing the dead surface here shrinks what there is to type.

BUG-028 — figure dropping non-image children. The fix is a product decision about what figure is (a general captioned container, or image-only with a warning), so it wants deciding before scheduling.

BUG-029 — knownSections inert under emitTag. Latent, minor, and its two fixes are materially different work. Not worth spending a decision on to round out a count.

What this milestone does not claim

playlist's hand-written merge shrinks; it does not vanish. BUG-030 makes the two syntaxes structurally uniform and correctly ordered, but adoptNestedTrack exists for attribute inheritance — defaulting a track's type from the playlist's, and passing down the playlist's artist — and emitAttributes cannot express that: its refs reach the extracted item data, never the parent rune's attributes. Roughly 55 lines become roughly 20. Whether emitAttributes should reach a parent at all is SPEC-130 D9's question, and is not asked here.

Outcome

Ten of eleven items landed across #654, #656, #657, #658 and #659; one was cancelled. Every deletion and consolidation item met the acceptance test above: refrakt inspect --json over every rune and variant on both sites rendered identical HTML before and after.

Where the result departs from the plan:

  • WORK-605 cancelled. playlist cannot hand its list items to track through emitTag: track has no name attribute, cue points would lose their structure, the embedded player reads tracksData, and the inherited artist would stop rendering visibly. BUG-030 is still fixed (WORK-604), so converging the two track forms is open to a later item that redesigns track rather than this one.
  • WORK-606 without the tag tightening. Narrowing the eight preamble match: 'tag' fields to tag:<name> would end a greedy run at the first stray tag and drop the matching items after it — new silent loss. The reference names the child rune through the sections-level emitTag instead.
  • WORK-603: groupByHeading at six loops, not seven. bento walks sibling nodes under a heading rather than list items.
  • WORK-599 moved one attribute. typeof now follows class on runes with a schema row; every value is unchanged.
  • WORK-608 added to the contracts. The styles transforms are names now, so structures.json records them where it used to drop the functions silently — additive, and kept deliberately.

A mixed list|tag:x field with emitTag silently drops the authored tags

BUG-030fixed

A mixed list|tag:x field with emitTag silently drops the authored tags

resolveSequence's emit branch walks the nodes a field collected and skips anything that is not a list (packages/runes/src/lib/resolver.ts:194):

runescontent-modelemitTagitemModelcontent-lossplaylist
const listNodes = field.greedy ? (result[field.name] as Node[]) : [result[field.name] as Node];
const tagNodes: Node[] = [];
for (const listNode of listNodes) {
	if (!listNode || (listNode as any).type !== 'list') continue;
	…
}
result[field.name] = tagNodes;      // ← replaces the whole collection

The skipped nodes are not merely unconverted — the last line replaces the field with tagNodes, so they are gone. A field declared match: 'list|tag:track' with emitTag: 'track' therefore keeps the list-derived tracks and discards every {% track %} the author wrote.

Steps to Reproduce

Resolve this body against a greedy field matching list|tag:track, with an itemModel and emitTag: 'track':

- Song A
- Song B

{% track name="Song C" /%}

- Song D

Expected

Four track tags, in document order: the three list-derived ones and the authored Song C. Both syntaxes are the same thing after conversion, which is the point of emitTag.

Actual

names: [ 'Song A', 'Song B', 'Song D' ]

Song C is gone. No error, no warning, no build diagnostic.

Why it matters beyond the immediate loss

This is what stops playlist from using the mechanism that already exists.

cast (plugins/business/src/tags/cast.ts:92-116) gets the dual-syntax composition almost free — a members field matching list with emitTag: 'cast-member', a separate items field matching tag, and a three-line merge because both arrive as the same kind of node:

const allMembers = [...asNodes(resolved.members), ...asNodes(resolved.items)];

playlist cannot do that. It declares match: 'list|tag:track' on one field without emitTag, then spends roughly 55 lines (plugins/media/src/tags/playlist.ts:316-353) reconciling two representations by hand — a listCursor consuming tracksData by list length, a transform-time adoptNestedTrack to retro-fit the authored tags into the parent's conventions, and a defensive tail for "a shape the resolver collected but this loop did not recognise".

Its own content model records the cost:

tag:track alongside list so the composition the docs promise works. Greedy because the two forms may alternate, and greedy collection is consecutive: prose between two tracks ends the run and pushes the rest to body.

Notes

  • Severity is major because the failure is silent content loss, the same class as BUG-028. A rune that refused the mixed declaration would be a smaller problem than one that quietly eats half of it.
  • No rune hits this today: cast, audio and plot use emitTag on list-only fields, and playlist uses a mixed field without emitTag. The bug is latent, and it is latent because the obvious combination does not work — so it has shaped the code around it rather than showing up as a defect.
  • The fix looks small: pass a non-list node through into tagNodes instead of continue-ing past it. It is already a tag, so it needs no conversion — only to keep its place. That yields a homogeneous, document-ordered array, which is strictly better than cast's concatenation (which appends all explicit tags after all list-derived ones and so loses interleaving).
  • Worth confirming as part of any fix: that an emitted tag and an authored one of the same name really are interchangeable downstream — adoptNestedTrack exists because playlist found they were not, and that difference should be removed rather than re-encoded.
  • Surfaced while considering whether SPEC-141's per-rune preprocess could subsume emitTag. It cannot and should not, but the question exposed this.

References

  • SPEC-003 — the declarative content model this belongs to
  • BUG-028 — figure dropping non-media children; the same silent-content-loss class
  • BUG-031 — the reference drops emitTag, template and the whole itemModel grammar, so neither half of the dual-syntax contract is visible
  • SPEC-141 — where the question that exposed this came from

Resolution

Completed: 2026-10-06

Fixed by WORK-604 (#658): resolveSequence's emitTag branch passes non-list nodes through in place, so a list|tag:x field resolves to one document-ordered array of tags. Regression tests cover the interleaved case from this report.

The reference drops the field-level authoring grammar

BUG-031fixed

The reference drops the field-level authoring grammar

For a rune whose primary syntax is a Markdown list, refrakt reference documents that a list is accepted and nothing about what goes in it. Three facts are lost, at two different points in the pipeline.

runesreferencedocsemitTagitemModeldx

What is lost, and where

FactSerialized?Rendered?
field.emitTagyes (reference.ts:385)no
field.templateyes (reference.ts:383)no
field.itemModelno — absent from SerializedContentFieldno

renderField (reference.ts:483) formats name, optional, greedy, match and description, and stops:

function renderField(field: SerializedContentField): string {
	const required = field.optional ? 'optional' : 'required';
	const repeat = field.greedy ? ', repeatable' : '';
	const match = formatMatch(field.match);
	const desc = field.description ? ` — ${field.description}` : '';
	return `\`${field.name}\` (${required}${repeat} ${match})${desc}`;
}

So two fields are carried all the way to the renderer and dropped there, and a third never survives the projection.

Steps to Reproduce

node packages/cli/dist/bin.js reference playlist --site main

Expected

Enough to write a track without reading the source or reverse-engineering the example.

Actual

- `tracks` (required, repeatable list or `track` tag) — Track listing

The dual syntax is visible here (formatMatch handles list|tag:track), but the list grammar is not. Every one of these is declared in itemModel and appears nowhere in the output:

FragmentDeclared as
**Speak to Me**{ name: 'name', match: 'strong' }
[Breathe](/tracks/breathe){ name: 'src', match: 'link', extract: 'href' }
*M83*{ name: 'artist', match: 'em' }
(1:13)pattern: /\((\d+:\d+(?::\d+)?)\)/
— March 1973pattern: /—\s*(.+)$/

cast is worse still — its items field declares match: 'tag' rather than match: 'tag:cast-member', so it renders as a bare "repeatable tag" that never names the rune an author would write.

The trap this hides

The date pattern is /—\s*(.+)$/ — U+2014 EM DASH, verified. A plain hyphen does not match. The example uses the em dash but nothing states the requirement, and the field is optional, so an author who types - gets no date, no warning, and no way to find out why short of reading the plugin source. (1:13) versus 1.13 fails the same way.

Why this reads as an oversight rather than a decision

The sections side of the same file already projects its extraction grammar:

export interface SerializedSectionsModel {
	headingExtract?: { fields: SerializedHeadingExtractField[] };  // regex source, as a string
	knownSections?: Record<string, SerializedKnownSection>;         // aliases + hasModel
}

SerializedHeadingExtractField.pattern is documented as "Regex source, or the literal string 'remainder'". So the reference knows how to serialize and describe pattern-based extraction, and does — for headings. itemModel is the same shape of fact, one struct over, and was never projected.

Section-level rendering is also the model to copy for tone. character renders the authoring shape, not the machinery:

Content is split into sections by heading elements. Each section becomes one named block.

Field-level should read the same way: lead with what to write, mention the emitted tag as the consequence.

Notes

  • Severity minor: docs-only, no wrong output. But this is the surface an author learns the rune from, and the hand-written description sentence is currently doing the job — cast's reads "List items with "Name - Role" pattern become entries", which is the generated field list's work done by hand, and which itself uses a hyphen where the model wants an em dash.
  • The only place the list grammar reaches a reader today is the example block, which comes from a fixture (ctx.fixtures[name] ?? RUNE_EXAMPLES[name]). Fixtures are validated — the corpus test parses, validates and transforms each one — so they cannot describe syntax the pipeline rejects. But they demonstrate one valid shape; they do not enumerate the grammar, and nothing requires a fixture to exercise every itemModel field. Playlist's fixture shows (1:13) and — March 1973; it does not show *artist*.
  • field.template is the third restatement of the same syntax (6 declarations, e.g. playlist's '- **Track Name** (3:45)'). Either render it or delete it — a hand-written snippet that nothing displays is strictly worse than no field.
  • Fixing this is one change to one path: widen SerializedContentField, then extend renderField. Both emitTag and template are already on the wire.
  • Not in scope, deliberately. The block editor (editor/src/server.ts:1022) reads RUNE_EXAMPLES directly instead of the merged fixture map, so plugin runes get no example there at all. Recorded here rather than filed — that package is dormant and will be revisited on its own terms.

References

  • SPEC-128 — generating rune attribute tables from the rune reference
  • BUG-030 — the runtime half: a mixed field with emitTag drops the authored tags
  • SPEC-003 — the declarative content model whose grammar this fails to surface

Resolution

Completed: 2026-10-06

Fixed by WORK-606 (#658): the reference projects itemModel and renders the template, the item grammar (including non-ASCII pattern characters such as the U+2014 date dash) and both field- and section-level emitTag. Tightening the preamble match: 'tag' fields was declined: a greedy run stops at the first non-match, so it would drop items after a stray tag.