Relationships
Related 24
Branches 3
History 1
1580a8dCreated (draft)
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:
| Mode | What the rune does | Runes | Composes |
|---|---|---|---|
| Place | stamps data-name on each <li> and keeps it | howto, recipe, steps, plan's checklists | ✓ — the template places the list, items survive |
| Promote | emitTag turns each item into a child rune, which builds itself | cast, plot, itinerary | ✓ — two tractable problems instead of one |
| Rebuild | itemModel decomposes each item; the transform reassembles it | playlist, 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
itemModelon 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:
| Rune | itemModel | emitTag | Mode |
|---|---|---|---|
cast (business) | ✓ | ✓ cast-member | promote |
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 %}