mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(compiler): add fluent transform authoring api
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user