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
+55
View File
@@ -0,0 +1,55 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
const CONFIG_FILE = join(homedir(), '.videojs', 'config.json');
export type Framework = 'html' | 'react';
interface CliConfig {
framework?: Framework;
}
export function readConfig(): CliConfig {
if (!existsSync(CONFIG_FILE)) return {};
try {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as CliConfig;
} catch {
return {};
}
}
function writeConfig(config: CliConfig): void {
const dir = dirname(CONFIG_FILE);
mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
const VALID_CONFIG: Record<keyof CliConfig, readonly string[]> = {
framework: ['html', 'react'],
};
export function getConfigValue(key: string): string | undefined {
if (!(key in VALID_CONFIG)) {
throw new Error(`Unknown config key: "${key}". Valid keys: ${Object.keys(VALID_CONFIG).join(', ')}`);
}
const config = readConfig();
return config[key as keyof CliConfig];
}
export function setConfigValue(key: string, value: string): void {
const validValues = VALID_CONFIG[key as keyof CliConfig];
if (!validValues) {
throw new Error(`Unknown config key: "${key}". Valid keys: ${Object.keys(VALID_CONFIG).join(', ')}`);
}
if (!validValues.includes(value)) {
throw new Error(`Invalid value "${value}" for "${key}". Valid values: ${validValues.join(', ')}`);
}
const config = readConfig();
(config as Record<string, string>)[key] = value;
writeConfig(config);
}
export function listConfig(): CliConfig {
return readConfig();
}
+35
View File
@@ -0,0 +1,35 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DOCS_DIR = join(__dirname, '..', 'docs');
function safePath(...segments: string[]): string | null {
const resolved = resolve(DOCS_DIR, ...segments);
if (relative(DOCS_DIR, resolved).startsWith('..')) return null;
return resolved;
}
export function docExistsInAnyFramework(slug: string): boolean {
return ['html', 'react'].some((fw) => {
const mdPath = safePath(fw, `${slug}.md`);
return mdPath !== null && existsSync(mdPath);
});
}
function stripLlmsFooter(content: string): string {
return content.replace(/\n---\n\n(\w+ documentation: https:\/\/.*\n)?All documentation: https:\/\/.*\n*$/, '');
}
export function readBundledDoc(framework: string, slug: string): string | null {
const mdPath = safePath(framework, `${slug}.md`);
if (!mdPath || !existsSync(mdPath)) return null;
return stripLlmsFooter(readFileSync(mdPath, 'utf-8'));
}
export function readLlmsTxt(framework: string): string | null {
const txtPath = safePath(framework, 'llms.txt');
if (!txtPath || !existsSync(txtPath)) return null;
return stripLlmsFooter(readFileSync(txtPath, 'utf-8'));
}
+61
View File
@@ -0,0 +1,61 @@
import {
generateHTMLInstallCode,
generateHTMLUsageCode,
generateReactCreateCode,
generateReactInstallCode,
generateReactUsageCode,
type InstallationOptions,
} from '@/utils/installation/codegen';
export function formatInstallationCode(opts: InstallationOptions): string {
if (opts.framework === 'html') {
return formatHTMLInstallation(opts);
}
return formatReactInstallation(opts);
}
function formatHTMLInstallation(opts: InstallationOptions): string {
const install = generateHTMLInstallCode(opts);
const usage = generateHTMLUsageCode(opts);
const sections: string[] = [];
sections.push('## Install Video.js\n');
if (opts.installMethod === 'cdn') {
sections.push(`\`\`\`html\n${install.cdn}\n\`\`\``);
} else {
sections.push(`\`\`\`bash\n${install[opts.installMethod]}\n\`\`\``);
}
if (usage.js) {
sections.push('\n## JavaScript imports\n');
sections.push(`\`\`\`javascript\n${usage.js}\n\`\`\``);
}
sections.push('\n## HTML\n');
sections.push(`\`\`\`html\n${usage.html}\n\`\`\``);
return sections.join('\n');
}
function formatReactInstallation(opts: InstallationOptions): string {
const install = generateReactInstallCode();
const create = generateReactCreateCode(opts);
const usage = generateReactUsageCode(opts);
const sections: string[] = [];
if (opts.installMethod === 'cdn') {
throw new Error('CDN install method is not supported for React');
}
sections.push('## Install Video.js\n');
sections.push(`\`\`\`bash\n${install[opts.installMethod]}\n\`\`\``);
sections.push('\n## Create your player\n');
sections.push('Add to `./components/player/index.tsx`:\n');
sections.push(`\`\`\`tsx\n${create['MyPlayer.tsx']}\n\`\`\``);
sections.push('\n## Use your player\n');
sections.push(`\`\`\`tsx\n${usage['App.tsx']}\n\`\`\``);
return sections.join('\n');
}
+169
View File
@@ -0,0 +1,169 @@
import * as p from '@clack/prompts';
import type { InstallationOptions } from '@/utils/installation/codegen';
import { detectRenderer } from '@/utils/installation/detect-renderer';
import type { InstallMethod, Renderer, Skin, UseCase } from '@/utils/installation/types';
import { VALID_RENDERERS } from '@/utils/installation/types';
import type { Framework } from './config.js';
export async function promptFramework(): Promise<Framework> {
const value = await p.select({
message: 'Which framework?',
options: [
{ value: 'html' as const, label: 'HTML (custom elements)' },
{ value: 'react' as const, label: 'React' },
],
});
if (p.isCancel(value)) process.exit(0);
p.note('💡 Tip: run `npx @videojs/cli config set framework ' + value + '` to save this preference');
return value;
}
const PRESET_OPTIONS: Array<{ value: UseCase; label: string }> = [
{ value: 'default-video', label: 'Video' },
{ value: 'default-audio', label: 'Audio' },
{ value: 'background-video', label: 'Background Video' },
];
function mediaOptionsForUseCase(useCase: UseCase): Array<{ value: Renderer; label: string }> {
const RENDERER_LABELS: Record<Renderer, string> = {
'background-video': 'Background Video',
hls: 'HLS',
'html5-audio': 'HTML5 Audio',
'html5-video': 'HTML5 Video',
};
return VALID_RENDERERS[useCase].map((r) => ({
value: r,
label: RENDERER_LABELS[r],
}));
}
function skinOptionsForUseCase(useCase: UseCase): Array<{ value: Skin; label: string }> {
if (useCase === 'background-video') {
return [{ value: 'video', label: 'Default' }];
}
const isAudio = useCase === 'default-audio';
return [
{ value: isAudio ? 'audio' : 'video', label: 'Default' },
{ value: isAudio ? 'minimal-audio' : 'minimal-video', label: 'Minimal' },
];
}
function installMethodOptions(framework: Framework): Array<{ value: InstallMethod; label: string }> {
const options: Array<{ value: InstallMethod; label: string }> = [
{ value: 'npm', label: 'npm' },
{ value: 'pnpm', label: 'pnpm' },
{ value: 'yarn', label: 'yarn' },
{ value: 'bun', label: 'bun' },
];
if (framework === 'html') {
options.unshift({ value: 'cdn', label: 'CDN' });
}
return options;
}
export interface PartialInstallFlags {
preset?: UseCase;
skin?: Skin;
rawSkin?: string;
sourceUrl?: string;
media?: Renderer;
installMethod?: InstallMethod;
}
export function mapRawSkin(skinFlag: string, useCase: UseCase): Skin {
const isAudio = useCase === 'default-audio';
const map: Record<string, Skin> = {
default: isAudio ? 'audio' : 'video',
minimal: isAudio ? 'minimal-audio' : 'minimal-video',
};
const result = map[skinFlag];
if (!result) {
console.error(`Invalid skin: "${skinFlag}". Must be "default" or "minimal".`);
process.exit(1);
}
return result;
}
export async function promptInstallOptions(
framework: Framework,
flags: PartialInstallFlags
): Promise<InstallationOptions> {
const useCase =
flags.preset ??
(await (async () => {
const value = await p.select({
message: 'Preset',
options: PRESET_OPTIONS,
});
if (p.isCancel(value)) process.exit(0);
return value;
})());
// Resolve raw --skin flag now that useCase is known
const resolvedSkin = flags.rawSkin ? mapRawSkin(flags.rawSkin, useCase) : flags.skin;
const skin =
resolvedSkin ??
(await (async () => {
const value = await p.select({
message: 'Skin',
options: skinOptionsForUseCase(useCase),
});
if (p.isCancel(value)) process.exit(0);
return value;
})());
const sourceUrl =
flags.sourceUrl ??
(await (async () => {
const value = await p.text({
message: 'Source URL (leave blank for demo)',
defaultValue: '',
});
if (p.isCancel(value)) process.exit(0);
return value ?? '';
})());
// Detect media type from URL when not explicitly provided
const detected = sourceUrl ? detectRenderer(sourceUrl, useCase) : null;
const media =
flags.media ??
(await (async () => {
const options = mediaOptionsForUseCase(useCase);
// Skip prompt if there's only one valid option
if (options.length === 1) return options[0]!.value;
const message = detected ? `Media source type (detected ${detected.label} from URL)` : 'Media source type';
const value = await p.select({
message,
options,
initialValue: detected?.renderer,
});
if (p.isCancel(value)) process.exit(0);
return value as Renderer;
})());
const installMethod =
flags.installMethod ??
(await (async () => {
const value = await p.select({
message: 'Install method',
options: installMethodOptions(framework),
});
if (p.isCancel(value)) process.exit(0);
return value;
})());
return {
framework,
useCase,
skin,
renderer: media,
sourceUrl,
installMethod,
};
}
+4
View File
@@ -0,0 +1,4 @@
export function replaceMarker(markdown: string, id: string, replacement: string): string {
const re = new RegExp(`<!-- cli:replace ${id} -->\\n[\\s\\S]*?\\n<!-- /cli:replace ${id} -->`);
return markdown.replace(re, () => replacement);
}
@@ -0,0 +1,56 @@
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
const testDir = join(tmpdir(), 'videojs-cli-test-' + Date.now());
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os');
return {
...actual,
homedir: () => testDir,
};
});
// Re-import after mock
const { getConfigValue, listConfig, setConfigValue } = await import('../config.js');
describe('config', () => {
it('returns empty config when no file exists', () => {
const config = listConfig();
expect(config).toEqual({});
});
it('returns undefined for missing key', () => {
expect(getConfigValue('framework')).toBeUndefined();
});
it('sets and gets a value', () => {
setConfigValue('framework', 'react');
expect(getConfigValue('framework')).toBe('react');
});
it('overwrites existing value', () => {
setConfigValue('framework', 'html');
setConfigValue('framework', 'react');
expect(getConfigValue('framework')).toBe('react');
});
it('lists all config entries', () => {
setConfigValue('framework', 'html');
const config = listConfig();
expect(config).toHaveProperty('framework', 'html');
});
it('rejects unknown config key on set', () => {
expect(() => setConfigValue('foo', 'bar')).toThrow('Unknown config key: "foo"');
});
it('rejects invalid value for known key on set', () => {
expect(() => setConfigValue('framework', 'vue')).toThrow('Invalid value "vue" for "framework"');
});
it('rejects unknown config key on get', () => {
expect(() => getConfigValue('foo')).toThrow('Unknown config key: "foo"');
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import type { InstallationOptions } from '@/utils/installation/codegen';
import { formatInstallationCode } from '../format.js';
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('formatInstallationCode', () => {
it('formats HTML + npm with install, JS imports, and HTML sections', () => {
const result = formatInstallationCode(baseHTML);
expect(result).toContain('## Install Video.js');
expect(result).toContain('npm install @videojs/html');
expect(result).toContain('## JavaScript imports');
expect(result).toContain('## HTML');
expect(result).toContain('<video-player>');
});
it('formats HTML + CDN without JS imports section', () => {
const result = formatInstallationCode({ ...baseHTML, installMethod: 'cdn' });
expect(result).toContain('## Install Video.js');
expect(result).toContain('<script');
expect(result).not.toContain('## JavaScript imports');
expect(result).toContain('## HTML');
});
it('formats React with install, create, and use sections', () => {
const result = formatInstallationCode(baseReact);
expect(result).toContain('## Install Video.js');
expect(result).toContain('npm install @videojs/react');
expect(result).toContain('## Create your player');
expect(result).toContain('MyPlayer');
expect(result).toContain('## Use your player');
});
it('uses pnpm install command when specified', () => {
const result = formatInstallationCode({ ...baseReact, installMethod: 'pnpm' });
expect(result).toContain('pnpm add @videojs/react');
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { replaceMarker } from '../replace.js';
describe('replaceMarker', () => {
it('replaces content between markers', () => {
const markdown = `# Title
<!-- cli:replace test -->
old content here
<!-- /cli:replace test -->
## Footer`;
const result = replaceMarker(markdown, 'test', 'new content');
expect(result).toBe(`# Title
new content
## Footer`);
});
it('returns unchanged markdown when marker not found', () => {
const markdown = '# Title\n\nSome content';
const result = replaceMarker(markdown, 'missing', 'replacement');
expect(result).toBe(markdown);
});
it('preserves content before and after markers', () => {
const markdown = `before
<!-- cli:replace id -->
middle
<!-- /cli:replace id -->
after`;
const result = replaceMarker(markdown, 'id', 'replaced');
expect(result).toContain('before');
expect(result).toContain('after');
expect(result).toContain('replaced');
expect(result).not.toContain('middle');
});
it('treats $ patterns in replacement literally', () => {
const markdown = `start
<!-- cli:replace test -->
old
<!-- /cli:replace test -->
end`;
const result = replaceMarker(markdown, 'test', 'https://example.com/video.php?id=$1&ref=$&');
expect(result).toContain('https://example.com/video.php?id=$1&ref=$&');
});
it('handles multiline content between markers', () => {
const markdown = `start
<!-- cli:replace multi -->
line 1
line 2
line 3
<!-- /cli:replace multi -->
end`;
const result = replaceMarker(markdown, 'multi', 'single line');
expect(result).toBe('start\nsingle line\nend');
});
});
@@ -0,0 +1,27 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* The CLI aliases source files from the site package via path aliases in
* tsdown.config.ts and vitest.config.ts. If these files move, the CLI build
* breaks silently. This test makes that failure loud.
*/
const SITE_ROOT = resolve(__dirname, '../../../../../site/src');
const ALIASED_FILES = [
'utils/installation/codegen.ts',
'utils/installation/types.ts',
'utils/installation/cdn-code.ts',
'utils/installation/detect-renderer.ts',
'consts.ts',
];
describe('site source aliases', () => {
for (const file of ALIASED_FILES) {
it(`site/src/${file} exists`, () => {
const fullPath = resolve(SITE_ROOT, file);
expect(existsSync(fullPath), `Aliased site file missing: ${fullPath}`).toBe(true);
});
}
});