From 664ff816456ad378ef5dd6955552be3e99954f18 Mon Sep 17 00:00:00 2001 From: Rahim Date: Sat, 29 Aug 2026 11:50:13 -0700 Subject: [PATCH] docs(site): document shadcn registry workflow --- .github/workflows/website-tests.yml | 2 +- pnpm-lock.yaml | 3 + site/package.json | 5 +- site/scripts/build-ejected-skins.ts | 6 - site/scripts/ejected-skins/config.ts | 149 --- site/scripts/ejected-skins/html.ts | 157 --- site/scripts/ejected-skins/index.ts | 79 -- .../scripts/ejected-skins/package-resolver.ts | 124 -- site/scripts/ejected-skins/react.ts | 1099 ----------------- site/scripts/tests/ejected-skins.test.ts | 178 --- site/src/components/Code/Shared.tsx | 24 +- site/src/components/Code/clientHighlighter.ts | 3 +- site/src/components/Code/serverHighlighter.ts | 3 +- site/src/components/docs/EjectedSkin.astro | 90 -- .../src/components/docs/RegistryInstall.astro | 68 + site/src/components/docs/ShadcnCommand.astro | 32 + .../docs/ShadcnRegistryCatalog.astro | 81 ++ site/src/components/docs/SourceExplorer.astro | 15 + .../components/docs/SourceExplorerClient.tsx | 167 +++ .../src/components/docs/skins/SkinGuide.astro | 141 +++ .../src/components/docs/skins/SkinPreview.tsx | 119 ++ .../docs/skins/SkinPreviewHtml.astro | 28 + .../components/docs/skins/SkinPreviewHtml.ts | 17 + site/src/components/docs/skins/skinGuides.ts | 135 ++ site/src/components/home/Demo/Demo.astro | 51 +- .../home/Demo/{Eject.tsx => Source.tsx} | 17 +- .../installation/ShadcnInstall.astro | 10 + .../installation/ShadcnInstallCommand.tsx | 63 + site/src/content.config.ts | 15 - site/src/content/docs/concepts/overview.mdx | 2 +- site/src/content/docs/concepts/presets.mdx | 8 +- site/src/content/docs/concepts/skins.mdx | 42 +- .../src/content/docs/concepts/why-videojs.mdx | 6 +- .../content/docs/getting-started/shadcn.mdx | 127 ++ .../add-a-poster-and-loading-placeholder.mdx | 6 +- .../docs/how-to/build-your-own-component.mdx | 4 +- .../content/docs/how-to/customize-skins.mdx | 116 +- site/src/content/docs/how-to/installation.mdx | 41 +- .../docs/how-to/migrate-from-media-chrome.mdx | 10 +- .../docs/how-to/migrate-from-mux-player.mdx | 10 +- .../content/docs/how-to/migrate-from-plyr.mdx | 24 +- .../docs/how-to/migrate-from-video-js-8.mdx | 14 +- .../content/docs/how-to/play-live-streams.mdx | 2 +- .../docs/how-to/use-videojs-with-svelte.mdx | 2 +- .../docs/how-to/use-videojs-with-vue.mdx | 2 +- .../content/docs/skins/background-video.mdx | 9 + site/src/content/docs/skins/default-audio.mdx | 9 + .../content/docs/skins/default-live-audio.mdx | 9 + .../content/docs/skins/default-live-video.mdx | 9 + site/src/content/docs/skins/default-video.mdx | 9 + site/src/content/docs/skins/minimal-audio.mdx | 9 + .../content/docs/skins/minimal-live-audio.mdx | 9 + .../content/docs/skins/minimal-live-video.mdx | 9 + site/src/content/docs/skins/minimal-video.mdx | 9 + site/src/docs.config.ts | 17 + .../installation/__tests__/shadcn.test.ts | 52 + .../src/utils/installation/shadcn-registry.ts | 149 +++ site/src/utils/installation/shadcn.ts | 58 + site/vite.config.ts | 12 +- vite.config.ts | 5 +- 60 files changed, 1520 insertions(+), 2151 deletions(-) delete mode 100644 site/scripts/build-ejected-skins.ts delete mode 100644 site/scripts/ejected-skins/config.ts delete mode 100644 site/scripts/ejected-skins/html.ts delete mode 100644 site/scripts/ejected-skins/index.ts delete mode 100644 site/scripts/ejected-skins/package-resolver.ts delete mode 100644 site/scripts/ejected-skins/react.ts delete mode 100644 site/scripts/tests/ejected-skins.test.ts delete mode 100644 site/src/components/docs/EjectedSkin.astro create mode 100644 site/src/components/docs/RegistryInstall.astro create mode 100644 site/src/components/docs/ShadcnCommand.astro create mode 100644 site/src/components/docs/ShadcnRegistryCatalog.astro create mode 100644 site/src/components/docs/SourceExplorer.astro create mode 100644 site/src/components/docs/SourceExplorerClient.tsx create mode 100644 site/src/components/docs/skins/SkinGuide.astro create mode 100644 site/src/components/docs/skins/SkinPreview.tsx create mode 100644 site/src/components/docs/skins/SkinPreviewHtml.astro create mode 100644 site/src/components/docs/skins/SkinPreviewHtml.ts create mode 100644 site/src/components/docs/skins/skinGuides.ts rename site/src/components/home/Demo/{Eject.tsx => Source.tsx} (75%) create mode 100644 site/src/components/installation/ShadcnInstall.astro create mode 100644 site/src/components/installation/ShadcnInstallCommand.tsx create mode 100644 site/src/content/docs/getting-started/shadcn.mdx create mode 100644 site/src/content/docs/skins/background-video.mdx create mode 100644 site/src/content/docs/skins/default-audio.mdx create mode 100644 site/src/content/docs/skins/default-live-audio.mdx create mode 100644 site/src/content/docs/skins/default-live-video.mdx create mode 100644 site/src/content/docs/skins/default-video.mdx create mode 100644 site/src/content/docs/skins/minimal-audio.mdx create mode 100644 site/src/content/docs/skins/minimal-live-audio.mdx create mode 100644 site/src/content/docs/skins/minimal-live-video.mdx create mode 100644 site/src/content/docs/skins/minimal-video.mdx create mode 100644 site/src/utils/installation/__tests__/shadcn.test.ts create mode 100644 site/src/utils/installation/shadcn-registry.ts create mode 100644 site/src/utils/installation/shadcn.ts diff --git a/.github/workflows/website-tests.yml b/.github/workflows/website-tests.yml index 1ad16c49b2..a1dd396f29 100644 --- a/.github/workflows/website-tests.yml +++ b/.github/workflows/website-tests.yml @@ -72,7 +72,7 @@ jobs: run: pnpm build:packages - name: Generate site content - run: pnpm exec vp run site#api-docs:generate && pnpm exec vp run site#ejected-skins && pnpm exec vp run site#cdn-manifest + run: pnpm exec vp run site#api-docs:generate && pnpm exec vp run site#cdn-manifest && pnpm exec vp run @videojs/skins#build:shadcn - name: Astro check run: cd site && pnpm astro check --minimumSeverity warning diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53dcf6d036..9c416ea762 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -858,6 +858,9 @@ importers: vitest: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.12.2)(@typescript/typescript6@6.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.11.1)(jsdom@27.4.0(supports-color@8.1.1)) + vjsc: + specifier: workspace:* + version: link:../packages/vjsc packages: diff --git a/site/package.json b/site/package.json index 5a452e5fa3..7be01e58f8 100644 --- a/site/package.json +++ b/site/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "api-docs": "vp run site#api-docs:generate", - "clean": "rimraf --glob dist .netlify 'src/content/generated-*-reference' src/content/cdn-media.json src/content/ejected-skins.json", + "clean": "rimraf --glob dist .netlify 'src/content/generated-*-reference' src/content/cdn-media.json", "astro": "astro", "check:anchors": "tsx scripts/check-anchors.ts", "test": "vp test run", @@ -79,6 +79,7 @@ "typescript": "npm:@typescript/typescript6@^6.0.0", "vite-plugin-svgr": "^4.5.0", "vite-plus": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "vjsc": "workspace:*" } } diff --git a/site/scripts/build-ejected-skins.ts b/site/scripts/build-ejected-skins.ts deleted file mode 100644 index b2a2a35f8a..0000000000 --- a/site/scripts/build-ejected-skins.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { main } from './ejected-skins/index.ts'; - -main().catch((error) => { - console.error('\x1b[35m[ejected-skins]\x1b[0m', '\x1b[31merror:\x1b[0m', error); - process.exit(1); -}); diff --git a/site/scripts/ejected-skins/config.ts b/site/scripts/ejected-skins/config.ts deleted file mode 100644 index 3919fb33c4..0000000000 --- a/site/scripts/ejected-skins/config.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { VJS10_HTML_CDN_BASE } from '../../src/consts'; - -export type MediaType = 'video' | 'audio'; -export type SkinVariant = 'default' | 'minimal'; -export type SkinStyle = 'css' | 'tailwind'; - -interface SkinMetadata { - id: string; - name: string; - platform: 'html' | 'react'; - style: SkinStyle; - mediaType: MediaType; - group: string; - variant: SkinVariant; - live: boolean; -} - -export interface HtmlSkinDef extends SkinMetadata { - platform: 'html'; - template: string; - css?: string; - iconSet: SkinVariant; -} - -export interface ReactSkinDef extends SkinMetadata { - platform: 'react'; - source: string; - css?: string; -} - -export type SkinDef = HtmlSkinDef | ReactSkinDef; - -export const HTML_CDN_BASE = VJS10_HTML_CDN_BASE; -export const DEMO_VIDEO_SRC = 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4'; -export const DEMO_POSTER_SRC = 'https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp'; -export const DEMO_LIVE_SRC = 'https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8'; -export const DEMO_LIVE_POSTER_SRC = - 'https://image.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM/thumbnail.webp'; - -export const LIVE_MEDIA = { - video: { subpath: 'hlsjs-video', tag: 'hlsjs-video', component: 'HlsJsVideo' }, - audio: { subpath: 'mux-audio', tag: 'mux-audio', component: 'MuxAudio' }, -} as const satisfies Record; - -export interface EjectedSkinEntry { - id: string; - name: string; - platform: 'html' | 'react'; - style: SkinStyle; - html?: string; - tsx?: Record; - jsx?: Record; - css?: string; -} - -const MEDIA_TYPES: MediaType[] = ['video', 'audio']; -const SKIN_VARIANTS: SkinVariant[] = ['default', 'minimal']; -const LIVE_MODES = [false, true]; - -function titleCase(value: string): string { - return `${value[0]!.toUpperCase()}${value.slice(1)}`; -} - -function getGroup(mediaType: MediaType, live: boolean): string { - return live ? `live-${mediaType}` : mediaType; -} - -function getId( - platform: SkinDef['platform'], - style: SkinStyle, - variant: SkinVariant, - mediaType: MediaType, - live: boolean -): string { - const base = `${variant}-${live ? 'live-' : ''}${mediaType}`; - - if (platform === 'react') return `${base}-react${style === 'tailwind' ? '-tailwind' : ''}`; - - return `${base}${style === 'tailwind' ? '-tailwind' : ''}`; -} - -function getName( - platform: SkinDef['platform'], - style: SkinStyle, - variant: SkinVariant, - mediaType: MediaType, - live: boolean -): string { - const base = `${titleCase(variant)} ${live ? 'Live ' : ''}${titleCase(mediaType)}`; - - if (platform === 'react') return `${base} (React${style === 'tailwind' ? ' + Tailwind' : ''})`; - - return style === 'tailwind' ? `${base} (Tailwind)` : base; -} - -function createHtmlSkin(style: SkinStyle, variant: SkinVariant, mediaType: MediaType, live: boolean): HtmlSkinDef { - const group = getGroup(mediaType, live); - const file = variant === 'minimal' ? 'minimal-skin' : 'skin'; - const template = - style === 'tailwind' - ? `site/scripts/ejected-skins/templates/html/${group}/${file}.tailwind.ts` - : `packages/html/src/presets/${group}/${file}.ts`; - - return { - id: getId('html', style, variant, mediaType, live), - name: getName('html', style, variant, mediaType, live), - platform: 'html', - style, - mediaType, - group, - variant, - live, - template, - ...(style === 'css' && { css: `packages/html/src/define/${group}/${file}.css` }), - iconSet: variant, - }; -} - -function createReactSkin(style: SkinStyle, variant: SkinVariant, mediaType: MediaType, live: boolean): ReactSkinDef { - const group = getGroup(mediaType, live); - const file = variant === 'minimal' ? 'minimal-skin' : 'skin'; - const styleSuffix = style === 'tailwind' ? '.tailwind' : ''; - - return { - id: getId('react', style, variant, mediaType, live), - name: getName('react', style, variant, mediaType, live), - platform: 'react', - style, - mediaType, - group, - variant, - live, - source: `packages/react/src/presets/${group}/${file}${styleSuffix}.tsx`, - ...(style === 'css' && { css: `packages/react/src/presets/${group}/${file}.css` }), - }; -} - -function createSkins(create: (variant: SkinVariant, mediaType: MediaType, live: boolean) => T): T[] { - return LIVE_MODES.flatMap((live) => - SKIN_VARIANTS.flatMap((variant) => MEDIA_TYPES.map((mediaType) => create(variant, mediaType, live))) - ); -} - -export const SKINS: SkinDef[] = [ - ...createSkins((variant, mediaType, live) => createHtmlSkin('css', variant, mediaType, live)), - ...createSkins((variant, mediaType, live) => createHtmlSkin('tailwind', variant, mediaType, live)), - ...createSkins((variant, mediaType, live) => createReactSkin('css', variant, mediaType, live)), - ...createSkins((variant, mediaType, live) => createReactSkin('tailwind', variant, mediaType, live)), -]; diff --git a/site/scripts/ejected-skins/html.ts b/site/scripts/ejected-skins/html.ts deleted file mode 100644 index 23ae3d19e0..0000000000 --- a/site/scripts/ejected-skins/html.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -import { - DEMO_LIVE_POSTER_SRC, - DEMO_LIVE_SRC, - DEMO_POSTER_SRC, - DEMO_VIDEO_SRC, - HTML_CDN_BASE, - type HtmlSkinDef, - LIVE_MEDIA, - type SkinDef, -} from './config.ts'; -import { pkgDistUrl, validatePackageImports } from './package-resolver.ts'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(scriptDir, '../../..'); - -function escapeHtml(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); -} - -export function extractTemplateLiteral(source: string): string { - const match = source.match( - /function\s+getTemplateHTML\s*\([^)]*\)\s*\{[\s\S]*?return\s+(?:\/\*html\*\/\s*)?`([\s\S]*?)`\s*;?\s*\}/ - ); - if (!match) throw new Error('Could not extract getTemplateHTML template literal'); - - return match[1]; -} - -export function parseImportedNames(source: string): Map { - const imports = new Map(); - const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; - let match: RegExpExecArray | null; - - while ((match = importRegex.exec(source)) !== null) { - const names = match[1] - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - - for (const name of names) { - const [importedName, localName = importedName] = name.split(/\s+as\s+/); - - imports.set(localName, match[2]); - } - } - - return imports; -} - -export function evaluateTemplate(templateBody: string, context: Record): string { - const fn = new Function(...Object.keys(context), `return \`${templateBody}\`;`); - const html = fn(...Object.values(context)) as string; - const lines = html.split('\n').map((line) => line.trimEnd()); - const minIndent = lines - .filter((line) => line.length > 0) - .reduce((minimum, line) => Math.min(minimum, line.length - line.trimStart().length), Infinity); - - return lines - .map((line) => (line.length > 0 ? line.slice(minIndent) : line)) - .join('\n') - .trim(); -} - -export function createRenderMediaIcon(iconSet: 'default' | 'minimal') { - return (name: string, attrs?: Record): string => { - const family = iconSet === 'minimal' ? ' family="minimal"' : ''; - const attrText = Object.entries(attrs ?? {}) - .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) - .join(''); - - return ``; - }; -} - -export function replaceSlots(html: string, skin: Pick): string { - const { live, mediaType } = skin; - const tag = live ? LIVE_MEDIA[mediaType].tag : mediaType === 'audio' ? 'audio' : 'video'; - const playsInline = mediaType === 'video' ? ' playsinline' : ''; - const mediaElement = `<${tag} src="${live ? DEMO_LIVE_SRC : DEMO_VIDEO_SRC}"${playsInline}>`; - - html = html.replace( - /^([ \t]*)', - '', - '', - ' ', - ' ', - ' ', - ].join('\n'); - - it('replaces the media slot and collapses the poster slot to its fallback image', () => { - const result = replaceSlots(slotSource, { mediaType: 'video', live: false }); - - expect(result).toContain(``); - expect(result).toContain(' '); - expect(result).not.toContain(''); - }); - - it('gives live skins a media element and a live source', () => { - const result = replaceSlots(slotSource, { mediaType: 'video', live: true }); - - expect(result).toContain(``); - expect(result).toContain(' '); - }); - - it('escapes generated media icons', () => { - expect(createRenderMediaIcon('minimal')('play&pause', { label: 'a"b' })).toBe( - '' - ); - }); - - it('wraps snippets with the matching player and CDN bundle', () => { - const skin = SKINS.find(({ id }) => id === 'minimal-audio'); - if (skin?.platform !== 'html') throw new Error('Missing HTML skin fixture'); - - expect(prependHtmlSkinScripts('', skin)).toContain( - '/audio-minimal.js">\n\n\n' - ); - }); - - it('loads the media bundle alongside the preset bundle for live skins', () => { - const skin = SKINS.find(({ id }) => id === 'minimal-live-video'); - if (skin?.platform !== 'html') throw new Error('Missing live HTML skin fixture'); - - const result = prependHtmlSkinScripts('', skin); - - expect(result).toContain('/live-video-minimal.js">'); - expect(result).toContain('/media/hlsjs-video.js">'); - expect(result).toContain(' { - const skin = SKINS.find(({ id }) => id === 'default-video'); - if (skin?.platform !== 'html') throw new Error('Missing HTML skin fixture'); - - expect(prependHtmlSkinScripts('', skin)).toContain( - `` - ); - }); - - it('gives a live video player the live poster', () => { - const skin = SKINS.find(({ id }) => id === 'minimal-live-video'); - if (skin?.platform !== 'html') throw new Error('Missing live HTML skin fixture'); - - expect(prependHtmlSkinScripts('', skin)).toContain( - `` - ); - }); - - it('does not link a generated stylesheet for Tailwind skins', () => { - const skin = SKINS.find(({ id }) => id === 'minimal-live-video-tailwind'); - if (skin?.platform !== 'html') throw new Error('Missing live Tailwind HTML skin fixture'); - - expect(prependHtmlSkinScripts('', skin)).not.toContain('player.css'); - }); -}); - -describe('ejected React skins', () => { - it('produces CSS and Tailwind players with matching dependencies', async () => { - const cssSkin = SKINS.find(({ id }) => id === 'default-live-video-react'); - const tailwindSkin = SKINS.find(({ id }) => id === 'default-live-video-react-tailwind'); - - if (cssSkin?.platform !== 'react' || tailwindSkin?.platform !== 'react') { - throw new Error('Missing live React skin fixtures'); - } - - const [cssEntry, tailwindEntry] = await Promise.all([buildEjectedSkin(cssSkin), buildEjectedSkin(tailwindSkin)]); - const cssSource = cssEntry.tsx?.['LiveVideoPlayer.tsx']; - const tailwindSource = tailwindEntry.tsx?.['LiveVideoPlayer.tsx']; - - expect(cssSource).toContain("import './player.css';"); - expect(tailwindSource).not.toContain("import './player.css';"); - expect(tailwindSource).toContain('export function LiveVideoPlayer'); - expect(tailwindSource).not.toContain('export function LiveVideoSkinTailwind'); - }); -}); - -describe('resolvePropsInterface', () => { - // Mirrors the real chain: the video alias sits between `BaseSkinProps` and - // the skin's own props type. - const source = [ - 'type BaseSkinProps = PropsWithChildren;', - '', - 'type BaseVideoSkinProps = BaseSkinProps & {', - ' /** Describes the skin component, not the player the ejected file exports. */', - ' renderPoster?: RenderProp | undefined;', - '};', - '', - 'export type VideoSkinProps = BaseVideoSkinProps;', - ].join('\n'); - - it('flattens the chain into one interface, leaving no alias behind', () => { - const result = resolvePropsInterface(source); - - expect(result).not.toContain('BaseSkinProps'); - expect(result).not.toContain('BaseVideoSkinProps'); - expect(result).toContain('export interface VideoSkinProps {'); - }); - - it('carries over what an alias added, without its JSDoc', () => { - const result = resolvePropsInterface(source); - - expect(result).toContain(' renderPoster?: RenderProp | undefined;'); - expect(result).not.toContain('Describes the skin component'); - }); -}); diff --git a/site/src/components/Code/Shared.tsx b/site/src/components/Code/Shared.tsx index b86b3bb413..55d33ec85b 100644 --- a/site/src/components/Code/Shared.tsx +++ b/site/src/components/Code/Shared.tsx @@ -11,20 +11,20 @@ export interface SharedProps { highlighter: Highlighter; } -interface Highlighted { +export interface Highlighted { html: string; preClassName: string | undefined; codeClassName: string | undefined; } // Build-time memo: identical (code, lang) pairs repeat across pages -// (e.g. the same ejected skin block on both /concepts/skins and -// /how-to/customize-skins). Shiki's codeToHast + hastToHtml is the +// (e.g. registry source reused by Skin and component reference pages). +// Shiki's codeToHast + hastToHtml is the // dominant cost per ServerCode; caching the rendered output reuses // it across every page in a single build. const highlightCache = new Map(); -function highlight(code: string, lang: BundledLanguage, highlighter: Highlighter): Highlighted { +export function highlightCode(code: string, lang: BundledLanguage, highlighter: Highlighter): Highlighted { const cacheKey = `${lang}\0${code}`; const cached = highlightCache.get(cacheKey); if (cached) return cached; @@ -40,18 +40,20 @@ function highlight(code: string, lang: BundledLanguage, highlighter: Highlighter // shiki gives us a root > pre > code > text structure // since we want to define pre and code ourselves, let's extract the text - let preProps: Record = {}; - let codeProps: Record = {}; + let preClassName: string | undefined; + let codeClassName: string | undefined; if (hast.type === 'root') { const pre = hast.children[0]; if (pre && pre.type === 'element' && pre.tagName === 'pre') { - preProps = pre.properties; + // SAFETY: Shiki emits the generated `class` property as a string on this known `pre` node. + preClassName = pre.properties.class as string | undefined; const codeNode = pre.children[0]; if (codeNode && codeNode.type === 'element' && codeNode.tagName === 'code') { - codeProps = codeNode.properties; + // SAFETY: Shiki emits the generated `class` property as a string on this known `code` node. + codeClassName = codeNode.properties.class as string | undefined; // everything looked as expected! Let's use the code's children as the new root hast.children = codeNode.children; } @@ -60,8 +62,8 @@ function highlight(code: string, lang: BundledLanguage, highlighter: Highlighter const result: Highlighted = { html: hastToHtml(hast), - preClassName: preProps.class as string | undefined, - codeClassName: codeProps.class as string | undefined, + preClassName, + codeClassName, }; highlightCache.set(cacheKey, result); @@ -69,7 +71,7 @@ function highlight(code: string, lang: BundledLanguage, highlighter: Highlighter } export default function Shared({ code, lang, highlighter }: SharedProps) { - const { html, preClassName, codeClassName } = highlight(code, lang, highlighter); + const { html, preClassName, codeClassName } = highlightCode(code, lang, highlighter); return (
diff --git a/site/src/components/Code/clientHighlighter.ts b/site/src/components/Code/clientHighlighter.ts
index 1a5a84af50..68390f5428 100644
--- a/site/src/components/Code/clientHighlighter.ts
+++ b/site/src/components/Code/clientHighlighter.ts
@@ -1,6 +1,7 @@
 import bash from 'shiki/langs/bash.mjs';
 import css from 'shiki/langs/css.mjs';
 import html from 'shiki/langs/html.mjs';
