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
@@ -1,4 +1,5 @@
{
"packages/cli": "10.0.0-beta.17",
"packages/core": "10.0.0-beta.17",
"packages/element": "10.0.0-beta.17",
"packages/html": "10.0.0-beta.17",
@@ -13,6 +13,7 @@
"type": "linked-versions",
"groupName": "videojs",
"components": [
"@videojs/cli",
"@videojs/core",
"@videojs/element",
"@videojs/html",
@@ -26,6 +27,12 @@
}
],
"packages": {
"packages/cli": {
"component": "@videojs/cli",
"prerelease": true,
"prerelease-type": "beta",
"versioning": "prerelease"
},
"packages/core": {
"component": "@videojs/core",
"prerelease": true,
+4
View File
@@ -74,6 +74,10 @@ jobs:
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm build:cdn
- name: Build CLI (requires site build)
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm build:cli
- name: Publish
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm -r publish --filter "./packages/*" --access public --provenance --no-git-checks
+1
View File
@@ -85,6 +85,7 @@ jobs:
fail-fast: false
matrix:
package:
- '@videojs/cli'
- '@videojs/core'
- '@videojs/store'
- '@videojs/utils'
+1
View File
@@ -33,6 +33,7 @@ site/src/content/generated-api-reference/
site/src/content/generated-component-reference/
site/src/content/generated-util-reference/
site/src/content/ejected-skins.json
packages/cli/docs/
# -------------------------
# Environment
+9
View File
@@ -170,6 +170,12 @@ const REQUIRED_FIELDS = ['sideEffects', 'files', 'exports'];
/** Required only when the package has a root "." export. */
const ROOT_EXPORT_FIELDS = ['main', 'module', 'types'];
/**
* Packages excluded from metadata checks.
* CLI is bin-only — sideEffects/exports don't apply.
*/
const METADATA_EXCLUDE = new Set(['cli']);
function checkPackageMetadata() {
const warnings = [];
@@ -179,6 +185,9 @@ function checkPackageMetadata() {
// Skip private packages — they're internal.
if (pkg.private) continue;
// Skip packages that don't need library metadata.
if (METADATA_EXCLUDE.has(dir)) continue;
// publishConfig.access is required for scoped public packages.
if (pkg.publishConfig?.access !== 'public') {
warnings.push(`${pkg.name}: missing publishConfig.access = "public"`);
+1
View File
@@ -17,6 +17,7 @@ export default {
'cd',
'ci',
'claude',
'cli',
'core',
'design',
'element',
@@ -1,130 +0,0 @@
---
status: draft
date: 2026-04-02
---
# CLI for LLM-friendly installation docs
Generate installation code from the command line. It's docs/installation.md, but without the interactive React UI that breaks in plain text.
This means... it's finally time for `@videojs/cli`. The same package will later support skin ejection and other workflows.
## Problem
The installation page walks users through framework, preset, skin, and media choices via React pickers. Each combination produces different code. This works in a browser, but the LLM markdown pipeline only captures a single default snapshot. Pickers render as bare labels, tabs flatten into unlabeled lists, and the branching logic disappears. LLMs see one confusing path through a multi-path guide.
Related: videojs/v10#1185
## Solution
**`@videojs/cli docs how-to/installation`** — a command that takes the same choices as the installation page and prints the corresponding code to stdout.
**`HumanCase` / `LLMCase` MDX components** — Astro components that show different content to browsers and the LLM markdown pipeline. installation.mdx wraps interactive pickers in `HumanCase` and CLI instructions in `LLMCase`. Same file, both audiences. Three consumer types are covered: humans still have their react-powered interactive web page, agentic LLMs run the CLI directly, chat LLMs recommend the CLI to the user.
## API
```
npx @videojs/cli docs how-to/installation [flags]
Flags:
--framework <html|react> (see "framework resolution" below.)
--preset <video|audio|background-video> (default: video)
--skin <default|minimal> (default: default)
--media <html5-video|html5-audio|hls|background-video> (default: per preset)
--source-url <url> (default: per media)
--install-method <cdn|npm|pnpm|yarn|bun> (default: npm)
```
When no `--source-url` is provided, the CLI uses a default demo URL matching the media type (HLS gets an `.m3u8`, others get `.mp4`). When a URL is provided, the CLI auto-detects the media type from the file extension (`.m3u8` → HLS, `.mp4`/`.webm` → HTML5 Video, `.mp3`/`.wav` → HTML5 Audio) — matching the installation page's detection behavior. A poster URL is included in defaults.
No flags starts interactive prompts. With `--framework`, the CLI prints code to stdout and defaults the rest. Invalid combinations exit non-zero with an error explaining the constraint.
```bash
# Interactive
npx @videojs/cli docs how-to/installation
# Flags — defaults everything except framework and media
npx @videojs/cli docs how-to/installation --framework react --media hls
```
## Single source of truth
If this command is serving the same content as installation.mdx... how do we keep the two in sync? Honestly, that's a tricky question. Obviously we have a single source of truth, but where is that truth?
I'm thinking that the codegen is going to live in the site and be imported by the CLI. After all, that's what this CLI is doing. Taking content from the site and displaying it in the CLI.
And then... it's neat that this CLI can generate code examples, but what of the content around the code examples? I'm a bit fuzzier on this, but I'm imagining the CLI will take installation.md and string-replace the static code examples with the generated ones.
## Wait, I noticed you called this @videojs/cli docs...
PLOT TWIST.
Yeah. So we had a few conversations around this and there was this desire to scope creep. To write to the directory. Stuff like that. But really, the only problem I'm trying to solve right now is... how do I serve _this_ doc to an LLM?
Calling this utility @videojs/cli docs how-to/installation really clarifies things for me. Obvious scope, obvious implementation, obvious consumption to the user.
Aaaand... I mean, we already have markdown docs lying around... it seems trivial to just... copy them over here, right? Why not serve all the docs through the cli? It'll be nice that they're versioned and local.
### @videojs/cli docs API
#### Reading a doc
```
npx @videojs/cli docs <slug> [--framework <html|react>]
```
The slug mirrors the site's URL structure. For example, the page at `/docs/framework/react/how-to/installation/` is:
```
npx @videojs/cli docs how-to/installation --framework react
```
Most pages serve their markdown directly. Pages with interactive content (like installation) override the default behavior and accept additional flags.
#### Framework resolution
Every doc requires a framework. Resolution order:
1. **`--framework` flag** — overrides saved preference, doesn't change it
2. **Saved preference** — set via `config set`
3. **Interactive prompt** — if nothing above resolves, the CLI asks and suggests saving the preference:
```
💡 Tip: run `npx @videojs/cli config set framework XYZ` to save this preference
```
#### Listing sections
```
npx @videojs/cli docs --list
```
Lists available doc pages, built from the site's sidebar config. Follows framework resolution rules above
#### Config
```
npx @videojs/cli config set <key> <value>
npx @videojs/cli config get <key>
npx @videojs/cli config list
```
Persists to `~/.videojs/config.json`. Currently the only setting is `framework`.
## Anything else?
I'm thinking of using bombshell-dev/clack, /args/ and /tab because it's a trendy combo and Rahim likes it. Idk. We can throw it out later. This seems portable.
## Alternatives considered
- **CSS visibility toggle** — Render all variants in HTML, toggle visibility with CSS so the markdown pipeline captures everything. The combinatorial explosion (framework × use case × skin × renderer × install method) makes the output unwieldy, and it gets worse as we add options.
- **Separate LLM guide** — Write a purpose-built markdown page for LLMs. Two documents to maintain, guaranteed drift.
- **Expand variants in the markdown pipeline** — Teach `llms-markdown.ts` to understand the picker components and render every combination under structured headers. The pipeline would need to understand component semantics it currently ignores, and the output would be long.
The CLI avoids the combinatorial problem entirely — it lets the consumer narrow their own path.
## Open questions for later
- **Broader `--framework` scope** — Should `--framework` expand beyond `html`/`react` to include app frameworks (Next, Astro, SvelteKit, etc.)? That's a good conversation that affects the docs, too, so I'm going to leave that aside for now.
- **MCP** — is a thing
- **Mux Uploader** — idk how we'd even reproduce this in a CLI but it would be so cool
+2 -1
View File
@@ -17,7 +17,8 @@
"scripts": {
"prepare": "simple-git-hooks",
"postinstall": "node build/scripts/link-aliases.mjs",
"build:packages": "turbo run build --filter='./packages/*'",
"build:packages": "turbo run build --filter='./packages/*' --filter='!@videojs/cli'",
"build:cli": "turbo run build --filter=@videojs/cli...",
"build:sandbox": "turbo run build --filter=@videojs/sandbox...",
"build:cdn": "turbo run build:cdn --filter=@videojs/html",
"build:site": "turbo run build --filter=site",
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@videojs/cli",
"type": "module",
"version": "10.0.0-beta.17",
"description": "Video.js documentation CLI",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/videojs/v10",
"directory": "packages/cli"
},
"bin": "./dist/index.js",
"files": [
"dist",
"docs"
],
"scripts": {
"copy-docs": "node scripts/copy-docs.js",
"build": "pnpm run copy-docs && tsdown",
"dev": "tsdown --watch",
"test": "vitest run",
"clean": "rimraf dist docs"
},
"dependencies": {
"@bomb.sh/args": "^0.3.1",
"@clack/prompts": "^0.10.1"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"site": "workspace:*",
"tsdown": "^0.21.4",
"typescript": "^6.0.2",
"vitest": "^4.1.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function findWorkspaceRoot(start) {
let dir = start;
while (dir !== dirname(dir)) {
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir;
dir = dirname(dir);
}
throw new Error('Could not find pnpm-workspace.yaml — is this script running inside the monorepo?');
}
const WORKSPACE_ROOT = findWorkspaceRoot(__dirname);
const SITE_DIST = join(WORKSPACE_ROOT, 'site', 'dist');
const CLI_DOCS = join(__dirname, '..', 'docs');
if (!existsSync(SITE_DIST)) {
console.warn('⚠ site/dist not found — skipping docs copy. Build the site first.');
process.exit(1);
}
// Clean and recreate docs dir
rmSync(CLI_DOCS, { recursive: true, force: true });
mkdirSync(CLI_DOCS, { recursive: true });
for (const framework of ['html', 'react']) {
const src = join(SITE_DIST, 'docs', 'framework', framework);
if (!existsSync(src)) continue;
const dest = join(CLI_DOCS, framework);
cpSync(src, dest, {
recursive: true,
filter: (path) => {
if (statSync(path).isDirectory()) return true;
return path.endsWith('.md') || path.endsWith('.txt');
},
});
}
console.log('✓ Docs copied to packages/cli/docs/');
+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();
});
});
});
+1
View File
@@ -0,0 +1 @@
declare const __CLI_VERSION__: string;
+40
View File
@@ -0,0 +1,40 @@
import { parse } from '@bomb.sh/args';
import { handleConfig } from './commands/config.js';
import { handleDocs } from './commands/docs.js';
const parsed = parse(process.argv.slice(2), {
alias: { f: 'framework', l: 'list', v: 'version', h: 'help' },
string: ['framework', 'preset', 'skin', 'media', 'source-url', 'install-method'],
boolean: ['list', 'version', 'help'],
});
const [command, ...rest] = parsed._ as string[];
if (parsed.version) {
console.log(`@videojs/cli v${__CLI_VERSION__}`);
process.exit(0);
}
if (!command) {
console.log(`@videojs/cli — Video.js 10 CLI
Commands:
docs <slug> [options] Read a doc page
docs --list [--framework] List available docs
config <set|get|list> [key] [value] Manage preferences
Options:
-f, --framework <html|react> JS framework
-v, --version Show version
-h, --help Show help`);
process.exit(0);
}
if (command === 'docs') {
await handleDocs(parsed, rest);
} else if (command === 'config') {
handleConfig(rest, { help: parsed.help });
} else {
console.error(`Unknown command: "${command}". Run with --help for usage.`);
process.exit(1);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Ambient type declarations for site modules imported via tsdown aliases.
*
* The CLI bundles code from `site/src/utils/installation/` at build time using
* tsdown's `alias` config. These declarations let `tsc` typecheck against the
* same signatures without following into the site source tree.
*/
declare module '@/utils/installation/types' {
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[]>;
}
declare module '@/utils/installation/codegen' {
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;
export function generateHTMLInstallCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer'>
): Record<'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun', string>;
export function generateReactInstallCode(): Record<'npm' | 'pnpm' | 'yarn' | 'bun', string>;
export function generateHTMLUsageCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer' | 'sourceUrl' | 'installMethod'>
): { html: string; js?: string };
export function generateReactCreateCode(
opts: Pick<InstallationOptions, 'useCase' | 'skin' | 'renderer'>
): Record<'MyPlayer.tsx', string>;
export function generateReactUsageCode(
opts: Pick<InstallationOptions, 'renderer' | 'sourceUrl'>
): Record<'App.tsx', string>;
}
declare module '@/utils/installation/detect-renderer' {
import type { Renderer, UseCase } from '@/utils/installation/types';
export interface DetectionResult {
renderer: Renderer;
label: string;
}
export function detectRenderer(url: string, useCase: UseCase): DetectionResult | null;
}
+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);
});
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'tsdown';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
export default defineConfig({
entry: { index: './src/index.ts' },
platform: 'node',
format: 'es',
clean: true,
banner: { js: '#!/usr/bin/env node' },
noExternal: ['site'],
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
alias: {
'@/utils/installation/codegen': resolve(__dirname, '../../site/src/utils/installation/codegen.ts'),
'@/utils/installation/types': resolve(__dirname, '../../site/src/utils/installation/types.ts'),
'@/utils/installation/cdn-code': resolve(__dirname, '../../site/src/utils/installation/cdn-code.ts'),
'@/utils/installation/detect-renderer': resolve(__dirname, '../../site/src/utils/installation/detect-renderer.ts'),
'@/consts': resolve(__dirname, '../../site/src/consts.ts'),
},
});
+23
View File
@@ -0,0 +1,23 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
define: {
__CLI_VERSION__: JSON.stringify('0.0.0-test'),
},
test: {
globals: true,
},
resolve: {
alias: {
'@/utils/installation/codegen': resolve(__dirname, '../../site/src/utils/installation/codegen.ts'),
'@/utils/installation/types': resolve(__dirname, '../../site/src/utils/installation/types.ts'),
'@/utils/installation/cdn-code': resolve(__dirname, '../../site/src/utils/installation/cdn-code.ts'),
'@/utils/installation/detect-renderer': resolve(
__dirname,
'../../site/src/utils/installation/detect-renderer.ts'
),
'@/consts': resolve(__dirname, '../../site/src/consts.ts'),
},
},
});
+46 -2
View File
@@ -157,6 +157,28 @@ importers:
specifier: ^8.0.0
version: 8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
packages/cli:
dependencies:
'@bomb.sh/args':
specifier: ^0.3.1
version: 0.3.1
'@clack/prompts':
specifier: ^0.10.1
version: 0.10.1
devDependencies:
site:
specifier: workspace:*
version: link:../../site
tsdown:
specifier: ^0.21.4
version: 0.21.4(typescript@6.0.2)
typescript:
specifier: ^6.0.2
version: 6.0.2
vitest:
specifier: ^4.1.0
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
packages/core:
dependencies:
'@videojs/spf':
@@ -980,13 +1002,22 @@ packages:
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@bomb.sh/args@0.3.1':
resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==}
'@capsizecss/unpack@4.0.0':
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
engines: {node: '>=18'}
'@clack/core@0.4.2':
resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==}
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
'@clack/prompts@0.10.1':
resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==}
'@clack/prompts@1.2.0':
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
@@ -8380,15 +8411,28 @@ snapshots:
'@blazediff/core@1.9.1': {}
'@bomb.sh/args@0.3.1': {}
'@capsizecss/unpack@4.0.0':
dependencies:
fontkitten: 1.0.3
'@clack/core@0.4.2':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@clack/prompts@0.10.1':
dependencies:
'@clack/core': 0.4.2
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@1.2.0':
dependencies:
'@clack/core': 1.2.0
@@ -10766,7 +10810,7 @@ snapshots:
'@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
playwright: 1.59.1
tinyrainbow: 3.1.0
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- bufferutil
- msw
@@ -10937,7 +10981,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/utils@4.1.0':
dependencies:
+15
View File
@@ -34,6 +34,21 @@ export default function llmsMarkdown(): AstroIntegration {
emDelimiter: '*',
});
// Ensure [data-llms-only] content passes through despite hidden attribute
turndown.addRule('llms-only', {
filter: (node) => node.nodeType === 1 && (node as Element).getAttribute('data-llms-only') !== null,
replacement: (content) => content,
});
// Wrap [data-cli-replace] content with text markers the CLI can find and replace
turndown.addRule('cli-replace', {
filter: (node) => node.nodeType === 1 && (node as Element).getAttribute('data-cli-replace') !== null,
replacement: (content, node) => {
const id = (node as Element).getAttribute('data-cli-replace');
return `\n<!-- cli:replace ${id} -->\n${content}\n<!-- /cli:replace ${id} -->\n`;
},
});
// Track all docs and blog pages for llms.txt index
const docsPages: PageEntry[] = [];
const blogPages: PageEntry[] = [];
+6
View File
@@ -0,0 +1,6 @@
---
---
<div class="contents" data-llms-ignore>
<slot />
</div>
+6
View File
@@ -0,0 +1,6 @@
---
---
<div hidden data-llms-only>
<slot />
</div>
@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { InstallMethod } from '@/stores/installation';
import { installMethod } from '@/stores/installation';
import type { InstallMethod } from '@/utils/installation/types';
import HTMLCdnCodeBlock from './HTMLCdnCodeBlock';
export default function HTMLInstallTabs() {
@@ -23,7 +23,6 @@ export default function HTMLInstallTabs() {
}
});
// Observe all tab elements for attribute changes
const tabs = root.querySelectorAll('[role="tab"]');
tabs.forEach((tab) => {
observer.observe(tab, { attributes: true, attributeFilter: ['data-tab-active'] });
@@ -1,134 +1,8 @@
import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import { VJS10_DEMO_VIDEO } from '@/consts';
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import { installMethod, renderer, skin, sourceUrl, useCase } from '@/stores/installation';
function getRendererTag(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'background-video',
// cloudflare: 'cloudflare-video',
// dash: 'dash-video',
hls: 'hls-video',
'html5-audio': 'audio',
'html5-video': 'video',
// jwplayer: 'jwplayer-video',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
// shaka: 'shaka-video',
// spotify: 'spotify-audio',
// vimeo: 'vimeo-video',
// wistia: 'wistia-video',
// youtube: 'youtube-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 {
// Background video has fixed skin
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 isVideoLikeRenderer(renderer: Renderer): boolean {
return renderer === 'html5-video' || renderer === 'hls' || renderer === 'background-video';
}
function getRendererElement(renderer: Renderer, url: string): string {
const tag = getRendererTag(renderer);
const src = url.trim() || getDefaultSourceUrl(renderer);
const playsInline = isVideoLikeRenderer(renderer) ? ' playsinline' : '';
return `<${tag} src="${src}"${playsInline}></${tag}>`;
}
function getDefaultSourceUrl(renderer: Renderer): string {
switch (renderer) {
case 'hls':
return VJS10_DEMO_VIDEO.hls;
case 'background-video':
case 'html5-audio':
case 'html5-video':
default:
return VJS10_DEMO_VIDEO.mp4;
}
}
function generateHTMLCode(useCase: UseCase, skin: Skin, renderer: Renderer, url: string): string {
const providerTag = getProviderTag(useCase);
const skinTag = getSkinTag(useCase, skin);
const rendererElement = getRendererElement(renderer, url);
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.
-->
${rendererElement}
</${skinTag}>
</${providerTag}>`;
}
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',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
};
return map[renderer] ?? null;
}
function generateJS(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}`;
}
import { generateHTMLUsageCode } from '@/utils/installation/codegen';
export default function HTMLUsageCodeBlock() {
const $useCase = useStore(useCase);
@@ -137,9 +11,17 @@ export default function HTMLUsageCodeBlock() {
const $installMethod = useStore(installMethod);
const $sourceUrl = useStore(sourceUrl);
const result = generateHTMLUsageCode({
useCase: $useCase,
skin: $skin,
renderer: $renderer,
sourceUrl: $sourceUrl,
installMethod: $installMethod,
});
return (
<>
{$installMethod !== 'cdn' && (
{result.js && (
<TabsRoot maxWidth={false}>
<TabsList label="HTML implementation">
<Tab value="javascript" initial>
@@ -147,7 +29,7 @@ export default function HTMLUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="javascript" initial>
<ClientCode code={generateJS($useCase, $skin, $renderer)} lang="javascript" />
<ClientCode code={result.js} lang="javascript" />
</TabsPanel>
</TabsRoot>
)}
@@ -158,7 +40,7 @@ export default function HTMLUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="html" initial>
<ClientCode code={generateHTMLCode($useCase, $skin, $renderer, $sourceUrl)} lang="html" />
<ClientCode code={result.html} lang="html" />
</TabsPanel>
</TabsRoot>
</>
@@ -1,143 +1,20 @@
import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import { renderer, skin, useCase } from '@/stores/installation';
function getRendererComponent(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'BackgroundVideo',
// cloudflare: 'CloudflareVideo',
// dash: 'DashVideo',
hls: 'HlsVideo',
'html5-audio': 'Audio',
'html5-video': 'Video',
// jwplayer: 'JwplayerVideo',
// mux-audio: 'MuxAudio',
// mux-background-video: 'MuxBackgroundVideo',
// mux-video: 'MuxVideo',
// shaka: 'ShakaVideo',
// spotify: 'SpotifyAudio',
// vimeo: 'VimeoVideo',
// wistia: 'WistiaVideo',
// youtube: 'YoutubeVideo',
};
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 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 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 isVideoLikeRenderer(renderer: Renderer): boolean {
return renderer === 'html5-video' || renderer === 'hls' || renderer === 'background-video';
}
function getRendererMediaSubpath(renderer: Renderer): string {
const map: Partial<Record<Renderer, string>> = {
// cloudflare: 'cloudflare-video',
// dash: 'dash-video',
hls: 'hls-video',
// jwplayer: 'jwplayer-video',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
// spotify: 'spotify-audio',
// vimeo: 'vimeo-video',
// wistia: 'wistia-video',
// youtube: 'youtube-video',
};
return map[renderer] ?? renderer;
}
function generateReactCode(useCase: UseCase, skin: Skin, renderer: Renderer): string {
const rendererComponent = getRendererComponent(renderer);
const featureType = getUseCaseFeatures(useCase);
// Background video has fixed skin and subpath imports, others use skin picker value
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`;
// Preset subpath where skin + default media components live
const presetSubpath = isBackgroundVideo ? 'background' : group;
// Skin and media imports — preset renderers share a subpath with the skin
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)}';`;
}
// Determine props — mux variants use src with stream.mux.com URL
const propsInterface = 'interface MyPlayerProps {\n src: string;\n}';
const destructuredProp = 'src';
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 `'use client';
${imports}
const Player = createPlayer({ features: ${featureType} });
${propsInterface}
export const MyPlayer = ({ ${destructuredProp} }: MyPlayerProps) => {
return (
<Player.Provider>
<${skinComponent}>
${rendererJsx}
</${skinComponent}>
</Player.Provider>
);
};`;
}
import { generateReactCreateCode } from '@/utils/installation/codegen';
export default function ReactCreateCodeBlock() {
const $useCase = useStore(useCase);
const $skin = useStore(skin);
const $renderer = useStore(renderer);
const result = generateReactCreateCode({
useCase: $useCase,
skin: $skin,
renderer: $renderer,
});
return (
<TabsRoot maxWidth={false}>
<TabsList label="React implementation">
@@ -146,7 +23,7 @@ export default function ReactCreateCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="react" initial>
<ClientCode code={generateReactCode($useCase, $skin, $renderer)} lang="tsx" />
<ClientCode code={result['MyPlayer.tsx']} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
@@ -1,34 +1,18 @@
import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import { VJS10_DEMO_VIDEO } from '@/consts';
import type { Renderer } from '@/stores/installation';
import { renderer, sourceUrl } from '@/stores/installation';
function getDefaultSourceUrl(renderer: Renderer): string {
return renderer === 'hls' ? VJS10_DEMO_VIDEO.hls : VJS10_DEMO_VIDEO.mp4;
}
function generateUsageCode(url: string, renderer: Renderer): string {
const source = url.trim() || getDefaultSourceUrl(renderer);
const playerProp = `src="${source}"`;
return `import { MyPlayer } from '../components/player';
export const HomePage = () => {
return (
<div>
<h1>Welcome to My App</h1>
<MyPlayer ${playerProp} />
</div>
);
};`;
}
import { generateReactUsageCode } from '@/utils/installation/codegen';
export default function ReactUsageCodeBlock() {
const $renderer = useStore(renderer);
const $sourceUrl = useStore(sourceUrl);
const result = generateReactUsageCode({
renderer: $renderer,
sourceUrl: $sourceUrl,
});
return (
<TabsRoot maxWidth={false}>
<TabsList label="React usage">
@@ -37,7 +21,7 @@ export default function ReactUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="react" initial>
<ClientCode code={generateUsageCode($sourceUrl, $renderer)} lang="tsx" />
<ClientCode code={result['App.tsx']} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
@@ -1,9 +1,10 @@
import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { Select, type SelectOption } from '@/components/Select';
import type { Renderer, UseCase } from '@/stores/installation';
import { renderer, sourceUrl, useCase, VALID_RENDERERS } from '@/stores/installation';
import { renderer, sourceUrl, useCase } from '@/stores/installation';
import { articleFor, detectRenderer } from '@/utils/installation/detect-renderer';
import type { Renderer, UseCase } from '@/utils/installation/types';
import { VALID_RENDERERS } from '@/utils/installation/types';
const RENDERER_LABELS: Record<Renderer, string> = {
'background-video': 'Background Video',
@@ -3,8 +3,8 @@ import { Minus, Sparkles } from 'lucide-react';
import { useEffect } from 'react';
import type { ImageRadioOption } from '@/components/ImageRadioGroup';
import ImageRadioGroup from '@/components/ImageRadioGroup';
import type { Skin } from '@/stores/installation';
import { skin, useCase } from '@/stores/installation';
import type { Skin } from '@/utils/installation/types';
const VIDEO_SKINS: ImageRadioOption<Skin>[] = [
{ value: 'video', label: 'Default', image: <Sparkles size={32} /> },
@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { Globe, Image } from 'lucide-react';
import ImageRadioGroup from '@/components/ImageRadioGroup';
import type { UseCase } from '@/stores/installation';
import { useCase } from '@/stores/installation';
import type { UseCase } from '@/utils/installation/types';
export default function UseCasePicker() {
const $useCase = useStore(useCase);
@@ -16,6 +16,8 @@ import Aside from '@/components/Aside.astro';
import DocsLink from '@/components/docs/DocsLink.astro';
import DocsLinkCard from '@/components/docs/DocsLinkCard.astro';
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
import HumanCase from '@/components/docs/HumanCase.astro';
import LLMCase from '@/components/docs/LLMCase.astro';
import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs.tsx';
<Aside type="caution" title="Beta Software">
@@ -29,6 +31,26 @@ Video.js is a **React video player component library** &mdash; composable primit
Video.js is an **HTML video player built on custom elements** &mdash; lightweight, framework-free components for building accessible, customizable players with minimal bundle size.
</FrameworkCase>
<LLMCase>
<div data-cli-replace="installation">
Run `npx @videojs/cli docs how-to/installation` interactively, or pass all flags:
```bash
npx @videojs/cli docs how-to/installation \
--framework <html|react> \
--preset <video|audio|background-video> \
--skin <default|minimal> \
--media <html5-video|html5-audio|hls|background-video> \
--source-url <url> \
--install-method <cdn|npm|pnpm|yarn|bun>
```
</div>
</LLMCase>
<HumanCase>
Answer the questions below to get started quickly with your first embed code.
## Choose your JS framework
@@ -120,6 +142,8 @@ Add it to your components folder in a new file.
</ContentWidth>
</FrameworkCase>
</HumanCase>
## CSP
If your application uses a Content Security Policy, you may need to allow additional sources for player features to work correctly.
+5 -1
View File
@@ -58,7 +58,11 @@ const fullTitle =
/>
<meta name="generator" content={Astro.generator} />
<meta name="algolia-site-verification" content="9CE2D1D6448256D3" />
{Astro.site?.origin !== PRODUCTION_URL.origin && <meta name="robots" content="noindex" />}
{
Astro.site?.origin !== PRODUCTION_URL.origin && (
<meta name="robots" content="noindex" />
)
}
{/* Analytics (production only) */}
{import.meta.env.PROD && <Posthog />}
+1 -32
View File
@@ -1,43 +1,12 @@
import { atom } from 'nanostores';
export type Renderer =
| 'background-video'
// | 'cloudflare'
// | 'dash'
| 'hls'
| 'html5-audio'
| 'html5-video';
// | 'jwplayer'
// | 'mux-audio'
// | 'mux-background-video'
// | 'mux-video'
// | 'shaka'
// | 'spotify'
// | 'vimeo'
// | 'wistia'
// | 'youtube'
export type Skin = 'video' | 'audio' | 'minimal-video' | 'minimal-audio';
export type UseCase = 'default-video' | 'default-audio' | 'background-video';
import type { InstallMethod, Renderer, Skin, UseCase } from '@/utils/installation/types';
export const renderer = atom<Renderer>('html5-video');
export const skin = atom<Skin>('video');
export const useCase = atom<UseCase>('default-video');
export const sourceUrl = atom<string>('');
export type InstallMethod = 'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun';
export const installMethod = atom<InstallMethod>('cdn');
/** Mux playback ID from successful upload (used by code generation) */
export const muxPlaybackId = atom<string | null>(null);
export const VALID_RENDERERS: Record<UseCase, Renderer[]> = {
'default-video': [
'html5-video',
/* 'cloudflare', 'dash', */ 'hls' /* , 'jwplayer', 'mux-video', 'vimeo', 'wistia', 'youtube' */,
],
'default-audio': ['html5-audio' /* , 'mux-audio', 'spotify' */],
'background-video': ['background-video' /* , 'mux-background-video' */],
};
@@ -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'],
};
+3 -1
View File
@@ -22,7 +22,9 @@
{ "path": "packages/spf/src/dom" },
{ "path": "packages/html" },
{ "path": "packages/react" }
{ "path": "packages/react" },
{ "path": "packages/cli" }
],
"files": []
}