Files
v10/packages/core/scripts/generate-components.ts
T

230 lines
7.9 KiB
TypeScript

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<const Config extends ComponentsConfig | readonly ComponentsConfig[]>(
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<Props>()({ ... }).
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<GenerateComponentsResult> {
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<ResolvedComponent>((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<void> {
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;
});
}