diff --git a/apps/e2e/scripts/generate-pages.ts b/apps/e2e/scripts/generate-pages.ts index 92da6744..f20aaf0e 100644 --- a/apps/e2e/scripts/generate-pages.ts +++ b/apps/e2e/scripts/generate-pages.ts @@ -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 { diff --git a/apps/sandbox/templates/simple-hls-html/main.ts b/apps/sandbox/templates/simple-hls-html/main.ts index fd48d583..682854ab 100644 --- a/apps/sandbox/templates/simple-hls-html/main.ts +++ b/apps/sandbox/templates/simple-hls-html/main.ts @@ -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; diff --git a/apps/sandbox/templates/simple-hls-react/main.tsx b/apps/sandbox/templates/simple-hls-react/main.tsx index 4ca5c771..6519acb9 100644 --- a/apps/sandbox/templates/simple-hls-react/main.tsx +++ b/apps/sandbox/templates/simple-hls-react/main.tsx @@ -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'; diff --git a/packages/html/package.json b/packages/html/package.json index cc712365..d073a30d 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -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", diff --git a/packages/html/src/cdn/audio-minimal-ui.ts b/packages/html/src/cdn/audio-minimal-ui.ts index 96aeef70..9e1bed8c 100644 --- a/packages/html/src/cdn/audio-minimal-ui.ts +++ b/packages/html/src/cdn/audio-minimal-ui.ts @@ -1 +1,2 @@ +import '../icons/element/minimal'; import '../define/audio/minimal-ui'; diff --git a/packages/html/src/cdn/audio-ui.ts b/packages/html/src/cdn/audio-ui.ts index d60b162f..e01dcd4a 100644 --- a/packages/html/src/cdn/audio-ui.ts +++ b/packages/html/src/cdn/audio-ui.ts @@ -1 +1,2 @@ +import '../icons/element/default'; import '../define/audio/ui'; diff --git a/packages/html/src/cdn/video-minimal-ui.ts b/packages/html/src/cdn/video-minimal-ui.ts index 60e3ef8a..e5fe608a 100644 --- a/packages/html/src/cdn/video-minimal-ui.ts +++ b/packages/html/src/cdn/video-minimal-ui.ts @@ -1 +1,2 @@ +import '../icons/element/minimal'; import '../define/video/minimal-ui'; diff --git a/packages/html/src/cdn/video-ui.ts b/packages/html/src/cdn/video-ui.ts index 181750ea..e2f94afc 100644 --- a/packages/html/src/cdn/video-ui.ts +++ b/packages/html/src/cdn/video-ui.ts @@ -1 +1,2 @@ +import '../icons/element/default'; import '../define/video/ui'; diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index 513bf64d..f1c825a6 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -60,9 +60,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -70,9 +68,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -94,9 +90,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 0e7f2e9f..09d4fa3e 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -44,9 +44,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -54,9 +52,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -78,9 +74,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 5f51e482..db72b6d7 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -60,9 +60,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -70,9 +68,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -89,9 +85,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index fa1da084..1d71c322 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -44,9 +44,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -54,9 +52,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -73,9 +69,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 35e9e5f9..b2945b21 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -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' })} - - Toggle captions - + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 70a8a75d..984e98f1 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -73,9 +73,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -83,9 +81,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -115,9 +111,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 8fb7e059..aed2ccba 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -52,9 +52,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -62,9 +60,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -93,9 +89,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${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' })} - - Toggle captions - + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 63dc732e..c397402f 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -75,9 +75,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -85,9 +83,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -110,9 +106,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index e186f423..f54979dd 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -54,9 +54,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek backward ${SEEK_TIME} seconds - + @@ -64,9 +62,7 @@ function getTemplateHTML() { ${SEEK_TIME} - - Seek forward ${SEEK_TIME} seconds - +
@@ -89,9 +85,7 @@ function getTemplateHTML() {
- - Toggle playback rate - + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/icons/default/index.ts b/packages/html/src/icons/default/index.ts new file mode 100644 index 00000000..0cb444fb --- /dev/null +++ b/packages/html/src/icons/default/index.ts @@ -0,0 +1 @@ +export * from '@videojs/icons/html/default'; diff --git a/packages/html/src/icons/element/default/index.ts b/packages/html/src/icons/element/default/index.ts new file mode 100644 index 00000000..d38e5b86 --- /dev/null +++ b/packages/html/src/icons/element/default/index.ts @@ -0,0 +1 @@ +import '@videojs/icons/element/default'; diff --git a/packages/html/src/icons/element/index.ts b/packages/html/src/icons/element/index.ts new file mode 100644 index 00000000..a707824d --- /dev/null +++ b/packages/html/src/icons/element/index.ts @@ -0,0 +1 @@ +import '@videojs/icons/element'; diff --git a/packages/html/src/icons/element/minimal/index.ts b/packages/html/src/icons/element/minimal/index.ts new file mode 100644 index 00000000..6d4ec233 --- /dev/null +++ b/packages/html/src/icons/element/minimal/index.ts @@ -0,0 +1 @@ +import '@videojs/icons/element/minimal'; diff --git a/packages/html/src/icons/index.ts b/packages/html/src/icons/index.ts new file mode 100644 index 00000000..14b2ff76 --- /dev/null +++ b/packages/html/src/icons/index.ts @@ -0,0 +1 @@ +export * from '@videojs/icons/html'; diff --git a/packages/html/src/icons/minimal/index.ts b/packages/html/src/icons/minimal/index.ts new file mode 100644 index 00000000..79f8cbf5 --- /dev/null +++ b/packages/html/src/icons/minimal/index.ts @@ -0,0 +1 @@ +export * from '@videojs/icons/html/minimal'; diff --git a/packages/html/src/icons/tests/index.test.ts b/packages/html/src/icons/tests/index.test.ts new file mode 100644 index 00000000..c2a8700b --- /dev/null +++ b/packages/html/src/icons/tests/index.test.ts @@ -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(' { + 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 = ''; + + await import('../element'); + await customElements.whenDefined('media-icon'); + + await vi.waitFor(() => { + expect(document.querySelector('media-icon')?.innerHTML).toContain(' { + 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 = ''; + + await import('../element'); + await customElements.whenDefined('media-icon'); + + await vi.waitFor(() => { + expect(document.querySelector('media-icon')?.innerHTML).toContain(' { + 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 = ` + + + `; + + await import('../element'); + await customElements.whenDefined('media-icon'); + + await vi.waitFor(() => { + expect(document.querySelector('#good')?.innerHTML).toContain(' { + 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 = ''; + + 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 = ''; + + await import('../element/minimal'); + await customElements.whenDefined('media-icon'); + + expect(document.querySelector('media-icon')?.innerHTML).toContain(' { + 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: { diff --git a/packages/icons/README.md b/packages/icons/README.md index 6cd23572..be9cf97e 100644 --- a/packages/icons/README.md +++ b/packages/icons/README.md @@ -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 `` 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 diff --git a/packages/icons/package.json b/packages/icons/package.json index f6a5d48e..fa41f598 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -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": [ diff --git a/packages/icons/scripts/build.ts b/packages/icons/scripts/build.ts index a7d3758b..e6d2a84b 100644 --- a/packages/icons/scripts/build.ts +++ b/packages/icons/scripts/build.ts @@ -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;`, + `export type IconLoader = () => IconMap | Promise;`, ``, `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;`, ` connectedCallback(): void;`, + ` disconnectedCallback(): void;`, ` attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;`, `}`, ``, @@ -288,6 +374,8 @@ async function buildIconSet(setName: string): Promise { writeFileSync(join(elementDir, 'icons.js'), buildIconMap(icons)); writeFileSync(join(elementDir, 'icons.d.ts'), `export declare const icons: Record;\n`); + writeFileSync(join(elementDir, 'index.js'), buildElementFamilyIndex(setName)); + writeFileSync(join(elementDir, 'index.d.ts'), `export {};\n`); } async function build(): Promise { diff --git a/packages/react/package.json b/packages/react/package.json index da56f51f..01c5add4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/icons/index.ts b/packages/react/src/icons/index.ts new file mode 100644 index 00000000..93b33e1a --- /dev/null +++ b/packages/react/src/icons/index.ts @@ -0,0 +1 @@ +export * from '@videojs/icons/react'; diff --git a/packages/react/src/icons/minimal/index.ts b/packages/react/src/icons/minimal/index.ts new file mode 100644 index 00000000..baa74810 --- /dev/null +++ b/packages/react/src/icons/minimal/index.ts @@ -0,0 +1 @@ +export * from '@videojs/icons/react/minimal'; diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index ba58e14f..4db7b85a 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index 08099f7b..a2d6524a 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -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'; diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index df9f8b8f..efa333d0 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 8dd3eb7e..881c24c2 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -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'; diff --git a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx index ad65a1df..5a18cf8f 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/live-audio/minimal-skin.tsx b/packages/react/src/presets/live-audio/minimal-skin.tsx index aa9ed7eb..4ac15878 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tsx @@ -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'; diff --git a/packages/react/src/presets/live-audio/skin.tailwind.tsx b/packages/react/src/presets/live-audio/skin.tailwind.tsx index 710a3165..97b0fe04 100644 --- a/packages/react/src/presets/live-audio/skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/live-audio/skin.tsx b/packages/react/src/presets/live-audio/skin.tsx index 8d3fcf01..deb81fa2 100644 --- a/packages/react/src/presets/live-audio/skin.tsx +++ b/packages/react/src/presets/live-audio/skin.tsx @@ -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'; diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index 8f14d516..e91b5927 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index c1bfc740..d8b08766 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -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'; diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index 9bd340cf..60f1904e 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index 750fae87..909abd38 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -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'; diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 001554eb..b15facd0 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index 9d878044..e4d3166b 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -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'; diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 45f26d62..0e50c467 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -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'; diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 387a6bfc..95477070 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -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'; diff --git a/site/scripts/build-ejected-skins.ts b/site/scripts/build-ejected-skins.ts index 9e4f7584..da8b0037 100644 --- a/site/scripts/build-ejected-skins.ts +++ b/site/scripts/build-ejected-skins.ts @@ -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 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(); @@ -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 { return imports; } -async function loadRenderIcon( - iconSet: 'default' | 'minimal' -): Promise<(name: string, attrs?: Record) => 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 .trim(); } +function escapeAttributeValue(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"'); +} + +function createRenderMediaIcon(iconSet: 'default' | 'minimal') { + return (name: string, attrs?: Record): string => { + const family = iconSet === 'minimal' ? ' family="minimal"' : ''; + const attrText = Object.entries(attrs ?? {}) + .map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`) + .join(''); + + return ``; + }; +} + /** * Replace ``, `` (default slot), and * `` with concrete elements so the ejected HTML is @@ -812,12 +821,11 @@ async function processHtmlSkin(skin: HtmlSkinDef): Promise { 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 = { - renderIcon, + renderIcon: createRenderMediaIcon(skin.iconSet), cn, SEEK_TIME: 10, }; @@ -853,48 +861,9 @@ async function processHtmlSkin(skin: HtmlSkinDef): Promise { } // --------------------------------------------------------------------------- -// 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> { - 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 = {}; - 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(/^(]*)>/, '$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);