mirror of
https://github.com/zoriya/v10.git
synced 2026-08-12 08:59:02 +00:00
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:
co-authored by
Claude Opus 4.6
parent
9681515446
commit
24b8b77c8a
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __CLI_VERSION__: string;
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+61
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user