mirror of
https://github.com/zoriya/v10.git
synced 2026-08-09 15:46:42 +00:00
230 lines
7.9 KiB
TypeScript
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;
|
|
});
|
|
}
|