+import json from 'shiki/langs/json.mjs';
 import tsx from 'shiki/langs/tsx.mjs';
 import ts from 'shiki/langs/typescript.mjs';
 
@@ -17,7 +18,7 @@ import createHighlighter, { getOrCreateCachedHighlighter } from './createHighlig
 // it a head start before `client:idle` hydration kicks in.
 // ClientCode.tsx consumes this via React 19's `use()` hook + Suspense.
 const highlighterPromise = getOrCreateCachedHighlighter('client', () =>
-  createHighlighter({ langs: [bash, html, ts, tsx, css] })
+  createHighlighter({ langs: [bash, html, json, ts, tsx, css] })
 );
 
 export function getClientHighlighter() {
diff --git a/site/src/components/Code/serverHighlighter.ts b/site/src/components/Code/serverHighlighter.ts
index 437e8b1634..9a1e902047 100644
--- a/site/src/components/Code/serverHighlighter.ts
+++ b/site/src/components/Code/serverHighlighter.ts
@@ -2,6 +2,7 @@ import bash from 'shiki/langs/bash.mjs';
 import css from 'shiki/langs/css.mjs';
 import html from 'shiki/langs/html.mjs';
 import javascript from 'shiki/langs/javascript.mjs';
+import json from 'shiki/langs/json.mjs';
 import tsx from 'shiki/langs/tsx.mjs';
 import ts from 'shiki/langs/typescript.mjs';
 
@@ -14,7 +15,7 @@ import createHighlighter, { getOrCreateCachedHighlighter } from './createHighlig
 // build and dominate first-render cost on code-heavy pages.
 const serverHighlighter = await getOrCreateCachedHighlighter('server', () =>
   createHighlighter({
-    langs: [bash, css, html, javascript, ts, tsx],
+    langs: [bash, css, html, javascript, json, ts, tsx],
   })
 );
 
diff --git a/site/src/components/docs/EjectedSkin.astro b/site/src/components/docs/EjectedSkin.astro
deleted file mode 100644
index e615ddde95..0000000000
--- a/site/src/components/docs/EjectedSkin.astro
+++ /dev/null
@@ -1,90 +0,0 @@
----
-import { getEntry } from 'astro:content';
-import type { BundledLanguage } from 'shiki';
-import ServerCode from '@/components/Code/ServerCode.astro';
-import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx';
-
-interface Props {
-  id: string;
-}
-
-const { id } = Astro.props;
-const entry = await getEntry('ejectedSkins', id);
-if (!entry) return;
-
-const skin = entry.data;
-
-const EXT_TO_LANG: Record = {
-  ts: 'ts',
-  tsx: 'tsx',
-  js: 'js',
-  jsx: 'jsx',
-};
-
-function langFromFilename(filename: string): BundledLanguage {
-  const ext = filename.split('.').pop() ?? '';
-
-  return EXT_TO_LANG[ext] ?? 'tsx';
-}
-
-// Order React files so the component file is first (initial tab), then config files.
-const reactFiles = skin.tsx
-  ? Object.entries(skin.tsx).sort(([a], [b]) => {
-      const aIsComponent = a.endsWith('.tsx') || a.endsWith('.jsx');
-      const bIsComponent = b.endsWith('.tsx') || b.endsWith('.jsx');
-      if (aIsComponent === bIsComponent) return a.localeCompare(b);
-
-      return aIsComponent ? -1 : 1;
-    })
-  : [];
----
-
-{
-  skin.platform === "react" ? (
-    
-      
-        {reactFiles.map(([filename], index) => (
-          
-            {filename}
-          
-        ))}
-        {skin.css && (
-          
-            skin.css
-          
-        )}
-      
-      {reactFiles.map(([filename, code], index) => (
-        
-          
-        
-      ))}
-      {skin.css && (
-        
-          
-        
-      )}
-    
-  ) : (
-    
-      
-        
-          HTML
-        
-        {skin.css && (
-          
-            CSS
-          
-        )}
-      
-      
-        
-      
-      {skin.css && (
-        
-          
-        
-      )}
-    
-  )
-}
diff --git a/site/src/components/docs/RegistryInstall.astro b/site/src/components/docs/RegistryInstall.astro
new file mode 100644
index 0000000000..1868d2bf0a
--- /dev/null
+++ b/site/src/components/docs/RegistryInstall.astro
@@ -0,0 +1,68 @@
+---
+import ServerCode from '@/components/Code/ServerCode.astro';
+import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx';
+import {
+  shadcnAddCommand,
+  shadcnItemUrl,
+  type ShadcnFramework,
+  type ShadcnStyling,
+} from '@/utils/installation/shadcn';
+import { loadRegistryInstallation } from '@/utils/installation/shadcn-registry';
+import SourceExplorer from './SourceExplorer.astro';
+
+interface Props {
+  framework: ShadcnFramework;
+  item: string;
+}
+
+const { framework, item } = Astro.props;
+const styles = ['tailwind', 'css'] as const satisfies readonly ShadcnStyling[];
+const installations = await Promise.all(
+  styles.map(async (styling) => ({
+    model: await loadRegistryInstallation(framework, styling, item),
+    styling,
+  }))
+);
+const command = shadcnAddCommand(item)!;
+---
+
+
+  
+    Tailwind CLI
+    Tailwind manual
+    CSS CLI
+    CSS manual
+  
+  {
+    installations.map(({ model, styling }, styleIndex) => (
+      <>
+        
+          

Add the @videojs namespace for the {framework} {styling} catalog, then install the editable source.

+ + + {model.item.docs ?

{model.item.docs.split('```')[0]?.trim()}

: null} +
+ +

+ Copy the exact files below and install these packages: {model.dependencies.join(' ')}. +

+ {model.externalRegistryDependencies.length > 0 ? ( +

+ Also install the Shadcn dependencies {model.externalRegistryDependencies.join(' ')}. +

+ ) : null} + /\/skin\.(?:html|tsx)$/.test(path)) ?? model.entryPaths[0]} + /> +

+ View the registry item JSON +

+
+ + )) + } +
diff --git a/site/src/components/docs/ShadcnCommand.astro b/site/src/components/docs/ShadcnCommand.astro new file mode 100644 index 0000000000..cfeabbbdc9 --- /dev/null +++ b/site/src/components/docs/ShadcnCommand.astro @@ -0,0 +1,32 @@ +--- +import ServerCode from '@/components/Code/ServerCode.astro'; +import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx'; + +interface Props { + action: string; +} + +const { action } = Astro.props; +const commands = { + npm: `npx shadcn@latest ${action}`, + pnpm: `pnpm dlx shadcn@latest ${action}`, + yarn: `yarn dlx shadcn@latest ${action}`, + bun: `bunx shadcn@latest ${action}`, +}; +--- + + + + npm + pnpm + Yarn + Bun + + { + Object.entries(commands).map(([manager, command], index) => ( + + + + )) + } + diff --git a/site/src/components/docs/ShadcnRegistryCatalog.astro b/site/src/components/docs/ShadcnRegistryCatalog.astro new file mode 100644 index 0000000000..a3b63ebbc0 --- /dev/null +++ b/site/src/components/docs/ShadcnRegistryCatalog.astro @@ -0,0 +1,81 @@ +--- +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import { shadcnItemUrl, type ShadcnCatalogItem } from '@/utils/installation/shadcn'; +import { readRegistryCatalog } from '@/utils/installation/shadcn-registry'; + +interface ItemMeta { + public?: boolean; + role?: 'component' | 'skin'; +} + +const [reactTailwind, reactCss, htmlTailwind, htmlCss] = await Promise.all([ + readRegistryCatalog('react', 'tailwind'), + readRegistryCatalog('react', 'css'), + readRegistryCatalog('html', 'tailwind'), + readRegistryCatalog('html', 'css'), +]); +const isPublicRole = (item: ShadcnCatalogItem, role: ItemMeta['role']) => { + // SAFETY: the local registry catalog is generated with this owned metadata shape and schema-validated before site tasks run. + const meta = item.meta as ItemMeta | undefined; + + return meta?.public === true && meta.role === role; +}; +const skinRows = reactTailwind.items.filter((item) => isPublicRole(item, 'skin')); +const componentRows = reactTailwind.items.filter((item) => isPublicRole(item, 'component')); +const hasItem = (catalog: { readonly items: readonly ShadcnCatalogItem[] }, name: string) => + catalog.items.some((item) => item.name === name); +--- + +

