From d799be1063518b85ad3f030bda07b9132e2db074 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Fri, 19 Jun 2026 09:44:54 -0700 Subject: [PATCH] feat(site): API reference pages for media elements (#1342) --- .gitignore | 1 + packages/core/src/dom/media/dash/index.ts | 5 + packages/core/src/dom/media/hls/index.ts | 18 + .../core/src/dom/media/native-hls/index.ts | 5 + .../core/src/dom/media/native-hls/live.ts | 13 +- .../src/dom/media/native-hls/stream-type.ts | 4 + .../engines/hls/adapter-audio-only.ts | 7 + .../spf/src/playback/engines/hls/adapter.ts | 7 + site/scripts/api-docs-builder/src/index.ts | 49 +- .../src/media-element-handler.ts | 1092 ++++++++++++++--- site/scripts/api-docs-builder/src/pipeline.ts | 17 +- .../api-docs-builder/src/tests/e2e.test.ts | 347 +++++- .../packages/core/src/core/media/types.ts | 19 +- .../packages/core/src/dom/media/audio-host.ts | 9 + .../core/src/dom/media/complex/index.ts | 25 +- .../core/src/dom/media/extending/index.ts | 34 +- .../packages/core/src/dom/media/media-host.ts | 33 + .../core/src/dom/media/mixin/base-host.ts | 21 + .../core/src/dom/media/mixin/index.ts | 24 + .../core/src/dom/media/mixin/mixin-a.ts | 39 + .../core/src/dom/media/mixin/mixin-b.ts | 36 + .../core/src/dom/media/simple/index.ts | 11 +- .../core/src/dom/media/spf-audio/index.ts | 11 + .../packages/core/src/dom/media/video-host.ts | 13 + .../html/src/define/media/mixin-video.ts | 10 + .../html/src/define/media/spf-audio.ts | 11 + .../html/src/media/mixin-video/index.ts | 14 + .../html/src/media/spf-audio/index.ts | 15 + .../monorepo/packages/spf/src/hls/index.ts | 11 + .../engines/hls/adapter-audio-only.ts | 50 + .../docs/api-reference/CodeChip.astro | 30 + .../api-reference/MediaHostPropsTable.astro | 69 ++ .../docs/api-reference/MediaReference.astro | 263 ++++ .../html/css/BasicUsage.astro | 9 + .../background-video/html/css/BasicUsage.css | 5 + .../background-video/html/css/BasicUsage.html | 5 + .../background-video/html/css/BasicUsage.ts | 1 + .../background-video/react/css/BasicUsage.css | 5 + .../background-video/react/css/BasicUsage.tsx | 9 + .../dash-video/html/css/BasicUsage.astro | 9 + .../demos/dash-video/html/css/BasicUsage.css | 6 + .../demos/dash-video/html/css/BasicUsage.html | 9 + .../demos/dash-video/html/css/BasicUsage.ts | 2 + .../demos/dash-video/react/css/BasicUsage.css | 4 + .../demos/dash-video/react/css/BasicUsage.tsx | 14 + .../demos/hls-video/html/css/BasicUsage.astro | 9 + .../demos/hls-video/html/css/BasicUsage.css | 6 + .../demos/hls-video/html/css/BasicUsage.html | 9 + .../demos/hls-video/html/css/BasicUsage.ts | 2 + .../demos/hls-video/react/css/BasicUsage.css | 4 + .../demos/hls-video/react/css/BasicUsage.tsx | 14 + .../demos/mux-audio/html/css/BasicUsage.astro | 9 + .../demos/mux-audio/html/css/BasicUsage.css | 4 + .../demos/mux-audio/html/css/BasicUsage.html | 6 + .../demos/mux-audio/html/css/BasicUsage.ts | 1 + .../demos/mux-audio/react/css/BasicUsage.css | 4 + .../demos/mux-audio/react/css/BasicUsage.tsx | 12 + .../demos/mux-video/html/css/BasicUsage.astro | 9 + .../demos/mux-video/html/css/BasicUsage.css | 6 + .../demos/mux-video/html/css/BasicUsage.html | 10 + .../demos/mux-video/html/css/BasicUsage.ts | 2 + .../demos/mux-video/react/css/BasicUsage.css | 4 + .../demos/mux-video/react/css/BasicUsage.tsx | 15 + .../html/css/BasicUsage.astro | 9 + .../native-hls-video/html/css/BasicUsage.css | 6 + .../native-hls-video/html/css/BasicUsage.html | 9 + .../native-hls-video/html/css/BasicUsage.ts | 2 + .../native-hls-video/react/css/BasicUsage.css | 4 + .../native-hls-video/react/css/BasicUsage.tsx | 14 + .../html/css/BasicUsage.astro | 9 + .../html/css/BasicUsage.css | 4 + .../html/css/BasicUsage.html | 5 + .../html/css/BasicUsage.ts | 1 + .../react/css/BasicUsage.css | 4 + .../react/css/BasicUsage.tsx | 11 + .../html/css/BasicUsage.astro | 9 + .../simple-hls-video/html/css/BasicUsage.css | 6 + .../simple-hls-video/html/css/BasicUsage.html | 9 + .../simple-hls-video/html/css/BasicUsage.ts | 2 + .../simple-hls-video/react/css/BasicUsage.css | 4 + .../simple-hls-video/react/css/BasicUsage.tsx | 14 + site/src/content.config.ts | 10 + .../docs/reference/background-video.mdx | 112 ++ .../src/content/docs/reference/dash-video.mdx | 61 + site/src/content/docs/reference/hls-video.mdx | 61 + site/src/content/docs/reference/mux-audio.mdx | 61 + site/src/content/docs/reference/mux-video.mdx | 61 + .../docs/reference/native-hls-video.mdx | 61 + .../docs/reference/simple-hls-audio-only.mdx | 61 + .../docs/reference/simple-hls-video.mdx | 61 + site/src/docs.config.ts | 14 + site/src/types/media-reference.ts | 32 + site/src/utils/mediaReferenceModel.js | 88 ++ site/src/utils/remarkConditionalHeadings.js | 31 + .../utils/tests/mediaReferenceModel.test.ts | 76 ++ 95 files changed, 3219 insertions(+), 210 deletions(-) create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/audio-host.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/media-host.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/base-host.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-a.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-b.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/spf-audio/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/video-host.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/mixin-video.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/spf-audio.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/mixin-video/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/spf-audio/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/hls/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/playback/engines/hls/adapter-audio-only.ts create mode 100644 site/src/components/docs/api-reference/CodeChip.astro create mode 100644 site/src/components/docs/api-reference/MediaHostPropsTable.astro create mode 100644 site/src/components/docs/api-reference/MediaReference.astro create mode 100644 site/src/components/docs/demos/background-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/background-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/background-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/background-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/background-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/background-video/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/dash-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/dash-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/dash-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/dash-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/dash-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/dash-video/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/hls-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/hls-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/hls-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/hls-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/hls-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/hls-video/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/mux-audio/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/mux-audio/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/mux-audio/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/mux-audio/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/mux-audio/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/mux-audio/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/mux-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/mux-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/mux-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/mux-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/mux-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/mux-video/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.tsx create mode 100644 site/src/content/docs/reference/background-video.mdx create mode 100644 site/src/content/docs/reference/dash-video.mdx create mode 100644 site/src/content/docs/reference/hls-video.mdx create mode 100644 site/src/content/docs/reference/mux-audio.mdx create mode 100644 site/src/content/docs/reference/mux-video.mdx create mode 100644 site/src/content/docs/reference/native-hls-video.mdx create mode 100644 site/src/content/docs/reference/simple-hls-audio-only.mdx create mode 100644 site/src/content/docs/reference/simple-hls-video.mdx create mode 100644 site/src/types/media-reference.ts create mode 100644 site/src/utils/mediaReferenceModel.js create mode 100644 site/src/utils/tests/mediaReferenceModel.test.ts diff --git a/.gitignore b/.gitignore index 774ebece..092d6f87 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ site/src/content/generated-api-reference/ site/src/content/generated-component-reference/ site/src/content/generated-util-reference/ site/src/content/generated-feature-reference/ +site/src/content/generated-media-reference/ site/src/content/generated-preset-reference/ site/src/content/ejected-skins.json packages/cli/docs/ diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/index.ts index f1947fcd..57782047 100644 --- a/packages/core/src/dom/media/dash/index.ts +++ b/packages/core/src/dom/media/dash/index.ts @@ -41,6 +41,11 @@ export class DashMedia super.destroy(); } + /** + * Underlying playback engine — the dash.js `MediaPlayerClass` instance. An + * advanced escape hatch for direct engine access; normal playback is driven + * through this element's own properties and methods. + */ get engine() { return this.#engine; } diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts index 7c6d4828..ad371b8f 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -49,6 +49,10 @@ export const hlsMediaDefaultProps: HlsMediaProps = { class HlsMediaEvent extends Event {} +/** + * @fires streamtypechange - Fired when the detected stream type changes. Read `streamType` for the new value. + * @fires targetlivewindowchange - Fired when the target live window changes. Read `targetLiveWindow` for the new value. + */ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { #delegate: HlsJsMedia | NativeHlsMedia | null = null; #mediaElement: HTMLVideoElement | null = null; @@ -84,10 +88,20 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { this.removeEventListener('loadstart', this.#stopTargetLoadStartEvent); } + /** + * Underlying playback engine — the hls.js `Hls` instance when playing via + * MSE, otherwise `null`. An advanced escape hatch for direct engine access; + * normal playback is driven through this element's own properties and methods. + */ get engine() { return this.#delegate?.engine ?? null; } + /** + * Playback configuration: a preferred playback path, an explicit content + * type, and options forwarded to hls.js. Reassigning reloads the engine when + * an engine-relevant option changes. + */ get config(): HlsMediaConfig { return super.config; } @@ -101,18 +115,22 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { return this.#delegate?.error ?? null; } + /** Populated only while the hls.js (MSE) engine is active; otherwise `undefined`. */ get videoTracks() { return this.#delegate instanceof HlsJsMedia ? this.#delegate.videoTracks : undefined; } + /** Populated only while the hls.js (MSE) engine is active; otherwise `undefined`. */ get audioTracks() { return this.#delegate instanceof HlsJsMedia ? this.#delegate.audioTracks : undefined; } + /** Selectable quality levels, populated only while the hls.js (MSE) engine is active; otherwise `undefined`. */ get videoRenditions() { return this.#delegate instanceof HlsJsMedia ? this.#delegate.videoRenditions : undefined; } + /** Selectable audio variants, populated only while the hls.js (MSE) engine is active; otherwise `undefined`. */ get audioRenditions() { return this.#delegate instanceof HlsJsMedia ? this.#delegate.audioRenditions : undefined; } diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts index 7ca972b4..941ea828 100644 --- a/packages/core/src/dom/media/native-hls/index.ts +++ b/packages/core/src/dom/media/native-hls/index.ts @@ -25,6 +25,10 @@ class NativeHlsMediaBase extends HTMLVideoElementHost implements Omit>(BaseClass: Base) { // Native HLS does not expose manifest-level `HOLD-BACK` / `PART-HOLD-BACK` // through a JS API, so we fetch the m3u8 ourselves and parse the relevant @@ -15,11 +18,19 @@ export function NativeHlsMediaLiveMixin>(BaseClass: Base) { class NativeHlsMediaStreamType extends (BaseClass as Constructor) { #streamType: MediaStreamType = MediaStreamTypes.UNKNOWN; #isUserStreamType = false; #disconnect: AbortController | null = null; + /** Current stream type (`'on-demand'` / `'live'` / `'unknown'`). */ get streamType(): MediaStreamType { return this.#streamType; } diff --git a/packages/spf/src/playback/engines/hls/adapter-audio-only.ts b/packages/spf/src/playback/engines/hls/adapter-audio-only.ts index 1c950233..70553952 100644 --- a/packages/spf/src/playback/engines/hls/adapter-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/adapter-audio-only.ts @@ -63,6 +63,12 @@ export function SimpleHlsAudioOnlyMediaMixin>(Base this.#engine = this.#createEngine(); } + /** + * Underlying playback engine — the low-level SPF reactive composition that + * drives playback. An advanced escape hatch for direct engine access; + * normal playback is driven through this element's own properties and + * methods. + */ get engine(): Composition { return this.#engine; } @@ -91,6 +97,7 @@ export function SimpleHlsAudioOnlyMediaMixin>(Base // preload — synchronous IDL attribute (WHATWG §4.8.11.2) // ------------------------------------------------------------------------- + /** Preload type (`'none'` / `'metadata'` / `'auto'`). */ get preload(): '' | 'none' | 'metadata' | 'auto' { return this.#preload; } diff --git a/packages/spf/src/playback/engines/hls/adapter.ts b/packages/spf/src/playback/engines/hls/adapter.ts index f4078e0d..49e5214f 100644 --- a/packages/spf/src/playback/engines/hls/adapter.ts +++ b/packages/spf/src/playback/engines/hls/adapter.ts @@ -62,6 +62,12 @@ export function SimpleHlsMediaMixin>(BaseClass: Ba this.#engine = this.#createEngine(); } + /** + * Underlying playback engine — the low-level SPF reactive composition that + * drives playback. An advanced escape hatch for direct engine access; + * normal playback is driven through this element's own properties and + * methods. + */ get engine(): Composition { return this.#engine; } @@ -90,6 +96,7 @@ export function SimpleHlsMediaMixin>(BaseClass: Ba // preload — synchronous IDL attribute (WHATWG §4.8.11.2) // ------------------------------------------------------------------------- + /** Preload type (`'none'` / `'metadata'` / `'auto'`). */ get preload(): '' | 'none' | 'metadata' | 'auto' { return this.#preload; } diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index 715d0c16..c0a60ade 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -1,6 +1,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { generateComponentReferences, generateFeatureReferences, generatePresetReferences } from './pipeline.js'; +import { MediaReferenceSchema } from '../../../src/types/media-reference.js'; +import { + generateComponentReferences, + generateFeatureReferences, + generateMediaElementReferences, + generatePresetReferences, +} from './pipeline.js'; import { ComponentReferenceSchema, FeatureReferenceSchema, PresetReferenceSchema } from './types.js'; import { generateUtilReferences } from './util-handler.js'; @@ -19,6 +25,7 @@ const MONOREPO_ROOT = path.resolve(import.meta.dirname, '../../../../'); const COMPONENT_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-component-reference'); const UTIL_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-util-reference'); const FEATURE_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-feature-reference'); +const MEDIA_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-media-reference'); const PRESET_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-preset-reference'); /** @@ -36,7 +43,13 @@ function main() { }; // Ensure output directories exist - for (const dir of [COMPONENT_OUTPUT_PATH, UTIL_OUTPUT_PATH, FEATURE_OUTPUT_PATH, PRESET_OUTPUT_PATH]) { + for (const dir of [ + COMPONENT_OUTPUT_PATH, + UTIL_OUTPUT_PATH, + FEATURE_OUTPUT_PATH, + MEDIA_OUTPUT_PATH, + PRESET_OUTPUT_PATH, + ]) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } @@ -116,6 +129,38 @@ function main() { log.info(`Done! Generated ${featureSuccessCount} feature files.`); + // Generate media element references + const mediaResults = generateMediaElementReferences(MONOREPO_ROOT); + + if (mediaResults.length === 0) { + log.info('No media elements found.'); + } else { + log.info(`Found ${mediaResults.length} media elements. Processing...`); + } + + let mediaSuccessCount = 0; + for (const result of mediaResults) { + const validated = MediaReferenceSchema.safeParse(result.reference); + if (!validated.success) { + log.error(`Schema validation failed for media element ${result.name}:`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + errorCount++; + continue; + } + + const outputFile = path.join(MEDIA_OUTPUT_PATH, `${validated.data.tagName}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`✅ Generated ${path.basename(outputFile)}`); + mediaSuccessCount++; + successCount++; + } + + log.info(`Done! Generated ${mediaSuccessCount} media element files.`); + // Generate preset references const presetResults = generatePresetReferences(MONOREPO_ROOT); diff --git a/site/scripts/api-docs-builder/src/media-element-handler.ts b/site/scripts/api-docs-builder/src/media-element-handler.ts index f5ce5333..142d97a3 100644 --- a/site/scripts/api-docs-builder/src/media-element-handler.ts +++ b/site/scripts/api-docs-builder/src/media-element-handler.ts @@ -2,7 +2,7 @@ * Media element reference extraction. * * Discovers media elements from packages/html/src/define/media/*.ts and extracts - * host properties, shared attributes/events/CSS vars, and slots. + * host properties, shared attributes/events, and CSS vars. * * Convention: * - Define files: packages/html/src/define/media/*.ts with inline class + static tagName @@ -12,8 +12,7 @@ * HTMLVideoElementHost or HTMLAudioElementHost with getter/setter pairs * - Shared data: packages/core/src/dom/media/custom-media-element/index.ts * exports CustomMediaElement factory (with static properties), VideoCSSVars, - * AudioCSSVars, and template functions - * - Slots: parsed from getVideoTemplateHTML / getCommonTemplateHTML in custom-media-element + * AudioCSSVars * * Exclusions (elements discovered but intentionally skipped): * - container.ts: re-exports a class, doesn't declare one inline → no static tagName found @@ -25,7 +24,7 @@ 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 } from './pipeline.js'; +import type { HostPropertyDef, MediaElementReference, MediaElementResult, MediaEventDef } from './pipeline.js'; // ─── Constants ────────────────────────────────────────────────────── @@ -55,6 +54,12 @@ interface MediaElementSource { * 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. + * + * Workspace imports go through `package.json#exports` and resolve to the + * built `dist/dev/*.d.ts` files, where the TypeScript compiler has collapsed + * mixin chains into opaque `_base` aliases. To preserve the original mixin + * structure for extraction, we remap the resolved dist `.d.ts` path back to + * the corresponding source `.ts` file. */ function resolveModuleToFile( fromFile: string, @@ -62,7 +67,21 @@ function resolveModuleToFile( compilerOptions: ts.CompilerOptions ): string | undefined { const result = ts.resolveModuleName(importSpecifier, fromFile, compilerOptions, ts.sys); - return result.resolvedModule?.resolvedFileName; + const resolved = result.resolvedModule?.resolvedFileName; + if (!resolved) return undefined; + return mapDistToSource(resolved); +} + +function mapDistToSource(resolvedPath: string): string { + if (!resolvedPath.endsWith('.d.ts')) return resolvedPath; + const match = resolvedPath.match(/^(.+\/packages\/[^/]+)\/dist\/dev\/(.+)\.d\.ts$/); + if (!match) return resolvedPath; + const [, pkgRoot, rest] = match; + const candidates = [`${pkgRoot}/src/${rest}.ts`, `${pkgRoot}/src/${rest}/index.ts`]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return resolvedPath; } // ─── Discovery ─────────────────────────────────────────────────────── @@ -249,29 +268,49 @@ function parseCustomMediaElementCall( /** * Extract getter/setter pairs from a host class and its ancestors, * mirroring what CustomMediaElement does at runtime when it walks - * the MediaHost prototype chain. + * the MediaHost prototype chain. Defaults are collected from the + * `*DefaultProps` exports in every file the walk visits — base files first, + * then mixins innermost-to-outermost, then the leaf class file — so the + * most-derived default wins, matching property override semantics. */ function extractHostProperties( filePath: string, hostClassName: string, - compilerOptions: ts.CompilerOptions + compilerOptions: ts.CompilerOptions, + nativeNames: Set ): Record { const properties: Record = {}; - extractClassProperties(filePath, hostClassName, properties, compilerOptions, new Set()); + const visitedFiles: string[] = []; + extractClassProperties(filePath, hostClassName, properties, compilerOptions, new Set(), nativeNames, visitedFiles); + + const defaults = new Map(); + for (const visitedFile of visitedFiles) { + for (const [name, value] of collectFileDefaults(visitedFile, compilerOptions)) { + defaults.set(name, value); + } + } + for (const [name, def] of Object.entries(properties)) { + const value = defaults.get(name); + if (value !== undefined) def.default = value; + } + return properties; } /** * Recursively extract getter/setter pairs from a class and its parent chain. - * Child properties override parent properties (checked via the `seen` set). - * Stops at host base classes (HTMLMediaElementHost, HTMLVideoElementHost, etc.). + * Handles both `extends Identifier` and `extends MixinA(MixinB(Base))`. Child + * properties override parent properties (checked via the `seen` set). Stops + * at host base classes (HTMLMediaElementHost, HTMLVideoElementHost, etc.). */ function extractClassProperties( filePath: string, className: string, properties: Record, compilerOptions: ts.CompilerOptions, - seen: Set + seen: Set, + nativeNames: Set, + visitedFiles: string[] ): void { if (seen.has(`${filePath}:${className}`)) return; seen.add(`${filePath}:${className}`); @@ -279,88 +318,416 @@ function extractClassProperties( const content = fs.readFileSync(filePath, 'utf-8'); const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); - const getters = new Map(); - const setters = new Set(); - let parentClassName: string | undefined; - let parentImportPath: string | undefined; - + let classNode: ts.ClassDeclaration | undefined; ts.forEachChild(sourceFile, (node) => { - if (!ts.isClassDeclaration(node) || !node.name || node.name.text !== className) return; + if (ts.isClassDeclaration(node) && node.name?.text === className) { + classNode = node; + } + }); + if (!classNode) return; - // Check for extends clause (host 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; - } + // Process the extends chain BEFORE applying own getters/setters, so that + // child overrides win in the merge step at the end of applyClassMembers. + const extendsClause = classNode.heritageClauses?.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword); + if (extendsClause && extendsClause.types.length > 0) { + const extendsExpr = unwrapExpression(extendsClause.types[0]!.expression); + processExtendsExpression( + extendsExpr, + sourceFile, + filePath, + properties, + compilerOptions, + seen, + nativeNames, + visitedFiles + ); + } + + visitedFiles.push(filePath); + applyClassMembers(classNode, sourceFile, properties, nativeNames); +} + +/** Strip parens and `as Foo` casts from an expression. */ +function unwrapExpression(expr: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr)) { + expr = expr.expression; + } + return expr; +} + +/** + * Process an `extends` expression — either an `Identifier` (regular class + * inheritance) or a `CallExpression` (mixin chain like `OuterMixin(InnerMixin(Base))`). + * Mixins are applied innermost-first so that outer mixin overrides win. + */ +function processExtendsExpression( + extendsExpr: ts.Expression, + sourceFile: ts.SourceFile, + filePath: string, + properties: Record, + compilerOptions: ts.CompilerOptions, + seen: Set, + nativeNames: Set, + visitedFiles: string[] +): void { + if (ts.isIdentifier(extendsExpr)) { + const parentClassName = extendsExpr.text; + if (HOST_BASE_CLASSES.has(parentClassName)) return; + const parentImportPath = findImportPath(sourceFile, parentClassName); + if (parentImportPath) { + const parentFilePath = resolveModuleToFile(filePath, parentImportPath, compilerOptions); + if (parentFilePath) { + extractClassProperties( + parentFilePath, + parentClassName, + properties, + compilerOptions, + seen, + nativeNames, + visitedFiles + ); + } + } else { + // Parent is declared in the same file. + extractClassProperties(filePath, parentClassName, properties, compilerOptions, seen, nativeNames, visitedFiles); + } + return; + } + + if (ts.isCallExpression(extendsExpr)) { + const { mixins, base } = unwindMixinChain(extendsExpr); + // Process the base class first (innermost identifier), then layer each + // mixin from innermost to outermost so outer mixins override inner ones. + processExtendsExpression(base, sourceFile, filePath, properties, compilerOptions, seen, nativeNames, visitedFiles); + for (let i = mixins.length - 1; i >= 0; i--) { + processMixin(mixins[i]!.name, sourceFile, filePath, properties, compilerOptions, seen, nativeNames, visitedFiles); + } + } +} + +/** + * Unwind a mixin call chain like `OuterMixin(InnerMixin(Base))` into a list + * of mixin names (outermost first) and the innermost expression (the base). + * Parens and `as` casts are stripped between layers. + */ +function unwindMixinChain(callExpr: ts.CallExpression): { + mixins: Array<{ name: string }>; + base: ts.Expression; +} { + const mixins: Array<{ name: string }> = []; + let current: ts.Expression = callExpr; + while (ts.isCallExpression(current)) { + if (ts.isIdentifier(current.expression)) { + mixins.push({ name: current.expression.text }); + } else { + // Non-identifier callee — give up walking further down. + break; + } + if (current.arguments.length === 0) break; + current = unwrapExpression(current.arguments[0]!); + } + return { mixins, base: current }; +} + +/** + * Walk a mixin function and merge the inner class's getters/setters into + * `properties`. Supports the three mixin shapes used in this codebase: + * A: `function MixinName(arg) { class Inner extends arg { ... } }` + * B: `const MixinName: Mixin<...> = (arg) => { class Inner extends arg { ... } }` + * C: `const MixinName = (arg) => { class Inner extends arg { ... } }` + * + * The inner class always extends a function parameter; we don't recurse on + * that — the outer chain walk already handles the base. + */ +function processMixin( + mixinName: string, + callerSourceFile: ts.SourceFile, + callerFilePath: string, + properties: Record, + compilerOptions: ts.CompilerOptions, + seen: Set, + nativeNames: Set, + visitedFiles: string[] +): void { + const resolved = resolveMixinDeclaration(mixinName, callerSourceFile, callerFilePath, compilerOptions, new Set()); + if (!resolved) return; + + const seenKey = `${resolved.filePath}::mixin::${resolved.name}`; + if (seen.has(seenKey)) return; + seen.add(seenKey); + + visitedFiles.push(resolved.filePath); + applyClassMembers(resolved.innerClass, resolved.sourceFile, properties, nativeNames); +} + +/** + * Resolve a mixin name to the file containing its declaration and the inner + * class returned by the mixin. Follows re-exports through barrel files + * (`export { Foo } from './bar'`). Returns `undefined` if not found. + */ +function resolveMixinDeclaration( + mixinName: string, + callerSourceFile: ts.SourceFile, + callerFilePath: string, + compilerOptions: ts.CompilerOptions, + visited: Set +): { name: string; filePath: string; sourceFile: ts.SourceFile; innerClass: ts.ClassDeclaration } | undefined { + const importPath = findImportPath(callerSourceFile, mixinName); + let mixinFilePath: string; + if (importPath) { + const resolved = resolveModuleToFile(callerFilePath, importPath, compilerOptions); + if (!resolved) return undefined; + mixinFilePath = resolved; + } else { + mixinFilePath = callerFilePath; + } + + const visitKey = `${mixinFilePath}::${mixinName}`; + if (visited.has(visitKey)) return undefined; + visited.add(visitKey); + + const content = fs.readFileSync(mixinFilePath, 'utf-8'); + const sourceFile = ts.createSourceFile(mixinFilePath, content, ts.ScriptTarget.Latest, true); + + const innerClass = findMixinInnerClass(sourceFile, mixinName); + if (innerClass) { + return { name: mixinName, filePath: mixinFilePath, sourceFile, innerClass }; + } + + // Not declared here — follow a re-export if present. + const reExport = findReExportSource(sourceFile, mixinName); + if (!reExport) { + // Entry-point barrels (and rolled-up entry .d.ts files, e.g. + // @videojs/spf/hls → dist/dev/hls.d.ts) import the implementation and + // re-export it without a module specifier — follow the import binding. + const importBinding = findImportPath(sourceFile, mixinName); + if (!importBinding) return undefined; + const importedFilePath = resolveModuleToFile(mixinFilePath, importBinding, compilerOptions); + if (!importedFilePath || importedFilePath === mixinFilePath) return undefined; + const importedVisitKey = `${importedFilePath}::${mixinName}`; + if (visited.has(importedVisitKey)) return undefined; + visited.add(importedVisitKey); + const importedContent = fs.readFileSync(importedFilePath, 'utf-8'); + const importedSourceFile = ts.createSourceFile(importedFilePath, importedContent, ts.ScriptTarget.Latest, true); + const importedInner = findMixinInnerClass(importedSourceFile, mixinName); + if (importedInner) { + return { name: mixinName, filePath: importedFilePath, sourceFile: importedSourceFile, innerClass: importedInner }; + } + return resolveMixinDeclaration(mixinName, importedSourceFile, importedFilePath, compilerOptions, visited); + } + + const targetName = reExport.exportedName; + const targetFilePath = resolveModuleToFile(mixinFilePath, reExport.moduleSpecifier, compilerOptions); + if (!targetFilePath) return undefined; + const targetContent = fs.readFileSync(targetFilePath, 'utf-8'); + const targetSourceFile = ts.createSourceFile(targetFilePath, targetContent, ts.ScriptTarget.Latest, true); + + const targetVisitKey = `${targetFilePath}::${targetName}`; + if (visited.has(targetVisitKey)) return undefined; + visited.add(targetVisitKey); + + const targetInner = findMixinInnerClass(targetSourceFile, targetName); + if (targetInner) { + return { name: targetName, filePath: targetFilePath, sourceFile: targetSourceFile, innerClass: targetInner }; + } + + // Multi-hop re-export (rare). + return resolveMixinDeclaration(targetName, targetSourceFile, targetFilePath, compilerOptions, visited); +} + +/** + * Look for a `export { Foo } from './bar'` (or `export { Foo as Bar } from './bar'`) + * matching the given name. Returns the source module specifier and the actual + * exported identifier in the target module. + */ +function findReExportSource( + sourceFile: ts.SourceFile, + name: string +): { moduleSpecifier: string; exportedName: string } | undefined { + let result: { moduleSpecifier: string; exportedName: string } | undefined; + ts.forEachChild(sourceFile, (node) => { + if (result) return; + if (!ts.isExportDeclaration(node)) return; + if (!node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) return; + if (!node.exportClause || !ts.isNamedExports(node.exportClause)) return; + for (const specifier of node.exportClause.elements) { + // `export { Foo as Bar }` — `propertyName` = original (Foo), `name` = alias (Bar). + const localName = specifier.name.text; + const targetName = (specifier.propertyName ?? specifier.name).text; + if (localName === name) { + result = { moduleSpecifier: node.moduleSpecifier.text, exportedName: targetName }; + return; } } + }); + return result; +} - for (const member of node.members) { - if (!ts.isGetAccessorDeclaration(member) && !ts.isSetAccessorDeclaration(member)) continue; - if (!member.name || !ts.isIdentifier(member.name)) continue; +/** + * Find the inner class declared inside a mixin function whose `extends` + * targets one of the function's parameters. + */ +function findMixinInnerClass(sourceFile: ts.SourceFile, mixinName: string): ts.ClassDeclaration | undefined { + let result: ts.ClassDeclaration | undefined; - 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; + function scanBody(body: ts.Node, paramNames: Set): void { + function visit(n: ts.Node): void { + if (result) return; + if (ts.isClassDeclaration(n) && n.heritageClauses) { + const ext = n.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword); + if (ext && ext.types.length > 0) { + const extExpr = unwrapExpression(ext.types[0]!.expression); + if (ts.isIdentifier(extExpr) && paramNames.has(extExpr.text)) { + result = n; + return; + } + } + } + ts.forEachChild(n, visit); + } + ts.forEachChild(body, visit); + } - if (ts.isGetAccessorDeclaration(member)) { - let type = 'unknown'; - if (member.type) { - type = member.type.getText(sourceFile); + ts.forEachChild(sourceFile, (node) => { + if (result) return; + + // Shape A: function declaration + if (ts.isFunctionDeclaration(node) && node.name?.text === mixinName && node.body) { + scanBody(node.body, getParameterNames(node.parameters)); + return; + } + + // Shapes B / C: const arrow / function expression + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === mixinName && + decl.initializer && + (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) + ) { + const body = decl.initializer.body; + if (ts.isBlock(body)) { + scanBody(body, getParameterNames(decl.initializer.parameters)); + } + return; } - 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 && !HOST_BASE_CLASSES.has(parentClassName)) { - // 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; + return result; +} - for (const specifier of importClause.namedBindings.elements) { - const importedName = (specifier.propertyName ?? specifier.name).text; - if (importedName === parentClassName) { - parentImportPath = node.moduleSpecifier.text; - break; - } - } - }); +function getParameterNames(params: ts.NodeArray): Set { + const names = new Set(); + for (const p of params) { + if (ts.isIdentifier(p.name)) names.add(p.name.text); + } + return names; +} - if (parentImportPath) { - const parentFilePath = resolveModuleToFile(filePath, parentImportPath, compilerOptions); - if (parentFilePath) { - // Extract parent properties first — child will override - extractClassProperties(parentFilePath, parentClassName, properties, compilerOptions, seen); +/** + * Collect getter/setter pairs from a single class node and merge them into + * `properties`. Description fallback: if a child override has no JSDoc, + * the closest ancestor's description is preserved. + */ +function applyClassMembers( + classNode: ts.ClassDeclaration, + sourceFile: ts.SourceFile, + properties: Record, + nativeNames: Set +): void { + const getters = new Map(); + const setters = new Set(); + + for (const member of classNode.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); } - } else { - // Parent is in the same file - extractClassProperties(filePath, parentClassName, properties, compilerOptions, seen); + const description = getJSDocDescription(member); + getters.set(name, { type, description }); + } else if (ts.isSetAccessorDeclaration(member)) { + setters.add(name); } } - // Apply this class's properties (overrides parent) for (const [name, info] of getters) { const def: HostPropertyDef = { type: info.type, readonly: !setters.has(name), }; - if (info.description) def.description = info.description; + // Description fallback: keep parent's if child has none. + const description = info.description ?? properties[name]?.description; + if (description) def.description = description; + if (nativeNames.has(name)) def.overridesNative = true; properties[name] = def; } } +/** + * Resolve host property types via the type checker, keyed by property name. + * Walking own class members only sees explicit annotations, so this reads the + * class's full instance type — which the checker resolves through the mixin + * chain — to recover types for unannotated getters (e.g. `get src()` → string). + */ +function resolveInferredTypes( + hostFilePath: string, + hostClassName: string, + program: ts.Program, + checker: ts.TypeChecker +): Map { + const types = new Map(); + const sourceFile = program.getSourceFile(hostFilePath); + if (!sourceFile) return types; + + let classNode: ts.ClassDeclaration | undefined; + const visit = (node: ts.Node) => { + if (ts.isClassDeclaration(node) && node.name?.text === hostClassName) classNode = node; + if (!classNode) ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!classNode?.name) return types; + + const symbol = checker.getSymbolAtLocation(classNode.name); + if (!symbol) return types; + + for (const prop of checker.getDeclaredTypeOfSymbol(symbol).getProperties()) { + const propType = checker.getTypeOfSymbolAtLocation(prop, classNode); + types.set(prop.name, checker.typeToString(propType)); + } + return types; +} + +function findImportPath(sourceFile: ts.SourceFile, name: string): string | undefined { + let importPath: string | undefined; + ts.forEachChild(sourceFile, (node) => { + if (importPath) return; + 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 === name) { + importPath = node.moduleSpecifier.text; + return; + } + } + }); + return importPath; +} + // ─── JSDoc Extraction ──────────────────────────────────────────────── function getJSDocDescription(node: ts.Node): string | undefined { @@ -382,6 +749,185 @@ function getJSDocDescription(node: ts.Node): string | undefined { return parts.join('') || undefined; } +// ─── Default Value Extraction ──────────────────────────────────────── + +const fileDefaultsCache = new Map>(); + +/** + * Collect default values from every `*DefaultProps` object literal exported + * by a file, in declaration order. Spread entries are resolved through + * imports (e.g. `{ ...hlsMediaDefaultProps, castSrc: '' }`). + */ +function collectFileDefaults(filePath: string, compilerOptions: ts.CompilerOptions): Map { + const cached = fileDefaultsCache.get(filePath); + if (cached) return cached; + + const defaults = new Map(); + fileDefaultsCache.set(filePath, defaults); + if (!fs.existsSync(filePath)) return defaults; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return; + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.name.text.endsWith('DefaultProps')) continue; + if (!decl.initializer) continue; + const init = unwrapExpression(decl.initializer); + if (!ts.isObjectLiteralExpression(init)) continue; + for (const [name, value] of resolveObjectLiteralEntries(init, sourceFile, filePath, compilerOptions, new Set())) { + defaults.set(name, value); + } + } + }); + + return defaults; +} + +/** + * Flatten an object literal into name → serialized value, resolving spreads + * of identifiers declared in the same file or imported from another file. + * Entries are processed in source order, so later entries override spreads. + */ +function resolveObjectLiteralEntries( + objectLiteral: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, + filePath: string, + compilerOptions: ts.CompilerOptions, + visited: Set +): Map { + const entries = new Map(); + + for (const prop of objectLiteral.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const value = serializeDefaultValue(prop.initializer, sourceFile, filePath, compilerOptions); + if (value !== undefined) entries.set(prop.name.text, value); + 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); + const spreadEntries = resolveObjectLiteralEntries( + resolved.objectLiteral, + resolved.sourceFile, + resolved.filePath, + compilerOptions, + visited + ); + for (const [name, value] of spreadEntries) { + entries.set(name, value); + } + } + } + + return entries; +} + +/** + * Resolve an identifier to a `const name = { ... }` object literal declared + * in the same file or in an imported file. + */ +function resolveConstObjectLiteral( + name: string, + sourceFile: ts.SourceFile, + filePath: string, + compilerOptions: ts.CompilerOptions +): { objectLiteral: ts.ObjectLiteralExpression; sourceFile: ts.SourceFile; filePath: string } | undefined { + const local = findConstObjectLiteral(sourceFile, name); + if (local) return { objectLiteral: local, sourceFile, filePath }; + + const importPath = findImportPath(sourceFile, name); + if (!importPath) return undefined; + const importedFilePath = resolveModuleToFile(filePath, importPath, compilerOptions); + if (!importedFilePath || !fs.existsSync(importedFilePath)) return undefined; + + const content = fs.readFileSync(importedFilePath, 'utf-8'); + const importedSourceFile = ts.createSourceFile(importedFilePath, content, ts.ScriptTarget.Latest, true); + const imported = findConstObjectLiteral(importedSourceFile, name); + if (!imported) return undefined; + return { objectLiteral: imported, sourceFile: importedSourceFile, filePath: importedFilePath }; +} + +function findConstObjectLiteral(sourceFile: ts.SourceFile, name: string): ts.ObjectLiteralExpression | undefined { + let result: ts.ObjectLiteralExpression | undefined; + ts.forEachChild(sourceFile, (node) => { + if (result || !ts.isVariableStatement(node)) return; + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== name || !decl.initializer) continue; + const init = unwrapExpression(decl.initializer); + if (ts.isObjectLiteralExpression(init)) { + result = init; + return; + } + } + }); + return result; +} + +// `undefined` is deliberately excluded — "default: undefined" conveys nothing +// beyond the table's "—" placeholder. +const LITERAL_IDENTIFIERS = new Set(['NaN', 'Infinity']); + +/** + * Serialize a default value expression for display in the docs: + * - Literals (strings, numbers, booleans, null, undefined, NaN) verbatim + * - Empty object literals as `{}`, non-empty abbreviated as `{…}` + * - Short array literals verbatim, long ones abbreviated as `[…]` + * - `Obj.MEMBER` resolved to the member's literal in a `... as const` object + * - Anything else is omitted (returns undefined) + */ +function serializeDefaultValue( + expr: ts.Expression, + sourceFile: ts.SourceFile, + filePath: string, + compilerOptions: ts.CompilerOptions +): string | undefined { + const value = unwrapExpression(expr); + + if ( + ts.isStringLiteral(value) || + ts.isNoSubstitutionTemplateLiteral(value) || + ts.isNumericLiteral(value) || + value.kind === ts.SyntaxKind.TrueKeyword || + value.kind === ts.SyntaxKind.FalseKeyword || + value.kind === ts.SyntaxKind.NullKeyword || + ts.isPrefixUnaryExpression(value) + ) { + return value.getText(sourceFile); + } + + if (ts.isIdentifier(value) && LITERAL_IDENTIFIERS.has(value.text)) { + return value.text; + } + + if (ts.isObjectLiteralExpression(value)) { + return value.properties.length === 0 ? '{}' : '{…}'; + } + + if (ts.isArrayLiteralExpression(value)) { + const text = value.getText(sourceFile); + return text.length <= 24 ? text : '[…]'; + } + + if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && ts.isIdentifier(value.name)) { + const resolved = resolveConstObjectLiteral(value.expression.text, sourceFile, filePath, compilerOptions); + if (!resolved) return undefined; + for (const prop of resolved.objectLiteral.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === value.name.text) { + return serializeDefaultValue(prop.initializer, resolved.sourceFile, resolved.filePath, compilerOptions); + } + } + return undefined; + } + + return undefined; +} + // ─── Shared Data Extraction ────────────────────────────────────────── /** @@ -436,100 +982,62 @@ function extractStaticProperties(filePath: string): string[] { return attributes; } -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[] = []; +// ─── Method Extraction ─────────────────────────────────────────────── - 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; -} +// Lifecycle methods plus EventTarget/DOM-query plumbing that the host class +// overrides but which aren't part of the native HTMLMediaElement method API the +// docs link to. +const EXCLUDED_METHOD_NAMES = new Set([ + 'attach', + 'detach', + 'destroy', + 'addEventListener', + 'removeEventListener', + 'querySelector', + 'querySelectorAll', +]); /** - * Extract slots from getCommonTemplateHTML — a factory function that returns - * a function containing the template string. + * Collect public instance method names declared directly on a named class. + * Excludes the constructor, lifecycle methods (attach/detach/destroy), + * private/protected `_`/`#` names, and accessors (getters/setters are + * properties, not methods). Returns [] if the file or class isn't found. */ -function extractSlotsFromTemplateFactory(filePath: string, factoryFnName: string): string[] { +function extractPublicMethodNames(filePath: string, className: string): string[] { + if (!fs.existsSync(filePath)) return []; + 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 === factoryFnName && node.body) { - // The factory returns a function — look for a return statement with a function/arrow - for (const stmt of node.body.statements) { - if (ts.isReturnStatement(stmt) && stmt.expression) { - // Could be an arrow function or function expression - let innerBody: ts.Block | ts.Expression | undefined; - if (ts.isArrowFunction(stmt.expression)) { - innerBody = stmt.expression.body; - } else if (ts.isFunctionExpression(stmt.expression)) { - innerBody = stmt.expression.body; - } - - if (innerBody) { - const templateText = ts.isBlock(innerBody) - ? extractTemplateString(innerBody) - : getTemplateText(innerBody as ts.Expression); - if (templateText) { - parseSlots(templateText, slots); - } - } - } - } - return; + let classNode: ts.ClassDeclaration | undefined; + ts.forEachChild(sourceFile, (node) => { + if (ts.isClassDeclaration(node) && node.name?.text === className) { + classNode = node; } - ts.forEachChild(node, visit); - } + }); + if (!classNode) return []; - visit(sourceFile); - return slots; + const names: string[] = []; + for (const member of classNode.members) { + if (!ts.isMethodDeclaration(member) || !member.name || !ts.isIdentifier(member.name)) continue; + if ( + member.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword + ) + ) { + continue; + } + const name = member.name.text; + if (name.startsWith('_') || name.startsWith('#')) continue; + if (EXCLUDED_METHOD_NAMES.has(name)) continue; + names.push(name); + } + return names; } -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 = /]*>/g; - let match: RegExpExecArray | null; - while ((match = slotRegex.exec(html)) !== null) { - const name = match[1] ?? ''; - slots.push(name); - } +/** Merge two method-name lists, dedupe by name, and sort alphabetically. */ +function mergeMethodNames(a: readonly string[], b: readonly string[]): string[] { + return [...new Set([...a, ...b])].sort(); } // ─── Event Extraction ──────────────────────────────────────────────── @@ -596,6 +1104,181 @@ function extractEventsFromTypes(filePath: string, interfaceName: string): string return events; } +/** + * Scan a host class (and its mixin/parent chain) for `this.dispatchEvent(new Event('name'))` + * style calls and `@fires` JSDoc tags, collecting dispatched event names into `events` + * and tag descriptions into `fires` (which also acts as an event source — dispatch + * sites in helper files the walk never visits can be declared via `@fires` alone). + * Forwarding patterns like `new (event.constructor as ...)(event.type, event)` are + * naturally skipped because their first argument is not a `StringLiteral`. + */ +function extractDispatchedEvents( + filePath: string, + className: string, + compilerOptions: ts.CompilerOptions, + seen: Set, + events: Set, + fires: Map +): Set { + const fileScanKey = `${filePath}::dispatchEvents`; + if (!seen.has(fileScanKey)) { + seen.add(fileScanKey); + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + scanForDispatchEvents(sourceFile, events); + scanForFiresTags(sourceFile, fires); + + let classNode: ts.ClassDeclaration | undefined; + ts.forEachChild(sourceFile, (n) => { + if (ts.isClassDeclaration(n) && n.name?.text === className) { + classNode = n; + } + }); + if (classNode) { + const extendsClause = classNode.heritageClauses?.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword); + if (extendsClause && extendsClause.types.length > 0) { + const extendsExpr = unwrapExpression(extendsClause.types[0]!.expression); + walkExtendsForDispatchEvents(extendsExpr, sourceFile, filePath, compilerOptions, seen, events, fires); + } + } + } + return events; +} + +function walkExtendsForDispatchEvents( + extendsExpr: ts.Expression, + sourceFile: ts.SourceFile, + filePath: string, + compilerOptions: ts.CompilerOptions, + seen: Set, + events: Set, + fires: Map +): void { + if (ts.isIdentifier(extendsExpr)) { + const parentName = extendsExpr.text; + if (HOST_BASE_CLASSES.has(parentName)) return; + const parentImportPath = findImportPath(sourceFile, parentName); + if (parentImportPath) { + const parentFilePath = resolveModuleToFile(filePath, parentImportPath, compilerOptions); + if (parentFilePath) { + extractDispatchedEvents(parentFilePath, parentName, compilerOptions, seen, events, fires); + } + } else { + extractDispatchedEvents(filePath, parentName, compilerOptions, seen, events, fires); + } + return; + } + + if (ts.isCallExpression(extendsExpr)) { + const { mixins, base } = unwindMixinChain(extendsExpr); + walkExtendsForDispatchEvents(base, sourceFile, filePath, compilerOptions, seen, events, fires); + for (const mixin of mixins) { + const importPath = findImportPath(sourceFile, mixin.name); + let mixinFilePath: string; + if (importPath) { + const resolved = resolveModuleToFile(filePath, importPath, compilerOptions); + if (!resolved) continue; + mixinFilePath = resolved; + } else { + mixinFilePath = filePath; + } + // Follow barrel re-exports so the actual mixin declaration file is + // scanned (mirrors resolveMixinDeclaration's re-export handling). + const declaration = resolveMixinDeclaration(mixin.name, sourceFile, filePath, compilerOptions, new Set()); + if (declaration) mixinFilePath = declaration.filePath; + const key = `${mixinFilePath}::dispatchEvents`; + if (seen.has(key)) continue; + seen.add(key); + const content = fs.readFileSync(mixinFilePath, 'utf-8'); + const mixinSourceFile = ts.createSourceFile(mixinFilePath, content, ts.ScriptTarget.Latest, true); + scanForDispatchEvents(mixinSourceFile, events); + scanForFiresTags(mixinSourceFile, fires); + } + } +} + +/** + * Collect `@fires name - description` JSDoc tags from a file. The tag may sit + * on the dispatching class, a mixin function, or the const holding a mixin + * arrow function. + */ +function scanForFiresTags(sourceFile: ts.SourceFile, fires: Map): void { + function visit(node: ts.Node): void { + const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc; + if (jsDocNodes) { + for (const doc of jsDocNodes) { + for (const tag of doc.tags ?? []) { + if (tag.tagName.text !== 'fires') continue; + const parsed = parseFiresTagComment(tag); + if (parsed && !fires.has(parsed.name)) { + fires.set(parsed.name, parsed.description); + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); +} + +function parseFiresTagComment(tag: ts.JSDocTag): { name: string; description: string } | undefined { + let comment = ''; + if (typeof tag.comment === 'string') { + comment = tag.comment; + } else if (tag.comment) { + comment = tag.comment.map((part) => ('text' in part ? part.text : '')).join(''); + } + const match = comment.trim().match(/^(\S+)\s*(?:-\s*)?(.*)$/s); + if (!match) return undefined; + const [, name, description] = match; + if (!name) return undefined; + return { name, description: description?.trim() ?? '' }; +} + +function scanForDispatchEvents(sourceFile: ts.SourceFile, events: Set): void { + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'dispatchEvent' && + node.arguments.length > 0 + ) { + const arg = node.arguments[0]!; + if (ts.isNewExpression(arg) && arg.arguments && arg.arguments.length > 0) { + const eventArg = arg.arguments[0]!; + if (ts.isStringLiteral(eventArg)) { + events.add(eventArg.text); + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); +} + +function dedupeStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +/** + * Build the set of property names declared on `HTMLMediaElement`, + * `HTMLVideoElement`, and `HTMLAudioElement` (per `lib.dom.d.ts`). Used to + * tag host properties that override a native member. + */ +function collectNativeMemberNames(program: ts.Program, anchorFile: ts.SourceFile): Set { + const checker = program.getTypeChecker(); + const names = new Set(); + for (const ifaceName of ['HTMLMediaElement', 'HTMLVideoElement', 'HTMLAudioElement']) { + const symbol = checker.resolveName(ifaceName, anchorFile, ts.SymbolFlags.Type, false); + if (!symbol) continue; + const type = checker.getDeclaredTypeOfSymbol(symbol); + for (const member of type.getProperties()) { + names.add(member.getName()); + } + } + return names; +} + // ─── Pipeline ──────────────────────────────────────────────────────── export function generateMediaElementReferences(monorepoRoot: string): MediaElementResult[] { @@ -618,11 +1301,52 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme const videoEvents = fs.existsSync(mediaTypesPath) ? extractEventsFromTypes(mediaTypesPath, 'VideoEvents') : []; const audioEvents = fs.existsSync(mediaTypesPath) ? extractEventsFromTypes(mediaTypesPath, 'AudioEvents') : []; - // Extract CSS vars using the existing handler (needs a TS program) - const program = ts.createProgram([customMediaPath], compilerOptions); + // Custom Video.js events (e.g. `streamtypechange`, `targetlivewindowchange`) + // are baked into the VideoEvents/AudioEvents contract via dedicated capability + // interfaces, but they are NOT native DOM media events — they must never + // surface in the `native` list (which points readers at MDN). They belong + // solely to the element-specific bucket, where `@fires` adds them per element + // that actually exposes the capability. + const customEventNames = fs.existsSync(mediaTypesPath) + ? new Set([ + ...extractEventsFromTypes(mediaTypesPath, 'MediaStreamTypeEvents'), + ...extractEventsFromTypes(mediaTypesPath, 'MediaLiveEvents'), + ]) + : new Set(); + + // Supported native media methods are the public instance methods forwarded + // from the shared base host classes — extracted ONCE per media type (mirroring + // how events come from VideoEvents/AudioEvents), not per element. Video adds + // the video-host methods; audio adds the audio-host methods. + const mediaHostPath = path.join(monorepoRoot, 'packages/core/src/dom/media/media-host.ts'); + const videoHostPath = path.join(monorepoRoot, 'packages/core/src/dom/media/video-host.ts'); + const audioHostPath = path.join(monorepoRoot, 'packages/core/src/dom/media/audio-host.ts'); + const baseMethods = extractPublicMethodNames(mediaHostPath, 'HTMLMediaElementHost'); + const videoMethods = mergeMethodNames(baseMethods, extractPublicMethodNames(videoHostPath, 'HTMLVideoElementHost')); + const audioMethods = mergeMethodNames(baseMethods, extractPublicMethodNames(audioHostPath, 'HTMLAudioElementHost')); + + // Extract CSS vars using the existing handler (needs a TS program). + // Ensure `lib.dom.d.ts` is loaded so HTMLMediaElement / HTMLVideoElement / + // HTMLAudioElement member names can be resolved for the `overridesNative` + // tag — `tsconfig.base.json` only lists `ES2022`. + const programOptions: ts.CompilerOptions = { + ...compilerOptions, + lib: dedupeStrings([...(compilerOptions.lib ?? []), 'lib.dom.d.ts']), + }; + const program = ts.createProgram( + dedupeStrings([customMediaPath, ...sources.map((s) => s.hostFilePath)]), + programOptions + ); + const checker = program.getTypeChecker(); const videoCSSVarsRaw = extractCSSVars(customMediaPath, program, 'Video'); const audioCSSVarsRaw = extractCSSVars(customMediaPath, program, 'Audio'); + // Collect native HTMLMediaElement/Video/Audio member names from lib.dom.d.ts. + const customMediaSourceFile = program.getSourceFile(customMediaPath); + const nativeNames = customMediaSourceFile + ? collectNativeMemberNames(program, customMediaSourceFile) + : new Set(); + const videoCSSVars: Record = {}; if (videoCSSVarsRaw) { for (const v of videoCSSVarsRaw.vars) { @@ -637,34 +1361,72 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme } } - // Extract slots from template functions - const videoSlots = extractSlotsFromTemplate(customMediaPath, 'getVideoTemplateHTML'); - const audioSlots = extractSlotsFromTemplateFactory(customMediaPath, 'getCommonTemplateHTML'); - const results: MediaElementResult[] = []; for (const source of sources) { - const hostProperties = extractHostProperties(source.hostFilePath, source.hostClassName, compilerOptions); + const hostProperties = extractHostProperties( + source.hostFilePath, + source.hostClassName, + compilerOptions, + nativeNames + ); - // Deduplicate: host props that overlap with native attributes - const hostAttrNames = new Set(); - for (const propName of Object.keys(hostProperties)) { - hostAttrNames.add(propName.toLowerCase()); + // 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)!; + } } - const nativeAttributes = allAttributes.filter((attr) => !hostAttrNames.has(attr)); + + // 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 slots = source.mediaType === 'video' ? videoSlots : audioSlots; - const events = source.mediaType === 'video' ? videoEvents : audioEvents; + + // Walk the host's mixin/parent chain collecting `@fires` descriptions. An + // event is documented as element-specific iff it carries a `@fires` tag — + // that tag is the authored signal that an event needs a description. Standard + // 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(); + extractDispatchedEvents(source.hostFilePath, source.hostClassName, compilerOptions, new Set(), new Set(), fires); + const elementSpecific: MediaEventDef[] = [...fires.keys()].sort().map((name) => { + const def: MediaEventDef = { name }; + const description = fires.get(name); + if (description) def.description = description; + return def; + }); + + // The native list points readers at MDN, so it must contain only genuine + // native DOM events. Exclude (1) element-specific events already surfaced + // with their own description, and (2) custom Video.js events from the + // 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 methods = source.mediaType === 'video' ? videoMethods : audioMethods; const reference: MediaElementReference = { name: source.className, tagName: source.tagName, + mediaType: source.mediaType, hostProperties, nativeAttributes, - events, + events: { native, elementSpecific }, + methods, cssCustomProperties, - slots, }; results.push({ name: source.className, reference }); diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts index 970240c9..01c62e11 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -582,16 +582,29 @@ export interface HostPropertyDef { type: string; description?: string; readonly: boolean; + overridesNative?: boolean; + /** Serialized default value from the host's `*DefaultProps` export. */ + default?: string; +} + +export interface MediaEventDef { + name: string; + /** Description from a `@fires` JSDoc tag on the dispatching class or mixin. */ + description?: string; } export interface MediaElementReference { name: string; tagName: string; + mediaType: 'video' | 'audio'; hostProperties: Record; nativeAttributes: string[]; - events: string[]; + events: { + native: string[]; + elementSpecific: MediaEventDef[]; + }; + methods: string[]; cssCustomProperties: Record; - slots: string[]; } export interface MediaElementResult { diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index c0ee2226..23f17242 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -64,11 +64,12 @@ * simple-video — Simple media element. Exercises: discovery via static * tagName in define/media/*.ts, minimal host (src rw, * engine readonly), shared attributes/events/CSS vars - * from custom-media-element, slots parsed from template HTML. + * from custom-media-element. * complex-video — Complex media element. Exercises: host with JSDoc * descriptions, multiple property types (string, boolean, - * Record), host-vs-native attribute deduplication - * (src, preload in host → omitted from nativeAttributes). + * Record), and the intentional content-attribute vs + * IDL-property overlap (src, preload appear in BOTH + * hostProperties and nativeAttributes — no dedup). * extending-video — Extending media element. Exercises: host inheritance * (ExtendingHost extends ComplexHost). Builder must * walk the extends chain to include inherited properties. @@ -1069,7 +1070,6 @@ describe('Preset pipeline (end-to-end)', () => { // - Host properties by following the CustomMediaElement(tag, Host) call to the // host class and walking its getter/setter pairs // - Shared native attributes from static properties, events, and CSS vars -// - Slots parsed from the template HTML (getVideoTemplateHTML / getCommonTemplateHTML) // - JSDoc descriptions from host getter/setter pairs // // Key behaviors: @@ -1077,8 +1077,13 @@ 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 -// - Deduplication: properties in the host that overlap with native attributes -// (e.g., src, preload) appear in hostProperties and are omitted from nativeAttributes +// - 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). +// - 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. describe('Media element pipeline (end-to-end)', () => { const results = generateMediaElementReferences(FIXTURE_ROOT); @@ -1094,7 +1099,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', 'SimpleVideo']); + expect(names).toEqual(['ComplexVideo', 'ExtendingVideo', 'MixinVideo', 'SimpleVideo', 'SpfAudio']); }); it('excludes container (re-export, not inline class declaration)', () => { @@ -1108,7 +1113,7 @@ describe('Media element pipeline (end-to-end)', () => { }); it('produces one result per media element', () => { - expect(results.length).toBe(3); + expect(results.length).toBe(5); }); }); @@ -1125,6 +1130,7 @@ describe('Media element pipeline (end-to-end)', () => { it('extracts the tag name', () => { const ref = findElement('SimpleVideo')!.reference; expect(ref.tagName).toBe('simple-video'); + expect(ref.mediaType).toBe('video'); }); it('extracts host properties with types and readonly flags', () => { @@ -1137,7 +1143,8 @@ describe('Media element pipeline (end-to-end)', () => { }); expect(props.src.description).toBeUndefined(); - // engine: readonly object + // engine: readonly, unannotated getter — type inferred by the checker + // (would be 'unknown' if the builder only read syntactic annotations). expect(props.engine).toMatchObject({ type: 'object', readonly: true, @@ -1151,9 +1158,12 @@ describe('Media element pipeline (end-to-end)', () => { expect(props.destroy).toBeUndefined(); }); - it('includes native attributes from static properties', () => { + it('includes the COMPLETE set of native attributes from static properties', () => { const ref = findElement('SimpleVideo')!.reference; - // src is in the host, so it should be omitted from nativeAttributes + // 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.arrayContaining([ 'autoplay', @@ -1166,14 +1176,24 @@ describe('Media element pipeline (end-to-end)', () => { 'preload', ]) ); - expect(ref.nativeAttributes).not.toContain('src'); + expect(ref.nativeAttributes).toContain('src'); + }); + + 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']); }); it('includes events derived from VideoEvents capability contracts', () => { const ref = findElement('SimpleVideo')!.reference; - // Events are extracted from VideoEvents in types.ts, which extends - // all capability event interfaces including TextTrackListEvents - expect(ref.events).toEqual([ + // Events are extracted from VideoEvents in types.ts, which extends all + // capability event interfaces including TextTrackListEvents. Custom + // 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([ 'play', 'playing', 'waiting', @@ -1198,6 +1218,20 @@ describe('Media element pipeline (end-to-end)', () => { 'changetrack', 'trackmodechange', ]); + // SimpleHost dispatches no events of its own. + expect(ref.events.elementSpecific).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` + // (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(elementSpecificNames).not.toContain('streamtypechange'); }); it('includes CSS custom properties from VideoCSSVars', () => { @@ -1209,11 +1243,6 @@ describe('Media element pipeline (end-to-end)', () => { 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', ''])); - }); }); // ───────────────────────────────────────────────────────────────── @@ -1234,7 +1263,16 @@ describe('Media element pipeline (end-to-end)', () => { it('extracts all host properties', () => { const props = findElement('ComplexVideo')!.reference.hostProperties; const propNames = Object.keys(props).sort(); - expect(propNames).toEqual(['config', 'debug', 'engine', 'preferPlayback', 'preload', 'src', 'type']); + expect(propNames).toEqual([ + 'config', + 'debug', + 'engine', + 'preferPlayback', + 'preload', + 'src', + 'streamType', + 'type', + ]); }); it('extracts JSDoc descriptions from host getters', () => { @@ -1261,19 +1299,46 @@ describe('Media element pipeline (end-to-end)', () => { expect(props.config.type).toContain('Record'); }); - it('deduplicates host props from nativeAttributes', () => { + it('keeps host-owned attributes in BOTH hostProperties and nativeAttributes', () => { const ref = findElement('ComplexVideo')!.reference; - // src and preload are in both the host AND native attributes. - // They should appear in hostProperties... + // 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(); - // ...and be omitted from nativeAttributes - expect(ref.nativeAttributes).not.toContain('src'); - expect(ref.nativeAttributes).not.toContain('preload'); + // ...and ALSO in nativeAttributes (no dedup). + expect(ref.nativeAttributes).toContain('src'); + expect(ref.nativeAttributes).toContain('preload'); // Other native attrs remain expect(ref.nativeAttributes).toContain('autoplay'); expect(ref.nativeAttributes).toContain('controls'); }); + + it('extracts defaults from the co-located defaultProps export', () => { + const props = findElement('ComplexVideo')!.reference.hostProperties; + // Literal values are emitted as source text (strings keep their quotes). + expect(props.src.default).toBe("''"); + expect(props.debug.default).toBe('false'); + expect(props.preload.default).toBe("'metadata'"); + expect(props.preferPlayback.default).toBe("'mse'"); + // `undefined` defaults are omitted — they convey nothing beyond the + // table's "—" placeholder. + expect(props.type.default).toBeUndefined(); + // Empty object literals stay literal. + expect(props.config.default).toBe('{}'); + }); + + 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; + expect(props.streamType.default).toBe("'unknown'"); + }); + + it('omits defaults for properties without a defaultProps entry', () => { + const props = findElement('ComplexVideo')!.reference.hostProperties; + expect(props.engine.default).toBeUndefined(); + }); }); // ───────────────────────────────────────────────────────────────── @@ -1327,6 +1392,31 @@ describe('Media element pipeline (end-to-end)', () => { // engine is readonly in ComplexHost and not overridden expect(props.engine.readonly).toBe(true); }); + + 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; + 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; + expect(props.playbackId.default).toBe("''"); + expect(props.maxResolution.default).toBe('1080'); + }); + + it('abbreviates non-empty object defaults', () => { + const props = findElement('ExtendingVideo')!.reference.hostProperties; + expect(props.tokens.default).toBe('{…}'); + }); + + it('omits defaults for properties without an entry', () => { + const props = findElement('ExtendingVideo')!.reference.hostProperties; + expect(props.customDomain.default).toBeUndefined(); + }); }); // ───────────────────────────────────────────────────────────────── @@ -1340,18 +1430,205 @@ 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).toContain('addtrack'); - expect(ref.events).toContain('removetrack'); - expect(ref.events).toContain('changetrack'); - expect(ref.events).toContain('trackmodechange'); + expect(ref.events.native).toContain('addtrack'); + expect(ref.events.native).toContain('removetrack'); + expect(ref.events.native).toContain('changetrack'); + expect(ref.events.native).toContain('trackmodechange'); }); - it('all video elements share the same event list', () => { - const simple = findElement('SimpleVideo')!.reference.events; - const complex = findElement('ComplexVideo')!.reference.events; - const extending = findElement('ExtendingVideo')!.reference.events; + 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; expect(complex).toEqual(simple); expect(extending).toEqual(simple); }); }); + + // ───────────────────────────────────────────────────────────────── + // MIXIN MEDIA ELEMENT: MixinVideo + // ───────────────────────────────────────────────────────────────── + // + // A media element whose host extends a chain of mixins + // (`MixinBVolumeMixin(MixinAFooMixin(MixinBaseHost))` — mirrors + // `MuxDataMediaMixin(GoogleCastMixin(HlsMedia))`). The builder must walk + // the call-expression extends, follow each mixin to its source file, and + // collect getters/setters from each mixin's inner class. + // + // Also exercises: + // - overridesNative tagging for properties whose name matches an + // HTMLMediaElement member (volume) + // - Description fallback through the chain (src has JSDoc on the base, + // overridden without JSDoc by MixinB) + // - Element-specific event extraction via this.dispatchEvent(new Event(...)) + // in mixin code (foochange dispatched by MixinAFooMixin) + + describe('MixinVideo (mixin chain)', () => { + it('extracts the tag name', () => { + const ref = findElement('MixinVideo')!.reference; + expect(ref.tagName).toBe('mixin-video'); + }); + + it('walks function-declaration mixin (Shape A)', () => { + const props = findElement('MixinVideo')!.reference.hostProperties; + expect(props.foo).toMatchObject({ + type: 'string', + readonly: false, + description: 'Mixin A documentation.', + }); + }); + + it('walks const-arrow mixin (Shape B)', () => { + const props = findElement('MixinVideo')!.reference.hostProperties; + 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; + expect(props.bar).toMatchObject({ + type: 'number', + readonly: false, + description: 'Leaf class own property.', + }); + }); + + it('marks volume as overridesNative (HTMLMediaElement member)', () => { + const props = findElement('MixinVideo')!.reference.hostProperties; + expect(props.volume.overridesNative).toBe(true); + }); + + it('does not mark non-native properties as overridesNative', () => { + const props = findElement('MixinVideo')!.reference.hostProperties; + expect(props.foo.overridesNative).toBeUndefined(); + expect(props.bar.overridesNative).toBeUndefined(); + }); + + 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; + expect(props.src.description).toBe('Source URL of the media.'); + }); + + it('documents a @fires event ONLY in element-specific, never in native', () => { + // streamtypechange is in VideoEvents (via MediaStreamTypeEvents) AND carries + // a @fires tag on the mixin — mirrors HlsMedia. Element-specific events live + // 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({ + name: 'streamtypechange', + description: 'Fired when the detected stream type changes.', + }); + }); + + it('does not document a dispatched-but-untagged event', () => { + // 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); + 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'); + expect(elementSpecificNames).not.toContain('play'); + }); + + it('extracts defaults declared in a mixin file', () => { + const props = findElement('MixinVideo')!.reference.hostProperties; + expect(props.foo.default).toBe("''"); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // CROSS-PACKAGE MIXIN AUDIO ELEMENT: SpfAudio + // ───────────────────────────────────────────────────────────────── + // + // An audio element whose host's only mixin lives in a different workspace + // package (spf), reached through that package's barrel file — mirrors + // SimpleHlsAudioOnlyMedia extending SimpleHlsAudioOnlyMediaMixin from + // @videojs/spf/hls. + // + // Also exercises: + // - @fires-declared event descriptions for events outside the native + // contract (audiomodechange also has a dispatch site, manifestparsed does + // not — the @fires tag alone surfaces both) + // - Defaults co-located with the mixin (spfAudioOnlyMediaDefaultProps) + // - AudioEvents capability contract + + describe('SpfAudio (cross-package mixin, audio host)', () => { + it('extracts the tag name and audio media type', () => { + const ref = findElement('SpfAudio')!.reference; + expect(ref.tagName).toBe('spf-audio'); + expect(ref.mediaType).toBe('audio'); + }); + + it('resolves the mixin through another package barrel', () => { + const props = findElement('SpfAudio')!.reference.hostProperties; + expect(props.src).toMatchObject({ + type: 'string', + readonly: false, + description: 'Source URL of the HLS manifest.', + }); + expect(props.preload).toMatchObject({ + type: 'string', + readonly: false, + description: 'Preload hint forwarded to the internal audio element.', + }); + }); + + it('extracts defaults declared next to the cross-package mixin', () => { + const props = findElement('SpfAudio')!.reference.hostProperties; + 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'); + }); + + it('surfaces a @fires event with its tag description', () => { + const ref = findElement('SpfAudio')!.reference; + expect(ref.events.elementSpecific).toContainEqual({ + name: 'audiomodechange', + description: 'Fired when the audio-only rendition changes.', + }); + }); + + it('includes @fires-declared events without a scanned dispatch site', () => { + const ref = findElement('SpfAudio')!.reference; + expect(ref.events.elementSpecific).toContainEqual({ + name: 'manifestparsed', + description: 'Fired after the multivariant playlist is parsed.', + }); + }); + + it('sorts element-specific events by name', () => { + const ref = findElement('SpfAudio')!.reference; + const names = ref.events.elementSpecific.map((e) => e.name); + expect(names).toEqual([...names].sort()); + }); + + it('has empty AudioCSSVars', () => { + const ref = findElement('SpfAudio')!.reference; + expect(ref.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'); + }); + }); }); diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/media/types.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/media/types.ts index a9e7a678..8265ca5b 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/media/types.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/media/types.ts @@ -6,6 +6,15 @@ * AudioEvents extends a subset (no text track events). */ +// Mirrors the real MediaStreamTypes const object. Exercises default-value +// resolution of property-access expressions (e.g. MediaStreamTypes.UNKNOWN) +// through an import to a `... as const` object literal. +export const MediaStreamTypes = { + ON_DEMAND: 'on-demand', + LIVE: 'live', + UNKNOWN: 'unknown', +} as const; + export interface EventLike { readonly type: string; readonly timeStamp: number; @@ -62,6 +71,13 @@ export interface TextTrackListEvents { trackmodechange: EventLike; } +// Video.js-specific event promoted into the typed contract — mirrors the real +// MediaStreamTypeEvents. The host that fires it also carries an `@fires` tag, so +// it appears in BOTH the native list and the described element-specific list. +export interface MediaStreamTypeEvents { + streamtypechange: EventLike; +} + export interface VideoEvents extends MediaPlaybackEvents, MediaPauseEvents, @@ -71,7 +87,8 @@ export interface VideoEvents MediaPlaybackRateEvents, MediaBufferEvents, MediaErrorEvents, - TextTrackListEvents {} + TextTrackListEvents, + MediaStreamTypeEvents {} export interface AudioEvents extends MediaPlaybackEvents, diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/audio-host.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/audio-host.ts new file mode 100644 index 00000000..6650c036 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/audio-host.ts @@ -0,0 +1,9 @@ +/** + * Mock audio host base — mirrors the real audio-host.ts. + * + * Adds no methods of its own: audio elements get only the shared media-host + * methods. + */ +import { HTMLMediaElementHost } from './media-host'; + +export class HTMLAudioElementHost extends HTMLMediaElementHost {} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/complex/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/complex/index.ts index d59afde6..46026515 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/complex/index.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/complex/index.ts @@ -3,10 +3,23 @@ * * 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. + * (src, preload) that should be deduplicated by the builder, and default + * values declared in a co-located `*DefaultProps` export (mirrors + * hlsMediaDefaultProps) including a const-object member reference. */ +import { MediaStreamTypes } from '../../../core/media/types'; import { HTMLVideoElementHost } from '../simple'; +export const complexMediaDefaultProps = { + src: '', + type: undefined, + preferPlayback: 'mse', + config: {}, + debug: false, + preload: 'metadata', + streamType: MediaStreamTypes.UNKNOWN, +}; + export class ComplexHost extends HTMLVideoElementHost { #src: string = ''; #type: string | undefined; @@ -15,6 +28,7 @@ export class ComplexHost extends HTMLVideoElementHost { #debug: boolean = false; #preload: string = 'metadata'; #engine: object | null = null; + #streamType: string = complexMediaDefaultProps.streamType; get src(): string { return this.#src; @@ -71,4 +85,13 @@ export class ComplexHost extends HTMLVideoElementHost { get engine(): object | null { return this.#engine; } + + /** Current stream type. */ + get streamType(): string { + return this.#streamType; + } + + set streamType(value: string) { + this.#streamType = value; + } } diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/extending/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/extending/index.ts index c7408812..d7879ef5 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/extending/index.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/extending/index.ts @@ -3,13 +3,25 @@ * * Exercises: host inheritance. The builder must walk the extends chain * to extract properties from both this class and its parent (ComplexHost). - * Child properties override parent properties of the same name. + * Child properties override parent properties of the same name. Defaults + * spread the parent's defaultProps (mirrors muxMediaDefaultProps) — the + * builder must resolve the spread through the import. `customDomain` + * deliberately has no default. */ -import { ComplexHost } from '../complex'; +import { ComplexHost, complexMediaDefaultProps } from '../complex'; + +export const extendingMediaDefaultProps = { + ...complexMediaDefaultProps, + playbackId: '', + tokens: { drm: '' }, + maxResolution: 1080, +}; export class ExtendingHost extends ComplexHost { #playbackId: string = ''; #customDomain: string = ''; + #tokens: Record = { ...extendingMediaDefaultProps.tokens }; + #maxResolution: number = extendingMediaDefaultProps.maxResolution; /** The playback ID for the video. */ get playbackId(): string { @@ -29,6 +41,24 @@ export class ExtendingHost extends ComplexHost { this.#customDomain = value; } + /** Playback tokens keyed by purpose. */ + get tokens(): Record { + return this.#tokens; + } + + set tokens(value: Record) { + this.#tokens = value; + } + + /** Maximum rendition height to request. */ + get maxResolution(): number { + return this.#maxResolution; + } + + set maxResolution(value: number) { + this.#maxResolution = value; + } + /** Overrides parent debug — adds network logging. */ get debug(): boolean { return super.debug; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/media-host.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/media-host.ts new file mode 100644 index 00000000..20249db7 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/media-host.ts @@ -0,0 +1,33 @@ +/** + * Mock shared media host base — mirrors the real media-host.ts. + * + * 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. + */ +export class HTMLMediaElementHost { + // Lifecycle methods — excluded from `methods`. + attach(_target: EventTarget): void {} + detach(): void {} + destroy(): void {} + + // Internal — excluded by the `_` prefix. + _forward(): void {} + + // Accessor — excluded (getters/setters are properties, not methods). + get src(): string { + return ''; + } + + play(): Promise { + return Promise.resolve(); + } + + pause(): void {} + + load(): void {} + + canPlayType(_type: string): string { + return ''; + } +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/base-host.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/base-host.ts new file mode 100644 index 00000000..2e3590d4 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/base-host.ts @@ -0,0 +1,21 @@ +/** + * Mock base host for the mixin chain fixture. + * + * Exercises: parent class providing a JSDoc-described property that the + * mixin chain may override without re-declaring the description (tests + * description fallback through the chain). + */ +import { HTMLVideoElementHost } from '../simple'; + +export class MixinBaseHost extends HTMLVideoElementHost { + #src: string = ''; + + /** Source URL of the media. */ + get src(): string { + return this.#src; + } + + set src(value: string) { + this.#src = value; + } +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/index.ts new file mode 100644 index 00000000..47eb9f8c --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/index.ts @@ -0,0 +1,24 @@ +/** + * Mock mixin-chain leaf class — mirrors MuxVideoMedia / NativeHlsMedia. + * + * Exercises: a class extending MixinB(MixinA(BaseHost)) — a chain of two + * mixins of different syntactic shapes. The builder must walk the + * call-expression extends, follow each mixin to its source file, and + * collect getters/setters from each mixin's inner class. + */ +import { MixinBaseHost } from './base-host'; +import { MixinAFooMixin } from './mixin-a'; +import { MixinBVolumeMixin } from './mixin-b'; + +export class MixinHost extends MixinBVolumeMixin(MixinAFooMixin(MixinBaseHost)) { + #bar: number = 0; + + /** Leaf class own property. */ + get bar(): number { + return this.#bar; + } + + set bar(value: number) { + this.#bar = value; + } +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-a.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-a.ts new file mode 100644 index 00000000..7dfa4519 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-a.ts @@ -0,0 +1,39 @@ +/** + * Mock mixin (Shape A — function declaration). + * + * Exercises: + * - Function-declaration mixin walking + * - Property addition with JSDoc + * - A dispatched-but-untagged event (foochange) is NOT documented — only + * `@fires`-tagged events surface in the element-specific list. + * - A `@fires` event that is ALSO part of the native contract (streamtypechange, + * in VideoEvents via MediaStreamTypeEvents) — mirrors HlsMedia. It must surface + * in the described element-specific list even though it is a native event. + * - Defaults declared in the mixin's own file (mirrors muxDataMediaDefaultProps) + */ +type Constructor = new (...args: any[]) => T; + +export const mixinAFooDefaultProps = { + foo: '', +}; + +/** + * @fires streamtypechange - Fired when the detected stream type changes. + */ +export function MixinAFooMixin(BaseClass: Base) { + class MixinAFoo extends BaseClass { + #foo: string = ''; + + /** Mixin A documentation. */ + get foo(): string { + return this.#foo; + } + + set foo(value: string) { + this.#foo = value; + (this as unknown as EventTarget).dispatchEvent(new Event('foochange')); + } + } + + return MixinAFoo as unknown as Base & Constructor<{ foo: string }>; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-b.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-b.ts new file mode 100644 index 00000000..e8b94c82 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/mixin/mixin-b.ts @@ -0,0 +1,36 @@ +/** + * Mock mixin (Shape B — const arrow function). + * + * Exercises: + * - Arrow-function mixin walking + * - Override of a native HTMLMediaElement member (volume) without JSDoc → overridesNative + * - Override of a parent property (src) without JSDoc → description fallback + */ +type Constructor = new (...args: any[]) => T; + +export const MixinBVolumeMixin = (superclass: Base) => { + class MixinBVolume extends superclass { + #volume: number = 1; + #src: string = ''; + + // Overrides HTMLMediaElement.volume without JSDoc — exercises overridesNative. + get volume(): number { + return this.#volume; + } + + set volume(value: number) { + this.#volume = value; + } + + // Overrides parent.src without JSDoc — exercises description fallback. + get src(): string { + return this.#src; + } + + set src(value: string) { + this.#src = value; + } + } + + return MixinBVolume as unknown as Base & Constructor<{ volume: number; src: string }>; +}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/simple/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/simple/index.ts index e6cd98bd..1be33213 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/simple/index.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/simple/index.ts @@ -3,6 +3,8 @@ * * Exercises: minimal host with just src (read-write) and engine (readonly). * No JSDoc on properties — tests that missing descriptions produce undefined. + * `engine` has no return-type annotation — tests that the checker infers the + * type (mirrors DashMedia's unannotated `get engine()`). */ // Stub — the builder walks the prototype chain and stops here. @@ -12,6 +14,13 @@ export class HTMLVideoElementHost { destroy(): void {} } +// Stub — audio counterpart, also a prototype-chain stop. +export class HTMLAudioElementHost { + attach(_target: EventTarget): void {} + detach(): void {} + destroy(): void {} +} + export class SimpleHost extends HTMLVideoElementHost { #src: string = ''; #engine: object = {}; @@ -24,7 +33,7 @@ export class SimpleHost extends HTMLVideoElementHost { this.#src = value; } - get engine(): object { + get engine() { return this.#engine; } } diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/spf-audio/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/spf-audio/index.ts new file mode 100644 index 00000000..ef3a8c83 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/spf-audio/index.ts @@ -0,0 +1,11 @@ +/** + * Mock audio-only host — mirrors SimpleHlsAudioOnlyMedia. + * + * Exercises: a host whose only mixin lives in a different workspace package + * (spf), reached through that package's barrel file, composed onto the + * audio host base. + */ +import { SpfAudioOnlyMediaMixin } from '../../../../../spf/src/hls'; +import { HTMLAudioElementHost } from '../simple'; + +export class SpfAudioHost extends SpfAudioOnlyMediaMixin(HTMLAudioElementHost) {} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/video-host.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/video-host.ts new file mode 100644 index 00000000..0be7d66c --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/media/video-host.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ +import { HTMLMediaElementHost } from './media-host'; + +export class HTMLVideoElementHost extends HTMLMediaElementHost { + requestFullscreen(): Promise { + return Promise.resolve(); + } +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/mixin-video.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/mixin-video.ts new file mode 100644 index 00000000..255c865b --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/mixin-video.ts @@ -0,0 +1,10 @@ +/** + * Mock mixin-chain element registration — mirrors define/media/mux-video.ts. + * + * Exercises: element whose host is a mixin chain (call-expression extends). + */ +import { MixinVideo } from '../../media/mixin-video'; + +export class MixinVideoElement extends MixinVideo { + static readonly tagName = 'mixin-video'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/spf-audio.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/spf-audio.ts new file mode 100644 index 00000000..3fcdef0f --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/media/spf-audio.ts @@ -0,0 +1,11 @@ +/** + * Mock audio-only element registration — mirrors define/media/simple-hls-audio-only.ts. + * + * Exercises: discovery of an audio element whose host mixin lives in another + * workspace package. + */ +import { SpfAudio } from '../../media/spf-audio'; + +export class SpfAudioElement extends SpfAudio { + static readonly tagName = 'spf-audio'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/mixin-video/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/mixin-video/index.ts new file mode 100644 index 00000000..3598f8d0 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/mixin-video/index.ts @@ -0,0 +1,14 @@ +/** + * Mock mixin-chain media element — mirrors MuxVideo / NativeHlsVideo. + * + * Exercises: standard composition where the host is a mixin chain. + */ +import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element'; +import { MixinHost } from '../../../../core/src/dom/media/mixin'; + +// Stub — the builder parses the AST, it doesn't run the code. +function MediaAttachMixin(base: any) { + return base; +} + +export class MixinVideo extends MediaAttachMixin(CustomMediaElement('video', MixinHost)) {} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/spf-audio/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/spf-audio/index.ts new file mode 100644 index 00000000..78a8001e --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/media/spf-audio/index.ts @@ -0,0 +1,15 @@ +/** + * Mock audio-only media element — mirrors SimpleHlsAudioOnly. + * + * Exercises: audio media type ('audio' tag argument) with a cross-package + * mixin host. + */ +import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element'; +import { SpfAudioHost } from '../../../../core/src/dom/media/spf-audio'; + +// Stub — the builder parses the AST, it doesn't run the code. +function MediaAttachMixin(base: any) { + return base; +} + +export class SpfAudio extends MediaAttachMixin(CustomMediaElement('audio', SpfAudioHost)) {} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/hls/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/hls/index.ts new file mode 100644 index 00000000..5654a2a3 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/hls/index.ts @@ -0,0 +1,11 @@ +/** + * Mock spf hls barrel — mirrors the @videojs/spf/hls subpath entry. + * + * The import + bare `export { … }` shape matches what tsdown emits in rolled-up + * entry `.d.ts` files (import the implementation, re-export without a module + * specifier). The builder must follow the import binding to the declaration. + */ +import { SpfAudioOnlyMediaMixin } from '../playback/engines/hls/adapter-audio-only'; + +export { spfAudioOnlyMediaDefaultProps } from '../playback/engines/hls/adapter-audio-only'; +export { SpfAudioOnlyMediaMixin }; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/playback/engines/hls/adapter-audio-only.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/playback/engines/hls/adapter-audio-only.ts new file mode 100644 index 00000000..9841f776 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/spf/src/playback/engines/hls/adapter-audio-only.ts @@ -0,0 +1,50 @@ +/** + * Mock SPF audio-only adapter mixin — mirrors SimpleHlsAudioOnlyMediaMixin. + * + * Exercises: + * - Cross-package mixin resolution (host lives in core, mixin in spf) + * - Defaults declared in the mixin's own file (spfAudioOnlyMediaDefaultProps) + * - `@fires`-declared events: `audiomodechange` also has a dispatch site, + * `manifestparsed` is dispatched from a helper the builder never scans — + * the @fires tag is its only source. + */ +type Constructor = new (...args: any[]) => T; + +export const spfAudioOnlyMediaDefaultProps = { + src: '', + preload: '', +}; + +/** + * Adds SPF audio-only HLS playback to a host. + * + * @fires audiomodechange - Fired when the audio-only rendition changes. + * @fires manifestparsed - Fired after the multivariant playlist is parsed. + */ +export const SpfAudioOnlyMediaMixin = (BaseClass: Base) => { + class SpfAudioOnlyMedia extends BaseClass { + #src: string = spfAudioOnlyMediaDefaultProps.src; + #preload: string = spfAudioOnlyMediaDefaultProps.preload; + + /** Source URL of the HLS manifest. */ + get src(): string { + return this.#src; + } + + set src(value: string) { + this.#src = value; + (this as unknown as EventTarget).dispatchEvent(new Event('audiomodechange')); + } + + /** Preload hint forwarded to the internal audio element. */ + get preload(): string { + return this.#preload; + } + + set preload(value: string) { + this.#preload = value; + } + } + + return SpfAudioOnlyMedia as unknown as Base & Constructor<{ src: string; preload: string }>; +}; diff --git a/site/src/components/docs/api-reference/CodeChip.astro b/site/src/components/docs/api-reference/CodeChip.astro new file mode 100644 index 00000000..38bd38e6 --- /dev/null +++ b/site/src/components/docs/api-reference/CodeChip.astro @@ -0,0 +1,30 @@ +--- +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; + +interface Props { + value: string; +} + +const { value } = Astro.props; +--- + +{value} + + diff --git a/site/src/components/docs/api-reference/MediaHostPropsTable.astro b/site/src/components/docs/api-reference/MediaHostPropsTable.astro new file mode 100644 index 00000000..87f5d058 --- /dev/null +++ b/site/src/components/docs/api-reference/MediaHostPropsTable.astro @@ -0,0 +1,69 @@ +--- +/** + * Renders the host properties table for media element API reference. + * + * Host properties are getter/setter pairs exposed by the media element's host + * class. They differ from standard HTML attributes — they're accessed via JS + * properties on the element instance. + */ +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { HostPropertyDef } from '@/types/media-reference'; +import DetailRow from './DetailRow.astro'; + +interface Props { + hostProperties: Record; + componentName: string; +} + +const { hostProperties, componentName } = Astro.props; + +const entries = Object.entries(hostProperties).sort(([a], [b]) => a.localeCompare(b)); +--- + + + + + + + + + + + + { + entries.map(([name, def]) => { + const id = `${componentName}-${name}`; + const readonlyNote = def.readonly ? "Read-only." : undefined; + const description = + [readonlyNote, def.description].filter(Boolean).join(" ") || + undefined; + + return ( + + + + + + ); + }) + } + +
PropertyTypeDefaultDetails
+ {name} + + {def.type} + + {def.default ?? "—"} +
diff --git a/site/src/components/docs/api-reference/MediaReference.astro b/site/src/components/docs/api-reference/MediaReference.astro new file mode 100644 index 00000000..14fc818e --- /dev/null +++ b/site/src/components/docs/api-reference/MediaReference.astro @@ -0,0 +1,263 @@ +--- +import { getEntry } from 'astro:content'; +import { kebabCase } from 'es-toolkit/string'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; +import A from '@/components/typography/A.astro'; +import H2 from '@/components/typography/H2Markdown.astro'; +import H3 from '@/components/typography/H3Markdown.astro'; +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import P from '@/components/typography/P.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { MediaReference } from '@/types/media-reference'; +import { createMediaReferenceModel } from '@/utils/mediaReferenceModel'; +import ApiCSSVarsTable from './ApiCSSVarsTable.astro'; +import CodeChip from './CodeChip.astro'; +import MediaHostPropsTable from './MediaHostPropsTable.astro'; + +interface Props { + media: string; +} + +const { media } = Astro.props; +const framework = Astro.params.framework === 'react' ? 'react' : 'html'; + +const entry = await getEntry('mediaReference', kebabCase(media)); +const ref: MediaReference | null = entry?.data ?? null; +if (!ref) return; + +const model = createMediaReferenceModel(media, ref); +if (!model) return; + +const hostPropsSection = model.sections.find((s: { key: string }) => s.key === 'hostProperties'); +const nativeAttrsSection = model.sections.find((s: { key: string }) => s.key === 'nativeAttributes'); +const eventsSection = model.sections.find((s: { key: string }) => s.key === 'events'); +const methodsSection = model.sections.find((s: { key: string }) => s.key === 'methods'); +const cssVarsSection = model.sections.find((s: { key: string }) => s.key === 'cssCustomProperties'); +const mediaTag = `<${ref.mediaType}>`; + +const mdnElementUrl = `https://developer.mozilla.org/en-US/docs/Web/HTML/Element/${ref.mediaType}`; +const mdnEventsUrl = 'https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#events'; +const mdnMethodsUrl = 'https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement'; + +// React prop spellings for the native attributes that differ from their HTML +// attribute names. +const REACT_PROP_NAMES: Record = { + autoplay: 'autoPlay', + playsinline: 'playsInline', + crossorigin: 'crossOrigin', +}; + +const sortedNativeAttributes = [...(ref.nativeAttributes ?? [])].sort(); +const attributeNames = + framework === 'react' ? sortedNativeAttributes.map((attr) => REACT_PROP_NAMES[attr] ?? attr) : sortedNativeAttributes; + +const sortedNativeEvents = [...(ref.events.native ?? [])].sort(); +const sortedMethods = [...(ref.methods ?? [])].sort(); + +// React exposes synthetic event props for the standard media events only. +// Track-list and Picture-in-Picture events have no prop and must be handled +// with a ref + addEventListener. Source: @types/react DOMAttributes. +const REACT_MEDIA_EVENTS = new Set([ + 'abort', + 'canplay', + 'canplaythrough', + 'durationchange', + 'emptied', + 'ended', + 'error', + 'loadeddata', + 'loadedmetadata', + 'loadstart', + 'pause', + 'play', + 'playing', + 'progress', + 'ratechange', + 'resize', + 'seeked', + 'seeking', + 'stalled', + 'suspend', + 'timeupdate', + 'volumechange', + 'waiting', +]); +const reactMappedEvents = sortedNativeEvents.filter((event) => REACT_MEDIA_EVENTS.has(event)); +const reactRefOnlyEvents = sortedNativeEvents.filter((event) => !REACT_MEDIA_EVENTS.has(event)); + +const elementSpecificEvents = ref.events.elementSpecific; +--- + + +

{model.heading.text}

+ + { + hostPropsSection && ( + <> +

{hostPropsSection.title}

+ + + ) + } + + { + nativeAttrsSection && framework === "html" && ( + <> +

{nativeAttrsSection.title}

+

+ Forwards these media attributes to the internal{" "} + {mediaTag} element. The standard ones + behave as described in the{" "} + MDN media attributes reference:{" "} + + {attributeNames.map((attr) => ( + + ))} + +

+ + ) + } + + { + nativeAttrsSection && framework === "react" && ( + <> +

{nativeAttrsSection.title}

+

+ Renders a native {mediaTag} element and + accepts these media attributes as React props. The standard ones + behave as described in the{" "} + MDN media attributes reference:{" "} + + {attributeNames.map((attr) => ( + + ))} + +

+ + ) + } + + { + eventsSection && ( + <> +

{eventsSection.title}

+ {framework === "html" ? ( +

+ Re-dispatches these media events from the internal{" "} + {mediaTag} element, so you can listen + for them directly. See media events:{" "} + + {sortedNativeEvents.map((event) => ( + + ))} + +

+ ) : ( + <> +

+ Handle these media events with React event props (e.g.{" "} + onPlay,{" "} + onTimeUpdate). See{" "} + media events:{" "} + + {reactMappedEvents.map((event) => ( + + ))} + +

+ {reactRefOnlyEvents.length > 0 && ( +

+ These media events have no React prop — attach a listener + through a ref with{" "} + addEventListener:{" "} + + {reactRefOnlyEvents.map((event) => ( + + ))} + +

+ )} + + )} + {elementSpecificEvents.length === 0 ? ( +

+ This element dispatches no events beyond the media events above. +

+ ) : ( + <> +

Beyond the media events above, this element emits:

+ + + + + + + + + {elementSpecificEvents.map((event) => ( + + + + + ))} + +
EventDescription
+ {event.name} + {event.description ?? ""}
+ {framework === "react" && ( +

+ These events have no built-in React prop. Attach a listener to + the element through a ref with{" "} + addEventListener. +

+ )} + + )} + + ) + } + + { + methodsSection && ( + <> +

{methodsSection.title}

+

+ Supports these methods from the native media API. See{" "} + HTMLMediaElement for the core methods:{" "} + + {sortedMethods.map((method) => ( + + ))} + +

+ {framework === "react" && ( +

+ In React, call these through a ref to + the element. +

+ )} + + ) + } + + { + cssVarsSection && ( + <> +

{cssVarsSection.title}

+ + + ) + } +
diff --git a/site/src/components/docs/demos/background-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/background-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/background-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/background-video/html/css/BasicUsage.css b/site/src/components/docs/demos/background-video/html/css/BasicUsage.css new file mode 100644 index 00000000..f607810c --- /dev/null +++ b/site/src/components/docs/demos/background-video/html/css/BasicUsage.css @@ -0,0 +1,5 @@ +.container { + display: grid; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/background-video/html/css/BasicUsage.html b/site/src/components/docs/demos/background-video/html/css/BasicUsage.html new file mode 100644 index 00000000..23e7c55f --- /dev/null +++ b/site/src/components/docs/demos/background-video/html/css/BasicUsage.html @@ -0,0 +1,5 @@ +
+ +
diff --git a/site/src/components/docs/demos/background-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/background-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..a4c61a6a --- /dev/null +++ b/site/src/components/docs/demos/background-video/html/css/BasicUsage.ts @@ -0,0 +1 @@ +import '@videojs/html/media/background-video'; diff --git a/site/src/components/docs/demos/background-video/react/css/BasicUsage.css b/site/src/components/docs/demos/background-video/react/css/BasicUsage.css new file mode 100644 index 00000000..3f85dae9 --- /dev/null +++ b/site/src/components/docs/demos/background-video/react/css/BasicUsage.css @@ -0,0 +1,5 @@ +.container { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/background-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/background-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..71c1c572 --- /dev/null +++ b/site/src/components/docs/demos/background-video/react/css/BasicUsage.tsx @@ -0,0 +1,9 @@ +import { BackgroundVideo } from '@videojs/react/media/background-video'; + +export default function BasicUsage() { + return ( +
+ +
+ ); +} diff --git a/site/src/components/docs/demos/dash-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/dash-video/html/css/BasicUsage.css b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.css new file mode 100644 index 00000000..6ae5b3a3 --- /dev/null +++ b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.css @@ -0,0 +1,6 @@ +.media-container { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/dash-video/html/css/BasicUsage.html b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.html new file mode 100644 index 00000000..91c05e60 --- /dev/null +++ b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.html @@ -0,0 +1,9 @@ + + + diff --git a/site/src/components/docs/demos/dash-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..7fec108f --- /dev/null +++ b/site/src/components/docs/demos/dash-video/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/media/container'; +import '@videojs/html/media/dash-video'; diff --git a/site/src/components/docs/demos/dash-video/react/css/BasicUsage.css b/site/src/components/docs/demos/dash-video/react/css/BasicUsage.css new file mode 100644 index 00000000..652391c2 --- /dev/null +++ b/site/src/components/docs/demos/dash-video/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.dash-video { + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/dash-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/dash-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..88874ad8 --- /dev/null +++ b/site/src/components/docs/demos/dash-video/react/css/BasicUsage.tsx @@ -0,0 +1,14 @@ +import { DashVideo } from '@videojs/react/media/dash-video'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/hls-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/hls-video/html/css/BasicUsage.css b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.css new file mode 100644 index 00000000..6ae5b3a3 --- /dev/null +++ b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.css @@ -0,0 +1,6 @@ +.media-container { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/hls-video/html/css/BasicUsage.html b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.html new file mode 100644 index 00000000..9f6917e5 --- /dev/null +++ b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.html @@ -0,0 +1,9 @@ + + + diff --git a/site/src/components/docs/demos/hls-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..be0c21f8 --- /dev/null +++ b/site/src/components/docs/demos/hls-video/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/media/container'; +import '@videojs/html/media/hls-video'; diff --git a/site/src/components/docs/demos/hls-video/react/css/BasicUsage.css b/site/src/components/docs/demos/hls-video/react/css/BasicUsage.css new file mode 100644 index 00000000..891910dc --- /dev/null +++ b/site/src/components/docs/demos/hls-video/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.hls-video { + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/hls-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/hls-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..50b1a568 --- /dev/null +++ b/site/src/components/docs/demos/hls-video/react/css/BasicUsage.tsx @@ -0,0 +1,14 @@ +import { HlsVideo } from '@videojs/react/media/hls-video'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.astro b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.css b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.css new file mode 100644 index 00000000..e5448cf4 --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.css @@ -0,0 +1,4 @@ +.mux-audio { + width: 100%; + height: 54px; +} diff --git a/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.html b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.html new file mode 100644 index 00000000..08f71a91 --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.html @@ -0,0 +1,6 @@ + diff --git a/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.ts b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.ts new file mode 100644 index 00000000..c68a1ac5 --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/html/css/BasicUsage.ts @@ -0,0 +1 @@ +import '@videojs/html/media/mux-audio'; diff --git a/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.css b/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.css new file mode 100644 index 00000000..e5448cf4 --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.mux-audio { + width: 100%; + height: 54px; +} diff --git a/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.tsx b/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.tsx new file mode 100644 index 00000000..71835517 --- /dev/null +++ b/site/src/components/docs/demos/mux-audio/react/css/BasicUsage.tsx @@ -0,0 +1,12 @@ +import { MuxAudio } from '@videojs/react/media/mux-audio'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/mux-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/mux-video/html/css/BasicUsage.css b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.css new file mode 100644 index 00000000..6ae5b3a3 --- /dev/null +++ b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.css @@ -0,0 +1,6 @@ +.media-container { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/mux-video/html/css/BasicUsage.html b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.html new file mode 100644 index 00000000..4c4973ba --- /dev/null +++ b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.html @@ -0,0 +1,10 @@ + + + diff --git a/site/src/components/docs/demos/mux-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..ab31f0a2 --- /dev/null +++ b/site/src/components/docs/demos/mux-video/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/media/container'; +import '@videojs/html/media/mux-video'; diff --git a/site/src/components/docs/demos/mux-video/react/css/BasicUsage.css b/site/src/components/docs/demos/mux-video/react/css/BasicUsage.css new file mode 100644 index 00000000..5dde7597 --- /dev/null +++ b/site/src/components/docs/demos/mux-video/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.mux-video { + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/mux-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/mux-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..26deb505 --- /dev/null +++ b/site/src/components/docs/demos/mux-video/react/css/BasicUsage.tsx @@ -0,0 +1,15 @@ +import { MuxVideo } from '@videojs/react/media/mux-video'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.css b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.css new file mode 100644 index 00000000..6ae5b3a3 --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.css @@ -0,0 +1,6 @@ +.media-container { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.html b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.html new file mode 100644 index 00000000..5ad4619e --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.html @@ -0,0 +1,9 @@ + + + diff --git a/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..2d4dbd7f --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/media/container'; +import '@videojs/html/media/native-hls-video'; diff --git a/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.css b/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.css new file mode 100644 index 00000000..971eb8d5 --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.native-hls-video { + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..84ba403f --- /dev/null +++ b/site/src/components/docs/demos/native-hls-video/react/css/BasicUsage.tsx @@ -0,0 +1,14 @@ +import { NativeHlsVideo } from '@videojs/react/media/native-hls-video'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.astro b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.css b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.css new file mode 100644 index 00000000..f65fc137 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.css @@ -0,0 +1,4 @@ +.simple-hls-audio-only { + width: 100%; + height: 54px; +} diff --git a/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.html b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.html new file mode 100644 index 00000000..b58026de --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.html @@ -0,0 +1,5 @@ + diff --git a/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.ts b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.ts new file mode 100644 index 00000000..77d7ba4f --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/html/css/BasicUsage.ts @@ -0,0 +1 @@ +import '@videojs/html/media/simple-hls-audio-only'; diff --git a/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.css b/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.css new file mode 100644 index 00000000..f65fc137 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.simple-hls-audio-only { + width: 100%; + height: 54px; +} diff --git a/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.tsx b/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.tsx new file mode 100644 index 00000000..41908091 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-audio-only/react/css/BasicUsage.tsx @@ -0,0 +1,11 @@ +import { SimpleHlsAudioOnly } from '@videojs/react/media/simple-hls-audio-only'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.astro b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.css b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.css new file mode 100644 index 00000000..6ae5b3a3 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.css @@ -0,0 +1,6 @@ +.media-container { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.html b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.html new file mode 100644 index 00000000..de35a8a1 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.html @@ -0,0 +1,9 @@ + + + diff --git a/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.ts b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.ts new file mode 100644 index 00000000..da2c5386 --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/media/container'; +import '@videojs/html/media/simple-hls-video'; diff --git a/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.css b/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.css new file mode 100644 index 00000000..58d6037c --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.css @@ -0,0 +1,4 @@ +.simple-hls-video { + width: 100%; + aspect-ratio: 16 / 9; +} diff --git a/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.tsx b/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.tsx new file mode 100644 index 00000000..d712acbc --- /dev/null +++ b/site/src/components/docs/demos/simple-hls-video/react/css/BasicUsage.tsx @@ -0,0 +1,14 @@ +import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video'; + +export default function BasicUsage() { + return ( + + ); +} diff --git a/site/src/content.config.ts b/site/src/content.config.ts index a5a87555..4a49b2ca 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -4,6 +4,7 @@ import { z } from 'astro/zod'; import { ComponentReferenceSchema } from './types/component-reference'; import { SUPPORTED_FRAMEWORKS } from './types/docs'; import { FeatureReferenceSchema } from './types/feature-reference'; +import { MediaReferenceSchema } from './types/media-reference'; import { PresetReferenceSchema } from './types/preset-reference'; import { UtilReferenceSchema } from './types/util-reference'; import { defaultGitService } from './utils/gitService'; @@ -139,6 +140,14 @@ const featureReference = defineCollection({ schema: FeatureReferenceSchema, }); +const mediaReference = defineCollection({ + loader: glob({ + pattern: '*.json', + base: './src/content/generated-media-reference', + }), + schema: MediaReferenceSchema, +}); + const presetReference = defineCollection({ loader: glob({ pattern: '*.json', @@ -168,6 +177,7 @@ export const collections = { componentReference, utilReference, featureReference, + mediaReference, presetReference, ejectedSkins, }; diff --git a/site/src/content/docs/reference/background-video.mdx b/site/src/content/docs/reference/background-video.mdx new file mode 100644 index 00000000..c54c1a08 --- /dev/null +++ b/site/src/content/docs/reference/background-video.mdx @@ -0,0 +1,112 @@ +--- +title: BackgroundVideo +frameworkTitle: + html: background-video +description: Decorative background video element with automatic muting and looping +--- + +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; +import MarkdownCode from "@/components/typography/MarkdownCode.astro"; +import Table from "@/components/typography/Table.astro"; +import Tbody from "@/components/typography/Tbody.astro"; +import Td from "@/components/typography/Td.astro"; +import Th from "@/components/typography/Th.astro"; +import Thead from "@/components/typography/Thead.astro"; +import Tr from "@/components/typography/Tr.astro"; + +{/* React demos */} +import BasicUsageDemoReact from "@/components/docs/demos/background-video/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/background-video/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/background-video/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/background-video/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/background-video/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/background-video/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/background-video/html/css/BasicUsage.ts?raw"; + +Decorative background video. By default it's muted, looped, and autoplaying — use the opt-out attributes below to change that. Renders a `