/** * Build ejected skin snippets for copy-paste usage. * * Produces `site/src/content/ejected-skins.json` with: * - HTML skins: rendered HTML templates with elements and resolved classes * - React skins: TSX (with types) and JSX (types stripped) with public icon imports * - CSS variants include a `css` field with all @imports resolved * - Tailwind variants omit the `css` field (users bring their own Tailwind) * * Prerequisites: `pnpm build:packages` (at minimum html, react, icons, skins, utils). */ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, relative as relativePath, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { resolveImports } from '../../build/plugins/resolve-css-imports.ts'; import { normalizeImports } from './normalize-imports.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '../..'); const PACKAGES_ROOT = resolve(ROOT, 'packages'); const PACKAGE_MANIFEST_CACHE = new Map(); const PREFIX = '\x1b[35m[ejected-skins]\x1b[0m'; const HTML_CDN_BASE = 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn'; const DEMO_VIDEO_SRC = 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4'; const DEMO_POSTER_SRC = 'https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp'; const log = { info: (...args: unknown[]) => console.log(PREFIX, ...args), warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args), error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args), }; const SKINS_SRC = resolve(ROOT, 'packages/skins/src'); const OUTPUT = resolve(ROOT, 'site/src/content/ejected-skins.json'); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type PackageExportTarget = string | Record; interface PackageManifest { name: string; exports?: Record; } interface HtmlSkinDef { id: string; name: string; platform: 'html'; style: 'css' | 'tailwind'; template: string; css?: string; iconSet: 'default' | 'minimal'; tailwindModule?: string; } interface ReactSkinDef { id: string; name: string; platform: 'react'; style: 'css' | 'tailwind'; source: string; css?: string; } type SkinDef = HtmlSkinDef | ReactSkinDef; type MediaType = 'video' | 'audio'; function getSkinMediaType(skin: SkinDef): MediaType { return skin.id.includes('audio') ? 'audio' : 'video'; } interface EjectedSkinEntry { id: string; name: string; platform: 'html' | 'react'; style: 'css' | 'tailwind'; html?: string; tsx?: string; jsx?: string; css?: string; } interface PackageSpecifierParts { packageDir: string; packageName: string; subpath: string; } // --------------------------------------------------------------------------- // Package resolution // --------------------------------------------------------------------------- function parsePackageSpecifier(specifier: string): PackageSpecifierParts { const parts = specifier.split('/'); if (parts.length < 2 || parts[0] !== '@videojs') { throw new Error(`Expected a @videojs package specifier, got "${specifier}"`); } const packageName = `${parts[0]}/${parts[1]}`; const packageDir = resolve(PACKAGES_ROOT, parts[1]); const subpath = parts.length > 2 ? `./${parts.slice(2).join('/')}` : '.'; return { packageDir, packageName, subpath }; } function readPackageManifest(packageDir: string): PackageManifest { const cached = PACKAGE_MANIFEST_CACHE.get(packageDir); if (cached) { return cached; } const manifestPath = resolve(packageDir, 'package.json'); if (!existsSync(manifestPath)) { throw new Error(`Missing package manifest: ${manifestPath}`); } const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as PackageManifest; PACKAGE_MANIFEST_CACHE.set(packageDir, manifest); return manifest; } function matchExportPattern(pattern: string, subpath: string): string | null { if (!pattern.includes('*')) { return pattern === subpath ? '' : null; } const [prefix, suffix] = pattern.split('*'); if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) { return null; } return subpath.slice(prefix.length, subpath.length - suffix.length); } function selectExportTarget(exportTarget: PackageExportTarget, specifier: string, packageName: string): string { if (typeof exportTarget === 'string') { return exportTarget; } const preferredConditions = ['default', 'development', 'import', 'module', 'node', 'types']; for (const condition of preferredConditions) { const target = exportTarget[condition]; if (target) { return target; } } throw new Error(`Package "${packageName}" exports "${specifier}" but does not provide a supported target condition`); } function resolvePackageExportFile(specifier: string): string { const { packageDir, packageName, subpath } = parsePackageSpecifier(specifier); const manifest = readPackageManifest(packageDir); const exportsField = manifest.exports; if (!exportsField) { throw new Error(`Package "${packageName}" does not define exports`); } const exactTarget = exportsField[subpath]; if (exactTarget) { const target = selectExportTarget(exactTarget, specifier, packageName); const filePath = resolve(packageDir, target.replace(/^\.\//, '')); if (!existsSync(filePath)) { throw new Error(`Resolved file does not exist: ${filePath}`); } return filePath; } for (const [pattern, exportTarget] of Object.entries(exportsField)) { const wildcardValue = matchExportPattern(pattern, subpath); if (wildcardValue === null) { continue; } const targetPattern = selectExportTarget(exportTarget, specifier, packageName); const filePath = resolve(packageDir, targetPattern.replace('*', wildcardValue).replace(/^\.\//, '')); if (!existsSync(filePath)) { throw new Error(`Resolved file does not exist: ${filePath}`); } return filePath; } throw new Error(`Package "${packageName}" does not export "${subpath}"`); } /** Resolve a `@videojs/*` package specifier to its built dist file URL. */ function pkgDistUrl(specifier: string): string { return pathToFileURL(resolvePackageExportFile(specifier)).href; } function collectPackageSpecifiers(source: string): string[] { const specifiers = new Set(); const importRegex = /from\s+['"](@videojs\/[^'"]+)['"]/g; let match: RegExpExecArray | null; while ((match = importRegex.exec(source)) !== null) { specifiers.add(match[1]); } return [...specifiers]; } function validatePackageImports(source: string, sourcePath: string): void { for (const specifier of collectPackageSpecifiers(source)) { try { resolvePackageExportFile(specifier); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Invalid package import "${specifier}" in "${sourcePath}": ${message}`); } } } function toRepoPath(filePath: string): string { return relativePath(ROOT, filePath); } function createSourceFile(filePath: string, source: string): ts.SourceFile { return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); } function isDirectivePrologueStatement(statement: ts.Statement): boolean { return ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression); } type NamedDeclaration = | ts.FunctionDeclaration | ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration; function isNamedDeclaration(statement: ts.Statement): statement is NamedDeclaration { return ( ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement) ); } function getStatementName(statement: ts.Statement): string | null { if (isNamedDeclaration(statement)) { return statement.name?.text ?? null; } if (ts.isVariableStatement(statement)) { const decl = statement.declarationList.declarations[0]; return decl && ts.isIdentifier(decl.name) ? decl.name.text : null; } return null; } function isRelativeImport(specifier: string): boolean { return specifier.startsWith('./') || specifier.startsWith('../'); } function resolveRelativeModulePath(importerPath: string, specifier: string): string { const basePath = resolve(dirname(importerPath), specifier); const candidates = [ basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.jsx`, resolve(basePath, 'index.ts'), resolve(basePath, 'index.tsx'), resolve(basePath, 'index.js'), resolve(basePath, 'index.jsx'), ]; for (const candidate of candidates) { if (existsSync(candidate) && statSync(candidate).isFile()) { return candidate; } } throw new Error(`Could not resolve relative import "${specifier}" from "${toRepoPath(importerPath)}"`); } function stripExportModifier(text: string): string { return text.replace(/^export\s+default\s+/, '').replace(/^export\s+/, ''); } function getImportStatementText(source: string, node: ts.ImportDeclaration): string { return source.slice(node.getFullStart(), node.getEnd()).trim(); } function findLocalDeclarationText(sourceFile: ts.SourceFile, localName: string): string | null { for (const statement of sourceFile.statements) { if (getStatementName(statement) === localName) { return statement.getText(sourceFile); } } return null; } function getNamedExportText(sourceFile: ts.SourceFile, exportName: string): string | null { for (const statement of sourceFile.statements) { const isExported = hasExportModifier(statement); if (isExported && getStatementName(statement) === exportName) { return stripExportModifier(statement.getText(sourceFile)); } if ( ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause) ) { for (const element of statement.exportClause.elements) { const exportedName = element.name.text; const localName = element.propertyName?.text ?? exportedName; if (exportedName === exportName) { return findLocalDeclarationText(sourceFile, localName); } } } } return null; } function getLocalDeclarationTexts(sourceFile: ts.SourceFile): Map { const declarations = new Map(); for (const statement of sourceFile.statements) { if (ts.isExportDeclaration(statement)) continue; const name = getStatementName(statement); if (!name) continue; const text = ts.canHaveModifiers(statement) ? stripExportModifier(statement.getText(sourceFile)) : statement.getText(sourceFile); declarations.set(name, text); } return declarations; } function collectDeclarationClosure( sourceFile: ts.SourceFile, declarationName: string, declarations: Map, seen = new Set() ): string[] { if (seen.has(declarationName)) { return []; } const declarationText = declarations.get(declarationName) ?? getNamedExportText(sourceFile, declarationName); if (!declarationText) { throw new Error(`Could not find declaration "${declarationName}" in "${sourceFile.fileName}"`); } seen.add(declarationName); const identifierRegex = /\b[A-Za-z_]\w*\b/g; const dependencyNames = new Set(); let match: RegExpExecArray | null; while ((match = identifierRegex.exec(declarationText)) !== null) { const identifier = match[0]; if (identifier !== declarationName && declarations.has(identifier)) { dependencyNames.add(identifier); } } const dependencyTexts = [...dependencyNames].flatMap((name) => collectDeclarationClosure(sourceFile, name, declarations, seen) ); return [...dependencyTexts, declarationText]; } function inlineModuleExport( sourceFile: ts.SourceFile, importName: string, localName: string, isTypeOnly: boolean ): string { const declarations = getLocalDeclarationTexts(sourceFile); const exportTexts = collectDeclarationClosure(sourceFile, importName, declarations); const exportText = exportTexts.join('\n\n'); if (importName === localName) { return exportText; } const aliasKeyword = isTypeOnly ? 'type' : 'const'; return `${exportText}\n\n${aliasKeyword} ${localName} = ${importName};`; } function inlineRelativeImports(source: string, sourcePath: string, rewriteSource = (value: string) => value): string { source = rewriteSource(source); const sourceFile = createSourceFile(sourcePath, source); const declarationsToInline: string[] = []; const extraImports = new Set(); const declarationsSeen = new Set(); const replacements: Array<{ start: number; end: number; text: string }> = []; for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement)) { continue; } const specifier = statement.moduleSpecifier.getText(sourceFile).slice(1, -1); if (!isRelativeImport(specifier)) { continue; } const importClause = statement.importClause; if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings) || importClause.name) { throw new Error(`Unsupported relative import in "${toRepoPath(sourcePath)}": ${statement.getText(sourceFile)}`); } const targetPath = resolveRelativeModulePath(sourcePath, specifier); const targetSource = rewriteSource(readFileSync(targetPath, 'utf-8')); validatePackageImports(targetSource, toRepoPath(targetPath)); const transformedTargetSource = inlineRelativeImports(targetSource, targetPath, rewriteSource); const transformedTargetFile = createSourceFile(targetPath, transformedTargetSource); for (const targetStatement of transformedTargetFile.statements) { if (isDirectivePrologueStatement(targetStatement)) { continue; } if (!ts.isImportDeclaration(targetStatement)) { break; } const targetSpecifier = targetStatement.moduleSpecifier.getText(transformedTargetFile).slice(1, -1); if (isRelativeImport(targetSpecifier)) { throw new Error( `Relative import remained after inlining in "${toRepoPath(targetPath)}": ${targetStatement.getText( transformedTargetFile )}` ); } extraImports.add(getImportStatementText(transformedTargetSource, targetStatement)); } for (const element of importClause.namedBindings.elements) { const importName = element.propertyName?.text ?? element.name.text; const localName = element.name.text; const declaration = inlineModuleExport(transformedTargetFile, importName, localName, element.isTypeOnly); if (!declarationsSeen.has(declaration)) { declarationsSeen.add(declaration); declarationsToInline.push(declaration); } } replacements.push({ start: statement.getFullStart(), end: statement.getEnd(), text: '', }); } let transformedSource = source; for (const replacement of replacements.sort((a, b) => b.start - a.start)) { transformedSource = `${transformedSource.slice(0, replacement.start)}${replacement.text}${transformedSource.slice( replacement.end )}`; } if (extraImports.size > 0) { transformedSource = `${[...extraImports].join('\n')}\n${transformedSource}`; } if (declarationsToInline.length > 0) { const insertPos = findLastImportEnd(transformedSource); const block = `\n${declarationsToInline.join('\n\n')}\n`; transformedSource = `${transformedSource.slice(0, insertPos)}${block}${transformedSource.slice(insertPos)}`; } transformedSource = normalizeImports(transformedSource); for (const relativeSpecifier of collectRelativeImportSpecifiers(transformedSource)) { throw new Error(`Relative import "${relativeSpecifier}" remains in "${toRepoPath(sourcePath)}" after inlining`); } return transformedSource; } function collectRelativeImportSpecifiers(source: string): string[] { const specifiers = new Set(); const importRegex = /from\s+['"]((?:\.\/|\.\.\/)[^'"]+)['"]/g; let match: RegExpExecArray | null; while ((match = importRegex.exec(source)) !== null) { specifiers.add(match[1]); } return [...specifiers]; } // --------------------------------------------------------------------------- // Skin definitions // --------------------------------------------------------------------------- const SKINS: SkinDef[] = [ // HTML CSS { id: 'default-video', name: 'Default Video', platform: 'html', style: 'css', template: 'packages/html/src/define/video/skin.ts', css: 'packages/html/src/define/video/skin.css', iconSet: 'default', }, { id: 'default-audio', name: 'Default Audio', platform: 'html', style: 'css', template: 'packages/html/src/define/audio/skin.ts', css: 'packages/html/src/define/audio/skin.css', iconSet: 'default', }, { id: 'minimal-video', name: 'Minimal Video', platform: 'html', style: 'css', template: 'packages/html/src/define/video/minimal-skin.ts', css: 'packages/html/src/define/video/minimal-skin.css', iconSet: 'minimal', }, { id: 'minimal-audio', name: 'Minimal Audio', platform: 'html', style: 'css', template: 'packages/html/src/define/audio/minimal-skin.ts', css: 'packages/html/src/define/audio/minimal-skin.css', iconSet: 'minimal', }, // HTML Tailwind { id: 'default-video-tailwind', name: 'Default Video (Tailwind)', platform: 'html', style: 'tailwind', template: 'packages/html/src/define/video/skin.tailwind.ts', iconSet: 'default', tailwindModule: '@videojs/skins/default/tailwind/video.tailwind', }, { id: 'default-audio-tailwind', name: 'Default Audio (Tailwind)', platform: 'html', style: 'tailwind', template: 'packages/html/src/define/audio/skin.tailwind.ts', iconSet: 'default', tailwindModule: '@videojs/skins/default/tailwind/audio.tailwind', }, { id: 'minimal-video-tailwind', name: 'Minimal Video (Tailwind)', platform: 'html', style: 'tailwind', template: 'packages/html/src/define/video/minimal-skin.tailwind.ts', iconSet: 'minimal', tailwindModule: '@videojs/skins/minimal/tailwind/video.tailwind', }, { id: 'minimal-audio-tailwind', name: 'Minimal Audio (Tailwind)', platform: 'html', style: 'tailwind', template: 'packages/html/src/define/audio/minimal-skin.tailwind.ts', iconSet: 'minimal', tailwindModule: '@videojs/skins/minimal/tailwind/audio.tailwind', }, // React CSS { id: 'default-video-react', name: 'Default Video (React)', platform: 'react', style: 'css', source: 'packages/react/src/presets/video/skin.tsx', css: 'packages/react/src/presets/video/skin.css', }, { id: 'default-audio-react', name: 'Default Audio (React)', platform: 'react', style: 'css', source: 'packages/react/src/presets/audio/skin.tsx', css: 'packages/react/src/presets/audio/skin.css', }, { id: 'minimal-video-react', name: 'Minimal Video (React)', platform: 'react', style: 'css', source: 'packages/react/src/presets/video/minimal-skin.tsx', css: 'packages/react/src/presets/video/minimal-skin.css', }, { id: 'minimal-audio-react', name: 'Minimal Audio (React)', platform: 'react', style: 'css', source: 'packages/react/src/presets/audio/minimal-skin.tsx', css: 'packages/react/src/presets/audio/minimal-skin.css', }, // React Tailwind { id: 'default-video-react-tailwind', name: 'Default Video (React + Tailwind)', platform: 'react', style: 'tailwind', source: 'packages/react/src/presets/video/skin.tailwind.tsx', }, { id: 'default-audio-react-tailwind', name: 'Default Audio (React + Tailwind)', platform: 'react', style: 'tailwind', source: 'packages/react/src/presets/audio/skin.tailwind.tsx', }, { id: 'minimal-video-react-tailwind', name: 'Minimal Video (React + Tailwind)', platform: 'react', style: 'tailwind', source: 'packages/react/src/presets/video/minimal-skin.tailwind.tsx', }, { id: 'minimal-audio-react-tailwind', name: 'Minimal Audio (React + Tailwind)', platform: 'react', style: 'tailwind', source: 'packages/react/src/presets/audio/minimal-skin.tailwind.tsx', }, ]; // --------------------------------------------------------------------------- // CSS resolution // --------------------------------------------------------------------------- function resolveCss(cssPath: string): string { const abs = resolve(ROOT, cssPath); const raw = readFileSync(abs, 'utf-8'); return resolveImports(raw, dirname(abs), SKINS_SRC); } function getHtmlSkinCdnFileName(skin: HtmlSkinDef): string { const isMinimal = skin.id.includes('minimal'); const prefix = skin.id.includes('video') ? 'video' : 'audio'; return isMinimal ? `${prefix}-minimal-ui` : `${prefix}-ui`; } function prependHtmlSkinScripts(html: string, skin: HtmlSkinDef): string { const cdnFileName = getHtmlSkinCdnFileName(skin); const scriptTag = ``; const cssLink = ``; const playerTag = getSkinMediaType(skin) === 'audio' ? 'audio-player' : 'video-player'; const indented = html .split('\n') .map((l) => (l.length > 0 ? ` ${l}` : l)) .join('\n'); return `${scriptTag}\n${cssLink}\n\n<${playerTag}>\n${indented}\n`; } // --------------------------------------------------------------------------- // HTML template extraction and evaluation // --------------------------------------------------------------------------- /** * Extract the body of `getTemplateHTML()` from the source file. * Returns the raw template literal content (without the surrounding backticks). */ function extractTemplateLiteral(source: string): string { // Match: function getTemplateHTML(...) { return /*html*/ `...`; } // or: function getTemplateHTML(...) { return `...`; } const match = source.match( /function\s+getTemplateHTML\s*\([^)]*\)\s*\{[\s\S]*?return\s+(?:\/\*html\*\/\s*)?`([\s\S]*?)`\s*;?\s*\}/ ); if (!match) { throw new Error('Could not extract getTemplateHTML template literal'); } return match[1]; } /** * Collect all import names that the template uses from the tailwind module. * Parses lines like: `import { foo, bar } from '@videojs/skins/...'` * and also picks up re-imports from other modules used in the template. */ function parseImportedNames(source: string): Map { const imports = new Map(); const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; let match: RegExpExecArray | null; while ((match = importRegex.exec(source)) !== null) { const names = match[1] .split(',') .map((s) => s.trim()) .filter(Boolean); const module = match[2]; for (const name of names) { // Handle `foo as bar` const parts = name.split(/\s+as\s+/); const localName = parts.length > 1 ? parts[1] : parts[0]; imports.set(localName, module); } } return imports; } async function loadCn(): Promise<(...args: unknown[]) => string> { const mod = await import(pkgDistUrl('@videojs/utils/style')); return mod.cn; } async function loadTailwindTokens(specifier: string): Promise> { return await import(pkgDistUrl(specifier)); } /** * Evaluate the HTML template by replacing `${...}` expressions with * their computed values. * * Uses `new Function()` to evaluate the template literal in a context * that provides renderIcon, cn, SEEK_TIME, and all tailwind tokens. */ function evaluateTemplate(templateBody: string, context: Record): string { const keys = Object.keys(context); const values = Object.values(context); // Build a function that returns the evaluated template literal const fn = new Function(...keys, `return \`${templateBody}\`;`); const html = fn(...values) as string; // Clean up whitespace: dedent, trim trailing spaces, and trim outer edges. const lines = html.split('\n').map((line) => line.trimEnd()); const minIndent = lines .filter((l) => l.length > 0) .reduce((min, l) => Math.min(min, l.length - l.trimStart().length), Infinity); return lines .map((l) => (l.length > 0 ? l.slice(minIndent) : l)) .join('\n') .trim(); } function escapeAttributeValue(value: string): string { return value.replaceAll('&', '&').replaceAll('"', '"'); } function createRenderMediaIcon(iconSet: 'default' | 'minimal') { return (name: string, attrs?: Record): string => { const family = iconSet === 'minimal' ? ' family="minimal"' : ''; const attrText = Object.entries(attrs ?? {}) .map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`) .join(''); return ``; }; } /** * Replace ``, `` (default slot), and * `` with concrete elements so the ejected HTML is * self-contained. */ function replaceSlots(html: string, mediaType: MediaType): string { const tag = mediaType === 'audio' ? 'audio' : 'video'; const playsInline = mediaType === 'video' ? ' playsinline' : ''; const mediaElement = `<${tag} src="${DEMO_VIDEO_SRC}"${playsInline}>`; // Replace the deprecated comment + slot="media" + default slot block with the // media element, preserving the original indentation. html = html.replace( /^([ \t]*)