feat(site): media element API reference builder (#1256)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-04-07 14:49:33 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0c8e19a4e0
commit cf357ad9a7
16 changed files with 1258 additions and 0 deletions
@@ -0,0 +1,590 @@
/**
* Media element reference extraction.
*
* Discovers media elements from packages/html/src/define/media/*.ts and extracts
* delegate properties, shared attributes/events/CSS vars, and slots.
*
* Convention:
* - Define files: packages/html/src/define/media/*.ts with inline class + static tagName
* - Media element classes: packages/html/src/media/{name}/index.ts
* composed as MediaPropsMixin(MediaAttachMixin(CustomMedia), Delegate)
* - Delegate classes: packages/core/src/dom/media/{name}/index.ts with getter/setter pairs
* - Shared data: packages/core/src/dom/media/custom-media-element/index.ts
* exports Attributes, Events, VideoCSSVars, AudioCSSVars, and template functions
* - Slots: parsed from getVideoTemplateHTML / getAudioTemplateHTML in custom-media-element
*
* Exclusions (elements discovered but intentionally skipped):
* - container.ts: re-exports a class, doesn't declare one inline → no static tagName found
* - background-video.ts: uses MediaAttachMixin(HTMLElement) without MediaPropsMixin →
* parseMixinChain returns null. Its API reference is manually maintained in MDX.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as ts from 'typescript';
import * as tae from 'typescript-api-extractor';
import { extractCSSVars } from './css-vars-handler.js';
import type { DelegatePropertyDef, MediaElementReference, MediaElementResult } from './pipeline.js';
// ─── Types ───────────────────────────────────────────────────────────
interface MediaElementSource {
defineFilePath: string;
className: string;
tagName: string;
mediaFilePath: string;
delegateFilePath: string;
delegateClassName: string;
customMediaClassName: string;
}
// ─── Module Resolution ───────────────────────────────────────────────
/**
* Resolve an import specifier to an absolute file path using TypeScript's
* module resolution. Handles both relative paths and workspace package
* imports (e.g., @videojs/core/dom/media/hls) via the project's tsconfig.
*/
function resolveModuleToFile(
fromFile: string,
importSpecifier: string,
compilerOptions: ts.CompilerOptions
): string | undefined {
const result = ts.resolveModuleName(importSpecifier, fromFile, compilerOptions, ts.sys);
return result.resolvedModule?.resolvedFileName;
}
// ─── Discovery ───────────────────────────────────────────────────────
function discoverMediaElements(monorepoRoot: string, compilerOptions: ts.CompilerOptions): MediaElementSource[] {
const defineDir = path.join(monorepoRoot, 'packages/html/src/define/media');
if (!fs.existsSync(defineDir)) return [];
const files = fs.readdirSync(defineDir).filter((f) => f.endsWith('.ts'));
const sources: MediaElementSource[] = [];
for (const file of files) {
const filePath = path.join(defineDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const result = parseDefineFile(sourceFile, filePath, compilerOptions);
if (result) {
sources.push(result);
}
}
return sources;
}
/**
* Parse a define/media file to extract class name, tagName, and import chain.
* Returns null if the file doesn't declare an inline class with static tagName
* (container.ts) or if the class doesn't use MediaPropsMixin (background-video.ts).
*/
function parseDefineFile(
sourceFile: ts.SourceFile,
filePath: string,
compilerOptions: ts.CompilerOptions
): MediaElementSource | null {
let className: string | undefined;
let tagName: string | undefined;
let baseClassName: string | undefined;
let baseImportPath: string | undefined;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isClassDeclaration(node) || !node.name) return;
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
if (!node.heritageClauses) return;
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
if (!extendsClause || extendsClause.types.length === 0) return;
for (const member of node.members) {
if (
ts.isPropertyDeclaration(member) &&
member.name &&
ts.isIdentifier(member.name) &&
member.name.text === 'tagName' &&
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
member.initializer &&
ts.isStringLiteral(member.initializer)
) {
className = node.name.text;
tagName = member.initializer.text;
baseClassName = extendsClause.types[0]!.expression.getText(sourceFile);
break;
}
}
});
if (!className || !tagName || !baseClassName) return null;
// Resolve the import path for the base class
ts.forEachChild(sourceFile, (node) => {
if (!ts.isImportDeclaration(node)) return;
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
const importClause = node.importClause;
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
for (const specifier of importClause.namedBindings.elements) {
if (specifier.name.text === baseClassName) {
baseImportPath = node.moduleSpecifier.text;
break;
}
}
});
if (!baseImportPath) return null;
const mediaFilePath = resolveModuleToFile(filePath, baseImportPath, compilerOptions);
if (!mediaFilePath) return null;
// Parse the media element file to find the delegate class
const mediaContent = fs.readFileSync(mediaFilePath, 'utf-8');
const mediaSourceFile = ts.createSourceFile(mediaFilePath, mediaContent, ts.ScriptTarget.Latest, true);
const delegateInfo = parseMixinChain(mediaSourceFile, baseClassName);
if (!delegateInfo) return null;
// Resolve delegate import path
let delegateImportPath: string | undefined;
ts.forEachChild(mediaSourceFile, (node) => {
if (!ts.isImportDeclaration(node)) return;
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
const importClause = node.importClause;
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
for (const specifier of importClause.namedBindings.elements) {
if (specifier.name.text === delegateInfo.delegateClassName) {
delegateImportPath = node.moduleSpecifier.text;
break;
}
}
});
if (!delegateImportPath) return null;
const delegateFilePath = resolveModuleToFile(mediaFilePath, delegateImportPath, compilerOptions);
if (!delegateFilePath) return null;
return {
defineFilePath: filePath,
className: stripElementSuffix(className),
tagName,
mediaFilePath,
delegateFilePath,
delegateClassName: delegateInfo.delegateClassName,
customMediaClassName: delegateInfo.customMediaClassName,
};
}
function stripElementSuffix(name: string): string {
return name.endsWith('Element') ? name.slice(0, -'Element'.length) : name;
}
/**
* Parse the media element class to find the MediaPropsMixin(Base, Delegate) call.
* Returns null for elements that don't use MediaPropsMixin (e.g., BackgroundVideo).
*/
function parseMixinChain(
sourceFile: ts.SourceFile,
className: string
): { delegateClassName: string; customMediaClassName: string } | null {
let delegateClassName: string | undefined;
let customMediaClassName: string | undefined;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isClassDeclaration(node)) return;
if (!node.name || node.name.text !== className) return;
if (!node.heritageClauses) return;
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
if (!extendsClause || extendsClause.types.length === 0) return;
const extendsExpr = extendsClause.types[0]!.expression;
findMediaPropsMixin(extendsExpr);
});
function findMediaPropsMixin(node: ts.Node): void {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'MediaPropsMixin') {
if (node.arguments.length >= 2) {
const delegateArg = node.arguments[1]!;
if (ts.isIdentifier(delegateArg)) {
delegateClassName = delegateArg.text;
}
const baseArg = node.arguments[0]!;
customMediaClassName = unwrapMixinBase(baseArg);
}
return;
}
ts.forEachChild(node, findMediaPropsMixin);
}
if (!delegateClassName || !customMediaClassName) return null;
return { delegateClassName, customMediaClassName };
}
function unwrapMixinBase(node: ts.Node): string | undefined {
if (ts.isIdentifier(node)) return node.text;
if (ts.isCallExpression(node) && node.arguments.length > 0) {
return unwrapMixinBase(node.arguments[0]!);
}
return undefined;
}
// ─── Delegate Property Extraction ────────────────────────────────────
/**
* Extract getter/setter pairs from a delegate class and its ancestors,
* mirroring what buildAttrPropMap() in media-props-mixin.ts does at runtime.
*/
function extractDelegateProperties(
filePath: string,
delegateClassName: string,
compilerOptions: ts.CompilerOptions
): Record<string, DelegatePropertyDef> {
const properties: Record<string, DelegatePropertyDef> = {};
extractClassProperties(filePath, delegateClassName, properties, compilerOptions, new Set());
return properties;
}
/**
* Recursively extract getter/setter pairs from a class and its parent chain.
* Child properties override parent properties (checked via the `seen` set).
*/
function extractClassProperties(
filePath: string,
className: string,
properties: Record<string, DelegatePropertyDef>,
compilerOptions: ts.CompilerOptions,
seen: Set<string>
): void {
if (seen.has(`${filePath}:${className}`)) return;
seen.add(`${filePath}:${className}`);
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const getters = new Map<string, { type: string; description?: string }>();
const setters = new Set<string>();
let parentClassName: string | undefined;
let parentImportPath: string | undefined;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isClassDeclaration(node) || !node.name || node.name.text !== className) return;
// Check for extends clause (delegate inheritance)
if (node.heritageClauses) {
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
if (extendsClause && extendsClause.types.length > 0) {
const extendsExpr = extendsClause.types[0]!.expression;
if (ts.isIdentifier(extendsExpr)) {
parentClassName = extendsExpr.text;
}
}
}
for (const member of node.members) {
if (!ts.isGetAccessorDeclaration(member) && !ts.isSetAccessorDeclaration(member)) continue;
if (!member.name || !ts.isIdentifier(member.name)) continue;
const name = member.name.text;
if (name.startsWith('_') || name.startsWith('#')) continue;
// target is an internal reference to the native media element, not a user-facing property
if (name === 'target') continue;
if (ts.isGetAccessorDeclaration(member)) {
let type = 'unknown';
if (member.type) {
type = member.type.getText(sourceFile);
}
const description = getJSDocDescription(member);
getters.set(name, { type, description });
} else if (ts.isSetAccessorDeclaration(member)) {
setters.add(name);
}
}
});
// Resolve parent class and extract its properties first (child overrides parent)
if (parentClassName && parentClassName !== 'EventTarget') {
// Find the import for the parent class
ts.forEachChild(sourceFile, (node) => {
if (!ts.isImportDeclaration(node)) return;
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
const importClause = node.importClause;
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
for (const specifier of importClause.namedBindings.elements) {
const importedName = (specifier.propertyName ?? specifier.name).text;
if (importedName === parentClassName) {
parentImportPath = node.moduleSpecifier.text;
break;
}
}
});
if (parentImportPath) {
const parentFilePath = resolveModuleToFile(filePath, parentImportPath, compilerOptions);
if (parentFilePath) {
// Extract parent properties first — child will override
extractClassProperties(parentFilePath, parentClassName, properties, compilerOptions, seen);
}
} else {
// Parent is in the same file
extractClassProperties(filePath, parentClassName, properties, compilerOptions, seen);
}
}
// Apply this class's properties (overrides parent)
for (const [name, info] of getters) {
const def: DelegatePropertyDef = {
type: info.type,
readonly: !setters.has(name),
};
if (info.description) def.description = info.description;
properties[name] = def;
}
}
// ─── JSDoc Extraction ────────────────────────────────────────────────
function getJSDocDescription(node: ts.Node): string | undefined {
const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc;
if (!jsDocNodes || jsDocNodes.length === 0) return undefined;
const doc = jsDocNodes[0]!;
if (typeof doc.comment === 'string') return doc.comment;
if (!doc.comment) return undefined;
const parts: string[] = [];
for (const part of doc.comment) {
if (typeof part === 'string') {
parts.push(part);
} else if ('text' in part) {
parts.push(part.text);
}
}
return parts.join('') || undefined;
}
// ─── Shared Data Extraction ──────────────────────────────────────────
function extractStringArray(filePath: string, varName: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const items: string[] = [];
ts.forEachChild(sourceFile, (node) => {
if (!ts.isVariableStatement(node)) return;
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || decl.name.text !== varName) continue;
if (!decl.initializer) continue;
let expr = decl.initializer;
if (ts.isAsExpression(expr)) expr = expr.expression;
if (ts.isArrayLiteralExpression(expr)) {
for (const el of expr.elements) {
if (ts.isStringLiteral(el)) {
items.push(el.text);
}
}
}
}
});
return items;
}
function extractSlotsFromTemplate(filePath: string, templateFnName: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const slots: string[] = [];
function visit(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name?.text === templateFnName && node.body) {
const templateText = extractTemplateString(node.body);
if (templateText) {
parseSlots(templateText, slots);
}
return;
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return slots;
}
function extractTemplateString(block: ts.Block): string | undefined {
for (const stmt of block.statements) {
if (ts.isReturnStatement(stmt) && stmt.expression) {
return getTemplateText(stmt.expression);
}
}
return undefined;
}
function getTemplateText(node: ts.Expression): string | undefined {
if (ts.isTaggedTemplateExpression(node)) {
return getTemplateText(node.template);
}
if (ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
if (ts.isTemplateExpression(node)) {
let text = node.head.text;
for (const span of node.templateSpans) {
text += span.literal.text;
}
return text;
}
return undefined;
}
function parseSlots(html: string, slots: string[]): void {
const slotRegex = /<slot(?:\s+name="([^"]*)")?[^>]*>/g;
let match: RegExpExecArray | null;
while ((match = slotRegex.exec(html)) !== null) {
const name = match[1] ?? '';
slots.push(name);
}
}
/**
* Determine whether a CustomMedia base class is video or audio by checking
* the extends clause of the class that defines it (e.g., DelegateMixin(CustomVideoElement, ...)).
*/
function resolveMediaType(
mediaFilePath: string,
customMediaClassName: string,
compilerOptions: ts.CompilerOptions
): 'video' | 'audio' {
const content = fs.readFileSync(mediaFilePath, 'utf-8');
const sourceFile = ts.createSourceFile(mediaFilePath, content, ts.ScriptTarget.Latest, true);
let importSource: string | undefined;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isImportDeclaration(node)) return;
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
const importClause = node.importClause;
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
for (const specifier of importClause.namedBindings.elements) {
if (specifier.name.text === customMediaClassName) {
importSource = node.moduleSpecifier.text;
break;
}
}
});
// Fallback: default to video (all current media elements are video-based)
if (!importSource) return 'video';
const resolvedPath = resolveModuleToFile(mediaFilePath, importSource, compilerOptions);
if (!resolvedPath) return 'video';
const sourceContent = fs.readFileSync(resolvedPath, 'utf-8');
const resolvedSourceFile = ts.createSourceFile(resolvedPath, sourceContent, ts.ScriptTarget.Latest, true);
// Check the extends clause for CustomAudioElement specifically
let mediaType: 'video' | 'audio' = 'video';
ts.forEachChild(resolvedSourceFile, (node) => {
if (!ts.isClassDeclaration(node) || node.name?.text !== customMediaClassName) return;
if (!node.heritageClauses) return;
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
if (!extendsClause || extendsClause.types.length === 0) return;
// Walk the extends expression looking for CustomAudioElement identifier
function checkForAudio(n: ts.Node): void {
if (ts.isIdentifier(n) && n.text === 'CustomAudioElement') {
mediaType = 'audio';
return;
}
ts.forEachChild(n, checkForAudio);
}
checkForAudio(extendsClause.types[0]!.expression);
});
return mediaType;
}
// ─── Pipeline ────────────────────────────────────────────────────────
export function generateMediaElementReferences(monorepoRoot: string): MediaElementResult[] {
const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json');
const config = tae.loadConfig(tsconfigPath);
config.options.rootDir = monorepoRoot;
const compilerOptions = config.options;
const sources = discoverMediaElements(monorepoRoot, compilerOptions);
if (sources.length === 0) return [];
const customMediaPath = path.join(monorepoRoot, 'packages/core/src/dom/media/custom-media-element/index.ts');
if (!fs.existsSync(customMediaPath)) return [];
// Read shared data
const allAttributes = extractStringArray(customMediaPath, 'Attributes');
const allEvents = extractStringArray(customMediaPath, 'Events');
// Extract CSS vars using the existing handler (needs a TS program)
const program = ts.createProgram([customMediaPath], compilerOptions);
const videoCSSVarsRaw = extractCSSVars(customMediaPath, program, 'Video');
const audioCSSVarsRaw = extractCSSVars(customMediaPath, program, 'Audio');
const videoCSSVars: Record<string, { description: string }> = {};
if (videoCSSVarsRaw) {
for (const v of videoCSSVarsRaw.vars) {
videoCSSVars[v.name] = { description: v.description };
}
}
const audioCSSVars: Record<string, { description: string }> = {};
if (audioCSSVarsRaw) {
for (const v of audioCSSVarsRaw.vars) {
audioCSSVars[v.name] = { description: v.description };
}
}
// Extract slots from template functions
const videoSlots = extractSlotsFromTemplate(customMediaPath, 'getVideoTemplateHTML');
const audioSlots = extractSlotsFromTemplate(customMediaPath, 'getAudioTemplateHTML');
const results: MediaElementResult[] = [];
for (const source of sources) {
const delegateProperties = extractDelegateProperties(
source.delegateFilePath,
source.delegateClassName,
compilerOptions
);
const mediaType = resolveMediaType(source.mediaFilePath, source.customMediaClassName, compilerOptions);
// Deduplicate: delegate props that overlap with native Attributes
const delegateAttrNames = new Set<string>();
for (const propName of Object.keys(delegateProperties)) {
delegateAttrNames.add(propName.toLowerCase());
}
const nativeAttributes = allAttributes.filter((attr) => !delegateAttrNames.has(attr));
const cssCustomProperties = mediaType === 'video' ? videoCSSVars : audioCSSVars;
const slots = mediaType === 'video' ? videoSlots : audioSlots;
const reference: MediaElementReference = {
name: source.className,
tagName: source.tagName,
delegateProperties,
nativeAttributes,
events: [...allEvents],
cssCustomProperties,
slots,
};
results.push({ name: source.className, reference });
}
return results;
}
@@ -570,3 +570,30 @@ export interface PresetResult {
}
export { generatePresetReferences } from './preset-handler.js';
// ═══════════════════════════════════════════════════════════════════════
// MEDIA ELEMENT REFERENCE PIPELINE
// ═══════════════════════════════════════════════════════════════════════
export interface DelegatePropertyDef {
type: string;
description?: string;
readonly: boolean;
}
export interface MediaElementReference {
name: string;
tagName: string;
delegateProperties: Record<string, DelegatePropertyDef>;
nativeAttributes: string[];
events: string[];
cssCustomProperties: Record<string, { description: string }>;
slots: string[];
}
export interface MediaElementResult {
name: string;
reference: MediaElementReference;
}
export { generateMediaElementReferences } from './media-element-handler.js';
@@ -59,6 +59,24 @@
* video/ — Exercises: feature bundle, React skins (*Skin naming),
* media element export, tailwind skin exclusion.
* audio/ — Exercises: single skin, different media element.
*
* 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
* from custom-media-element, slots parsed from template HTML.
* complex-video — Complex media element. Exercises: delegate 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
* 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.
*/
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -66,7 +84,9 @@ import {
type FeatureResult,
generateComponentReferences,
generateFeatureReferences,
generateMediaElementReferences,
generatePresetReferences,
type MediaElementResult,
type PresetResult,
} from '../pipeline';
import { getUtilEntries, type UtilEntry } from '../util-handler';
@@ -962,3 +982,263 @@ describe('Preset pipeline (end-to-end)', () => {
});
});
});
// ═══════════════════════════════════════════════════════════════════════
// MEDIA ELEMENT PIPELINE
// ═══════════════════════════════════════════════════════════════════════
//
// Media elements are custom elements that wrap native <video>/<audio> with
// streaming delegates (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
//
// 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
describe('Media element pipeline (end-to-end)', () => {
const results = generateMediaElementReferences(FIXTURE_ROOT);
function findElement(name: string): MediaElementResult | undefined {
return results.find((r) => r.name === name);
}
// ─────────────────────────────────────────────────────────────────
// DISCOVERY
// ─────────────────────────────────────────────────────────────────
describe('Discovery', () => {
it('discovers media elements from define/media/ files', () => {
const names = results.map((r) => r.name).sort();
expect(names).toEqual(['ComplexVideo', 'ExtendingVideo', 'SimpleVideo']);
});
it('excludes container (re-export, not inline class declaration)', () => {
expect(findElement('MediaContainer')).toBeUndefined();
expect(findElement('MediaContainerElement')).toBeUndefined();
});
it('excludes background-video (no MediaPropsMixin, manually maintained)', () => {
expect(findElement('BackgroundVideo')).toBeUndefined();
expect(findElement('BackgroundVideoElement')).toBeUndefined();
});
it('produces one result per media element', () => {
expect(results.length).toBe(3);
});
});
// ─────────────────────────────────────────────────────────────────
// 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.
describe('SimpleVideo (minimal delegate)', () => {
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;
// src: read-write string
expect(props.src).toMatchObject({
type: 'string',
readonly: false,
});
expect(props.src.description).toBeUndefined();
// engine: readonly object
expect(props.engine).toMatchObject({
type: 'object',
readonly: true,
});
});
it('excludes delegate methods (attach, detach, destroy)', () => {
const props = findElement('SimpleVideo')!.reference.delegateProperties;
expect(props.attach).toBeUndefined();
expect(props.detach).toBeUndefined();
expect(props.destroy).toBeUndefined();
});
it('includes native attributes from the shared Attributes array', () => {
const ref = findElement('SimpleVideo')!.reference;
// src is in the delegate, so it should be omitted from nativeAttributes
expect(ref.nativeAttributes).toEqual(
expect.arrayContaining([
'autoplay',
'controls',
'crossorigin',
'loop',
'muted',
'playsinline',
'poster',
'preload',
])
);
expect(ref.nativeAttributes).not.toContain('src');
});
it('includes events from the shared Events array', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.events).toEqual(
expect.arrayContaining([
'abort',
'canplay',
'durationchange',
'ended',
'pause',
'play',
'timeupdate',
'volumechange',
])
);
});
it('includes CSS custom properties from VideoCSSVars', () => {
const css = findElement('SimpleVideo')!.reference.cssCustomProperties;
expect(css['--media-object-fit']).toEqual({
description: 'Object fit for the video.',
});
expect(css['--media-video-border-radius']).toEqual({
description: 'Border radius of the video element.',
});
});
it('includes slots parsed from the video template HTML', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.slots).toEqual(expect.arrayContaining(['media', '']));
});
});
// ─────────────────────────────────────────────────────────────────
// 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).
// Tests that the builder extracts descriptions from JSDoc on getters and
// deduplicates delegate props from nativeAttributes.
describe('ComplexVideo (full delegate, 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;
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;
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.');
expect(props.engine.description).toBe('The underlying playback engine instance.');
});
it('marks readonly properties correctly', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
// engine: getter only → readonly
expect(props.engine.readonly).toBe(true);
// src: getter + setter → not readonly
expect(props.src.readonly).toBe(false);
expect(props.debug.readonly).toBe(false);
});
it('extracts property types', () => {
const props = findElement('ComplexVideo')!.reference.delegateProperties;
expect(props.src.type).toBe('string');
expect(props.debug.type).toBe('boolean');
expect(props.config.type).toContain('Record');
});
it('deduplicates delegate 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();
// ...and be omitted from nativeAttributes
expect(ref.nativeAttributes).not.toContain('src');
expect(ref.nativeAttributes).not.toContain('preload');
// Other native attrs remain
expect(ref.nativeAttributes).toContain('autoplay');
expect(ref.nativeAttributes).toContain('controls');
});
});
// ─────────────────────────────────────────────────────────────────
// EXTENDING MEDIA ELEMENT: ExtendingVideo
// ─────────────────────────────────────────────────────────────────
//
// A media element whose delegate extends another delegate (mirrors
// MuxMediaDelegate extending HlsMediaDelegate). The builder must
// walk the extends chain to include inherited properties. Child
// properties override parent definitions.
describe('ExtendingVideo (delegate 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;
expect(props.playbackId).toMatchObject({
type: 'string',
readonly: false,
description: 'The playback ID for the video.',
});
expect(props.customDomain).toMatchObject({
type: 'string',
readonly: false,
description: 'Custom domain for asset delivery.',
});
});
it('includes inherited properties from ComplexDelegate', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
// These are inherited from ComplexDelegate
expect(props.src).toBeDefined();
expect(props.type).toBeDefined();
expect(props.preferPlayback).toBeDefined();
expect(props.config).toBeDefined();
expect(props.preload).toBeDefined();
expect(props.engine).toBeDefined();
});
it('child overrides replace parent definitions', () => {
const props = findElement('ExtendingVideo')!.reference.delegateProperties;
// ExtendingDelegate 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
expect(props.engine.readonly).toBe(true);
});
});
});
@@ -0,0 +1,78 @@
/**
* Mock complex delegate mirrors HlsMediaDelegate.
*
* Exercises: multiple getter/setter pairs with JSDoc descriptions,
* readonly properties, boolean type, overlap with native Attributes
* (src, preload) that should be deduplicated by the builder.
*/
export class ComplexDelegate {
#src: string = '';
#type: string | undefined;
#preferPlayback: string | undefined = 'mse';
#config: Record<string, unknown> = {};
#debug: boolean = false;
#preload: string = 'metadata';
#engine: object | null = null;
get src(): string {
return this.#src;
}
set src(value: string) {
this.#src = value;
}
/** Explicit source type. When unset, inferred from the source URL extension. */
get type(): string | undefined {
return this.#type;
}
set type(value: string | undefined) {
this.#type = value;
}
/** Whether to prefer `'mse'` or `'native'` playback. */
get preferPlayback(): string | undefined {
return this.#preferPlayback;
}
set preferPlayback(value: string | undefined) {
this.#preferPlayback = value;
}
get config(): Record<string, unknown> {
return this.#config;
}
set config(value: Record<string, unknown>) {
this.#config = value;
}
/** Enable debug logging. */
get debug(): boolean {
return this.#debug;
}
set debug(value: boolean) {
this.#debug = value;
}
get preload(): string {
return this.#preload;
}
set preload(value: string) {
this.#preload = value;
}
/** The underlying playback engine instance. */
get engine(): object | null {
return this.#engine;
}
attach(_target: EventTarget): void {}
detach(): void {}
destroy(): void {}
}
export class ComplexCustomMedia {}
@@ -0,0 +1,93 @@
/**
* 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.
*
* 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. */
borderRadius: '--media-video-border-radius',
/** Object fit for the video. */
objectFit: '--media-object-fit',
/** Object position for the video. */
objectPosition: '--media-object-position',
/** Duration of the caption track transition. */
captionTrackDuration: '--media-caption-track-duration',
/** Delay before the caption track transition. */
captionTrackDelay: '--media-caption-track-delay',
/** Vertical offset of the caption track. */
captionTrackY: '--media-caption-track-y',
} as const;
/** CSS custom property names for audio elements. */
export const AudioCSSVars = {} as const;
// Minimal template stubs — the builder parses <slot> elements from these.
function getVideoTemplateHTML(attrs: Record<string, string>): string {
return /*html*/ `
<style>
video {
border-radius: var(${VideoCSSVars.borderRadius});
object-fit: var(${VideoCSSVars.objectFit}, contain);
object-position: var(${VideoCSSVars.objectPosition}, center);
}
</style>
<slot name="media">
<video></video>
</slot>
<slot></slot>
`;
}
function getAudioTemplateHTML(attrs: Record<string, string>): string {
return /*html*/ `
<style>
audio { width: 100%; }
</style>
<slot name="media">
<audio></audio>
</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;
}
export const CustomVideoElement = class {
static getTemplateHTML = getVideoTemplateHTML;
};
export const CustomAudioElement = class {
static getTemplateHTML = getAudioTemplateHTML;
};
@@ -0,0 +1,42 @@
/**
* Mock extending delegate mirrors MuxMediaDelegate extending HlsMediaDelegate.
*
* Exercises: delegate inheritance. The builder must walk the extends chain
* to extract properties from both this class and its parent (ComplexDelegate).
* Child properties override parent properties of the same name.
*/
import { ComplexDelegate } from '../complex';
export class ExtendingDelegate extends ComplexDelegate {
#playbackId: string = '';
#customDomain: string = '';
/** The playback ID for the video. */
get playbackId(): string {
return this.#playbackId;
}
set playbackId(value: string) {
this.#playbackId = value;
}
/** Custom domain for asset delivery. */
get customDomain(): string {
return this.#customDomain;
}
set customDomain(value: string) {
this.#customDomain = value;
}
/** Overrides parent debug — adds network logging. */
get debug(): boolean {
return super.debug;
}
set debug(value: boolean) {
super.debug = value;
}
}
export class ExtendingCustomMedia {}
@@ -0,0 +1,28 @@
/**
* Mock simple delegate mirrors DashMediaDelegate.
*
* Exercises: minimal delegate with just src (read-write) and engine (readonly).
* No JSDoc on properties tests that missing descriptions produce undefined.
*/
export class SimpleDelegate {
#src: string = '';
#engine: object = {};
get src(): string {
return this.#src;
}
set src(value: string) {
this.#src = value;
}
get engine(): object {
return this.#engine;
}
attach(_target: EventTarget): void {}
detach(): void {}
destroy(): void {}
}
export class SimpleCustomMedia {}
@@ -0,0 +1,13 @@
/**
* Mock background video registration mirrors define/media/background-video.ts.
*
* Exercises: exclusion. BackgroundVideo uses MediaAttachMixin(HTMLElement)
* without MediaPropsMixin. The builder should discover this file (it has
* static tagName) but skip it because parseMixinChain returns null.
* Its API reference is manually maintained in MDX (#1243).
*/
import { BackgroundVideo } from '../../media/background-video';
export class BackgroundVideoElement extends BackgroundVideo {
static readonly tagName = 'background-video';
}
@@ -0,0 +1,11 @@
/**
* Mock complex video element registration mirrors define/media/hls-video.ts.
*
* Exercises: element discovery via static tagName in define/media/*.ts,
* with a delegate that has JSDoc and overlapping native attributes.
*/
import { ComplexVideo } from '../../media/complex-video';
export class ComplexVideoElement extends ComplexVideo {
static readonly tagName = 'complex-video';
}
@@ -0,0 +1,14 @@
/**
* Mock media container registration mirrors define/media/container.ts.
*
* Exercises: container exclusion. The real container.ts does NOT define a
* new class with `static tagName` inline it imports an already-defined
* class. The builder should exclude this from media element discovery.
*/
class MediaContainerElement {
static readonly tagName = 'media-container';
}
// No `export class ... extends` with `static tagName` — the class is
// defined elsewhere and only registered here.
export { MediaContainerElement };
@@ -0,0 +1,10 @@
/**
* Mock extending video element registration mirrors define/media/mux-video.ts.
*
* Exercises: element with a delegate that extends another delegate.
*/
import { ExtendingVideo } from '../../media/extending-video';
export class ExtendingVideoElement extends ExtendingVideo {
static readonly tagName = 'extending-video';
}
@@ -0,0 +1,10 @@
/**
* Mock simple video element registration mirrors define/media/dash-video.ts.
*
* Exercises: element discovery via static tagName in define/media/*.ts.
*/
import { SimpleVideo } from '../../media/simple-video';
export class SimpleVideoElement extends SimpleVideo {
static readonly tagName = 'simple-video';
}
@@ -0,0 +1,12 @@
/**
* Mock background video mirrors the real BackgroundVideo.
*
* Exercises: exclusion of elements that use MediaAttachMixin(HTMLElement)
* without MediaPropsMixin. The builder's parseMixinChain returns null
* because there is no MediaPropsMixin call in the extends chain.
*/
function MediaAttachMixin(base: any) {
return base;
}
export class BackgroundVideo extends MediaAttachMixin(Object) {}
@@ -0,0 +1,17 @@
/**
* Mock complex media element mirrors HlsVideo.
*
* Exercises: standard mixin composition with a complex delegate
* 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.
function MediaAttachMixin(base: any) {
return base;
}
function MediaPropsMixin(base: any, _delegate: any) {
return base;
}
export class ComplexVideo extends MediaPropsMixin(MediaAttachMixin(ComplexCustomMedia), ComplexDelegate) {}
@@ -0,0 +1,15 @@
/**
* Mock extending media element mirrors MuxVideo.
*
* Exercises: media element with a delegate that inherits from another delegate.
*/
import { ExtendingCustomMedia, ExtendingDelegate } 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) {}
@@ -0,0 +1,18 @@
/**
* 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
* and resolve its properties.
*/
import { SimpleCustomMedia, SimpleDelegate } from '../../../../core/src/dom/media/simple';
// Stubs — 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) {}