mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(site): surface native media properties on element reference pages (#1722)
This commit is contained in:
@@ -24,7 +24,14 @@ 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 { HostPropertyDef, MediaElementReference, MediaElementResult, MediaEventDef } from './pipeline.js';
|
||||
import type {
|
||||
HostPropertyDef,
|
||||
MediaElementReference,
|
||||
MediaElementResult,
|
||||
MediaEventDef,
|
||||
MediaTargetTag,
|
||||
ReactMediaReference,
|
||||
} from './pipeline.js';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,6 +53,12 @@ interface MediaElementSource {
|
||||
hostFilePath: string;
|
||||
hostClassName: string;
|
||||
mediaType: 'video' | 'audio';
|
||||
targetTag: MediaTargetTag;
|
||||
}
|
||||
|
||||
interface StaticMediaProperty {
|
||||
property: string;
|
||||
attribute: string;
|
||||
}
|
||||
|
||||
// ─── Module Resolution ───────────────────────────────────────────────
|
||||
@@ -206,6 +219,7 @@ function parseDefineFile(
|
||||
hostFilePath,
|
||||
hostClassName: hostInfo.hostClassName,
|
||||
mediaType: hostInfo.mediaType,
|
||||
targetTag: hostInfo.targetTag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,9 +234,10 @@ function stripElementSuffix(name: string): string {
|
||||
function parseCustomMediaElementCall(
|
||||
sourceFile: ts.SourceFile,
|
||||
className: string
|
||||
): { hostClassName: string; mediaType: 'video' | 'audio' } | null {
|
||||
): { hostClassName: string; mediaType: 'video' | 'audio'; targetTag: MediaTargetTag } | null {
|
||||
let hostClassName: string | undefined;
|
||||
let mediaType: 'video' | 'audio' | undefined;
|
||||
let targetTag: MediaTargetTag | undefined;
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isClassDeclaration(node)) return;
|
||||
@@ -236,6 +251,15 @@ function parseCustomMediaElementCall(
|
||||
findCustomMediaElement(extendsExpr);
|
||||
});
|
||||
|
||||
// Media implementations may put template behavior on a local base class
|
||||
// and export a thin mixed-in subclass (VimeoVideo is the real-world case).
|
||||
// Each media module owns a single CustomMediaElement composition, so use
|
||||
// that composition when it is not directly present in the exported class's
|
||||
// extends expression.
|
||||
if (!hostClassName || !mediaType || !targetTag) {
|
||||
findCustomMediaElement(sourceFile);
|
||||
}
|
||||
|
||||
function findCustomMediaElement(node: ts.Node): void {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
@@ -243,10 +267,11 @@ function parseCustomMediaElementCall(
|
||||
node.expression.text === 'CustomMediaElement'
|
||||
) {
|
||||
if (node.arguments.length >= 2) {
|
||||
// First arg: media type string literal ('video' or 'audio')
|
||||
// First arg: rendered target tag (`video`, `audio`, or `iframe`).
|
||||
const tagArg = node.arguments[0]!;
|
||||
if (ts.isStringLiteral(tagArg)) {
|
||||
mediaType = tagArg.text === 'audio' ? 'audio' : 'video';
|
||||
if (ts.isStringLiteral(tagArg) && ['video', 'audio', 'iframe'].includes(tagArg.text)) {
|
||||
targetTag = tagArg.text as MediaTargetTag;
|
||||
mediaType = targetTag === 'audio' ? 'audio' : 'video';
|
||||
}
|
||||
// Second arg: host class identifier
|
||||
const hostArg = node.arguments[1]!;
|
||||
@@ -259,8 +284,8 @@ function parseCustomMediaElementCall(
|
||||
ts.forEachChild(node, findCustomMediaElement);
|
||||
}
|
||||
|
||||
if (!hostClassName || !mediaType) return null;
|
||||
return { hostClassName, mediaType };
|
||||
if (!hostClassName || !mediaType || !targetTag) return null;
|
||||
return { hostClassName, mediaType, targetTag };
|
||||
}
|
||||
|
||||
// ─── Host Property Extraction ───────────────────────────────────────
|
||||
@@ -931,14 +956,14 @@ function serializeDefaultValue(
|
||||
// ─── Shared Data Extraction ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract native attribute names from the `static properties` object inside
|
||||
* the CustomMediaElement factory. Each key maps to an attribute name via
|
||||
* `props[key].attribute ?? key.toLowerCase()`.
|
||||
* Extract property-to-attribute mappings from the `static properties` object
|
||||
* inside the CustomMediaElement factory. The property name is needed to
|
||||
* classify standard attributes separately from Video.js-specific ones.
|
||||
*/
|
||||
function extractStaticProperties(filePath: string): string[] {
|
||||
function extractStaticProperties(filePath: string): StaticMediaProperty[] {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||
const attributes: string[] = [];
|
||||
const properties: StaticMediaProperty[] = [];
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
// Look for: static properties = { ... }
|
||||
@@ -971,7 +996,7 @@ function extractStaticProperties(filePath: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
attributes.push(attrName);
|
||||
properties.push({ property: propName, attribute: attrName });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -979,7 +1004,146 @@ function extractStaticProperties(filePath: string): string[] {
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return attributes;
|
||||
return properties;
|
||||
}
|
||||
|
||||
// ─── React Surface Extraction ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the public React surface from the matching media component.
|
||||
*
|
||||
* Convention:
|
||||
* - `forwardRef<HTML*Element, *Props>` declares the public ref target.
|
||||
* - extending `VideoHTMLAttributes` / `AudioHTMLAttributes` opts into the
|
||||
* native React DOM props.
|
||||
* - the defaults object passed to `useSyncProps` is the runtime source of
|
||||
* truth for Video.js-specific props.
|
||||
*/
|
||||
function extractReactReference(
|
||||
monorepoRoot: string,
|
||||
source: MediaElementSource,
|
||||
compilerOptions: ts.CompilerOptions,
|
||||
propertyDefinitions: Record<string, HostPropertyDef>
|
||||
): ReactMediaReference | undefined {
|
||||
const mediaDirectory = path.basename(path.dirname(source.mediaFilePath));
|
||||
const reactFilePath = path.join(monorepoRoot, 'packages/react/src/media', mediaDirectory, 'index.tsx');
|
||||
if (!fs.existsSync(reactFilePath)) return undefined;
|
||||
|
||||
const content = fs.readFileSync(reactFilePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(reactFilePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const propsInterfaceName = `${source.className}Props`;
|
||||
let target: MediaTargetTag | undefined;
|
||||
let acceptsNativeProps = false;
|
||||
let defaultsName: string | undefined;
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isInterfaceDeclaration(node) && node.name.text === propsInterfaceName) {
|
||||
acceptsNativeProps =
|
||||
node.heritageClauses?.some((clause) =>
|
||||
clause.types.some((type) => /(?:Video|Audio)HTMLAttributes/.test(type.getText(sourceFile)))
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.name.text === source.className &&
|
||||
node.initializer &&
|
||||
ts.isCallExpression(node.initializer) &&
|
||||
ts.isIdentifier(node.initializer.expression) &&
|
||||
node.initializer.expression.text === 'forwardRef'
|
||||
) {
|
||||
const refType = node.initializer.typeArguments?.[0]?.getText(sourceFile);
|
||||
if (refType === 'HTMLVideoElement') target = 'video';
|
||||
if (refType === 'HTMLAudioElement') target = 'audio';
|
||||
if (refType === 'HTMLIFrameElement') target = 'iframe';
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === 'useSyncProps' &&
|
||||
node.arguments.length >= 3
|
||||
) {
|
||||
const defaultsArg = node.arguments[2]!;
|
||||
if (ts.isIdentifier(defaultsArg)) defaultsName = defaultsArg.text;
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
if (!target) return undefined;
|
||||
|
||||
const props: Record<string, HostPropertyDef> = {};
|
||||
if (defaultsName) {
|
||||
const resolved = resolveConstObjectLiteral(defaultsName, sourceFile, reactFilePath, compilerOptions);
|
||||
if (resolved) {
|
||||
const defaultValues = resolveObjectLiteralEntries(
|
||||
resolved.objectLiteral,
|
||||
resolved.sourceFile,
|
||||
resolved.filePath,
|
||||
compilerOptions,
|
||||
new Set()
|
||||
);
|
||||
const names = resolveObjectLiteralPropertyNames(
|
||||
resolved.objectLiteral,
|
||||
resolved.sourceFile,
|
||||
resolved.filePath,
|
||||
compilerOptions,
|
||||
new Set()
|
||||
);
|
||||
|
||||
for (const name of [...names].sort()) {
|
||||
const definition = propertyDefinitions[name];
|
||||
const prop: HostPropertyDef = definition
|
||||
? { ...definition, readonly: false }
|
||||
: { type: 'unknown', readonly: false };
|
||||
const defaultValue = defaultValues.get(name);
|
||||
if (defaultValue !== undefined) prop.default = defaultValue;
|
||||
props[name] = prop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { target, acceptsNativeProps, props };
|
||||
}
|
||||
|
||||
/** Collect object-literal keys, including keys whose defaults are not serializable. */
|
||||
function resolveObjectLiteralPropertyNames(
|
||||
objectLiteral: ts.ObjectLiteralExpression,
|
||||
sourceFile: ts.SourceFile,
|
||||
filePath: string,
|
||||
compilerOptions: ts.CompilerOptions,
|
||||
visited: Set<string>
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const prop of objectLiteral.properties) {
|
||||
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
||||
names.add(prop.name.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isSpreadAssignment(prop) && ts.isIdentifier(prop.expression)) {
|
||||
const resolved = resolveConstObjectLiteral(prop.expression.text, sourceFile, filePath, compilerOptions);
|
||||
if (!resolved) continue;
|
||||
const visitKey = `${resolved.filePath}::${prop.expression.text}`;
|
||||
if (visited.has(visitKey)) continue;
|
||||
visited.add(visitKey);
|
||||
for (const name of resolveObjectLiteralPropertyNames(
|
||||
resolved.objectLiteral,
|
||||
resolved.sourceFile,
|
||||
resolved.filePath,
|
||||
compilerOptions,
|
||||
visited
|
||||
)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
// ─── Method Extraction ───────────────────────────────────────────────
|
||||
@@ -1237,6 +1401,52 @@ function parseFiresTagComment(tag: ts.JSDocTag): { name: string; description: st
|
||||
|
||||
function scanForDispatchEvents(sourceFile: ts.SourceFile, events: Set<string>): void {
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isForOfStatement(node) &&
|
||||
ts.isVariableDeclarationList(node.initializer) &&
|
||||
ts.isArrayLiteralExpression(node.expression)
|
||||
) {
|
||||
const declaration = node.initializer.declarations[0];
|
||||
const loopName = declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : undefined;
|
||||
let dispatchesLoopValue = false;
|
||||
|
||||
if (loopName) {
|
||||
function findLoopDispatch(child: ts.Node): void {
|
||||
if (
|
||||
ts.isNewExpression(child) &&
|
||||
ts.isIdentifier(child.expression) &&
|
||||
child.expression.text === 'Event' &&
|
||||
child.arguments?.[0] &&
|
||||
ts.isIdentifier(child.arguments[0]) &&
|
||||
child.arguments[0].text === loopName
|
||||
) {
|
||||
dispatchesLoopValue = true;
|
||||
}
|
||||
ts.forEachChild(child, findLoopDispatch);
|
||||
}
|
||||
findLoopDispatch(node.statement);
|
||||
}
|
||||
|
||||
if (dispatchesLoopValue) {
|
||||
for (const element of node.expression.elements) {
|
||||
if (ts.isStringLiteral(element)) events.add(element.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter implementations commonly use a local `emit` helper to bridge
|
||||
// events from a third-party player (for example Vimeo). Literal calls are
|
||||
// still an unambiguous declaration of the events the adapter implements.
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === 'emit' &&
|
||||
node.arguments[0] &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
events.add(node.arguments[0].text);
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
@@ -1294,7 +1504,7 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
if (!fs.existsSync(customMediaPath)) return [];
|
||||
|
||||
// Read shared data
|
||||
const allAttributes = extractStaticProperties(customMediaPath);
|
||||
const staticProperties = extractStaticProperties(customMediaPath);
|
||||
|
||||
// Extract events from capability contract types
|
||||
const mediaTypesPath = path.join(monorepoRoot, 'packages/core/src/core/media/types.ts');
|
||||
@@ -1334,7 +1544,13 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
lib: dedupeStrings([...(compilerOptions.lib ?? []), 'lib.dom.d.ts']),
|
||||
};
|
||||
const program = ts.createProgram(
|
||||
dedupeStrings([customMediaPath, ...sources.map((s) => s.hostFilePath)]),
|
||||
dedupeStrings([
|
||||
customMediaPath,
|
||||
mediaHostPath,
|
||||
videoHostPath,
|
||||
audioHostPath,
|
||||
...sources.map((s) => s.hostFilePath),
|
||||
]),
|
||||
programOptions
|
||||
);
|
||||
const checker = program.getTypeChecker();
|
||||
@@ -1347,6 +1563,36 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
? collectNativeMemberNames(program, customMediaSourceFile)
|
||||
: new Set<string>();
|
||||
|
||||
const baseHostProperties = extractHostProperties(mediaHostPath, 'HTMLMediaElementHost', compilerOptions, nativeNames);
|
||||
const videoHostProperties = extractHostProperties(
|
||||
videoHostPath,
|
||||
'HTMLVideoElementHost',
|
||||
compilerOptions,
|
||||
nativeNames
|
||||
);
|
||||
const audioHostProperties = extractHostProperties(
|
||||
audioHostPath,
|
||||
'HTMLAudioElementHost',
|
||||
compilerOptions,
|
||||
nativeNames
|
||||
);
|
||||
|
||||
function fillInferredTypes(properties: Record<string, HostPropertyDef>, filePath: string, className: string): void {
|
||||
const inferredTypes = resolveInferredTypes(filePath, className, program, checker);
|
||||
for (const [name, def] of Object.entries(properties)) {
|
||||
if (def.type === 'unknown' && inferredTypes.has(name)) {
|
||||
def.type = inferredTypes.get(name)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fillInferredTypes(baseHostProperties, mediaHostPath, 'HTMLMediaElementHost');
|
||||
fillInferredTypes(videoHostProperties, videoHostPath, 'HTMLVideoElementHost');
|
||||
fillInferredTypes(audioHostProperties, audioHostPath, 'HTMLAudioElementHost');
|
||||
|
||||
const videoBaseSurface = { ...baseHostProperties, ...videoHostProperties };
|
||||
const audioBaseSurface = { ...baseHostProperties, ...audioHostProperties };
|
||||
|
||||
const videoCSSVars: Record<string, { description: string }> = {};
|
||||
if (videoCSSVarsRaw) {
|
||||
for (const v of videoCSSVarsRaw.vars) {
|
||||
@@ -1371,25 +1617,39 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
nativeNames
|
||||
);
|
||||
|
||||
// The AST walk only reads explicit return-type annotations; getters without
|
||||
// one fall back to the literal string 'unknown'. Fill those gaps from the
|
||||
// type checker, which infers the real type across the mixin chain. Authored
|
||||
// annotations are left untouched.
|
||||
const inferredTypes = resolveInferredTypes(source.hostFilePath, source.hostClassName, program, checker);
|
||||
for (const [name, def] of Object.entries(hostProperties)) {
|
||||
if (def.type === 'unknown' && inferredTypes.has(name)) {
|
||||
def.type = inferredTypes.get(name)!;
|
||||
fillInferredTypes(hostProperties, source.hostFilePath, source.hostClassName);
|
||||
|
||||
const baseSurface =
|
||||
source.targetTag === 'video' ? videoBaseSurface : source.targetTag === 'audio' ? audioBaseSurface : {};
|
||||
const publicProperties = { ...baseSurface, ...hostProperties };
|
||||
const propertyDefinitions: Record<string, HostPropertyDef> = {};
|
||||
for (const [name, definition] of Object.entries(baseSurface)) {
|
||||
if (!definition.overridesNative) propertyDefinitions[name] = definition;
|
||||
}
|
||||
Object.assign(propertyDefinitions, hostProperties);
|
||||
|
||||
const standardAttributes: string[] = [];
|
||||
const customAttributes: Record<string, HostPropertyDef> = {};
|
||||
for (const { property, attribute } of staticProperties) {
|
||||
const definition = publicProperties[property];
|
||||
|
||||
if (source.targetTag === 'iframe') {
|
||||
// Embed attributes only have media semantics when the synthetic host
|
||||
// implements the corresponding property. Unmatched shared attributes
|
||||
// are inert because iframe targets do not receive attribute forwarding.
|
||||
if (definition) customAttributes[attribute] = { ...definition, readonly: false };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition && !definition.overridesNative) {
|
||||
customAttributes[attribute] = { ...definition, readonly: false };
|
||||
} else {
|
||||
standardAttributes.push(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
// Native attributes are the COMPLETE markup-settable set from `static
|
||||
// properties`. Host-owned names (src/preload/stream-type) intentionally
|
||||
// overlap with hostProperties — this mirrors MDN's content-attribute vs
|
||||
// IDL-property model: the same name is both a settable attribute and a
|
||||
// richer JS property.
|
||||
const nativeAttributes = [...allAttributes];
|
||||
|
||||
const cssCustomProperties = source.mediaType === 'video' ? videoCSSVars : audioCSSVars;
|
||||
const cssCustomProperties =
|
||||
source.targetTag === 'video' ? videoCSSVars : source.targetTag === 'audio' ? audioCSSVars : {};
|
||||
|
||||
// Walk the host's mixin/parent chain collecting `@fires` descriptions. An
|
||||
// event is documented as element-specific iff it carries a `@fires` tag —
|
||||
@@ -1397,8 +1657,24 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
// DOM events are never tagged, and a tagged event stays documented even when
|
||||
// it also lives in the typed media events contract (e.g. streamtypechange).
|
||||
const fires = new Map<string, string>();
|
||||
extractDispatchedEvents(source.hostFilePath, source.hostClassName, compilerOptions, new Set(), new Set(), fires);
|
||||
const elementSpecific: MediaEventDef[] = [...fires.keys()].sort().map((name) => {
|
||||
const dispatchedEvents = new Set<string>();
|
||||
extractDispatchedEvents(
|
||||
source.hostFilePath,
|
||||
source.hostClassName,
|
||||
compilerOptions,
|
||||
new Set(),
|
||||
dispatchedEvents,
|
||||
fires
|
||||
);
|
||||
const contractEvents = source.mediaType === 'video' ? videoEvents : audioEvents;
|
||||
const contractEventNames = new Set(contractEvents);
|
||||
const customEventNamesForElement = new Set(fires.keys());
|
||||
if (source.targetTag === 'iframe') {
|
||||
for (const name of dispatchedEvents) {
|
||||
if (!contractEventNames.has(name)) customEventNamesForElement.add(name);
|
||||
}
|
||||
}
|
||||
const customEvents: MediaEventDef[] = [...customEventNamesForElement].sort().map((name) => {
|
||||
const def: MediaEventDef = { name };
|
||||
const description = fires.get(name);
|
||||
if (description) def.description = description;
|
||||
@@ -1411,22 +1687,52 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
|
||||
// capability interfaces — these are never native, even on elements that
|
||||
// don't fire them (e.g. dash-video has no streamType, so streamtypechange
|
||||
// appears nowhere).
|
||||
const elementSpecificNames = new Set(elementSpecific.map((e) => e.name));
|
||||
const native = (source.mediaType === 'video' ? videoEvents : audioEvents).filter(
|
||||
(n) => !elementSpecificNames.has(n) && !customEventNames.has(n)
|
||||
const customEventSet = new Set(customEvents.map((event) => event.name));
|
||||
const standardEvents = contractEvents.filter(
|
||||
(name) =>
|
||||
!customEventSet.has(name) &&
|
||||
!customEventNames.has(name) &&
|
||||
(source.targetTag !== 'iframe' || dispatchedEvents.has(name))
|
||||
);
|
||||
|
||||
const methods = source.mediaType === 'video' ? videoMethods : audioMethods;
|
||||
const baseMethodNames =
|
||||
source.targetTag === 'video' ? videoMethods : source.targetTag === 'audio' ? audioMethods : [];
|
||||
const methods = mergeMethodNames(
|
||||
baseMethodNames,
|
||||
extractPublicMethodNames(source.hostFilePath, source.hostClassName)
|
||||
);
|
||||
|
||||
const nativeProperties = Object.entries(baseSurface)
|
||||
.filter(([name, definition]) => definition.overridesNative && !(name in hostProperties))
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
|
||||
const react = extractReactReference(monorepoRoot, source, compilerOptions, publicProperties);
|
||||
|
||||
const reference: MediaElementReference = {
|
||||
name: source.className,
|
||||
tagName: source.tagName,
|
||||
mediaType: source.mediaType,
|
||||
hostProperties,
|
||||
nativeAttributes,
|
||||
events: { native, elementSpecific },
|
||||
methods,
|
||||
cssCustomProperties,
|
||||
platforms: {
|
||||
html: {
|
||||
target: source.targetTag,
|
||||
attributes: {
|
||||
standard: standardAttributes.sort(),
|
||||
custom: customAttributes,
|
||||
},
|
||||
properties: {
|
||||
definitions: propertyDefinitions,
|
||||
native: nativeProperties,
|
||||
},
|
||||
events: {
|
||||
standard: standardEvents,
|
||||
custom: customEvents,
|
||||
},
|
||||
methods,
|
||||
cssCustomProperties,
|
||||
},
|
||||
...(react ? { react } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
results.push({ name: source.className, reference });
|
||||
|
||||
@@ -668,18 +668,40 @@ export interface MediaEventDef {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MediaTargetTag = 'video' | 'audio' | 'iframe';
|
||||
|
||||
export interface HtmlMediaReference {
|
||||
target: MediaTargetTag;
|
||||
attributes: {
|
||||
standard: string[];
|
||||
custom: Record<string, HostPropertyDef>;
|
||||
};
|
||||
properties: {
|
||||
definitions: Record<string, HostPropertyDef>;
|
||||
native: string[];
|
||||
};
|
||||
events: {
|
||||
standard: string[];
|
||||
custom: MediaEventDef[];
|
||||
};
|
||||
methods: string[];
|
||||
cssCustomProperties: Record<string, { description: string }>;
|
||||
}
|
||||
|
||||
export interface ReactMediaReference {
|
||||
target: MediaTargetTag;
|
||||
acceptsNativeProps: boolean;
|
||||
props: Record<string, HostPropertyDef>;
|
||||
}
|
||||
|
||||
export interface MediaElementReference {
|
||||
name: string;
|
||||
tagName: string;
|
||||
mediaType: 'video' | 'audio';
|
||||
hostProperties: Record<string, HostPropertyDef>;
|
||||
nativeAttributes: string[];
|
||||
events: {
|
||||
native: string[];
|
||||
elementSpecific: MediaEventDef[];
|
||||
platforms: {
|
||||
html: HtmlMediaReference;
|
||||
react?: ReactMediaReference;
|
||||
};
|
||||
methods: string[];
|
||||
cssCustomProperties: Record<string, { description: string }>;
|
||||
}
|
||||
|
||||
export interface MediaElementResult {
|
||||
|
||||
@@ -1151,8 +1151,8 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
// MEDIA ELEMENT PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Media elements are custom elements that wrap native <video>/<audio> with
|
||||
// streaming hosts (HLS, DASH, etc.). They are discovered from
|
||||
// Media elements are custom elements that adapt native <video>/<audio> targets
|
||||
// or embedded players. They are discovered from
|
||||
// packages/html/src/define/media/*.ts by looking for files that declare a
|
||||
// class with `static tagName`.
|
||||
//
|
||||
@@ -1160,7 +1160,9 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
// - Tag name from the element class's static tagName
|
||||
// - 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
|
||||
// - Standard/custom attributes from static properties and host accessors
|
||||
// - Platform metadata from the matching React component conventions
|
||||
// - Events and CSS vars for the HTML custom element
|
||||
// - JSDoc descriptions from host getter/setter pairs
|
||||
//
|
||||
// Key behaviors:
|
||||
@@ -1168,13 +1170,16 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
// - Exclusion: container.ts (re-exports, no inline class), background-video.ts
|
||||
// (no CustomMediaElement — uses MediaAttachMixin(HTMLElement) directly)
|
||||
// - Host inheritance: child host extends parent, builder walks the chain
|
||||
// - Attribute overlap: nativeAttributes is the COMPLETE markup-settable set
|
||||
// (no dedup). Host-owned names (e.g., src, preload) appear in BOTH
|
||||
// hostProperties and nativeAttributes (content-attribute vs IDL-property).
|
||||
// - Attribute classification: standard attributes remain an MDN-linked list;
|
||||
// Video.js-specific attributes use their corresponding host definitions.
|
||||
// - Methods: native media methods are extracted ONCE per media type from the
|
||||
// shared base host classes (media-host + video-host/audio-host).
|
||||
// - Event buckets: element-specific (@fires-tagged) events live ONLY in
|
||||
// elementSpecific, never in native.
|
||||
// - Properties: the inherited native surface is compact, while source-authored
|
||||
// definitions retain types, defaults, and descriptions.
|
||||
// - Event buckets: custom (@fires-tagged) events live ONLY in `custom`, never
|
||||
// in the standard MDN-linked list.
|
||||
// - React: forwardRef and useSyncProps conventions produce the ref target and
|
||||
// Video.js-specific prop table without per-element configuration.
|
||||
|
||||
describe('Media element pipeline (end-to-end)', () => {
|
||||
const results = generateMediaElementReferences(FIXTURE_ROOT);
|
||||
@@ -1190,7 +1195,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
describe('Discovery', () => {
|
||||
it('discovers media elements from define/media/ files', () => {
|
||||
const names = results.map((r) => r.name).sort();
|
||||
expect(names).toEqual(['ComplexVideo', 'ExtendingVideo', 'MixinVideo', 'SimpleVideo', 'SpfAudio']);
|
||||
expect(names).toEqual(['ComplexVideo', 'EmbedVideo', 'ExtendingVideo', 'MixinVideo', 'SimpleVideo', 'SpfAudio']);
|
||||
});
|
||||
|
||||
it('excludes container (re-export, not inline class declaration)', () => {
|
||||
@@ -1204,7 +1209,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('produces one result per media element', () => {
|
||||
expect(results.length).toBe(5);
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1225,7 +1230,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('extracts host properties with types and readonly flags', () => {
|
||||
const props = findElement('SimpleVideo')!.reference.hostProperties;
|
||||
const props = findElement('SimpleVideo')!.reference.platforms.html.properties.definitions;
|
||||
|
||||
// src: read-write string
|
||||
expect(props.src).toMatchObject({
|
||||
@@ -1243,19 +1248,15 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('excludes host lifecycle methods (attach, detach, destroy)', () => {
|
||||
const props = findElement('SimpleVideo')!.reference.hostProperties;
|
||||
const props = findElement('SimpleVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.attach).toBeUndefined();
|
||||
expect(props.detach).toBeUndefined();
|
||||
expect(props.destroy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes the COMPLETE set of native attributes from static properties', () => {
|
||||
it('separates standard attributes from Video.js-specific attributes', () => {
|
||||
const ref = findElement('SimpleVideo')!.reference;
|
||||
// nativeAttributes is the full markup-settable set from `static
|
||||
// properties` — no dedup against host props. `src` is settable as an
|
||||
// attribute even though the host also exposes it as a richer property,
|
||||
// so it appears in BOTH places (MDN content-attribute vs IDL-property).
|
||||
expect(ref.nativeAttributes).toEqual(
|
||||
expect(ref.platforms.html.attributes.standard).toEqual(
|
||||
expect.arrayContaining([
|
||||
'autoplay',
|
||||
'controls',
|
||||
@@ -1267,14 +1268,37 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
'preload',
|
||||
])
|
||||
);
|
||||
expect(ref.nativeAttributes).toContain('src');
|
||||
expect(ref.platforms.html.attributes.standard).toContain('src');
|
||||
expect(ref.platforms.html.attributes.standard).not.toContain('stream-type');
|
||||
expect(ref.platforms.html.attributes.custom['stream-type']).toMatchObject({
|
||||
type: 'string',
|
||||
readonly: false,
|
||||
});
|
||||
expect(ref.platforms.html.attributes.custom['stream-type'].description).toContain('Current stream type');
|
||||
});
|
||||
|
||||
it('extracts native media methods from the shared base host classes', () => {
|
||||
const ref = findElement('SimpleVideo')!.reference;
|
||||
// Video methods = media-host methods + video-host methods, deduped + sorted.
|
||||
// Lifecycle methods (attach/detach/destroy) and accessors are excluded.
|
||||
expect(ref.methods).toEqual(['canPlayType', 'load', 'pause', 'play', 'requestFullscreen']);
|
||||
expect(ref.platforms.html.methods).toEqual(['canPlayType', 'load', 'pause', 'play', 'requestFullscreen']);
|
||||
});
|
||||
|
||||
it('extracts native passthrough properties from the shared base host classes', () => {
|
||||
const ref = findElement('SimpleVideo')!.reference;
|
||||
// Video native properties = media-host + video-host accessors, filtered to
|
||||
// genuine native members and deduped against hostProperties. `currentTime`
|
||||
// and `volume` come from media-host; `videoWidth` is video-only.
|
||||
expect(ref.platforms.html.properties.native).toEqual(['currentTime', 'videoWidth', 'volume']);
|
||||
// Video.js-specific base accessors receive full definitions instead.
|
||||
expect(ref.platforms.html.properties.native).not.toContain('streamType');
|
||||
expect(ref.platforms.html.properties.native).not.toContain('isFullscreen');
|
||||
expect(ref.platforms.html.properties.definitions.streamType).toBeDefined();
|
||||
expect(ref.platforms.html.properties.definitions.isFullscreen).toBeDefined();
|
||||
// `src` is native but re-declared in hostProperties → deduped out (shown in
|
||||
// the rich table instead).
|
||||
expect(ref.platforms.html.properties.definitions.src).toBeDefined();
|
||||
expect(ref.platforms.html.properties.native).not.toContain('src');
|
||||
});
|
||||
|
||||
it('includes events derived from VideoEvents capability contracts', () => {
|
||||
@@ -1284,7 +1308,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
// Video.js events from MediaStreamTypeEvents/MediaLiveEvents
|
||||
// (streamtypechange) are NOT native and are excluded here — they only
|
||||
// appear in elementSpecific, and only on elements that @fires them.
|
||||
expect(ref.events.native).toEqual([
|
||||
expect(ref.platforms.html.events.standard).toEqual([
|
||||
'play',
|
||||
'playing',
|
||||
'waiting',
|
||||
@@ -1310,23 +1334,23 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
'trackmodechange',
|
||||
]);
|
||||
// SimpleHost dispatches no events of its own.
|
||||
expect(ref.events.elementSpecific).toEqual([]);
|
||||
expect(ref.platforms.html.events.custom).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits custom events entirely when the element does not @fires them', () => {
|
||||
// Regression guard: streamtypechange lives in the VideoEvents contract via
|
||||
// MediaStreamTypeEvents, but SimpleVideo has no @fires tag for it (and no
|
||||
// streamType capability). A custom event must never leak into `native`
|
||||
// streamType event documentation). A custom event must never leak into the standard list
|
||||
// (which points readers at MDN) — with no @fires it appears in NEITHER
|
||||
// bucket. Mirrors dash-video / simple-hls-video in the real monorepo.
|
||||
const ref = findElement('SimpleVideo')!.reference;
|
||||
expect(ref.events.native).not.toContain('streamtypechange');
|
||||
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
|
||||
expect(ref.platforms.html.events.standard).not.toContain('streamtypechange');
|
||||
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
|
||||
expect(elementSpecificNames).not.toContain('streamtypechange');
|
||||
});
|
||||
|
||||
it('includes CSS custom properties from VideoCSSVars', () => {
|
||||
const css = findElement('SimpleVideo')!.reference.cssCustomProperties;
|
||||
const css = findElement('SimpleVideo')!.reference.platforms.html.cssCustomProperties;
|
||||
expect(css['--media-object-fit']).toEqual({
|
||||
description: 'Object fit for the video.',
|
||||
});
|
||||
@@ -1352,12 +1376,13 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('extracts all host properties', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
const propNames = Object.keys(props).sort();
|
||||
expect(propNames).toEqual([
|
||||
'config',
|
||||
'debug',
|
||||
'engine',
|
||||
'isFullscreen',
|
||||
'preferPlayback',
|
||||
'preload',
|
||||
'src',
|
||||
@@ -1367,7 +1392,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('extracts JSDoc descriptions from host getters', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
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.');
|
||||
@@ -1375,7 +1400,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('marks readonly properties correctly', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
// engine: getter only → readonly
|
||||
expect(props.engine.readonly).toBe(true);
|
||||
// src: getter + setter → not readonly
|
||||
@@ -1384,7 +1409,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('extracts property types', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.src.type).toBe('string');
|
||||
expect(props.debug.type).toBe('boolean');
|
||||
expect(props.config.type).toContain('Record');
|
||||
@@ -1395,18 +1420,18 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
// src and preload are richer host properties AND genuinely settable as
|
||||
// markup attributes — the intentional content-attribute vs IDL-property
|
||||
// overlap. They appear in hostProperties...
|
||||
expect(ref.hostProperties.src).toBeDefined();
|
||||
expect(ref.hostProperties.preload).toBeDefined();
|
||||
expect(ref.platforms.html.properties.definitions.src).toBeDefined();
|
||||
expect(ref.platforms.html.properties.definitions.preload).toBeDefined();
|
||||
// ...and ALSO in nativeAttributes (no dedup).
|
||||
expect(ref.nativeAttributes).toContain('src');
|
||||
expect(ref.nativeAttributes).toContain('preload');
|
||||
expect(ref.platforms.html.attributes.standard).toContain('src');
|
||||
expect(ref.platforms.html.attributes.standard).toContain('preload');
|
||||
// Other native attrs remain
|
||||
expect(ref.nativeAttributes).toContain('autoplay');
|
||||
expect(ref.nativeAttributes).toContain('controls');
|
||||
expect(ref.platforms.html.attributes.standard).toContain('autoplay');
|
||||
expect(ref.platforms.html.attributes.standard).toContain('controls');
|
||||
});
|
||||
|
||||
it('extracts defaults from the co-located defaultProps export', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
// Literal values are emitted as source text (strings keep their quotes).
|
||||
expect(props.src.default).toBe("''");
|
||||
expect(props.debug.default).toBe('false');
|
||||
@@ -1422,14 +1447,71 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
it('resolves const-object member defaults through imports', () => {
|
||||
// streamType: MediaStreamTypes.UNKNOWN — the builder resolves the member
|
||||
// access to its literal value in the imported `as const` object.
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.streamType.default).toBe("'unknown'");
|
||||
});
|
||||
|
||||
it('omits defaults for properties without a defaultProps entry', () => {
|
||||
const props = findElement('ComplexVideo')!.reference.hostProperties;
|
||||
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.engine.default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts the matching React surface from source conventions', () => {
|
||||
const react = findElement('ComplexVideo')!.reference.platforms.react;
|
||||
expect(react).toMatchObject({
|
||||
target: 'video',
|
||||
acceptsNativeProps: true,
|
||||
});
|
||||
expect(Object.keys(react!.props).sort()).toEqual([
|
||||
'config',
|
||||
'debug',
|
||||
'preferPlayback',
|
||||
'preload',
|
||||
'src',
|
||||
'streamType',
|
||||
'type',
|
||||
]);
|
||||
expect(react!.props.streamType.default).toBe("'unknown'");
|
||||
expect(react!.props.engine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// EMBED MEDIA ELEMENT: EmbedVideo
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('EmbedVideo (iframe-backed media)', () => {
|
||||
it('uses the target declared by CustomMediaElement', () => {
|
||||
const ref = findElement('EmbedVideo')!.reference;
|
||||
expect(ref.platforms.html.target).toBe('iframe');
|
||||
expect(ref.platforms.react?.target).toBe('iframe');
|
||||
});
|
||||
|
||||
it('does not invent native video properties, methods, or CSS for an iframe target', () => {
|
||||
const html = findElement('EmbedVideo')!.reference.platforms.html;
|
||||
expect(html.properties.native).toEqual([]);
|
||||
expect(html.methods).toEqual(['play']);
|
||||
expect(html.cssCustomProperties).toEqual({});
|
||||
});
|
||||
|
||||
it('documents only attributes implemented by the synthetic media host', () => {
|
||||
const attributes = findElement('EmbedVideo')!.reference.platforms.html.attributes;
|
||||
expect(attributes.standard).toEqual([]);
|
||||
expect(Object.keys(attributes.custom).sort()).toEqual(['autoplay', 'src']);
|
||||
expect(attributes.custom['stream-type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('documents only events dispatched by the embedded media adapter', () => {
|
||||
const events = findElement('EmbedVideo')!.reference.platforms.html.events;
|
||||
expect(events.standard).toEqual(['play', 'waiting', 'loadedmetadata']);
|
||||
expect(events.custom).toEqual([{ name: 'adapterready' }]);
|
||||
});
|
||||
|
||||
it('extracts custom React props without claiming native media props', () => {
|
||||
const react = findElement('EmbedVideo')!.reference.platforms.react;
|
||||
expect(react).toMatchObject({ target: 'iframe', acceptsNativeProps: false });
|
||||
expect(Object.keys(react!.props).sort()).toEqual(['autoplay', 'src']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1448,7 +1530,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('includes own properties from ExtendingHost', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.playbackId).toMatchObject({
|
||||
type: 'string',
|
||||
readonly: false,
|
||||
@@ -1462,7 +1544,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('includes inherited properties from ComplexHost', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
// These are inherited from ComplexHost
|
||||
expect(props.src).toBeDefined();
|
||||
expect(props.type).toBeDefined();
|
||||
@@ -1473,13 +1555,13 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('child overrides replace parent definitions', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
// 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.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
// engine is readonly in ComplexHost and not overridden
|
||||
expect(props.engine.readonly).toBe(true);
|
||||
});
|
||||
@@ -1487,25 +1569,25 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
it('resolves spread defaults through the parent defaultProps import', () => {
|
||||
// extendingMediaDefaultProps = { ...complexMediaDefaultProps, ... } —
|
||||
// the builder must follow the spread to the imported object literal.
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.src.default).toBe("''");
|
||||
expect(props.debug.default).toBe('false');
|
||||
expect(props.streamType.default).toBe("'unknown'");
|
||||
});
|
||||
|
||||
it('extracts own defaults alongside spread defaults', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.playbackId.default).toBe("''");
|
||||
expect(props.maxResolution.default).toBe('1080');
|
||||
});
|
||||
|
||||
it('abbreviates non-empty object defaults', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.tokens.default).toBe('{…}');
|
||||
});
|
||||
|
||||
it('omits defaults for properties without an entry', () => {
|
||||
const props = findElement('ExtendingVideo')!.reference.hostProperties;
|
||||
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.customDomain.default).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1521,16 +1603,16 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
describe('Event extraction from capability contracts', () => {
|
||||
it('video elements include text track events from VideoEvents', () => {
|
||||
const ref = findElement('SimpleVideo')!.reference;
|
||||
expect(ref.events.native).toContain('addtrack');
|
||||
expect(ref.events.native).toContain('removetrack');
|
||||
expect(ref.events.native).toContain('changetrack');
|
||||
expect(ref.events.native).toContain('trackmodechange');
|
||||
expect(ref.platforms.html.events.standard).toContain('addtrack');
|
||||
expect(ref.platforms.html.events.standard).toContain('removetrack');
|
||||
expect(ref.platforms.html.events.standard).toContain('changetrack');
|
||||
expect(ref.platforms.html.events.standard).toContain('trackmodechange');
|
||||
});
|
||||
|
||||
it('all video elements share the same native event list', () => {
|
||||
const simple = findElement('SimpleVideo')!.reference.events.native;
|
||||
const complex = findElement('ComplexVideo')!.reference.events.native;
|
||||
const extending = findElement('ExtendingVideo')!.reference.events.native;
|
||||
const simple = findElement('SimpleVideo')!.reference.platforms.html.events.standard;
|
||||
const complex = findElement('ComplexVideo')!.reference.platforms.html.events.standard;
|
||||
const extending = findElement('ExtendingVideo')!.reference.platforms.html.events.standard;
|
||||
expect(complex).toEqual(simple);
|
||||
expect(extending).toEqual(simple);
|
||||
});
|
||||
@@ -1561,7 +1643,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('walks function-declaration mixin (Shape A)', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.foo).toMatchObject({
|
||||
type: 'string',
|
||||
readonly: false,
|
||||
@@ -1570,14 +1652,14 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('walks const-arrow mixin (Shape B)', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.volume).toBeDefined();
|
||||
expect(props.volume.type).toBe('number');
|
||||
expect(props.volume.readonly).toBe(false);
|
||||
});
|
||||
|
||||
it('includes leaf-class own properties', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.bar).toMatchObject({
|
||||
type: 'number',
|
||||
readonly: false,
|
||||
@@ -1586,12 +1668,12 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('marks volume as overridesNative (HTMLMediaElement member)', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.volume.overridesNative).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark non-native properties as overridesNative', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.foo.overridesNative).toBeUndefined();
|
||||
expect(props.bar.overridesNative).toBeUndefined();
|
||||
});
|
||||
@@ -1599,7 +1681,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
it('inherits parent description when child override has no JSDoc', () => {
|
||||
// src has JSDoc on MixinBaseHost; MixinB overrides without JSDoc.
|
||||
// The description should fall through from the base.
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.src.description).toBe('Source URL of the media.');
|
||||
});
|
||||
|
||||
@@ -1609,8 +1691,8 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
// ONLY in the elementSpecific bucket (where they carry their description);
|
||||
// they are excluded from native so they are never listed twice.
|
||||
const ref = findElement('MixinVideo')!.reference;
|
||||
expect(ref.events.native).not.toContain('streamtypechange');
|
||||
expect(ref.events.elementSpecific).toContainEqual({
|
||||
expect(ref.platforms.html.events.standard).not.toContain('streamtypechange');
|
||||
expect(ref.platforms.html.events.custom).toContainEqual({
|
||||
name: 'streamtypechange',
|
||||
description: 'Fired when the detected stream type changes.',
|
||||
});
|
||||
@@ -1620,20 +1702,20 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
// foochange is dispatched via this.dispatchEvent(new Event('foochange')) but
|
||||
// has no @fires tag, so it is not surfaced — documentation requires a tag.
|
||||
const ref = findElement('MixinVideo')!.reference;
|
||||
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
|
||||
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
|
||||
expect(elementSpecificNames).not.toContain('foochange');
|
||||
});
|
||||
|
||||
it('separates native events from element-specific events', () => {
|
||||
const ref = findElement('MixinVideo')!.reference;
|
||||
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
|
||||
expect(ref.events.native).toContain('play');
|
||||
expect(ref.events.native).not.toContain('foochange');
|
||||
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
|
||||
expect(ref.platforms.html.events.standard).toContain('play');
|
||||
expect(ref.platforms.html.events.standard).not.toContain('foochange');
|
||||
expect(elementSpecificNames).not.toContain('play');
|
||||
});
|
||||
|
||||
it('extracts defaults declared in a mixin file', () => {
|
||||
const props = findElement('MixinVideo')!.reference.hostProperties;
|
||||
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.foo.default).toBe("''");
|
||||
});
|
||||
});
|
||||
@@ -1662,7 +1744,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('resolves the mixin through another package barrel', () => {
|
||||
const props = findElement('SpfAudio')!.reference.hostProperties;
|
||||
const props = findElement('SpfAudio')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.src).toMatchObject({
|
||||
type: 'string',
|
||||
readonly: false,
|
||||
@@ -1676,20 +1758,20 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
});
|
||||
|
||||
it('extracts defaults declared next to the cross-package mixin', () => {
|
||||
const props = findElement('SpfAudio')!.reference.hostProperties;
|
||||
const props = findElement('SpfAudio')!.reference.platforms.html.properties.definitions;
|
||||
expect(props.src.default).toBe("''");
|
||||
expect(props.preload.default).toBe("''");
|
||||
});
|
||||
|
||||
it('uses AudioEvents for native events (no text track events)', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
expect(ref.events.native).toContain('play');
|
||||
expect(ref.events.native).not.toContain('addtrack');
|
||||
expect(ref.platforms.html.events.standard).toContain('play');
|
||||
expect(ref.platforms.html.events.standard).not.toContain('addtrack');
|
||||
});
|
||||
|
||||
it('surfaces a @fires event with its tag description', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
expect(ref.events.elementSpecific).toContainEqual({
|
||||
expect(ref.platforms.html.events.custom).toContainEqual({
|
||||
name: 'audiomodechange',
|
||||
description: 'Fired when the audio-only rendition changes.',
|
||||
});
|
||||
@@ -1697,7 +1779,7 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
|
||||
it('includes @fires-declared events without a scanned dispatch site', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
expect(ref.events.elementSpecific).toContainEqual({
|
||||
expect(ref.platforms.html.events.custom).toContainEqual({
|
||||
name: 'manifestparsed',
|
||||
description: 'Fired after the multivariant playlist is parsed.',
|
||||
});
|
||||
@@ -1705,21 +1787,31 @@ describe('Media element pipeline (end-to-end)', () => {
|
||||
|
||||
it('sorts element-specific events by name', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
const names = ref.events.elementSpecific.map((e) => e.name);
|
||||
const names = ref.platforms.html.events.custom.map((e) => e.name);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it('has empty AudioCSSVars', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
expect(ref.cssCustomProperties).toEqual({});
|
||||
expect(ref.platforms.html.cssCustomProperties).toEqual({});
|
||||
});
|
||||
|
||||
it('extracts audio methods from the shared base host (no video-only methods)', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
// Audio methods = media-host methods + audio-host methods. The fixture
|
||||
// audio host adds none, so video-only methods (requestFullscreen) are absent.
|
||||
expect(ref.methods).toEqual(['canPlayType', 'load', 'pause', 'play']);
|
||||
expect(ref.methods).not.toContain('requestFullscreen');
|
||||
expect(ref.platforms.html.methods).toEqual(['canPlayType', 'load', 'pause', 'play']);
|
||||
expect(ref.platforms.html.methods).not.toContain('requestFullscreen');
|
||||
});
|
||||
|
||||
it('extracts native properties from the shared base host (no video-only props)', () => {
|
||||
const ref = findElement('SpfAudio')!.reference;
|
||||
// Audio native properties = media-host accessors only (audio host adds
|
||||
// none), filtered to native members and deduped against hostProperties
|
||||
// (src is re-declared by the mixin). videoWidth is video-only → absent.
|
||||
expect(ref.platforms.html.properties.native).toEqual(['currentTime', 'volume']);
|
||||
expect(ref.platforms.html.properties.native).not.toContain('videoWidth');
|
||||
expect(ref.platforms.html.properties.native).not.toContain('src');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -81,6 +81,7 @@ export function CustomMediaElement(tag: string, Host: any) {
|
||||
poster: { type: String },
|
||||
preload: { type: String },
|
||||
src: { type: String },
|
||||
streamType: { type: String, attribute: 'stream-type' },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/embed/index.ts
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
/** Mock iframe-backed media host — mirrors VimeoMedia. */
|
||||
export const embedMediaDefaultProps = {
|
||||
src: '',
|
||||
autoplay: false,
|
||||
};
|
||||
|
||||
export class EmbedHost extends EventTarget {
|
||||
#src = embedMediaDefaultProps.src;
|
||||
#autoplay = embedMediaDefaultProps.autoplay;
|
||||
|
||||
get src(): string {
|
||||
return this.#src;
|
||||
}
|
||||
set src(value: string) {
|
||||
this.#src = value;
|
||||
}
|
||||
|
||||
get autoplay(): boolean {
|
||||
return this.#autoplay;
|
||||
}
|
||||
set autoplay(value: boolean) {
|
||||
this.#autoplay = value;
|
||||
}
|
||||
|
||||
/** Start playback through the embedded player. */
|
||||
play(): Promise<void> {
|
||||
this.dispatchEvent(new Event('play'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
attach(_target: HTMLIFrameElement): void {
|
||||
const emit = (type: string) => this.dispatchEvent(new Event(type));
|
||||
emit('waiting');
|
||||
for (const type of ['loadedmetadata', 'adapterready']) {
|
||||
this.dispatchEvent(new Event(type));
|
||||
}
|
||||
}
|
||||
detach(): void {}
|
||||
destroy(): void {}
|
||||
}
|
||||
Vendored
+27
-1
@@ -4,6 +4,10 @@
|
||||
* Exercises method extraction: the builder collects public instance methods
|
||||
* from this class (per media type) for the reference's `methods` field.
|
||||
* Lifecycle methods (attach/detach/destroy) and accessors are excluded.
|
||||
*
|
||||
* Also exercises native-property extraction: getters/setters whose names match
|
||||
* native HTMLMediaElement members surface in `nativeProperties`; non-native
|
||||
* accessors (e.g. `streamType`) and re-declared natives (`src`) do not.
|
||||
*/
|
||||
export class HTMLMediaElementHost {
|
||||
// Lifecycle methods — excluded from `methods`.
|
||||
@@ -14,11 +18,33 @@ export class HTMLMediaElementHost {
|
||||
// Internal — excluded by the `_` prefix.
|
||||
_forward(): void {}
|
||||
|
||||
// Accessor — excluded (getters/setters are properties, not methods).
|
||||
// Accessor — excluded from `methods` (it's a property, not a method). Native
|
||||
// member, but re-declared on engine hosts → deduped out of nativeProperties.
|
||||
get src(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Native passthroughs — surface in nativeProperties.
|
||||
get currentTime(): number {
|
||||
return 0;
|
||||
}
|
||||
set currentTime(_value: number) {}
|
||||
|
||||
get volume(): number {
|
||||
return 1;
|
||||
}
|
||||
set volume(_value: number) {}
|
||||
|
||||
// Video.js-specific — NOT a native member, so excluded from nativeProperties.
|
||||
/**
|
||||
* Current stream type (`'on-demand'`, `'live'`, or `'unknown'`). Consumers can
|
||||
* set it when the host does not detect the stream type automatically.
|
||||
*/
|
||||
get streamType(): string {
|
||||
return 'unknown';
|
||||
}
|
||||
set streamType(_value: string) {}
|
||||
|
||||
play(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
Vendored
+13
-1
@@ -2,7 +2,9 @@
|
||||
* Mock video host base — mirrors the real video-host.ts.
|
||||
*
|
||||
* Exercises video-specific method extraction: requestFullscreen is added on
|
||||
* top of the shared media-host methods for video elements only.
|
||||
* top of the shared media-host methods for video elements only. Also exercises
|
||||
* video-only native-property extraction (videoWidth) and a non-native helper
|
||||
* (isFullscreen) that must be filtered out of nativeProperties.
|
||||
*/
|
||||
import { HTMLMediaElementHost } from './media-host';
|
||||
|
||||
@@ -10,4 +12,14 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost {
|
||||
requestFullscreen(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Native HTMLVideoElement member — surfaces in nativeProperties (video only).
|
||||
get videoWidth(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Video.js-specific helper — NOT a native member, excluded.
|
||||
get isFullscreen(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { EmbedVideo } from '../../media/embed-video';
|
||||
|
||||
export class EmbedVideoElement extends EmbedVideo {
|
||||
static readonly tagName = 'embed-video';
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element';
|
||||
import { EmbedHost } from '../../../../core/src/dom/media/embed';
|
||||
|
||||
function MediaAttachMixin(base: any) {
|
||||
return base;
|
||||
}
|
||||
|
||||
class EmbedCustomMediaElement extends CustomMediaElement('iframe', EmbedHost) {}
|
||||
|
||||
export class EmbedVideo extends MediaAttachMixin(EmbedCustomMediaElement) {}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Mock React media component.
|
||||
*
|
||||
* Exercises the source conventions used by real media components:
|
||||
* native React video props, a forwarded native-element ref, and a defaults
|
||||
* object passed to useSyncProps for Video.js-specific props.
|
||||
*/
|
||||
import { complexMediaDefaultProps } from '../../../../core/src/dom/media/complex';
|
||||
|
||||
interface VideoHTMLAttributes<Element> {
|
||||
element?: Element;
|
||||
}
|
||||
|
||||
interface ComplexVideoProps extends VideoHTMLAttributes<HTMLVideoElement>, Partial<typeof complexMediaDefaultProps> {}
|
||||
|
||||
declare function forwardRef<Ref, Props>(render: (props: Props, ref: Ref) => unknown): unknown;
|
||||
declare function useSyncProps(target: object, props: object, defaults: object): object;
|
||||
|
||||
export const ComplexVideo = forwardRef<HTMLVideoElement, ComplexVideoProps>(function ComplexVideo(props, ref) {
|
||||
useSyncProps({}, props, complexMediaDefaultProps);
|
||||
return { ref };
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { embedMediaDefaultProps } from '../../../../core/src/dom/media/embed';
|
||||
|
||||
interface EmbedVideoProps extends Partial<typeof embedMediaDefaultProps> {}
|
||||
|
||||
declare function forwardRef<Ref, Props>(render: (props: Props, ref: Ref) => unknown): unknown;
|
||||
declare function useSyncProps(target: object, props: object, defaults: object): object;
|
||||
|
||||
export const EmbedVideo = forwardRef<HTMLIFrameElement, EmbedVideoProps>(function EmbedVideo(props, ref) {
|
||||
useSyncProps({}, props, embedMediaDefaultProps);
|
||||
return { ref };
|
||||
});
|
||||
Reference in New Issue
Block a user