fix(icons): use icon exports in ejected skins (#1489)

This commit is contained in:
Sam Potts
2026-04-29 16:05:35 +10:00
committed by GitHub
parent 8e2b7e4f6d
commit d0b36ed5b8
49 changed files with 472 additions and 346 deletions
+2 -1
View File
@@ -296,7 +296,8 @@ function ejectedHtmlPage(): string {
// Path from pages/ to the site content
const jsonPath = '../../../../../../site/src/content/ejected-skins.json';
return `import '@videojs/html/video/ui';
return `import '@videojs/html/icons/element';
import '@videojs/html/video/ui';
import ejectedSkins from '${jsonPath}';
interface EjectedSkinEntry {
@@ -14,7 +14,7 @@ import '@videojs/html/media/container';
import '@videojs/html/media/simple-hls-video';
import '@videojs/html/ui/play-button';
import '@videojs/html/ui/mute-button';
import { pauseIcon, playIcon, restartIcon, volumeHighIcon, volumeOffIcon } from '@videojs/icons/html';
import { pauseIcon, playIcon, restartIcon, volumeHighIcon, volumeOffIcon } from '@videojs/html/icons';
const html = String.raw;
@@ -1,4 +1,5 @@
import '@app/styles.css';
// SimpleHlsVideo + Video.js integration sandbox — React
// http://localhost:5173/simple-hls-react/
//
@@ -6,8 +7,8 @@ import '@app/styles.css';
// player with play/mute controls. SimpleHlsVideo registers itself via
// useMediaAttach so the store discovers it without any querySelector.
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeOffIcon } from '@videojs/icons/react';
import { Container, createPlayer, MuteButton, PlayButton } from '@videojs/react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeOffIcon } from '@videojs/react/icons';
import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video';
import { videoFeatures } from '@videojs/react/video';
import { createRoot } from 'react-dom/client';
+12
View File
@@ -14,6 +14,8 @@
"types": "dist/dev/index.d.ts",
"sideEffects": [
"./dist/*/define/**/*.js",
"./dist/*/icons/element/**/*.js",
"./dist/*/icons/dist/element/**/*.js",
"./cdn/**/*.js"
],
"files": [
@@ -26,6 +28,16 @@
"development": "./dist/dev/index.js",
"default": "./dist/default/index.js"
},
"./icons": {
"types": "./dist/dev/icons/index.d.ts",
"development": "./dist/dev/icons/index.js",
"default": "./dist/default/icons/index.js"
},
"./icons/*": {
"types": "./dist/dev/icons/*/index.d.ts",
"development": "./dist/dev/icons/*/index.js",
"default": "./dist/default/icons/*/index.js"
},
"./video": {
"types": "./dist/dev/presets/video.d.ts",
"development": "./dist/dev/presets/video.js",
@@ -1 +1,2 @@
import '../icons/element/minimal';
import '../define/audio/minimal-ui';
+1
View File
@@ -1 +1,2 @@
import '../icons/element/default';
import '../define/audio/ui';
@@ -1 +1,2 @@
import '../icons/element/minimal';
import '../define/video/minimal-ui';
+1
View File
@@ -1 +1,2 @@
import '../icons/element/default';
import '../define/video/ui';
@@ -60,9 +60,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="${cn(button.base, button.subtle, button.icon)}">
<span class="${iconContainer}">
@@ -70,9 +68,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
</div>
<div class="${time.controls}">
@@ -94,9 +90,7 @@ function getTemplateHTML() {
<div class="${buttonGroup}">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="${cn(button.base, button.subtle, button.icon, playbackRate.button)}">
</media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-mute-button commandfor="audio-volume-popover" class="${cn(button.base, button.subtle, button.icon, iconState.mute.button)}">
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
@@ -44,9 +44,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-tooltip">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="media-button media-button--subtle media-button--icon media-button--seek">
<span class="media-icon__container">
@@ -54,9 +52,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-tooltip">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-tooltip"></media-tooltip>
</div>
<div class="media-time-controls">
@@ -78,9 +74,7 @@ function getTemplateHTML() {
<div class="media-button-group">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="media-button media-button--subtle media-button--icon media-button--playback-rate">
</media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-tooltip">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-mute-button commandfor="audio-volume-popover" class="media-button media-button--subtle media-button--icon media-button--mute">
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
@@ -60,9 +60,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="${cn(button.base, button.subtle, button.icon)}">
<span class="${iconContainer}">
@@ -70,9 +68,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
</div>
<div class="${time.group}">
@@ -89,9 +85,7 @@ function getTemplateHTML() {
<div class="${buttonGroup}">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="${cn(button.base, button.subtle, button.icon, playbackRate.button)}"></media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-mute-button commandfor="audio-volume-popover" class="${cn(button.base, button.subtle, button.icon, iconState.mute.button)}">
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
+3 -9
View File
@@ -44,9 +44,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-surface media-tooltip">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="media-button media-button--subtle media-button--icon media-button--seek">
<span class="media-icon__container">
@@ -54,9 +52,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-surface media-tooltip">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
</div>
<div class="media-time-controls">
@@ -73,9 +69,7 @@ function getTemplateHTML() {
<div class="media-button-group">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="media-button media-button--subtle media-button--icon media-button--playback-rate"></media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-surface media-tooltip">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-mute-button commandfor="audio-volume-popover" class="media-button media-button--subtle media-button--icon media-button--mute">
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
@@ -67,9 +67,7 @@ function getTemplateHTML() {
${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })}
${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })}
</media-captions-button>
<media-tooltip id="captions-tooltip" side="top" class="media-tooltip">
Toggle captions
</media-tooltip>
<media-tooltip id="captions-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-cast-button commandfor="cast-tooltip" class="media-button media-button--subtle media-button--icon media-button--cast">
${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })}
@@ -73,9 +73,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="${cn(button.base, button.subtle, button.icon)}">
<span class="${iconContainer}">
@@ -83,9 +81,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
</div>
<div class="${time.controls}">
@@ -115,9 +111,7 @@ function getTemplateHTML() {
<div class="${buttonGroupEnd}">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="${cn(button.base, button.subtle, button.icon, playbackRate.button)}">
</media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-mute-button commandfor="video-volume-popover" class="${cn(button.base, button.subtle, button.icon, iconState.mute.button)}">
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
+4 -12
View File
@@ -52,9 +52,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-tooltip">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="media-button media-button--subtle media-button--icon media-button--seek">
<span class="media-icon__container">
@@ -62,9 +60,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-tooltip">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-tooltip"></media-tooltip>
</div>
<div class="media-time-controls">
@@ -93,9 +89,7 @@ function getTemplateHTML() {
<div class="media-button-group">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="media-button media-button--subtle media-button--icon media-button--playback-rate"></media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-tooltip">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-mute-button commandfor="video-volume-popover" class="media-button media-button--subtle media-button--icon media-button--mute">
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
@@ -116,9 +110,7 @@ function getTemplateHTML() {
${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })}
${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })}
</media-captions-button>
<media-tooltip id="captions-tooltip" side="top" class="media-tooltip">
Toggle captions
</media-tooltip>
<media-tooltip id="captions-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-cast-button commandfor="cast-tooltip" class="media-button media-button--subtle media-button--icon media-button--cast">
${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })}
@@ -75,9 +75,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="${cn(button.base, button.subtle, button.icon)}">
<span class="${iconContainer}">
@@ -85,9 +83,7 @@ function getTemplateHTML() {
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
</div>
<div class="${time.group}">
@@ -110,9 +106,7 @@ function getTemplateHTML() {
<div class="${buttonGroupEnd}">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="${cn(button.base, button.subtle, button.icon, playbackRate.button)}"></media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-mute-button commandfor="video-volume-popover" class="${cn(button.base, button.subtle, button.icon, iconState.mute.button)}">
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
+3 -9
View File
@@ -54,9 +54,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-surface media-tooltip">
Seek backward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-backward-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-seek-button commandfor="seek-forward-tooltip" seconds="${SEEK_TIME}" class="media-button media-button--subtle media-button--icon media-button--seek">
<span class="media-icon__container">
@@ -64,9 +62,7 @@ function getTemplateHTML() {
<span class="media-icon__label">${SEEK_TIME}</span>
</span>
</media-seek-button>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-surface media-tooltip">
Seek forward ${SEEK_TIME} seconds
</media-tooltip>
<media-tooltip id="seek-forward-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
</div>
<div class="media-time-controls">
@@ -89,9 +85,7 @@ function getTemplateHTML() {
<div class="media-button-group">
<media-playback-rate-button commandfor="playback-rate-tooltip" class="media-button media-button--subtle media-button--icon media-button--playback-rate"></media-playback-rate-button>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-surface media-tooltip">
Toggle playback rate
</media-tooltip>
<media-tooltip id="playback-rate-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-mute-button commandfor="video-volume-popover" class="media-button media-button--subtle media-button--icon media-button--mute">
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
+1
View File
@@ -0,0 +1 @@
export * from '@videojs/icons/html/default';
@@ -0,0 +1 @@
import '@videojs/icons/element/default';
+1
View File
@@ -0,0 +1 @@
import '@videojs/icons/element';
@@ -0,0 +1 @@
import '@videojs/icons/element/minimal';
+1
View File
@@ -0,0 +1 @@
export * from '@videojs/icons/html';
+1
View File
@@ -0,0 +1 @@
export * from '@videojs/icons/html/minimal';
+116
View File
@@ -0,0 +1,116 @@
import { Window } from 'happy-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('@videojs/html/icons', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('exports SVG strings without registering media-icon', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
const { playIcon } = await import('../index');
expect(playIcon).toContain('<svg');
expect(customElements.get('media-icon')).toBeUndefined();
});
it('renders icons that exist before the registry module loads', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
document.body.innerHTML = '<media-icon name="play"></media-icon>';
await import('../element');
await customElements.whenDefined('media-icon');
await vi.waitFor(() => {
expect(document.querySelector('media-icon')?.innerHTML).toContain('<svg');
});
});
it('renders minimal icons from the lazy family loader', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
document.body.innerHTML = '<media-icon family="minimal" name="play"></media-icon>';
await import('../element');
await customElements.whenDefined('media-icon');
await vi.waitFor(() => {
expect(document.querySelector('media-icon')?.innerHTML).toContain('<svg');
});
});
it('does not microtask-loop when icon name is missing from a loaded family', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
document.body.innerHTML = `
<media-icon id="good" name="play"></media-icon>
<media-icon id="bad" name="__no_such_icon__"></media-icon>
`;
await import('../element');
await customElements.whenDefined('media-icon');
await vi.waitFor(() => {
expect(document.querySelector('#good')?.innerHTML).toContain('<svg');
});
expect(document.querySelector('#bad')?.innerHTML).toBe('');
});
it('does not microtask-loop when family has no loader', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
document.body.innerHTML = '<media-icon family="__unknown_family__" name="play"></media-icon>';
await import('../element');
await customElements.whenDefined('media-icon');
await Promise.resolve();
await Promise.resolve();
expect(document.querySelector('media-icon')?.innerHTML).toBe('');
});
it('renders icons from a family-specific import', async () => {
const testWindow = new Window();
vi.stubGlobal('window', testWindow);
vi.stubGlobal('document', testWindow.document);
vi.stubGlobal('customElements', testWindow.customElements);
vi.stubGlobal('HTMLElement', testWindow.HTMLElement);
document.body.innerHTML = '<media-icon family="minimal" name="play"></media-icon>';
await import('../element/minimal');
await customElements.whenDefined('media-icon');
expect(document.querySelector('media-icon')?.innerHTML).toContain('<svg');
});
});
+4 -1
View File
@@ -83,7 +83,10 @@ for (const mode of buildModes) {
noExternal: [/.*/],
inlineOnly: false,
treeshake: {
moduleSideEffects: [{ test: /\/define\//, sideEffects: true }],
moduleSideEffects: [
{ test: /\/define\//, sideEffects: true },
{ test: /\/icons\/(?:dist\/)?element\//, sideEffects: true },
],
},
outDir,
alias: {
+12 -1
View File
@@ -29,9 +29,17 @@ const presetEntries = Object.fromEntries(
})
);
const iconEntries = Object.fromEntries(
globSync('src/icons/**/index.ts').map((file) => {
const key = file.replace('src/', '').replace('.ts', '');
return [key, file];
})
);
const createConfig = (mode: BuildMode): UserConfig => ({
entry: {
index: 'src/index.ts',
...iconEntries,
...defineEntries,
...presetEntries,
},
@@ -45,7 +53,10 @@ const createConfig = (mode: BuildMode): UserConfig => ({
// The sideEffects field in package.json uses dist paths, but the build
// runs against source. Ensure define/* modules (which register custom
// elements as a side effect) are never tree-shaken from skin bundles.
moduleSideEffects: [{ test: /\/define\//, sideEffects: true }],
moduleSideEffects: [
{ test: /\/define\//, sideEffects: true },
{ test: /\/icons\/(?:dist\/)?element\//, sideEffects: true },
],
},
noExternal: [/^@videojs\/icons/, /^@videojs\/skins/],
alias: {
+18 -6
View File
@@ -35,7 +35,7 @@ Icons are grouped into visual sets. Each set contains the same icon names with d
Import icons as React components. They accept standard SVG props and support ref forwarding.
```tsx
import { PlayIcon, PauseIcon } from '@videojs/icons/react';
import { PlayIcon, PauseIcon } from '@videojs/react/icons';
function Controls() {
return (
@@ -49,7 +49,7 @@ function Controls() {
Import from a specific icon set:
```tsx
import { PlayIcon } from '@videojs/icons/react/minimal';
import { PlayIcon } from '@videojs/react/icons/minimal';
```
### HTML
@@ -57,7 +57,7 @@ import { PlayIcon } from '@videojs/icons/react/minimal';
Import icons as SVG strings for use in plain HTML or web components.
```ts
import { playIcon, pauseIcon } from '@videojs/icons/html';
import { playIcon, pauseIcon } from '@videojs/html/icons';
button.innerHTML = playIcon;
```
@@ -65,7 +65,19 @@ button.innerHTML = playIcon;
Import from a specific icon set:
```ts
import { playIcon } from '@videojs/icons/html/minimal';
import { playIcon } from '@videojs/html/icons/minimal';
```
Register the `<media-icon>` custom element separately when you want HTML skins to render icons lazily:
```ts
import '@videojs/html/icons/element';
```
Import a specific element icon set when eager family registration is preferred:
```ts
import '@videojs/html/icons/element/minimal';
```
## Styling
@@ -100,8 +112,8 @@ The build automatically:
Create a new directory under `src/assets/` (e.g., `src/assets/custom/`), add SVG files, and rebuild. The set is automatically available via:
```ts
import { PlayIcon } from '@videojs/icons/react/custom';
import { playIcon } from '@videojs/icons/html/custom';
import { PlayIcon } from '@videojs/react/icons/custom';
import { playIcon } from '@videojs/html/icons/custom';
```
## Development
+4
View File
@@ -41,6 +41,10 @@
"./element": {
"types": "./dist/element/index.d.ts",
"default": "./dist/element/index.js"
},
"./element/*": {
"types": "./dist/element/*/index.d.ts",
"default": "./dist/element/*/index.js"
}
},
"files": [
+97 -9
View File
@@ -128,19 +128,43 @@ function buildIconMap(icons: { name: string; content: string }[]): string {
}
function buildElementIndex(sets: string[]): string {
const varName = (set: string) => `${camelCase(set)}Icons`;
const imports = sets.map((set) => `import { icons as ${varName(set)} } from './${set}/icons.js';`).join('\n');
const registers = sets.map((set) => `MediaIconElement.register('${set}', ${varName(set)});`).join('\n');
const loaders = sets
.map(
(set) =>
` mediaIconElement.registerLoader?.('${set}', () => import('./${set}/icons.js').then((module) => module.icons));`
)
.join('\n');
return [
`import { MediaIconElement } from './base.js';`,
imports,
``,
`if (!customElements.get('media-icon')) {`,
` customElements.define('media-icon', MediaIconElement);`,
`if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined') {`,
` const mediaIconElement = customElements.get('media-icon') || MediaIconElement;`,
``,
loaders,
``,
` if (!customElements.get('media-icon')) {`,
` customElements.define('media-icon', MediaIconElement);`,
` }`,
`}`,
``,
registers,
].join('\n');
}
function buildElementFamilyIndex(set: string): string {
return [
`import { MediaIconElement } from '../base.js';`,
`import { icons } from './icons.js';`,
``,
`if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined') {`,
` const mediaIconElement = customElements.get('media-icon') || MediaIconElement;`,
``,
` mediaIconElement.register?.('${set}', icons);`,
``,
` if (!customElements.get('media-icon')) {`,
` customElements.define('media-icon', MediaIconElement);`,
` }`,
`}`,
``,
].join('\n');
}
@@ -149,6 +173,9 @@ function buildElementBase(): string {
return [
`export class MediaIconElement extends HTMLElement {`,
` static #families = new Map();`,
` static #loaders = new Map();`,
` static #loading = new Map();`,
` static #instances = new Set();`,
``,
` static register(family, icons) {`,
` const map = MediaIconElement.#families.get(family) ?? new Map();`,
@@ -156,6 +183,37 @@ function buildElementBase(): string {
` map.set(name, svg);`,
` }`,
` MediaIconElement.#families.set(family, map);`,
` MediaIconElement.#renderFamily(family);`,
` }`,
``,
` static registerLoader(family, load) {`,
` MediaIconElement.#loaders.set(family, load);`,
` }`,
``,
` static load(family) {`,
` if (MediaIconElement.#families.has(family)) return Promise.resolve();`,
``,
` const pending = MediaIconElement.#loading.get(family);`,
` if (pending) return pending;`,
``,
` const loader = MediaIconElement.#loaders.get(family);`,
` if (!loader) return Promise.resolve();`,
``,
` const loading = Promise.resolve()`,
` .then(() => loader())`,
` .then((icons) => {`,
` if (icons) MediaIconElement.register(family, icons);`,
` })`,
` .finally(() => MediaIconElement.#loading.delete(family));`,
``,
` MediaIconElement.#loading.set(family, loading);`,
` return loading;`,
` }`,
``,
` static #renderFamily(family) {`,
` for (const icon of MediaIconElement.#instances) {`,
` if (icon.#family === family) icon.#render();`,
` }`,
` }`,
``,
` static get observedAttributes() {`,
@@ -167,17 +225,41 @@ function buildElementBase(): string {
` }`,
``,
` connectedCallback() {`,
` MediaIconElement.#instances.add(this);`,
` this.#render();`,
` }`,
``,
` disconnectedCallback() {`,
` MediaIconElement.#instances.delete(this);`,
` }`,
``,
` get #family() {`,
` return this.getAttribute('family') || 'default';`,
` }`,
``,
` #render() {`,
` const name = this.getAttribute('name');`,
` if (!name) return;`,
``,
` const family = this.getAttribute('family') || 'default';`,
` const family = this.#family;`,
` const icons = MediaIconElement.#families.get(family);`,
` const svg = icons?.get(name);`,
` if (!svg) return;`,
` if (!svg) {`,
` if (MediaIconElement.#families.has(family)) return;`,
``,
` MediaIconElement.load(family).then(() => {`,
` if (`,
` !this.isConnected ||`,
` this.getAttribute('name') !== name ||`,
` this.#family !== family ||`,
` !MediaIconElement.#families.has(family)`,
` ) {`,
` return;`,
` }`,
` this.#render();`,
` }, () => {});`,
` return;`,
` }`,
``,
` this.innerHTML = svg;`,
` }`,
@@ -189,10 +271,14 @@ function buildElementBase(): string {
function buildElementBaseTypes(): string {
return [
`export type IconMap = Record<string, string>;`,
`export type IconLoader = () => IconMap | Promise<IconMap>;`,
``,
`export declare class MediaIconElement extends HTMLElement {`,
` static register(family: string, icons: IconMap): void;`,
` static registerLoader(family: string, load: IconLoader): void;`,
` static load(family: string): Promise<void>;`,
` connectedCallback(): void;`,
` disconnectedCallback(): void;`,
` attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;`,
`}`,
``,
@@ -288,6 +374,8 @@ async function buildIconSet(setName: string): Promise<void> {
writeFileSync(join(elementDir, 'icons.js'), buildIconMap(icons));
writeFileSync(join(elementDir, 'icons.d.ts'), `export declare const icons: Record<string, string>;\n`);
writeFileSync(join(elementDir, 'index.js'), buildElementFamilyIndex(setName));
writeFileSync(join(elementDir, 'index.d.ts'), `export {};\n`);
}
async function build(): Promise<void> {
+10
View File
@@ -22,6 +22,16 @@
"development": "./dist/dev/index.js",
"default": "./dist/default/index.js"
},
"./icons": {
"types": "./dist/dev/icons/index.d.ts",
"development": "./dist/dev/icons/index.js",
"default": "./dist/default/icons/index.js"
},
"./icons/*": {
"types": "./dist/dev/icons/*/index.d.ts",
"development": "./dist/dev/icons/*/index.js",
"default": "./dist/default/icons/*/index.js"
},
"./media/*": {
"types": "./dist/dev/media/*/index.d.ts",
"development": "./dist/dev/media/*/index.js",
+1
View File
@@ -0,0 +1 @@
export * from '@videojs/icons/react';
@@ -0,0 +1 @@
export * from '@videojs/icons/react/minimal';
@@ -1,12 +1,3 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind';
import {
button,
@@ -25,6 +16,15 @@ import {
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
@@ -1,3 +1,5 @@
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
PauseIcon,
PlayIcon,
@@ -6,9 +8,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
@@ -1,12 +1,3 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import {
button,
buttonGroup,
@@ -25,6 +16,7 @@ import {
} from '@videojs/skins/default/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, SeekIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
+1 -9
View File
@@ -1,14 +1,6 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, SeekIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey/hotkey';
@@ -1,11 +1,3 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import {
button,
buttonGroup,
@@ -19,6 +11,7 @@ import {
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
@@ -1,13 +1,6 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
@@ -1,4 +1,3 @@
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/icons/react';
import {
button,
buttonGroup,
@@ -12,6 +11,7 @@ import {
} from '@videojs/skins/default/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { MuteButton } from '@/ui/mute-button';
@@ -1,6 +1,6 @@
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/icons/react';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey/hotkey';
@@ -1,20 +1,3 @@
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import {
bufferingIndicator,
button,
@@ -33,6 +16,23 @@ import {
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,3 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -14,10 +17,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,20 +1,3 @@
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import {
bufferingIndicator,
button,
@@ -33,6 +16,23 @@ import {
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,3 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -14,10 +17,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,21 +1,3 @@
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { playbackRate } from '@videojs/skins/default/tailwind/video.tailwind';
import {
bufferingIndicator,
@@ -40,6 +22,24 @@ import {
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,3 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -15,10 +18,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
@@ -1,21 +1,3 @@
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import {
bufferingIndicator,
button,
@@ -40,6 +22,24 @@ import {
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
+4 -4
View File
@@ -1,3 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -15,10 +18,7 @@ import {
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton } from '@/ui/captions-button';
+52 -114
View File
@@ -2,15 +2,15 @@
* Build ejected skin snippets for copy-paste usage.
*
* Produces `site/src/content/ejected-skins.json` with:
* - HTML skins: rendered HTML templates with inline SVGs and resolved classes
* - React skins: TSX (with types) and JSX (types stripped) with inline SVGs
* - HTML skins: rendered HTML templates with <media-icon> elements and resolved classes
* - React skins: TSX (with types) and JSX (types stripped) with public icon imports
* - CSS variants include a `css` field with all @imports resolved
* - Tailwind variants omit the `css` field (users bring their own Tailwind)
*
* Prerequisites: `pnpm build:packages` (at minimum icons, skins, utils).
* Prerequisites: `pnpm build:packages` (at minimum html, react, icons, skins, utils).
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, relative as relativePath, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
@@ -284,7 +284,7 @@ function resolveRelativeModulePath(importerPath: string, specifier: string): str
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return candidate;
}
}
@@ -410,7 +410,8 @@ function inlineModuleExport(
return `${exportText}\n\n${aliasKeyword} ${localName} = ${importName};`;
}
function inlineRelativeImports(source: string, sourcePath: string): string {
function inlineRelativeImports(source: string, sourcePath: string, rewriteSource = (value: string) => value): string {
source = rewriteSource(source);
const sourceFile = createSourceFile(sourcePath, source);
const declarationsToInline: string[] = [];
const extraImports = new Set<string>();
@@ -433,9 +434,9 @@ function inlineRelativeImports(source: string, sourcePath: string): string {
}
const targetPath = resolveRelativeModulePath(sourcePath, specifier);
const targetSource = readFileSync(targetPath, 'utf-8');
const targetSource = rewriteSource(readFileSync(targetPath, 'utf-8'));
validatePackageImports(targetSource, toRepoPath(targetPath));
const transformedTargetSource = inlineRelativeImports(targetSource, targetPath);
const transformedTargetSource = inlineRelativeImports(targetSource, targetPath, rewriteSource);
const transformedTargetFile = createSourceFile(targetPath, transformedTargetSource);
for (const targetStatement of transformedTargetFile.statements) {
@@ -736,13 +737,6 @@ function parseImportedNames(source: string): Map<string, string> {
return imports;
}
async function loadRenderIcon(
iconSet: 'default' | 'minimal'
): Promise<(name: string, attrs?: Record<string, string>) => string> {
const mod = await import(pkgDistUrl(`@videojs/icons/render/${iconSet}`));
return mod.renderIcon;
}
async function loadCn(): Promise<(...args: unknown[]) => string> {
const mod = await import(pkgDistUrl('@videojs/utils/style'));
return mod.cn;
@@ -779,6 +773,21 @@ function evaluateTemplate(templateBody: string, context: Record<string, unknown>
.trim();
}
function escapeAttributeValue(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}
function createRenderMediaIcon(iconSet: 'default' | 'minimal') {
return (name: string, attrs?: Record<string, string>): string => {
const family = iconSet === 'minimal' ? ' family="minimal"' : '';
const attrText = Object.entries(attrs ?? {})
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
.join('');
return `<media-icon name="${escapeAttributeValue(name)}"${family}${attrText}></media-icon>`;
};
}
/**
* Replace `<slot name="media">`, `<slot>` (default slot), and
* `<slot name="poster">` with concrete elements so the ejected HTML is
@@ -812,12 +821,11 @@ async function processHtmlSkin(skin: HtmlSkinDef): Promise<string> {
validatePackageImports(source, skin.template);
const templateBody = extractTemplateLiteral(source);
const renderIcon = await loadRenderIcon(skin.iconSet);
const cn = await loadCn();
// Build context object with all the variables the template needs
const context: Record<string, unknown> = {
renderIcon,
renderIcon: createRenderMediaIcon(skin.iconSet),
cn,
SEEK_TIME: 10,
};
@@ -853,48 +861,9 @@ async function processHtmlSkin(skin: HtmlSkinDef): Promise<string> {
}
// ---------------------------------------------------------------------------
// React skin processing — inline SVGs, resolve imports, produce TSX + JSX
// React skin processing - resolve imports, produce TSX + JSX
// ---------------------------------------------------------------------------
/** Convert PascalCase icon component name to kebab-case icon name. */
function componentToIconName(name: string): string {
return name
.replace(/Icon$/, '')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
}
/** Convert HTML SVG attribute names to JSX camelCase equivalents. */
function svgToJsx(svg: string): string {
return svg
.replace(/\bstroke-width=/g, 'strokeWidth=')
.replace(/\bstroke-linecap=/g, 'strokeLinecap=')
.replace(/\bstroke-linejoin=/g, 'strokeLinejoin=')
.replace(/\bstroke-dasharray=/g, 'strokeDasharray=')
.replace(/\bstroke-dashoffset=/g, 'strokeDashoffset=')
.replace(/\bstroke-miterlimit=/g, 'strokeMiterlimit=')
.replace(/\bfill-rule=/g, 'fillRule=')
.replace(/\bclip-rule=/g, 'clipRule=')
.replace(/\bfill-opacity=/g, 'fillOpacity=')
.replace(/\bstroke-opacity=/g, 'strokeOpacity=');
}
/** Load the raw icons map from a render dist module. */
async function loadIconsMap(iconSet: 'default' | 'minimal'): Promise<Record<string, string>> {
const mod = await import(pkgDistUrl(`@videojs/icons/render/${iconSet}`));
const renderIcon = mod.renderIcon as (name: string) => string;
const assetsDir = resolve(PACKAGES_ROOT, 'icons/src/assets', iconSet);
const iconNames = readdirSync(assetsDir)
.filter((f) => f.endsWith('.svg'))
.map((f) => f.replace(/\.svg$/, ''));
const map: Record<string, string> = {};
for (const name of iconNames) {
const svg = renderIcon(name);
if (svg) map[name] = svg;
}
return map;
}
/** Serialize a JS value to source code. */
function serializeValue(value: unknown, indent = 0): string {
if (typeof value === 'string') return JSON.stringify(value);
@@ -1019,46 +988,18 @@ function splitTopLevelCommas(str: string): string[] {
return result;
}
/** Remove icon imports and generate icon component definitions. */
async function inlineReactIcons(source: string): Promise<{ source: string; iconComponents: string[] }> {
const sourceFile = createSourceFile('react-skin.tsx', source);
const iconImport = sourceFile.statements.find((statement) => {
if (!ts.isImportDeclaration(statement)) {
return false;
}
const specifier = statement.moduleSpecifier.getText(sourceFile).slice(1, -1);
return /^@videojs\/icons\/react(?:\/(default|minimal))?$/.test(specifier);
});
if (!iconImport || !ts.isImportDeclaration(iconImport)) {
return { source, iconComponents: [] };
}
const iconSpecifier = iconImport.moduleSpecifier.getText(sourceFile).slice(1, -1);
const iconSetMatch = iconSpecifier.match(/@videojs\/icons\/react(?:\/(default|minimal))?/);
const iconSet = (iconSetMatch?.[1] || 'default') as 'default' | 'minimal';
const namedBindings = iconImport.importClause?.namedBindings;
const iconNames =
namedBindings && ts.isNamedImports(namedBindings) ? namedBindings.elements.map((element) => element.name.text) : [];
const iconsMap = await loadIconsMap(iconSet);
const iconComponents: string[] = [];
for (const componentName of iconNames) {
const iconName = componentToIconName(componentName);
const rawSvg = iconsMap[iconName];
if (!rawSvg) {
log.warn(`No SVG found for ${componentName} (icon: ${iconName})`);
continue;
}
const jsxSvg = svgToJsx(rawSvg).replace(/^(<svg[^>]*)>/, '$1 {...props}>');
iconComponents.push(`function ${componentName}(props: ComponentProps<'svg'>): ReactNode {\n return ${jsxSvg};\n}`);
}
// Remove the icon import, keep JSX component calls as-is
source = `${source.slice(0, iconImport.getFullStart())}${source.slice(iconImport.getEnd())}`;
return { source, iconComponents };
/**
* Rewrite package-private or local React icon imports to public package
* re-exports for ejected skins.
*/
function rewriteReactIconImports(source: string): string {
return source
.replace(/from\s+['"]@videojs\/icons\/react(?:\/default)?['"]/g, "from '@videojs/react/icons'")
.replace(/from\s+['"]@videojs\/icons\/react\/minimal['"]/g, "from '@videojs/react/icons/minimal'")
.replace(/from\s+['"]@\/icons['"]/g, "from '@videojs/react/icons'")
.replace(/from\s+['"]@\/icons\/minimal['"]/g, "from '@videojs/react/icons/minimal'")
.replace(/from\s+['"]\.\.\/\.\.\/icons['"]/g, "from '@videojs/react/icons'")
.replace(/from\s+['"]\.\.\/\.\.\/icons\/minimal['"]/g, "from '@videojs/react/icons/minimal'");
}
/**
@@ -1535,55 +1476,52 @@ function flattenSkinIntoPlayer(source: string, mediaType: MediaType): string {
}
/**
* Process a React skin: inline SVG icons, resolve all imports,
* Process a React skin: rewrite icon imports, resolve imports,
* and produce both TSX and JSX versions.
*/
async function processReactSkin(skin: ReactSkinDef): Promise<{ tsx: string; jsx: string }> {
const absPath = resolve(ROOT, skin.source);
let source = readFileSync(absPath, 'utf-8');
source = rewriteReactIconImports(source);
validatePackageImports(source, skin.source);
const postImport: string[] = [];
// 1. Inline relative imports recursively so the output is self-contained.
source = inlineRelativeImports(source, absPath);
source = inlineRelativeImports(source, absPath, rewriteReactIconImports);
// 2. Extract icon components (remove import, keep JSX calls, generate components)
const icons = await inlineReactIcons(source);
source = icons.source;
// 3. Resolve @videojs/skins/* tokens (Tailwind skins only, private package)
// 2. Resolve @videojs/skins/* tokens (Tailwind skins only, private package)
source = await inlineSkinTokens(source, postImport);
// 4. Replace cn calls with template literals
// 3. Replace cn calls with template literals
source = inlineCn(source);
// 5. Consolidate @/ path aliases → @videojs/react
// 4. Consolidate @/ path aliases → @videojs/react
source = rewritePathAliases(source);
// 6. Inline private package imports (core/dom → react, predicates, isRenderProp)
// 5. Inline private package imports (core/dom → react, predicates, isRenderProp)
const privates = inlinePrivatePackages(source);
source = privates.source;
// 7. Insert collected non-import code after the final import statement
// 6. Insert collected non-import code after the final import statement
if (postImport.length > 0) {
const insertPos = findLastImportEnd(source);
const block = `\n${postImport.join('\n\n')}\n`;
source = `${source.slice(0, insertPos)}${block}${source.slice(insertPos)}`;
}
// 8. Replace Base*SkinProps chain with a clean interface
// 7. Replace Base*SkinProps chain with a clean interface
source = resolvePropsInterface(source);
// 9. Flatten ERROR_CLASSNAMES into ErrorDialog JSX (@temporary — remove with flattenErrorClasses)
// 8. Flatten ERROR_CLASSNAMES into ErrorDialog JSX (@temporary — remove with flattenErrorClasses)
source = flattenErrorClasses(source);
// 10. Reorganize into sections with comment headers
let tsx = reorganizeReactOutput(source, privates.utilities, icons.iconComponents);
// 9. Reorganize into sections with comment headers
let tsx = reorganizeReactOutput(source, privates.utilities, []);
// 11. Destructure skin props in function argument instead of body
// 10. Destructure skin props in function argument instead of body
tsx = destructureSkinProps(tsx);
// 12. Flatten skin into player (merge props, inline body, wrap in Player.Provider)
// 11. Flatten skin into player (merge props, inline body, wrap in Player.Provider)
tsx = flattenSkinIntoPlayer(tsx, getSkinMediaType(skin));
const jsx = tsxToJsx(tsx);