SPEC-155
ID:SPEC-155Status:draft

Media plugin composition audit

Related 24

Summary

Eighth plugin audited against SPEC-145, and the one that came with a specific question: can playlist be a composition the way recipe can, given the right primitives?

Yes — and the two things it needs both already exist in the codebase, unused by it. One is designed for precisely playlist's shape; the other is a shipped attribute pair. What does not compose is its embedded player, which already has a decoupled form.

Auditing the coupling that decoupled form depends on also turned up two measured defects in shipped behaviour, one of them a regression, in a path nothing tests. Those are the audit's most immediately useful output and they are independent of composition.

The distinction that makes the question answerable

Per-rune verdicts fail on repeated-item runes, because "does this rune compose" turns entirely on what happens to its items. Three modes, and only one is a blocker:

ModeWhat the rune doesRunesComposes
Placestamps data-name on each <li> and keeps ithowto, recipe, steps, plan's checklists✓ — the template places the list, items survive
PromoteemitTag turns each item into a child rune, which builds itselfcast, plot, itinerary✓ — two tractable problems instead of one
RebuilditemModel decomposes each item; the transform reassembles itplaylist, audio✗ — a template cannot reassemble

playlist rebuilds. playlist.ts:269-314 walks tracksData and hand-builds, per item, a track-name span, a track-artist span, a track-duration span, a duration meta carrying PT…S, a track-meta span and an optional cue-point list. A Markdoc template places nodes; it cannot do that.

So the answer is not "playlist is too complex". It is "playlist rebuilds where it could promote."

Promoting is already the designed answer, and playlist is the documented case

packages/types/src/content-model.ts:47-51:

When set alongside itemModel on a list field, each extracted item is [emitted as a tag].

That sentence describes playlist's tracks field exactly — a list field with an itemModel. It is implemented (packages/runes/src/lib/resolver.ts:188-215, building Ast.Node('tag', attrs, itemChildren, field.emitTag) per item) and exercised by two runes.

Counted across the repo, four runes use itemModel at all:

RuneitemModelemitTagMode
cast (business)✓✓ cast-memberpromote
plot (storytelling)✓✓promote
playlist (media)✓ ×5, two levels✗rebuild
audio (media)✓✗rebuild

Two promote, two rebuild, and both rebuilders are in this plugin. Playlist has the most elaborate item model in the codebase — two nesting levels, four regex patterns, extract: 'href', pattern: 'remainder' — and is the one that did not adopt the mechanism written for it.

This is the sixth instance of the pattern this series keeps producing — the primitive exists, adoption is partial, the bespoke code predates it — and the most on-the-nose, because the type system's own doc comment describes the hold-out's case.

cast is the worked precedent, and it is playlist's shape almost line for line:

itemModel: { fields: [ … ] },
emitTag: 'cast-member',
emitAttributes: { name: '$name', role: '$role', image: '$image' },

Playlist's item fields (name, src, artist, duration, date) and track's attributes (src, artist, duration, number, date, url) are two spellings of one list, so emitAttributes is a near-mechanical mapping. And {% track %} already lands in the same tracks field — match: 'list|tag:track', added by WORK-572 for BUG-016. Promoting the list items makes the two forms one form instead of two that merge by walking node counts.

The player is the one part that cannot compose, and it already has a decoupled form

playlist.ts:371-373 hand-writes a custom element and a payload derived from its own items:

playerEl = new Tag('div', { 'data-name': 'player' }, [
  new Tag('rf-audio', { waveform: 'false' }, [
    new Tag('script', { type: 'application/json' }, [JSON.stringify(playerData)]),
  ]),
]);

Three blockers at once: a custom element (client lifecycle — the map class, SPEC-148 D3), derived data (the payload is computed from tracksData — SPEC-150 D2 and SPEC-151 D2's blocker, third instance), and duplication of what audio already does.

But the decoupling is a shipped feature. playlist declares id — "Unique identifier used to link an audio rune to this playlist" — and audio declares playlist — "ID of a playlist rune to load tracks from." <rf-audio> resolves it at runtime: it finds the playlist element in the document, reads its track items, and binds click handlers (packages/behaviors/src/elements/audio.ts:150-170).

So a composed playlist needs no player capability at all. The author writes {% audio playlist="x" %} beside {% playlist id="x" %}, and the inline player attribute is what does not survive composition — not the player.

Two measured defects in that coupling

Measured on the plugin's own canonical fixture (refrakt inspect playlist --site main):

<section typeof="MusicAlbum" … data-rune="playlist">
  <li data-field="track" typeof="MusicRecording" property="track">   ← markdown list item
  <li data-field="track" typeof="MusicRecording" property="track">   ← markdown list item
  <li data-field="track" typeof="MusicRecording" … data-rune="track"> ← authored {% track %}