From 5f48fcc6d6d81c2cb990eae056aa562fcd1cc4ee Mon Sep 17 00:00:00 2001 From: Renzo Delfino <75499398+R-Delfino95@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:59:57 -0300 Subject: [PATCH] fix(docs): split ejected React skin sample into player + component files (#1588) --- apps/e2e/scripts/sync-ejected-skins.ts | 12 +++- site/scripts/build-ejected-skins.ts | 76 ++++++++++++++-------- site/src/components/docs/EjectedSkin.astro | 39 +++++++++-- site/src/components/home/Demo/Demo.astro | 14 +++- site/src/components/home/Demo/Eject.tsx | 13 ++++ site/src/content.config.ts | 4 +- 6 files changed, 117 insertions(+), 41 deletions(-) diff --git a/apps/e2e/scripts/sync-ejected-skins.ts b/apps/e2e/scripts/sync-ejected-skins.ts index 66e2ea33..3a6d2ebc 100644 --- a/apps/e2e/scripts/sync-ejected-skins.ts +++ b/apps/e2e/scripts/sync-ejected-skins.ts @@ -14,7 +14,7 @@ const OUT_DIR = resolve(import.meta.dirname, '../apps/vite/src/_generated'); interface EjectedSkinEntry { id: string; platform: string; - tsx?: string; + tsx?: Record; css?: string; } @@ -22,13 +22,19 @@ const skins: EjectedSkinEntry[] = JSON.parse(readFileSync(EJECTED_SKINS_JSON, 'u const reactVideo = skins.find((s) => s.id === 'default-video-react'); -if (!reactVideo?.tsx) { +const componentSource = reactVideo?.tsx?.['VideoPlayer.tsx']; +const playerSource = reactVideo?.tsx?.['player.ts']; + +if (!componentSource || !playerSource) { throw new Error('Ejected skin "default-video-react" not found. Run `pnpm -F site ejected-skins` first.'); } mkdirSync(OUT_DIR, { recursive: true }); -writeFileSync(resolve(OUT_DIR, 'ejected-react-video-skin.tsx'), reactVideo.tsx); +// Component file is renamed to match the import path used in generate-pages.ts; +// `player.ts` keeps its name so the relative `./player` import resolves. +writeFileSync(resolve(OUT_DIR, 'ejected-react-video-skin.tsx'), componentSource); +writeFileSync(resolve(OUT_DIR, 'player.ts'), playerSource); if (reactVideo.css) { writeFileSync(resolve(OUT_DIR, 'player.css'), reactVideo.css); diff --git a/site/scripts/build-ejected-skins.ts b/site/scripts/build-ejected-skins.ts index 824a5b87..09bc4e2d 100644 --- a/site/scripts/build-ejected-skins.ts +++ b/site/scripts/build-ejected-skins.ts @@ -79,8 +79,8 @@ interface EjectedSkinEntry { platform: 'html' | 'react'; style: 'css' | 'tailwind'; html?: string; - tsx?: string; - jsx?: string; + tsx?: Record; + jsx?: Record; css?: string; } @@ -1397,11 +1397,17 @@ function destructureSkinProps(source: string): string { } /** - * Flatten the skin into a Player component: merge SkinProps into PlayerProps - * (adding `src`), inline the skin body into VideoPlayer/AudioPlayer wrapped - * in `Player.Provider`, and remove the separate Skin export. + * Flatten the skin into a Player component. Produces two files: + * - `player.ts`: owns the `createPlayer({ features })` call and exports `Player`. + * - `VideoPlayer.tsx` / `AudioPlayer.tsx`: imports `Player` from `./player` and + * owns the React component. Splitting these avoids React Fast Refresh bailing + * out (a file must export only components for Fast Refresh to apply edits). + * + * Also: merges SkinProps into PlayerProps (adding `src`), inlines the skin body + * into VideoPlayer/AudioPlayer wrapped in `Player.Provider`, and removes the + * separate Skin export. */ -function flattenSkinIntoPlayer(source: string, mediaType: MediaType): string { +function flattenSkinIntoPlayer(source: string, mediaType: MediaType): { player: string; component: string } { const isVideo = mediaType === 'video'; const mediaTag = isVideo ? 'Video' : 'Audio'; const features = isVideo ? 'videoFeatures' : 'audioFeatures'; @@ -1409,25 +1415,28 @@ function flattenSkinIntoPlayer(source: string, mediaType: MediaType): string { const subpath = isVideo ? 'video' : 'audio'; const playsInline = isVideo ? ' playsInline' : ''; - // 1. Add createPlayer to the @videojs/react import - source = source.replace( - /import \{([^}]+)\} from '@videojs\/react';/, - (_, names) => `import { createPlayer,${names}} from '@videojs/react';` - ); + const player = [ + `import { createPlayer } from '@videojs/react';`, + `import { ${features} } from '@videojs/react/${subpath}';`, + '', + `export const Player = createPlayer({ features: ${features} });`, + '', + ].join('\n'); - // 2. Add Video/Audio + features import and CSS import - const mediaImport = `import { ${mediaTag}, ${features} } from '@videojs/react/${subpath}';`; + // 1. Add Video/Audio import, CSS import, and Player import from ./player + const mediaImport = `import { ${mediaTag} } from '@videojs/react/${subpath}';`; const cssImport = "import './player.css';"; - source = source.replace(/(import \{[^}]*\} from '@videojs\/react';)/, `$1\n${mediaImport}\n${cssImport}`); - - // 3. Add Player const above the interface, rename SkinProps → PlayerProps, replace `children` with `src` + const playerImport = "import { Player } from './player';"; source = source.replace( - /export interface \w+SkinProps/, - `export const Player = createPlayer({ features: ${features} });\n\nexport interface ${playerName}Props` + /(import \{[^}]*\} from '@videojs\/react';)/, + `$1\n${mediaImport}\n${cssImport}\n${playerImport}` ); + + // 2. Rename SkinProps → PlayerProps, replace `children` with `src` + source = source.replace(/export interface \w+SkinProps/, `export interface ${playerName}Props`); source = source.replace(/(\s*)children\?: ReactNode;/, `$1src: string;`); - // 4. Replace the skin function: rename, swap children→src, wrap in Player.Provider + // 3. Replace the skin function: rename, swap children→src, wrap in Player.Provider // Match the destructured form: function XSkin({ children, className, poster, ...rest }: XSkinProps): ReactNode { source = source.replace( /export function \w+Skin\(\{ children, ([^}]+)\}: \w+SkinProps\): ReactNode \{\n([\s\S]*?)\n\}/, @@ -1452,7 +1461,7 @@ function flattenSkinIntoPlayer(source: string, mediaType: MediaType): string { const hasPoster = destructuredRest.includes('poster'); const posterExample = hasPoster ? `\n * poster="${DEMO_POSTER_SRC}"` : ''; - // Player const, @example JSDoc, and the function signature + // @example JSDoc and the function signature const header = [ '/**', ' * @example', @@ -1469,17 +1478,19 @@ function flattenSkinIntoPlayer(source: string, mediaType: MediaType): string { } ); - // 5. Remove the "Skin" section header (it's now part of "Player") + // 4. Remove the "Skin" section header (it's now part of "Player") source = source.replace(/\/\/ =+\n\/\/ Skin\n\/\/ =+\n\n/, `${sectionHeader('Player')}\n\n`); - return source; + return { player, component: source }; } /** * 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 }> { +async function processReactSkin( + skin: ReactSkinDef +): Promise<{ tsx: Record; jsx: Record }> { const absPath = resolve(ROOT, skin.source); let source = readFileSync(absPath, 'utf-8'); source = rewriteReactIconImports(source); @@ -1521,12 +1532,21 @@ async function processReactSkin(skin: ReactSkinDef): Promise<{ tsx: string; jsx: // 10. Destructure skin props in function argument instead of body tsx = destructureSkinProps(tsx); - // 11. Flatten skin into player (merge props, inline body, wrap in Player.Provider) - tsx = flattenSkinIntoPlayer(tsx, getSkinMediaType(skin)); + // 11. Flatten skin into player (split into player.ts + Player.tsx, wrap in Player.Provider) + const mediaType = getSkinMediaType(skin); + const { player, component } = flattenSkinIntoPlayer(tsx, mediaType); + const componentFile = mediaType === 'video' ? 'VideoPlayer' : 'AudioPlayer'; - const jsx = tsxToJsx(tsx); - - return { tsx, jsx }; + return { + tsx: { + 'player.ts': player, + [`${componentFile}.tsx`]: component, + }, + jsx: { + 'player.js': tsxToJsx(player), + [`${componentFile}.jsx`]: tsxToJsx(component), + }, + }; } // --------------------------------------------------------------------------- diff --git a/site/src/components/docs/EjectedSkin.astro b/site/src/components/docs/EjectedSkin.astro index b12fe2a3..f0af4892 100644 --- a/site/src/components/docs/EjectedSkin.astro +++ b/site/src/components/docs/EjectedSkin.astro @@ -1,5 +1,6 @@ --- import { getEntry } from 'astro:content'; +import type { BundledLanguage } from 'shiki'; import ServerCode from '@/components/Code/ServerCode.astro'; import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx'; @@ -11,24 +12,50 @@ const { id } = Astro.props; const entry = await getEntry('ejectedSkins', id); if (!entry) return; const skin = entry.data; + +const EXT_TO_LANG: Record = { + ts: 'ts', + tsx: 'tsx', + js: 'js', + jsx: 'jsx', +}; + +function langFromFilename(filename: string): BundledLanguage { + const ext = filename.split('.').pop() ?? ''; + return EXT_TO_LANG[ext] ?? 'tsx'; +} + +// Order React files so the component file is first (initial tab), then config files. +const reactFiles = skin.tsx + ? Object.entries(skin.tsx).sort(([a], [b]) => { + const aIsComponent = a.endsWith('.tsx') || a.endsWith('.jsx'); + const bIsComponent = b.endsWith('.tsx') || b.endsWith('.jsx'); + if (aIsComponent === bIsComponent) return a.localeCompare(b); + return aIsComponent ? -1 : 1; + }) + : []; --- { skin.platform === "react" ? ( - - Skin.tsx - + {reactFiles.map(([filename], index) => ( + + {filename} + + ))} {skin.css && ( skin.css )} - - - + {reactFiles.map(([filename, code], index) => ( + + + + ))} {skin.css && ( diff --git a/site/src/components/home/Demo/Demo.astro b/site/src/components/home/Demo/Demo.astro index 29defdef..b7e87c29 100644 --- a/site/src/components/home/Demo/Demo.astro +++ b/site/src/components/home/Demo/Demo.astro @@ -78,9 +78,14 @@ const minimalVideoReact = (await getEntry('ejectedSkins', 'minimal-video-react') + + {codeLabel} + {playerSlot && ( + + JS + + )} CSS @@ -51,6 +59,11 @@ export default function EjectDemo(props: EjectDemoProps) { {codeSlot} + {playerSlot && ( + + {playerSlot} + + )} {cssSlot} diff --git a/site/src/content.config.ts b/site/src/content.config.ts index e1547a3d..9acb9e84 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -183,8 +183,8 @@ const ejectedSkins = defineCollection({ platform: z.enum(['html', 'react']), style: z.enum(['css', 'tailwind']), html: z.string().optional(), - tsx: z.string().optional(), - jsx: z.string().optional(), + tsx: z.record(z.string(), z.string()).optional(), + jsx: z.record(z.string(), z.string()).optional(), css: z.string().optional(), }), });