chore(site): rewrite media element builder for MediaHost architecture (#1334)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-04-14 12:26:29 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b731cba6ba
commit d8cd59e0fd
11 changed files with 505 additions and 311 deletions
@@ -62,21 +62,21 @@
*
* Media elements (packages/html/src/define/media/ + packages/core/src/dom/media/):
* simple-video — Simple media element. Exercises: discovery via static
* tagName in define/media/*.ts, minimal delegate (src rw,
* engine readonly), shared Attributes/Events/CSS vars
* tagName in define/media/*.ts, minimal host (src rw,
* engine readonly), shared attributes/events/CSS vars
* from custom-media-element, slots parsed from template HTML.
* complex-video — Complex media element. Exercises: delegate with JSDoc
* complex-video — Complex media element. Exercises: host with JSDoc
* descriptions, multiple property types (string, boolean,
* Record), delegate-vs-native attribute deduplication
* (src, preload in delegate → omitted from nativeAttributes).
* extending-video — Extending media element. Exercises: delegate inheritance
* (ExtendingDelegate extends ComplexDelegate). Builder must
* Record), host-vs-native attribute deduplication
* (src, preload in host → omitted from nativeAttributes).
* extending-video — Extending media element. Exercises: host inheritance
* (ExtendingHost extends ComplexHost). Builder must
* walk the extends chain to include inherited properties.
* Child overrides (debug) replace parent definitions.
* container.ts — Exclusion case. Not a media element — re-exports an
* existing class instead of declaring one inline.
* background-video.ts — Exclusion case. Uses MediaAttachMixin(HTMLElement)
* without MediaPropsMixin. API reference manually maintained.
* without CustomMediaElement. API reference manually maintained.
*/
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -988,25 +988,25 @@ describe('Preset pipeline (end-to-end)', () => {
// ═══════════════════════════════════════════════════════════════════════
//
// Media elements are custom elements that wrap native <video>/<audio> with
// streaming delegates (HLS, DASH, etc.). They are discovered from
// streaming hosts (HLS, DASH, etc.). They are discovered from
// packages/html/src/define/media/*.ts by looking for files that declare a
// class with `static tagName`.
//
// The builder extracts:
// - Tag name from the element class's static tagName
// - Delegate properties by following the mixin chain to the delegate class
// and walking its getter/setter pairs (mirrors MediaPropsMixin at runtime)
// - Shared native attributes, events, and CSS vars from custom-media-element
// - Slots parsed from the template HTML (getVideoTemplateHTML / getAudioTemplateHTML)
// - JSDoc descriptions from delegate getter/setter pairs
// - Host properties by following the CustomMediaElement(tag, Host) call to the
// host class and walking its getter/setter pairs
// - Shared native attributes from static properties, events, and CSS vars
// - Slots parsed from the template HTML (getVideoTemplateHTML / getCommonTemplateHTML)
// - JSDoc descriptions from host getter/setter pairs
//
// Key behaviors:
// - Discovery: files in define/media/ with an inline class declaration + static tagName
// - Exclusion: container.ts (re-exports, no inline class), background-video.ts
// (no MediaPropsMixin — uses MediaAttachMixin(HTMLElement) directly)
// - Delegate inheritance: child delegate extends parent, builder walks the chain
// - Deduplication: properties in the delegate that overlap with native Attributes
// (e.g., src, preload) appear in delegateProperties and are omitted from nativeAttributes
// (no CustomMediaElement — uses MediaAttachMixin(HTMLElement) directly)
// - Host inheritance: child host extends parent, builder walks the chain
// - Deduplication: properties in the host that overlap with native attributes
// (e.g., src, preload) appear in hostProperties and are omitted from nativeAttributes
describe('Media element pipeline (end-to-end)', () => {
const results = generateMediaElementReferences(FIXTURE_ROOT);
@@ -1030,7 +1030,7 @@ describe('Media element pipeline (end-to-end)', () => {
expect(findElement('MediaContainerElement')).toBeUndefined();
});
it('excludes background-video (no MediaPropsMixin, manually maintained)', () => {
it('excludes background-video (no CustomMediaElement, manually maintained)', () => {
expect(findElement('BackgroundVideo')).toBeUndefined();
expect(findElement('BackgroundVideoElement')).toBeUndefined();
});
@@ -1044,19 +1044,19 @@ describe('Media element pipeline (end-to-end)', () => {
// SIMPLE MEDIA ELEMENT: SimpleVideo
// ─────────────────────────────────────────────────────────────────
//
// A minimal media element with a simple delegate (src rw, engine readonly).
// No JSDoc on delegate properties — descriptions should be undefined.
// No overlap between delegate props and native Attributes (engine is not
// in Attributes), so nativeAttributes should be the full shared list.
// A minimal media element with a simple host (src rw, engine readonly).
// No JSDoc on host properties — descriptions should be undefined.
// No overlap between host props and native attributes (engine is not
// in static properties), so nativeAttributes should be the full shared list.
describe('SimpleVideo (minimal delegate)', () => {
describe('SimpleVideo (minimal host)', () => {
it('extracts the tag name', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.tagName).toBe('simple-video');
});
it('extracts delegate properties with types and readonly flags', () => {
const props = findElement('SimpleVideo')!.reference.delegateProperties;
it('extracts host properties with types and readonly flags', () => {
const props = findElement('SimpleVideo')!.reference.hostProperties;
// src: read-write string
expect(props.src).toMatchObject({
@@ -1072,16 +1072,16 @@ describe('Media element pipeline (end-to-end)', () => {
});
});
it('excludes delegate methods (attach, detach, destroy)', () => {
const props = findElement('SimpleVideo')!.reference.delegateProperties;
it('excludes host lifecycle methods (attach, detach, destroy)', () => {
const props = findElement('SimpleVideo')!.reference.hostProperties;
expect(props.attach).toBeUndefined();
expect(props.detach).toBeUndefined();
expect(props.destroy).toBeUndefined();
});
it('includes native attributes from the shared Attributes array', () => {
it('includes native attributes from static properties', () => {
const ref = findElement('SimpleVideo')!.reference;
// src is in the delegate, so it should be omitted from nativeAttributes
// src is in the host, so it should be omitted from nativeAttributes
expect(ref.nativeAttributes).toEqual(
expect.arrayContaining([
'autoplay',
@@ -1097,20 +1097,35 @@ describe('Media element pipeline (end-to-end)', () => {
expect(ref.nativeAttributes).not.toContain('src');
});
it('includes events from the shared Events array', () => {
it('includes events derived from VideoEvents capability contracts', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.events).toEqual(
expect.arrayContaining([
'abort',
'canplay',
'durationchange',
'ended',
'pause',
'play',
'timeupdate',
'volumechange',
])
);
// Events are extracted from VideoEvents in types.ts, which extends
// all capability event interfaces including TextTrackListEvents
expect(ref.events).toEqual([
'play',
'playing',
'waiting',
'pause',
'ended',
'timeupdate',
'durationchange',
'seeking',
'seeked',
'loadedmetadata',
'loadstart',
'emptied',
'canplay',
'canplaythrough',
'loadeddata',
'volumechange',
'ratechange',
'progress',
'error',
'addtrack',
'removetrack',
'changetrack',
'trackmodechange',
]);
});
it('includes CSS custom properties from VideoCSSVars', () => {
@@ -1133,25 +1148,25 @@ describe('Media element pipeline (end-to-end)', () => {
// COMPLEX MEDIA ELEMENT: ComplexVideo
// ─────────────────────────────────────────────────────────────────
//
// A full media element with a complex delegate that has JSDoc descriptions,
// multiple property types, and overlap with native Attributes (src, preload).
// A full media element with a complex host that has JSDoc descriptions,
// multiple property types, and overlap with native attributes (src, preload).
// Tests that the builder extracts descriptions from JSDoc on getters and
// deduplicates delegate props from nativeAttributes.
// deduplicates host props from nativeAttributes.
describe('ComplexVideo (full delegate, JSDoc, deduplication)', () => {
describe('ComplexVideo (full host, JSDoc, deduplication)', () => {
it('extracts the tag name', () => {
const ref = findElement('ComplexVideo')!.reference;
expect(ref.tagName).toBe('complex-video');
});
it('extracts all delegate properties', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
it('extracts all host properties', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const propNames = Object.keys(props).sort();
expect(propNames).toEqual(['config', 'debug', 'engine', 'preferPlayback', 'preload', 'src', 'type']);
});
it('extracts JSDoc descriptions from delegate getters', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
it('extracts JSDoc descriptions from host getters', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
expect(props.type.description).toBe('Explicit source type. When unset, inferred from the source URL extension.');
expect(props.preferPlayback.description).toBe("Whether to prefer `'mse'` or `'native'` playback.");
expect(props.debug.description).toBe('Enable debug logging.');
@@ -1159,7 +1174,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('marks readonly properties correctly', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
const props = findElement('ComplexVideo')!.reference.hostProperties;
// engine: getter only → readonly
expect(props.engine.readonly).toBe(true);
// src: getter + setter → not readonly
@@ -1168,18 +1183,18 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts property types', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
const props = findElement('ComplexVideo')!.reference.hostProperties;
expect(props.src.type).toBe('string');
expect(props.debug.type).toBe('boolean');
expect(props.config.type).toContain('Record');
});
it('deduplicates delegate props from nativeAttributes', () => {
it('deduplicates host props from nativeAttributes', () => {
const ref = findElement('ComplexVideo')!.reference;
// src and preload are in both the delegate AND native Attributes.
// They should appear in delegateProperties...
expect(ref.delegateProperties.src).toBeDefined();
expect(ref.delegateProperties.preload).toBeDefined();
// src and preload are in both the host AND native attributes.
// They should appear in hostProperties...
expect(ref.hostProperties.src).toBeDefined();
expect(ref.hostProperties.preload).toBeDefined();
// ...and be omitted from nativeAttributes
expect(ref.nativeAttributes).not.toContain('src');
expect(ref.nativeAttributes).not.toContain('preload');
@@ -1193,19 +1208,19 @@ describe('Media element pipeline (end-to-end)', () => {
// EXTENDING MEDIA ELEMENT: ExtendingVideo
// ─────────────────────────────────────────────────────────────────
//
// A media element whose delegate extends another delegate (mirrors
// MuxMediaBase extending HlsMediaBase). The builder must
// walk the extends chain to include inherited properties. Child
// properties override parent definitions.
// A media element whose host extends another host (mirrors
// MuxVideoMedia extending HlsMedia). The builder must walk the
// extends chain to include inherited properties. Child properties
// override parent definitions.
describe('ExtendingVideo (delegate inheritance)', () => {
describe('ExtendingVideo (host inheritance)', () => {
it('extracts the tag name', () => {
const ref = findElement('ExtendingVideo')!.reference;
expect(ref.tagName).toBe('extending-video');
});
it('includes own properties from ExtendingDelegate', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
it('includes own properties from ExtendingHost', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
expect(props.playbackId).toMatchObject({
type: 'string',
readonly: false,
@@ -1218,9 +1233,9 @@ describe('Media element pipeline (end-to-end)', () => {
});
});
it('includes inherited properties from ComplexDelegate', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
// These are inherited from ComplexDelegate
it('includes inherited properties from ComplexHost', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
// These are inherited from ComplexHost
expect(props.src).toBeDefined();
expect(props.type).toBeDefined();
expect(props.preferPlayback).toBeDefined();
@@ -1230,15 +1245,41 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('child overrides replace parent definitions', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
// ExtendingDelegate overrides debug with different JSDoc
const props = findElement('ExtendingVideo')!.reference.hostProperties;
// ExtendingHost overrides debug with different JSDoc
expect(props.debug.description).toBe('Overrides parent debug — adds network logging.');
});
it('inherited readonly flags are preserved', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
// engine is readonly in ComplexDelegate and not overridden
const props = findElement('ExtendingVideo')!.reference.hostProperties;
// engine is readonly in ComplexHost and not overridden
expect(props.engine.readonly).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────
// CROSS-CUTTING: EVENT EXTRACTION
// ─────────────────────────────────────────────────────────────────
//
// Events are derived from the capability contract types in
// packages/core/src/core/media/types.ts, not hardcoded.
// VideoEvents includes TextTrackListEvents; AudioEvents does not.
describe('Event extraction from capability contracts', () => {
it('video elements include text track events from VideoEvents', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.events).toContain('addtrack');
expect(ref.events).toContain('removetrack');
expect(ref.events).toContain('changetrack');
expect(ref.events).toContain('trackmodechange');
});
it('all video elements share the same event list', () => {
const simple = findElement('SimpleVideo')!.reference.events;
const complex = findElement('ComplexVideo')!.reference.events;
const extending = findElement('ExtendingVideo')!.reference.events;
expect(complex).toEqual(simple);
expect(extending).toEqual(simple);
});
});
});
@@ -0,0 +1,84 @@
/**
* Mock media contract types mirrors packages/core/src/core/media/types.ts.
*
* Exercises: event extraction from capability event interfaces.
* VideoEvents extends all capability events (including TextTrackListEvents).
* AudioEvents extends a subset (no text track events).
*/
export interface EventLike<Detail = void> {
readonly type: string;
readonly timeStamp: number;
readonly detail?: Detail;
}
export interface MediaPlaybackEvents {
play: EventLike;
playing: EventLike;
waiting: EventLike;
}
export interface MediaPauseEvents {
pause: EventLike;
ended: EventLike;
}
export interface MediaSeekEvents {
timeupdate: EventLike;
durationchange: EventLike;
seeking: EventLike;
seeked: EventLike;
loadedmetadata: EventLike;
}
export interface MediaSourceEvents {
loadstart: EventLike;
emptied: EventLike;
canplay: EventLike;
canplaythrough: EventLike;
loadeddata: EventLike;
}
export interface MediaVolumeEvents {
volumechange: EventLike;
}
export interface MediaPlaybackRateEvents {
ratechange: EventLike;
}
export interface MediaBufferEvents {
progress: EventLike;
}
export interface MediaErrorEvents {
error: EventLike;
}
export interface TextTrackListEvents {
addtrack: EventLike;
removetrack: EventLike;
changetrack: EventLike;
trackmodechange: EventLike;
}
export interface VideoEvents
extends MediaPlaybackEvents,
MediaPauseEvents,
MediaSeekEvents,
MediaSourceEvents,
MediaVolumeEvents,
MediaPlaybackRateEvents,
MediaBufferEvents,
MediaErrorEvents,
TextTrackListEvents {}
export interface AudioEvents
extends MediaPlaybackEvents,
MediaPauseEvents,
MediaSeekEvents,
MediaSourceEvents,
MediaVolumeEvents,
MediaPlaybackRateEvents,
MediaBufferEvents,
MediaErrorEvents {}
@@ -1,11 +1,13 @@
/**
* Mock complex delegate mirrors HlsMediaBase.
* Mock complex host mirrors HlsMedia.
*
* Exercises: multiple getter/setter pairs with JSDoc descriptions,
* readonly properties, boolean type, overlap with native Attributes
* readonly properties, boolean type, overlap with native attributes
* (src, preload) that should be deduplicated by the builder.
*/
export class ComplexDelegate {
import { HTMLVideoElementHost } from '../simple';
export class ComplexHost extends HTMLVideoElementHost {
#src: string = '';
#type: string | undefined;
#preferPlayback: string | undefined = 'mse';
@@ -69,10 +71,4 @@ export class ComplexDelegate {
get engine(): object | null {
return this.#engine;
}
attach(_target: EventTarget): void {}
detach(): void {}
destroy(): void {}
}
export class ComplexCustomMedia {}
@@ -1,37 +1,14 @@
/**
* Mock custom media element infrastructure.
*
* Exercises: shared Events, Attributes, and CSS vars that the builder reads
* to populate media element references. Slots are parsed from the template
* HTML (getVideoTemplateHTML / getAudioTemplateHTML), not from exported arrays.
* Exercises: shared native attributes (via static properties), CSS vars,
* and slots that the builder reads to populate media element references.
* Slots are parsed from getVideoTemplateHTML / getCommonTemplateHTML.
*
* VideoCSSVars/AudioCSSVars follow the `{ camelKey: '--var-name' }` pattern
* with JSDoc descriptions, matching UI component css-vars files.
*/
export const Events = [
'abort',
'canplay',
'durationchange',
'ended',
'pause',
'play',
'timeupdate',
'volumechange',
] as const;
export const Attributes = [
'autoplay',
'controls',
'crossorigin',
'loop',
'muted',
'playsinline',
'poster',
'preload',
'src',
] as const;
/** CSS custom property names for video elements. */
export const VideoCSSVars = {
/** Border radius of the video element. */
@@ -68,26 +45,44 @@ function getVideoTemplateHTML(attrs: Record<string, string>): string {
`;
}
function getAudioTemplateHTML(attrs: Record<string, string>): string {
return /*html*/ `
<style>
audio { width: 100%; }
</style>
<slot name="media">
<audio></audio>
</slot>
<slot></slot>
`;
function getCommonTemplateHTML(tag: string) {
return (attrs: Record<string, string>) => {
return /*html*/ `
<style>
${tag} { width: 100%; }
</style>
<slot name="media">
<${tag}></${tag}>
</slot>
<slot></slot>
`;
};
}
// Minimal stubs — the builder only needs to detect these by name, not run them.
export function CustomMediaMixin(base: any, _opts: any) {
return base;
}
// Stub — the builder parses the AST, it doesn't run the code.
// Mirrors the real CustomMediaElement factory signature.
export function CustomMediaElement(tag: string, Host: any) {
class CustomMedia {
static getTemplateHTML = tag === 'video' ? getVideoTemplateHTML : getCommonTemplateHTML(tag);
static shadowRootOptions = { mode: 'open' };
export const CustomVideoElement = class {
static getTemplateHTML = getVideoTemplateHTML;
};
export const CustomAudioElement = class {
static getTemplateHTML = getAudioTemplateHTML;
};
static properties = {
autoPictureInPicture: { type: Boolean },
autoplay: { type: Boolean },
controls: { type: Boolean },
controlsList: { type: String },
crossOrigin: { type: String },
defaultMuted: { type: Boolean, attribute: 'muted' },
disablePictureInPicture: { type: Boolean },
disableRemotePlayback: { type: Boolean },
loading: { type: String },
loop: { type: Boolean },
playsInline: { type: Boolean },
poster: { type: String },
preload: { type: String },
src: { type: String },
};
}
return CustomMedia;
}
@@ -1,13 +1,13 @@
/**
* Mock extending delegate mirrors MuxMediaBase extending HlsMediaBase.
* Mock extending host mirrors MuxVideoMedia extending HlsMedia.
*
* Exercises: delegate inheritance. The builder must walk the extends chain
* to extract properties from both this class and its parent (ComplexDelegate).
* Exercises: host inheritance. The builder must walk the extends chain
* to extract properties from both this class and its parent (ComplexHost).
* Child properties override parent properties of the same name.
*/
import { ComplexDelegate } from '../complex';
import { ComplexHost } from '../complex';
export class ExtendingDelegate extends ComplexDelegate {
export class ExtendingHost extends ComplexHost {
#playbackId: string = '';
#customDomain: string = '';
@@ -38,5 +38,3 @@ export class ExtendingDelegate extends ComplexDelegate {
super.debug = value;
}
}
export class ExtendingCustomMedia {}
@@ -1,10 +1,18 @@
/**
* Mock simple delegate mirrors DashMediaBase.
* Mock simple host mirrors DashMedia.
*
* Exercises: minimal delegate with just src (read-write) and engine (readonly).
* Exercises: minimal host with just src (read-write) and engine (readonly).
* No JSDoc on properties tests that missing descriptions produce undefined.
*/
export class SimpleDelegate {
// Stub — the builder walks the prototype chain and stops here.
export class HTMLVideoElementHost {
attach(_target: EventTarget): void {}
detach(): void {}
destroy(): void {}
}
export class SimpleHost extends HTMLVideoElementHost {
#src: string = '';
#engine: object = {};
@@ -19,10 +27,4 @@ export class SimpleDelegate {
get engine(): object {
return this.#engine;
}
attach(_target: EventTarget): void {}
detach(): void {}
destroy(): void {}
}
export class SimpleCustomMedia {}
@@ -1,17 +1,16 @@
/**
* Mock complex media element mirrors HlsVideo.
*
* Exercises: standard mixin composition with a complex delegate
* that has JSDoc descriptions on its getter/setters.
* Exercises: standard composition with CustomMediaElement factory
* and a complex host that has JSDoc descriptions on its getter/setters.
*/
import { ComplexCustomMedia, ComplexDelegate } from '../../../../core/src/dom/media/complex';
// Stubs — the builder parses the AST, it doesn't run the code.
import { ComplexHost } from '../../../../core/src/dom/media/complex';
import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element';
// Stub — the builder parses the AST, it doesn't run the code.
function MediaAttachMixin(base: any) {
return base;
}
function MediaPropsMixin(base: any, _delegate: any) {
return base;
}
export class ComplexVideo extends MediaPropsMixin(MediaAttachMixin(ComplexCustomMedia), ComplexDelegate) {}
export class ComplexVideo extends MediaAttachMixin(CustomMediaElement('video', ComplexHost)) {}
@@ -1,15 +1,13 @@
/**
* Mock extending media element mirrors MuxVideo.
*
* Exercises: media element with a delegate that inherits from another delegate.
* Exercises: media element with a host that inherits from another host.
*/
import { ExtendingCustomMedia, ExtendingDelegate } from '../../../../core/src/dom/media/extending';
import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element';
import { ExtendingHost } from '../../../../core/src/dom/media/extending';
function MediaAttachMixin(base: any) {
return base;
}
function MediaPropsMixin(base: any, _delegate: any) {
return base;
}
export class ExtendingVideo extends MediaPropsMixin(MediaAttachMixin(ExtendingCustomMedia), ExtendingDelegate) {}
export class ExtendingVideo extends MediaAttachMixin(CustomMediaElement('video', ExtendingHost)) {}
@@ -1,18 +1,16 @@
/**
* Mock simple media element mirrors DashVideo.
*
* Exercises: standard mixin composition with a simple delegate.
* The builder follows this import chain to discover the delegate class
* Exercises: standard composition with CustomMediaElement factory.
* The builder follows this import chain to discover the host class
* and resolve its properties.
*/
import { SimpleCustomMedia, SimpleDelegate } from '../../../../core/src/dom/media/simple';
import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element';
import { SimpleHost } from '../../../../core/src/dom/media/simple';
// Stubs — the builder parses the AST, it doesn't run the code.
// Stub — the builder parses the AST, it doesn't run the code.
function MediaAttachMixin(base: any) {
return base;
}
function MediaPropsMixin(base: any, _delegate: any) {
return base;
}
export class SimpleVideo extends MediaPropsMixin(MediaAttachMixin(SimpleCustomMedia), SimpleDelegate) {}
export class SimpleVideo extends MediaAttachMixin(CustomMediaElement('video', SimpleHost)) {}