diff --git a/.github/release-please/.release-please-manifest.json b/.github/release-please/.release-please-manifest.json index b34cef501..0379e9a3d 100644 --- a/.github/release-please/.release-please-manifest.json +++ b/.github/release-please/.release-please-manifest.json @@ -1,4 +1,5 @@ { + "packages/cli": "10.0.0-beta.15", "packages/core": "10.0.0-beta.15", "packages/element": "10.0.0-beta.15", "packages/html": "10.0.0-beta.15", diff --git a/.github/release-please/release-please-config.json b/.github/release-please/release-please-config.json index 106c216cb..609d0947d 100644 --- a/.github/release-please/release-please-config.json +++ b/.github/release-please/release-please-config.json @@ -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, diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index db53e206a..f38937cba 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -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 diff --git a/.gitignore b/.gitignore index 61f0b6e2c..6f890eb5a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/commitlint.config.js b/commitlint.config.js index c2f85b9fe..63f104faa 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -17,6 +17,7 @@ export default { 'cd', 'ci', 'claude', + 'cli', 'core', 'design', 'element', diff --git a/internal/design/site/cli-llm-friendly-installation.md b/internal/design/site/cli-llm-friendly-installation.md deleted file mode 100644 index 18aabbd75..000000000 --- a/internal/design/site/cli-llm-friendly-installation.md +++ /dev/null @@ -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 (see "framework resolution" below.) - --preset (default: video) - --skin (default: default) - --media (default: per preset) - --source-url (default: per media) - --install-method (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 [--framework ] -``` - -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 -npx @videojs/cli config get -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 diff --git a/package.json b/package.json index ac4884f4f..794968d10 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..8b0790c32 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "@videojs/cli", + "type": "module", + "version": "10.0.0-beta.15", + "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" + } +} diff --git a/packages/cli/scripts/copy-docs.js b/packages/cli/scripts/copy-docs.js new file mode 100644 index 000000000..dbe6fe3c2 --- /dev/null +++ b/packages/cli/scripts/copy-docs.js @@ -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/'); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 000000000..b4de7e30c --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,66 @@ +import { getConfigValue, listConfig, setConfigValue } from '../utils/config.js'; + +const CONFIG_HELP = `Usage: @videojs/cli config + +Keys: + framework 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 '); + 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 '); + 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); + } +} diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts new file mode 100644 index 000000000..e3a0ec42e --- /dev/null +++ b/packages/cli/src/commands/docs.ts @@ -0,0 +1,204 @@ +import * as p from '@clack/prompts'; +import { validateInstallationOptions } from '@/utils/installation/codegen'; +import type { InstallMethod, Renderer, Skin, 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 { 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 { + 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 = { + 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; +} + +function mapSkinFlag(skinFlag: string, preset: string): Skin { + const isAudio = preset === 'audio'; + const map: Record = { + 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; +} + +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 (flags.preset) { + partial.skin = mapSkinFlag(flags.skin, flags.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']) { + partial.installMethod = validateInstallMethod(flags['install-method'], framework); + } + + return partial; +} + +const DOCS_HELP = `Usage: @videojs/cli docs [--framework ] + @videojs/cli docs --list [--framework ] + +Installation flags (for docs how-to/installation): + --preset + --skin + --source-url + --media + --install-method `; + +export async function handleDocs(flags: ParsedFlags, positionals: string[]): Promise { + 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); +} diff --git a/packages/cli/src/commands/tests/docs.test.ts b/packages/cli/src/commands/tests/docs.test.ts new file mode 100644 index 000000000..66fb79077 --- /dev/null +++ b/packages/cli/src/commands/tests/docs.test.ts @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +// --- Fixtures --- + +const INSTALLATION_DOC = `# Installation + +Intro paragraph. + + +Placeholder for CLI-generated code. + + +## 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 = {}) => ({ + framework: 'html', + preset: 'video', + skin: 'default', + media: 'html5-video', + 'source-url': '', + 'install-method': 'npm', + ...overrides, + }); + + const reactFlags = (overrides: Record = {}) => ({ + 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(''); + expect(out).not.toContain('\\n[\\s\\S]*?\\n`); + return markdown.replace(re, replacement); +} diff --git a/packages/cli/src/utils/tests/config.test.ts b/packages/cli/src/utils/tests/config.test.ts new file mode 100644 index 000000000..02ea399f2 --- /dev/null +++ b/packages/cli/src/utils/tests/config.test.ts @@ -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('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"'); + }); +}); diff --git a/packages/cli/src/utils/tests/format.test.ts b/packages/cli/src/utils/tests/format.test.ts new file mode 100644 index 000000000..a985b8acd --- /dev/null +++ b/packages/cli/src/utils/tests/format.test.ts @@ -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(''); + }); + + it('formats HTML + CDN without JS imports section', () => { + const result = formatInstallationCode({ ...baseHTML, installMethod: 'cdn' }); + expect(result).toContain('## Install Video.js'); + expect(result).toContain(' { + 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'); + }); +}); diff --git a/packages/cli/src/utils/tests/replace.test.ts b/packages/cli/src/utils/tests/replace.test.ts new file mode 100644 index 000000000..b3aa710b8 --- /dev/null +++ b/packages/cli/src/utils/tests/replace.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { replaceMarker } from '../replace.js'; + +describe('replaceMarker', () => { + it('replaces content between markers', () => { + const markdown = `# Title + + +old content here + + +## 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 + +middle + +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('handles multiline content between markers', () => { + const markdown = `start + +line 1 +line 2 +line 3 + +end`; + + const result = replaceMarker(markdown, 'multi', 'single line'); + expect(result).toBe('start\nsingle line\nend'); + }); +}); diff --git a/packages/cli/src/utils/tests/site-aliases.test.ts b/packages/cli/src/utils/tests/site-aliases.test.ts new file mode 100644 index 000000000..c59313fc1 --- /dev/null +++ b/packages/cli/src/utils/tests/site-aliases.test.ts @@ -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); + }); + } +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..2a9c96049 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts new file mode 100644 index 000000000..a4f9d89e6 --- /dev/null +++ b/packages/cli/tsdown.config.ts @@ -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'), + }, +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..2d74f45c5 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -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'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a869ccfb..74e8548be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/site/integrations/llms-markdown.ts b/site/integrations/llms-markdown.ts index 2d62af928..2a3e01745 100644 --- a/site/integrations/llms-markdown.ts +++ b/site/integrations/llms-markdown.ts @@ -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\n${content}\n\n`; + }, + }); + // Track all docs and blog pages for llms.txt index const docsPages: PageEntry[] = []; const blogPages: PageEntry[] = []; diff --git a/site/src/components/docs/HumanCase.astro b/site/src/components/docs/HumanCase.astro new file mode 100644 index 000000000..77d9afd67 --- /dev/null +++ b/site/src/components/docs/HumanCase.astro @@ -0,0 +1,6 @@ +--- +--- + +
+ +
diff --git a/site/src/components/docs/LLMCase.astro b/site/src/components/docs/LLMCase.astro new file mode 100644 index 000000000..7e99ffb2e --- /dev/null +++ b/site/src/components/docs/LLMCase.astro @@ -0,0 +1,6 @@ +--- +--- + + diff --git a/site/src/components/installation/HTMLInstallTabs.tsx b/site/src/components/installation/HTMLInstallTabs.tsx index be6d9f262..56ea16150 100644 --- a/site/src/components/installation/HTMLInstallTabs.tsx +++ b/site/src/components/installation/HTMLInstallTabs.tsx @@ -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'] }); diff --git a/site/src/components/installation/HTMLUsageCodeBlock.tsx b/site/src/components/installation/HTMLUsageCodeBlock.tsx index 518293522..a5d859dd0 100644 --- a/site/src/components/installation/HTMLUsageCodeBlock.tsx +++ b/site/src/components/installation/HTMLUsageCodeBlock.tsx @@ -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 = { - '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 = { - '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 = { - 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}>`; -} - -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 ` -<${providerTag}> - - <${skinTag}> - - ${rendererElement} - -`; -} - -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> = { - 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 && ( @@ -147,7 +29,7 @@ export default function HTMLUsageCodeBlock() { - + )} @@ -158,7 +40,7 @@ export default function HTMLUsageCodeBlock() { - + diff --git a/site/src/components/installation/ReactCreateCodeBlock.tsx b/site/src/components/installation/ReactCreateCodeBlock.tsx index f524fba9d..3d1959447 100644 --- a/site/src/components/installation/ReactCreateCodeBlock.tsx +++ b/site/src/components/installation/ReactCreateCodeBlock.tsx @@ -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 = { - '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 = { - 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 = { - '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> = { - // 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 ( - - <${skinComponent}> - ${rendererJsx} - - - ); -};`; -} +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 ( @@ -146,7 +23,7 @@ export default function ReactCreateCodeBlock() { - + ); diff --git a/site/src/components/installation/ReactUsageCodeBlock.tsx b/site/src/components/installation/ReactUsageCodeBlock.tsx index 75debb494..0659d854d 100644 --- a/site/src/components/installation/ReactUsageCodeBlock.tsx +++ b/site/src/components/installation/ReactUsageCodeBlock.tsx @@ -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 ( -
-

Welcome to My App

- -
- ); -};`; -} +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 ( @@ -37,7 +21,7 @@ export default function ReactUsageCodeBlock() { - + ); diff --git a/site/src/components/installation/RendererSelect.tsx b/site/src/components/installation/RendererSelect.tsx index 004db7974..2071ec96f 100644 --- a/site/src/components/installation/RendererSelect.tsx +++ b/site/src/components/installation/RendererSelect.tsx @@ -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 = { 'background-video': 'Background Video', diff --git a/site/src/components/installation/SkinPicker.tsx b/site/src/components/installation/SkinPicker.tsx index 7610177df..74d3e55b3 100644 --- a/site/src/components/installation/SkinPicker.tsx +++ b/site/src/components/installation/SkinPicker.tsx @@ -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[] = [ { value: 'video', label: 'Default', image: }, diff --git a/site/src/components/installation/UseCasePicker.tsx b/site/src/components/installation/UseCasePicker.tsx index 4e661c55c..69a2230c0 100644 --- a/site/src/components/installation/UseCasePicker.tsx +++ b/site/src/components/installation/UseCasePicker.tsx @@ -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); diff --git a/site/src/content/docs/how-to/installation.mdx b/site/src/content/docs/how-to/installation.mdx index fe70037c1..c733945f6 100644 --- a/site/src/content/docs/how-to/installation.mdx +++ b/site/src/content/docs/how-to/installation.mdx @@ -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';