v0.48.0 · Compositions go live

All releases

hero, cta and testimonial are now compositions: short Markdown definitions that place section and actions (and, for testimonial, pullquote), shipped in the marketing plugin in place of their own transforms and stylesheets. Every rune reference page opens with a generated header that names the rune's plugin, marks a composition, and shows the rune's source in a drawer. Any link to a drawer on the page now opens it, and pages can read $site.name.

warning

Action needed:

  • Themes: hero, cta and testimonial no longer render rf-hero*, rf-cta* or rf-testimonial* classes, and cta's data-rune is now cta. Style the section ([data-prominence="marquee"] for hero, "display" for cta) and actions they are made of, or reach a rune through [data-rune="hero"].
  • Content: hero takes align="start | center | end". Run refrakt migrate hero <paths> --apply to rewrite left, right and the deprecated justify.
  • Content: a cta inside a pricing table is no longer left-aligned for you; write align="start".

Background: · · · ·

Changes

  • Breaking: cta and testimonial are now compositions (SPEC-181, WORK-703, WORK-704). The marketing plugin ships them as runes/cta.md and runes/testimonial.md, beside hero; the native runes, their theme config and their Lumina and skeleton stylesheets are removed.
  • cta
    • No rf-cta* classes, and data-rune="cta". The native rune rendered as data-rune="call-to-action". A cta is now <div data-rune="cta"> around a .rf-section at display prominence and a .rf-actions. Style [data-prominence="display"] and the actions vocabulary, or reach it through [data-rune="cta"].
    • The call-to-action alias stays, and resolves to the composition.
    • --in-hero and --in-pricing are gone. A composed rune has no BEM block for a context modifier to extend. A cta inside a pricing table is left-aligned by writing align="start", which the cta now accepts (start | center | end).
    • Two leading headings read as a headline and body (ADR-046), where the native rune read an eyebrow and a headline. An eyebrow is a short paragraph before the headline.
  • testimonial
    • No rf-testimonial* classes. A testimonial is a section with the avatar as its media and a featured pullquote, whose <figcaption> holds the attribution (<cite> name and source). The review alias stays.
    • The Review / Person / Rating graph is unchanged, except that a comma attribution (**Name**, Role) no longer leaves the comma in jobTitle (BUG-019).
    • variant reaches the root as written. The native rune always rendered data-variant="card".
    • parseAttribution is no longer exported from @refrakt-md/marketing.
    • The fixtures for inspect moved to fixtures/cta.md and fixtures/testimonial.md; the editor snippets went with the plugin entries.
  • Breaking: hero is now a composition (SPEC-181, WORK-702). The marketing plugin ships it as runes/hero.md, a section at marquee prominence with an actions list, and the native rune, its theme config and its stylesheets are removed. It is the first rune to go live from the staging site.
    • No rf-hero* classes. A hero renders as <div data-rune="hero"> around a .rf-section and a .rf-actions. Theme CSS written against .rf-hero, .rf-hero__headline, .rf-hero__blurb, .rf-hero__actions or .rf-hero--cover no longer matches: style the section ([data-prominence="marquee"]) and actions vocabulary instead, or reach the hero through [data-rune="hero"]. packages/lumina/styles/runes/hero.css and the skeleton's hero.css are deleted. Context modifiers keyed on hero (--in-hero) still apply.
    • align takes start | center | end. left, right and the deprecated justify are no longer accepted. refrakt migrate hero <paths> --apply rewrites them on {% hero %} tags: left → start, right → end, justify → align.
    • What it reads is unchanged otherwise. Media and content split on ---; media-position, media-ratio, valign, collapse, content-place, height and aspect behave as before. A second leading heading is now kept as body instead of being dropped (BUG-047).
    • The marketing plugin declares runeDir: 'runes' and publishes runes/ and fixtures/. hero's gallery fixture moved to fixtures/hero.md; its editor snippet is gone with its plugin entry.
    • Page metadata still comes from the hero. extractSeo reads the composed hero's headline and blurb slots for the OG title and description, as it read the native hero's fields; without it a hero's eyebrow became the page description.
  • $site page variable (SPEC-187 D7). Pages can tell which site they are rendered for: $site.name is the site's key in refrakt.config.json ("main" for a single-site config). normalizeRefraktConfig tags each site with its key as name (a derived field; an authored name that differs from the key is reported), and buildPageContentVariables reads it from the site config every adapter already passes, so every adapter's pages get it with no adapter change. A content load with no site config has no $site. The new NamedSiteConfig type is exported from @refrakt-md/transform/node.
  • A link to a drawer opens it (SPEC-187 D10). Any link whose target is a drawer on the same page — [Install](#install), {% link href="#install" %}, an actions item — becomes a drawer trigger, as {% ref %} already was. The target may be the drawer's id or its #drawer-<id> anchor; a drawer wins over a heading with the same id; a link to an id no drawer has is unchanged. A trigger's text can now carry an {% icon %}.
  • Actions in a bar (SPEC-187 D11). actions inside a bar gets the rf-actions--in-bar context modifier. Lumina renders it compact, at a badge's height, and tonal rather than filled, a primary included; the skeleton keeps it a row on a phone.
  • A bar's paragraphs take no block margin in Lumina, so a group written as a paragraph lines up with the group beside it.

v0.48.0 — Compositions go live

v0.48.0complete

v0.48.0 — Compositions go live

The first composed runes replace their plugin counterparts: hero, cta and testimonial move from site/staged-runes/ into the marketing plugin (SPEC-181 D5). Beside them, every rune reference page gets a generated intro that says what the rune is and shows its source (SPEC-187).

Why

The compositions have been reviewed, and staying staged costs. Each staged rune is maintained twice: its plugin transform, config and CSS, and its composition. Hero and cta showed no difference on staging (WORK-689); the testimonial has been approved. SPEC-181 takes them live one at a time, as each review is accepted.

Nothing on a reference page says a rune is a composition. After going live, the pages look exactly as before. A composition's definition is short Markdoc a reader can learn from and copy; the intro marks it and opens it in a drawer. The same intro replaces the install hint written by hand on 45 pages.

What lands

Eight items:

  • Going live (SPEC-181):
    • WORK-702: hero, and the marketing plugin's runeDir;
    • WORK-703: cta;
    • WORK-704: testimonial.
  • The rune intro (SPEC-187):
    • WORK-705: the per-rune intro data;
    • WORK-706: the rune-intro partial;
    • WORK-709: a link to a drawer opens it, and the intro's actions are actions in a bar;
    • WORK-707: staged runes shown as staged on staging;
    • WORK-708: every rune page, the hint removed, and the guard.

Sequencing inside the milestone

  • WORK-702 first among the go-lives: it is the first runeDir load and the first go-live diff. WORK-703 follows it; WORK-704 follows it and WORK-700 (v0.47.0).
  • WORK-705, then WORK-706, then WORK-709; WORK-707 any time after WORK-706, and WORK-708 last, on the partial's final markup.
  • The two strands are independent. If the intro lands first, each go-live turns its badge on with no edit to the page.

Deliberately not here

  • steps. It stays staged: whether the composition should keep the author's heading level and read a lone paragraph as a blurb is not decided yet.
  • A composition marker in the rune catalog and in refrakt inspect / reference. They can read the same data afterwards.
  • A "customize this rune" guide, copying a composition into a project's runes.dir.

Declarative schema.org mapping

SPEC-130shipped

Declarative schema.org mapping

Move the schema.org channel from 35 imperative call sites across 30 runes into declarative data, keyed by the rune's own names — the same shape BEM, modifiers and editHints already use.

Declared on the rune, not in theme config. Schema.org output is what a rune means, and ADR-028 settles that such facts are rune identity: a theme may not redefine them. So the table sits beside the rune's other self-declarations and is referenced from config, exactly as sections is.

runesschema-orgseoconfig
// plugins/media/src/tags/playlist.ts — beside playlistSections, playlistMediaSlots
export const playlistSchema = {
  by: 'type',
  album:   { type: 'MusicAlbum',    properties: { track: 'track' },   children: { track: 'MusicRecording' } },
  podcast: { type: 'PodcastSeries', properties: { track: 'hasPart' }, children: { track: 'PodcastEpisode' } },
} as const;

Problem

A rune's structured data is written by hand inside its transform:

createComponentRenderable({ rune: 'accordion', schemaOrgType: 'FAQPage',
  schema: { mainEntity: items },
  …
});

Four consequences:

It is larger than the schema: surface suggests. The 22 schema: maps are the part that already looks like data. The rest of the channel is written straight into the tree:

FormSitesExample
schemaOrgType: on createComponentRenderable35every emitter
schema: property maps22recipe, event, track
typeof: on a hand-built Tag7testimonial's Person, event's Place
property: on a hand-built Tag16playlist's byArtist spans
node.attributes.typeof = … / .property = … mutation8recipe's <li>s, accordion's Answer

So roughly a third of the channel is invisible to a survey of schema:, and all of it is the interesting third: it is where nested entities, per-item types and computed positions are produced. A table that only replaces schema: would leave the hard half imperative and the rune half-declarative — the "not half of each" failure this spec's own acceptance criteria rule out.

It cannot be overridden. An accordion is a FAQPage on every site, in every context, forever. Rendering a list of universal attributes as accordion items (WORK-548) therefore publishes roughly 970 fabricated Question entries across 88 pages — plausible-looking structured data asserting something untrue.

It cannot be changed coherently even in principle, because the type appears at two levels. accordion declares FAQPage with mainEntity; each accordion-item independently declares Question with name / acceptedAnswer. Switching to ItemList means renaming the parent's property and changing the child's type and both of its properties. An attribute on the parent cannot reach the child's transform, so the change is not expressible without the rune carrying a per-type table — which is what this spec proposes making it.

It is invisible to tooling. refrakt contracts claims to describe the complete output of the identity transform and says nothing about schema.org; refrakt reference likewise. Nobody can answer "what structured data does this page emit?" without reading transforms.

Constraint: this cannot live in the engine

The obvious home is the identity transform engine, which already walks the tree reading config. It does not work, and the reason is worth stating plainly because it is not obvious from the architecture diagram.

packages/content/src/site.ts never applies the identity transform. It runs Markdoc.transform and then, at line 393, extractSeo — and collectJsonLd derives the JSON-LD by walking that tree for typeof attributes. The engine runs later, at render time. So schema emitted by the engine would reach the HTML and never reach the JSON-LD.

extractSeo is in fact earlier than "before the engine": it runs inside the per-page loop, before runPipeline at line 453, and page.seo is never recomputed from the enriched pages. So the harvest point precedes the whole cross-page pipeline too.

The mapping is therefore data consulted at transform time, not engine behaviour. createContentModelSchema is the place that sees every rune's own transform — it wraps each one and already post-processes the result (the tint and bg meta injection). 34 of the 35 emitters go through it, so schema application is one step there rather than 35 edits.

The one emitter it does not see

buildAutoBreadcrumb (packages/runes/src/config.ts) constructs breadcrumb and breadcrumb-item renderables from a postProcess pipeline hook, not from a Markdoc schema. Two things follow, and the second is a live defect rather than a design question:

  • A wrapper-based mechanism cannot reach it. Either the hook calls the same applier directly, or breadcrumb auto keeps a hand-written mapping and the "not half of each" criterion is already violated on day one.
  • Its structured data never reaches the JSON-LD at all today. postProcess is phase 4; extractSeo ran in phase 1. {% breadcrumb auto=true %} renders a correct BreadcrumbList in the HTML and contributes nothing to page.seo.jsonLd. Nothing tests this — no pipeline test asserts on seo.

The same blind spot covers every other sentinel resolved in postProcess (collection, pagination, aggregate, drawer) should any of them ever carry schema.

That reframes the rejected alternative below. "Relocate extractSeo" is not merely a larger change with no upside; it is the only option that closes the postProcess hole — and, as the hidden-carrier section shows, the only one that lets the rendered node carry the schema. Keeping the harvest where it is means this spec ships a mechanism with a known gap, which is defensible but must be said out loud — see the harvest decision, and the two-point invariant that makes the move testable.

Selection and application split

An earlier draft of this section was called "Nothing needs threading" and claimed the table could live wholly in createContentModelSchema, with createComponentRenderable untouched. That is wrong, and the reason decides the shape of the mechanism.

createComponentRenderable classifies each property <meta> by consulting schema: at build time:

const isSeoMeta = n.name === 'meta' && schemaTags.has(n);
if (!isSeoMeta) n.attributes['data-field'] = toKebabCase(k);
…
if (!isSeoMeta) pureDataMetas.add(n);
…
.filter(c => !emptySeoMetas.has(c) && !pureDataMetas.has(c))

A property meta that is not named in schema: gets data-field and is dropped from the children. The ones that survive do so precisely because the rune declares them — visible in today's output:

<meta content="Pink Floyd" property="byArtist" />   <!-- playlist -->
<meta content="PT15M" property="prepTime" />        <!-- recipe -->

So if the call sites "stop passing schema entirely" and the table is applied afterwards in the wrapper, those nodes no longer exist by the time the applier runs. There is nothing left to stamp.

This is not a corner case. It hits every schema value that is a <meta> also listed in properties — at least 8 runes and ~16 properties: recipe (3), event (3), track (5), playlist, character, realm, lore, howto. That is almost exactly the "computed metas — declarative by reference" row of the survey, the half this spec called easy. The reference only holds while the rune declares it at build time. (embed escapes because its schema metas are not in properties; non-meta nodes escape because only metas are dropped.)

The fix is the field bag, and it is simpler than it looked. A prototype (below) settled this. data-rune-fields already carries every scalar property value, typed, on the rune root — which is exactly what a value-only schema property needs. The applier does not need the dropped <meta> to survive; it rebuilds a carrier from the bag:

const node = findByName(root, src);
if (node) { node.attributes.property = prop; return; }   // visible carrier: stamp in place
const v = bag[src];                                       // value-only: rebuild
if (v !== undefined) root.children.push(new Tag('meta', { property: prop, content: String(v) }));

So createComponentRenderable needs no knowledge of schema at all — it simply always treats property metas as pure data, which is already what it does when schema: is absent. Selection needs attrs and application needs the tree plus the bag; the wrapper has all three. Nothing is threaded into createComponentRenderable after all.

An earlier revision of this section proposed threading the resolved row down one level, on the assumption that a dropped node was unrecoverable. It is not — the bag is the recovery. Recorded because the reasoning ("the meta is gone, therefore the applier must run where the meta still exists") is plausible and wrong, and someone will re-derive it.

So the table is still an option to createContentModelSchema, sitting with the rune-identity declarations already there — sections, mediaSlots, provides, base — and the resolved row is threaded one level down:

export const playlist = createContentModelSchema({
  sections: playlistSections,
  mediaSlots: playlistMediaSlots,
  schema: playlistSchema,        // ← beside its siblings
  attributes: { … },
  transform(resolved, attrs, config) { … },
});

Since ADR-028 rules out merging, there is no merged view to assemble and no config.variables threading is required — the table is a module constant in lexical scope, and contracts / reference read it the way they already read sections. The threading is one resolved row, passed one level, not a table plumbed through the pipeline.

The 35 call sites still get simpler: they stop hand-writing schemaOrgType and the schema map, and carry a single opaque value they do not construct.

The child mapping works because Markdoc transforms bottom-up. By the time the wrapper sees result, the children carry their own typeof, so it rewrites them — the move stripSchemaOrg already makes for schema="none" (WORK-552).

Relocating extractSeo is the alternative, and it is really two alternatives that must not be conflated: moving it after the pipeline and moving it after the engine differ by an order of magnitude. Decided below: the first is in scope, the second is ruled out.

Ownership: the rune, not the theme

An earlier draft of this spec put the table in ThemeConfig.runes alongside block and structure, and listed "a site can restate a rune's schema without forking it" as a benefit. That is an anti-feature, and ADR-028 had already decided against it:

Attribute applicability is a property of the rune, never of the theme.

Content becomes portable in fact, not just in principle. Today the same markdown can mean different things under different themes.

Emission stays theme-agnostic, and no mechanism will be added for a theme to suppress it.

Schema.org is the sharpest case that reasoning covers. It is a machine-readable public claim about what the content is; a theme able to change it would mean the same markdown says different things to a search engine depending on how the site is skinned. Styling is the theme's business — emission is not.

So schema follows the pattern already established for sections:

// SPEC-125 Phase 2 — join tables the rune declares about itself. Referenced
// from the theme config rather than owned by it: a theme may not redefine
// what a section *is* (ADR-028).
export const accordionSections = { … } as const;

Declared on the rune, referenced from config so tooling can read it, and added to IDENTITY_FIELDS in packages/transform/src/identity-fields.ts so no merge path can redefine it.

The legitimate per-site need is already served, at the right layer. An author who wants different structured data says so in content — by the attribute the rune already derives from, or schema="none" — where they are making a claim about their own content. A theme making that claim on their behalf, for every page, is the thing being ruled out.

Key direction: the rune's name, not the schema property

The table is keyed by a name in the rune's own flat namespace and valued by the schema.org property:

properties: { 'author-name': 'name', 'author-role': 'jobTitle' }

"Flat namespace" is ADR-008's term and is the precise one here: properties and refs share a single namespace with uniqueness enforced, so a key names exactly one node. Which HTML attribute that name surfaces as — data-name for a refs entry, data-field for a properties entry — is a consequence of which map the rune put it in, not a second key space. The applier resolves both, e.g. playlist's track items are addressed as track and carry data-field="track".

That asymmetry is worth a glance but not a fix here: the track <li>s are structural children parked in properties (the "content-marker property" idiom createComponentRenderable documents, shared with budget's category, accordion's item, itinerary's day), so they get data-field and no BEM element class, while their container — a refs entry — gets both. Moving those to refs would be a genuine cleanup and would give the items the .rf-playlist__track class they arguably want, but it changes HTML output across several runes for reasons unrelated to schema.org. Out of scope here.

This is deliberate, and it is the opposite of the imperative schema: map it replaces (schema: { reviewBody: quoteTag } — schema property as key). The flip is worth stating because anyone who has read the 22 existing maps will read the new form as backwards.

It goes this way because every declarative join table in the codebase does:

TableKeyValue
sectionsheadlinetitle
mediaSlotsmediacover
editHintsheadlineinline
autoLabelsummarysummary
contextModifiersparent-runesuffix

All keyed by what the rune has, valued by what it means — which is what SPEC-125 means by "join tables the rune declares about itself". The imperative schema: map is the odd one out, and it is the one going away.

One invariant reinforces it: ADR-008 gives properties and refs a single flat namespace with uniqueness enforced (createComponentRenderable throws on a collision), so these keys are guaranteed unique. Schema property names carry no such guarantee, and keying by them would forbid two sources mapping to one property — recipeIngredient drawn from two places, say. Neither multiplicity is needed today, but only one direction has the uniqueness already enforced.

The cost is real and is accepted: curating a table ("is PodcastEpisode right for PodcastSeries?") means scanning schema.org names, which now sit in the value column. The mitigation is that inspect and reference render the resolved table, and can order it by schema property regardless of how it was authored — the review view need not match the authoring view.

What the table has to express

Surveyed across all 30 emitting runes — not just the 22 schema: maps — they fall into three groups, and only the middle one matches the design as sketched above.

Group A — a type and nothing else (7 runes)

gallery, data-table, budget, itinerary, map, symbol, blog declare a schemaOrgType and no schema: map at all. What reaches the page is an empty entity:

{ "@context": "https://schema.org", "@type": "Dataset" }
{ "@context": "https://schema.org", "@type": "ItemList" }

Checked by running extractSeo directly over gallery, data-table, budget, itinerary and map; symbol and blog carry no schema: map either, so nothing stamps a property inside them. These assert a type and describe nothing — a Dataset with no name, description or distribution; an ItemList with no itemListElement. They are not wrong the way a podcast typed as a music playlist is wrong, but they are pure noise in every consumer.

The table forces the question the imperative form let us skip: should an entity with no properties be emitted at all? Proposed rule — no. A row that resolves to a bare @type emits nothing, and a rune that wants an entity declares at least one property. That deletes seven meaningless entities across the catalog for free, and gives Group A a migration that is "write the mapping or lose the type", which is the right forcing function.

Group B — a flat map of named refs (14 runes)

figure, embed, cast-member, character, realm, faction, plot, lore, organization, pricing, tier, timeline-entry, track, breadcrumb-item. These are the clean case the original survey describes: the values are either named refs (nameTag, titleTag, tiers) or computed metas (parsedPriceMeta, estimatedTimeMeta) that already carry data-name / data-field. The transform computes values, the table names their schema roles, and nothing has to be computed by the table.

Group C — imperative construction (9 runes)

accordion, accordion-item, breadcrumb, event, how-to, playlist, recipe, testimonial, timeline. Here the schema is not a map over existing nodes; the transform builds schema-bearing structure. Four distinct shapes, none of which the table as first sketched expresses — though, as the next section shows, what each one actually needs differs enormously:

ShapeWhereWhat it does
Retype + wrapaccordion-item, recipe, how-tosets typeof on a node it did not create and wraps its children in a <div property="text"> / <p property="text"> to supply the value
Synthesise an entitytestimonial, eventbuilds a Person / Rating / Place span from text pulled out of other tags (authorNameTag.children.filter(…)) or from an attribute (attrs.location)
Index-derived valuesbreadcrumb, timelinethe parent emits each child's position from the loop index — a value that exists nowhere in the content
Inline property stampsplaylistwrites property: 'byArtist', property: 'duration' directly onto the spans it builds for each track

What Group C actually costs

The obvious reading is "these nodes are unaddressable, so give everything a data-name first". That is the wrong diagnosis, and acting on it would be mostly wasted work. Taken shape by shape:

Inline property stamps — nothing missing. playlist's track spans already carry data-name="track-name" / "track-artist" / "track-duration", set on the same line that stamps the RDFa. They are addressable today; they are simply not keyed declaratively. Pure lookup once the table exists.

Retype + wrap — a data-name adds nothing, and the wrapper has to stay. recipe's <li>s already get data-name = 'ingredient' / 'step' on the line above the mutation, so addressing is not the obstacle. The wrapper is — and the tempting move is to delete it by teaching collectJsonLd to take a typed node's own text as a named property:

// seo.ts — collectProperties
if (prop && !childTypeof) { /* extract value */ }

That would break the HTML. The rule mirrors RDFa rather than deviating from it. RDFa Core 1.1 §7.5 step 11 resolves a property's object as: @content → literal; else @typeof present and @about absent → the typed resource; else a plain literal from the text. So an element carrying both property and typeof has its object fixed to the typed resource, and its text is unreachable as a literal. Today's output —

<div typeof="Answer" property="acceptedAnswer"><div property="text">…</div></div>

— expresses _:q acceptedAnswer _:a . _:a a Answer . _:a text "…". Without the inner element it expresses the first two triples and says nothing about the text. Since SPEC-082 chose to render the SEO carriers inline ("Option B" — component.ts:108), refrakt publishes the RDFa and the JSON-LD on the same page, so the two would assert different graphs on every accordion, recipe and how-to.

The wrapper is therefore the conformant idiom, not a workaround. What is wrong is that runes hand-write it. Move the wrapping into the applier and let the table declare only the role:

accordionItem: {
  type: 'Question',
  properties: { name: 'name' },
  entities: {
    body: { type: 'Answer', property: 'acceptedAnswer', text: 'text' },
  },
}

text: names the property that takes the node's own content; the applier emits the conformant wrapper, the way the engine's structure entries already own injected elements. That keeps the table a lookup, keeps the HTML valid, and collapses into the entities: form below rather than being a fifth concept — an Answer is a nested entity built from the body node.

Note the collector is otherwise a fair RDFa subset: property + href on an <a>, property + src on an <img>, and <meta property content> all resolve as RDFa 1.1 specifies. The one rule worth changing was the one holding conformance up.

The other three shapes are already conformant, checked against refrakt inspect output so the question does not get re-opened:

<li typeof="MusicRecording" data-field="track" property="track">  <!-- typed resource held by a property -->
<span typeof="Person" property="author"><meta property="name" …>  <!-- same, with inner literals -->
<meta content="1" property="position" />                          <!-- plain literal -->

So wrapping was the only place where the imperative form was load-bearing for the standard rather than merely habitual. The rest of Group C is imperative by accretion, and can be moved into the table without touching what the HTML asserts.

Index-derived values — one keyword. position: 'index'. A value that is not in the content has to be generated, and no amount of naming helps. The vocabulary is closed: index is the only generator anywhere in the codebase, used by two runes.

Synthesised entities — one new entry kind, and still not about naming. Naming the invented <span typeof="Person"> is pointless, because under a declarative table the applier is what creates it. What matters is whether the sources are addressable, and they already are — testimonial has author-name / author-role refs and a rating property; event has location as a data-field before the Place span duplicates the value. So the gap is a third kind of entry — group these named nodes into a nested entity:

entities: {
  author: { type: 'Person', property: 'author',
            properties: { 'author-name': 'name', 'author-role': 'jobTitle' } },
  rating: { type: 'Rating', property: 'reviewRating',
            properties: { rating: 'ratingValue' } },
}

This is an extension rather than a new concept: properties and refs already project over the same nodes twice (tier's nameTag is in both). entities is a third projection.

Total: one new entry kind (entities, carrying an optional text), one keyword (index), no change to collectJsonLd, and no new data-names for these runes — though the prototype later found a separate set that does need naming, cutting across all three groups. Group C is not the hard half of this spec; it is four small, separable decisions, and only one of them touches the table's shape.

Constraint: styling already selects on this channel

Surfaced while checking whether the property="text" wrappers were safe to delete. Lumina selects on RDFa attributes in six places:

SelectorStateWhy
.rf-tier h1[property="name"]livenameTag is in both refs and schema
.rf-plot > span[property="name"]livetitleTag is in both
.rf-tier p[property="price"]deadproperty="price" lands on parsedPriceMeta, a <meta> — not on the <p>
.rf-lore > span[property="title"]deadthe span carries property="headline"
.rf-bond > span[property="from"]deadbond has no schema: map at all
.rf-bond > span[property="to"]deadas above

Confirmed against refrakt inspect output for each rune, and none of the four dead rules has a BEM equivalent elsewhere in its stylesheet — so a lore title, a bond endpoint and a tier price render unstyled today. The CSS coverage test does not catch it: it checks that config-derived selectors exist, not that hand-written ones match.

Two consequences this spec has to absorb:

  • schema="none" is a styling hazard. stripSchemaOrg deletes property wholesale, so on pricing it would unstyle the tier heading. It is benign on accordion by luck rather than design, and WORK-552 has already shipped.
  • Any rename in the table silently restyles a rune. Every live selector is a tripwire across the migration, and the four dead ones are what that tripwire looks like after it fires — nobody noticed either time.

So the rule this spec should establish: presentation never selects on the schema.org channel. The refs already produce BEM element classes for every one of these nodes (.rf-tier__name, .rf-lore__title), which is what the CSS should have used. Migrating the six rules is small and half of it is a bug fix regardless of whether this spec ships — filed as BUG-015. Guarding it afterwards is a one-line assertion in the CSS coverage test.

This also sharpens the ADR-028 argument. The reason a theme may not redefine schema.org is that emission is a claim about content, not about skin. A theme that styles off emission has the same coupling pointed the other way: it makes the rune's appearance depend on its SEO channel, so a correction to the structured data becomes a visual regression.

Adjacent cleanup, moved out: property: 'contentSection'

An earlier revision of this spec scoped in the removal of property: 'contentSection' — 29 transforms passing a value that becomes data-field="content-section" and that nothing reads. It rode the same call-site sweep, so folding it in looked free.

It has moved to SPEC-133. The reason is that it is not the schema.org channel: createComponentRenderable maps the property field to data-field, not to the RDFa property attribute, and the thing it is an instance of is the six-way overload of data-field that SPEC-133 exists to resolve. Two different things called property in one call is a naming problem, not an emission one.

What stays here is only the consequence for this spec: the applier addresses nodes by a name in the rune's flat namespace, so whichever way SPEC-133 settles properties versus refs, the table keys do not change — see "Key direction" above.

The driving case: playlist

accordion alone would have produced a weaker design. playlist is the rune that shows what the table actually has to express, because it already declares what its content is and emits the wrong schema anyway:

type: { matches: ['album', 'podcast', 'audiobook', 'series', 'mix'] }  // line 28
…
schemaOrgType: 'MusicPlaylist'                                          // line 237, unconditional
schema: { name, image, byArtist, track: trackItems }
const trackAttrs = { typeof: 'MusicRecording' };                        // line 153, unconditional

So {% playlist type="podcast" %} publishes a podcast as a music playlist whose episodes are music recordings. Silently, on every podcast. Filed separately as BUG-013, since it is wrong today whenever this spec lands.

The correct mapping needs both kinds of change at once:

typeschema.orgItemsTrack propertyRelation to MusicPlaylist
albumMusicAlbumMusicRecordingtracksubtype — safe narrowing
mixMusicPlaylistMusicRecordingtracktoday's emitted type, for every row
podcastPodcastSeriesPodcastEpisodehasPartbranch switch
audiobookAudiobookChapterhasPartbranch switch
seriesCreativeWorkSeriesCreativeWorkhasPartbranch switch

Note that even the default is imprecise: album is a MusicAlbum, a subtype of MusicPlaylist, so the safe narrowing is already available and unused. The attribute's own default is album (attrs.type ?? 'album', line 98), not mix — so a table keyed on type needs an explicit fallback row for the absent case, and by: has to say whether it reads the attribute's default or requires the row named by the modifier's own default.

Consequence: schema keys off a modifier, not a new attribute

type and a schema attribute would be the same fact stated twice, and an author who disagreed with themselves would get no warning. So the mapping keys off the modifier the rune already has:

Playlist: {
  schema: {
    by: 'type',
    album:   { type: 'MusicAlbum',    properties: { track: 'track' },   children: { track: 'MusicRecording' } },
    podcast: { type: 'PodcastSeries', properties: { track: 'hasPart' }, children: { track: 'PodcastEpisode' } },
    …
  },
}

by names an existing entry in the rune's modifiers — itself an identity field under ADR-028 — so this reuses the mechanism that already drives BEM modifiers and data attributes rather than inventing a parallel one.

One wrinkle to settle: modifiers live in config, and the row is picked in the wrapper from attrs. modifiers: { type: { source: 'meta', default } } is read by the engine, which has no part in this path. So by: 'type' is in practice "the attribute named type", and the claim that it reuses the modifier mechanism is presentational unless something validates that the key really is a declared modifier. Either validate it (cheap — both declarations are in scope at build time) or say plainly that by names an attribute.

The child mapping has to remap properties, not just the type

children: { track: 'PodcastEpisode' } changes the item's @type and nothing else. But the item's properties are stamped inline, and they are MusicRecording properties. Today's actual output for type="podcast":

{ "@type": "MusicPlaylist", "name": "The Sunday Show", "byArtist": "Acme",
  "track": [ { "@type": "MusicRecording", "name": "Episode One",
               "byArtist": "Acme", "duration": "PT1800S" } ] }

Retyping the item to PodcastEpisode leaves byArtist on it — a property PodcastEpisode does not have. Worse than MusicRecording, which at least was coherently wrong. So a child row is a type plus a property map, the same shape as a parent row, and the design should show it that way:

podcast: {
  type: 'PodcastSeries',
  properties: { track: 'hasPart' },
  children: {
    track: {
      type: 'PodcastEpisode',
      properties: { 'track-name': 'name', 'track-duration': 'duration' },
      // `track-artist` is dropped: PodcastSeries carries the publisher, not the item
    },
  },
}

Which also means those inline spans must be reachable by name. They already carry data-name="track-name" / "track-artist" / "track-duration" — so playlist is addressable today, and it is the Group C rune that needs the least prep. That is a point in favour of it as the driving case.

That settles the layering:

  • derive — a content attribute picks the schema, because the author has already said what the thing is
  • suppress — schema="none", orthogonal (WORK-552)
  • override — schema="<Type>" was the third layer in an earlier draft. Deferred: with every multi-type rune carrying a table, it has no demonstrated use, and dropping it is what lets D5 dispense with validation. See D3.

Two emitters, one mapping

MusicRecording is stamped in two places: playlist's own <li> items (line 153) and the standalone track rune. Whether a per-type child mapping has to reach both was left open here as a design question this spec "must answer, not gloss".

Answered in D9 below, and the premise was wrong. The two emitters never populate the same collection: playlist builds its items exclusively from markdown-list itemModel data, and a {% track %} inside a {% playlist %} is not one of its tracks at all (BUG-016). So there is no shared mapping to keep in step, contextProperties is not required, and the per-type child-mapping criterion stands as written.

A related structural fact the table compiles down to: collectJsonLd nests a child entity only when the same node carries both typeof and property. Anything else typed floats up as a detached top-level entity — a {% figure %} inside an {% accordion %} currently emits a sibling ImageObject related to nothing. So "declare the child's type" and "declare the property that holds it" are one operation, and the table must not let a rune do the first without the second.

Curation, not validation

These mappings are hand-written per rune. Nothing checks that PodcastEpisode is really the right item type for PodcastSeries — refrakt ships no schema.org ontology and this spec does not propose adding one. At five rows per rune that is the right trade, but it means the table is a human judgement recorded in config, and should be reviewed as such.

What it unlocks

A safe type override. The per-type table makes schema="ItemList" express the parent rename and the child's type change together — the thing that is not expressible today.

Contextual schema. A child rune could declare what it means inside a given parent, mirroring the existing contextModifiers: { 'parent-rune': 'suffix' }:

Accordion: { schema: { …, contextProperties: { recipe: 'recipeInstructions' } } }

Worth noting this case is already expressible imperatively — a parent can map a child's tags into its own schema map with a cursor. What config adds is letting the child declare it, so the parent needs no knowledge of every rune that might appear inside it.

Deferred by D9, which found the case that motivated it (playlist / track) does not need it and could not use it: at transform time a child cannot see its parent, and a parent that wants a child in its graph must stamp property on it anyway. Kept here as a possible future, not a requirement of this spec.

Tooling. contracts and reference can describe the structured data, which closes a real gap in what contracts claims to cover. Both read config, so the table has to be reachable from config — which it is, by the sections pattern.

refrakt inspect belongs on that list too, and is the one that matters most in practice: it is the per-rune tool an author actually reaches for, and every claim in this spec was verified with it. Today it shows the RDFa only incidentally, as raw HTML in the rendered output. It should show the resolved schema row — the type, the property mapping, the child mappings — the way it already shows BEM classes and data attributes, and --audit should be able to report a rune whose table names a data-name the rune does not emit.

One declaration instead of two. defineRune({ schemaOrgType }) already carries a rune's type in the catalog — packages/runes/src/index.ts, nine entries. Nothing reads Rune.schemaOrgType: it is assigned in rune.ts:59 and never consumed, by reference, contracts, the language server or anything else. It has also drifted — nine entries for thirty emitting runes, and Accordion: 'FAQPage' is no longer unconditional since WORK-552. The table should replace it and the field should be deleted, or the second stale declaration outlives the first.

Prototype: the entities: shape, measured

entities: carries the whole of shape 2 and had only been sketched, so it was built throwaway against two runes — testimonial (sources are visible nodes) and event (a source that exists only as an attribute value). Both were rewritten to drop schemaOrgType, the schema: map and the hand-built entity spans, declaring instead:

export const testimonialSchema = {
  type: 'Review',
  properties: { quote: 'reviewBody' },
  entities: {
    author: { type: 'Person', property: 'author',
              properties: { 'author-name': 'name', 'author-role': 'jobTitle' } },
    rating: { type: 'Rating', property: 'reviewRating',
              properties: { rating: 'ratingValue' } },
  },
} as const;

Both reproduced today's JSON-LD. testimonial byte-identical (including its existing jobTitle: ", CTO at Acme" comma defect — the prototype was checked for equivalence, not improvement); event identical under the normalised comparison, differing only in key order because rebuilt carriers are appended last. The applier is about 60 lines and needs three resolution strategies: stamp a named node in place, rebuild a value-only carrier from the bag, or synthesise an entity span from either.

testimonial's rating is the case that mattered: its meta is dropped by createComponentRenderable once the rune stops declaring schema:, and the applier recovered the value from the bag. That is what settled the section above.

What the prototype found: unaddressable sources

A schema source that is in neither properties nor refs is reachable by neither route — not in the bag, and carrying no data-name. Two kinds recur:

KindRunesSources
Metas built only for SEOembed, tiertitleMeta, urlMeta, embedUrlMeta; parsedPriceMeta, resolvedCurrencyMeta
Image nodesfigure, recipe, playlist, realm, factionimgs[0], seoImage, sceneImgTag — the node itself is anonymous; only its wrapper is named

Roughly ten sources across seven runes. Each needs either a refs entry (giving it a data-name) or a properties entry (putting it in the bag) before the table can address it — small and mechanical, but it must happen before those runes migrate.

This also corrects an earlier claim in this spec that Group C needed "zero new data-names". That holds for the runes Group C named; it does not hold for this set, which cuts across all three groups and was invisible until the mechanism was built.

What the prototype did not cover

by: row selection from attrs, per-type children: mappings, and the text: carrier form are all still sketches. playlist exercises all three at once and is the natural second prototype if one is wanted.

Harvest at both points, and assert they agree

The spec has been treating the harvest point as a decision — keep extractSeo where it is, or move it. It is better as an invariant: harvest at both points and require the two graphs to match. Drift then fails a test instead of shipping.

It holds today. Measured by running collectJsonLd over the pre-engine tree and over the same tree after createTransform, across 15 runes spanning core and five plugins:

ResultRunes
Identicalaccordion, breadcrumb, figure, embed, gallery, budget, track, testimonial, pricing, timeline, cast
Key order onlyrecipe, howto, playlist, event
Semantic driftnone

So the engine is schema-preserving, and the test goes green on day one. It is a regression net, not a migration.

The comparison has to sort object keys and preserve array order. The four order-only cases are the runes whose metas the engine relocates to the end of the block, so collectProperties meets them later — a difference with no meaning in JSON-LD. Array order is the opposite: itemListElement, step, track and recipeInstructions are ordered sequences, and a breadcrumb whose items reversed is a real defect. A naive JSON.stringify comparison fails four runes for nothing; a normalise-keys-keep-arrays comparison fails only on substance.

This subsumes the RDFa/JSON-LD question. The shipped JSON-LD is harvested pre-engine and the shipped HTML is the post-engine tree, so "both harvests agree" is exactly "the RDFa in the published HTML expresses the same graph as the published JSON-LD" — the invariant this spec was otherwise going to assert by hand. With one caveat worth recording: collectJsonLd is an RDFa subset (no @about, @resource, @vocab, prefixes or @rel), so it proves our reader sees the same graph at both points, not that a conformant processor would. Upgrading to a real distiller over the rendered HTML is a later option, not a prerequisite.

It also makes relocation safe rather than brave. Every argument for moving extractSeo — the postProcess hole, and stamping the rendered node instead of a hidden <meta> (below) — becomes a change with a net under it instead of a leap. And the net is not passive: run at page level, through the real pipeline, the invariant fails today on a page using {% breadcrumb auto=true %}, because the post-engine harvest sees a BreadcrumbList the pre-engine one never had. That is the known bug surfacing itself, which is the behaviour wanted.

Hence a scope choice: rune-level (fixtures, cheap, catches engine drift) or page-level (through runPipeline, catches the postProcess class too). Both, ideally — they are the same assertion at two granularities.

The hidden carrier, and what relocation would buy

A value-only schema property is emitted twice, and neither copy is the one the reader sees. recipe's rendered output:

<article typeof="Recipe" … data-prep-time="PT15M">
  …
  <dl data-name="metadata">
    <div data-name="row" data-field="prepTime"><dt>Prep</dt><dd data-meta-type="temporal">15m</dd></div>
  …
  <meta content="PT15M" property="prepTime" />   <!-- trailing, after the content div -->
</article>

PT15M appears three times: the fields bag on the root, the rendered <dd>, and a hidden <meta>. The RDFa is correct — the meta is inside the Recipe scope, so the triples are right — but the node a human reads carries no schema, and the node carrying the schema is invisible.

The cause is the same seam as everything else here, inverted. The transform stamps a <meta> because at transform time the <dd> does not exist — the engine builds that <dl> later from data-rune-fields. Not "the engine cannot emit schema" but its mirror: the engine's own output cannot carry schema.

RDFa has the right idiom for a human/machine value split — @content overriding the element text:

<dd property="prepTime" content="PT15M" data-meta-type="temporal">15m</dd>

One node instead of two. But the engine builds that <dd>, so stamping it means stamping at engine time, which today's pre-engine harvest would never see — you would need the meta and the stamp, worse than now.

This was a second argument for relocating extractSeo past the engine. It is not enough — see the decision below. The hidden metas stay, and the table simply says which kind of carrier a row wants, defaulting to the hidden <meta>: today's behaviour, made explicit rather than incidental.

Decision: move the harvest after the pipeline, not after the engine

The two relocations differ by an order of magnitude, and the difference is structural rather than a matter of effort.

After the pipeline — in scope

Recompute seo from enrichedPages once runPipeline returns, inside loadSite. One change in one place: extractSeo is pure, the pages are in hand, and it stays where content loading already lives — framework-agnostic, before any adapter sees anything. It closes the postProcess hole completely.

After the engine — ruled out

There is no post-engine seam to move to. The engine is not invoked at one place in the framework; in SvelteKit it runs in the site's own load function:

// site/src/routes/[...slug]/+page.server.ts
const renderable = hl(transform(serialized));   // ← the engine, line 29
…
seo: page.seo,                                   // ← line 41

Three lines apart, in code the user owns. Eleventy differs again — it passes page.renderable straight to renderPage and reads page.seo off the load result, never calling createTransform itself.

So a post-engine harvest is not one relocation but N, several of them in user-land site code. The pre-engine harvest exists because it is the last framework-agnostic point — which is what this spec's earlier "content loading is framework-agnostic" line was gesturing at, and it is a structural fact rather than a preference.

What that forecloses is only the rendered-node carrier, and that is cosmetic: the RDFa is correct either way, both channels carry the right triples, and the cost is three hidden elements per rune. Not worth N adapter changes plus churn in every site's load function.

Detection survives the decision. The two-point invariant runs in tests, where engine output is available in a harness, so drift is caught without production harvesting moving at all.

Two sub-decisions inside the move

  • extractSeo returns og metadata as well as JSON-LD. Recomputing after the pipeline re-derives og from the enriched tree, and extractOgMeta reads hero / first h1 / first paragraph / first image — all of which postProcess can touch (collection and pagination sentinels inject content). Recompute both and let the baseline show what moves; recomputing only jsonLd would leave og deriving from a tree that no longer matches the page.
  • This is an output change, by design. Pages using {% breadcrumb auto=true %} gain a BreadcrumbList they do not publish today. That is the fix, but it should land as a visible diff against the baseline rather than as a silent side effect — another reason the baseline snapshot sequences first.

Before anything moves: the baseline

This migration rewrites structured data across 30 runes in 10 packages, and the output is a thing nobody looks at directly — a page renders identically whether its JSON-LD is right or ruined. Every regression in this spec's history was found by running extractSeo, not by reading a transform, and four dead CSS selectors survived years of review precisely because nothing asserted them.

So the first work item is a baseline snapshot of today's JSON-LD for every emitting rune, before a single call site changes. Per-rune fixtures through extractSeo, committed. Then each migration step is reviewed as a diff against it: playlist type="podcast" should change, recipe should not, and the seven Group A entities disappear in exactly one commit.

The existing coverage is not that. packages/runes/test/seo.test.ts and the per-plugin seo.test.ts files assert a handful of runes — accordion, breadcrumb, playlist — with hand-written expectations, and several of the runes this spec changes have no JSON-LD test at all. Cheap to build now while the current behaviour is the reference; impossible to reconstruct once the sweep has started.

Decisions

Everything below was an open question. None needs more research; each is recorded with the reasoning so it is not re-litigated.

D1 — schema="none" stays a value, not a second attribute

The vocabulary is "a schema.org type, or none", which is a union of two kinds of thing and reads fine anyway. The alternative — a separate suppression attribute — splits one concern across two spellings, which is worse. It has also shipped (WORK-552), so the cost of changing it is real and the benefit is cosmetic. Document the vocabulary; change nothing.

D2 — organization is the precedent, not the inconsistency

A correction to earlier revisions of this spec, which twice described organization's type= as free-text and unvalidated. It is neither:

const orgType = ['Organization', 'LocalBusiness', 'Corporation',
                 'EducationalOrganization', 'GovernmentOrganization', 'NonProfit'] as const;
type: { type: String, required: false, matches: orgType.slice(), … }

A curated six-value enum, enforced by Markdoc. So organization is already doing exactly what this spec proposes — deriving a schema.org type from a content attribute the author has already set — just expressed imperatively. It converts to a by: 'type' table directly, and its rows are the simplest possible kind: a type and nothing else, since all six share Organization's properties.

And its list contains a type that does not exist. schema.org has NGO; there is no NonProfit. The enum validates against itself, so matches is satisfied and nothing notices that the value is not in the vocabulary it claims to speak. Correcting it to NGO belongs with this conversion.

That finding is the argument for the whole spec in miniature: the mapping was already a curated table, it was already wrong, and it was wrong somewhere no tool could look. Making it data that inspect and contracts can print is what turns "someone would have to notice" into "someone can review it".

D3 — a universal schema="<Type>" override is deferred

With per-type tables everywhere and no free-text path (D2), the layering this spec described reduces to two live mechanisms: derive (a content attribute picks the row) and suppress (schema="none"). The third, override, has no demonstrated use once the default is derived — nobody has asked for it, and it is the only one that lets an author assert a type the rune has no mapping for.

Deferring it is also what makes D5 hold. Ship derive and suppress; add override if a real case appears.

D4 — an entity with no properties is not emitted

A row that resolves to a bare @type emits nothing. Seven runes change — gallery, data-table, budget, itinerary, map, symbol, blog — each of which currently publishes {"@context": …, "@type": "Dataset"} and no more. A type assertion with nothing attached tells a consumer nothing; it is noise in every channel that reads it.

This gives Group A a migration with a forcing function — write a mapping or lose the type — rather than a silent carry-forward of seven meaningless entities.

D5 — no validation mechanism; review is the control

Narrowing to a subtype is always safe; switching branches is not. With every multi-type rune carrying a curated table (D2) and no override (D3), a rune only ever emits a type it has a row for, so the unsafe case stops being expressible. No validation mechanism is needed.

But "no mechanism" is not "no risk", and D2 is the proof. Nothing checks a table against schema.org, because refrakt ships no ontology and this spec does not propose adding one. What replaces validation is visibility: inspect and contracts print the resolved table, so a wrong row is reviewable. That is a weaker guarantee than validation and should be described as such rather than implied away.

D6 — declared list properties always serialise as arrays

appendToProperty stores the first value as a scalar and only promotes on the second, so a one-track playlist emits "track": { … } and a two-track one emits "track": [ … ] — the shape of the output varying with the amount of content. A row may declare a property a list; those always emit an array.

Cheap while the shape is being designed, awkward to retrofit. It changes output for every single-item collection, so it lands as a visible diff against the baseline.

D7 — the table is part of the public plugin contract

It has to be: 67 of the runes live in plugins, and a plugin rune must declare its schema the way a core rune does. That extends "curation, not validation" (D5) to third parties, who would be asserting schema.org types on their users' pages with no ontology to check them against and no theme-level suppression (by ADR-028, and rightly).

Accepted. It is the same trust already extended to any plugin's transform code, which can emit arbitrary typeof today, and schema="none" gives the author an out. D2 shows the risk is not hypothetical even in first-party code — which is an argument for inspect --audit covering plugin runes, not for withholding the mechanism.

D8 — the postProcess hook calls the applier

buildAutoBreadcrumb builds its renderables outside any Markdoc schema, so the wrapper cannot reach it. It is one call site: the hook applies the resolved row directly. That keeps "not half of each" exceptionless rather than carving out the one emitter that happens to live in a pipeline phase.

The position divergence folds in here — breadcrumb emits <meta property="position" content="1">, a string in RDFa against a number in the JSON-LD. Emit String(index + 1) so both channels agree, rather than teaching the invariant to tolerate a mismatch.

D9 — no contextProperties; each emitter keys off its own attribute

The "two emitters, one mapping" section above called this a question the spec must answer. Answered here, against the built packages rather than by reading the transforms — and the premise it rested on is false.

The two emitters never populate the same collection. playlist builds its track items exclusively from markdown-list itemModel data (playlist.ts:121-155). Its content model has no field matching a track tag, so a {% track %} inside a {% playlist %} falls through to the greedy body field: it renders as a bare <li> in the prose body, outside the <ol data-name="tracks">, and its JSON-LD floats up as a detached top-level MusicRecording. Filed as BUG-016 — it is wrong today, and the docs recommend it.

So the feared mix — "a podcast's inline items become PodcastEpisode while {% track %} children stay MusicRecording" — cannot arise, because a {% track %} was never among the playlist's children in the first place.

Each emitter already declares its own type. track carries its own five-value enum — song | episode | chapter | talk | video (track.ts:15) — and emits MusicRecording for all five, exactly as playlist ignores its own type. The same defect, in a second rune, independently fixable. So both convert to by: 'type' over their own attribute:

Runeby:Rows
playlistits typealbum, mix, podcast, audiobook, series — plus the child mapping for its transform-built items
trackits typesong, episode, chapter, talk, video

Two tables, no threading, nothing to keep in step. The consistency the section worried about is a property of each rune honouring the attribute the author already set, not of a shared mapping.

And the general rule, which outlives this rune pair: the parent retypes its children; a child never declares its context. collectJsonLd nests a typed node into its parent only when that node carries both typeof and property (seo.ts:109), and only the parent can supply the property. So a parent that wants a child in its graph must already touch that child's attributes — at which point stamping typeof alongside property is free, and contextProperties would be a second mechanism for what children: does. Markdoc's bottom-up transform order says the same thing from the other side: at transform time a child cannot see its parent, while a parent sees its already-transformed children. contextModifiers works only because the engine runs later, over a whole tree.

That rule splits into two channels, which behave differently and should be stated separately:

  • property — which collection the child joins (track vs hasPart) — is always the parent's. It describes a relationship, and only the parent knows it. This is forced, not chosen: without the parent's stamp the child floats free whatever type it carries.
  • typeof — what the child is — is the child's when declared, else the parent's child-row default.

contextProperties therefore stays deferred, on the same grounds as D3's override: no demonstrated use.

BUG-016 is being resolved by making the composition work (WORK-572), and that does not disturb any of the above — it confirms it. A nested {% track %} with no explicit type takes the playlist's child type; one with an explicit type keeps it; the playlist supplies the collection property either way. The child still declares nothing about its parent. What it costs this spec is only that playlist's children: mapping must match tag-built children as well as transform-built ones — a wider matcher on the parent, not a new mechanism.

Migration shape

Seven work items. The first two are independent of the table and land first.

  1. Baseline snapshot — JSON-LD fixtures for all 30 emitting runes, committed before anything moves.
  2. Harvest after the pipeline — recompute seo from enrichedPages, plus the two-point drift invariant. Fixes breadcrumb auto on its own.
  3. CSS decoupling — the six Lumina rules off the schema.org channel (BUG-015), plus the coverage assertion that keeps them off.
  4. The mechanism — the table, the applier, entities / text / index, IDENTITY_FIELDS, and the ~10 unaddressable sources named first.
  5. Group A — apply D4; seven runes lose a bare type or gain a mapping.
  6. Group B — 14 runes, mechanical.
  7. Group C — nine runes, four separable shapes; playlist first since it exercises by:, children: and the inline stamps at once.

Tooling (contracts, reference, inspect) rides items 4–7 rather than forming its own, since each needs the table to exist before it has anything to print.

Non-goals

  • Page- and site-level schema. WebSite and Organization entities are built in seoToHtml and the Next/Nuxt head helpers, not from runes — and are absent from the Svelte(Kit) and html adapters entirely, so refrakt's own site publishes neither. That inconsistency is real but orthogonal; this spec is the rune-level channel only, and should say so rather than imply contracts will answer "what structured data does this page emit?" in full.
  • A schema.org ontology. See "Curation, not validation" above.

Acceptance Criteria

  • A baseline JSON-LD snapshot covers all 30 emitting runes and is committed before the first call site changes, so every later step is reviewed as a diff against it
  • seo is recomputed from enrichedPages after runPipeline, so postProcess-injected schema reaches the JSON-LD — {% breadcrumb auto=true %} publishes the BreadcrumbList it renders, asserted by a test
  • A rune's schema.org type and property mapping are expressible in config, keyed by a name in the rune's flat namespace (ADR-008) whether it surfaces as data-name or data-field
  • A schema value whose <meta> carrier is dropped as pure data is rebuilt from data-rune-fields, so recipe, event, track, playlist, character, realm, lore and howto keep the RDFa they emit today without createComponentRenderable gaining any knowledge of schema
  • Every schema source is addressable — the ~10 sources in neither properties nor refs (embed, tier, and the image nodes in figure, recipe, playlist, realm, faction) gain a name or a bag entry before their rune migrates
  • Plugin runes declare schema the same way core runes do, and the plugin-facing contract says so
  • The mapping is applied at transform time, so extractSeo still sees it — a test asserts the JSON-LD, not just the HTML attributes
  • A rune can offer more than one type, with per-type property names and per-type child mappings — where a child mapping carries its own property map, not just a type name
  • A child entity is never declared without the property that holds it, so it cannot float up as a detached top-level entity
  • Suppressing schema entirely is expressible on every rune, not just accordion, and strips the whole subtree rather than just the root
  • schema joins IDENTITY_FIELDS, so no merge path — theme override or variant delta — can redefine what a rune means
  • refrakt contracts describes the schema.org output it currently omits, and refrakt inspect shows a rune's resolved schema row rather than leaving it to be read out of the raw HTML
  • Every one of the 22 existing schema: maps is expressible, including the computed-meta cases
  • The imperative typeof: / property: stamps and the attributes.typeof = … mutations are covered too, or the runes carrying them are named as out of scope with a reason
  • A nested entity can name the property that takes its own content, with the applier emitting the RDFa-conformant wrapper — no rune hand-writes one
  • The JSON-LD is harvested at both the pre-engine and post-engine points and the two graphs are asserted equal — object keys normalised, array order significant — so RDFa/JSON-LD divergence fails a test rather than shipping
  • Grouping named sibling nodes into a nested entity is expressible, so testimonial's Person / Rating and event's Place are declared rather than synthesised by hand
  • A property declared a list always serialises as an array, so a one-item collection and a two-item one have the same shape (D6)
  • organization's six-value enum becomes a by: 'type' table, and NonProfit is corrected to NGO — a type that does not exist in schema.org (D2)
  • breadcrumb's position agrees between the RDFa and the JSON-LD (D8)
  • No stylesheet selects on property=; the six Lumina rules move to BEM element classes and a CSS coverage assertion keeps them there
  • defineRune({ schemaOrgType }) is deleted or fed from the table — the type is declared once
  • typeof="PageSection" is not emitted anywhere — it is not a schema.org type (the removal itself belongs to SPEC-133; this spec only requires that no invented type survives)
  • No entity is emitted without at least one property (D4) — each of the seven Group A runes either gains a mapping or stops emitting a type
  • The imperative form either still works or is fully migrated — not half of each, per rune

References

  • WORK-552 — schema="none" on accordion, shipping ahead of this
  • WORK-548 — the ~970 fabricated Question entries that surfaced it
  • SPEC-082 — the schema.org channel this reworks
  • ADR-028 — rune identity is not theme configuration; why the table belongs to the rune
  • BUG-013 — the mistyped playlists this would fix
  • BUG-015 — stylesheets selecting on the schema.org channel, four of them already dead
  • BUG-016 — a {% track %} inside a {% playlist %} is not one of its tracks; the finding behind D9
  • SPEC-133 — what data-field and data-name each mean; carries the contentSection removal this spec surfaced
  • ADR-008 — the flat namespace the table's keys live in

Staged runes: a composition is reviewed on its own reference page before it replaces a plugin rune

SPEC-181accepted

Staged runes: a composition is reviewed on its own reference page before it replaces a plugin rune

A composition that will replace a plugin rune is first written as a project rune of a second docs site, staging. That site renders the same content as main, with the staged compositions in place of the plugin runes they replace. You review a rune by opening its reference page on both sites and switching tabs.

Each rune goes live on its own once its review is accepted. Going live moves the definition into the plugin, in a work item of its own. This is the convention for every conversion, starting with hero, cta and testimonial in v0.44.0.

runescompositionsitestagingreviewpluginsrollout

Why

A composition has no page where anyone can look at it.

  • The composed hero, cta and testimonial are test fixtures in packages/runes/test/fixtures/composed/. The five composed storytelling runes are in packages/content/test/fixtures/composed-storytelling/runes/.
  • The only code that loads them is the test harnesses.
  • SPEC-147 D2 and the v0.44.0 milestone gate each replacement on a reviewed comparison with the plugin rune. Today that review means reading a list of differences in a work item's resolution. Nobody can see the differences.

The reference page is the right review surface; a gallery is not.

  • The gallery fixtures cover a few attribute combinations and are not yet solid.
  • The reference page under site/content/runes/ is what users read. It already shows each attribute, variant and pairing. It is also the page that must render correctly once the rune goes live.
  • Other pages that use the rune (the homepage uses hero) are reviewed for free.

The mechanism already exists.

  • A site can declare a project rune directory (runes.dir). A project rune may replace a plugin rune when runes.prefer names __project__ for it (SPEC-153 D8). A project rune that takes a plugin rune's name without that entry is a build error (project-plugin-name).
  • refrakt.config.json already declares several sites.
  • So a second site over the same content needs no new configuration option, and no new code in any package. It is what any refrakt user would do to replace a plugin rune with their own. That means this repository exercises the path itself instead of only testing it.

A prefixed name (lab-hero) would review the wrong thing.

  • It emits data-rune="lab-hero", not the hero that ships.
  • Every reference page would need a hand-edited copy that uses the prefix.
  • A staged rune here keeps its real name and its real output.

Design

The staging site

refrakt.config.json gains a staging site:

  • the same contentDir (./site/content), theme, tints, plugins, route rules and icons as main;
  • plus runes.dir set to site/staged-runes and a runes.prefer entry of __project__ for each staged rune.
"staging": {
  "contentDir": "./site/content",
  "runes": {
    "dir": "site/staged-runes",
    "prefer": { "hero": "__project__", "cta": "__project__" }
  }
}

The other fields are elided above. Sites cannot extend each other, so they are a copy of main's, and a test asserts that staging equals main apart from runes.

site/vite.config.ts reads the site name from REFRAKT_SITE, defaulting to main.

  • npm run dev:staging serves the staging site on its own port.
  • npm run build:staging builds it into its own output directory.
  • Paths are identical on both sites, so comparing is switching between two tabs.

main never reads site/staged-runes, so its output, the contracts and the SEO baseline are unaffected by anything staged.

Where a staged definition lives

  • site/staged-runes/<rune>.md, named after the plugin rune it replaces (SPEC-153 D9).
  • One directory for all plugins, because runes.dir is a single directory.
  • The comparison test harnesses read the definition from there. The reviewed file and the tested file are the same file.

Content both sites can render

The two sites share one contentDir. When a composition changes how an author writes the rune, the shared content can only suit one of them:

  • hero: the composed rune takes align="start | center | end", while the homepage and the hero reference page write align="left";
  • testimonial: the composed rune takes author and role attributes and reports the **Name** — Role paragraph as content-unmatched, while the plugin rune takes no author attribute.

So the plugin rune learns the new form first. It accepts the composition's authoring form as well as its own, as an additive change. This repository's content is then migrated on main (with the codemod, where there is one), and both sites render the same source.

  • hero accepts start and end as synonyms for left and right.
  • testimonial accepts author and role, and parses the paragraph only when neither is set.

This also gives users a migration window. Authors can move to the new form while the plugin rune is still live, so the go-live removes only the old form.

Going live

Going live is a work item of its own, never part of the conversion:

  • git mv site/staged-runes/<rune>.md plugins/<plugin>/runes/<rune>.md;
  • the plugin declares runeDir (SPEC-153 D2) if it does not yet;
  • the rune's transform, theme entry and CSS are removed, and so is its prefer entry;
  • both copies of the contracts and the SEO baseline are regenerated, and every diff is explained;
  • the changeset carries a migration note. It also carries a codemod (refrakt migrate) when authored content changes meaning;
  • the rune's reference page is updated for any attribute whose vocabulary changed.

Rollout

Runes go live one at a time, as each review is accepted. Nothing waits for the other conversions.

Waiting until every intended conversion is staged was considered and rejected:

  • The wait would be long. Marketing alone needs SPEC-156 (steps, feature), SPEC-162 and SPEC-167 (pricing, tier) and SPEC-145 D30 (cast-member). The first staged rune would wait several milestones.
  • Reviews would go stale. section, actions and the composition machinery keep changing under staged runes. A rune approved early would need reviewing again before a single switch-over.
  • Everything would be maintained twice. Each plugin rune keeps its transform, config and CSS until it goes live, and every fix lands in both versions.
  • Problems would surface late, and all together. The first go-live is what exercises the plugin runeDir, the contract and baseline diffs, and WORK-669's reporting of theme overrides. A single switch-over would find their problems at once, in the largest breaking release possible.

Going live one at a time costs authors more, smaller migrations. Each is a breaking change (no rf-<rune> classes, and some vocabulary differs, e.g. align takes start | end rather than left | right). Each ships with its own migration note, and with a codemod where content must change. A plugin is mixed while this happens, part composed and part native. That is acceptable: nothing promises that a plugin's runes are built the same way.

Runes go live together only when they cannot be migrated separately.

  • That is the case when one migration step must change several runes at once, or a theme or author could not follow the intermediate state.
  • A shared vocabulary change is not by itself such a case. The codemod is per rune and idempotent, so running it at each go-live is cheap.

Storytelling is a plugin-level decision. SPEC-147 D1 replaces that plugin alongside and deletes it separately. Its compositions stay staged until that decision is taken, and then go live, or become documented project runes, together.

A rune goes live when

  1. its mirror on the staging site has been reviewed, light and dark, on its reference page and on every other page that uses it;
  2. every difference listed in its conversion item is either accepted or fixed;
  3. any authored content whose meaning changes has a codemod, run over this repository's content, and the plugin rune has accepted the new form for at least one release (D7);
  4. it does not depend on a primitive whose spec is still draft.

Decisions

D1 — a staged composition is a project rune of a second site, under its final name

site/staged-runes/<rune>.md, loaded only by the staging site, preferred over the plugin rune through runes.prefer. There is no new configuration option and no prefix. A composition that replaces nothing (teaser, the section-grammar fixtures) is not staged; it stays a test fixture.

D2 — the reference page is the review surface

The staging site renders the whole docs site. A rune is reviewed on its reference page and on every other page that uses it. There is no separate gallery of staged runes.

D3 — staging is a copy of main apart from runes, and a test holds it there

This is the price of not having site inheritance. A test fails when the two sites differ in any field other than runes.

D4 — the test harnesses read the staged definition

The comparison tests (SPEC-147 D2) and the reviewed page read the same file. A staged rune may not also have a copy in a test fixture directory.

D5 — going live is its own work item, one rune at a time

Going live is never part of a conversion. A rune goes live once the four gates in the rollout are met. Runes are grouped only when they cannot be migrated separately. Storytelling goes live as a whole, by the SPEC-147 decision.

D6 — the convention is stated where conversions are planned and checked where they are tested

  • CLAUDE.md and the rune authoring docs describe it.
  • A test fails when a composed definition under a test fixture directory has the name of a shipping plugin rune.

D7 — the plugin rune accepts the composition's authoring form before the rune is staged

A staged composition must render the shared content without errors. Where its authoring form differs from the plugin rune's, the plugin rune gains the new form additively, and this repository's content is migrated on main before the rune is staged. The staging site's validation (refrakt validate --site staging) reports nothing that main's does not.

D8 — a site may declare its rune directory reviewed, so a staged definition keeps its schema

Decided 2026-10-30, while staging testimonial (WORK-681). A staged definition is a project rune, and SPEC-145 D25 rejects schema in a project definition: its row would publish as JSON-LD that nobody reviews. The composed testimonial declares its Review row, as the storytelling compositions declare theirs, so D1 could not stage them as written.

D25's reason is the reviewer, not where the file sits. A staged definition in this repository is first-party code on its way into a plugin, reviewed in a pull request like the plugin itself. So a site may say so: runes.schema: "allow" admits schema in its runes.dir, and only the staging site sets it here.

  • The default stays deny. A user's project keeps D25's protection unless it opts in.
  • It is an assertion, not a convenience switch. The config reference and the authoring guide describe it as "every definition in this directory is reviewed like first-party code", and D25's rejection names it, so a user who reaches for it sees what it claims.
  • It amends SPEC-145 D25 item 2, which a mechanical check would still lift for everyone.
"staging": {
  "runes": {
    "dir": "site/staged-runes",
    "schema": "allow",
    "prefer": { "hero": "__project__", "cta": "__project__", "testimonial": "__project__" }
  }
}

A preferred staged rune also takes the aliases it shares with the plugin rune it shadows (review with testimonial). An alias another plugin rune claims still needs its own entry.

Not in scope

  • Staging a replacement for a core rune. Core names are not shadowable (SPEC-153 D8).
  • A hosted staging deploy. build:staging makes one possible; deploying it is a separate decision.
  • Site inheritance in refrakt.config.json. D3's test is the stand-in.
  • Fixing BUG-037. The staging site uses __project__, which that bug does not affect.

Acceptance Criteria

  • The staging site serves the docs content with staged runes in place of plugin runes, through existing configuration only, and a test holds it equal to main apart from runes
  • main's output, the contracts and the SEO baseline are unchanged by anything staged
  • The plugin hero and testimonial accept their compositions' authoring forms, and this repository's content uses them
  • hero, cta and testimonial are staged in site/staged-runes/, their tests read them there, and their reference pages render with them on the staging site with no validation findings beyond main's
  • The storytelling compositions are staged likewise
  • The convention and rollout are documented, and a test catches a composed definition of a plugin rune outside staging

References

  • SPEC-145: composed runes.
  • SPEC-147: D1 replace alongside, D2 every difference explained.
  • SPEC-153: D2 plugin runeDir, D8 precedence, D9 the filename is the name.
  • SPEC-160, SPEC-161: the marketing conversions.
  • ADR-042, WORK-669: theme overrides of composed runes.

Every rune reference page opens with a generated intro: package, title, description and a bar

SPEC-187implemented

Every rune reference page opens with a generated intro: package, title, description and a bar

Every page under site/content/runes/ opens with the same intro, rendered from data rather than written by hand: the rune's package as an eyebrow, its title, its description as the blurb, and a bar. The bar's left side says what the rune is (a composition, a status other than stable, the schema.org type it publishes); its right side holds actions (an Install drawer, the rune's source in a drawer). It replaces the "This rune is part of @refrakt-md/…" hint.

docsrunesreferencecompositionsitedatainclude

Why

Compositions are about to go live, and nothing on a reference page says a rune is one. Once hero, cta and testimonial move into the marketing plugin's runeDir (SPEC-181 D5), their reference pages look exactly as before. A composition's definition is a short Markdoc file a reader can learn from and copy into their own runes.dir; the page should say so and show it.

The page header is hand-written on 119 pages, and has drifted. The install hint appears on 45 of them, worded by hand. The # Title repeats the frontmatter title, and the opening paragraph often repeats the frontmatter description. SPEC-128 solved the same problem for attribute tables: a generated data file, one shared partial, and a guard.

The parts exist. scripts/generate-rune-attributes.mjs already writes per-rune data; the include rune (SPEC-129) resolves data in a shared partial before the transform; section reads an eyebrow, headline and blurb; bar is a two-group row; file-ref with preview="drawer" shows a file in a drawer with a GitHub link.

Design

{% section prominence="prominent" %}
marketing

# Hero

Full-width intro sections for landing pages with background support and action buttons

{% bar %}
{% badge %}{% icon name="puzzle" /%} Composition{% /badge %}
---
{% actions %}
- [{% icon name="package" /%} Install](#rune-install)
- [{% icon name="code" /%} Source](#rune-source)
{% /actions %}
{% /bar %}
{% /section %}

{% drawer id="rune-install" title="Install" %}
…the two steps, built from the package (D8)…
{% /drawer %}

{% drawer id="rune-source" title="Source" %}
{% snippet path="plugins/marketing/runes/hero.md" /%}
{% /drawer %}

The actions are ordinary links to drawers on the page (D10), placed with actions and styled by it in the bar's context (D11); the Source drawer is declared by the partial (D12). The partial fills every value from the record.

A page writes one line where its header was:

{% include file="rune-intro.md" variables={r: "rune:hero"} /%}

Decisions

D1 — every rune page gets the intro, native or composed

The intro is the page's header, not a composition marker. A native rune's page shows the same eyebrow, title, blurb and bar; only the badges differ. One convention on every page is easier to read and to guard than two.

D2 — the intro is a section; the bar is its body

  • Eyebrow: the plugin's short name as plain text (marketing, not @refrakt-md/marketing), or core. Amended: not a link; the plugin page is linked from the Install drawer.
  • Headline: the page's # Title, still an <h1>.
  • Blurb: the frontmatter description.
  • Body: the bar, directly under the blurb.

The bar is not the eyebrow: section's eyebrow matches a paragraph, and a leading {% bar %} would stop the header from being read. It is not the footer either: the footer zone exists only with media-position and would need an empty media zone.

D3 — the bar's left side says what the rune is; its right side holds actions

Left, in this order, each only when it applies:

  1. Kind: a Composition badge with a puzzle icon when the rune is composed. A native rune shows no kind badge: native is the default and is not announced.
  2. Status: a badge when the frontmatter status is not stable.
  3. schema.org type: the type the rune publishes, linked to its schema.org page (D9).

Right: an Install action for a plugin rune (D8), then a Source action (D4). A core rune shows only Source. The right side is never empty, so the bar's height and position do not change from page to page.

Amended: the actions are actions items, each an icon and a short label (D10, D11). An earlier note made them text links, because ref and file-ref take only a text label; D10 removes the need for either rune here.

D4 — the source is the file that defines the rune

A composition's source is its .md definition: the plugin's runeDir file, or a project rune's. A native rune's source is its schema file (packages/runes/src/tags/<rune>.ts or the plugin's src/tags/<rune>.ts). The drawer shows what actually builds the rune, whichever kind it is.

D5 — the data is generated, and title and description come from the page's frontmatter

The generator writes one record per rune: title, description, package, composed, status, schemaType (empty when the rune publishes nothing, D9) and source (repo-relative). title and description are read from the reference page's frontmatter, so they exist once. composed, source and schemaType come from how the rune resolves, not from a flag an author sets. Whether this extends generate-rune-attributes.mjs or is a sibling script is an implementation choice; the drift check covers it either way.

D6 — the hand-written header goes, and a guard keeps it gone

The rollout removes, on every rune page, the hand-written # Title and the install hint, and merges or cuts an opening paragraph that restates the description. scripts/check-rune-docs.mjs fails on a rune page without the intro include, or with the old hint.

D7 — staging shows the staged state honestly

The committed data file does not depend on the site, so on its own it would tell staging.refrakt.md that a staged rune is native. On the staging site a rune that runes.prefer resolves to site/staged-runes/ shows a Staged badge in place of Composition (a staged rune is always a composition, so one badge says both) and its staged definition as the source. Whether that is a per-site data file or a value computed at build time is decided in the work item, and the D3 test of staging against main (SPEC-181) must keep passing.

Amended (WORK-707): a page could not tell which site it is rendered for. Each adapter selects its site with resolveSite() and passes only the site's config on, so the name was dropped before the page variables were built. So:

  • A $site page variable, $site.name the site's key in refrakt.config.json. Normalizing the config tags each site with its key, and buildPageContentVariables() — the one place every page's variables are built — reads it from the config it already receives. No adapter changes. An object, so other site fields can join it later without a breaking change. name is derived: an authored name that differs from the key is reported. A load with no site config has no $site.
  • The intro data has one partition per site, keyed by name, each from that site's resolution. On staging, a rune resolved from site/staged-runes/ is staged, its source that file. The partial selects with root=$site.name.

D8 — install is its own action, a drawer, built from the package

The install command is neither in the Source drawer (a different reader, a different question) nor only behind the package eyebrow (a link nobody knows to click, where the hint it replaces was visible on the page).

  • An Install action on the bar's right opens an Install drawer with both steps the hint gives today: npm install <package> and the plugins entry in refrakt.config.json, and a link to the plugin's overview page.
  • The drawer's text is built by the partial from the record's package, so its wording lives in one place. The data carries no install field.
  • The eyebrow still links to the plugin's overview page, whose Installation section says the same.

D9 — the schema.org badge shows the type the rune publishes by default

SPEC-130 D5 trades validation for visibility: nothing checks a schema table against schema.org, so a wrong row is caught by someone reading it. A rune's reference page is where most people will read it, so the bar shows it.

  • The type is the one the resolved row publishes with the rune's defaults, read from the same row refrakt inspect <rune> --site main prints.
  • When the type depends on an attribute (playlist's type), the badge shows the default type; the attribute section explains the variants.
  • When an attribute can switch it off (schema="none"), the badge still shows the default type.
  • A rune that publishes nothing shows no badge, not a "none" badge.

Today only ref and file-ref open a drawer: the drawer behaviour wires a[data-target-type="drawer"], and only those two runes set the attribute. A Markdown link to the drawer's anchor scrolls to it and does not open it. That ties every drawer trigger to two runes that take only a text label, so a trigger cannot carry an icon or be an actions button.

Any link whose target is a drawer on the same page is a trigger. [Install](#rune-install), {% link href="#rune-install" %} and an actions item all open the drawer rune-install.

  • Where: the build step that already knows the page's drawers rewrites such a link's href to the drawer's anchor (#drawer-<id>) and sets data-target-type="drawer". The drawer behaviour is unchanged.
  • Which id: the link names the drawer's own id, as ref does. #drawer-<id> is accepted too, since it is what a rendered trigger's href already is.
  • A drawer wins. When a drawer and a heading share an id, the link opens the drawer. When no drawer has the id, the link is left alone: it is an ordinary same-page anchor.
  • Without JavaScript the link still reaches the drawer, which renders in flow at its position.
  • Icons come with it. Markdown link text takes inline tags, so [{% icon name="code" /%} Source](#rune-source) needs no new attribute on any rune.

ref and file-ref keep working as they do. The file-ref monospace treatment of prose labels (label="the latest release notes") is a separate defect and not changed here.

The intro's Install and Source are things a reader does, so they are authored as actions. Their look in a bar is the theme's.

  • Not a bar link style. Most links in bars are values: the plan plugin's bars link an entity's source spec, its milestone, its ID. Restyling every link in a bar as an action would make a value look like a command, across every bar layout. Links that are values keep prose link styling.
  • A context modifier on Actions: contextModifiers: { bar: 'in-bar' }, giving .rf-actions--in-bar. It is named in the structure contract and inspect, so another theme sees the hook rather than finding a descendant selector.
  • What it means: quieter than a button and clearly not prose. Compact (the badges' height, small text, tight padding), tonal rather than filled (text or muted colour, a subtle surface or border that strengthens on hover), an icon before the label. Rank still reaches the markup; Lumina gives a primary action in a bar no fill, so authors need no "no primary" option.
  • How: actions already takes its size from properties a host sets (--action-padding, --action-font-size), as marquee does. The bar is one more host; the tonal look is Lumina's, and the skeleton keeps the row structural.

D12 — the Source drawer is declared by the partial

file-ref preview="drawer" hoists its drawer under an id derived from the path, which the partial cannot build: the preprocess resolver does not concatenate (BUG-010). So the partial declares the drawer itself, {% drawer id="rune-source" title="Source" %} around {% snippet path=$row.source /%}, followed by a link to the file on GitHub. The record carries that URL (sourceUrl), built by the generator from the site's repoUrl and repoBranch.

If snippet cannot take its path from $row inside data, file-ref gains an id attribute for its drawer instead, and the partial keeps it.

Sketches

Illustrative, not pixel targets. [▣] is Install, [</>] is Source.

hero — a composition in a plugin
  MARKETING
  Hero
  Full-width intro sections for landing pages with background support and action buttons
  ┌───────────────────────────────────────────────────────────┐
  │ [🧩 Composition]                           [▣]  [</>]     │
  └───────────────────────────────────────────────────────────┘

testimonial — a composition that publishes structured data
  │ [🧩 Composition] [Review]                  [▣]  [</>]     │

howto — a native plugin rune
  │ [HowTo]                                    [▣]  [</>]     │

hint — a core rune
  │                                                 [</>]     │

a plugin rune whose status is not stable
  │ [⚠ Experimental] [MusicPlaylist]           [▣]  [</>]     │

testimonial on staging.refrakt.md, before it goes live (D7)
  │ [🧩 Staged] [Review]                       [▣]  [</>]     │
┌ Install ──────────────────────┐   ┌ Source ───────────────────────────┐
│ npm install @refrakt-md/      │   │ plugins/marketing/runes/hero.md   │
│   marketing                   │   │ ───────────────────────────────── │
│                               │   │ ---                               │
│ refrakt.config.json           │   │ tag: div                          │
│ "plugins": [                  │   │ description: Full-width intro…    │
│   "@refrakt-md/marketing"     │   │ attributes:                       │
│ ]                             │   │   align: { … }                    │
│                               │   │ …                                 │
│ → Marketing plugin overview   │   │ View on GitHub ↗                  │
└───────────────────────────────┘   └───────────────────────────────────┘

To confirm in implementation

  1. The <h1> inside an include still feeds the page title, the TOC and the heading index. The include resolves before the transform, so it should; a test asserts it.
  2. file-ref takes its path from a $row value. data substitutes values in the preprocess phase, before file-ref reads the file; a test asserts it.
  3. The register. section's default prominence and spacing on a docs page read as a page header, not a landing-page band. If not, the partial sets the section's attributes; it does not add CSS for one site. (Confirmed in WORK-706: prominent matches a docs page's <h1>.)
  4. snippet takes its path from a $row value inside data (D12). (Confirmed in WORK-709.)
  5. actions ranks link items with icons in them. It finds the first a, which a Markdown link already is at transform time; a test asserts it. (Confirmed in WORK-709.)

Not in scope

  • Composing steps or taking any other rune live. That is SPEC-181's work, one item per rune.
  • The rune catalog page and refrakt inspect / reference showing a composition marker. Both can read the same data later.

References

  • SPEC-181: staged runes and the go-live gates.
  • SPEC-128: the generated attribute tables this follows.
  • SPEC-129: the pre-transform include.

A comma attribution leaves the comma in the testimonial role

BUG-019fixed

A comma attribution leaves the comma in the testimonial role

{% testimonial %} parses its attribution line as **Name** — Role, stripping a leading -, – or — from the text after the <strong>. It does not strip a comma, so the comma form — which is the obvious alternative and which nothing rejects — carries the separator into the role.

runesmarketingseo

Steps to Reproduce

  1. Write a testimonial whose attribution uses a comma rather than an em dash:

    {% testimonial rating=4 %}
    > The semantic approach just makes sense.
    
    **Alex Rivera**, CTO at Acme
    {% /testimonial %}
    
  2. Read the rendered role, and the jobTitle in the page's JSON-LD.

Expected

CTO at Acme — the same result the documented em-dash form gives.

Actual

, CTO at Acme, in both channels. Recorded in contracts/seo-baseline/baseline.json under the testimonial.comma fixture:

"author": { "@type": "Person", "jobTitle": ", CTO at Acme", "name": "Alex Rivera" }

Cause

plugins/marketing/src/tags/testimonial.ts:

.replace(/^\s*[-–—]\s*/, '')
.trim()

The character class covers three dashes. .trim() removes whitespace, so a leading comma survives at position 0.

Why it was left alone by WORK-571

WORK-571 required this defect to be preserved or fixed deliberately, and the choice recorded rather than made by accident. Preserved, for two reasons:

  • The defect is in the attribution parser, not the schema mapping. testimonialSchema reads author-role faithfully; the text handed to it is what is wrong. SPEC-130's scope is the mapping.
  • Fixing it changes rendered HTML on every comma-form testimonial, not just the structured data. That is a separate user-visible change and deserves its own review and its own changeset entry rather than riding inside a migration whose whole evidence is "the baseline diff shows only what was intended".

Fix

Widen the character class to include a comma, and cover the form in the rune's tests and its documentation — the docs currently show only the em dash, so the comma form is undocumented as well as mishandled. Decide at that point whether the comma form should be supported or rejected; supporting it is the smaller change and matches what authors evidently write.

Regenerating the baseline will show the testimonial.comma fixture's jobTitle losing its leading comma, and nothing else.

Environment

  • Found against the v0.35.0 line, in @refrakt-md/marketing
  • Predates SPEC-130; recorded by WORK-562's baseline

Resolution

Completed: 2026-11-03

Fixed by the composed testimonial going live (WORK-704, refrakt-md/refrakt#769). Its attribution pattern (^\s*[-–—,]\s*(.+)$, SPEC-182 D6) accepts a comma as the separator, so **Alex Rivera**, CTO at Acme publishes jobTitle: "CTO at Acme". The SEO baseline's testimonial.comma fixture records the fixed value; composed-testimonial.test.ts asserts it.