SPEC-130
ID:SPEC-130Status:draft

Declarative schema.org mapping

Move the schema.org channel from 29 imperative call sites into declarative data, keyed by data-name — 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.

Branches 4
History 5
  1. a2a0a00
    Created (draft)by bjornolofandersson
  2. 3f88768
    Content editedby Claude
    docs(plan): SPEC-130 — no threading needed; schema is a schema-construct
  3. fce1770
    Content editedby Claude
    docs(plan): SPEC-130 — schema is rune identity, not theme configuration
  4. 26a53b1
    Content editedby Claude
    docs(plan): make playlist the driving case for SPEC-130, and file BUG-01
  5. 7532b55
    Content editedby Claude
    feat(runes): `{% accordion schema="none" %}` emits no structured data
// 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 },

});

Three consequences:

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.

The mapping is therefore data consulted at transform time, not engine behaviour. createContentModelSchema is the single place that sees every rune — it wraps each rune's transform and already post-processes the result (the tint and bg meta injection). Every one of the 29 schema emitters goes through it; none is a raw Schema. So schema application is one step there, not 29 edits.

Nothing needs threading

The obvious guess is that the table is passed to createComponentRenderable. It cannot be: picking a row needs attrs (for by: 'type'), and that function receives only the assembled result.

The wrapper has both. So schema is an option to createContentModelSchema, sitting with the rune-identity declarations already there — sections, mediaSlots, provides, base:

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 29 call sites get simpler: they stop passing schemaOrgType and schema entirely rather than gaining an argument.

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 to after the engine is the alternative. It is a larger change — content loading is framework-agnostic — and it is not obviously better, so it is recorded as rejected rather than unconsidered.

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 a different type says so in contentschema="none", schema="<Type>" — 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.

What the table has to express

Surveyed across all 21 schema: maps in the codebase, the values split two ways:

KindExamplesDeclarative?
Named refsnameTag, titleTag, tiers, trackItemsYes — already carry data-name / data-field
Computed metasparsedPriceMeta, resolvedCurrencyMeta, estimatedTimeMetaYes, by reference — the tag carries data-field; only its value is computed

So the split is clean: the transform computes values, the table names their schema roles. No case requires the table to compute anything, which is what makes this tractable.

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 default
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.

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.

That settles the layering:

  • derive — a content attribute picks the schema, because the author has already said what the thing is
  • overrideschema="<Type>" for when the author knows better than the mapping; rarely needed once the default is derived
  • suppressschema="none", orthogonal to both (WORK-552)

Two emitters, one mapping

MusicRecording is stamped in two places: playlist's own <li> items (line 153) and the standalone track rune. A per-type child mapping has to reach both, or a podcast's inline items become PodcastEpisode while {% track %} children stay MusicRecording. Whether that is one config entry consulted twice or two entries kept in step is a design question this spec must answer, not gloss.

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.

Tooling. contracts and reference can describe the structured data, which closes a real gap in what contracts claims to cover.

Open questions

  • Is none a type or a mode? schema="none" suppresses; every other value names a type. Reads fine, but it makes the attribute's vocabulary a union of two kinds of thing.
  • Validation. Narrowing to a subtype is always safe (schema.org properties are inherited); switching branches is not. With a per-type table the rune only offers types it has mappings for, so the unsafe case stops being expressible — which may make validation unnecessary rather than deferred. Confirm.
  • Migration shape. 29 call sites. Whether the imperative form stays supported alongside config, or is removed in one pass, decides whether this is one work item or several.

Acceptance Criteria

  • A rune's schema.org type and property mapping are expressible in config, keyed by data-name / data-field
  • 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
  • Suppressing schema entirely is expressible, 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
  • Every one of the 21 existing schema: maps is expressible, including the computed-meta cases
  • The imperative form either still works or is fully migrated — not half of each

References

  • WORK-552schema="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