/** * Build ejected skin snippets for copy-paste usage. * * Produces `site/src/content/ejected-skins.json` with: * - HTML skins: rendered HTML templates with inline SVGs and resolved classes * - React skins: TSX (with types) and JSX (types stripped) with inline SVGs * - 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 icons, skins, utils). */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { resolveImports } from '../../build/plugins/resolve-css-imports.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '../..'); const PREFIX = '\x1b[35m[ejected-skins]\x1b[0m'; 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), }; /** Resolve a `@videojs/*` package specifier to its built dist file URL. */ function pkgDistUrl(specifier: string): string { const mapping: Record = { '@videojs/icons/render/default': 'packages/icons/dist/render/default/index.js', '@videojs/icons/render/minimal': 'packages/icons/dist/render/minimal/index.js', '@videojs/utils/style': 'packages/utils/dist/style.js', '@videojs/skins/default/tailwind/video.tailwind': 'packages/skins/dist/default/default/tailwind/video.tailwind.js', '@videojs/skins/default/tailwind/audio.tailwind': 'packages/skins/dist/default/default/tailwind/audio.tailwind.js', '@videojs/skins/minimal/tailwind/video.tailwind': 'packages/skins/dist/default/minimal/tailwind/video.tailwind.js', '@videojs/skins/minimal/tailwind/audio.tailwind': 'packages/skins/dist/default/minimal/tailwind/audio.tailwind.js', }; const rel = mapping[specifier]; if (!rel) throw new Error(`No dist mapping for: ${specifier}`); return pathToFileURL(resolve(ROOT, rel)).href; } const SKINS_SRC = resolve(ROOT, 'packages/skins/src'); const OUTPUT = resolve(ROOT, 'site/src/content/ejected-skins.json'); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- 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; interface EjectedSkinEntry { id: string; name: string; platform: 'html' | 'react'; style: 'css' | 'tailwind'; html?: string; tsx?: string; jsx?: string; css?: string; } // --------------------------------------------------------------------------- // 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); } // --------------------------------------------------------------------------- // 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 loadRenderIcon( iconSet: 'default' | 'minimal' ): Promise<(name: string, attrs?: Record) => string> { const mod = await import(pkgDistUrl(`@videojs/icons/render/${iconSet}`)); return mod.renderIcon; } 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: normalize indentation and trim return html .split('\n') .map((line) => line.trimEnd()) .join('\n') .trim(); } /** * Process an HTML skin: extract the template, evaluate it with the right * context, and return the rendered HTML string. */ async function processHtmlSkin(skin: HtmlSkinDef): Promise { const absPath = resolve(ROOT, skin.template); const source = readFileSync(absPath, 'utf-8'); const templateBody = extractTemplateLiteral(source); const renderIcon = await loadRenderIcon(skin.iconSet); const cn = await loadCn(); // Build context object with all the variables the template needs const context: Record = { renderIcon, cn, SEEK_TIME: 10, }; if (skin.style === 'tailwind') { // Load the primary tailwind module if (skin.tailwindModule) { const tokens = await loadTailwindTokens(skin.tailwindModule); Object.assign(context, tokens); } // Check if the source imports from additional tailwind modules const imports = parseImportedNames(source); const loadedModules = new Set(); if (skin.tailwindModule) loadedModules.add(skin.tailwindModule); for (const [name, mod] of imports) { if (mod.includes('/tailwind/') && !loadedModules.has(mod)) { loadedModules.add(mod); const extraTokens = await loadTailwindTokens(mod); // Only add names that aren't already in context if (!(name in context) && name in extraTokens) { context[name] = extraTokens[name]; } } } } return evaluateTemplate(templateBody, context); } // --------------------------------------------------------------------------- // React skin processing — inline SVGs, resolve imports, produce TSX + JSX // --------------------------------------------------------------------------- /** Convert PascalCase icon component name to kebab-case icon name. */ function componentToIconName(name: string): string { return name .replace(/Icon$/, '') .replace(/([a-z])([A-Z])/g, '$1-$2') .toLowerCase(); } /** Convert HTML SVG attribute names to JSX camelCase equivalents. */ function svgToJsx(svg: string): string { return svg .replace(/\bstroke-width=/g, 'strokeWidth=') .replace(/\bstroke-linecap=/g, 'strokeLinecap=') .replace(/\bstroke-linejoin=/g, 'strokeLinejoin=') .replace(/\bstroke-dasharray=/g, 'strokeDasharray=') .replace(/\bstroke-dashoffset=/g, 'strokeDashoffset=') .replace(/\bstroke-miterlimit=/g, 'strokeMiterlimit=') .replace(/\bfill-rule=/g, 'fillRule=') .replace(/\bclip-rule=/g, 'clipRule=') .replace(/\bfill-opacity=/g, 'fillOpacity=') .replace(/\bstroke-opacity=/g, 'strokeOpacity='); } /** Load the raw icons map from a render dist module. */ async function loadIconsMap(iconSet: 'default' | 'minimal'): Promise> { const mod = await import(pkgDistUrl(`@videojs/icons/render/${iconSet}`)); const renderIcon = mod.renderIcon as (name: string) => string; const knownIcons = [ 'play', 'pause', 'restart', 'seek', 'spinner', 'volume-high', 'volume-low', 'volume-off', 'captions-off', 'captions-on', 'fullscreen-enter', 'fullscreen-exit', 'pip', ]; const map: Record = {}; for (const name of knownIcons) { const svg = renderIcon(name); if (svg) map[name] = svg; } return map; } /** Serialize a JS value to source code. */ function serializeValue(value: unknown, indent = 0): string { if (typeof value === 'string') return JSON.stringify(value); if (typeof value === 'function') { // Function tokens (like `root`) — resolve with false (no shadow DOM) return `() => ${JSON.stringify((value as (arg: boolean) => string)(false))}`; } if (typeof value === 'object' && value !== null) { const entries = Object.entries(value as Record); const pad = ' '.repeat(indent + 1); const closePad = ' '.repeat(indent); const parts = entries.map(([k, v]) => `${pad}${k}: ${serializeValue(v, indent + 1)}`); return `{\n${parts.join(',\n')},\n${closePad}}`; } return String(value); } /** Strip TypeScript types from TSX source to produce plain JSX. */ function tsxToJsx(source: string): string { const result = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.Preserve, }, }); return result.outputText; } // -- React source transforms -- // Each transform removes its import(s) and collects any non-import code // (const declarations, type defs, inlined components) into `postImport`. // After all transforms, collected code is inserted after the final import. /** * Remove `cn` import and replace all `cn(...)` calls with template literals. * `cn(a, b)` → `` `${a} ${b}` ``, with string literal args inlined directly. */ function inlineCn(source: string): string { if (!source.match(/import\s+\{[^}]*\bcn\b[^}]*\}\s+from\s+['"]@videojs\/utils\/style['"]/)) { return source; } source = source.replace(/import\s+\{[^}]*\bcn\b[^}]*\}\s+from\s+['"]@videojs\/utils\/style['"];?\n?/g, ''); return replaceCnCalls(source); } /** Replace all `cn(...)` calls with `[...].filter(Boolean).join(' ')`. */ function replaceCnCalls(source: string): string { const parts: string[] = []; let i = 0; while (i < source.length) { const cnIndex = source.indexOf('cn(', i); if (cnIndex === -1) { parts.push(source.slice(i)); break; } // Ensure `cn(` is a standalone call, not part of another identifier if (cnIndex > 0 && /\w/.test(source[cnIndex - 1])) { parts.push(source.slice(i, cnIndex + 3)); i = cnIndex + 3; continue; } parts.push(source.slice(i, cnIndex)); // Find the matching closing paren const argsStart = cnIndex + 3; let depth = 1; let j = argsStart; while (j < source.length && depth > 0) { if (source[j] === '(') depth++; else if (source[j] === ')') depth--; if (depth > 0) j++; } const argsStr = source.slice(argsStart, j); const args = splitTopLevelCommas(argsStr).map((a) => a.trim()); parts.push(`[${args.join(', ')}].filter(Boolean).join(' ')`); i = j + 1; // skip past closing paren } return parts.join(''); } /** Split a string by commas that are not inside parentheses, brackets, or template literals. */ function splitTopLevelCommas(str: string): string[] { const result: string[] = []; let current = ''; let depth = 0; let templateDepth = 0; for (let i = 0; i < str.length; i++) { const ch = str[i]; if (ch === '(' || ch === '[') depth++; else if (ch === ')' || ch === ']') depth--; else if (ch === '`') templateDepth = templateDepth > 0 ? templateDepth - 1 : templateDepth + 1; else if (ch === ',' && depth === 0 && templateDepth === 0) { result.push(current); current = ''; continue; } current += ch; } if (current) result.push(current); return result; } /** Replace icon component JSX with inline SVGs and remove icon imports. */ async function inlineReactIcons(source: string): Promise { const iconImportMatch = source.match(/from\s+['"]@videojs\/icons\/react(?:\/(default|minimal))?['"]/); if (!iconImportMatch) return source; const iconSet = (iconImportMatch[1] || 'default') as 'default' | 'minimal'; const iconImportBlock = source.match(/import\s+\{([\s\S]*?)\}\s+from\s+['"]@videojs\/icons\/react(?:\/\w+)?['"]/); const iconNames = iconImportBlock?.[1] ?.split(',') .map((s) => s.trim()) .filter(Boolean) || []; const iconsMap = await loadIconsMap(iconSet); for (const componentName of iconNames) { const iconName = componentToIconName(componentName); const rawSvg = iconsMap[iconName]; if (!rawSvg) { log.warn(`No SVG found for ${componentName} (icon: ${iconName})`); continue; } const jsxSvg = svgToJsx(rawSvg); const regex = new RegExp(`<${componentName}\\s+className=((?:"[^"]*")|(?:\\{[^}]+\\}))\\s*/>`, 'g'); source = source.replace(regex, (_, classNameAttr: string) => { return jsxSvg.replace(' m.replace(names, `${names.trim()}, useRef`) ); } return source; } /** * Replace `@videojs/skins/*` imports (private package) with inline const * declarations containing the resolved token values. */ async function inlineSkinTokens(source: string, postImport: string[]): Promise { const regex = /import\s+\{([^}]*)\}\s+from\s+['"](@videojs\/skins\/[^'"]+)['"]\s*;?\n?/; let match: RegExpMatchArray | null; while ((match = source.match(regex)) !== null) { const names = match[1] .split(',') .map((s) => s.trim()) .filter(Boolean); const moduleSpec = match[2]; const mod = await import(pkgDistUrl(moduleSpec)); const declarations = names.map((name) => `const ${name} = ${serializeValue(mod[name])};`).join('\n'); source = source.replace(match[0], ''); postImport.push(declarations); } return source; } /** * Replace relative type imports with inline type definitions. * - `../types` → remove import, resolve `BaseSkinProps` usage in-place * - `./skin` / `./minimal-skin` → remove import, add type to postImport */ function inlineRelativeTypes(source: string, postImport: string[]): string { const baseSkinPropsType = 'PropsWithChildren<{ style?: CSSProperties; className?: string }>'; // Remove `import type { BaseSkinProps } from '../types'` source = source.replace(/import\s+type\s+\{\s*BaseSkinProps\s*\}\s+from\s+['"]\.\.\/types['"];?\n?/g, ''); // Resolve `export type XxxProps = BaseSkinProps` in-place (already after imports) source = source.replace( /export\s+type\s+(\w+)\s*=\s*BaseSkinProps\s*;/g, (_, name: string) => `export type ${name} = ${baseSkinPropsType};` ); // Replace `import type { XxxProps } from './skin'` or './minimal-skin' source = source.replace( /import\s+type\s+\{\s*(\w+)\s*\}\s+from\s+['"]\.\/(?:minimal-)?skin['"];?\n?/g, (_, typeName: string) => { postImport.push(`type ${typeName} = ${baseSkinPropsType};`); return ''; } ); // Ensure PropsWithChildren and CSSProperties are in the react import if (source.includes('PropsWithChildren') || postImport.some((c) => c.includes('PropsWithChildren'))) { const reactImportRegex = /import\s+\{([^}]+)\}\s+from\s+['"]react['"]/; const reactMatch = source.match(reactImportRegex); if (reactMatch) { let names = reactMatch[1]; if (!names.includes('PropsWithChildren')) { names = `${names.trim()}, type PropsWithChildren`; } if (!names.includes('CSSProperties')) { names = `${names.trim()}, type CSSProperties`; } source = source.replace(reactMatch[0], `import { ${names} } from 'react'`); } } return source; } /** * Consolidate `@/` path alias imports into `@videojs/react`. * All UI components and hooks are re-exported from the main package entry. */ function rewritePathAliases(source: string): string { const aliasRegex = /import\s+(type\s+)?\{([^}]+)\}\s+from\s+['"]@\/[^'"]+['"];?\n?/g; const valueNames: string[] = []; const typeNames: string[] = []; const matches: string[] = []; let match: RegExpExecArray | null; while ((match = aliasRegex.exec(source)) !== null) { matches.push(match[0]); const isTypeImport = !!match[1]; const names = match[2] .split(',') .map((s) => s.trim()) .filter(Boolean); for (const name of names) { if (isTypeImport || name.startsWith('type ')) { typeNames.push(name.replace(/^type\s+/, '')); } else { valueNames.push(name); } } } if (matches.length === 0) return source; for (const m of matches) { source = source.replace(m, ''); } const allNames = [...valueNames, ...typeNames.map((n) => `type ${n}`)]; const importLine = `import { ${allNames.join(', ')} } from '@videojs/react';\n`; const lastImportIndex = findLastImportEnd(source); source = `${source.slice(0, lastImportIndex)}${importLine}${source.slice(lastImportIndex)}`; return source; } /** Find the byte offset just past the last import statement in the source. */ function findLastImportEnd(source: string): number { const importRegex = /^import\s+.+from\s+['"][^'"]+['"];?\s*$/gm; let lastEnd = 0; let match: RegExpExecArray | null; while ((match = importRegex.exec(source)) !== null) { lastEnd = match.index + match[0].length + 1; } return lastEnd; } /** * Process a React skin: inline SVG icons, resolve all imports, * and produce both TSX and JSX versions. */ async function processReactSkin(skin: ReactSkinDef): Promise<{ tsx: string; jsx: string }> { const absPath = resolve(ROOT, skin.source); let source = readFileSync(absPath, 'utf-8'); const postImport: string[] = []; // 1. Inline SVG icons (replace icon components with markup) source = await inlineReactIcons(source); // 2. Inline ErrorDialog component (video skins only) source = inlineErrorDialog(source, postImport); // 3. Resolve @videojs/skins/* tokens (Tailwind skins only, private package) source = await inlineSkinTokens(source, postImport); // 4. Replace cn calls with template literals source = inlineCn(source); // 5. Inline relative type imports (../types, ./skin, ./minimal-skin) source = inlineRelativeTypes(source, postImport); // 6. Consolidate @/ path aliases → @videojs/react source = rewritePathAliases(source); // 7. Insert collected non-import code after the final import statement if (postImport.length > 0) { const insertPos = findLastImportEnd(source); const block = `\n${postImport.join('\n\n')}\n`; source = `${source.slice(0, insertPos)}${block}${source.slice(insertPos)}`; } const tsx = source; const jsx = tsxToJsx(source); return { tsx, jsx }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main(): Promise { log.info('Building ejected skins...\n'); const entries: EjectedSkinEntry[] = []; for (const skin of SKINS) { log.info(`Processing: ${skin.id}`); const entry: EjectedSkinEntry = { id: skin.id, name: skin.name, platform: skin.platform, style: skin.style, }; if (skin.platform === 'html') { entry.html = await processHtmlSkin(skin); } else { const { tsx, jsx } = await processReactSkin(skin); entry.tsx = tsx; entry.jsx = jsx; } if (skin.css) { entry.css = resolveCss(skin.css); } entries.push(entry); } // Write output mkdirSync(dirname(OUTPUT), { recursive: true }); writeFileSync(OUTPUT, `${JSON.stringify(entries, null, 2)}\n`); log.info(`✅ Wrote ${entries.length} entries to ${OUTPUT}`); } main().catch((err) => { log.error(err); process.exit(1); });