import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; export interface BulkComponentEntry { files: string; name: (filename: string) => string; } export type ComponentEntry = string | BulkComponentEntry; export interface ComponentsConfig { components: readonly ComponentEntry[]; output: string; runtimeImport?: string | undefined; } export interface GenerateComponentsOptions { cwd?: string | undefined; } export interface GenerateComponentsResult { outputPath: string; source: string; } interface ManifestComponent { kind: 'manifest'; name: string; /** Module specifier (relative to output) for `import XDef from '...'`. */ manifestFrom: string; } interface InlineComponent { kind: 'inline'; name: string; } type ResolvedComponent = ManifestComponent | InlineComponent; export function defineComponentsConfig( config: Config ): Config { return config; } function isDefineComponentCall(node: ts.Node): node is ts.CallExpression { if (!ts.isCallExpression(node)) return false; const callee = node.expression; if (ts.isIdentifier(callee) && callee.text === 'defineComponent') return true; // Curried form: defineComponent()({ ... }). return ( ts.isCallExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === 'defineComponent' ); } function findDefaultExportCall(sourceFile: ts.SourceFile): ts.CallExpression | null { for (const stmt of sourceFile.statements) { if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; if (isDefineComponentCall(stmt.expression)) return stmt.expression; } return null; } function parseComponentName(manifestPath: string): string { const sourceText = readFileSync(manifestPath, 'utf8'); const sourceFile = ts.createSourceFile(manifestPath, sourceText, ts.ScriptTarget.Latest, true); const call = findDefaultExportCall(sourceFile); if (!call) { throw new Error(`No \`export default defineComponent(...)\` found in ${manifestPath}`); } const arg = call.arguments[0]; if (!arg || !ts.isObjectLiteralExpression(arg)) { throw new Error(`defineComponent() in ${manifestPath} must take an object literal`); } for (const prop of arg.properties) { if ( ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'name' && ts.isStringLiteral(prop.initializer) ) { return prop.initializer.text; } } throw new Error(`defineComponent() in ${manifestPath} is missing a literal \`name:\` field`); } function manifestPathToImport(manifestPath: string, outputFile: string): string { let rel = relative(dirname(outputFile), manifestPath); if (!rel.startsWith('.')) rel = `./${rel}`; return rel.replace(/\.[cm]?tsx?$/, ''); } function fileStem(filePath: string): string { const base = basename(filePath); const ext = extname(base); return ext ? base.slice(0, -ext.length) : base; } function resolveManifestEntry(pattern: string, cwd: string, outputAbsolute: string): ManifestComponent[] { const matches = globSync(pattern, { cwd }).map((path) => (isAbsolute(path) ? path : resolve(cwd, path))); return matches.map((manifestPath) => ({ kind: 'manifest', name: parseComponentName(manifestPath), manifestFrom: manifestPathToImport(manifestPath, outputAbsolute), })); } function resolveBulkEntry(entry: BulkComponentEntry, cwd: string): InlineComponent[] { const matches = globSync(entry.files, { cwd }); return matches.map((file) => ({ kind: 'inline', name: entry.name(fileStem(file)), })); } function compareImportSpecifiers(a: string, b: string): number { const aKey = a.replaceAll('/', ' '); const bKey = b.replaceAll('/', ' '); if (aKey < bKey) return -1; if (aKey > bKey) return 1; return 0; } function emitHeader(entries: readonly ResolvedComponent[], runtimeImport: string): string { const manifestLines = entries .filter((entry): entry is ManifestComponent => entry.kind === 'manifest') .sort((a, b) => compareImportSpecifiers(a.manifestFrom, b.manifestFrom)) .map((entry) => `import ${entry.name}Def from '${entry.manifestFrom}';`) .join('\n'); const head = `// AUTO-GENERATED by \`@videojs/core/scripts/generate-components\`. DO NOT EDIT. import { createComponent } from '${runtimeImport}';`; return manifestLines ? `${head}\n\n${manifestLines}` : head; } function manifestRef(entry: ResolvedComponent): string { return entry.kind === 'manifest' ? `${entry.name}Def` : `{ name: '${entry.name}' }`; } function emitComponents(entries: readonly ResolvedComponent[]): string { return entries.map((entry) => `export const ${entry.name} = createComponent(${manifestRef(entry)});`).join('\n'); } function emitMetadata(entries: readonly ResolvedComponent[]): string { const lines = entries.map((entry) => ` ${entry.name}: ${manifestRef(entry)},`); return `export const COMPONENTS = {\n${lines.join('\n')}\n} as const; export type Components = typeof COMPONENTS;`; } export async function generateComponents( config: ComponentsConfig, options: GenerateComponentsOptions = {} ): Promise { const { components, output, runtimeImport = '@videojs/core/jsx-runtime' } = config; const cwd = options.cwd ?? process.cwd(); const outputAbsolute = isAbsolute(output) ? output : resolve(cwd, output); const resolved = components.flatMap((entry) => typeof entry === 'string' ? resolveManifestEntry(entry, cwd, outputAbsolute) : resolveBulkEntry(entry, cwd) ); const entries = resolved.sort((a, b) => a.name.localeCompare(b.name)); if (entries.length === 0) { throw new Error(`No component sources matched: ${JSON.stringify(components)}`); } const source = `${[emitHeader(entries, runtimeImport), emitComponents(entries), emitMetadata(entries)].join('\n\n')}\n`; const existing = existsSync(outputAbsolute) ? readFileSync(outputAbsolute, 'utf8') : null; if (existing !== source) { mkdirSync(dirname(outputAbsolute), { recursive: true }); writeFileSync(outputAbsolute, source, 'utf8'); } return { outputPath: outputAbsolute, source }; } interface ConfigModule { default?: ComponentsConfig | readonly ComponentsConfig[]; config?: ComponentsConfig | readonly ComponentsConfig[]; } function parseConfigArg(argv: readonly string[]): string { for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; if (arg === '--config' || arg === '-c') { const value = argv[i + 1]; if (!value) throw new Error('Missing value for --config'); return value; } if (arg.startsWith('--config=')) return arg.slice('--config='.length); } return 'components.config.js'; } async function loadConfig(configPath: string): Promise<{ configs: readonly ComponentsConfig[]; cwd: string }> { const absolute = isAbsolute(configPath) ? configPath : resolve(process.cwd(), configPath); const mod = (await import(pathToFileURL(absolute).href)) as ConfigModule; const config = mod.default ?? mod.config; if (!config) { throw new Error(`Config file ${absolute} must export a default component generator config.`); } return { configs: Array.isArray(config) ? config : [config], cwd: dirname(absolute) }; } async function runCli(): Promise { const configPath = parseConfigArg(process.argv.slice(2)); const { configs, cwd } = await loadConfig(configPath); for (const config of configs) { const result = await generateComponents(config, { cwd }); process.stdout.write(`Wrote ${result.outputPath}\n`); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { runCli().catch((error: unknown) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }