mirror of
https://github.com/zoriya/v10.git
synced 2026-08-09 07:37:48 +00:00
1437 lines
54 KiB
TypeScript
1437 lines
54 KiB
TypeScript
/**
|
|
* Media element reference extraction.
|
|
*
|
|
* Discovers media elements from packages/html/src/define/media/*.ts and extracts
|
|
* host properties, shared attributes/events, and CSS vars.
|
|
*
|
|
* Convention:
|
|
* - Define files: packages/html/src/define/media/*.ts with inline class + static tagName
|
|
* - Media element classes: packages/html/src/media/{name}/index.ts
|
|
* composed as MediaAttachMixin(CustomMediaElement('video'|'audio', Host))
|
|
* - Host classes: packages/core/src/dom/media/{name}/index.ts extending
|
|
* 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
|
|
*
|
|
* Exclusions (elements discovered but intentionally skipped):
|
|
* - container.ts: re-exports a class, doesn't declare one inline → no static tagName found
|
|
* - background-video.ts: uses MediaAttachMixin(HTMLElement) without CustomMediaElement →
|
|
* parseCustomMediaElementCall returns null. Its API reference is manually maintained in MDX.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as ts from 'typescript';
|
|
import * as tae from 'typescript-api-extractor';
|
|
import { extractCSSVars } from './css-vars-handler.js';
|
|
import type { HostPropertyDef, MediaElementReference, MediaElementResult, MediaEventDef } from './pipeline.js';
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────
|
|
|
|
/** Classes that mark the end of the host prototype chain for property extraction. */
|
|
const HOST_BASE_CLASSES = new Set([
|
|
'HTMLMediaElementHost',
|
|
'HTMLVideoElementHost',
|
|
'HTMLAudioElementHost',
|
|
'EventTarget',
|
|
]);
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
|
|
interface MediaElementSource {
|
|
defineFilePath: string;
|
|
className: string;
|
|
tagName: string;
|
|
mediaFilePath: string;
|
|
hostFilePath: string;
|
|
hostClassName: string;
|
|
mediaType: 'video' | 'audio';
|
|
}
|
|
|
|
// ─── Module Resolution ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve an import specifier to an absolute file path using TypeScript's
|
|
* module resolution. Handles both relative paths and workspace package
|
|
* imports (e.g., @videojs/core/dom/media/hls) via the project's tsconfig.
|
|
*
|
|
* 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,
|
|
importSpecifier: string,
|
|
compilerOptions: ts.CompilerOptions
|
|
): string | undefined {
|
|
const result = ts.resolveModuleName(importSpecifier, fromFile, compilerOptions, ts.sys);
|
|
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 ───────────────────────────────────────────────────────
|
|
|
|
function discoverMediaElements(monorepoRoot: string, compilerOptions: ts.CompilerOptions): MediaElementSource[] {
|
|
const defineDir = path.join(monorepoRoot, 'packages/html/src/define/media');
|
|
if (!fs.existsSync(defineDir)) return [];
|
|
|
|
const files = fs.readdirSync(defineDir).filter((f) => f.endsWith('.ts'));
|
|
const sources: MediaElementSource[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(defineDir, file);
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
|
|
const result = parseDefineFile(sourceFile, filePath, compilerOptions);
|
|
if (result) {
|
|
sources.push(result);
|
|
}
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
/**
|
|
* Parse a define/media file to extract class name, tagName, and import chain.
|
|
* Returns null if the file doesn't declare an inline class with static tagName
|
|
* (container.ts) or if the class doesn't use CustomMediaElement (background-video.ts).
|
|
*/
|
|
function parseDefineFile(
|
|
sourceFile: ts.SourceFile,
|
|
filePath: string,
|
|
compilerOptions: ts.CompilerOptions
|
|
): MediaElementSource | null {
|
|
let className: string | undefined;
|
|
let tagName: string | undefined;
|
|
let baseClassName: string | undefined;
|
|
let baseImportPath: string | undefined;
|
|
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (!ts.isClassDeclaration(node) || !node.name) return;
|
|
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
|
|
if (!node.heritageClauses) return;
|
|
|
|
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
|
|
if (!extendsClause || extendsClause.types.length === 0) return;
|
|
|
|
for (const member of node.members) {
|
|
if (
|
|
ts.isPropertyDeclaration(member) &&
|
|
member.name &&
|
|
ts.isIdentifier(member.name) &&
|
|
member.name.text === 'tagName' &&
|
|
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
|
|
member.initializer &&
|
|
ts.isStringLiteral(member.initializer)
|
|
) {
|
|
className = node.name.text;
|
|
tagName = member.initializer.text;
|
|
baseClassName = extendsClause.types[0]!.expression.getText(sourceFile);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!className || !tagName || !baseClassName) return null;
|
|
|
|
// Resolve the import path for the base class
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (!ts.isImportDeclaration(node)) return;
|
|
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
|
|
const importClause = node.importClause;
|
|
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
|
|
|
|
for (const specifier of importClause.namedBindings.elements) {
|
|
if (specifier.name.text === baseClassName) {
|
|
baseImportPath = node.moduleSpecifier.text;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!baseImportPath) return null;
|
|
|
|
const mediaFilePath = resolveModuleToFile(filePath, baseImportPath, compilerOptions);
|
|
if (!mediaFilePath) return null;
|
|
|
|
// Parse the media element file to find the CustomMediaElement(tag, Host) call
|
|
const mediaContent = fs.readFileSync(mediaFilePath, 'utf-8');
|
|
const mediaSourceFile = ts.createSourceFile(mediaFilePath, mediaContent, ts.ScriptTarget.Latest, true);
|
|
|
|
const hostInfo = parseCustomMediaElementCall(mediaSourceFile, baseClassName);
|
|
if (!hostInfo) return null;
|
|
|
|
// Resolve host class import path
|
|
let hostImportPath: string | undefined;
|
|
ts.forEachChild(mediaSourceFile, (node) => {
|
|
if (!ts.isImportDeclaration(node)) return;
|
|
if (!ts.isStringLiteral(node.moduleSpecifier)) return;
|
|
const importClause = node.importClause;
|
|
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) return;
|
|
|
|
for (const specifier of importClause.namedBindings.elements) {
|
|
if (specifier.name.text === hostInfo.hostClassName) {
|
|
hostImportPath = node.moduleSpecifier.text;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!hostImportPath) return null;
|
|
|
|
const hostFilePath = resolveModuleToFile(mediaFilePath, hostImportPath, compilerOptions);
|
|
if (!hostFilePath) return null;
|
|
|
|
return {
|
|
defineFilePath: filePath,
|
|
className: stripElementSuffix(className),
|
|
tagName,
|
|
mediaFilePath,
|
|
hostFilePath,
|
|
hostClassName: hostInfo.hostClassName,
|
|
mediaType: hostInfo.mediaType,
|
|
};
|
|
}
|
|
|
|
function stripElementSuffix(name: string): string {
|
|
return name.endsWith('Element') ? name.slice(0, -'Element'.length) : name;
|
|
}
|
|
|
|
/**
|
|
* Parse the media element class to find the CustomMediaElement(tag, Host) call.
|
|
* Returns null for elements that don't use CustomMediaElement (e.g., BackgroundVideo).
|
|
*/
|
|
function parseCustomMediaElementCall(
|
|
sourceFile: ts.SourceFile,
|
|
className: string
|
|
): { hostClassName: string; mediaType: 'video' | 'audio' } | null {
|
|
let hostClassName: string | undefined;
|
|
let mediaType: 'video' | 'audio' | undefined;
|
|
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (!ts.isClassDeclaration(node)) return;
|
|
if (!node.name || node.name.text !== className) return;
|
|
if (!node.heritageClauses) return;
|
|
|
|
const extendsClause = node.heritageClauses.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
|
|
if (!extendsClause || extendsClause.types.length === 0) return;
|
|
|
|
const extendsExpr = extendsClause.types[0]!.expression;
|
|
findCustomMediaElement(extendsExpr);
|
|
});
|
|
|
|
function findCustomMediaElement(node: ts.Node): void {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === 'CustomMediaElement'
|
|
) {
|
|
if (node.arguments.length >= 2) {
|
|
// First arg: media type string literal ('video' or 'audio')
|
|
const tagArg = node.arguments[0]!;
|
|
if (ts.isStringLiteral(tagArg)) {
|
|
mediaType = tagArg.text === 'audio' ? 'audio' : 'video';
|
|
}
|
|
// Second arg: host class identifier
|
|
const hostArg = node.arguments[1]!;
|
|
if (ts.isIdentifier(hostArg)) {
|
|
hostClassName = hostArg.text;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
ts.forEachChild(node, findCustomMediaElement);
|
|
}
|
|
|
|
if (!hostClassName || !mediaType) return null;
|
|
return { hostClassName, mediaType };
|
|
}
|
|
|
|
// ─── Host Property Extraction ───────────────────────────────────────
|
|
|
|
/**
|
|
* Extract getter/setter pairs from a host class and its ancestors,
|
|
* mirroring what CustomMediaElement does at runtime when it walks
|
|
* 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,
|
|
nativeNames: Set<string>
|
|
): Record<string, HostPropertyDef> {
|
|
const properties: Record<string, HostPropertyDef> = {};
|
|
const visitedFiles: string[] = [];
|
|
extractClassProperties(filePath, hostClassName, properties, compilerOptions, new Set(), nativeNames, visitedFiles);
|
|
|
|
const defaults = new Map<string, string>();
|
|
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.
|
|
* 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<string, HostPropertyDef>,
|
|
compilerOptions: ts.CompilerOptions,
|
|
seen: Set<string>,
|
|
nativeNames: Set<string>,
|
|
visitedFiles: string[]
|
|
): void {
|
|
if (seen.has(`${filePath}:${className}`)) return;
|
|
seen.add(`${filePath}:${className}`);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
|
|
let classNode: ts.ClassDeclaration | undefined;
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (ts.isClassDeclaration(node) && node.name?.text === className) {
|
|
classNode = node;
|
|
}
|
|
});
|
|
if (!classNode) return;
|
|
|
|
// 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<string, HostPropertyDef>,
|
|
compilerOptions: ts.CompilerOptions,
|
|
seen: Set<string>,
|
|
nativeNames: Set<string>,
|
|
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 = <Base extends ...>(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<string, HostPropertyDef>,
|
|
compilerOptions: ts.CompilerOptions,
|
|
seen: Set<string>,
|
|
nativeNames: Set<string>,
|
|
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<string>
|
|
): { 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
function scanBody(body: ts.Node, paramNames: Set<string>): 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function getParameterNames(params: ts.NodeArray<ts.ParameterDeclaration>): Set<string> {
|
|
const names = new Set<string>();
|
|
for (const p of params) {
|
|
if (ts.isIdentifier(p.name)) names.add(p.name.text);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* 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<string, HostPropertyDef>,
|
|
nativeNames: Set<string>
|
|
): void {
|
|
const getters = new Map<string, { type: string; description?: string }>();
|
|
const setters = new Set<string>();
|
|
|
|
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);
|
|
}
|
|
const description = getJSDocDescription(member);
|
|
getters.set(name, { type, description });
|
|
} else if (ts.isSetAccessorDeclaration(member)) {
|
|
setters.add(name);
|
|
}
|
|
}
|
|
|
|
for (const [name, info] of getters) {
|
|
const def: HostPropertyDef = {
|
|
type: info.type,
|
|
readonly: !setters.has(name),
|
|
};
|
|
// 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<string, string> {
|
|
const types = new Map<string, string>();
|
|
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 {
|
|
const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc;
|
|
if (!jsDocNodes || jsDocNodes.length === 0) return undefined;
|
|
|
|
const doc = jsDocNodes[0]!;
|
|
if (typeof doc.comment === 'string') return doc.comment;
|
|
if (!doc.comment) return undefined;
|
|
|
|
const parts: string[] = [];
|
|
for (const part of doc.comment) {
|
|
if (typeof part === 'string') {
|
|
parts.push(part);
|
|
} else if ('text' in part) {
|
|
parts.push(part.text);
|
|
}
|
|
}
|
|
return parts.join('') || undefined;
|
|
}
|
|
|
|
// ─── Default Value Extraction ────────────────────────────────────────
|
|
|
|
const fileDefaultsCache = new Map<string, Map<string, string>>();
|
|
|
|
/**
|
|
* 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<string, string> {
|
|
const cached = fileDefaultsCache.get(filePath);
|
|
if (cached) return cached;
|
|
|
|
const defaults = new Map<string, string>();
|
|
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<string>
|
|
): Map<string, string> {
|
|
const entries = new Map<string, string>();
|
|
|
|
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 ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract native attribute names from the `static properties` object inside
|
|
* the CustomMediaElement factory. Each key maps to an attribute name via
|
|
* `props[key].attribute ?? key.toLowerCase()`.
|
|
*/
|
|
function extractStaticProperties(filePath: string): string[] {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
const attributes: string[] = [];
|
|
|
|
function visit(node: ts.Node): void {
|
|
// Look for: static properties = { ... }
|
|
if (
|
|
ts.isPropertyDeclaration(node) &&
|
|
node.name &&
|
|
ts.isIdentifier(node.name) &&
|
|
node.name.text === 'properties' &&
|
|
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
|
|
node.initializer &&
|
|
ts.isObjectLiteralExpression(node.initializer)
|
|
) {
|
|
for (const prop of node.initializer.properties) {
|
|
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
|
|
|
|
const propName = prop.name.text;
|
|
let attrName = propName.toLowerCase();
|
|
|
|
// Check for explicit `attribute` override in the property config
|
|
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
for (const configProp of prop.initializer.properties) {
|
|
if (
|
|
ts.isPropertyAssignment(configProp) &&
|
|
ts.isIdentifier(configProp.name) &&
|
|
configProp.name.text === 'attribute' &&
|
|
ts.isStringLiteral(configProp.initializer)
|
|
) {
|
|
attrName = configProp.initializer.text;
|
|
}
|
|
}
|
|
}
|
|
|
|
attributes.push(attrName);
|
|
}
|
|
return;
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
return attributes;
|
|
}
|
|
|
|
// ─── Method Extraction ───────────────────────────────────────────────
|
|
|
|
// 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',
|
|
]);
|
|
|
|
/**
|
|
* 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 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);
|
|
|
|
let classNode: ts.ClassDeclaration | undefined;
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (ts.isClassDeclaration(node) && node.name?.text === className) {
|
|
classNode = node;
|
|
}
|
|
});
|
|
if (!classNode) return [];
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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 ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract event names from a composite event interface (e.g. VideoEvents, AudioEvents)
|
|
* by walking its `extends` chain and collecting property keys from each parent interface.
|
|
*
|
|
* Convention: capability event interfaces (MediaPlaybackEvents, etc.) are flat
|
|
* `eventName: EventLike` maps, and VideoEvents/AudioEvents compose them via `extends`.
|
|
*/
|
|
function extractEventsFromTypes(filePath: string, interfaceName: string): string[] {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
|
|
// Build a map of interface name → { extends list, own property keys }
|
|
const interfaces = new Map<string, { extends: string[]; keys: string[] }>();
|
|
|
|
ts.forEachChild(sourceFile, (node) => {
|
|
if (!ts.isInterfaceDeclaration(node) || !node.name) return;
|
|
|
|
const name = node.name.text;
|
|
const extendsList: string[] = [];
|
|
const keys: string[] = [];
|
|
|
|
if (node.heritageClauses) {
|
|
for (const clause of node.heritageClauses) {
|
|
if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
for (const type of clause.types) {
|
|
if (ts.isIdentifier(type.expression)) {
|
|
extendsList.push(type.expression.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const member of node.members) {
|
|
if (ts.isPropertySignature(member) && member.name && ts.isIdentifier(member.name)) {
|
|
keys.push(member.name.text);
|
|
}
|
|
}
|
|
|
|
interfaces.set(name, { extends: extendsList, keys });
|
|
});
|
|
|
|
// Recursively collect keys from the target interface and all ancestors
|
|
const events: string[] = [];
|
|
const visited = new Set<string>();
|
|
|
|
function collect(name: string): void {
|
|
if (visited.has(name)) return;
|
|
visited.add(name);
|
|
|
|
const iface = interfaces.get(name);
|
|
if (!iface) return;
|
|
|
|
for (const parent of iface.extends) {
|
|
collect(parent);
|
|
}
|
|
events.push(...iface.keys);
|
|
}
|
|
|
|
collect(interfaceName);
|
|
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<string>,
|
|
events: Set<string>,
|
|
fires: Map<string, string>
|
|
): Set<string> {
|
|
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<string>,
|
|
events: Set<string>,
|
|
fires: Map<string, string>
|
|
): 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<string, string>): 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<string>): 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<string> {
|
|
const checker = program.getTypeChecker();
|
|
const names = new Set<string>();
|
|
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[] {
|
|
const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json');
|
|
const config = tae.loadConfig(tsconfigPath);
|
|
config.options.rootDir = monorepoRoot;
|
|
const compilerOptions = config.options;
|
|
|
|
const sources = discoverMediaElements(monorepoRoot, compilerOptions);
|
|
if (sources.length === 0) return [];
|
|
|
|
const customMediaPath = path.join(monorepoRoot, 'packages/core/src/dom/media/custom-media-element/index.ts');
|
|
if (!fs.existsSync(customMediaPath)) return [];
|
|
|
|
// Read shared data
|
|
const allAttributes = extractStaticProperties(customMediaPath);
|
|
|
|
// Extract events from capability contract types
|
|
const mediaTypesPath = path.join(monorepoRoot, 'packages/core/src/core/media/types.ts');
|
|
const videoEvents = fs.existsSync(mediaTypesPath) ? extractEventsFromTypes(mediaTypesPath, 'VideoEvents') : [];
|
|
const audioEvents = fs.existsSync(mediaTypesPath) ? extractEventsFromTypes(mediaTypesPath, 'AudioEvents') : [];
|
|
|
|
// 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<string>();
|
|
|
|
// 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<string>();
|
|
|
|
const videoCSSVars: Record<string, { description: string }> = {};
|
|
if (videoCSSVarsRaw) {
|
|
for (const v of videoCSSVarsRaw.vars) {
|
|
videoCSSVars[v.name] = { description: v.description };
|
|
}
|
|
}
|
|
|
|
const audioCSSVars: Record<string, { description: string }> = {};
|
|
if (audioCSSVarsRaw) {
|
|
for (const v of audioCSSVarsRaw.vars) {
|
|
audioCSSVars[v.name] = { description: v.description };
|
|
}
|
|
}
|
|
|
|
const results: MediaElementResult[] = [];
|
|
|
|
for (const source of sources) {
|
|
const hostProperties = extractHostProperties(
|
|
source.hostFilePath,
|
|
source.hostClassName,
|
|
compilerOptions,
|
|
nativeNames
|
|
);
|
|
|
|
// The AST walk only reads explicit return-type annotations; getters without
|
|
// one fall back to the literal string 'unknown'. Fill those gaps from the
|
|
// type checker, which infers the real type across the mixin chain. Authored
|
|
// annotations are left untouched.
|
|
const inferredTypes = resolveInferredTypes(source.hostFilePath, source.hostClassName, program, checker);
|
|
for (const [name, def] of Object.entries(hostProperties)) {
|
|
if (def.type === 'unknown' && inferredTypes.has(name)) {
|
|
def.type = inferredTypes.get(name)!;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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<string, string>();
|
|
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: { native, elementSpecific },
|
|
methods,
|
|
cssCustomProperties,
|
|
};
|
|
|
|
results.push({ name: source.className, reference });
|
|
}
|
|
|
|
return results;
|
|
}
|