Skin blocks

+ +

+ Skin blocks install editable UI source. Players and media remain direct @videojs/react or + @videojs/html imports. +

+ + + + + + + { + skinRows.map((item) => ( + + + + + + + + )) + } + +
SkinReact + TailwindReact + CSSHTML + TailwindHTML + CSS
{item.title}{item.name}{hasItem(reactCss, item.name) ? {item.name} : null}{hasItem(htmlTailwind, item.name) ? {item.name} : null}{hasItem(htmlCss, item.name) ? {item.name} : null}
+ +

React UI components

+ +

+ Individual React UI components install to stable paths under components/videojs/ui. + Tailwind and CSS catalogs expose the same item names. +

+ + + + + { + componentRows.map((item) => ( + + + + + + )) + } + +
ComponentTailwindCSS
{item.title}{item.name}{hasItem(reactCss, item.name) ? {item.name} : null}
diff --git a/site/src/components/docs/SourceExplorer.astro b/site/src/components/docs/SourceExplorer.astro new file mode 100644 index 0000000000..d152c9375c --- /dev/null +++ b/site/src/components/docs/SourceExplorer.astro @@ -0,0 +1,15 @@ +--- +import type { RegistrySourceFile } from '@/utils/installation/shadcn-registry'; +import SourceExplorerClient from './SourceExplorerClient'; + +interface Props { + files: readonly RegistrySourceFile[]; + initialPath?: string; +} + +const { files, initialPath } = Astro.props; +--- + + +
+
diff --git a/site/src/components/docs/SourceExplorerClient.tsx b/site/src/components/docs/SourceExplorerClient.tsx new file mode 100644 index 0000000000..a8a44ac5e8 --- /dev/null +++ b/site/src/components/docs/SourceExplorerClient.tsx @@ -0,0 +1,167 @@ +import clsx from 'clsx'; +import { Check, Copy, FileCode2, Folder } from 'lucide-react'; +import { Suspense, use, useId, useMemo, useState } from 'react'; + +import { getClientHighlighter } from '@/components/Code/clientHighlighter'; +import { highlightCode } from '@/components/Code/Shared'; +import { shared } from '@/components/typography/styles'; +import type { RegistrySourceFile } from '@/utils/installation/shadcn-registry'; + +interface SourceExplorerProps { + readonly files: readonly RegistrySourceFile[]; + readonly initialPath?: string | undefined; +} + +interface Directory { + readonly files: readonly RegistrySourceFile[]; + readonly path: string; +} + +export default function SourceExplorer(props: SourceExplorerProps) { + return ( + }> + + + ); +} + +function SourceExplorerInner({ files, initialPath }: SourceExplorerProps) { + const highlighter = use(getClientHighlighter()); + const selectId = useId(); + const initialFile = files.find((file) => file.path === initialPath) ?? files[0]; + const [selectedPath, setSelectedPath] = useState(initialFile?.path ?? ''); + const [copied, setCopied] = useState(false); + const selected = files.find((file) => file.path === selectedPath) ?? initialFile; + const directories = useMemo(() => groupByDirectory(files), [files]); + + if (!selected) return null; + + const copy = async () => { + await navigator.clipboard.writeText(selected.code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }; + + const highlighted = highlightCode(selected.code, selected.lang, highlighter); + + return ( +
+
+ + +
+ +
+ + +
+
+ + + {selected.path} + + +
+
+            
+          
+ + {copied ? `${selected.path} copied` : ''} + +
+
+
+ ); +} + +function SourceExplorerFallback() { + return ( +
+ ); +} + +function groupByDirectory(files: readonly RegistrySourceFile[]): readonly Directory[] { + const directories = new Map(); + + for (const file of files) { + const directory = dirname(file.path); + const entries = directories.get(directory) ?? []; + + entries.push(file); + directories.set(directory, entries); + } + + return [...directories].map(([path, entries]) => ({ files: entries, path })); +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf('/') + 1); +} + +function dirname(path: string): string { + const index = path.lastIndexOf('/'); + + return index === -1 ? '.' : path.slice(0, index); +} diff --git a/site/src/components/docs/skins/SkinGuide.astro b/site/src/components/docs/skins/SkinGuide.astro new file mode 100644 index 0000000000..c8898bc164 --- /dev/null +++ b/site/src/components/docs/skins/SkinGuide.astro @@ -0,0 +1,141 @@ +--- +import ServerCode from '@/components/Code/ServerCode.astro'; +import Aside from '@/components/Aside.astro'; +import DocsLink from '@/components/docs/DocsLink.astro'; +import MinimalFrame from '@/components/frames/Minimal.astro'; +import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx'; +import SkinPreview from './SkinPreview'; +import SkinPreviewHtml from './SkinPreviewHtml.astro'; +import type { SkinGuideDefinition } from './skinGuides'; + +interface Props { + definition: SkinGuideDefinition; +} + +const { definition } = Astro.props; +const framework = Astro.params.framework; +const live = definition.preset.startsWith('live-'); +const packageName = `@videojs/${framework}/${definition.preset}`; +const stylesheet = `${packageName}/${definition.htmlRegistration}.css`; +const reactMedia = live ? (definition.media === 'video' ? 'HlsVideo' : 'HlsAudio') : definition.media === 'video' ? 'Video' : 'Audio'; +const reactMediaPackage = live ? `@videojs/react/media/hls-${definition.media}` : packageName; +const htmlMedia = live ? `hls-${definition.media}` : definition.media; +const htmlMediaImport = live ? `import '@videojs/html/media/hls-${definition.media}';\n` : ''; +const source = live ? 'https://example.com/live.m3u8' : `${definition.media}.${definition.media === 'video' ? 'mp4' : 'mp3'}`; +const reactUsage = `import { ${definition.reactPlayer}, ${definition.reactSkin} } from '${packageName}'; +import { ${reactMedia} } from '${reactMediaPackage}'; +import '${stylesheet}'; + +export function Player() { + return ( + <${definition.reactPlayer}> + <${definition.reactSkin}> + <${reactMedia} src="${source}"${live ? ' crossOrigin="anonymous"' : ''} /> + + + ); +}`; +const htmlUsage = ` + +<${definition.preset}-player> + <${definition.htmlSkin}> + <${htmlMedia} src="${source}"${live ? ' crossorigin="anonymous"' : ''}> + +`; +const backgroundReact = `import { + BackgroundVideo, + BackgroundVideoPlayer, + BackgroundVideoSkin, +} from '@videojs/react/background'; +import '@videojs/react/background/skin.css'; + + + + + +`; +const backgroundHtml = ` + + + + + +`; +const usage = definition.preset === 'background' + ? framework === 'react' + ? backgroundReact + : backgroundHtml + : framework === 'react' + ? reactUsage + : htmlUsage; +--- + +

