refactor(compiler)!: move component generation to core

This commit is contained in:
Rahim
2026-06-19 19:37:09 -07:00
parent b2c6b3489f
commit f64acdfd15
158 changed files with 2849 additions and 712 deletions
+2 -13
View File
@@ -15,14 +15,6 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./jsx-runtime": {
"types": "./dist/jsx-runtime.d.ts",
"default": "./dist/jsx-runtime.js"
},
"./jsx-dev-runtime": {
"types": "./dist/jsx-dev-runtime.d.ts",
"default": "./dist/jsx-dev-runtime.js"
},
"./vite": {
"types": "./dist/plugins/vite.d.ts",
"default": "./dist/plugins/vite.js"
@@ -46,15 +38,13 @@
"./tailwind": {
"types": "./dist/tailwind/index.d.ts",
"default": "./dist/tailwind/index.js"
},
"./tsconfig.preset.json": "./tsconfig.preset.json"
}
},
"bin": {
"vjs": "./dist/cli.js"
},
"files": [
"dist",
"tsconfig.preset.json"
"dist"
],
"scripts": {
"build": "tsdown",
@@ -80,7 +70,6 @@
"typescript": "^6.0.2"
},
"devDependencies": {
"@videojs/core": "workspace:*",
"tsdown": "^0.21.4",
"vite": "^8.0.0",
"vitest": "^4.1.0"
+1 -18
View File
@@ -10,8 +10,7 @@ import {
formatCompilerDiagnosticJsonLine,
formatDiagnosticSummaryJsonLine,
} from './diagnostics';
import { generate } from './generate';
import { CONFIG_FILENAMES, loadConfig } from './load-config';
import { loadConfig } from './load-config';
interface ParsedArgs {
command: string | undefined;
@@ -54,7 +53,6 @@ function printHelp(): void {
'Usage: vjs <command> [options]',
'',
'Commands:',
' generate Generate components from the configured manifests',
' compile <file> Compile a JSX file',
'',
'Options:',
@@ -67,18 +65,6 @@ function printHelp(): void {
);
}
async function runGenerate(configOverride: string | undefined): Promise<void> {
const cwd = process.cwd();
const loaded = await loadConfig(cwd, configOverride);
if (!loaded) {
throw new Error(
`No compiler config found in ${cwd}. Expected one of: ${CONFIG_FILENAMES.join(', ')}, or pass --config <path>.`
);
}
const result = await generate(loaded.config);
process.stdout.write(`Wrote ${result.outputPath}\n`);
}
async function runCompile(
positional: readonly string[],
configOverride: string | undefined,
@@ -128,9 +114,6 @@ async function main(): Promise<void> {
}
switch (command) {
case 'generate':
await runGenerate(configOverride);
return;
case 'compile':
await runCompile(positional, configOverride, outFile, diagnosticsFormat);
return;
-25
View File
@@ -41,30 +41,6 @@ export interface StylePipeline {
setup(context: CompilerContext): CompilerPipelineStep | Promise<CompilerPipelineStep>;
}
/**
* Bulk-defined component entry. Globs `files`, derives each component's name
* from the filename (extension stripped) via `name(stem)`, and inline-emits
* `createComponent({ name })` calls. Components defined this way are
* BaseProps-only — to type Props, parts, or partProps, use a manifest file.
*/
export interface BulkComponentEntry {
files: string;
name: (filename: string) => string;
}
export type ComponentEntry = string | BulkComponentEntry;
export interface GenerateConfig {
/**
* Component sources. Each entry is either:
* - a glob string matching `*-component.ts` manifest files, or
* - a `{ files, name }` object that bulk-defines components from arbitrary files.
*/
components: readonly ComponentEntry[];
/** Path the generator writes the components file to. */
output: string;
}
/**
* Per-target compile configuration. Currently only `react` is shipped, but
* the shape is extensible for `html`/etc.
@@ -84,7 +60,6 @@ export interface CompilerTarget {
export interface CompilerConfig {
files?: readonly string[] | undefined;
generate?: GenerateConfig;
target?: CompilerTarget | undefined;
styles?: StylePipeline | undefined;
}
-58
View File
@@ -1,58 +0,0 @@
declare const __PROPS_BRAND__: unique symbol;
export interface ComponentManifest<
Props extends object = Record<string, never>,
Parts extends readonly string[] = readonly string[],
PartProps extends Partial<Record<Parts[number], object>> = Partial<Record<Parts[number], object>>,
> {
name: string;
parts?: Parts;
dataAttrs?: Record<string, string>;
partProps?: PartProps;
readonly [__PROPS_BRAND__]?: Props;
}
export type InferProps<T> =
T extends ComponentManifest<infer P, readonly string[], Partial<Record<string, object>>> ? P : never;
export type InferParts<T> =
T extends ComponentManifest<object, infer Parts, Partial<Record<string, object>>>
? readonly string[] extends Parts
? never
: Parts[number]
: never;
export type InferPartProps<T, K extends string> =
T extends ComponentManifest<object, readonly string[], infer PartProps>
? K extends keyof PartProps
? PartProps[K]
: never
: never;
/**
* Define a component manifest.
*
* Curried so the `Props` generic can be supplied without disabling inference
* of `Parts` and `PartProps` from the manifest body:
*
* @example
* const Slider = defineComponent<SliderProps>()({
* name: 'Slider',
* parts: SliderParts,
* dataAttrs: SliderDataAttrs,
* });
*
* const Controls = defineComponent()({
* name: 'Controls',
* parts: ControlsParts,
* dataAttrs: ControlsDataAttrs,
* });
*/
export function defineComponent<Props extends object = Record<string, never>>() {
return <
const Parts extends readonly string[] = readonly string[],
const PartProps extends Partial<Record<Parts[number], object>> = Partial<Record<Parts[number], object>>,
>(
manifest: Omit<ComponentManifest<Props, Parts, PartProps>, typeof __PROPS_BRAND__>
): ComponentManifest<Props, Parts, PartProps> => manifest as ComponentManifest<Props, Parts, PartProps>;
}
-159
View File
@@ -1,159 +0,0 @@
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path';
import ts from 'typescript';
import type { BulkComponentEntry, CompilerConfig } from './config';
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;
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<P>()(...) — outer call's expression is the inner call.
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(/\.ts$/, '');
}
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((p) => (isAbsolute(p) ? p : resolve(cwd, p)));
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 emitHeader(entries: readonly ResolvedComponent[]): string {
const manifestLines = entries
.filter((e): e is ManifestComponent => e.kind === 'manifest')
.sort((a, b) => compareImportSpecifiers(a.manifestFrom, b.manifestFrom))
.map((e) => `import ${e.name}Def from '${e.manifestFrom}';`)
.join('\n');
const head = `// AUTO-GENERATED by \`@videojs/compiler\`. DO NOT EDIT.
import { createComponent } from '@videojs/compiler/jsx-runtime';`;
return manifestLines ? `${head}\n\n${manifestLines}` : head;
}
function manifestRef(entry: ResolvedComponent): string {
return entry.kind === 'manifest' ? `${entry.name}Def` : `{ name: '${entry.name}' }`;
}
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 emitComponents(entries: readonly ResolvedComponent[]): string {
return entries.map((e) => `export const ${e.name} = createComponent(${manifestRef(e)});`).join('\n');
}
function emitMetadata(entries: readonly ResolvedComponent[]): string {
const lines = entries.map((e) => ` ${e.name}: ${manifestRef(e)},`);
return `export const COMPONENTS = {\n${lines.join('\n')}\n} as const;
export type Components = typeof COMPONENTS;`;
}
export interface GenerateResult {
outputPath: string;
source: string;
}
export async function generate(config: CompilerConfig): Promise<GenerateResult> {
if (!config.generate) {
throw new Error('@videojs/compiler: generate() requires a `generate` field in the compiler config');
}
const { components, output } = config.generate;
const cwd = process.cwd();
const outputAbsolute = isAbsolute(output) ? output : resolve(cwd, output);
const resolved: ResolvedComponent[] = 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), emitComponents(entries), emitMetadata(entries)].join('\n\n')}\n`;
// Skip the write when contents are unchanged so watch-mode rebuilds don't
// re-trigger themselves.
const existing = existsSync(outputAbsolute) ? readFileSync(outputAbsolute, 'utf8') : null;
if (existing !== source) {
writeFileSync(outputAbsolute, source, 'utf8');
}
return { outputPath: outputAbsolute, source };
}
+9 -8
View File
@@ -12,13 +12,6 @@ export {
react,
type StylePipeline,
} from './config';
export {
type ComponentManifest,
defineComponent,
type InferPartProps,
type InferParts,
type InferProps,
} from './define-component';
export {
compilerDiagnosticToJsonEvent,
type DiagnosticFormat,
@@ -38,6 +31,14 @@ export {
mapLogLevelToString,
shouldUseColor,
} from './diagnostics';
export { type GenerateResult, generate } from './generate';
export { type TailwindMode, type TailwindOptions, tailwind } from './tailwind';
export type { ImportRef, ImportRule } from './transforms/imports';
export {
accessPath,
type JsxChildReplacement,
jsxExpression,
propertyAccess,
type ReplaceJsxChildOptions,
readStringAttribute,
replaceJsxChild,
} from './transforms/jsx';
-2
View File
@@ -1,2 +0,0 @@
export * from './jsx-runtime';
export { jsx as jsxDEV } from './jsx-runtime';
-100
View File
@@ -1,100 +0,0 @@
import type { ComponentManifest, InferPartProps, InferParts, InferProps } from './define-component';
export const VIDEOJS_NODE = Symbol.for('@videojs/node');
export type ComponentType = string | Component<never> | typeof Fragment;
export interface ComponentNode {
readonly [VIDEOJS_NODE]: true;
readonly type: ComponentType;
readonly props: Record<string, unknown>;
readonly key: string | number | null;
}
export interface BaseProps {
className?: string | undefined;
children?: unknown;
}
export interface Component<Props extends object> {
(props: BaseProps & Props): ComponentNode;
readonly $$component: { name: string; part: string | null };
}
type PartComponentProps<M, K extends string> = K extends 'Root'
? InferProps<M>
: [NonNullable<InferPartProps<M, K>>] extends [never]
? Record<string, never>
: NonNullable<InferPartProps<M, K>>;
type CompoundComponent<M> = {
[K in InferParts<M> & string]: Component<PartComponentProps<M, K>>;
};
export type CreateComponentResult<M> = [InferParts<M>] extends [never]
? Component<InferProps<M>>
: CompoundComponent<M>;
function makePart<Props extends object>(name: string, part: string | null): Component<Props> {
const fn = (_props: BaseProps & Props): ComponentNode => {
throw new Error(`@videojs/compiler: <${name}${part ? `.${part}` : ''}> can only be evaluated by the compiler.`);
};
Object.assign(fn, { $$component: { name, part } });
return fn as Component<Props>;
}
export function createComponent<
M extends ComponentManifest<object, readonly string[], Partial<Record<string, object>>>,
>(manifest: M): CreateComponentResult<M> {
const parts = manifest.parts ?? [];
if (parts.length === 0) {
return makePart(manifest.name, null) as CreateComponentResult<M>;
}
const compound: Record<string, Component<never>> = {};
for (const part of parts) {
compound[part] = makePart(manifest.name, part);
}
return compound as CreateComponentResult<M>;
}
function createNode(type: ComponentType, props: Record<string, unknown>, key?: string | number | null): ComponentNode {
return {
[VIDEOJS_NODE]: true,
type,
props,
key: key ?? null,
};
}
export function jsx(type: ComponentType, props: Record<string, unknown>, key?: string | number | null): ComponentNode {
return createNode(type, props, key);
}
export function jsxs(type: ComponentType, props: Record<string, unknown>, key?: string | number | null): ComponentNode {
return createNode(type, props, key);
}
export const Fragment: unique symbol = Symbol.for('@videojs/fragment') as never;
export namespace JSX {
export type Element = ComponentNode;
export interface ElementChildrenAttribute {
children: Record<string, never>;
}
export interface IntrinsicAttributes {
key?: string | number | undefined;
}
export interface IntrinsicElements {
div: BaseProps;
span: BaseProps;
}
}
+1 -14
View File
@@ -43,23 +43,10 @@ export async function loadConfigFile(configPath: string): Promise<LoadedCompiler
if (!config) {
throw new Error(`Config file ${configPath} must export a default compiler config (use \`defineConfig\`).`);
}
return { config: resolveConfigPaths(config, configPath), configPath, configDir: dirname(configPath) };
return { config, configPath, configDir: dirname(configPath) };
}
export async function loadConfig(cwd: string, override: string | undefined): Promise<LoadedCompilerConfig | null> {
const configPath = findConfig(cwd, override);
return configPath ? loadConfigFile(configPath) : null;
}
function resolveConfigPaths(config: CompilerConfig, configPath: string): CompilerConfig {
if (!config.generate) return config;
const base = dirname(configPath);
const { output, components } = config.generate;
return {
...config,
generate: {
components,
output: isAbsolute(output) ? output : resolve(base, output),
},
};
}
+3 -3
View File
@@ -1,8 +1,8 @@
/**
* React-target plugins for `@videojs/compiler`. Houses the framework-pattern
* helpers that lower constrained-JSX skin idioms into React's render-prop
* slot composition idiom. Re-exports `replace` and `wrap` for convenience so
* a config can import everything from one subpath.
* helpers that lower constrained JSX into React-friendly component shapes.
* Re-exports `replace` and `wrap` for convenience so a config can import
* everything from one subpath.
*/
export { type ReplaceOptions, replace } from '../transforms/replace';
+1 -1
View File
@@ -46,7 +46,7 @@ export interface UtilityCss {
variants: readonly Variant[];
/**
* `@property` registrations Tailwind emitted for this utility. These supply
* the typed defaults for `--tw-*` slots referenced (but not set) by the
* the typed defaults for `--tw-*` variables referenced (but not set) by the
* declarations see `emitCss`'s `properties` option.
*/
properties?: readonly PropertyRule[];
@@ -23,7 +23,7 @@ export interface DesignSystem {
* Resolve a `@theme` variable (e.g. `--spacing`, `--color-white`) to its
* value, or `undefined` if the theme doesn't define it. Used to emit a
* self-contained theme block for the variables compiled rules reference.
* Returns `undefined` for `@property`-registered slots like `--tw-*`.
* Returns `undefined` for `@property`-registered variables like `--tw-*`.
*/
resolveThemeVar(name: string): string | undefined;
}
+11 -11
View File
@@ -62,7 +62,7 @@ export interface EmitCssOptions {
/**
* Inline matching CSS custom properties into the values that reference
* them, then drop the declarations themselves. Useful for stripping
* Tailwind's internal `--tw-*` slots from the final output.
* Tailwind's internal `--tw-*` registered variables from the final output.
*
* - `true` inline `--tw-*` (regex `/^--tw-/`).
* - `RegExp` inline any `--name` whose name (excluding the leading
@@ -81,7 +81,7 @@ export interface EmitCssOptions {
* output references but doesn't itself declare, so the CSS resolves without a
* separate Tailwind theme/preflight on the page. Typically
* `design.resolveThemeVar`. Returns `undefined` to leave a variable alone
* (e.g. `@property`-registered `--tw-*` slots).
* (e.g. `@property`-registered `--tw-*` variables).
*/
resolveThemeVar?: (name: string) => string | undefined;
/**
@@ -90,7 +90,7 @@ export interface EmitCssOptions {
*/
themeSelector?: string;
/**
* How to handle Tailwind's `@property`-registered slots (e.g. `--tw-content`,
* How to handle Tailwind's `@property`-registered variables (e.g. `--tw-content`,
* `--tw-shadow`) that the compiled rules reference but never set locally.
* Without this they resolve to nothing and break e.g. `content:
* var(--tw-content)` suppresses the `::after`/`::before` box.
@@ -112,12 +112,12 @@ export interface PropertyDef {
export interface RegisteredPropertiesOptions {
/**
* - `'emit'` emit `@property` rules for referenced slots, preserving
* - `'emit'` emit `@property` rules for referenced variables, preserving
* Tailwind's typed defaults (relies on browser `@property` support).
* - `'inline'` substitute each slot's `initial-value` into the values that
* - `'inline'` substitute each variable's `initial-value` into the values that
* reference it (and drop any `--tw-*` setter declarations), so the output
* is fully self-contained. This is a superset of `inlineVars` for the
* matched slots.
* matched variables.
*/
mode: 'emit' | 'inline';
/** Which property names to handle. Defaults to `inlineVars`'s matcher, else `/^--tw-/`. */
@@ -126,7 +126,7 @@ export interface RegisteredPropertiesOptions {
* Override or supply a property's definition. Receives the name and the
* definition captured from Tailwind's output (if any); return a new
* definition (merged in), or `undefined` to keep the captured one. Lets you
* fix an initial-value or register a slot Tailwind didn't.
* fix an initial-value or register a variable Tailwind didn't.
*/
resolve?: (name: string, captured: PropertyDef | undefined) => PropertyDef | undefined;
}
@@ -148,7 +148,7 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
const hoist = opts.hoist === false ? undefined : opts.hoist;
const inlineVars = normalizeInlineMatcher(opts.inlineVars);
// Registered `@property` (--tw-*) handling. In 'inline' mode the slots'
// Registered `@property` (--tw-*) handling. In 'inline' mode the variables'
// initial-values seed the inline pass as fallbacks (and the matcher widens to
// cover them, even when `inlineVars` was off). In 'emit' mode they're left in
// place to be emitted as `@property` rules.
@@ -264,7 +264,7 @@ function collectDefinedVars(css: string): Set<string> {
}
/*
* Registered `@property` slots
* Registered `@property` variables
* */
/** Aggregate the `@property` defs captured across every rule (first wins). */
@@ -296,7 +296,7 @@ function buildFallbackSetters(
}
/**
* Build `@property` rules for every matching slot the CSS references but
* Build `@property` rules for every matching variable the CSS references but
* doesn't itself declare. Descriptors default to `syntax: "*"` / `inherits:
* false` when a resolved def omits them.
*/
@@ -591,7 +591,7 @@ function applyInline(
// Effective setters, narrowest last: registered `@property` initial-values
// (lowest), then hoist root, then rule-local. Initial-values resolve
// references to slots no rule ever sets (e.g. `content: var(--tw-content)`).
// references to variables no rule ever sets (e.g. `content: var(--tw-content)`).
const setters = new Map<string, string>(fallbackSetters);
for (const [name, value] of rootSetters) setters.set(name, value);
for (const [name, value] of localSetters) setters.set(name, value);
+2 -2
View File
@@ -78,7 +78,7 @@ export interface TailwindOptions {
* Inline matching CSS custom properties into their consumers, dropping
* the matching declarations. Same shape as `EmitCssOptions['inlineVars']`:
*
* - `true` inline `--tw-*` (Tailwind's internal slots).
* - `true` inline `--tw-*` (Tailwind's internal registered variables).
* - `RegExp` inline any `--name` matching.
* - omitted no inlining.
*
@@ -86,7 +86,7 @@ export interface TailwindOptions {
*/
inlineVars?: true | RegExp;
/**
* Handle Tailwind's `@property`-registered slots (`--tw-content`, etc.) that
* Handle Tailwind's `@property`-registered variables (`--tw-content`, etc.) that
* are referenced but never set, so the output isn't broken (e.g.
* `content: var(--tw-content)`). See `RegisteredPropertiesOptions` choose
* `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in),
@@ -94,7 +94,7 @@ describe('decompose — variants', () => {
});
describe('decompose — @property registrations', () => {
it('captures the @property rule Tailwind appends for a slot', () => {
it('captures the @property rule Tailwind appends for a registered variable', () => {
const r = decompose('before:content-["x"]', design);
expect(r).not.toBeNull();
const content = r!.properties?.find((p) => p.name === '--tw-content');
@@ -533,7 +533,7 @@ describe('emitCss — theme variables', () => {
});
});
describe('emitCss — registered @property slots', () => {
describe('emitCss — registered @property variables', () => {
// The `after:absolute` pattern: an `::after` rule that references
// `--tw-content` but never sets it, relying on Tailwind's @property default.
const contentRule = (): CompiledRule => ({
@@ -591,7 +591,7 @@ describe('emitCss — registered @property slots', () => {
);
});
it('honours the match filter (leaves non-matching slots alone)', async () => {
it('honours the match filter (leaves non-matching variables alone)', async () => {
const out = await emitCss({
rules: [contentRule()],
properties: { mode: 'inline', match: /^--brand-/ },
@@ -600,7 +600,7 @@ describe('emitCss — registered @property slots', () => {
expect(out.css).toMatch(/var\(--tw-content\)/);
});
it('leaves slots untouched when no properties option is given (back-compat)', async () => {
it('leaves variables untouched when no properties option is given (back-compat)', async () => {
const out = await emitCss({ rules: [contentRule()] });
if (out.kind !== 'merged') throw new Error('expected merged');
expect(out.css).toMatch(/var\(--tw-content\)/);
+21 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { compile, type ReactTargetOptions, react } from '..';
import { accessPath, compile, jsxExpression, type ReactTargetOptions, react, replaceJsxChild } from '..';
import { parse } from '../ast';
import { anyTag, byTag, hasChild } from '../matchers';
import { addProp, childAsProp, replace, wrap } from '../react';
@@ -53,14 +53,14 @@ describe('compile (transformImports — bare-string rule)', () => {
describe('compile (transformImports — function rule)', () => {
it('rewrites per-identifier source and bucket-merges by resolved target', async () => {
const source = `import { PlayButton, MuteButton } from '@videojs/core/components';\nconst _ = [PlayButton, MuteButton];`;
const source = `import { Alpha, Beta } from '@fixture/components';\nconst _ = [Alpha, Beta];`;
const { code } = await compileReact(source, {
imports: {
'@videojs/core/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }),
'@fixture/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }),
},
});
expect(code).toContain(`import { PlayButton } from "./ui/playbutton"`);
expect(code).toContain(`import { MuteButton } from "./ui/mutebutton"`);
expect(code).toContain(`import { Alpha } from "./ui/alpha"`);
expect(code).toContain(`import { Beta } from "./ui/beta"`);
});
it('renames identifiers when the rule returns a different `name`', async () => {
@@ -139,6 +139,22 @@ describe('childAsProp', () => {
});
});
describe('replaceJsxChild', () => {
it('replaces matched JSX children with expression helpers', async () => {
const source = `function App({ values }){ return <Container><Token name="poster-image"/></Container>; }`;
const { code } = await compileReact(source, {
transforms: [
replaceJsxChild({
match: byTag('Token'),
replace: (_node, factory) => jsxExpression(factory, accessPath(factory, 'values', 'poster-image')),
}),
],
});
expect(collapse(code)).toContain(collapse(`<Container>{values["poster-image"]}</Container>`));
});
});
describe('addProp', () => {
it('emits a JSX value by default and adds the import', async () => {
const source = `function App(){ return <PlayButton/>; }`;
+3 -3
View File
@@ -20,7 +20,7 @@ import {
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/core/components';
} from '@fixture/components';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -38,7 +38,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/components';
} from '@fixture/icons/components';
import { cn } from '@videojs/utils/style';
import { video as styles } from '../tailwind';
@@ -52,7 +52,7 @@ export interface VideoSkinProps {
export function VideoSkin({ className }: VideoSkinProps) {
return (
<Container data-skin="default-video" className={cn(styles.root, className)}>
<Container data-skin="default-video" className={cn(styles.container, className)}>
<Poster className={styles.poster} />
<BufferingIndicator className={styles.bufferingIndicator.root}>
@@ -1,138 +0,0 @@
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 { generate } from '../generate';
const STUB = 'const defineComponent: any = () => (m: any) => m;';
function setup(): { dir: string; output: string; pattern: string } {
const dir = mkdtempSync(join(tmpdir(), 'videojs-compiler-'));
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-compiler-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('generate (manifest entries)', () => {
it('imports each manifest as `<Name>Def` default-import', async () => {
const { output, pattern } = setup();
await generate({ generate: { components: [pattern], output } });
const source = readFileSync(output, 'utf8');
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('emits createComponent(Def) for each component', async () => {
const { output, pattern } = setup();
await generate({ generate: { components: [pattern], output } });
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 { output, pattern } = setup();
await generate({ generate: { components: [pattern], output } });
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('generate (bulk entries)', () => {
it('inlines createComponent({ name }) for each matched file', async () => {
const { dir, output } = setupBulk();
await generate({
generate: {
components: [{ files: join(dir, 'assets/*.svg'), name: (f) => `${f[0]!.toUpperCase()}${f.slice(1)}Icon` }],
output,
},
});
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 generate({
generate: {
components: [{ files: join(dir, 'assets/*.svg'), name: (f) => `${f[0]!.toUpperCase()}${f.slice(1)}Icon` }],
output,
},
});
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 generate({
generate: {
components: [
{
files: join(dir, 'assets/*.svg'),
name: (f) => {
if (received === null) received = f;
return `${f}Icon`;
},
},
],
output,
},
});
expect(received).not.toContain('.svg');
});
});
@@ -12,7 +12,7 @@ const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx');
/**
* End-to-end smoke test: feed a representative constrained-JSX video skin
* (vendored under `fixtures/`) through `compile()` with the same shape
* `@videojs/react`'s build hook uses, and sanity-check the output's structural
* a React package build hook uses, and sanity-check the output's structural
* shape. Snapshot-style assertions intentionally use `.toContain` over a full
* snapshot to keep the test resilient to incidental whitespace differences
* from the TS printer.
@@ -22,11 +22,11 @@ describe('integration: default/video skin → React', () => {
let code = '';
const imports: Record<string, ImportRule> = {
'@videojs/core/components': (name) => ({
'@fixture/components': (name) => ({
source: `./src/ui/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
name,
}),
'@videojs/icons/components': '@videojs/icons/react',
'@fixture/icons/components': '@fixture/icons/react',
'../tailwind': '@videojs/skins/default/tailwind',
};
@@ -51,7 +51,7 @@ describe('integration: default/video skin → React', () => {
code = result.code;
});
it('rewrites @videojs/core/components imports to per-identifier UI sources', () => {
it('rewrites component imports to per-identifier UI sources', () => {
expect(code).toMatch(/import \{ PlayButton \} from "\.\/src\/ui\/play-button"/);
// MuteButton lives under the volume Popover.Root subtree, which is replaced
// wholesale by VolumePopover — its import is correctly dropped by the
@@ -59,9 +59,9 @@ describe('integration: default/video skin → React', () => {
expect(code).not.toMatch(/import \{ MuteButton \}/);
});
it('rewrites @videojs/icons/components to @videojs/icons/react', () => {
expect(code).toContain('@videojs/icons/react');
expect(code).not.toContain('@videojs/icons/components');
it('rewrites icon component imports', () => {
expect(code).toContain('@fixture/icons/react');
expect(code).not.toContain('@fixture/icons/components');
});
it('substitutes the volume Popover.Root with VolumePopover', () => {
@@ -1,47 +0,0 @@
/** @jsxImportSource @videojs/compiler */
import { PlayButton, Slider, Time } from '@videojs/core/components';
import { describe, it } from 'vitest';
describe('constrained JSX', () => {
it('accepts a single component', () => {
void (<PlayButton className="x" />);
});
it('rejects invalid props on a single component', () => {
// @ts-expect-error - className must be a string
void (<PlayButton className={5} />);
});
it('accepts compound parts inside their root', () => {
void (
<Slider.Root orientation="vertical" thumbAlignment="edge">
<Slider.Track>
<Slider.Fill />
</Slider.Track>
<Slider.Thumb />
</Slider.Root>
);
});
it('rejects invalid compound root props', () => {
// @ts-expect-error - `bogus` is not a valid orientation
void (<Slider.Root orientation="bogus" />);
});
it('rejects invalid Time.Value props', () => {
void (<Time.Value type="current" className="t" />);
// @ts-expect-error - `bogus` not in Time type union
void (<Time.Value type="bogus" />);
});
it('accepts div and span as layout intrinsics', () => {
void (
<div className="row">
<span className="label">hello</span>
</div>
);
// @ts-expect-error - arbitrary HTML attributes (id) are not allowed on layout intrinsics
void (<div id="foo" />);
});
});
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@videojs/compiler",
"noEmit": true,
"isolatedDeclarations": false,
"composite": false,
"incremental": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["**/*.test-d.tsx"]
}
+75
View File
@@ -0,0 +1,75 @@
import { isString } from '@videojs/utils/predicate';
import ts from 'typescript';
import type { JsxElementLike, Matcher } from '../matchers';
const IDENTIFIER_NAME_RE = /^[$A-Z_a-z][$\w]*$/;
export type JsxChildReplacement = ts.JsxChild | readonly ts.JsxChild[];
export interface ReplaceJsxChildOptions {
match: Matcher;
replace: (node: JsxElementLike, factory: ts.NodeFactory) => JsxChildReplacement | undefined;
}
export function replaceJsxChild(options: ReplaceJsxChildOptions): ts.TransformerFactory<ts.SourceFile> {
return (context) => {
const visit: ts.Visitor = (node) => {
if (isJsxElementLike(node) && options.match(node)) {
const replacement = options.replace(node, context.factory);
return replacement ?? node;
}
return ts.visitEachChild(node, visit, context);
};
return (sourceFile) => ts.visitEachChild(sourceFile, visit, context);
};
}
export function jsxExpression(factory: ts.NodeFactory, expression: ts.Expression): ts.JsxExpression {
return factory.createJsxExpression(undefined, expression);
}
export function accessPath(
factory: ts.NodeFactory,
root: string | ts.Expression,
...path: readonly string[]
): ts.Expression {
let expression = isString(root) ? factory.createIdentifier(root) : root;
for (const property of path) {
expression = propertyAccess(factory, expression, property);
}
return expression;
}
export function propertyAccess(factory: ts.NodeFactory, expression: ts.Expression, property: string): ts.Expression {
if (IDENTIFIER_NAME_RE.test(property)) {
return factory.createPropertyAccessExpression(expression, property);
}
return factory.createElementAccessExpression(expression, factory.createStringLiteral(property));
}
export function readStringAttribute(attributes: ts.JsxAttributes, name: string): string | null | undefined {
const attr = attributes.properties.find(
(property) => ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name
);
if (!attr || !ts.isJsxAttribute(attr)) return undefined;
const init = attr.initializer;
if (!init) return '';
if (ts.isStringLiteral(init)) return init.text;
if (ts.isJsxExpression(init) && init.expression) {
if (ts.isStringLiteral(init.expression) || ts.isNoSubstitutionTemplateLiteral(init.expression)) {
return init.expression.text;
}
}
return null;
}
function isJsxElementLike(node: ts.Node): node is JsxElementLike {
return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node);
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@videojs/compiler"
}
}
-2
View File
@@ -4,8 +4,6 @@ export default defineConfig({
entry: {
index: './src/index.ts',
cli: './src/cli.ts',
'jsx-runtime': './src/jsx-runtime.ts',
'jsx-dev-runtime': './src/jsx-dev-runtime.ts',
'plugins/vite': './src/plugins/vite.ts',
'ast/index': './src/ast/index.ts',
'matchers/index': './src/matchers/index.ts',