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