{definition.when}

+ + + { + framework === "react" ? ( + + ) : ( + + ) + } + + +

Use the packaged preset

+ + + + {framework === "react" ? "App.tsx" : "index.html"} + + + + + + +

Customize the skin

+ +

+ Packaged skins support --media-accent-color, --media-accent-text-color, --media-border-radius, and --media-scale-unit. Add your own class name and style values to the skin root. +

+ +{definition.media === "video" && definition.preset !== "background" ? ( + framework === "react" ? ( +

Use the skin's renderPoster prop to replace its poster.

+ ) : ( +

Place an image in the poster slot to replace the poster.

+ ) +) : null} + +

+ See Customize skins for the complete styling and ownership workflow. +

+ +{ + definition.preset === "background" ? ( + + ) : null +} + +

Behavior and API

+ +
    +
  • + The packaged export is {definition.reactSkin} in React and <{definition.htmlSkin}> in HTML. +
  • +
  • {live ? "The live preset replaces on-demand time controls with live-edge state." : "The on-demand preset includes duration and seeking behavior."}
  • +
  • {definition.variant === "minimal" ? "Minimal keeps the same feature family in a smaller responsive layout." : "Default exposes the full primary and secondary control regions."}
  • +
+ +

+ For player state and feature details, see Presets. For the UI building blocks, see UI components. +

