v0.35.0
Released September 17, 2026 · All releases
Declarative schema.org mapping. Thirty runes publish structured data. Every one of them writes it by hand, inside its transform, in a channel no tool can read and no reviewer looks at. This milestone turns that channel into data keyed by the rune's own names — the same shape BEM, modifiers and editHints already use — and builds the review surface it has never had.
Changes
- Declare a rune's schema.org mapping as a table (SPEC-130, WORK-561/563/565/566)
- A rune's structured data used to be built by hand inside its
transform():schemaOrgType: 'Product'here, aschema: { name: someLocalTag }there, a<span typeof="Person">assembled from filtered children somewhere else. No tool could see any of it, because it only existed as JavaScript that had already run — and JSON-LD is the one output nobody looks at, since a page renders identically whether its structured data is right or ruined. - A rune now declares
schema:oncreateContentModelSchemaas data, and an applier resolves it against the rendered tree: - Sources resolve by name, and the lookup is attribute-agnostic: a
data-nameand adata-fieldare equally findable, so a node moving betweenpropertiesandrefsnever breaks a table. A source that exists only as an attribute value is rebuilt from thedata-rune-fieldsbag; the rendered HTML is never relocated to suit the schema. schemais an identity field. A theme may restructure and re-decorate a rune; it may not restate what the rune asserts about a site's content by changing how it looks (ADR-028).- Declared lists always serialise as arrays.
lists: ['track']fixes the shape of a collection property regardless of how many items an author wrote — previously a one-item collection emitted an object and a two-item one an array, so every consumer had to handle both. - Every source is addressable. Roughly 20 nodes the schema reached only as anonymous local variables or array positions now carry names.
refrakt inspect <rune>,refrakt contractsandrefrakt referenceall print the resolved row. Nothing validates a table against schema.org — refrakt ships no ontology — so visibility replaces validation, and a wrong row is caught by a reviewer reading it. What is checked: a source matching no emitted node, no field-bag entry and no declared attribute is flagged, because that property is silently absent from the published graph.- SEO is now harvested after the cross-page pipeline, not during per-page transform.
{% breadcrumb auto=true %}resolves its ancestors in apostProcesshook, so before this its JSON-LD was harvested from a tree that did not yet have them — the rendered RDFa and the published JSON-LD disagreed on every auto breadcrumb. - Also new:
contracts/seo-baseline/baseline.json, a committed record of the structured data every rune emits, so a schema change is reviewed as a diff. - Per-type schema for
playlistandtrack— BUG-013 (WORK-569, SPEC-130) - Both runes declared an enum of kinds and then published one type for all of them.
{% playlist type="podcast" %}announced a podcast as aMusicPlaylistwhose episodes wereMusicRecordings, and{% track type="episode" %}did the same on its own. Each rune already knew what its content was and emitted the wrong schema anyway. - Each now keys off the attribute its author already set, with its own table, and neither rune declares anything about the other.
- A
playlistpublishes by kind:albumis aMusicAlbumwithtrack, a narrowing of theMusicPlaylistit used to emit;mixstays aMusicPlaylist, the one kind with no narrower type; andpodcast,audiobookandseriesswitch branch toPodcastSeries,AudiobookandCreativeWorkSeries, holding their items inhasPartasPodcastEpisodes,Chapters andCreativeWorks. track's five kinds map toMusicRecording,PodcastEpisode,Chapter,CreativeWorkandVideoObject.- A child row carries a property map, not just a type. Retyping an item and leaving its stamps would put
byArtiston aPodcastEpisode, which does not have that property — worse than theMusicRecordingit replaced, which was at least coherently wrong. A parent that retypes a child now owns that child's mapping, and anything it does not name is dropped. A type the author stated is the exception and survives: a{% track type="song" %}inside a podcast stays a song, with its own properties. - Breaking for consumers of this data. Four of the five playlist kinds change type, three of them change the property holding their items from
tracktohasPart, andbyArtistno longer appears on non-music items or on aPodcastSeries.track/hasPartare also declared lists now, so a one-item playlist emits an array rather than a bare object. - Also in this change:
refrakt inspectaudits achildren:row against the children rather than against the parent, and accepts a stamped property as evidence that a source resolved — without which every property the applier rebuilds from the field bag reported as broken, since the identity transform consumes the bag before the audit reads the tree. - Style runes by BEM class, not the schema.org channel (WORK-564, BUG-015)
- Fourteen CSS rules across Lumina and Skeleton selected on
[property=…]— the RDFa attribute the structured-data channel writes. That coupled a rune's appearance to what it asserts about its content, so renaming a schema.org property would unstyle a rune, and four of the rules were already dead because apropertyhad moved years ago. - All fourteen now select on the
data-name-derived BEM element class instead (.rf-lore__title,.rf-plot__title, and so on). Where a rule had a livemeta[property]half and a deadspan[property]half, the dead half is gone and the live one kept. A CSS coverage assertion walks every stylesheet underpackages/andplugins/and fails on any[property…]selector, so the coupling cannot come back. .rf-lore__titleand.rf-plot__titlepinnedfont-size, which overrode the prominence chain —{% plot prominence="display" %}had been inert. The declarations are removed;sections.cssalready supplies the resting value.- A
schema="none"test passed vacuously:stripSchemaOrghas exactly one caller (accordion), so the attribute does nothing onpricing. - If you override Lumina or Skeleton CSS and your selectors mention
[property=…]on a rune's children, they no longer match the shipped markup's intent — switch to the BEM element class. If you relied on.rf-lore__titleor.rf-plot__titlecarrying a fixedfont-size, set it yourself or use theprominenceaxis, which now works. - An entity with no properties is not emitted (WORK-567, SPEC-130 D4)
- Seven runes declared a schema.org type and no property mapping at all, each publishing
{"@context": …, "@type": "Dataset"}and nothing more. A type assertion with nothing attached tells a consumer nothing; it is noise in every channel that reads it. collectJsonLdnow drops an entity that resolves to a bare@type, and never nests a bare child into its parent. Enforcing it in the collector rather than per rune keeps it enforced: a rune that declares a type and forgets the mapping loses the entity instead of quietly joining the seven.gallery(ImageGallery) — the images are plain markdown pictures with nothing naming them;image/associatedMedianeeds each typed as anImageObject.data-table(Dataset) — no headline to supply aname, nodistributionto point at; its properties are all interaction config.budgetanditinerary(ItemList) — the children carry real data, butitemListElementneeds them typed, and neither a cost breakdown nor a day-by-day plan is a ranked list to begin with.map(Place) — a type error rather than a thin entity: a map showing three landmarks is not itself a place. The name and description belong to themap-pinchildren.blog(Blog) — this belongs to the page, not the rune. Itspostscontainer is empty at transform time andname/url/blogPostare page-level facts.symbolkeptTechArticleand gained a real mapping —namefrom the symbol's own heading,descriptionfrom the lead paragraph.- If you consume structured data from those six runes, there is now nothing where an empty type assertion used to be. Dropping the type also removes the
typeoffrom the rendered HTML, so the RDFa channel goes quiet with the JSON-LD rather than keeping a bare assertion in the markup. - Thirteen flat schema tables, and
NonProfitbecomesNGO(WORK-568, SPEC-130) figure,embed,cast-member,character,realm,faction,plot,lore,organization,pricing,tier,timeline-entryandtracknow declare their schema.org mapping as a table instead of building it inside their transform. Each reproduces its previous JSON-LD exactly, except where a change is named below. (breadcrumb-itemis the fourteenth of the group and moves with its parent, since the property that holds a child and the child's type are one declaration.)organization type="NonProfit"is nowtype="NGO". schema.org hasNGOand has never hadNonProfit. The rune's curated six-value enum validated against itself, somatcheswas satisfied and nothing noticed the value was not in the vocabulary it claimed to speak — every site writingNonProfitpublished a@typeno consumer resolves. The enum, the schema table and the generated attribute reference all sayNGOnow.- This is breaking for content.
{% organization type="NonProfit" %}no longer passes validation; change it totype="NGO". Failing loudly is the point — the old value silently published an unresolvable type. organizationalso becomes aby: 'type'table over its six rows, which is what itstypeof: attrs.typewas already doing, now written where a reviewer can read it and keyed off the same list that feedsmatches, so the accepted values and the published types cannot drift apart.pricingdeclaresoffersa list. A single-tier pricing block serialises"offers": [{…}]instead of the bare object it used to emit, so the shape no longer varies with how much content an author wrote.- Two smaller shape notes:
embed's three RDFa carriers are rebuilt from the field bag and therefore appended last, so their order in the markup changes (the JSON-LD is identical); andcast-member's portrait image gains aportraitname, and with it anrf-cast-member__portraitclass, so the schema reaches it by name rather than through a local variable. - Generate breadcrumb and timeline positions from a declared index (WORK-571)
breadcrumbandtimelineused to walk their children and push a<meta property="position">into each. Both now declaregenerated: { position: 'index' }and the applier produces it — the one generator in the vocabulary, used by exactly these two runes.positionis now a string. The generator emitsString(index + 1), so the RDFa attribute and the JSON-LD say the same thing. Before this the meta carried a number, which the JSON-LD collector passed through verbatim while the rendered markup stringified it — the two channels disagreed on every breadcrumb and every timeline. If you readpositionout of a page's JSON-LD, compare it as a string or coerce it.itemListElementis a declared list, so a one-item breadcrumb trail or a one-entry timeline emits an array rather than a bare object, aspricing'soffersalready did.{% breadcrumb auto=true %}is built from a pipeline hook rather than a Markdoc schema, so the wrapper that normally applies a rune's table cannot reach it. The hook now calls the applier itself, which means both breadcrumb paths publish from the same table instead of from a declaration and a hand-written copy that can drift apart.- Fix the changelog generator mangling wrapped bullets and tables
scripts/generate-changelog.mjsflattens each package CHANGELOG into the prose summary atsite/content/releases.md. Two shapes did not survive the flattening:- A sub-bullet that wraps had its continuation lines classified as prose, so every bullet was truncated at its first line and the orphaned tails were joined into a single sentence. v0.34.0's entry for the three validation defects reads as three unrelated half-thoughts run together, and every release back to v0.30.1 has instances of it.
- A markdown table was unwrapped into one line of pipes and dashes.
- Continuation lines now attach to the bullet above them, and tables are dropped the way fenced code blocks already are — the changelog is a flat prose summary and a table has no flat form, so the readable choice is to leave it in the package CHANGELOG and say the same thing in a sentence.
releases.mdis regenerated here, which repairs the existing entries: 68 releases unchanged, fourteen garbled lines gone, nothing else moved.- Accept
{% track %}children in{% playlist %}(WORK-569 part, BUG-016) - A
{% track %}written inside a{% playlist %}is now one of that playlist's tracks — in the listing, in the HTML, and in the structured data. It used to fall through to the greedybodyfield: rendered as an<li>outside any list, published as a detached top-level entity, and recommended by the docs the whole time. - The bar is equivalence — a nested track with no explicit
typeproduces the same schema as the same content written as a list item — asserted directly rather than assumed. An explicit child type that contradicts its container is honoured: a{% track type="song" %}inside a podcast stays a song, because the author said so. - Two defects fixed to get there:
- The sequence resolver could not handle a greedy
itemModelfield. Withgreedy, the resolved value is an array, and the extraction guarded on'type' in node— so makingtracksgreedy silently disabled its own extraction and broke the list form outright. It now concatenatesresolveListItemsacross the collected lists in document order. trackwas dropping two of its own fields.artistMetaanddurationMetawere declared but never pushed intochildren, so they were stamped onto nodes that were not in the tree: a standalone{% track artist="Radiohead" duration="4:01" %}published its name and nothing else.rootAttrswas likewise assembled and never applied, sosrcreached nothing.- If you have a
{% track %}nested inside a{% playlist %}, its rendered position and its structured data both change — the track moves into the playlist's<ol>and its entity nests under the playlist'strackproperty instead of standing alone at the top level. That is the shape the docs always described. - Declare
accordion,recipeandhow-to's retype-and-wrap (WORK-570) - The last four runes building their schema.org data by hand. Each set
typeofon nodes it did not create and hand-wrote a<div property="text">wrapper to carry the value; both are now declared, and the applier emits the wrapper. - No output changes. The rendered HTML is the same elements with the same attributes and values in the same positions — only the attribute serialisation order moves on eight of them, since the applier runs after the renderable is built rather than before. The published JSON-LD is unchanged across the whole baseline corpus.
- The wrapper stays, and there is now a test saying why. RDFa Core 1.1 §7.5 step 11 fixes a property's object to the typed resource when an element carries both
propertyandtypeof, so its own text is unreachable as a literal; deleting the inner element looks like a simplification and silently drops a triple from the RDFa while the JSON-LD keeps emitting it. - A source naming several nodes stamped only the first, so a recipe published one of its six ingredients and dropped the rest.
- Fixing that made name lookup reach into nested runes, so a character's
namebecame the character plus each of its section headings. Name resolution now stops at another rune's node, matching the flat namespace's per-rune scope. - With this, no rune anywhere passes
schemaOrgTypeor aschema:map tocreateComponentRenderable, and no transform mutatesattributes.typeof— the declarative mapping SPEC-130 set out to build is the only form left, and a test over the catalog keeps it that way.