feat(compiler): add fluent transform authoring api

This commit is contained in:
Rahim
2026-06-25 11:58:51 -07:00
parent 7b05ba4588
commit 86d84bc45a
6 changed files with 509 additions and 161 deletions
+15 -16
View File
@@ -75,35 +75,34 @@ export {
type StyleVisitorResult,
} from './styles';
export {
type CreateHelpers,
type EditHelpers,
type FunctionDeclarationContext,
type FunctionDeclarationEdit,
type FunctionDeclarationEditOptions,
type ConstStatementOptions,
type FunctionPropSpec,
type FunctionSelection,
type ImportOptions,
type ImportReference,
type InterfaceDeclarationContext,
type InterfaceDeclarationEdit,
type InterfaceDeclarationEditOptions,
type InterfacePropertyContext,
type InterfacePropertyEdit,
type InterfacePropertyEditOptions,
type InterfacePropertySelection,
type InterfaceSelection,
type JsxElementContext,
type JsxElementEdit,
type JsxElementEditOptions,
type JsxElementReplacement,
type JsxElementSelection,
type JsxHelpers,
type JsxPropContext,
type JsxPropEditOptions,
type JsxPropsSelection,
type JsxPropsSpec,
type JsxPropValue,
type MatchHelpers,
type MatchPredicate,
type RefHelpers,
type ModuleSelection,
type StatementHelpers,
type StatementSpec,
type TransformCallback,
type TransformHelpers,
type TransformOptions,
type TransformStep,
type TypeHelpers,
transform,
type ValueOnlyIfOptions,
type ValueArrayOptions,
type ValueHelpers,
type ValueReference,
} from './transform';
export {
+92 -67
View File
@@ -34,67 +34,38 @@ export function Skin({ children, className }: SkinProps) {
config: {
plugins: [
transform((code) => {
const cn = code.ref.import('@fixture/style', 'cn');
const BaseSkinProps = code.ref.import('@fixture/react', 'BaseSkinProps', { type: true });
const Button = code.ref.import('@fixture/renderers', 'Button');
const SliderTrack = code.ref.import('@fixture/renderers', 'SliderTrack');
const isString = code.ref.import('@fixture/predicate', 'isString');
const ReactNode = code.ref.import('react', 'ReactNode', { type: true });
const cn = code.import('@fixture/style', 'cn');
const BaseSkinProps = code.import('@fixture/react', 'BaseSkinProps', { type: true });
const Button = code.import('@fixture/renderers', 'Button');
const SliderTrack = code.import('@fixture/renderers', 'SliderTrack');
const isString = code.import('@fixture/predicate', 'isString');
const ReactNode = code.import('react', 'ReactNode', { type: true });
return [
code.edit.import.rewrite({ '@fixture/core': '@fixture/react' }),
code.edit.jsx.element({
when: code.match.jsx.tag('Tooltip.Trigger'),
transform: code.edit.jsx.moveChildToProp('render'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Controls.Root'),
transform: code.edit.jsx.addProp('data-controls', ''),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Poster'),
transform: () => {
return code.create.jsx.renderIf(
'poster',
code.create.jsx.element('Poster', {
src: code.create.value.onlyIf({ value: 'poster', condition: isString }),
})
);
},
}),
code.edit.jsx.element({
when: code.match.jsx.tag('PlayButton'),
transform: code.edit.jsx.addProp('render', code.create.jsx.element(Button)),
}),
code.edit.jsx.element({
when: code.match.jsx.tag(/^Slider\.(Track)$/),
transform: (element, context) => {
const part = context.tagName.split('.')[1];
return part === 'Track' ? code.edit.jsx.replaceTag(SliderTrack)(element, context) : undefined;
},
}),
code.edit.jsx.prop({
when: code.match.all(code.match.jsx.prop('className'), code.match.value.array()),
transform: ({ value }) => code.create.value.call(cn, code.create.value.arrayItems(value)),
}),
code.edit.interface.declaration({
when: code.match.interface.name('SkinProps'),
transform: code.edit.interface.extends(BaseSkinProps),
}),
code.edit.interface.property({
when: code.match.all(code.match.interface.name(/Props$/), code.match.interface.property('children')),
transform: code.edit.interface.setType(() =>
code.create.type.union(code.create.type.named(ReactNode), code.create.type.undefined())
),
}),
code.edit.function.declaration({
when: code.match.function.name('Skin'),
transform: code.edit.function.addProps(['poster', { name: 'rest', spread: true }]),
}),
code.imports({ '@fixture/core': '@fixture/react' }),
code.jsx.element('Tooltip.Trigger').childToProp('render'),
code.jsx.element('Controls.Root').addProp('data-controls', ''),
code.jsx.element('Container').spreadProps('rest'),
code.jsx.element('Poster').replace(() =>
code.jsx.if(
'poster',
code.jsx.create('Poster', {
src: code.value.when('poster', isString),
})
)
),
code.jsx.element('PlayButton').addProp('render', code.jsx.create(Button)),
code.jsx.element(/^Slider\.(Track)$/).replace(SliderTrack),
code.jsx
.props('className')
.where(code.value.isArray())
.replace(({ value }) => code.value.call(cn, code.value.arrayItems(value))),
code.interface('SkinProps').extends(BaseSkinProps),
code
.interface(/Props$/)
.property('children')
.setType(() => code.type.union(code.type.named(ReactNode), code.type.undefined())),
code.function('Skin').addProps(['poster', { name: 'rest', spread: true }]),
];
}),
],
@@ -121,6 +92,67 @@ export function Skin({ children, className }: SkinProps) {
expect(compact(result.code)).toContain(compact('<Controls.Root data-controls="">'));
expect(compact(result.code)).toContain(compact('<Tooltip.Trigger render={<PlayButton'));
});
it('materializes default lazy imports', async () => {
const source = `export function Skin(){ return <Action/>; }`;
const result = await compile(source, {
config: {
plugins: [
transform((code) => {
const Button = code.import('@fixture/renderers', 'Button', { default: true });
return [code.jsx.element('Action').addProp('render', code.jsx.create(Button))];
}),
],
},
});
expect(result.code).toContain('import Button from "@fixture/renderers"');
expect(compact(result.code)).toContain(compact('<Action render={<Button />} />'));
});
it('adds module constants and function-scope statements', async () => {
const source = `import { Container } from '@fixture/core';
export function DefaultVideoSkin({ poster }) {
return <Container />;
}
`;
const result = await compile(source, {
config: {
plugins: [
transform((code) => {
const usePoster = code.import('@fixture/react', 'usePoster');
return [
code.module.prepend(
code.statement.const('TOP_ACTIONS', code.value.array([code.value.string('togglePaused')]), {
asConst: true,
export: true,
})
),
code
.function('DefaultVideoSkin')
.prepend(code.statement.const('posterState', code.value.call(usePoster, ['poster']))),
code
.function('DefaultVideoSkin')
.beforeReturn(code.statement.const('ready', code.value.call('Boolean', ['posterState']))),
];
}),
],
},
});
const compactCode = compact(result.code);
expect(result.code.indexOf('import { Container }')).toBeLessThan(result.code.indexOf('export const TOP_ACTIONS'));
expect(result.code).toContain('import { usePoster } from "@fixture/react"');
expect(compactCode).toContain(compact('export const TOP_ACTIONS = ["togglePaused"] as const;'));
expect(compactCode).toContain(compact('const posterState = usePoster(poster);'));
expect(compactCode).toContain(compact('const ready = Boolean(posterState);return <Container />;'));
});
});
describe('compileProject', () => {
@@ -137,14 +169,7 @@ describe('compileProject', () => {
entryFileNames: '[name].tsx',
banner: '// Generated\n',
},
plugins: [
transform((code) => [
code.edit.jsx.element({
when: code.match.jsx.tag('Root'),
transform: code.edit.jsx.addProp('data-root', ''),
}),
]),
],
plugins: [transform((code) => [code.jsx.element('Root').addProp('data-root', '')])],
},
{ configDir: workDir }
);
+335 -9
View File
@@ -8,11 +8,17 @@ import { type ImportRewriteOptions, type ImportRule, transformImports } from './
export interface ImportReference {
readonly source: string;
readonly name: string;
readonly default?: boolean | undefined;
readonly type?: boolean | undefined;
}
export interface ImportOptions {
default?: boolean | undefined;
type?: boolean | undefined;
}
export interface RefHelpers {
import(source: string, name: string, options?: { type?: boolean | undefined }): ImportReference;
import(source: string, name: string, options?: ImportOptions): ImportReference;
}
export type ValueReference = string | ImportReference | ts.Expression;
@@ -48,11 +54,14 @@ export interface MatchHelpers {
export interface CreateHelpers {
value: {
and(left: ValueReference, right: ts.Expression): ts.BinaryExpression;
array(items: readonly ValueReference[], options?: ValueArrayOptions): ts.ArrayLiteralExpression | ts.AsExpression;
arrayItems(value: ts.Expression): ts.Expression[];
call(callee: ValueReference, args: readonly ts.Expression[]): ts.CallExpression;
call(callee: ValueReference, args: readonly ValueReference[]): ts.CallExpression;
conditional(test: ts.Expression, whenTrue: ts.Expression, whenFalse: ts.Expression): ts.ConditionalExpression;
identifier(value: string | ImportReference): ts.Identifier;
number(value: number): ts.NumericLiteral;
onlyIf(options: ValueOnlyIfOptions): ts.ConditionalExpression;
string(value: string): ts.StringLiteral;
undefined(): ts.Identifier;
};
jsx: {
@@ -93,11 +102,98 @@ export interface EditHelpers {
};
}
export interface ValueHelpers {
and(left: ValueReference, right: ts.Expression): ts.BinaryExpression;
array(items: readonly ValueReference[], options?: ValueArrayOptions): ts.ArrayLiteralExpression | ts.AsExpression;
arrayItems(value: ts.Expression): ts.Expression[];
call(callee: ValueReference, args: readonly ValueReference[]): ts.CallExpression;
conditional(test: ts.Expression, whenTrue: ts.Expression, whenFalse: ts.Expression): ts.ConditionalExpression;
identifier(value: string | ImportReference): ts.Identifier;
isArray(): MatchPredicate;
number(value: number): ts.NumericLiteral;
string(value: string): ts.StringLiteral;
when(
value: ValueReference,
condition: ValueReference,
fallback?: ValueReference | undefined
): ts.ConditionalExpression;
undefined(): ts.Identifier;
}
export interface JsxHelpers {
create(tag: string | ImportReference, props?: JsxPropsSpec): ts.JsxSelfClosingElement;
element(tag: string | RegExp): JsxElementSelection;
if(test: ValueReference, element: ts.Expression): ts.JsxExpression;
props(name: string): JsxPropsSelection;
}
export interface ValueArrayOptions {
asConst?: boolean | undefined;
}
export interface JsxElementSelection {
addProp(name: string, value?: JsxPropValue): CompilerTransform;
childToProp(prop: string): CompilerTransform;
replace(replacement: string | ImportReference | JsxElementReplacement): CompilerTransform;
spreadProps(value: ValueReference): CompilerTransform;
}
export type JsxElementReplacement = (context: JsxElementContext) => ts.Node | undefined;
export interface JsxPropsSelection {
replace(transform: (context: JsxPropContext) => ts.Expression | undefined): CompilerTransform;
where(predicate: MatchPredicate): JsxPropsSelection;
}
export interface TypeHelpers {
named(value: string | ImportReference): ts.TypeReferenceNode;
union(...types: readonly ts.TypeNode[]): ts.UnionTypeNode;
undefined(): ts.KeywordTypeNode;
}
export interface InterfaceSelection {
extends(value: string | ImportReference): CompilerTransform;
property(name: string): InterfacePropertySelection;
}
export interface InterfacePropertySelection {
setType(type: (context: InterfacePropertyContext) => ts.TypeNode): CompilerTransform;
}
export interface FunctionSelection {
addProps(props: readonly FunctionPropSpec[], parameterIndex?: number): CompilerTransform;
append(statements: StatementSpec): CompilerTransform;
beforeReturn(statements: StatementSpec): CompilerTransform;
prepend(statements: StatementSpec): CompilerTransform;
}
export interface ModuleSelection {
append(statements: StatementSpec): CompilerTransform;
prepend(statements: StatementSpec): CompilerTransform;
}
export interface StatementHelpers {
const(name: string, initializer: ValueReference, options?: ConstStatementOptions): ts.VariableStatement;
}
export interface ConstStatementOptions {
asConst?: boolean | undefined;
export?: boolean | undefined;
type?: ts.TypeNode | undefined;
}
export type StatementSpec = ts.Statement | readonly ts.Statement[];
export interface TransformHelpers {
ref: RefHelpers;
match: MatchHelpers;
create: CreateHelpers;
edit: EditHelpers;
import(source: string, name: string, options?: ImportOptions): ImportReference;
imports(rules: Record<string, ImportRule>): CompilerTransform;
function(name: string | RegExp): FunctionSelection;
interface(name: string | RegExp): InterfaceSelection;
jsx: JsxHelpers;
module: ModuleSelection;
statement: StatementHelpers;
type: TypeHelpers;
value: ValueHelpers;
}
export type TransformStep = CompilerTransform | CompilerPlugin | null | undefined | false;
@@ -221,6 +317,7 @@ function createTransformHelpers(refs: MutableImportReference[], context: Compile
[IMPORT_REF_SYMBOL]: true,
source,
name,
default: options.default,
type: options.type,
used: false,
} as MutableImportReference;
@@ -232,8 +329,146 @@ function createTransformHelpers(refs: MutableImportReference[], context: Compile
const match = createMatchHelpers();
const create = createCreateHelpers();
const edit = createEditHelpers(context);
const statement = createStatementHelpers();
const value = createValueHelpers(match, create);
return { ref, match, create, edit };
return {
import: ref.import,
imports: (rules) => edit.import.rewrite(rules),
function: (name) => createFunctionSelection(name, match, edit),
interface: (name) => createInterfaceSelection(name, match, edit),
jsx: createJsxHelpers(match, create, edit),
module: createModuleSelection(),
statement,
type: create.type,
value,
};
}
function createValueHelpers(match: MatchHelpers, create: CreateHelpers): ValueHelpers {
return {
and: create.value.and,
array: create.value.array,
arrayItems: create.value.arrayItems,
call: create.value.call,
conditional: create.value.conditional,
identifier: create.value.identifier,
isArray: match.value.array,
number: create.value.number,
string: create.value.string,
when(value, condition, fallback) {
return create.value.onlyIf({ value, condition, ...(fallback === undefined ? {} : { fallback }) });
},
undefined: create.value.undefined,
};
}
function createStatementHelpers(): StatementHelpers {
return {
const(name, initializer, options = {}) {
const modifiers = options.export ? [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)] : undefined;
const expression = options.asConst ? asConst(valueFromReference(initializer)) : valueFromReference(initializer);
return ts.factory.createVariableStatement(
modifiers,
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier(name),
undefined,
options.type,
expression
),
],
ts.NodeFlags.Const
)
);
},
};
}
function createModuleSelection(): ModuleSelection {
return {
append: (statements) => editModuleStatements('append', statements),
prepend: (statements) => editModuleStatements('prepend', statements),
};
}
function createJsxHelpers(match: MatchHelpers, create: CreateHelpers, edit: EditHelpers): JsxHelpers {
return {
create: create.jsx.element,
element: (tag) => createJsxElementSelection(tag, match, edit),
if: create.jsx.renderIf,
props: (name) => createJsxPropsSelection(name, match, edit),
};
}
function createJsxElementSelection(tag: string | RegExp, match: MatchHelpers, edit: EditHelpers): JsxElementSelection {
const when = match.jsx.tag(tag);
return {
addProp: (name, value) => edit.jsx.element({ when, transform: edit.jsx.addProp(name, value) }),
childToProp: (prop) => edit.jsx.element({ when, transform: edit.jsx.moveChildToProp(prop) }),
replace(replacement) {
const transform: JsxElementEdit =
typeof replacement === 'function'
? (_element, context) => replacement(context)
: edit.jsx.replaceTag(replacement);
return edit.jsx.element({ when, transform });
},
spreadProps: (value) => edit.jsx.element({ when, transform: edit.jsx.addPropsSpread(value) }),
};
}
function createJsxPropsSelection(
name: string,
match: MatchHelpers,
edit: EditHelpers,
predicates: readonly MatchPredicate[] = []
): JsxPropsSelection {
const when = match.all(match.jsx.prop(name), ...predicates);
return {
replace: (transform) => edit.jsx.prop({ when, transform }),
where: (predicate) => createJsxPropsSelection(name, match, edit, [...predicates, predicate]),
};
}
function createInterfaceSelection(name: string | RegExp, match: MatchHelpers, edit: EditHelpers): InterfaceSelection {
const when = match.interface.name(name);
return {
extends: (value) => edit.interface.declaration({ when, transform: edit.interface.extends(value) }),
property: (property) => ({
setType: (type) =>
edit.interface.property({
when: match.all(when, match.interface.property(property)),
transform: edit.interface.setType(type),
}),
}),
};
}
function createFunctionSelection(name: string | RegExp, match: MatchHelpers, edit: EditHelpers): FunctionSelection {
const when = match.function.name(name);
return {
addProps: (props, parameterIndex) =>
edit.function.declaration({ when, transform: edit.function.addProps(props, parameterIndex) }),
append: (statements) =>
edit.function.declaration({
when,
transform: ({ function: declaration, factory }) => editFunctionBody(declaration, 'append', statements, factory),
}),
beforeReturn: (statements) =>
edit.function.declaration({
when,
transform: ({ function: declaration, factory }) =>
editFunctionBody(declaration, 'beforeReturn', statements, factory),
}),
prepend: (statements) =>
edit.function.declaration({
when,
transform: ({ function: declaration, factory }) =>
editFunctionBody(declaration, 'prepend', statements, factory),
}),
};
}
function createMatchHelpers(): MatchHelpers {
@@ -291,12 +526,16 @@ function createCreateHelpers(): CreateHelpers {
right
);
},
array(items, options = {}) {
const array = ts.factory.createArrayLiteralExpression(items.map(valueFromReference));
return options.asConst ? asConst(array) : array;
},
arrayItems(value) {
if (!ts.isArrayLiteralExpression(value)) return [];
return value.elements.filter((item): item is ts.Expression => !ts.isSpreadElement(item));
},
call(callee, args) {
return ts.factory.createCallExpression(valueFromReference(callee), undefined, [...args]);
return ts.factory.createCallExpression(valueFromReference(callee), undefined, args.map(valueFromReference));
},
conditional(test, whenTrue, whenFalse) {
return ts.factory.createConditionalExpression(
@@ -311,6 +550,9 @@ function createCreateHelpers(): CreateHelpers {
if (isImportReference(value)) value.used = true;
return ts.factory.createIdentifier(typeof value === 'string' ? value : value.name);
},
number(value) {
return ts.factory.createNumericLiteral(value);
},
onlyIf(options) {
const value = valueFromReference(options.value);
return ts.factory.createConditionalExpression(
@@ -323,6 +565,9 @@ function createCreateHelpers(): CreateHelpers {
: valueFromReference(options.fallback)
);
},
string(value) {
return ts.factory.createStringLiteral(value);
},
undefined() {
return ts.factory.createIdentifier('undefined');
},
@@ -589,7 +834,12 @@ function materializeImportRefs(refs: readonly MutableImportReference[], context:
let result = sourceFile;
for (const ref of refs) {
if (!ref.used) continue;
result = addNamedImport(result, { source: ref.source, name: ref.name, type: ref.type }, factory, context);
result = addNamedImport(
result,
{ source: ref.source, name: ref.name, default: ref.default, type: ref.type },
factory,
context
);
}
return result;
};
@@ -608,6 +858,82 @@ function pipeTransforms(transforms: readonly CompilerTransform[]): CompilerTrans
};
}
function editModuleStatements(position: 'prepend' | 'append', statements: StatementSpec): CompilerTransform {
const nextStatements = normalizeStatements(statements);
return (context) => {
const factory = context.factory;
return (sourceFile) => {
if (nextStatements.length === 0) return sourceFile;
if (position === 'append') {
return factory.updateSourceFile(sourceFile, [...sourceFile.statements, ...nextStatements]);
}
let insertIndex = 0;
for (let i = 0; i < sourceFile.statements.length; i++) {
if (ts.isImportDeclaration(sourceFile.statements[i]!)) insertIndex = i + 1;
}
return factory.updateSourceFile(sourceFile, [
...sourceFile.statements.slice(0, insertIndex),
...nextStatements,
...sourceFile.statements.slice(insertIndex),
]);
};
};
}
function editFunctionBody(
declaration: ts.FunctionDeclaration,
position: 'prepend' | 'append' | 'beforeReturn',
statements: StatementSpec,
factory: ts.NodeFactory
): ts.FunctionDeclaration | undefined {
if (!declaration.body) return undefined;
const nextStatements = normalizeStatements(statements);
if (nextStatements.length === 0) return undefined;
let bodyStatements: ts.Statement[];
if (position === 'prepend') {
bodyStatements = [...nextStatements, ...declaration.body.statements];
} else if (position === 'append') {
bodyStatements = [...declaration.body.statements, ...nextStatements];
} else {
const returnIndex = declaration.body.statements.findIndex(ts.isReturnStatement);
const insertIndex = returnIndex >= 0 ? returnIndex : declaration.body.statements.length;
bodyStatements = [
...declaration.body.statements.slice(0, insertIndex),
...nextStatements,
...declaration.body.statements.slice(insertIndex),
];
}
return factory.updateFunctionDeclaration(
declaration,
declaration.modifiers,
declaration.asteriskToken,
declaration.name,
declaration.typeParameters,
declaration.parameters,
declaration.type,
factory.updateBlock(declaration.body, bodyStatements)
);
}
function normalizeStatements(statements: StatementSpec): ts.Statement[] {
return isStatementArray(statements) ? [...statements] : [statements];
}
function isStatementArray(statements: StatementSpec): statements is readonly ts.Statement[] {
return Array.isArray(statements);
}
function asConst(expression: ts.Expression): ts.AsExpression {
return ts.factory.createAsExpression(expression, ts.factory.createTypeReferenceNode('const'));
}
function liftSingleChildToProp(
element: JsxElementLike,
prop: string,
+26 -2
View File
@@ -4,6 +4,7 @@ import { resolveRelative } from './imports';
export interface AddImportRef {
source: string;
name: string;
default?: boolean | undefined;
type?: boolean | undefined;
}
@@ -36,6 +37,25 @@ export function addNamedImport(
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (stmt.moduleSpecifier.text !== target) continue;
const clause = stmt.importClause;
if (ref.default) {
if (!clause) continue;
if (clause.isTypeOnly !== Boolean(ref.type)) continue;
if (clause.name?.text === ref.name) return sourceFile;
if (clause.name) continue;
const updated = factory.updateImportDeclaration(
stmt,
stmt.modifiers,
factory.createImportClause(clause.isTypeOnly, factory.createIdentifier(ref.name), clause.namedBindings),
stmt.moduleSpecifier,
stmt.attributes
);
return factory.updateSourceFile(
sourceFile,
sourceFile.statements.map((s) => (s === stmt ? updated : s))
);
}
if (!clause?.namedBindings || !ts.isNamedImports(clause.namedBindings)) continue;
if (clause.isTypeOnly && !ref.type) continue;
if (clause.namedBindings.elements.some((e) => e.name.text === ref.name)) {
@@ -70,8 +90,12 @@ export function addNamedImport(
undefined,
factory.createImportClause(
Boolean(ref.type),
undefined,
factory.createNamedImports([factory.createImportSpecifier(false, undefined, factory.createIdentifier(ref.name))])
ref.default ? factory.createIdentifier(ref.name) : undefined,
ref.default
? undefined
: factory.createNamedImports([
factory.createImportSpecifier(false, undefined, factory.createIdentifier(ref.name)),
])
),
factory.createStringLiteral(target)
);
+37 -59
View File
@@ -6,8 +6,6 @@ const ICON_COMPONENTS_SOURCE = '@videojs/icons/components';
const SOURCE_TAILWIND_SOURCE = './tailwind/video.tailwind';
const GENERATED_TAILWIND_SOURCE = '@videojs/skins/default/tailwind/video.tailwind';
const BUTTON_RENDER_TARGET = /Button$/;
export default defineConfig({
input: {
'default-video': '../skins/src/default/video.skin.tsx',
@@ -20,74 +18,54 @@ export default defineConfig({
plugins: [
transform((code) => {
// Types
const BaseVideoSkinProps = code.ref.import('./src/presets/types', 'BaseVideoSkinProps', { type: true });
const ReactNode = code.ref.import('react', 'ReactNode', { type: true });
const BaseVideoSkinProps = code.import('./src/presets/types', 'BaseVideoSkinProps', { type: true });
const ReactNode = code.import('react', 'ReactNode', { type: true });
// Utils
const cn = code.ref.import('@videojs/utils/style', 'cn');
const isString = code.ref.import('@videojs/utils/predicate', 'isString');
const isRenderProp = code.ref.import('@/utils/use-render', 'isRenderProp');
const cn = code.import('@videojs/utils/style', 'cn');
const isString = code.import('@videojs/utils/predicate', 'isString');
const isRenderProp = code.import('@/utils/use-render', 'isRenderProp');
// Components
const Poster = code.ref.import('@/ui/poster', 'Poster');
const Poster = code.import('@/ui/poster', 'Poster');
return [
code.edit.import.rewrite({
code.imports({
[CORE_COMPONENTS_SOURCE]: coreComponentImport,
[ICON_COMPONENTS_SOURCE]: '@/icons',
[SOURCE_TAILWIND_SOURCE]: GENERATED_TAILWIND_SOURCE,
}),
tailwind({ mode: 'preserve' }),
code.edit.jsx.element({
when: code.match.jsx.tag(/^(Tooltip|Popover)\.Trigger$/),
transform: code.edit.jsx.moveChildToProp('render'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Controls.Root'),
transform: code.edit.jsx.addProp('data-controls', ''),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Text'),
transform: code.edit.jsx.replaceTag('span'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Poster'),
transform: () => {
return code.create.jsx.renderIf(
'poster',
code.create.jsx.element(Poster, {
src: code.create.value.onlyIf({ value: 'poster', condition: isString }),
render: code.create.value.onlyIf({ value: 'poster', condition: isRenderProp }),
})
);
},
}),
code.edit.jsx.element({
when: code.match.jsx.tag(BUTTON_RENDER_TARGET),
transform: code.edit.jsx.addProp('type', 'button'),
}),
code.edit.jsx.prop({
when: code.match.all(code.match.jsx.prop('className'), code.match.value.array()),
transform: ({ value }) => code.create.value.call(cn, code.create.value.arrayItems(value)),
}),
code.edit.interface.property({
when: code.match.all(code.match.interface.name(/Props$/), code.match.interface.property('children')),
transform: code.edit.interface.setType(() =>
code.create.type.union(code.create.type.named(ReactNode), code.create.type.undefined())
),
}),
code.edit.interface.declaration({
when: code.match.interface.name('DefaultVideoSkinProps'),
transform: code.edit.interface.extends(BaseVideoSkinProps),
}),
code.edit.function.declaration({
when: code.match.function.name('DefaultVideoSkin'),
transform: code.edit.function.addProps(['poster', { name: 'rest', spread: true }]),
}),
code
.interface(/Props$/)
.property('children')
.setType(() => code.type.union(code.type.named(ReactNode), code.type.undefined())),
code.interface('DefaultVideoSkinProps').extends(BaseVideoSkinProps),
code.jsx.element(/^(Tooltip|Popover)\.Trigger$/).childToProp('render'),
code.jsx.element('Controls.Root').addProp('data-controls', ''),
code.jsx.element('Container').spreadProps('rest'),
code.jsx.element('Text').replace('span'),
code.jsx.element('Poster').replace(() =>
code.jsx.if(
'poster',
code.jsx.create(Poster, {
src: code.value.when('poster', isString),
render: code.value.when('poster', isRenderProp),
})
)
),
code.jsx
.props('className')
.where(code.value.isArray())
.replace(({ value }) => code.value.call(cn, code.value.arrayItems(value))),
code.function('DefaultVideoSkin').addProps(['poster', { name: 'rest', spread: true }]),
];
}),
],
@@ -105,15 +105,11 @@ describe('skins compiler config', () => {
)
);
expect(compactCode).toContain(compact('<Tooltip.Trigger render={<PlayButton'));
expect(compactCode).toContain(compact('<PlayButton className={cn(iconButton, playIcon.button)} type="button">'));
expect(compactCode).toContain(
compact('<SeekButton seconds={-SEEK_TIME} className={cn(iconButton, seek.button)} type="button">')
);
expect(compactCode).toContain(compact('<PlayButton className={cn(iconButton, playIcon.button)}>'));
expect(compactCode).toContain(compact('<SeekButton seconds={-SEEK_TIME} className={cn(iconButton, seek.button)}>'));
expect(compactCode).toContain(compact('<SeekIcon className={cn(icon, iconFlipped)} />'));
expect(compactCode).toContain(compact('<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>'));
expect(compactCode).toContain(
compact('<SeekButton seconds={SEEK_TIME} className={cn(iconButton, seek.button)} type="button">')
);
expect(compactCode).toContain(compact('<SeekButton seconds={SEEK_TIME} className={cn(iconButton, seek.button)}>'));
expect(compactCode).toContain(compact('<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>'));
expect(compactCode).not.toContain('iconContainer');
expect(compactCode).not.toContain('<Text');
@@ -123,7 +119,7 @@ describe('skins compiler config', () => {
expect(compactCode).toContain(compact('<FeatureAvailability is="volume" except="unsupported">'));
expect(compactCode).toContain(compact('<Popover.Root openOnHover delay={200} closeDelay={100} side="top">'));
expect(compactCode).toContain(compact('<Popover.Trigger render={<MuteControl />} />'));
expect(compactCode).toContain(compact('<MuteButton className={cn(iconButton, muteIcon.button)} type="button">'));
expect(compactCode).toContain(compact('<MuteButton className={cn(iconButton, muteIcon.button)}>'));
expect(compactCode).toContain(compact('<VolumeOffIcon className={cn(icon, muteIcon.volumeOff)} />'));
expect(compactCode).toContain(compact('<Popover.Popup className={cn(popup.popover, popup.volume)}>'));
expect(compactCode).toContain(