feat: add a CDN media-availability manifest and read it across install surfaces (#1737)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-06-25 11:20:45 -07:00
committed by GitHub
co-authored by Claude
parent a74b8c0993
commit f1f2129739
20 changed files with 300 additions and 77 deletions
+1
View File
@@ -37,6 +37,7 @@ site/src/content/generated-feature-reference/
site/src/content/generated-media-reference/
site/src/content/generated-preset-reference/
site/src/content/ejected-skins.json
site/src/content/cdn-media.json
packages/cli/docs/
packages/html/docs/
packages/react/docs/
+15 -1
View File
@@ -5,7 +5,13 @@ 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 {
mapRawSkin,
type PartialInstallFlags,
promptFramework,
promptInstallOptions,
supportsCdnInstall,
} from '../utils/prompts.js';
import { replaceMarker, stripOmitMarkers } from '../utils/replace.js';
interface ParsedFlags {
@@ -177,6 +183,14 @@ export async function handleDocs(flags: ParsedFlags, positionals: string[]): Pro
process.exit(1);
}
// The interactive prompt hides CDN for renderers without a CDN build; guard
// the non-interactive flag path so a `--install-method cdn` request for one
// can't emit a broken snippet.
if (opts.installMethod === 'cdn' && !supportsCdnInstall(opts.renderer)) {
console.error('Error: this source type has no CDN build. Install it with npm, pnpm, yarn, or bun.');
process.exit(1);
}
const generated = formatInstallationCode(opts);
const output = stripOmitMarkers(replaceMarker(markdown, 'installation', generated));
printVersionHeader();
+12
View File
@@ -59,3 +59,15 @@ declare module '@/utils/installation/detect-renderer' {
export function detectRenderer(url: string, useCase: UseCase): DetectionResult | null;
}
declare module '@/utils/installation/cdn-code' {
import type { Renderer, Skin, UseCase } from '@/utils/installation/types';
export function generateCdnCode(useCase: UseCase, skin: Skin, renderer: Renderer): string;
export function rendererSupportsCdn(renderer: Renderer, cdnMediaSubpaths: readonly string[]): boolean;
}
declare module '@/content/cdn-media.json' {
const entries: Array<{ id: string }>;
export default entries;
}
+16 -3
View File
@@ -1,10 +1,18 @@
import * as p from '@clack/prompts';
import cdnMedia from '@/content/cdn-media.json';
import { rendererSupportsCdn } from '@/utils/installation/cdn-code';
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';
const CDN_MEDIA_SUBPATHS = cdnMedia.map((entry) => entry.id);
export function supportsCdnInstall(renderer: Renderer): boolean {
return rendererSupportsCdn(renderer, CDN_MEDIA_SUBPATHS);
}
export async function promptFramework(): Promise<Framework> {
const value = await p.select({
message: 'Which framework?',
@@ -50,14 +58,19 @@ function skinOptionsForUseCase(useCase: UseCase): Array<{ value: Skin; label: st
];
}
function installMethodOptions(framework: Framework): Array<{ value: InstallMethod; label: string }> {
function installMethodOptions(
framework: Framework,
renderer: Renderer
): 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') {
// CDN is HTML-only, and only when the renderer ships a CDN build — matching
// the install page, which hides the CDN tab for renderers without one.
if (framework === 'html' && supportsCdnInstall(renderer)) {
options.unshift({ value: 'cdn', label: 'CDN' });
}
return options;
@@ -154,7 +167,7 @@ export async function promptInstallOptions(
(await (async () => {
const value = await p.select({
message: 'Install method',
options: installMethodOptions(framework),
options: installMethodOptions(framework, media),
});
if (p.isCancel(value)) process.exit(0);
return value;
+9
View File
@@ -0,0 +1,9 @@
[
{ "id": "dash-video" },
{ "id": "hls-video" },
{ "id": "mux-audio" },
{ "id": "mux-video" },
{ "id": "native-hls-video" },
{ "id": "simple-hls-audio-only" },
{ "id": "simple-hls-video" }
]
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { supportsCdnInstall } from '../prompts.js';
// Wires the cdn-media manifest into the CLI the same way the install page reads
// the cdnMedia collection. Every current renderer ships (or is covered by) a
// CDN build, so all resolve true; the no-CDN path (e.g. Vimeo) arrives with the
// new rendering engines. `rendererSupportsCdn`'s false branch is unit-tested in
// cdn-code.test.ts.
describe('supportsCdnInstall', () => {
it('returns true for preset renderers (covered by the preset bundle)', () => {
expect(supportsCdnInstall('html5-video')).toBe(true);
expect(supportsCdnInstall('html5-audio')).toBe(true);
expect(supportsCdnInstall('background-video')).toBe(true);
});
it('returns true for hls, whose media bundle ships a CDN build', () => {
expect(supportsCdnInstall('hls')).toBe(true);
});
});
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
'@/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'),
'@/content/cdn-media.json': resolve(__dirname, '../../site/src/content/cdn-media.json'),
'@/consts': resolve(__dirname, '../../site/src/consts.ts'),
},
});
+5
View File
@@ -17,6 +17,11 @@ export default defineConfig({
__dirname,
'../../site/src/utils/installation/detect-renderer.ts'
),
// The real manifest is generated at build time (gitignored) and bundled
// by tsdown. CLI tests are intentionally hermetic (`test` has no turbo
// build dependency), so they resolve a committed fixture that mirrors the
// manifest's shape and contents instead of forcing a CDN build.
'@/content/cdn-media.json': resolve(__dirname, 'src/utils/tests/fixtures/cdn-media.json'),
'@/consts': resolve(__dirname, '../../site/src/consts.ts'),
},
},
+2 -1
View File
@@ -5,9 +5,10 @@
"scripts": {
"api-docs": "tsx scripts/api-docs-builder/src/index.ts",
"ejected-skins": "tsx scripts/build-ejected-skins.ts",
"cdn-manifest": "tsx scripts/build-cdn-manifest.ts",
"dev": "NETLIFY_DEV=1 astro dev",
"build": "astro build",
"clean": "rm -rf dist .netlify src/content/generated-component-reference src/content/generated-util-reference src/content/ejected-skins.json",
"clean": "rm -rf dist .netlify src/content/generated-component-reference src/content/generated-util-reference src/content/ejected-skins.json src/content/cdn-media.json",
"astro": "astro",
"test": "vitest run",
"test:watch": "vitest",
+65
View File
@@ -0,0 +1,65 @@
/**
* Build the CDN media manifest for the installation guide.
*
* Scans the built `@videojs/html` CDN media bundles and records which media
* subpaths actually ship a CDN build. The installation page uses this to hide
* the CDN install option for renderers that have no CDN bundle (e.g. Vimeo).
*
* Produces `site/src/content/cdn-media.json` as an array of `{ id }` entries
* (one per media subpath), consumed via the `cdnMedia` content collection.
*
* Source of truth: the built output of `@videojs/html`'s `build:cdn` task
* (configured in `packages/html/tsdown.cdn.config.ts`). Reading the build
* output rather than a hand-maintained list means a renderer that fails to
* ship a CDN bundle correctly shows as no-CDN.
*
* Prerequisites: `@videojs/html`'s `build:cdn` (wired as a turbo dependency).
*/
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { z } from 'astro/zod';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '../..');
const CDN_MEDIA_DIR = resolve(ROOT, 'packages/html/cdn/media');
const OUTPUT = resolve(ROOT, 'site/src/content/cdn-media.json');
const PREFIX = '\x1b[35m[cdn-manifest]\x1b[0m';
const log = {
info: (...args: unknown[]) => console.log(PREFIX, ...args),
warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args),
error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args),
};
const ManifestSchema = z.array(z.object({ id: z.string() }));
function main() {
if (!existsSync(CDN_MEDIA_DIR)) {
log.error(`CDN media build not found at ${CDN_MEDIA_DIR}.`);
log.error("Run @videojs/html's build:cdn first (it's wired as a turbo dependency).");
process.exit(1);
}
// Production bundles are `<subpath>.js`; skip dev (`.dev.js`), sourcemaps,
// and type stubs.
const subpaths = readdirSync(CDN_MEDIA_DIR)
.filter((file) => file.endsWith('.js') && !file.endsWith('.dev.js'))
.map((file) => file.replace(/\.js$/, ''))
.sort();
if (subpaths.length === 0) {
log.error(`No CDN media bundles found in ${CDN_MEDIA_DIR}.`);
process.exit(1);
}
const entries = ManifestSchema.parse(subpaths.map((id) => ({ id })));
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, `${JSON.stringify(entries, null, 2)}\n`);
log.info(`✅ Wrote ${entries.length} CDN media entries to ${OUTPUT}`);
}
main();
@@ -0,0 +1,10 @@
---
import { getCollection } from 'astro:content';
import HTMLInstallTabsClient from './HTMLInstallTabsClient';
// Read the generated CDN media manifest server-side and hand the list to the
// client island, which decides per-renderer whether to offer the CDN option.
const cdnMedia = (await getCollection('cdnMedia')).map((entry) => entry.id);
---
<HTMLInstallTabsClient client:idle cdnMedia={cdnMedia} />
@@ -1,64 +0,0 @@
import { useEffect, useRef } from 'react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import { installMethod } from '@/stores/installation';
import type { InstallMethod } from '@/utils/installation/types';
import HTMLCdnCodeBlock from './HTMLCdnCodeBlock';
export default function HTMLInstallTabs() {
const ref = useRef<HTMLDivElement>(null);
// Observe tab changes and sync to installMethod store
useEffect(() => {
const root = ref.current?.querySelector('[data-tabs-root]');
if (!root) return;
const observer = new MutationObserver(() => {
const activeTab = root.querySelector('[role="tab"][data-tab-active="true"]');
if (activeTab) {
const value = activeTab.getAttribute('data-value');
if (value) {
installMethod.set(value as InstallMethod);
}
}
});
const tabs = root.querySelectorAll('[role="tab"]');
tabs.forEach((tab) => {
observer.observe(tab, { attributes: true, attributeFilter: ['data-tab-active'] });
});
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
<TabsRoot>
<TabsList label="Installation">
<Tab value="cdn" initial>
cdn
</Tab>
<Tab value="npm">npm</Tab>
<Tab value="pnpm">pnpm</Tab>
<Tab value="yarn">yarn</Tab>
<Tab value="bun">bun</Tab>
</TabsList>
<TabsPanel value="cdn" initial>
<HTMLCdnCodeBlock />
</TabsPanel>
<TabsPanel value="npm">
<ClientCode code="npm install @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="pnpm">
<ClientCode code="pnpm add @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="yarn">
<ClientCode code="yarn add @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="bun">
<ClientCode code="bun add @videojs/html" lang="bash" />
</TabsPanel>
</TabsRoot>
</div>
);
}
@@ -0,0 +1,86 @@
import { useStore } from '@nanostores/react';
import clsx from 'clsx';
import { useEffect, useRef } from 'react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import { shared } from '@/components/typography/styles';
import { installMethod, renderer } from '@/stores/installation';
import { rendererSupportsCdn } from '@/utils/installation/cdn-code';
import type { InstallMethod } from '@/utils/installation/types';
import HTMLCdnCodeBlock from './HTMLCdnCodeBlock';
interface HTMLInstallTabsProps {
/** Media subpaths that ship a CDN build, from the cdn-media manifest. */
cdnMedia: string[];
}
export default function HTMLInstallTabs({ cdnMedia }: HTMLInstallTabsProps) {
const ref = useRef<HTMLDivElement>(null);
const $renderer = useStore(renderer);
const supportsCdn = rendererSupportsCdn($renderer, cdnMedia);
// Mirror the active install-method tab into the store so the usage code block
// can react (e.g. CDN omits the JS imports). Observing from the stable
// wrapper rather than the tabs root means the observer survives the keyed
// remount below, so it never needs to re-attach.
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new MutationObserver(() => {
const value = el.querySelector('[role="tab"][data-tab-active="true"]')?.getAttribute('data-value');
if (value) installMethod.set(value as InstallMethod);
});
observer.observe(el, { subtree: true, attributes: true, attributeFilter: ['data-tab-active'] });
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
{/* Remount the tab set when CDN availability changes so the active tab
resets cleanly to its initial. Without this, flipping the npm tab's
`initial` while the CDN tab mounts/unmounts can leave two tabs active
at once and desync installMethod from the visible tab. */}
<TabsRoot key={supportsCdn ? 'with-cdn' : 'without-cdn'}>
<TabsList label="Installation">
{supportsCdn && (
<Tab value="cdn" initial>
cdn
</Tab>
)}
<Tab value="npm" initial={!supportsCdn}>
npm
</Tab>
<Tab value="pnpm">pnpm</Tab>
<Tab value="yarn">yarn</Tab>
<Tab value="bun">bun</Tab>
</TabsList>
{supportsCdn && (
<TabsPanel value="cdn" initial>
<HTMLCdnCodeBlock />
</TabsPanel>
)}
<TabsPanel value="npm" initial={!supportsCdn}>
<ClientCode code="npm install @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="pnpm">
<ClientCode code="pnpm add @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="yarn">
<ClientCode code="yarn add @videojs/html" lang="bash" />
</TabsPanel>
<TabsPanel value="bun">
<ClientCode code="bun add @videojs/html" lang="bash" />
</TabsPanel>
</TabsRoot>
{!supportsCdn && (
<p className={clsx(shared.p, shared.prose)}>
This source type isn't available via CDN install it with a package manager (npm, pnpm, yarn, or bun).
</p>
)}
</div>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import type { HTMLTag, Polymorphic } from 'astro/types';
import { clsx } from 'clsx';
import { twMerge } from '@/utils/twMerge';
import { shared } from './styles';
type Props<Tag extends HTMLTag = 'p'> = Polymorphic<{ as: Tag }> & {
maxWidth?: boolean;
@@ -13,7 +14,7 @@ const { as: Tag = 'p', maxWidth = true, class: className, ...props } = Astro.pro
---
<Tag
class={twMerge(clsx("my-4", maxWidth && "mx-auto max-w-3xl"), className)}
class={twMerge(clsx(shared.p, maxWidth && shared.prose), className)}
{...props}
>
<slot />
+2
View File
@@ -13,6 +13,8 @@ export const shared = {
em: 'italic',
li: '',
ol: 'list-decimal list-outside pl-4 space-y-1',
p: 'my-4',
prose: 'mx-auto max-w-3xl',
strong: 'font-bold',
ul: 'list-disc list-outside pl-4 space-y-1',
} as const;
+11
View File
@@ -170,6 +170,16 @@ const ejectedSkins = defineCollection({
}),
});
// Media subpaths that ship a CDN build, generated by scripts/build-cdn-manifest.ts.
// Each entry's id is a media subpath (e.g. `hls-video`). Used by the installation
// guide to hide the CDN install option for renderers with no CDN bundle.
const cdnMedia = defineCollection({
loader: file('./src/content/cdn-media.json'),
schema: z.object({
id: z.string(),
}),
});
export const collections = {
blog,
docs,
@@ -180,4 +190,5 @@ export const collections = {
mediaReference,
presetReference,
ejectedSkins,
cdnMedia,
};
@@ -7,7 +7,7 @@ import JSPicker from '@/components/installation/JSPicker.astro';
import RendererPicker from '@/components/installation/RendererPicker';
import SkinPickerSection from '@/components/installation/SkinPickerSection.astro';
import UseCasePicker from '@/components/installation/UseCasePicker';
import HTMLInstallTabs from '@/components/installation/HTMLInstallTabs';
import HTMLInstallTabs from '@/components/installation/HTMLInstallTabs.astro';
import HTMLUsageCodeBlock from '@/components/installation/HTMLUsageCodeBlock';
import ReactCreateCodeBlock from '@/components/installation/ReactCreateCodeBlock';
import ReactUsageCodeBlock from '@/components/installation/ReactUsageCodeBlock';
@@ -112,7 +112,7 @@ Video.js supports a wide range of file types and hosting services. It's easy to
## Install Video.js
<FrameworkCase frameworks={["html"]}>
<HTMLInstallTabs client:idle />
<HTMLInstallTabs />
</FrameworkCase>
<FrameworkCase frameworks={["react"]}>
<TabsRoot client:idle>
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { generateCdnCode } from '../cdn-code';
import { generateCdnCode, rendererSupportsCdn } from '../cdn-code';
describe('generateCdnCode', () => {
it('generates video preset CDN tags for html5-video', () => {
@@ -33,3 +33,21 @@ describe('generateCdnCode', () => {
);
});
});
describe('rendererSupportsCdn', () => {
const manifest = ['hls-video'];
it('returns true for preset renderers (covered by the preset bundle, no media subpath)', () => {
expect(rendererSupportsCdn('html5-video', manifest)).toBe(true);
expect(rendererSupportsCdn('html5-audio', manifest)).toBe(true);
expect(rendererSupportsCdn('background-video', manifest)).toBe(true);
});
it('returns true for a media renderer whose subpath is in the manifest', () => {
expect(rendererSupportsCdn('hls', manifest)).toBe(true);
});
it('returns false for a media renderer whose subpath is absent from the manifest', () => {
expect(rendererSupportsCdn('hls', [])).toBe(false);
});
});
+17 -2
View File
@@ -10,14 +10,29 @@ function getCdnFileName(useCase: UseCase, skin: Skin): string {
return skin;
}
function getCdnMediaSubpath(renderer: Renderer): string | null {
// Renderer → media subpath name, independent of whether a CDN build exists.
// Preset renderers (html5-video/audio, background-video) are covered by the
// preset bundle and have no separate media script, so they map to null.
function getMediaSubpath(renderer: Renderer): string | null {
const map: Partial<Record<Renderer, string>> = {
hls: 'hls-video',
};
return map[renderer] ?? null;
}
// Whether a renderer can be installed via CDN, given the set of media subpaths
// that ship a CDN build (from the cdn-media manifest). Preset renderers always
// can (no separate media script); a media renderer can only if its subpath is
// in the manifest.
export function rendererSupportsCdn(renderer: Renderer, cdnMediaSubpaths: readonly string[]): boolean {
const subpath = getMediaSubpath(renderer);
return subpath === null || cdnMediaSubpaths.includes(subpath);
}
function getCdnMediaSubpath(renderer: Renderer): string | null {
return getMediaSubpath(renderer);
}
export function generateCdnCode(useCase: UseCase, skin: Skin, renderer: Renderer): string {
const name = getCdnFileName(useCase, skin);
const mediaSubpath = getCdnMediaSubpath(renderer);
+6 -2
View File
@@ -10,8 +10,12 @@
"dependsOn": ["^build"],
"outputs": ["src/content/ejected-skins.json"]
},
"cdn-manifest": {
"dependsOn": ["^build:cdn"],
"outputs": ["src/content/cdn-media.json"]
},
"build": {
"dependsOn": ["$TURBO_EXTENDS$", "api-docs", "ejected-skins"],
"dependsOn": ["$TURBO_EXTENDS$", "api-docs", "ejected-skins", "cdn-manifest"],
"env": [
"OAUTH_CLIENT_ID",
"OAUTH_CLIENT_SECRET",
@@ -23,7 +27,7 @@
]
},
"dev": {
"dependsOn": ["$TURBO_EXTENDS$", "api-docs", "ejected-skins"],
"dependsOn": ["$TURBO_EXTENDS$", "api-docs", "ejected-skins", "cdn-manifest"],
"env": [
"OAUTH_CLIENT_ID",
"OAUTH_CLIENT_SECRET",