mirror of
https://github.com/zoriya/v10.git
synced 2026-08-09 23:58:06 +00:00
refactor(compiler)!: move component generation to core
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateComponents } from '../generate-components';
|
||||
|
||||
const STUB = 'const defineComponent: any = () => (m: any) => m;';
|
||||
|
||||
function setup(): { dir: string; output: string; pattern: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'videojs-components-'));
|
||||
mkdirSync(join(dir, 'play-button'));
|
||||
mkdirSync(join(dir, 'slider'));
|
||||
mkdirSync(join(dir, 'hotkey'));
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'play-button', 'play-button-data-attrs.ts'),
|
||||
`export const PlayButtonDataAttrs = {} as const;`
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'play-button', 'play-button-component.ts'),
|
||||
`import { PlayButtonDataAttrs } from './play-button-data-attrs';
|
||||
${STUB}
|
||||
export default defineComponent<{ disabled?: boolean }>()({
|
||||
name: 'PlayButton',
|
||||
dataAttrs: PlayButtonDataAttrs,
|
||||
});`
|
||||
);
|
||||
|
||||
writeFileSync(join(dir, 'slider', 'slider-parts.ts'), `export const SliderParts = ['Root', 'Track'] as const;`);
|
||||
writeFileSync(join(dir, 'slider', 'slider-data-attrs.ts'), `export const SliderDataAttrs = {} as const;`);
|
||||
writeFileSync(
|
||||
join(dir, 'slider', 'slider-component.ts'),
|
||||
`import { SliderDataAttrs } from './slider-data-attrs';
|
||||
import { SliderParts } from './slider-parts';
|
||||
${STUB}
|
||||
export default defineComponent<{ orientation?: 'horizontal' | 'vertical' }>()({
|
||||
name: 'Slider',
|
||||
parts: SliderParts,
|
||||
dataAttrs: SliderDataAttrs,
|
||||
});`
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'hotkey', 'hotkey-component.ts'),
|
||||
`${STUB}
|
||||
export default defineComponent()({ name: 'Hotkey' });`
|
||||
);
|
||||
|
||||
return { dir, output: join(dir, 'out.ts'), pattern: join(dir, '*/*-component.ts') };
|
||||
}
|
||||
|
||||
function setupBulk(): { dir: string; output: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'videojs-components-bulk-'));
|
||||
mkdirSync(join(dir, 'assets'));
|
||||
writeFileSync(join(dir, 'assets', 'play.svg'), '<svg/>');
|
||||
writeFileSync(join(dir, 'assets', 'pause.svg'), '<svg/>');
|
||||
return { dir, output: join(dir, 'out.ts') };
|
||||
}
|
||||
|
||||
describe('generateComponents (manifest entries)', () => {
|
||||
it('imports each manifest as `<Name>Def` default-import', async () => {
|
||||
const { dir, output, pattern } = setup();
|
||||
await generateComponents({ components: [pattern], output }, { cwd: dir });
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain("import { createComponent } from '@videojs/core/jsx-runtime';");
|
||||
expect(source).toContain("import PlayButtonDef from './play-button/play-button-component';");
|
||||
expect(source).toContain("import SliderDef from './slider/slider-component';");
|
||||
expect(source).toContain("import HotkeyDef from './hotkey/hotkey-component';");
|
||||
});
|
||||
|
||||
it('uses an explicit runtime import when configured', async () => {
|
||||
const { dir, output, pattern } = setup();
|
||||
await generateComponents({ components: [pattern], output, runtimeImport: '../../jsx-runtime' }, { cwd: dir });
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain("import { createComponent } from '../../jsx-runtime';");
|
||||
});
|
||||
|
||||
it('emits createComponent(Def) for each component', async () => {
|
||||
const { dir, output, pattern } = setup();
|
||||
await generateComponents({ components: [pattern], output }, { cwd: dir });
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain('export const PlayButton = createComponent(PlayButtonDef);');
|
||||
expect(source).toContain('export const Slider = createComponent(SliderDef);');
|
||||
expect(source).toContain('export const Hotkey = createComponent(HotkeyDef);');
|
||||
});
|
||||
|
||||
it('emits COMPONENTS referencing each definition', async () => {
|
||||
const { dir, output, pattern } = setup();
|
||||
await generateComponents({ components: [pattern], output }, { cwd: dir });
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain('export const COMPONENTS = {');
|
||||
expect(source).toContain('export type Components = typeof COMPONENTS;');
|
||||
expect(source).toContain('PlayButton: PlayButtonDef,');
|
||||
expect(source).toContain('Slider: SliderDef,');
|
||||
expect(source).toContain('Hotkey: HotkeyDef,');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateComponents (bulk entries)', () => {
|
||||
it('inlines createComponent({ name }) for each matched file', async () => {
|
||||
const { dir, output } = setupBulk();
|
||||
await generateComponents(
|
||||
{
|
||||
components: [
|
||||
{
|
||||
files: join(dir, 'assets/*.svg'),
|
||||
name: (filename) => `${filename[0]!.toUpperCase()}${filename.slice(1)}Icon`,
|
||||
},
|
||||
],
|
||||
output,
|
||||
},
|
||||
{ cwd: dir }
|
||||
);
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain("export const PauseIcon = createComponent({ name: 'PauseIcon' });");
|
||||
expect(source).toContain("export const PlayIcon = createComponent({ name: 'PlayIcon' });");
|
||||
});
|
||||
|
||||
it('emits COMPONENTS with inline manifests for bulk entries', async () => {
|
||||
const { dir, output } = setupBulk();
|
||||
await generateComponents(
|
||||
{
|
||||
components: [
|
||||
{
|
||||
files: join(dir, 'assets/*.svg'),
|
||||
name: (filename) => `${filename[0]!.toUpperCase()}${filename.slice(1)}Icon`,
|
||||
},
|
||||
],
|
||||
output,
|
||||
},
|
||||
{ cwd: dir }
|
||||
);
|
||||
const source = readFileSync(output, 'utf8');
|
||||
expect(source).toContain("PlayIcon: { name: 'PlayIcon' },");
|
||||
expect(source).toContain("PauseIcon: { name: 'PauseIcon' },");
|
||||
});
|
||||
|
||||
it('strips the file extension before passing to name()', async () => {
|
||||
const { dir, output } = setupBulk();
|
||||
let received: string | null = null;
|
||||
await generateComponents(
|
||||
{
|
||||
components: [
|
||||
{
|
||||
files: join(dir, 'assets/*.svg'),
|
||||
name: (filename) => {
|
||||
if (received === null) received = filename;
|
||||
return `${filename}Icon`;
|
||||
},
|
||||
},
|
||||
],
|
||||
output,
|
||||
},
|
||||
{ cwd: dir }
|
||||
);
|
||||
expect(received).not.toContain('.svg');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user