diff --git a/site/src/components/docs/skins/SkinPreview.tsx b/site/src/components/docs/skins/SkinPreview.tsx new file mode 100644 index 0000000000..abdf532fe5 --- /dev/null +++ b/site/src/components/docs/skins/SkinPreview.tsx @@ -0,0 +1,119 @@ +import { Audio, AudioPlayer, AudioSkin, MinimalAudioSkin } from '@videojs/react/audio'; +import { BackgroundVideo, BackgroundVideoPlayer, BackgroundVideoSkin } from '@videojs/react/background'; + +import '@videojs/react/background/skin.css'; +import { LiveAudioPlayer, LiveAudioSkin, MinimalLiveAudioSkin } from '@videojs/react/live-audio'; +import { LiveVideoPlayer, LiveVideoSkin, MinimalLiveVideoSkin } from '@videojs/react/live-video'; +import { HlsAudio } from '@videojs/react/media/hls-audio'; + +import '@videojs/react/audio/minimal-skin.css'; +import '@videojs/react/audio/skin.css'; +import { HlsVideo } from '@videojs/react/media/hls-video'; + +import '@videojs/react/live-audio/minimal-skin.css'; +import '@videojs/react/live-audio/skin.css'; +import { MinimalVideoSkin, Video, VideoPlayer, VideoSkin } from '@videojs/react/video'; + +import '@videojs/react/live-video/minimal-skin.css'; +import '@videojs/react/live-video/skin.css'; +import { VJS10_DEMO_LIVE, VJS10_DEMO_VIDEO } from '@/consts'; + +import '@videojs/react/video/minimal-skin.css'; +import '@videojs/react/video/skin.css'; + +export type SkinPreviewName = + | 'audio' + | 'background' + | 'live-audio' + | 'live-video' + | 'minimal-audio' + | 'minimal-live-audio' + | 'minimal-live-video' + | 'minimal-video' + | 'video'; + +export interface SkinPreviewProps { + skin: SkinPreviewName; +} + +type BackgroundSkinStyle = React.CSSProperties & Record<'--media-object-fit', string>; + +const backgroundSkinStyle: BackgroundSkinStyle = { '--media-object-fit': 'cover' }; + +export default function SkinPreview({ skin }: SkinPreviewProps) { + switch (skin) { + case 'video': + return ( + + + + + ); + case 'minimal-video': + return ( + + + + + ); + case 'audio': + return ( + + + + + ); + case 'minimal-audio': + return ( + + + + + ); + case 'live-video': + return ( + + + + + + ); + case 'minimal-live-video': + return ( + + + + + + ); + case 'live-audio': + return ( + + + + + + ); + case 'minimal-live-audio': + return ( + + + + + + ); + case 'background': + return ( + + + + + + + ); + } +} diff --git a/site/src/components/docs/skins/SkinPreviewHtml.astro b/site/src/components/docs/skins/SkinPreviewHtml.astro new file mode 100644 index 0000000000..008730d4d2 --- /dev/null +++ b/site/src/components/docs/skins/SkinPreviewHtml.astro @@ -0,0 +1,28 @@ +--- +import { VJS10_DEMO_LIVE, VJS10_DEMO_VIDEO } from '@/consts'; +import type { SkinPreviewName } from './SkinPreview'; + +interface Props { + skin: SkinPreviewName; +} + +const { skin } = Astro.props; +const minimal = skin.startsWith('minimal-'); +const preset = minimal ? skin.slice('minimal-'.length) : skin; +const live = preset.startsWith('live-'); +const video = preset.endsWith('video'); +const background = preset === 'background'; +const playerTag = background ? 'background-video-player' : `${preset}-player`; +const skinTag = background ? 'background-video-skin' : `${preset}-${minimal ? 'minimal-' : ''}skin`; +const mediaTag = background ? 'background-video' : live ? `hls-${video ? 'video' : 'audio'}` : video ? 'video' : 'audio'; +const source = live ? VJS10_DEMO_LIVE.hls : VJS10_DEMO_VIDEO.mp4; +const poster = video || background ? `` : ''; +const skinStyle = video || background ? 'display:block;aspect-ratio:16/9;--media-object-fit:cover' : 'display:block'; +const markup = `<${playerTag}><${skinTag} style="${skinStyle}">${poster}<${mediaTag} src="${source}" ${video ? 'playsinline ' : ''}${live ? 'crossorigin="anonymous"' : ''}>`; +--- + +
+ + diff --git a/site/src/components/docs/skins/SkinPreviewHtml.ts b/site/src/components/docs/skins/SkinPreviewHtml.ts new file mode 100644 index 0000000000..4777038b3d --- /dev/null +++ b/site/src/components/docs/skins/SkinPreviewHtml.ts @@ -0,0 +1,17 @@ +import '@videojs/html/audio/minimal-skin'; +import '@videojs/html/audio/player'; +import '@videojs/html/audio/skin'; +import '@videojs/html/background/player'; +import '@videojs/html/background/skin'; +import '@videojs/html/background/video'; +import '@videojs/html/live-audio/minimal-skin'; +import '@videojs/html/live-audio/player'; +import '@videojs/html/live-audio/skin'; +import '@videojs/html/live-video/minimal-skin'; +import '@videojs/html/live-video/player'; +import '@videojs/html/live-video/skin'; +import '@videojs/html/media/hls-audio'; +import '@videojs/html/media/hls-video'; +import '@videojs/html/video/minimal-skin'; +import '@videojs/html/video/player'; +import '@videojs/html/video/skin'; diff --git a/site/src/components/docs/skins/skinGuides.ts b/site/src/components/docs/skins/skinGuides.ts new file mode 100644 index 0000000000..60e4268953 --- /dev/null +++ b/site/src/components/docs/skins/skinGuides.ts @@ -0,0 +1,135 @@ +import type { SkinPreviewName } from './SkinPreview'; + +export interface SkinGuideDefinition { + readonly name: SkinPreviewName; + readonly title: string; + readonly description: string; + readonly when: string; + readonly preset: 'audio' | 'background' | 'live-audio' | 'live-video' | 'video'; + readonly variant: 'default' | 'minimal'; + readonly reactSkin: string; + readonly reactPlayer: string; + readonly htmlSkin: string; + readonly htmlRegistration: string; + readonly media: 'audio' | 'video'; +} + +export const skinGuides = { + video: { + name: 'video', + title: 'Default video skin', + description: 'Responsive on-demand video controls with settings, feedback, gestures, and keyboard input.', + when: 'Use it for a full-featured on-demand video player.', + preset: 'video', + variant: 'default', + reactSkin: 'VideoSkin', + reactPlayer: 'VideoPlayer', + htmlSkin: 'video-skin', + htmlRegistration: 'skin', + media: 'video', + }, + 'minimal-video': { + name: 'minimal-video', + title: 'Minimal video skin', + description: 'Compact on-demand video controls that wrap cleanly while retaining the complete video feature set.', + when: 'Use it when the full video feature set should take less visual space.', + preset: 'video', + variant: 'minimal', + reactSkin: 'MinimalVideoSkin', + reactPlayer: 'VideoPlayer', + htmlSkin: 'video-minimal-skin', + htmlRegistration: 'minimal-skin', + media: 'video', + }, + audio: { + name: 'audio', + title: 'Default audio skin', + description: 'On-demand audio controls for playback, seeking, volume, speed, errors, and keyboard feedback.', + when: 'Use it for an audio player with a familiar full control set.', + preset: 'audio', + variant: 'default', + reactSkin: 'AudioSkin', + reactPlayer: 'AudioPlayer', + htmlSkin: 'audio-skin', + htmlRegistration: 'skin', + media: 'audio', + }, + 'minimal-audio': { + name: 'minimal-audio', + title: 'Minimal audio skin', + description: 'Compact on-demand audio controls with responsive time, volume, speed, and feedback.', + when: 'Use it for audio playback in a narrow or secondary surface.', + preset: 'audio', + variant: 'minimal', + reactSkin: 'MinimalAudioSkin', + reactPlayer: 'AudioPlayer', + htmlSkin: 'audio-minimal-skin', + htmlRegistration: 'minimal-skin', + media: 'audio', + }, + 'live-video': { + name: 'live-video', + title: 'Default live video skin', + description: 'Live video controls with live-edge state, captions, remote playback, feedback, and input controls.', + when: 'Use it for a live stream that needs the full video control set.', + preset: 'live-video', + variant: 'default', + reactSkin: 'LiveVideoSkin', + reactPlayer: 'LiveVideoPlayer', + htmlSkin: 'live-video-skin', + htmlRegistration: 'skin', + media: 'video', + }, + 'minimal-live-video': { + name: 'minimal-live-video', + title: 'Minimal live video skin', + description: 'Compact live video controls with live-edge, captions, feedback, gestures, and keyboard input.', + when: 'Use it for a live stream with a smaller control footprint.', + preset: 'live-video', + variant: 'minimal', + reactSkin: 'MinimalLiveVideoSkin', + reactPlayer: 'LiveVideoPlayer', + htmlSkin: 'live-video-minimal-skin', + htmlRegistration: 'minimal-skin', + media: 'video', + }, + 'live-audio': { + name: 'live-audio', + title: 'Default live audio skin', + description: 'Live audio controls for playback, live-edge, volume, errors, and keyboard feedback.', + when: 'Use it for a live audio stream with explicit live-edge state.', + preset: 'live-audio', + variant: 'default', + reactSkin: 'LiveAudioSkin', + reactPlayer: 'LiveAudioPlayer', + htmlSkin: 'live-audio-skin', + htmlRegistration: 'skin', + media: 'audio', + }, + 'minimal-live-audio': { + name: 'minimal-live-audio', + title: 'Minimal live audio skin', + description: 'Compact live audio controls for playback, live-edge, volume, errors, and keyboard feedback.', + when: 'Use it for a compact live audio player.', + preset: 'live-audio', + variant: 'minimal', + reactSkin: 'MinimalLiveAudioSkin', + reactPlayer: 'LiveAudioPlayer', + htmlSkin: 'live-audio-minimal-skin', + htmlRegistration: 'minimal-skin', + media: 'audio', + }, + background: { + name: 'background', + title: 'Background video preset', + description: 'A control-free video layout for decorative background playback.', + when: 'Use it for muted, looping decoration behind page content.', + preset: 'background', + variant: 'default', + reactSkin: 'BackgroundVideoSkin', + reactPlayer: 'BackgroundVideoPlayer', + htmlSkin: 'background-video-skin', + htmlRegistration: 'skin', + media: 'video', + }, +} as const satisfies Readonly>; diff --git a/site/src/components/home/Demo/Demo.astro b/site/src/components/home/Demo/Demo.astro index b7e87c2960..41b5186f25 100644 --- a/site/src/components/home/Demo/Demo.astro +++ b/site/src/components/home/Demo/Demo.astro @@ -1,15 +1,24 @@ --- -import { getEntry } from 'astro:content'; import ServerCode from '@/components/Code/ServerCode.astro'; +import { loadRegistryInstallation } from '@/utils/installation/shadcn-registry'; import FrameworkControl from '../FrameworkControl'; import BaseDemo from './Base'; import { generateHTMLCode, generateReactCode } from './baseCode'; -import EjectDemo from './Eject'; +import SourceDemo from './Source'; -const defaultVideo = (await getEntry('ejectedSkins', 'default-video'))!.data; -const defaultVideoReact = (await getEntry('ejectedSkins', 'default-video-react'))!.data; -const minimalVideo = (await getEntry('ejectedSkins', 'minimal-video'))!.data; -const minimalVideoReact = (await getEntry('ejectedSkins', 'minimal-video-react'))!.data; +const [defaultHtml, defaultReact, minimalHtml, minimalReact] = await Promise.all([ + loadRegistryInstallation('html', 'css', 'video'), + loadRegistryInstallation('react', 'css', 'video'), + loadRegistryInstallation('html', 'css', 'video-minimal'), + loadRegistryInstallation('react', 'css', 'video-minimal'), +]); + +function source(model: Awaited>, suffix: string): string { + const file = model.files.find((candidate) => candidate.path.endsWith(suffix)); + if (!file) throw new Error(`Registry item ${model.item.name} does not include ${suffix}.`); + + return file.code; +} ---
{/* @ts-expect-error — astro#9451 */} - + - + - - + - - +
diff --git a/site/src/components/home/Demo/Eject.tsx b/site/src/components/home/Demo/Source.tsx similarity index 75% rename from site/src/components/home/Demo/Eject.tsx rename to site/src/components/home/Demo/Source.tsx index 77df458a2d..c5c3c17286 100644 --- a/site/src/components/home/Demo/Eject.tsx +++ b/site/src/components/home/Demo/Source.tsx @@ -3,21 +3,19 @@ import { useStore } from '@nanostores/react'; import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs'; import { framework, skin } from '@/stores/homePageDemos'; -interface EjectDemoProps { +interface SourceDemoProps { className?: string; defaultHtmlCode: React.ReactNode; defaultHtmlCss: React.ReactNode; defaultReactCode: React.ReactNode; - defaultReactPlayer: React.ReactNode; defaultReactCss: React.ReactNode; minimalHtmlCode: React.ReactNode; minimalHtmlCss: React.ReactNode; minimalReactCode: React.ReactNode; - minimalReactPlayer: React.ReactNode; minimalReactCss: React.ReactNode; } -export default function EjectDemo(props: EjectDemoProps) { +export default function SourceDemo(props: SourceDemoProps) { const $framework = useStore(framework); const $skin = useStore(skin); @@ -31,7 +29,6 @@ export default function EjectDemo(props: EjectDemoProps) { : isDefault ? props.defaultReactCode : props.minimalReactCode; - const playerSlot = isHtml ? null : isDefault ? props.defaultReactPlayer : props.minimalReactPlayer; const cssSlot = isHtml ? isDefault ? props.defaultHtmlCss @@ -48,11 +45,6 @@ export default function EjectDemo(props: EjectDemoProps) { {codeLabel} - {playerSlot && ( - - JS - - )} CSS @@ -60,11 +52,6 @@ export default function EjectDemo(props: EjectDemoProps) { {codeSlot} - {playerSlot && ( - - {playerSlot} - - )} {cssSlot} diff --git a/site/src/components/installation/ShadcnInstall.astro b/site/src/components/installation/ShadcnInstall.astro new file mode 100644 index 0000000000..56937ec674 --- /dev/null +++ b/site/src/components/installation/ShadcnInstall.astro @@ -0,0 +1,10 @@ +--- +import { isValidFramework } from '@/types/docs'; +import ShadcnInstallCommand from './ShadcnInstallCommand'; + +const framework = Astro.params.framework; +if (!isValidFramework(framework)) throw new Error(`Unsupported framework: ${framework ?? 'missing'}.`); + +--- + + diff --git a/site/src/components/installation/ShadcnInstallCommand.tsx b/site/src/components/installation/ShadcnInstallCommand.tsx new file mode 100644 index 0000000000..45df4cba56 --- /dev/null +++ b/site/src/components/installation/ShadcnInstallCommand.tsx @@ -0,0 +1,63 @@ +import { useStore } from '@nanostores/react'; + +import ClientCode from '@/components/Code/ClientCode'; +import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs'; +import { skin, useCase } from '@/stores/installation'; +import { + resolveShadcnInstallation, + shadcnAddCommand, + shadcnRegistryUrl, + type ShadcnFramework, +} from '@/utils/installation/shadcn'; + +interface Props { + framework: ShadcnFramework; +} + +export default function ShadcnInstallCommand({ framework }: Props) { + const $skin = useStore(skin); + const $useCase = useStore(useCase); + const selected = resolveShadcnInstallation({ useCase: $useCase, skin: $skin }); + + if (selected.packageOnly) { + return ( +

+ This selection stays package-managed. Choose Default or Minimal to install editable skin source from the + registry. +

+ ); + } + + const command = shadcnAddCommand(selected.item)!; + + return ( + <> +

+ Configure the @videojs namespace once, then install the selected skin. The Player and media remain + package imports. +

+ + + + Tailwind + + Vanilla CSS + + + + + + + + + + + + ); +} diff --git a/site/src/content.config.ts b/site/src/content.config.ts index 5852d3f269..d3ffd26fd2 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -175,20 +175,6 @@ const presetReference = defineCollection({ schema: PresetReferenceSchema, }); -const ejectedSkins = defineCollection({ - loader: file('./src/content/ejected-skins.json'), - schema: z.object({ - id: z.string(), - name: z.string(), - platform: z.enum(['html', 'react']), - style: z.enum(['css', 'tailwind']), - html: z.string().optional(), - tsx: z.record(z.string(), z.string()).optional(), - jsx: z.record(z.string(), z.string()).optional(), - css: z.string().optional(), - }), -}); - // Media subpaths that ship a CDN build, generated by scripts/build-cdn-manifest.ts. // Each entry's id is a media subpath (e.g. `hlsjs-video`). Used by the installation // guide to hide the CDN install option for renderers with no CDN bundle. @@ -209,6 +195,5 @@ export const collections = { featureReference, mediaReference, presetReference, - ejectedSkins, cdnMedia, }; diff --git a/site/src/content/docs/concepts/overview.mdx b/site/src/content/docs/concepts/overview.mdx index 7e191ffa02..1962e55976 100644 --- a/site/src/content/docs/concepts/overview.mdx +++ b/site/src/content/docs/concepts/overview.mdx @@ -108,7 +108,7 @@ If you want more control than a packaged skin offers, build your own UI from our ``` -The easiest way to get started is to eject the closest skin and use its pre-styled components as a foundation. +The easiest way to get started is to install the closest skin's editable source and use its pre-styled components as a foundation. Learn more about UI components Build your own component diff --git a/site/src/content/docs/concepts/presets.mdx b/site/src/content/docs/concepts/presets.mdx index bd3cbf94f4..938314ee98 100644 --- a/site/src/content/docs/concepts/presets.mdx +++ b/site/src/content/docs/concepts/presets.mdx @@ -54,7 +54,7 @@ The default presets are `/video` and `/audio`. These cover the baseline controls ## What's in a preset -Each React preset exports a named, preconfigured player component and typed `usePlayer` hook, plus the building blocks needed to customize or eject from it: +Each React preset exports a named, preconfigured player component and typed `usePlayer` hook, plus the building blocks needed to customize it or own its source: - **Player** — A player component named for the preset, such as `VideoPlayer`, already configured with the preset's feature bundle. - **Typed hook** — A `usePlayer` hook whose state and actions match the preset. @@ -95,7 +95,7 @@ A complete player imports `video/player` and one skin. Each skin registration in ## Customizing a preset -You can customize a preset in three ways: extend its feature bundle, eject its skin, or swap its media element. +You can customize a preset in three ways: extend its feature bundle, install its skin source, or swap its media element. ### Extend the feature bundle @@ -147,9 +147,9 @@ function Hero() { ``` -### Eject the skin +### Own the skin source -If a preset's skin is close but not quite right, you can eject it — copy its internal components into your project and customize from there. +If a preset's skin is close but not quite right, install its editable source from the Video.js Shadcn registry and customize it in your project. ### Swap the media element diff --git a/site/src/content/docs/concepts/skins.mdx b/site/src/content/docs/concepts/skins.mdx index 4408aa9466..823b90632f 100644 --- a/site/src/content/docs/concepts/skins.mdx +++ b/site/src/content/docs/concepts/skins.mdx @@ -8,7 +8,6 @@ import FrameworkCase from '@/components/docs/FrameworkCase.astro'; import DocsLink from '@/components/docs/DocsLink.astro'; import DocsLinkCard from '@/components/docs/DocsLinkCard.astro'; import Aside from '@/components/Aside.astro'; -import EjectedSkin from '@/components/docs/EjectedSkin.astro';