feat(cli): add @videojs/cli docs command for LLM-friendly installation (#1214)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-04-14 11:07:49 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9681515446
commit 24b8b77c8a
49 changed files with 2061 additions and 460 deletions
@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
generateHTMLInstallCode,
generateHTMLUsageCode,
generateReactCreateCode,
generateReactInstallCode,
generateReactUsageCode,
type InstallationOptions,
validateInstallationOptions,
} from '../codegen';
const baseHTML: InstallationOptions = {
framework: 'html',
useCase: 'default-video',
skin: 'video',
renderer: 'html5-video',
sourceUrl: '',
installMethod: 'npm',
};
const baseReact: InstallationOptions = {
framework: 'react',
useCase: 'default-video',
skin: 'video',
renderer: 'html5-video',
sourceUrl: '',
installMethod: 'npm',
};
describe('validateInstallationOptions', () => {
it('accepts valid HTML + npm combo', () => {
expect(validateInstallationOptions(baseHTML)).toEqual({ valid: true });
});
it('accepts valid React + npm combo', () => {
expect(validateInstallationOptions(baseReact)).toEqual({ valid: true });
});
it('rejects React + CDN', () => {
const result = validateInstallationOptions({ ...baseReact, installMethod: 'cdn' });
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.reason).toContain('CDN');
}
});
it('accepts any renderer regardless of use case', () => {
expect(validateInstallationOptions({ ...baseHTML, useCase: 'default-audio', renderer: 'hls' })).toEqual({
valid: true,
});
});
});
describe('generateHTMLInstallCode', () => {
it('returns install commands for all methods', () => {
const result = generateHTMLInstallCode(baseHTML);
expect(result.npm).toBe('npm install @videojs/html');
expect(result.pnpm).toBe('pnpm add @videojs/html');
expect(result.yarn).toBe('yarn add @videojs/html');
expect(result.bun).toBe('bun add @videojs/html');
});
it('returns CDN script tags', () => {
const result = generateHTMLInstallCode(baseHTML);
expect(result.cdn).toContain('<script');
expect(result.cdn).toContain('cdn.jsdelivr.net');
});
it('includes HLS media script in CDN output', () => {
const result = generateHTMLInstallCode({ ...baseHTML, renderer: 'hls' });
expect(result.cdn).toContain('media/hls-video.js');
});
});
describe('generateReactInstallCode', () => {
it('returns install commands for all methods', () => {
const result = generateReactInstallCode();
expect(result.npm).toBe('npm install @videojs/react');
expect(result.pnpm).toBe('pnpm add @videojs/react');
expect(result.yarn).toBe('yarn add @videojs/react');
expect(result.bun).toBe('bun add @videojs/react');
});
});
describe('generateHTMLUsageCode', () => {
it('generates HTML with video-player and video-skin for default video', () => {
const result = generateHTMLUsageCode(baseHTML);
expect(result.html).toContain('<video-player>');
expect(result.html).toContain('<video-skin>');
expect(result.html).toContain('<video src=');
expect(result.html).toContain('playsinline');
});
it('includes JS imports when not CDN', () => {
const result = generateHTMLUsageCode(baseHTML);
expect(result.js).toBeDefined();
expect(result.js).toContain("import '@videojs/html/video/player'");
expect(result.js).toContain("import '@videojs/html/video/skin'");
});
it('omits JS imports when CDN', () => {
const result = generateHTMLUsageCode({ ...baseHTML, installMethod: 'cdn' });
expect(result.js).toBeUndefined();
});
it('uses audio tags for audio use case', () => {
const opts: InstallationOptions = {
...baseHTML,
useCase: 'default-audio',
skin: 'audio',
renderer: 'html5-audio',
};
const result = generateHTMLUsageCode(opts);
expect(result.html).toContain('<audio-player>');
expect(result.html).toContain('<audio-skin>');
expect(result.html).toContain('<audio src=');
expect(result.html).not.toContain('playsinline');
});
it('uses background-video tags', () => {
const opts: InstallationOptions = {
...baseHTML,
useCase: 'background-video',
renderer: 'background-video',
};
const result = generateHTMLUsageCode(opts);
expect(result.html).toContain('<background-video-player>');
expect(result.html).toContain('<background-video-skin>');
});
it('includes HLS media import in JS', () => {
const result = generateHTMLUsageCode({ ...baseHTML, renderer: 'hls' });
expect(result.js).toContain("import '@videojs/html/media/hls-video'");
});
it('uses minimal skin tag', () => {
const result = generateHTMLUsageCode({ ...baseHTML, skin: 'minimal-video' });
expect(result.html).toContain('<video-minimal-skin>');
expect(result.js).toContain("import '@videojs/html/video/minimal-skin'");
});
it('uses custom source URL when provided', () => {
const result = generateHTMLUsageCode({ ...baseHTML, sourceUrl: 'https://example.com/video.mp4' });
expect(result.html).toContain('https://example.com/video.mp4');
});
it('uses default demo URL when source URL is empty', () => {
const result = generateHTMLUsageCode(baseHTML);
expect(result.html).toContain('stream.mux.com');
});
});
describe('generateReactCreateCode', () => {
it('generates a React player component for default video', () => {
const result = generateReactCreateCode(baseReact);
const code = result['MyPlayer.tsx'];
expect(code).toContain("'use client'");
expect(code).toContain('createPlayer');
expect(code).toContain('videoFeatures');
expect(code).toContain('<VideoSkin>');
expect(code).toContain('<Video src={src} playsInline />');
expect(code).toContain("from '@videojs/react/video'");
expect(code).toContain("import '@videojs/react/video/skin.css'");
});
it('uses separate media import for HLS', () => {
const result = generateReactCreateCode({ ...baseReact, renderer: 'hls' });
const code = result['MyPlayer.tsx'];
expect(code).toContain("import { VideoSkin } from '@videojs/react/video'");
expect(code).toContain("import { HlsVideo } from '@videojs/react/media/hls-video'");
expect(code).toContain('<HlsVideo src={src} playsInline />');
});
it('uses audio features and components', () => {
const opts: InstallationOptions = {
...baseReact,
useCase: 'default-audio',
skin: 'audio',
renderer: 'html5-audio',
};
const result = generateReactCreateCode(opts);
const code = result['MyPlayer.tsx'];
expect(code).toContain('audioFeatures');
expect(code).toContain('<AudioSkin>');
expect(code).toContain('<Audio src={src} />');
expect(code).not.toContain('playsInline');
});
it('uses minimal skin component', () => {
const result = generateReactCreateCode({ ...baseReact, skin: 'minimal-video' });
const code = result['MyPlayer.tsx'];
expect(code).toContain('<MinimalVideoSkin>');
expect(code).toContain("import '@videojs/react/video/minimal-skin.css'");
});
it('uses background video components', () => {
const opts: InstallationOptions = {
...baseReact,
useCase: 'background-video',
renderer: 'background-video',
};
const result = generateReactCreateCode(opts);
const code = result['MyPlayer.tsx'];
expect(code).toContain('backgroundFeatures');
expect(code).toContain('<BackgroundVideoSkin>');
expect(code).toContain('<BackgroundVideo');
expect(code).toContain("import '@videojs/react/background/skin.css'");
});
});
describe('generateReactUsageCode', () => {
it('generates usage code with default URL', () => {
const result = generateReactUsageCode(baseReact);
const code = result['App.tsx'];
expect(code).toContain("import { MyPlayer } from '../components/player'");
expect(code).toContain('<MyPlayer src=');
expect(code).toContain('stream.mux.com');
});
it('uses HLS URL for HLS renderer', () => {
const result = generateReactUsageCode({ ...baseReact, renderer: 'hls' });
const code = result['App.tsx'];
expect(code).toContain('.m3u8');
});
it('uses custom source URL', () => {
const result = generateReactUsageCode({ ...baseReact, sourceUrl: 'https://example.com/stream.m3u8' });
const code = result['App.tsx'];
expect(code).toContain('https://example.com/stream.m3u8');
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import type { Renderer, Skin, UseCase } from '@/utils/installation/types';
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn';
+294
View File
@@ -0,0 +1,294 @@
import { VJS10_DEMO_VIDEO } from '@/consts';
import { generateCdnCode } from '@/utils/installation/cdn-code';
import type { InstallMethod, Renderer, Skin, UseCase } from '@/utils/installation/types';
export interface InstallationOptions {
framework: 'html' | 'react';
useCase: UseCase;
skin: Skin;
renderer: Renderer;
sourceUrl: string;
installMethod: InstallMethod;
}
type ValidationResult = { valid: true } | { valid: false; reason: string };
export function validateInstallationOptions(opts: InstallationOptions): ValidationResult {
if (opts.framework === 'react' && opts.installMethod === 'cdn') {
return { valid: false, reason: 'CDN installation is not supported for React. Use npm, pnpm, yarn, or bun.' };
}
return { valid: true };
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function getDefaultSourceUrl(renderer: Renderer): string {
return renderer === 'hls' ? VJS10_DEMO_VIDEO.hls : VJS10_DEMO_VIDEO.mp4;
}
function resolveSourceUrl(sourceUrl: string, renderer: Renderer): string {
return sourceUrl.trim() || getDefaultSourceUrl(renderer);
}
function isVideoLikeRenderer(renderer: Renderer): boolean {
return renderer === 'html5-video' || renderer === 'hls' || renderer === 'background-video';
}
function getSkinImportParts(skin: Skin): { group: string; skinFile: string } {
if (skin === 'minimal-video') return { group: 'video', skinFile: 'minimal-skin' };
if (skin === 'minimal-audio') return { group: 'audio', skinFile: 'minimal-skin' };
return { group: skin, skinFile: 'skin' };
}
function getMediaImportSubpath(renderer: Renderer): string | null {
const map: Partial<Record<Renderer, string>> = {
hls: 'hls-video',
};
return map[renderer] ?? null;
}
// ---------------------------------------------------------------------------
// HTML Install
// ---------------------------------------------------------------------------
export function generateHTMLInstallCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer'>
): Record<'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun', string> {
return {
cdn: generateCdnCode(opts.useCase, opts.skin, opts.renderer),
npm: 'npm install @videojs/html',
pnpm: 'pnpm add @videojs/html',
yarn: 'yarn add @videojs/html',
bun: 'bun add @videojs/html',
};
}
// ---------------------------------------------------------------------------
// React Install
// ---------------------------------------------------------------------------
export function generateReactInstallCode(): Record<'npm' | 'pnpm' | 'yarn' | 'bun', string> {
return {
npm: 'npm install @videojs/react',
pnpm: 'pnpm add @videojs/react',
yarn: 'yarn add @videojs/react',
bun: 'bun add @videojs/react',
};
}
// ---------------------------------------------------------------------------
// HTML Usage
// ---------------------------------------------------------------------------
function getRendererTag(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'background-video',
hls: 'hls-video',
'html5-audio': 'audio',
'html5-video': 'video',
};
return map[renderer];
}
function getProviderTag(useCase: UseCase): string {
const map: Record<UseCase, string> = {
'default-video': 'video-player',
'default-audio': 'audio-player',
'background-video': 'background-video-player',
};
return map[useCase];
}
function getSkinTag(useCase: UseCase, skin: Skin): string {
if (useCase === 'background-video') {
return 'background-video-skin';
}
const map: Record<Skin, string> = {
video: 'video-skin',
audio: 'audio-skin',
'minimal-video': 'video-minimal-skin',
'minimal-audio': 'audio-minimal-skin',
};
return map[skin];
}
function generateHTMLMarkup(useCase: UseCase, skin: Skin, renderer: Renderer, url: string): string {
const providerTag = getProviderTag(useCase);
const skinTag = getSkinTag(useCase, skin);
const tag = getRendererTag(renderer);
const src = resolveSourceUrl(url, renderer);
const playsInline = isVideoLikeRenderer(renderer) ? ' playsinline' : '';
return `<!--
The PlayerProvider passes state between the UI components
and Media, and makes fully custom UIs possible.
It does not have layout by default (display:contents)
-->
<${providerTag}>
<!--
Skins contain the entire player UI and are easily swappable.
They can each be "ejected" for full control and customization
of UI components.
-->
<${skinTag}>
<!--
Media are players without UIs, handling networking
and display of the media. They are easily swappable
to handle different sources.
-->
<${tag} src="${src}"${playsInline}></${tag}>
</${skinTag}>
</${providerTag}>`;
}
function generateHTMLJSImports(useCase: UseCase, skin: Skin, renderer: Renderer): string {
if (useCase === 'background-video') {
const mediaSubpath = getMediaImportSubpath(renderer);
const mediaImport = mediaSubpath ? `\nimport '@videojs/html/media/${mediaSubpath}';` : '';
return `import '@videojs/html/background/player';
import '@videojs/html/background/skin';
import '@videojs/html/background/video';${mediaImport}`;
}
const { group, skinFile } = getSkinImportParts(skin);
const mediaSubpath = getMediaImportSubpath(renderer);
const mediaImport = mediaSubpath ? `\nimport '@videojs/html/media/${mediaSubpath}';` : '';
return `import '@videojs/html/${group}/player';
import '@videojs/html/${group}/${skinFile}';${mediaImport}`;
}
export function generateHTMLUsageCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer' | 'sourceUrl' | 'installMethod'>
): { html: string; js?: string } {
const html = generateHTMLMarkup(opts.useCase, opts.skin, opts.renderer, opts.sourceUrl);
const js = opts.installMethod !== 'cdn' ? generateHTMLJSImports(opts.useCase, opts.skin, opts.renderer) : undefined;
return { html, js };
}
// ---------------------------------------------------------------------------
// React Create
// ---------------------------------------------------------------------------
function getRendererComponent(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'BackgroundVideo',
hls: 'HlsVideo',
'html5-audio': 'Audio',
'html5-video': 'Video',
};
return map[renderer];
}
function getSkinComponent(skin: Skin): string {
const map: Record<Skin, string> = {
video: 'VideoSkin',
audio: 'AudioSkin',
'minimal-video': 'MinimalVideoSkin',
'minimal-audio': 'MinimalAudioSkin',
};
return map[skin];
}
function getUseCaseFeatures(useCase: UseCase): string {
const map: Record<UseCase, string> = {
'default-video': 'videoFeatures',
'default-audio': 'audioFeatures',
'background-video': 'backgroundFeatures',
};
return map[useCase];
}
function isPresetRenderer(renderer: Renderer): boolean {
return renderer === 'html5-video' || renderer === 'html5-audio' || renderer === 'background-video';
}
function getRendererMediaSubpath(renderer: Renderer): string {
const map: Partial<Record<Renderer, string>> = {
hls: 'hls-video',
};
return map[renderer] ?? renderer;
}
export function generateReactCreateCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer'>
): Record<'MyPlayer.tsx', string> {
const { useCase, skin, renderer } = opts;
const rendererComponent = getRendererComponent(renderer);
const featureType = getUseCaseFeatures(useCase);
const isBackgroundVideo = useCase === 'background-video';
const skinComponent = isBackgroundVideo ? 'BackgroundVideoSkin' : getSkinComponent(skin);
const { group, skinFile } = getSkinImportParts(skin);
const skinCssImport = isBackgroundVideo
? '@videojs/react/background/skin.css'
: `@videojs/react/${group}/${skinFile}.css`;
const presetSubpath = isBackgroundVideo ? 'background' : group;
let presetImport: string;
let mediaImport: string | null = null;
if (isPresetRenderer(renderer)) {
presetImport = `import { ${skinComponent}, ${rendererComponent} } from '@videojs/react/${presetSubpath}';`;
} else {
presetImport = `import { ${skinComponent} } from '@videojs/react/${presetSubpath}';`;
mediaImport = `import { ${rendererComponent} } from '@videojs/react/media/${getRendererMediaSubpath(renderer)}';`;
}
const rendererProps = isVideoLikeRenderer(renderer) ? 'src={src} playsInline' : 'src={src}';
const rendererJsx = `<${rendererComponent} ${rendererProps} />`;
const imports = [
`import '${skinCssImport}';`,
`import { createPlayer, ${featureType} } from '@videojs/react';`,
presetImport,
...(mediaImport ? [mediaImport] : []),
].join('\n');
return {
'MyPlayer.tsx': `'use client';
${imports}
const Player = createPlayer({ features: ${featureType} });
interface MyPlayerProps {
src: string;
}
export const MyPlayer = ({ src }: MyPlayerProps) => {
return (
<Player.Provider>
<${skinComponent}>
${rendererJsx}
</${skinComponent}>
</Player.Provider>
);
};`,
};
}
// ---------------------------------------------------------------------------
// React Usage
// ---------------------------------------------------------------------------
export function generateReactUsageCode(
opts: Pick<InstallationOptions, 'renderer' | 'sourceUrl'>
): Record<'App.tsx', string> {
const source = resolveSourceUrl(opts.sourceUrl, opts.renderer);
return {
'App.tsx': `import { MyPlayer } from '../components/player';
export const HomePage = () => {
return (
<div>
<h1>Welcome to My App</h1>
<MyPlayer src="${source}" />
</div>
);
};`,
};
}
@@ -1,5 +1,5 @@
import type { Renderer, UseCase } from '@/stores/installation';
import { VALID_RENDERERS } from '@/stores/installation';
import type { Renderer, UseCase } from '@/utils/installation/types';
import { VALID_RENDERERS } from '@/utils/installation/types';
export interface DetectionResult {
renderer: Renderer;
+13
View File
@@ -0,0 +1,13 @@
export type Renderer = 'background-video' | 'hls' | 'html5-audio' | 'html5-video';
export type Skin = 'video' | 'audio' | 'minimal-video' | 'minimal-audio';
export type UseCase = 'default-video' | 'default-audio' | 'background-video';
export type InstallMethod = 'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun';
export const VALID_RENDERERS: Record<UseCase, Renderer[]> = {
'default-video': ['html5-video', 'hls'],
'default-audio': ['html5-audio'],
'background-video': ['background-video'],
};