feat(compiler): improve skin transform dx

This commit is contained in:
Rahim
2026-06-22 16:36:39 -07:00
parent fcee71e1eb
commit aefa6c39c8
10 changed files with 793 additions and 178 deletions
+65 -45
View File
@@ -75,7 +75,7 @@ Purpose: create one canonical source skin tree that can be compiled later.
Status:
- Started with `packages/skins/src/default/video.skin.tsx`.
- Current scaffold covers container, children slot, controls root/groups, play button, seek buttons, time slider without thumbnail preview, cast, AirPlay, PiP, fullscreen button, hotkeys, gestures, and status announcer.
- Current scaffold covers container, children slot, poster slot, buffering indicator, error dialog shell, controls root/groups, play button, seek buttons, time slider without thumbnail preview, cast, AirPlay, PiP, fullscreen button, hotkeys, gestures, and status announcer.
- Current scaffold typechecks and does not change `@videojs/skins` package output.
- Core manifests now use `parts: { Part: defineComponentPart<Props>() }` instead of `parts: [...]` plus `partProps`.
- Core JSX runtime now infers props from every part descriptor, including `Root`.
@@ -88,9 +88,9 @@ Gap inventory for the remaining `default/video` port:
| Tooltip trigger composition | React uses `render={<PlayButton />}` to avoid nested buttons. | React lowering | Source can use `<Tooltip.Trigger><PlayButton /></Tooltip.Trigger>`; React generator must lift the single child into `render`. Same for seek/fullscreen/cast/airplay/pip and popover triggers. |
| Controls marker | Manual React sets `data-controls=""` on `Controls.Root` for Tailwind `has-*` selectors. | Target lowering | Source JSX cannot set `data-*`; React/HTML lowering should add the marker to `Controls.Root`. |
| Cast, AirPlay, PiP buttons | Manual React repeats tooltip + button + icon patterns. | Existing Core parts | Added to source scaffold; still depends on trigger lowering for generated React parity. |
| Poster | Manual React skin prop supports `poster?: string | renderProp`. Core `Poster` has state only; source JSX cannot pass React render props. | Needs design decision | Options: model target-neutral poster source props, use a named `Slot`, or keep poster handling in React generated wrapper. |
| Buffering indicator shell | Manual React uses `BufferingIndicator render` with an outer root and inner surface container. | Core part candidate | Consider changing `BufferingIndicator` to compound parts such as `Root` and `Container`, or define a target lowering wrapper. |
| Error dialog shell | Manual React uses `ErrorDialog.Popup` plus raw dialog/content/actions wrappers. | Core part candidate | Consider adding semantic parts such as `Panel`, `Content`, and `Actions` to `ErrorDialog` rather than source-local wrappers. |
| Poster | Manual React skin prop supports `poster?: string | renderProp`. Core `Poster` has state only; source JSX cannot pass React render props. | Done via React lowering | Source uses a neutral `<Poster />` marker. React lowering extends `BaseVideoSkinProps`, destructures `poster`, spreads remaining container props, and emits the conditional React `Poster` with string/render-prop guards. |
| Buffering indicator shell | Manual React uses `BufferingIndicator render` with an outer root and inner surface container. | Deferred parity gap | Generated slice keeps the root `BufferingIndicator` and spinner only; the inner surface container is visual-only and should be reintroduced only as a deliberate Core part or skin abstraction. |
| Error dialog shell | Manual React uses `ErrorDialog.Popup` plus raw dialog/content/actions wrappers. | Done without new primitives | `Popup` is the dialog content primitive. The generated source uses `Popup`, `Title`, `Description`, and `Close` only, with `error.popup` / `error.close` skin tokens carrying the layout. Layout-only `Panel`/`Content`/`Actions` parts were rejected. |
| Overlay | Manual React uses a raw `<div className={overlay} />`. | Needs design decision | Could become a Core `Overlay` component, target-lowered visual element, or a constrained skin-local abstraction if we allow those later. |
| Thumbnail preview shell | Manual React wraps `Slider.Thumbnail`, pointer time, and spinner in a raw thumbnail root. | Core part candidate | Existing `Slider.Thumbnail` is the thumbnail image component, not the visual preview shell. Need a semantic wrapper/part before source can represent this without `div`. |
| Volume popover | Manual React conditionally skips the popover when volume is unsupported. | React lowering or Core component | Existing `MuteButton`, `Popover`, and `VolumeSlider` can express most structure, but the unsupported branch currently depends on React state selection. |
@@ -100,7 +100,7 @@ Gap inventory for the remaining `default/video` port:
Recommended next coding order:
- Define React lowering for child-as-trigger composition and `Controls.Root` marker.
- Resolve one structural Core-part gap at a time, starting with ErrorDialog or BufferingIndicator because they are small and isolated.
- Resolve one structural gap at a time; next candidates are poster, overlay, thumbnail shell, or input feedback shells.
Steps:
- Use `packages/react/src/presets/video/skin.tailwind.tsx` as the structural reference for `default/video`.
@@ -119,7 +119,6 @@ Expected gap areas:
- React render wrappers for buttons and slider pieces.
- Dynamic settings menu option lists that currently use React hooks.
- Poster render/source handling.
- Error dialog inner content/action shells.
- Buffering and input-feedback inner shells.
- Thumbnail wrappers and status/icon group wrappers.
@@ -148,15 +147,16 @@ Purpose: compile the Phase 1 source skin into a React Tailwind module that match
Status:
- Replaced the temporary React-owned compiler module/script with `packages/react/skins.compiler.config.ts`.
- The compiler now has an additive project path: config `input` / `output` / `plugins`, `compileProject(config, options)`, and CLI project mode via `vjs compile --config ...` when no file is passed.
- The compiler exposes a generic `transform(({ ref, match, create, edit }) => [...])` plugin helper. React lowering is expressed with generic import, JSX, and interface/type edits rather than React-specific compiler internals.
- The compiler exposes a generic `transform((code) => [...])` plugin helper. React lowering is expressed with generic import, JSX, and interface/type edits rather than React-specific compiler internals.
- The compiler config is plugins-only; the temporary `config.styles` compatibility slot was removed to avoid competing extension paths.
- Current generated output is checked in at `packages/react/src/presets/video/default-video.generated.tailwind.tsx` as an unexported temporary generated artifact.
- Current lowering covers the represented `default/video` source slice only: component/icon import rewrites, Tailwind token import rewrite, `Tooltip.Trigger` child-as-`render`, `Controls.Root` `data-controls` marker, `className` array to React `cn(...)`, and source `children?: unknown` to React `ReactNode`.
- Current lowering covers the represented `default/video` source slice only: component/icon import rewrites, Tailwind token import rewrite, `Tooltip.Trigger` child-as-`render`, `Controls.Root` `data-controls` marker, button `type="button"` defaults, poster prop lowering, container rest-prop spreading, `className` array to React `cn(...)`, and source `children?: unknown` to React `ReactNode`.
- Existing manual React presets remain untouched and still provide the runtime/exported implementation.
Remaining prototype gaps before replacing a manual preset:
- Generated output does not yet add the manual React `Button`/slider render wrappers; it relies on current React component default elements plus source-authored classes.
- The source skin still omits known structural gaps from Phase 1: poster, buffering indicator, error dialog internals, overlay, volume popover, settings menu, thumbnail shell, and input feedback shells.
- Generated output now keeps button and slider visual classes in the source skin. React lowering uses the default React UI parts instead of generated skin renderer wrappers for this slice.
- Time/volume-specific slider roots should use their own namespaced parts in source (`TimeSlider.Track`, `VolumeSlider.Track`, etc.). The React lowering may replace track/fill/buffer/thumb tags with skin wrapper components, but those wrappers must render the matching React `Slider.*` primitive internally.
- The source skin still omits known structural gaps from Phase 1: overlay, volume popover, settings menu, thumbnail shell, seek labels, and input feedback shells.
- `compile:skins` emits TypeScript-printer formatting; checked-in output is formatted with Biome after generation.
Compiler config direction:
@@ -178,23 +178,22 @@ output: {
- Keep exact per-entry outputs as a possible later escape hatch if generated source files need paths that cannot be expressed cleanly with `output.dir` and `entryFileNames`.
- Replace `pipeline([...])` with a `transform(...)` plugin factory. The callback receives transform-context helpers and returns one ordered declarative pipeline.
- `ref.import(...)` creates lazy symbol references used by transforms/builders. It should not emit imports immediately; imports are emitted only when a transform actually uses the reference.
- `code.ref.import(...)` creates lazy symbol references used by transforms/builders. It should not emit imports immediately; imports are emitted only when a transform actually uses the reference.
- Keep matching, creation, and mutation separate:
- `match.*` selects nodes and composes predicates.
- `create.*` builds expressions, types, JSX values, and declarations.
- `edit.*` applies visitors/mutations.
- `code.match.*` selects nodes and composes predicates.
- `code.create.*` builds output values, types, and JSX.
- `code.edit.*` applies visitors/mutations.
- Prefer top-level `match` with domain subnamespaces so composition has one mental model:
- `match.jsx.tag('Controls.Root')`
- `match.jsx.attribute('className')`
- `match.interface.name(/Props$/)`
- `match.interface.property('children')`
- `match.import.source('@videojs/core/components')`
- `code.match.jsx.tag('Controls.Root')`
- `code.match.jsx.prop('className')`
- `code.match.interface.name(/Props$/)`
- `code.match.interface.property('children')`
- Keep edit domains explicit:
- `edit.import.rewrite(...)`
- `edit.jsx.element(...)`
- `edit.jsx.attribute(...)`
- `edit.interface.property(...)`
- Do not add a one-off compiler API such as `jsx.arrayAttributeToCall(...)`. Express that as generic composition: match a JSX attribute whose value is an array, then replace the value with a call expression built from `create.expr.call(cn, create.jsx.arrayElements(value))`.
- `code.edit.import.rewrite(...)`
- `code.edit.jsx.element(...)`
- `code.edit.jsx.prop(...)`
- `code.edit.interface.property(...)`
- Do not add a one-off compiler API such as `jsx.arrayPropToCall(...)`. Express that as generic composition: match a JSX prop whose value is an array, then replace the value with a call expression built from `code.create.value.call(cn, code.create.value.arrayItems(value))`.
- React config sketch:
```ts
@@ -211,32 +210,34 @@ export default defineConfig({
banner: '// Generated by @videojs/compiler. Do not edit.\n',
},
plugins: [
transform(({ ref, match, create, edit }) => {
const cn = ref.import('@videojs/utils/style', 'cn');
const ReactNode = ref.import('react', 'ReactNode', { type: true });
transform((code) => {
const cn = code.ref.import('@videojs/utils/style', 'cn');
const ReactNode = code.ref.import('react', 'ReactNode', { type: true });
return [
edit.import.rewrite({
code.edit.import.rewrite({
'@videojs/core/components': coreComponentImport,
'@videojs/icons/components': '@/icons',
'./tailwind/video.tailwind': '@videojs/skins/default/tailwind/video.tailwind',
}),
tailwind({ mode: 'preserve' }),
edit.jsx.element({
match: match.jsx.tag('Tooltip.Trigger'),
transform: edit.jsx.childAsProp('render'),
code.edit.jsx.element({
when: code.match.jsx.tag('Tooltip.Trigger'),
transform: code.edit.jsx.moveChildToProp('render'),
}),
edit.jsx.element({
match: match.jsx.tag('Controls.Root'),
transform: edit.jsx.addAttribute('data-controls', ''),
code.edit.jsx.element({
when: code.match.jsx.tag('Controls.Root'),
transform: code.edit.jsx.addProp('data-controls', ''),
}),
edit.jsx.attribute({
match: match.all(match.jsx.attribute('className'), match.jsx.value.array()),
transform: ({ value }) => create.expr.call(cn, create.jsx.arrayElements(value)),
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)),
}),
edit.interface.property({
match: match.all(match.interface.name(/Props$/), match.interface.property('children')),
transform: edit.interface.setType(() => create.type.union(create.type.ref(ReactNode), create.type.undefined())),
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())
),
}),
];
}),
@@ -275,15 +276,15 @@ interface CompilerPlugin {
- `transform`: parse/edit source, rewrite generated imports, lower JSX, update interfaces/types, process className, and run Tailwind transforms.
- `render`: final per-file shaping such as generated banners, source pragma removal, comment normalization, or formatter integration.
- `writeBundle`: whole-output validation, stale output checks, drift reporting, and summaries.
- Import rewriting remains a transform, not `resolve`. Rollup `resolveId` answers "what file should this import load?"; React lowering answers "what import should generated source contain?" and belongs in `edit.import.rewrite(...)`.
- Import rewriting remains a transform, not `resolve`. Rollup `resolveId` answers "what file should this import load?"; React lowering answers "what import should generated source contain?" and belongs in `code.edit.import.rewrite(...)`.
- `tailwind({ mode: 'preserve' })` should also be a compiler plugin so Tailwind work participates in the same lifecycle and can emit CSS assets through plugin context when running in extract mode.
Implemented compiler surface:
- Project compilation for config input/output via `compileProject(config, options)` and CLI project mode.
- Generic AST primitives in `@videojs/compiler`: `match`, `create`, `edit`, and transform-context `ref` helpers.
- Lazy value/type import materialization through `ref.import(...)` and `create.*` builders.
- Generic AST primitives in `@videojs/compiler`: `code.match`, `code.create`, `code.edit`, and transform-context `code.ref` helpers.
- Lazy value/type import materialization through `code.ref.import(...)` and `code.create.*` builders.
- Generic interface/type transforms for `children?: unknown` to `ReactNode` without React importing `typescript`.
- Generic JSX element/attribute transforms for child-as-prop, adding attributes, and replacing attribute values.
- Generic JSX element/attribute transforms for child-as-prop, adding attributes, conditional JSX expressions, guard expressions, object-form JSX attributes, and replacing attribute values.
- The source-file pragma was removed from `packages/skins/src/default/video.skin.tsx`; skins rely on `packages/skins/tsconfig.json` `jsxImportSource`.
Steps:
@@ -313,6 +314,25 @@ Verification:
Gate question:
- Should generated files be committed into `packages/react/src/presets/**`, generated into `__generated__` and re-exported, or generated only at build time?
Next implementation sequence:
1. Finish default video React wrapper parity before expanding coverage. Done for the current source slice.
- Add React-owned helper components for generated skins instead of teaching `@videojs/compiler` React semantics.
- Source owns shared icon button visual classes through `iconButton = [button.base, button.subtle, button.icon]` and slider part classes through `slider.*` tokens.
- React lowering keeps default `TimeSlider.*` / `VolumeSlider.*` parts and adds button `type="button"` defaults instead of importing generated skin renderer wrappers.
- Keep this first slice narrow: source-owned class composition plus target-only props. Defer missing structural regions to later steps.
2. Expand `packages/skins/src/default/video.skin.tsx` toward manual default-video parity.
- Add overlay, volume popover, settings menu, thumbnail shell, seek labels, and input feedback islands.
- Classify each gap before implementation as source-neutral structure, React-only behavior, or React helper output.
3. Add focused generated-output parity tests as each chunk lands.
- Assert key imports and structures rather than full snapshots.
- Cover wrapper `render` props, buffering, settings, volume, thumbnail, and input feedback incrementally.
4. Decide generated artifact placement before replacing public exports.
- Recommendation: generate into a `__generated__` path and keep stable hand-written export files once replacement begins.
5. After default video reaches parity, expand horizontally.
- Add `default/audio.skin.tsx`, `minimal/video.skin.tsx`, and `minimal/audio.skin.tsx`.
- Defer live/background variants until base audio/video generation is stable.
## Phase 3 - Expand React Tailwind Coverage
Purpose: move from one slice to all supported React Tailwind skins.
@@ -383,4 +403,4 @@ Gate question:
## Immediate Next Step
Prototype React lowering for the existing `default/video` source scaffold, starting with child-as-trigger composition and the `Controls.Root` marker. Resolve the first structural Core-part gap only after that lowering path is proven.
Continue expanding `default/video` source parity one structural gap at a time. The next likely gap is overlay after deciding whether the missing shell belongs in Core parts, source abstractions, or React lowering.
+13 -2
View File
@@ -77,15 +77,24 @@ export {
export {
type CreateHelpers,
type EditHelpers,
type FunctionDeclarationContext,
type FunctionDeclarationEdit,
type FunctionDeclarationEditOptions,
type FunctionPropSpec,
type ImportReference,
type InterfaceDeclarationContext,
type InterfaceDeclarationEdit,
type InterfaceDeclarationEditOptions,
type InterfacePropertyContext,
type InterfacePropertyEdit,
type InterfacePropertyEditOptions,
type JsxAttributeContext,
type JsxAttributeEditOptions,
type JsxElementContext,
type JsxElementEdit,
type JsxElementEditOptions,
type JsxPropContext,
type JsxPropEditOptions,
type JsxPropsSpec,
type JsxPropValue,
type MatchHelpers,
type MatchPredicate,
type RefHelpers,
@@ -94,6 +103,8 @@ export {
type TransformOptions,
type TransformStep,
transform,
type ValueOnlyIfOptions,
type ValueReference,
} from './transform';
export {
type AddImportContext,
+75 -23
View File
@@ -18,7 +18,7 @@ afterEach(() => {
describe('transform', () => {
it('composes generic import, JSX attribute, JSX element, and interface edits', async () => {
const source = `import { Container, Controls, Tooltip } from '@fixture/core';
const source = `import { Container, Controls, PlayButton, Poster, Slider, Tooltip } from '@fixture/core';
import { styles } from './tokens';
export interface SkinProps {
@@ -26,37 +26,75 @@ export interface SkinProps {
}
export function Skin({ children, className }: SkinProps) {
return <Container className={[styles.root, className]}>{children}<Controls.Root><Tooltip.Trigger><button /></Tooltip.Trigger></Controls.Root></Container>;
return <Container className={[styles.root, className]}>{children}<Poster /><Controls.Root><Slider.Track className={[styles.track]} /><Tooltip.Trigger><PlayButton className={[styles.action]} /></Tooltip.Trigger></Controls.Root></Container>;
}
`;
const result = await compile(source, {
config: {
plugins: [
transform(({ ref, match, create, edit }) => {
const cn = ref.import('@fixture/style', 'cn');
const ReactNode = ref.import('react', 'ReactNode', { type: true });
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 });
return [
edit.import.rewrite({ '@fixture/core': '@fixture/react' }),
edit.jsx.element({
match: match.jsx.tag('Tooltip.Trigger'),
transform: edit.jsx.childAsProp('render'),
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'),
}),
edit.jsx.element({
match: match.jsx.tag('Controls.Root'),
transform: edit.jsx.addAttribute('data-controls', ''),
code.edit.jsx.element({
when: code.match.jsx.tag('Controls.Root'),
transform: code.edit.jsx.addProp('data-controls', ''),
}),
edit.jsx.attribute({
match: match.all(match.jsx.attribute('className'), match.jsx.value.array()),
transform: ({ value }) => create.expr.call(cn, create.jsx.arrayElements(value)),
code.edit.jsx.element({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
edit.interface.property({
match: match.all(match.interface.name(/Props$/), match.interface.property('children')),
transform: edit.interface.setType(() =>
create.type.union(create.type.ref(ReactNode), create.type.undefined())
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 }]),
}),
];
}),
],
@@ -65,12 +103,23 @@ export function Skin({ children, className }: SkinProps) {
expect(result.diagnostics).toEqual([]);
expect(result.code).toContain('from "@fixture/react"');
expect(result.code).toContain('from "@fixture/renderers"');
expect(result.code).toContain('from "@fixture/predicate"');
expect(result.code).toContain('from "@fixture/style"');
expect(result.code).toContain('import type { ReactNode } from "react"');
expect(compact(result.code)).toContain(compact('interface SkinProps extends BaseSkinProps'));
expect(compact(result.code)).toContain(compact('children?: ReactNode | undefined;'));
expect(compact(result.code)).toContain(compact('className={cn(styles.root, className)}'));
expect(compact(result.code)).toContain(
compact('function Skin({ children, className, poster, ...rest }: SkinProps)')
);
expect(compact(result.code)).toContain(compact('<Container className={cn(styles.root, className)} {...rest}>'));
expect(compact(result.code)).toContain(
compact('{poster && <Poster src={isString(poster) ? poster : undefined} />}')
);
expect(compact(result.code)).toContain(compact('<PlayButton className={cn(styles.action)} render={<Button />} />'));
expect(compact(result.code)).toContain(compact('<SliderTrack className={cn(styles.track)} />'));
expect(compact(result.code)).toContain(compact('<Controls.Root data-controls="">'));
expect(compact(result.code)).toContain(compact('<Tooltip.Trigger render={<button />} />'));
expect(compact(result.code)).toContain(compact('<Tooltip.Trigger render={<PlayButton'));
});
});
@@ -89,8 +138,11 @@ describe('compileProject', () => {
banner: '// Generated\n',
},
plugins: [
transform(({ match, edit }) => [
edit.jsx.element({ match: match.jsx.tag('Root'), transform: edit.jsx.addAttribute('data-root', '') }),
transform((code) => [
code.edit.jsx.element({
when: code.match.jsx.tag('Root'),
transform: code.edit.jsx.addProp('data-root', ''),
}),
]),
],
},
+453 -71
View File
@@ -15,33 +15,55 @@ export interface RefHelpers {
import(source: string, name: string, options?: { type?: boolean | undefined }): ImportReference;
}
export type ValueReference = string | ImportReference | ts.Expression;
export type JsxPropValue = string | ImportReference | ts.Expression | undefined;
export type JsxPropsSpec = readonly (ts.JsxAttribute | ts.JsxSpreadAttribute)[] | Record<string, JsxPropValue>;
export interface ValueOnlyIfOptions {
value: ValueReference;
condition: ValueReference;
fallback?: ValueReference | undefined;
}
export type MatchPredicate<Value = unknown, Context = unknown> = (value: Value, context?: Context) => boolean;
export interface MatchHelpers {
all(...predicates: readonly MatchPredicate[]): MatchPredicate;
value: {
array(): MatchPredicate;
};
jsx: {
tag(name: string | RegExp): MatchPredicate;
attribute(name: string): MatchPredicate;
value: {
array(): MatchPredicate;
};
prop(name: string): MatchPredicate;
};
interface: {
name(name: string | RegExp): MatchPredicate;
property(name: string): MatchPredicate;
};
function: {
name(name: string | RegExp): MatchPredicate;
};
}
export interface CreateHelpers {
expr: {
call(callee: string | ImportReference | ts.Expression, args: readonly ts.Expression[]): ts.CallExpression;
value: {
and(left: ValueReference, right: ts.Expression): ts.BinaryExpression;
arrayItems(value: ts.Expression): ts.Expression[];
call(callee: ValueReference, args: readonly ts.Expression[]): ts.CallExpression;
conditional(test: ts.Expression, whenTrue: ts.Expression, whenFalse: ts.Expression): ts.ConditionalExpression;
identifier(value: string | ImportReference): ts.Identifier;
onlyIf(options: ValueOnlyIfOptions): ts.ConditionalExpression;
undefined(): ts.Identifier;
};
jsx: {
arrayElements(value: ts.Expression): ts.Expression[];
element(tag: string | ImportReference, props?: JsxPropsSpec): ts.JsxSelfClosingElement;
expression(value: ts.Expression): ts.JsxExpression;
prop(name: string, value?: JsxPropValue): ts.JsxAttribute;
renderIf(test: ValueReference, element: ts.Expression): ts.JsxExpression;
spreadProps(value: ValueReference): ts.JsxSpreadAttribute;
};
type: {
ref(value: string | ImportReference): ts.TypeReferenceNode;
named(value: string | ImportReference): ts.TypeReferenceNode;
union(...types: readonly ts.TypeNode[]): ts.UnionTypeNode;
undefined(): ts.KeywordTypeNode;
};
@@ -53,14 +75,22 @@ export interface EditHelpers {
};
jsx: {
element(options: JsxElementEditOptions): CompilerTransform;
attribute(options: JsxAttributeEditOptions): CompilerTransform;
childAsProp(prop: string): JsxElementEdit;
addAttribute(name: string, value?: string | ts.Expression | undefined): JsxElementEdit;
prop(options: JsxPropEditOptions): CompilerTransform;
addProp(name: string, value?: JsxPropValue): JsxElementEdit;
addPropsSpread(value: ValueReference): JsxElementEdit;
moveChildToProp(prop: string): JsxElementEdit;
replaceTag(tag: string | ImportReference): JsxElementEdit;
};
interface: {
declaration(options: InterfaceDeclarationEditOptions): CompilerTransform;
extends(value: string | ImportReference): InterfaceDeclarationEdit;
property(options: InterfacePropertyEditOptions): CompilerTransform;
setType(type: (context: InterfacePropertyContext) => ts.TypeNode): InterfacePropertyEdit;
};
function: {
declaration(options: FunctionDeclarationEditOptions): CompilerTransform;
addProps(props: readonly FunctionPropSpec[], parameterIndex?: number): FunctionDeclarationEdit;
};
}
export interface TransformHelpers {
@@ -79,26 +109,28 @@ export interface TransformOptions {
}
export interface JsxElementContext {
element: JsxElementLike;
factory: ts.NodeFactory;
tagName: string;
}
export type JsxElementEdit = (element: JsxElementLike, context: JsxElementContext) => JsxElementLike | undefined;
export type JsxElementEdit = (element: JsxElementLike, context: JsxElementContext) => ts.Node | undefined;
export interface JsxElementEditOptions {
match: MatchPredicate;
when: MatchPredicate;
transform: JsxElementEdit;
}
export interface JsxAttributeContext {
export interface JsxPropContext {
element: JsxElementLike;
attribute: ts.JsxAttribute;
prop: ts.JsxAttribute;
value: ts.Expression;
factory: ts.NodeFactory;
}
export interface JsxAttributeEditOptions {
match: MatchPredicate;
transform(context: JsxAttributeContext): ts.Expression | undefined;
export interface JsxPropEditOptions {
when: MatchPredicate;
transform(context: JsxPropContext): ts.Expression | undefined;
}
export interface InterfacePropertyContext {
@@ -107,13 +139,39 @@ export interface InterfacePropertyContext {
factory: ts.NodeFactory;
}
export interface InterfaceDeclarationContext {
interface: ts.InterfaceDeclaration;
factory: ts.NodeFactory;
}
export type InterfaceDeclarationEdit = (context: InterfaceDeclarationContext) => ts.InterfaceDeclaration | undefined;
export interface InterfaceDeclarationEditOptions {
when: MatchPredicate;
transform: InterfaceDeclarationEdit;
}
export type InterfacePropertyEdit = (context: InterfacePropertyContext) => ts.PropertySignature | undefined;
export interface InterfacePropertyEditOptions {
match: MatchPredicate;
when: MatchPredicate;
transform: InterfacePropertyEdit;
}
export interface FunctionDeclarationContext {
function: ts.FunctionDeclaration;
factory: ts.NodeFactory;
}
export type FunctionDeclarationEdit = (context: FunctionDeclarationContext) => ts.FunctionDeclaration | undefined;
export interface FunctionDeclarationEditOptions {
when: MatchPredicate;
transform: FunctionDeclarationEdit;
}
export type FunctionPropSpec = string | { name: string; spread?: boolean | undefined };
interface MutableImportReference extends ImportReference {
used: boolean;
}
@@ -184,6 +242,12 @@ function createMatchHelpers(): MatchHelpers {
(...predicates) =>
(value, context) =>
predicates.every((predicate) => predicate(value, context)),
value: {
array: () => (value, context) => {
const expression = readJsxPropValue(value, context);
return Boolean(expression && ts.isArrayLiteralExpression(expression));
},
},
jsx: {
tag: (name) => (value, context) => {
const element = readJsxElement(value, context);
@@ -191,16 +255,10 @@ function createMatchHelpers(): MatchHelpers {
const current = tagName(element);
return typeof name === 'string' ? current === name : name.test(current);
},
attribute: (name) => (value, context) => {
const attr = readJsxAttribute(value, context);
prop: (name) => (value, context) => {
const attr = readJsxProp(value, context);
return Boolean(attr && ts.isIdentifier(attr.name) && attr.name.text === name);
},
value: {
array: () => (value, context) => {
const expression = readJsxAttributeValue(value, context);
return Boolean(expression && ts.isArrayLiteralExpression(expression));
},
},
},
interface: {
name: (name) => (value, context) => {
@@ -213,28 +271,92 @@ function createMatchHelpers(): MatchHelpers {
return Boolean(property && ts.isIdentifier(property.name) && property.name.text === name);
},
},
function: {
name: (name) => (value, context) => {
const declaration = readFunctionDeclaration(value, context);
if (!declaration?.name) return false;
return typeof name === 'string' ? declaration.name.text === name : name.test(declaration.name.text);
},
},
};
}
function createCreateHelpers(): CreateHelpers {
return {
expr: {
value: {
and(left, right) {
return ts.factory.createBinaryExpression(
valueFromReference(left),
ts.factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
right
);
},
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(expressionFromReference(callee), undefined, [...args]);
return ts.factory.createCallExpression(valueFromReference(callee), undefined, [...args]);
},
conditional(test, whenTrue, whenFalse) {
return ts.factory.createConditionalExpression(
test,
ts.factory.createToken(ts.SyntaxKind.QuestionToken),
whenTrue,
ts.factory.createToken(ts.SyntaxKind.ColonToken),
whenFalse
);
},
identifier(value) {
if (isImportReference(value)) value.used = true;
return ts.factory.createIdentifier(typeof value === 'string' ? value : value.name);
},
onlyIf(options) {
const value = valueFromReference(options.value);
return ts.factory.createConditionalExpression(
ts.factory.createCallExpression(valueFromReference(options.condition), undefined, [value]),
ts.factory.createToken(ts.SyntaxKind.QuestionToken),
value,
ts.factory.createToken(ts.SyntaxKind.ColonToken),
options.fallback === undefined
? ts.factory.createIdentifier('undefined')
: valueFromReference(options.fallback)
);
},
undefined() {
return ts.factory.createIdentifier('undefined');
},
},
jsx: {
arrayElements(value) {
if (!ts.isArrayLiteralExpression(value)) return [];
return value.elements.filter((item): item is ts.Expression => !ts.isSpreadElement(item));
element(tag, props = []) {
return ts.factory.createJsxSelfClosingElement(
jsxTagNameFromReference(tag),
undefined,
ts.factory.createJsxAttributes(createJsxProps(props, ts.factory))
);
},
expression(value) {
return ts.factory.createJsxExpression(undefined, value);
},
prop(name, value) {
return createJsxProp(name, value, ts.factory);
},
renderIf(test, element) {
return ts.factory.createJsxExpression(
undefined,
ts.factory.createBinaryExpression(
valueFromReference(test),
ts.factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
element
)
);
},
spreadProps(value) {
return ts.factory.createJsxSpreadAttribute(valueFromReference(value));
},
},
type: {
ref(value) {
named(value) {
if (isImportReference(value)) value.used = true;
return ts.factory.createTypeReferenceNode(typeof value === 'string' ? value : value.name);
},
@@ -262,17 +384,28 @@ function createEditHelpers(context: CompilerContext): EditHelpers {
},
jsx: {
element: editJsxElement,
attribute: editJsxAttribute,
childAsProp:
prop: editJsxProp,
addProp:
(name, value) =>
(element, { factory }) =>
addJsxProp(element, name, value, factory),
addPropsSpread:
(value) =>
(element, { factory }) =>
addJsxPropsSpread(element, value, factory),
moveChildToProp:
(prop) =>
(element, { factory }) =>
liftSingleChildToProp(element, prop, factory),
addAttribute:
(name, value) =>
replaceTag:
(tag) =>
(element, { factory }) =>
addJsxAttribute(element, name, value, factory),
replaceJsxTag(element, tag, factory),
},
interface: {
declaration: editInterfaceDeclaration,
extends: (value) => (interfaceContext) =>
addInterfaceExtends(interfaceContext.interface, value, interfaceContext.factory),
property: editInterfaceProperty,
setType: (type) => (propertyContext) => {
const factory = propertyContext.factory;
@@ -285,10 +418,17 @@ function createEditHelpers(context: CompilerContext): EditHelpers {
);
},
},
function: {
declaration: editFunctionDeclaration,
addProps:
(props, parameterIndex = 0) =>
(functionContext) =>
addFunctionProps(functionContext.function, parameterIndex, props, functionContext.factory),
},
};
}
function expressionFromReference(value: string | ImportReference | ts.Expression): ts.Expression {
function valueFromReference(value: ValueReference): ts.Expression {
if (typeof value === 'string') return ts.factory.createIdentifier(value);
if (isImportReference(value)) {
value.used = true;
@@ -298,20 +438,33 @@ function expressionFromReference(value: string | ImportReference | ts.Expression
throw new TypeError('Expected an expression or import reference.');
}
function jsxTagNameFromReference(value: string | ImportReference): ts.JsxTagNameExpression {
if (isImportReference(value)) value.used = true;
const text = typeof value === 'string' ? value : value.name;
const parts = text.split('.');
let current: ts.Identifier | ts.PropertyAccessExpression = ts.factory.createIdentifier(parts[0]!);
for (const part of parts.slice(1)) {
current = ts.factory.createPropertyAccessExpression(current, ts.factory.createIdentifier(part));
}
return current as ts.JsxTagNameExpression;
}
function editJsxElement(options: JsxElementEditOptions): CompilerTransform {
return (context) => {
const visit = (node: ts.Node): ts.Node => {
const next = ts.visitEachChild(node, visit, context);
if (!isJsxElementLike(next)) return next;
if (!options.match(next, { element: next })) return next;
return options.transform(next, { factory: context.factory }) ?? next;
const elementContext: JsxElementContext = { element: next, factory: context.factory, tagName: tagName(next) };
if (!options.when(next, elementContext)) return next;
return options.transform(next, elementContext) ?? next;
};
return (sourceFile) => ts.visitEachChild(sourceFile, visit, context);
};
}
function editJsxAttribute(options: JsxAttributeEditOptions): CompilerTransform {
function editJsxProp(options: JsxPropEditOptions): CompilerTransform {
return (context) => {
const factory = context.factory;
@@ -323,11 +476,11 @@ function editJsxAttribute(options: JsxAttributeEditOptions): CompilerTransform {
let changed = false;
const properties = attrs.properties.map((property) => {
if (!ts.isJsxAttribute(property)) return property;
const value = readAttributeExpression(property);
const value = readPropValue(property);
if (!value) return property;
const attributeContext: JsxAttributeContext = { element: next, attribute: property, value, factory };
if (!options.match(property, attributeContext)) return property;
const replacement = options.transform(attributeContext);
const propContext: JsxPropContext = { element: next, prop: property, value, factory };
if (!options.when(property, propContext)) return property;
const replacement = options.transform(propContext);
if (!replacement) return property;
changed = true;
return factory.updateJsxAttribute(
@@ -373,7 +526,7 @@ function editInterfaceProperty(options: InterfacePropertyEditOptions): CompilerT
const members = next.members.map((member) => {
if (!ts.isPropertySignature(member)) return member;
const propertyContext: InterfacePropertyContext = { interface: next, property: member, factory };
if (!options.match(member, propertyContext)) return member;
if (!options.when(member, propertyContext)) return member;
const replacement = options.transform(propertyContext);
if (!replacement) return member;
changed = true;
@@ -395,6 +548,40 @@ function editInterfaceProperty(options: InterfacePropertyEditOptions): CompilerT
};
}
function editInterfaceDeclaration(options: InterfaceDeclarationEditOptions): CompilerTransform {
return (context) => {
const factory = context.factory;
const visit = (node: ts.Node): ts.Node => {
const next = ts.visitEachChild(node, visit, context);
if (!ts.isInterfaceDeclaration(next)) return next;
const interfaceContext: InterfaceDeclarationContext = { interface: next, factory };
if (!options.when(next, interfaceContext)) return next;
return options.transform(interfaceContext) ?? next;
};
return (sourceFile) => ts.visitEachChild(sourceFile, visit, context);
};
}
function editFunctionDeclaration(options: FunctionDeclarationEditOptions): CompilerTransform {
return (context) => {
const factory = context.factory;
const visit = (node: ts.Node): ts.Node => {
const next = ts.visitEachChild(node, visit, context);
if (!ts.isFunctionDeclaration(next)) return next;
const functionContext: FunctionDeclarationContext = { function: next, factory };
if (!options.when(next, functionContext)) return next;
return options.transform(functionContext) ?? next;
};
return (sourceFile) => ts.visitEachChild(sourceFile, visit, context);
};
}
function materializeImportRefs(refs: readonly MutableImportReference[], context: CompilerContext): CompilerTransform {
return (transformContext) => {
const factory = transformContext.factory;
@@ -428,7 +615,7 @@ function liftSingleChildToProp(
): JsxElementLike | undefined {
if (!ts.isJsxElement(element)) return undefined;
const opening = element.openingElement;
if (hasAttribute(opening.attributes, prop)) return undefined;
if (hasProp(opening.attributes, prop)) return undefined;
const child = singleElementChild(element.children);
if (!child) return undefined;
@@ -441,25 +628,16 @@ function liftSingleChildToProp(
return factory.createJsxSelfClosingElement(opening.tagName, opening.typeArguments, nextAttrs);
}
function addJsxAttribute(
function addJsxProp(
element: JsxElementLike,
name: string,
value: string | ts.Expression | undefined,
value: JsxPropValue,
factory: ts.NodeFactory
): JsxElementLike | undefined {
const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes;
if (hasAttribute(attrs, name)) return undefined;
if (hasProp(attrs, name)) return undefined;
const initializer =
value === undefined
? undefined
: typeof value === 'string'
? factory.createStringLiteral(value)
: factory.createJsxExpression(undefined, value);
const nextAttrs = factory.createJsxAttributes([
...attrs.properties,
factory.createJsxAttribute(factory.createIdentifier(name), initializer),
]);
const nextAttrs = factory.createJsxAttributes([...attrs.properties, createJsxProp(name, value, factory)]);
if (ts.isJsxElement(element)) {
return factory.updateJsxElement(
@@ -478,6 +656,183 @@ function addJsxAttribute(
return factory.updateJsxSelfClosingElement(element, element.tagName, element.typeArguments, nextAttrs);
}
function addJsxPropsSpread(
element: JsxElementLike,
value: ValueReference,
factory: ts.NodeFactory
): JsxElementLike | undefined {
const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes;
const expression = valueFromReference(value);
if (typeof value === 'string' && hasPropsSpread(attrs, value)) return undefined;
const nextAttrs = factory.createJsxAttributes([...attrs.properties, factory.createJsxSpreadAttribute(expression)]);
if (ts.isJsxElement(element)) {
return factory.updateJsxElement(
element,
factory.updateJsxOpeningElement(
element.openingElement,
element.openingElement.tagName,
element.openingElement.typeArguments,
nextAttrs
),
element.children,
element.closingElement
);
}
return factory.updateJsxSelfClosingElement(element, element.tagName, element.typeArguments, nextAttrs);
}
function addInterfaceExtends(
declaration: ts.InterfaceDeclaration,
value: string | ImportReference,
factory: ts.NodeFactory
): ts.InterfaceDeclaration | undefined {
const name = typeof value === 'string' ? value : value.name;
const heritageClauses = declaration.heritageClauses ? [...declaration.heritageClauses] : [];
const extendsIndex = heritageClauses.findIndex((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword);
const nextType = factory.createExpressionWithTypeArguments(valueFromReference(value), undefined);
if (extendsIndex >= 0) {
const extendsClause = heritageClauses[extendsIndex]!;
if (extendsClause.types.some((type) => heritageTypeName(type) === name)) return undefined;
heritageClauses[extendsIndex] = factory.updateHeritageClause(extendsClause, [...extendsClause.types, nextType]);
} else {
heritageClauses.push(factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [nextType]));
}
return factory.updateInterfaceDeclaration(
declaration,
declaration.modifiers,
declaration.name,
declaration.typeParameters,
heritageClauses,
declaration.members
);
}
function addFunctionProps(
declaration: ts.FunctionDeclaration,
parameterIndex: number,
props: readonly FunctionPropSpec[],
factory: ts.NodeFactory
): ts.FunctionDeclaration | undefined {
const parameter = declaration.parameters[parameterIndex];
if (!parameter || !ts.isObjectBindingPattern(parameter.name)) return undefined;
const existing = new Set<string>();
const normalElements: ts.BindingElement[] = [];
let spreadElement: ts.BindingElement | undefined;
for (const element of parameter.name.elements) {
const name = bindingElementName(element);
if (name) existing.add(name);
if (element.dotDotDotToken) {
spreadElement = element;
} else {
normalElements.push(element);
}
}
let changed = false;
const nextElements = [...normalElements];
let nextSpreadElement = spreadElement;
for (const spec of props) {
const name = typeof spec === 'string' ? spec : spec.name;
const spread = typeof spec === 'object' && spec.spread === true;
if (spread) {
if (nextSpreadElement) continue;
nextSpreadElement = factory.createBindingElement(
factory.createToken(ts.SyntaxKind.DotDotDotToken),
undefined,
factory.createIdentifier(name),
undefined
);
changed = true;
continue;
}
if (existing.has(name)) continue;
nextElements.push(factory.createBindingElement(undefined, undefined, factory.createIdentifier(name), undefined));
existing.add(name);
changed = true;
}
if (!changed) return undefined;
const nextBinding = factory.updateObjectBindingPattern(
parameter.name,
nextSpreadElement ? [...nextElements, nextSpreadElement] : nextElements
);
const nextParameter = factory.updateParameterDeclaration(
parameter,
parameter.modifiers,
parameter.dotDotDotToken,
nextBinding,
parameter.questionToken,
parameter.type,
parameter.initializer
);
const nextParameters = declaration.parameters.map((item, index) => (index === parameterIndex ? nextParameter : item));
return factory.updateFunctionDeclaration(
declaration,
declaration.modifiers,
declaration.asteriskToken,
declaration.name,
declaration.typeParameters,
nextParameters,
declaration.type,
declaration.body
);
}
function replaceJsxTag(
element: JsxElementLike,
tag: string | ImportReference,
factory: ts.NodeFactory
): JsxElementLike {
if (ts.isJsxElement(element)) {
return factory.updateJsxElement(
element,
factory.updateJsxOpeningElement(
element.openingElement,
jsxTagNameFromReference(tag),
element.openingElement.typeArguments,
element.openingElement.attributes
),
element.children,
factory.updateJsxClosingElement(element.closingElement, jsxTagNameFromReference(tag))
);
}
return factory.updateJsxSelfClosingElement(
element,
jsxTagNameFromReference(tag),
element.typeArguments,
element.attributes
);
}
function createJsxProp(name: string, value: JsxPropValue, factory: ts.NodeFactory): ts.JsxAttribute {
const initializer =
value === undefined
? undefined
: typeof value === 'string'
? factory.createStringLiteral(value)
: factory.createJsxExpression(undefined, valueFromReference(value));
return factory.createJsxAttribute(factory.createIdentifier(name), initializer);
}
function createJsxProps(spec: JsxPropsSpec, factory: ts.NodeFactory): (ts.JsxAttribute | ts.JsxSpreadAttribute)[] {
if (Array.isArray(spec)) return [...spec];
return Object.entries(spec).map(([name, value]) => createJsxProp(name, value, factory));
}
function singleElementChild(
children: readonly ts.JsxChild[]
): ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | null {
@@ -494,14 +849,33 @@ function singleElementChild(
return found;
}
function hasAttribute(attrs: ts.JsxAttributes, name: string): boolean {
function hasProp(attrs: ts.JsxAttributes, name: string): boolean {
return attrs.properties.some(
(property) => ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name
);
}
function readAttributeExpression(attribute: ts.JsxAttribute): ts.Expression | undefined {
const init = attribute.initializer;
function hasPropsSpread(attrs: ts.JsxAttributes, name: string): boolean {
return attrs.properties.some(
(property) =>
ts.isJsxSpreadAttribute(property) && ts.isIdentifier(property.expression) && property.expression.text === name
);
}
function heritageTypeName(type: ts.ExpressionWithTypeArguments): string | undefined {
const expression = type.expression;
if (ts.isIdentifier(expression)) return expression.text;
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
return undefined;
}
function bindingElementName(element: ts.BindingElement): string | undefined {
if (ts.isIdentifier(element.name)) return element.name.text;
return undefined;
}
function readPropValue(prop: ts.JsxAttribute): ts.Expression | undefined {
const init = prop.initializer;
if (!init) return undefined;
if (ts.isStringLiteral(init)) return init;
if (ts.isJsxExpression(init) && init.expression) return init.expression;
@@ -514,16 +888,16 @@ function readJsxElement(value: unknown, context: unknown): JsxElementLike | unde
return undefined;
}
function readJsxAttribute(value: unknown, context: unknown): ts.JsxAttribute | undefined {
if (isJsxAttribute(value)) return value;
if (isObject(context) && isJsxAttribute(context.attribute)) return context.attribute;
function readJsxProp(value: unknown, context: unknown): ts.JsxAttribute | undefined {
if (isJsxProp(value)) return value;
if (isObject(context) && isJsxProp(context.prop)) return context.prop;
return undefined;
}
function readJsxAttributeValue(value: unknown, context: unknown): ts.Expression | undefined {
function readJsxPropValue(value: unknown, context: unknown): ts.Expression | undefined {
if (isObject(context) && isNode(context.value) && ts.isExpression(context.value)) return context.value;
const attribute = readJsxAttribute(value, context);
return attribute ? readAttributeExpression(attribute) : undefined;
const prop = readJsxProp(value, context);
return prop ? readPropValue(prop) : undefined;
}
function readInterface(value: unknown, context: unknown): ts.InterfaceDeclaration | undefined {
@@ -542,11 +916,19 @@ function readInterfaceProperty(value: unknown, context: unknown): ts.PropertySig
return undefined;
}
function readFunctionDeclaration(value: unknown, context: unknown): ts.FunctionDeclaration | undefined {
if (isNode(value) && ts.isFunctionDeclaration(value)) return value;
if (isObject(context) && isNode(context.function) && ts.isFunctionDeclaration(context.function)) {
return context.function as ts.FunctionDeclaration;
}
return undefined;
}
function isJsxElementLike(value: unknown): value is JsxElementLike {
return Boolean(isNode(value) && (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value)));
}
function isJsxAttribute(value: unknown): value is ts.JsxAttribute {
function isJsxProp(value: unknown): value is ts.JsxAttribute {
return Boolean(isNode(value) && ts.isJsxAttribute(value));
}
+56 -17
View File
@@ -6,6 +6,8 @@ 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',
@@ -16,35 +18,72 @@ export default defineConfig({
banner: '// Generated by @videojs/compiler. Do not edit.\n',
},
plugins: [
transform(({ ref, match, create, edit }) => {
const cn = ref.import('@videojs/utils/style', 'cn');
const ReactNode = ref.import('react', 'ReactNode', { type: true });
transform((code) => {
// Types
const BaseVideoSkinProps = code.ref.import('./src/presets/types', 'BaseVideoSkinProps', { type: true });
const ReactNode = code.ref.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');
// Components
const Poster = code.ref.import('@/ui/poster', 'Poster');
return [
edit.import.rewrite({
code.edit.import.rewrite({
[CORE_COMPONENTS_SOURCE]: coreComponentImport,
[ICON_COMPONENTS_SOURCE]: '@/icons',
[SOURCE_TAILWIND_SOURCE]: GENERATED_TAILWIND_SOURCE,
}),
tailwind({ mode: 'preserve' }),
edit.jsx.element({
match: match.jsx.tag('Tooltip.Trigger'),
transform: edit.jsx.childAsProp('render'),
code.edit.jsx.element({
when: code.match.jsx.tag('Tooltip.Trigger'),
transform: code.edit.jsx.moveChildToProp('render'),
}),
edit.jsx.element({
match: match.jsx.tag('Controls.Root'),
transform: edit.jsx.addAttribute('data-controls', ''),
code.edit.jsx.element({
when: code.match.jsx.tag('Controls.Root'),
transform: code.edit.jsx.addProp('data-controls', ''),
}),
edit.jsx.attribute({
match: match.all(match.jsx.attribute('className'), match.jsx.value.array()),
transform: ({ value }) => create.expr.call(cn, create.jsx.arrayElements(value)),
code.edit.jsx.element({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
edit.interface.property({
match: match.all(match.interface.name(/Props$/), match.interface.property('children')),
transform: edit.interface.setType(() =>
create.type.union(create.type.ref(ReactNode), create.type.undefined())
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 }]),
}),
];
}),
],
@@ -22,16 +22,65 @@ describe('skins compiler config', () => {
expect(code).toContain('from "@videojs/utils/style"');
expect(code).toContain('from "@/player/context"');
expect(code).toContain('from "@/ui/airplay-button"');
expect(code).toContain('from "@/ui/buffering-indicator"');
expect(code).toContain('from "@/ui/error-dialog"');
expect(code).toContain('from "@/ui/pip-button"');
expect(code).toContain('from "@/ui/play-button"');
expect(code).toContain('from "@/ui/poster"');
expect(code).not.toContain('from "@/ui/slider"');
expect(code).toContain('from "@/utils/use-render"');
expect(code).toContain('from "@videojs/utils/predicate"');
expect(code).toContain('from "@/icons"');
expect(code).not.toContain('from "../skin-renderers"');
expect(code).toContain('from "../types"');
expect(code).toContain('import type { ReactNode } from "react"');
const compactCode = compact(code);
expect(compactCode).toContain(compact('export interface DefaultVideoSkinProps extends BaseVideoSkinProps'));
expect(compactCode).toContain(compact('const iconButton = [button.base, button.subtle, button.icon];'));
expect(compactCode).toContain(compact('children?: ReactNode | undefined;'));
expect(compactCode).toContain(compact('<Container className={cn(container, className)}>'));
expect(compactCode).toContain(
compact('DefaultVideoSkin({ className, children, poster, ...rest }: DefaultVideoSkinProps)')
);
expect(compactCode).toContain(compact('<Container className={cn(container, className)} {...rest}>'));
expect(compactCode).toContain(
compact(
'{poster && <Poster src={isString(poster) ? poster : undefined} render={isRenderProp(poster) ? poster : undefined} />}'
)
);
expect(compactCode).toContain(compact('<BufferingIndicator className={bufferingIndicator.root}>'));
expect(compactCode).toContain(compact('<SpinnerIcon className={icon} />'));
expect(compactCode).not.toContain('BufferingIndicatorRoot');
expect(compactCode).toContain(compact('<ErrorDialog.Popup className={error.popup}>'));
expect(compactCode).toContain(
compact('<ErrorDialog.Title className={error.title}>Something went wrong.</ErrorDialog.Title>')
);
expect(compactCode).toContain(compact('<ErrorDialog.Description className={error.description} />'));
expect(compactCode).toContain(
compact('<ErrorDialog.Close className={cn(button.base, button.primary, error.close)}>OK</ErrorDialog.Close>')
);
expect(compactCode).not.toContain('ErrorDialog.Panel');
expect(compactCode).not.toContain('ErrorDialog.Content');
expect(compactCode).not.toContain('ErrorDialog.Actions');
expect(compactCode).toContain(compact('<Controls.Root className={controls} data-controls="">'));
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)} type="button">')
);
expect(compactCode).not.toContain(compact('render={<Button />}'));
expect(compactCode).toContain(compact('<TimeSlider.Root className={slider.root}>'));
expect(compactCode).toContain(compact('<TimeSlider.Track className={slider.track}>'));
expect(compactCode).toContain(compact('<TimeSlider.Fill className={cn(slider.fill.base, slider.fill.fill)} />'));
expect(compactCode).toContain(
compact('<TimeSlider.Buffer className={cn(slider.fill.base, slider.fill.buffer)} />')
);
expect(compactCode).toContain(
compact('<TimeSlider.Thumb className={cn(slider.thumb.base, slider.thumb.interactive)} />')
);
expect(compactCode).toContain(compact('<TimeSlider.Preview className={slider.preview}>'));
expect(compactCode).toContain(compact('<TimeSlider.Value type="pointer" className={slider.value} />'));
expect(compactCode).not.toContain('SliderTrack');
expect(compactCode).not.toContain('className={[');
});
});
@@ -2,12 +2,14 @@
import {
airplayIcon,
bufferingIndicator,
button,
buttonGroupEnd,
buttonGroupStart,
castIcon,
container,
controls,
error,
fullscreenIcon,
icon,
iconContainer,
@@ -18,6 +20,7 @@ import {
slider,
time,
} from '@videojs/skins/default/tailwind/video.tailwind';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import type { ReactNode } from 'react';
import {
@@ -33,41 +36,65 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
} from '@/icons';
import { Container } from '@/player/context';
import { AirPlayButton } from '@/ui/airplay-button';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CastButton } from '@/ui/cast-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { Gesture } from '@/ui/gesture';
import { Hotkey } from '@/ui/hotkey';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { Poster } from '@/ui/poster';
import { SeekButton } from '@/ui/seek-button';
import { StatusAnnouncer } from '@/ui/status-announcer';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { Tooltip } from '@/ui/tooltip';
import { isRenderProp } from '@/utils/use-render';
import type { BaseVideoSkinProps } from '../types';
const SEEK_TIME = 10;
export interface DefaultVideoSkinProps {
className?: string | undefined;
const iconButton = [button.base, button.subtle, button.icon];
export interface DefaultVideoSkinProps extends BaseVideoSkinProps {
className?: string;
children?: ReactNode | undefined;
}
export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) {
export function DefaultVideoSkin({ className, children, poster, ...rest }: DefaultVideoSkinProps) {
return (
<Container className={cn(container, className)}>
<Container className={cn(container, className)} {...rest}>
{children}
{poster && (
<Poster src={isString(poster) ? poster : undefined} render={isRenderProp(poster) ? poster : undefined} />
)}
<BufferingIndicator className={bufferingIndicator.root}>
<SpinnerIcon className={icon} />
</BufferingIndicator>
<ErrorDialog.Root>
<ErrorDialog.Popup className={error.popup}>
<ErrorDialog.Title className={error.title}>Something went wrong.</ErrorDialog.Title>
<ErrorDialog.Description className={error.description} />
<ErrorDialog.Close className={cn(button.base, button.primary, error.close)}>OK</ErrorDialog.Close>
</ErrorDialog.Popup>
</ErrorDialog.Root>
<Controls.Root className={controls} data-controls="">
<Tooltip.Provider>
<Controls.Group className={buttonGroupStart}>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<PlayButton className={cn(button.base, button.subtle, button.icon, playIcon.button)}>
<PlayButton className={cn(iconButton, playIcon.button)} type="button">
<RestartIcon className={cn(icon, playIcon.restart)} />
<PlayIcon className={cn(icon, playIcon.play)} />
<PauseIcon className={cn(icon, playIcon.pause)} />
@@ -83,7 +110,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<SeekButton seconds={-SEEK_TIME} className={cn(button.base, button.subtle, button.icon)}>
<SeekButton seconds={-SEEK_TIME} className={cn(iconButton)} type="button">
<SeekIcon className={cn(icon, iconContainer, iconFlipped)} />
</SeekButton>
}
@@ -97,7 +124,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<SeekButton seconds={SEEK_TIME} className={cn(button.base, button.subtle, button.icon)}>
<SeekButton seconds={SEEK_TIME} className={cn(iconButton)} type="button">
<SeekIcon className={cn(icon, iconContainer)} />
</SeekButton>
}
@@ -128,7 +155,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<CastButton className={cn(button.base, button.subtle, button.icon, castIcon.button)}>
<CastButton className={cn(iconButton, castIcon.button)} type="button">
<CastEnterIcon className={cn(icon, castIcon.enter)} />
<CastExitIcon className={cn(icon, castIcon.exit)} />
</CastButton>
@@ -143,7 +170,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirPlayButton className={cn(button.base, button.subtle, button.icon, airplayIcon.button)}>
<AirPlayButton className={cn(iconButton, airplayIcon.button)} type="button">
<AirPlayEnterIcon className={cn(icon, airplayIcon.enter)} />
<AirPlayExitIcon className={cn(icon, airplayIcon.exit)} />
</AirPlayButton>
@@ -158,7 +185,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<PiPButton className={cn(button.base, button.subtle, button.icon, pipIcon.button)}>
<PiPButton className={cn(iconButton, pipIcon.button)} type="button">
<PipEnterIcon className={cn(icon, pipIcon.off)} />
<PipExitIcon className={cn(icon, pipIcon.on)} />
</PiPButton>
@@ -173,7 +200,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<FullscreenButton className={cn(button.base, button.subtle, button.icon, fullscreenIcon.button)}>
<FullscreenButton className={cn(iconButton, fullscreenIcon.button)} type="button">
<FullscreenEnterIcon className={cn(icon, fullscreenIcon.enter)} />
<FullscreenExitIcon className={cn(icon, fullscreenIcon.exit)} />
</FullscreenButton>
@@ -2,6 +2,18 @@ import { cn } from '@videojs/utils/style';
export const error = {
root: 'peer/error group/error hidden data-[open]:flex absolute inset-0 z-20 items-center justify-center outline-none',
popup: cn(
'peer/error group/error hidden data-[open]:flex absolute left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 outline-none',
'flex-col gap-3 max-w-72 p-5 rounded-[1.75rem] text-white',
// Animation
'transition-[opacity,scale,transform]',
'duration-(--media-error-dialog-transition-duration)',
'delay-(--media-error-dialog-transition-delay)',
'ease-(--media-error-dialog-transition-timing-function)',
'data-starting-style:opacity-0 data-starting-style:scale-50',
'data-ending-style:opacity-0 data-ending-style:scale-50',
'data-ending-style:delay-0'
),
dialog: cn(
'flex flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white',
// Animation
@@ -17,4 +29,5 @@ export const error = {
title: 'font-semibold leading-tight',
description: 'opacity-70 wrap-anywhere',
actions: 'flex gap-2 *:flex-1',
close: 'w-full',
};
@@ -187,6 +187,7 @@ export const bufferingIndicator = {
export const error = {
...baseError,
popup: cn(baseError.popup, surface, 'text-shadow-2xs text-shadow-black/25'),
dialog: cn(baseError.dialog, surface, 'text-shadow-2xs text-shadow-black/25'),
content: cn(baseError.content, 'text-shadow-inherit'),
title: cn(baseError.title, 'text-base'),
+29 -8
View File
@@ -1,13 +1,16 @@
import {
AirPlayButton,
BufferingIndicator,
CastButton,
Container,
Controls,
ErrorDialog,
FullscreenButton,
Gesture,
Hotkey,
PiPButton,
PlayButton,
Poster,
SeekButton,
StatusAnnouncer,
Time,
@@ -27,15 +30,18 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
} from '@videojs/icons/components';
import {
airplayIcon,
bufferingIndicator,
button,
buttonGroupEnd,
buttonGroupStart,
castIcon,
container,
controls,
error,
fullscreenIcon,
icon,
iconContainer,
@@ -48,9 +54,10 @@ import {
} from './tailwind/video.tailwind';
const SEEK_TIME = 10;
const iconButton = [button.base, button.subtle, button.icon];
export interface DefaultVideoSkinProps {
className?: string | undefined;
className?: string;
children?: unknown;
}
@@ -59,12 +66,26 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Container className={[container, className]}>
{children}
<Poster />
<BufferingIndicator className={bufferingIndicator.root}>
<SpinnerIcon className={icon} />
</BufferingIndicator>
<ErrorDialog.Root>
<ErrorDialog.Popup className={error.popup}>
<ErrorDialog.Title className={error.title}>Something went wrong.</ErrorDialog.Title>
<ErrorDialog.Description className={error.description} />
<ErrorDialog.Close className={[button.base, button.primary, error.close]}>OK</ErrorDialog.Close>
</ErrorDialog.Popup>
</ErrorDialog.Root>
<Controls.Root className={controls}>
<Tooltip.Provider>
<Controls.Group className={buttonGroupStart}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PlayButton className={[button.base, button.subtle, button.icon, playIcon.button]}>
<PlayButton className={[iconButton, playIcon.button]}>
<RestartIcon className={[icon, playIcon.restart]} />
<PlayIcon className={[icon, playIcon.play]} />
<PauseIcon className={[icon, playIcon.pause]} />
@@ -78,7 +99,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={-SEEK_TIME} className={[button.base, button.subtle, button.icon]}>
<SeekButton seconds={-SEEK_TIME} className={[iconButton]}>
<SeekIcon className={[icon, iconContainer, iconFlipped]} />
</SeekButton>
</Tooltip.Trigger>
@@ -90,7 +111,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={SEEK_TIME} className={[button.base, button.subtle, button.icon]}>
<SeekButton seconds={SEEK_TIME} className={[iconButton]}>
<SeekIcon className={[icon, iconContainer]} />
</SeekButton>
</Tooltip.Trigger>
@@ -119,7 +140,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Controls.Group className={buttonGroupEnd}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CastButton className={[button.base, button.subtle, button.icon, castIcon.button]}>
<CastButton className={[iconButton, castIcon.button]}>
<CastEnterIcon className={[icon, castIcon.enter]} />
<CastExitIcon className={[icon, castIcon.exit]} />
</CastButton>
@@ -132,7 +153,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<AirPlayButton className={[button.base, button.subtle, button.icon, airplayIcon.button]}>
<AirPlayButton className={[iconButton, airplayIcon.button]}>
<AirPlayEnterIcon className={[icon, airplayIcon.enter]} />
<AirPlayExitIcon className={[icon, airplayIcon.exit]} />
</AirPlayButton>
@@ -145,7 +166,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PiPButton className={[button.base, button.subtle, button.icon, pipIcon.button]}>
<PiPButton className={[iconButton, pipIcon.button]}>
<PipEnterIcon className={[icon, pipIcon.off]} />
<PipExitIcon className={[icon, pipIcon.on]} />
</PiPButton>
@@ -158,7 +179,7 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<FullscreenButton className={[button.base, button.subtle, button.icon, fullscreenIcon.button]}>
<FullscreenButton className={[iconButton, fullscreenIcon.button]}>
<FullscreenEnterIcon className={[icon, fullscreenIcon.enter]} />
<FullscreenExitIcon className={[icon, fullscreenIcon.exit]} />
</FullscreenButton>