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
+66
View File
@@ -0,0 +1,66 @@
import { getConfigValue, listConfig, setConfigValue } from '../utils/config.js';
const CONFIG_HELP = `Usage: @videojs/cli config <set|get|list>
Keys:
framework <html|react> JS framework for docs`;
export function handleConfig(args: string[], flags?: { help?: boolean }): void {
const [subcommand, key, value] = args;
if (flags?.help) {
console.log(CONFIG_HELP);
process.exit(0);
}
switch (subcommand) {
case 'set': {
if (!key || !value) {
console.error('Usage: @videojs/cli config set <key> <value>');
process.exit(1);
}
try {
setConfigValue(key, value);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
console.log(`Set ${key} = ${value}`);
break;
}
case 'get': {
if (!key) {
console.error('Usage: @videojs/cli config get <key>');
process.exit(1);
}
try {
const val = getConfigValue(key);
if (val !== undefined) {
console.log(val);
} else {
console.error(`No value set for "${key}"`);
process.exit(1);
}
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
break;
}
case 'list': {
const config = listConfig();
const entries = Object.entries(config);
if (entries.length === 0) {
console.log('No configuration set.');
} else {
for (const [k, v] of entries) {
console.log(`${k} = ${v}`);
}
}
break;
}
default:
console.error(CONFIG_HELP);
process.exit(1);
}
}
+190
View File
@@ -0,0 +1,190 @@
import * as p from '@clack/prompts';
import { validateInstallationOptions } from '@/utils/installation/codegen';
import type { InstallMethod, Renderer, UseCase } from '@/utils/installation/types';
import type { Framework } from '../utils/config.js';
import { getConfigValue } from '../utils/config.js';
import { docExistsInAnyFramework, readBundledDoc, readLlmsTxt } from '../utils/docs.js';
import { formatInstallationCode } from '../utils/format.js';
import { mapRawSkin, type PartialInstallFlags, promptFramework, promptInstallOptions } from '../utils/prompts.js';
import { replaceMarker } from '../utils/replace.js';
interface ParsedFlags {
framework?: string;
list?: boolean;
help?: boolean;
preset?: string;
skin?: string;
media?: string;
'source-url'?: string;
'install-method'?: string;
}
function printVersionHeader(): void {
console.log(`@videojs/cli v${__CLI_VERSION__}\n`);
}
async function resolveFramework(flags: ParsedFlags): Promise<Framework> {
if (flags.framework === 'html' || flags.framework === 'react') {
return flags.framework;
}
if (flags.framework) {
console.error(`Invalid framework: "${flags.framework}". Must be "html" or "react".`);
process.exit(1);
}
const saved = getConfigValue('framework');
if (saved === 'html' || saved === 'react') return saved;
return promptFramework();
}
function mapPresetToUseCase(preset: string): UseCase {
const map: Record<string, UseCase> = {
video: 'default-video',
audio: 'default-audio',
'background-video': 'background-video',
};
const result = map[preset];
if (!result) {
console.error(`Invalid preset: "${preset}". Must be "video", "audio", or "background-video".`);
process.exit(1);
}
return result;
}
const ALL_RENDERERS: Renderer[] = ['html5-video', 'html5-audio', 'hls', 'background-video'];
function validateMedia(media: string): Renderer {
if (!ALL_RENDERERS.includes(media as Renderer)) {
console.error(`Invalid media type: "${media}". Valid options: ${ALL_RENDERERS.join(', ')}`);
process.exit(1);
}
return media as Renderer;
}
function validateInstallMethod(method: string, framework: Framework): InstallMethod {
const valid = framework === 'html' ? ['cdn', 'npm', 'pnpm', 'yarn', 'bun'] : ['npm', 'pnpm', 'yarn', 'bun'];
if (!valid.includes(method)) {
console.error(`Invalid install method: "${method}". Valid options: ${valid.join(', ')}`);
process.exit(1);
}
return method as InstallMethod;
}
function buildPartialFlags(flags: ParsedFlags, framework: Framework): PartialInstallFlags {
const partial: PartialInstallFlags = {};
if (flags.preset) {
partial.preset = mapPresetToUseCase(flags.preset);
}
if (flags.skin) {
if (partial.preset) {
partial.skin = mapRawSkin(flags.skin, partial.preset);
} else {
partial.rawSkin = flags.skin;
}
}
if (flags['source-url'] !== undefined) {
partial.sourceUrl = flags['source-url'];
}
if (flags.media) {
partial.media = validateMedia(flags.media);
}
if (flags['install-method'] !== undefined) {
partial.installMethod = validateInstallMethod(flags['install-method'], framework);
}
return partial;
}
const DOCS_HELP = `Usage: @videojs/cli docs <slug> [--framework <html|react>]
@videojs/cli docs --list [--framework <html|react>]
Installation flags (for docs how-to/installation):
--preset <video|audio|background-video>
--skin <default|minimal>
--source-url <url>
--media <html5-video|html5-audio|hls|background-video>
--install-method <cdn|npm|pnpm|yarn|bun>`;
export async function handleDocs(flags: ParsedFlags, positionals: string[]): Promise<void> {
if (flags.help) {
console.log(DOCS_HELP);
process.exit(0);
}
// --list: print llms.txt
if (flags.list) {
const framework = await resolveFramework(flags);
const content = readLlmsTxt(framework);
if (!content) {
console.error(`No documentation index found for framework "${framework}".`);
process.exit(1);
}
console.log(content);
return;
}
const slug = positionals[0];
if (!slug) {
console.error(DOCS_HELP);
process.exit(1);
}
// Bail early if the doc doesn't exist in either framework
if (!docExistsInAnyFramework(slug)) {
console.error(`Doc not found: "${slug}".`);
console.error('Run `@videojs/cli docs --list` to see available pages.');
process.exit(1);
}
const framework = await resolveFramework(flags);
const markdown = readBundledDoc(framework, slug);
if (!markdown) {
console.error(`Doc not found: "${slug}" for framework "${framework}".`);
console.error('Run `@videojs/cli docs --list` to see available pages.');
process.exit(1);
}
// Installation page: generate code and replace markers
if (slug === 'how-to/installation') {
const partial = buildPartialFlags(flags, framework);
const needsPrompting =
!partial.preset ||
(!partial.skin && !partial.rawSkin) ||
partial.sourceUrl === undefined ||
!partial.media ||
!partial.installMethod;
if (needsPrompting) {
p.intro('Video.js Installation');
}
const opts = await promptInstallOptions(framework, partial);
if (needsPrompting) {
p.outro('');
}
const validation = validateInstallationOptions(opts);
if (!validation.valid) {
console.error(`Error: ${validation.reason}`);
process.exit(1);
}
const generated = formatInstallationCode(opts);
const output = replaceMarker(markdown, 'installation', generated);
printVersionHeader();
console.log(output);
return;
}
// Regular doc: print as-is
printVersionHeader();
console.log(markdown);
}
@@ -0,0 +1,332 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
// --- Fixtures ---
const INSTALLATION_DOC = `# Installation
Intro paragraph.
<!-- cli:replace installation -->
Placeholder for CLI-generated code.
<!-- /cli:replace installation -->
## Next steps
Footer content.`;
const REGULAR_DOC = `# Skins
Video.js comes with several skins.`;
const LLMS_TXT = `# Video.js Docs
/how-to/installation
/concepts/skins`;
// --- Mocks ---
vi.mock('../../utils/docs.js', () => ({
readBundledDoc: vi.fn(),
readLlmsTxt: vi.fn(),
docExistsInAnyFramework: vi.fn(),
}));
vi.mock('../../utils/config.js', () => ({
getConfigValue: vi.fn(() => undefined),
setConfigValue: vi.fn(),
listConfig: vi.fn(() => ({})),
}));
vi.mock('@clack/prompts', () => ({
select: vi.fn(),
text: vi.fn(),
isCancel: vi.fn(() => false),
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
}));
import * as p from '@clack/prompts';
import { getConfigValue } from '../../utils/config.js';
import { docExistsInAnyFramework, readBundledDoc, readLlmsTxt } from '../../utils/docs.js';
import { handleDocs } from '../docs.js';
// --- Helpers ---
class ExitError extends Error {
code: number;
constructor(code?: number | string | null) {
super(`process.exit(${code})`);
this.code = typeof code === 'number' ? code : 0;
}
}
let stdout: string[];
let stderr: string[];
function output(): string {
return stdout.join('\n');
}
function errors(): string {
return stderr.join('\n');
}
beforeEach(() => {
stdout = [];
stderr = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(' '));
});
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
stderr.push(args.map(String).join(' '));
});
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new ExitError(code);
});
(readBundledDoc as Mock).mockImplementation((_fw: string, slug: string) => {
if (slug === 'how-to/installation') return INSTALLATION_DOC;
if (slug === 'concepts/skins') return REGULAR_DOC;
return null;
});
(readLlmsTxt as Mock).mockReturnValue(LLMS_TXT);
(docExistsInAnyFramework as Mock).mockImplementation((slug: string) =>
['how-to/installation', 'concepts/skins'].includes(slug)
);
(getConfigValue as Mock).mockReturnValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Tests ---
describe('handleDocs', () => {
describe('--help', () => {
it('prints usage text and exits', async () => {
await expect(handleDocs({ help: true }, [])).rejects.toThrow(ExitError);
expect(output()).toContain('Usage:');
expect(output()).toContain('--framework');
});
});
describe('--list', () => {
it('prints llms.txt for the given framework', async () => {
await handleDocs({ list: true, framework: 'html' }, []);
expect(output()).toContain('Video.js Docs');
expect(output()).toContain('/how-to/installation');
});
});
describe('error handling', () => {
it('errors when no slug is provided', async () => {
await expect(handleDocs({ framework: 'html' }, [])).rejects.toThrow(ExitError);
expect(errors()).toContain('Usage:');
});
it('errors when doc does not exist in any framework', async () => {
(docExistsInAnyFramework as Mock).mockReturnValue(false);
await expect(handleDocs({ framework: 'html' }, ['nonexistent'])).rejects.toThrow(ExitError);
expect(errors()).toContain('Doc not found: "nonexistent"');
});
it('errors when doc exists in other framework but not the requested one', async () => {
(readBundledDoc as Mock).mockReturnValue(null);
await expect(handleDocs({ framework: 'react' }, ['concepts/skins'])).rejects.toThrow(ExitError);
expect(errors()).toContain('Doc not found: "concepts/skins" for framework "react"');
});
it('errors with invalid framework value', async () => {
await expect(handleDocs({ framework: 'vue' }, ['concepts/skins'])).rejects.toThrow(ExitError);
expect(errors()).toContain('Invalid framework: "vue"');
});
it('errors with invalid preset', async () => {
await expect(handleDocs({ framework: 'html', preset: 'livestream' }, ['how-to/installation'])).rejects.toThrow(
ExitError
);
expect(errors()).toContain('Invalid preset: "livestream"');
});
it('errors with invalid skin', async () => {
await expect(
handleDocs({ framework: 'html', preset: 'video', skin: 'custom' }, ['how-to/installation'])
).rejects.toThrow(ExitError);
expect(errors()).toContain('Invalid skin: "custom"');
});
it('errors with invalid install method for framework', async () => {
await expect(
handleDocs({ framework: 'react', 'install-method': 'cdn' }, ['how-to/installation'])
).rejects.toThrow(ExitError);
expect(errors()).toContain('Invalid install method: "cdn"');
});
});
describe('regular docs', () => {
it('prints version header followed by markdown content', async () => {
await handleDocs({ framework: 'html' }, ['concepts/skins']);
const out = output();
expect(out).toContain('@videojs/cli v');
expect(out).toContain('# Skins');
expect(out).toContain('Video.js comes with several skins');
});
});
describe('installation page', () => {
const htmlFlags = (overrides: Record<string, string> = {}) => ({
framework: 'html',
preset: 'video',
skin: 'default',
media: 'html5-video',
'source-url': '',
'install-method': 'npm',
...overrides,
});
const reactFlags = (overrides: Record<string, string> = {}) => ({
framework: 'react',
preset: 'video',
skin: 'default',
media: 'html5-video',
'source-url': '',
'install-method': 'npm',
...overrides,
});
describe('HTML framework', () => {
it('generates npm installation with JS imports and HTML sections', async () => {
await handleDocs(htmlFlags(), ['how-to/installation']);
const out = output();
expect(out).toContain('## Install Video.js');
expect(out).toContain('npm install @videojs/html');
expect(out).toContain('## JavaScript imports');
expect(out).toContain('## HTML');
expect(out).toContain('<video-player>');
expect(out).not.toContain('<!-- cli:replace');
expect(out).toContain('Intro paragraph');
expect(out).toContain('## Next steps');
});
it('generates CDN installation without JS imports section', async () => {
await handleDocs(htmlFlags({ 'install-method': 'cdn' }), ['how-to/installation']);
const out = output();
expect(out).toContain('## Install Video.js');
expect(out).toContain('<script');
expect(out).not.toContain('## JavaScript imports');
expect(out).toContain('## HTML');
});
it('switches install command for pnpm', async () => {
await handleDocs(htmlFlags({ 'install-method': 'pnpm' }), ['how-to/installation']);
expect(output()).toContain('pnpm add @videojs/html');
});
it('generates audio preset with audio-specific elements', async () => {
await handleDocs(htmlFlags({ preset: 'audio', skin: 'default', media: 'html5-audio' }), [
'how-to/installation',
]);
expect(output()).toContain('audio-player');
});
it('generates minimal skin variant', async () => {
await handleDocs(htmlFlags({ skin: 'minimal' }), ['how-to/installation']);
expect(output()).toContain('minimal');
});
it('includes custom source URL in generated code', async () => {
await handleDocs(htmlFlags({ 'source-url': 'https://example.com/my-video.mp4' }), ['how-to/installation']);
expect(output()).toContain('https://example.com/my-video.mp4');
});
it('uses demo URLs when source-url is empty', async () => {
await handleDocs(htmlFlags({ 'source-url': '' }), ['how-to/installation']);
expect(output()).toMatch(/stream\.mux\.com|mux\.com/);
});
it('generates background-video preset', async () => {
await handleDocs(htmlFlags({ preset: 'background-video', skin: 'default', media: 'background-video' }), [
'how-to/installation',
]);
expect(output()).toContain('background-video-player');
});
});
describe('React framework', () => {
it('generates npm installation with create and use sections', async () => {
await handleDocs(reactFlags(), ['how-to/installation']);
const out = output();
expect(out).toContain('## Install Video.js');
expect(out).toContain('npm install @videojs/react');
expect(out).toContain('## Create your player');
expect(out).toContain('MyPlayer');
expect(out).toContain('## Use your player');
});
it('generates HLS media variant', async () => {
await handleDocs(reactFlags({ media: 'hls' }), ['how-to/installation']);
expect(output()).toContain('hls');
});
});
});
describe('framework resolution', () => {
it('uses --framework flag directly', async () => {
await handleDocs({ framework: 'html' }, ['concepts/skins']);
expect(getConfigValue).not.toHaveBeenCalled();
});
it('falls back to saved config when flag is omitted', async () => {
(getConfigValue as Mock).mockReturnValue('react');
await handleDocs({}, ['concepts/skins']);
expect(readBundledDoc).toHaveBeenCalledWith('react', 'concepts/skins');
});
});
describe('prompting behavior', () => {
it('does not prompt when all flags are provided', async () => {
await handleDocs(
{
framework: 'html',
preset: 'video',
skin: 'default',
media: 'html5-video',
'source-url': '',
'install-method': 'npm',
},
['how-to/installation']
);
expect(p.intro).not.toHaveBeenCalled();
expect(p.select).not.toHaveBeenCalled();
expect(p.text).not.toHaveBeenCalled();
});
it('prompts for missing options when only some flags are provided', async () => {
(p.select as Mock)
.mockResolvedValueOnce('video') // skin
.mockResolvedValueOnce('html5-video') // media
.mockResolvedValueOnce('npm'); // installMethod
(p.text as Mock).mockResolvedValueOnce(''); // sourceUrl
await handleDocs({ framework: 'html', preset: 'video' }, ['how-to/installation']);
expect(p.intro).toHaveBeenCalledWith('Video.js Installation');
expect(p.select).toHaveBeenCalled();
expect(output()).toContain('## Install Video.js');
});
it('source-url without --media still requires prompting (detection is a hint, not auto-set)', async () => {
(p.select as Mock)
.mockResolvedValueOnce('default-video') // preset
.mockResolvedValueOnce('video') // skin
.mockResolvedValueOnce('hls') // media (user confirms detection hint)
.mockResolvedValueOnce('npm'); // installMethod
await handleDocs({ framework: 'html', 'source-url': 'https://example.com/video.m3u8' }, ['how-to/installation']);
expect(p.intro).toHaveBeenCalled();
expect(p.select).toHaveBeenCalled();
});
});
});