diff --git a/.github/workflows/website-tests.yml b/.github/workflows/website-tests.yml index 1ad16c49b2..7c7d5d0f14 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 - name: Astro check run: cd site && pnpm astro check --minimumSeverity warning diff --git a/site/package.json b/site/package.json index b0775d55b1..0cab596d42 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", 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 bf21e2bb88..0000000000 --- a/site/scripts/ejected-skins/config.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { VJS10_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_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/internal/skins/${variant}-${group}/template.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) => createReactSkin('css', variant, mediaType, live)), -]; diff --git a/site/scripts/ejected-skins/html.ts b/site/scripts/ejected-skins/html.ts deleted file mode 100644 index ccbf636ae7..0000000000 --- a/site/scripts/ejected-skins/html.ts +++ /dev/null @@ -1,158 +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+(?:\/\*\s*html\s*\*\/\s*)?`([\s\S]*?)`\s*;?\s*\}/ - ) ?? source.match(/createTemplate\s*\(\s*(?:\/\*\s*html\s*\*\/\s*)?`([\s\S]*?)`\s*\)/); - if (!match) throw new Error('Could not extract HTML 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( - `` - ); - }); -}); - -describe('ejected React skins', () => { - it('produces CSS skin sources with matching dependencies', async () => { - const cssSkin = SKINS.find(({ id }) => id === 'default-live-video-react'); - if (cssSkin?.platform !== 'react') throw new Error('Missing live React skin fixture'); - - const cssEntry = await buildEjectedSkin(cssSkin); - const cssSource = cssEntry.tsx?.['LiveVideoPlayer.tsx']; - - expect(cssSource).toContain("import './player.css';"); - expect(cssSource).toContain('export function LiveVideoSkin'); - }); -}); - -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..26000aa1d5 100644 --- a/site/src/components/Code/Shared.tsx +++ b/site/src/components/Code/Shared.tsx @@ -18,8 +18,8 @@ interface Highlighted { } // 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. the same install command on several skin references). 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(); diff --git a/site/src/components/docs/DocsLink.astro b/site/src/components/docs/DocsLink.astro index 00abfa21c6..8e414f7f52 100644 --- a/site/src/components/docs/DocsLink.astro +++ b/site/src/components/docs/DocsLink.astro @@ -6,8 +6,10 @@ import A from '../typography/A.astro'; interface Props extends Omit, 'href'> { slug: string; + /** Heading ID within the target guide. */ + anchor?: string; } -const { slug, class: className } = Astro.props; +const { slug, anchor, class: className } = Astro.props; const { framework: paramFramework } = Astro.params; @@ -15,10 +17,11 @@ if (!paramFramework || !isValidFramework(paramFramework)) { throw new Error(`Invalid or missing framework param "${paramFramework ?? 'undefined'}".`); } -const { url: href } = resolveDocsLinkUrl({ +const { url } = resolveDocsLinkUrl({ targetSlug: slug, contextFramework: paramFramework, }); +const href = anchor ? `${url}#${anchor}` : url; --- diff --git a/site/src/components/docs/DocsLinkCard.astro b/site/src/components/docs/DocsLinkCard.astro index 5d00b1edb1..f696ed705b 100644 --- a/site/src/components/docs/DocsLinkCard.astro +++ b/site/src/components/docs/DocsLinkCard.astro @@ -6,9 +6,11 @@ import { resolveDocsLinkUrl } from '@/utils/docs/routing'; interface Props extends Omit, 'href'> { slug: string; + /** Heading ID within the target guide. */ + anchor?: string; description?: string; } -const { slug, description } = Astro.props; +const { slug, anchor, description } = Astro.props; const { framework: paramFramework } = Astro.params; @@ -16,10 +18,11 @@ if (!paramFramework || !isValidFramework(paramFramework)) { throw new Error(`Invalid or missing framework param "${paramFramework ?? 'undefined'}".`); } -const { url: href } = resolveDocsLinkUrl({ +const { url } = resolveDocsLinkUrl({ targetSlug: slug, contextFramework: paramFramework, }); +const href = anchor ? `${url}#${anchor}` : url; ---
= { - 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/SidebarItem.astro b/site/src/components/docs/SidebarItem.astro index 9c50a3c926..eb49ab7035 100644 --- a/site/src/components/docs/SidebarItem.astro +++ b/site/src/components/docs/SidebarItem.astro @@ -70,10 +70,11 @@ function getGroupRotate(depth: number) { "flex items-center gap-4 py-2 text-faded-black dark:text-manila-light", "cursor-pointer", depth === 0 - ? "font-display text-h4 leading-tight uppercase" + ? "font-display text-h4 uppercase font-bold" : "text-p3", - depth > 0 && "border-l pl-4", - isActive ? "border-orange" : "border-manila-75 dark:border-warm-gray", + depth > 0 && "pl-4", + depth > 0 && isActive && + "relative before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-orange", getGroupMargin(depth), ]} style={ @@ -82,12 +83,17 @@ function getGroupRotate(depth: number) { > {item.sidebarLabel}