diff --git a/.gitignore b/.gitignore index 61f0b6e2c..bde7ac782 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ __tests__/coverage/ site/src/content/generated-api-reference/ site/src/content/generated-component-reference/ site/src/content/generated-util-reference/ +site/src/content/generated-feature-reference/ +site/src/content/generated-preset-reference/ site/src/content/ejected-skins.json # ------------------------- diff --git a/packages/html/src/presets/audio.ts b/packages/html/src/presets/audio.ts index 12498901a..0255d9950 100644 --- a/packages/html/src/presets/audio.ts +++ b/packages/html/src/presets/audio.ts @@ -1,3 +1,4 @@ +/** Audio-only player preset with playback and volume controls. */ export { audioFeatures } from '@videojs/core/dom'; export { MinimalAudioSkinElement } from '../define/audio/minimal-skin'; export { MinimalAudioSkinTailwindElement } from '../define/audio/minimal-skin.tailwind'; diff --git a/packages/html/src/presets/background.ts b/packages/html/src/presets/background.ts index 928fd911b..9a6ad5a92 100644 --- a/packages/html/src/presets/background.ts +++ b/packages/html/src/presets/background.ts @@ -1 +1,2 @@ +/** Ambient background video preset with no user controls. */ export { backgroundFeatures } from '@videojs/core/dom'; diff --git a/packages/html/src/presets/video.ts b/packages/html/src/presets/video.ts index befde0462..143c74fd0 100644 --- a/packages/html/src/presets/video.ts +++ b/packages/html/src/presets/video.ts @@ -1,3 +1,4 @@ +/** General-purpose video player preset with full playback controls. */ export { videoFeatures } from '@videojs/core/dom'; export { MinimalVideoSkinElement } from '../define/video/minimal-skin'; export { MinimalVideoSkinTailwindElement } from '../define/video/minimal-skin.tailwind'; diff --git a/packages/react/src/presets/audio/index.ts b/packages/react/src/presets/audio/index.ts index 49f2a3f88..e96c79c49 100644 --- a/packages/react/src/presets/audio/index.ts +++ b/packages/react/src/presets/audio/index.ts @@ -1,3 +1,4 @@ +/** Audio-only player preset with playback and volume controls. */ export { audioFeatures } from '@videojs/core/dom'; export { Audio, type AudioProps } from '@/media/audio'; export * from './minimal-skin'; diff --git a/packages/react/src/presets/background/index.ts b/packages/react/src/presets/background/index.ts index 6a85b9633..75d7dee60 100644 --- a/packages/react/src/presets/background/index.ts +++ b/packages/react/src/presets/background/index.ts @@ -1,3 +1,4 @@ +/** Ambient background video preset with no user controls. */ export { backgroundFeatures } from '@videojs/core/dom'; export { BackgroundVideo, type BackgroundVideoProps } from '@/media/background-video'; export * from './skin'; diff --git a/packages/react/src/presets/video/index.ts b/packages/react/src/presets/video/index.ts index 3e2c1ca59..1158b11a5 100644 --- a/packages/react/src/presets/video/index.ts +++ b/packages/react/src/presets/video/index.ts @@ -1,3 +1,4 @@ +/** General-purpose video player preset with full playback controls. */ export { videoFeatures } from '@videojs/core/dom'; export { Video, type VideoProps } from '@/media/video'; export * from './minimal-skin'; diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index 757bc0858..d289ae231 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { generateComponentReferences } from './pipeline.js'; -import { ComponentReferenceSchema } from './types.js'; +import { generateComponentReferences, generateFeatureReferences, generatePresetReferences } from './pipeline.js'; +import { ComponentReferenceSchema, FeatureReferenceSchema, PresetReferenceSchema } from './types.js'; import { generateUtilReferences } from './util-handler.js'; // Magenta prefix - visible on both light and dark terminals @@ -18,6 +18,8 @@ const log = { const MONOREPO_ROOT = path.resolve(import.meta.dirname, '../../../../'); const COMPONENT_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-component-reference'); const UTIL_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-util-reference'); +const FEATURE_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-feature-reference'); +const PRESET_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-preset-reference'); /** * Main entry point. @@ -33,9 +35,11 @@ function main() { originalWarn.apply(console, args); }; - // Ensure output directory exists - if (!fs.existsSync(COMPONENT_OUTPUT_PATH)) { - fs.mkdirSync(COMPONENT_OUTPUT_PATH, { recursive: true }); + // Ensure output directories exist + for (const dir of [COMPONENT_OUTPUT_PATH, UTIL_OUTPUT_PATH, FEATURE_OUTPUT_PATH, PRESET_OUTPUT_PATH]) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } } // Generate component references via pipeline @@ -80,6 +84,66 @@ function main() { log.info(`Done! Generated ${utilResult.success} util files.`); + // Generate feature references + const featureResults = generateFeatureReferences(MONOREPO_ROOT); + + if (featureResults.length === 0) { + log.info('No features found.'); + } else { + log.info(`Found ${featureResults.length} features. Processing...`); + } + + for (const result of featureResults) { + const validated = FeatureReferenceSchema.safeParse(result.reference); + if (!validated.success) { + log.error(`Schema validation failed for feature ${result.name}:`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + errorCount++; + continue; + } + + const outputFile = path.join(FEATURE_OUTPUT_PATH, `${result.slug}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`✅ Generated ${path.basename(outputFile)}`); + successCount++; + } + + log.info(`Done! Generated ${featureResults.length} feature files.`); + + // Generate preset references + const presetResults = generatePresetReferences(MONOREPO_ROOT); + + if (presetResults.length === 0) { + log.info('No presets found.'); + } else { + log.info(`Found ${presetResults.length} presets. Processing...`); + } + + for (const result of presetResults) { + const validated = PresetReferenceSchema.safeParse(result.reference); + if (!validated.success) { + log.error(`Schema validation failed for preset ${result.name}:`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + errorCount++; + continue; + } + + const outputFile = path.join(PRESET_OUTPUT_PATH, `${result.name}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`✅ Generated ${path.basename(outputFile)}`); + successCount++; + } + + log.info(`Done! Generated ${presetResults.length} preset files.`); + console.warn = originalWarn; if (errorCount > 0) { diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts index 459cbeea6..962fc737b 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -553,6 +553,7 @@ export interface PresetSkinDef { export interface PresetReference { name: string; + description?: string; featureBundle: string; features: string[]; html: { diff --git a/site/scripts/api-docs-builder/src/preset-handler.ts b/site/scripts/api-docs-builder/src/preset-handler.ts index f6644c3e1..7e43b9b30 100644 --- a/site/scripts/api-docs-builder/src/preset-handler.ts +++ b/site/scripts/api-docs-builder/src/preset-handler.ts @@ -45,13 +45,65 @@ function parseNamedExports(filePath: string): ExportInfo[] { if (element.isTypeOnly) continue; exports.push({ name: element.name.text, sourceSpecifier }); } + } else if (!node.exportClause) { + // `export * from './skin'` — resolve the source and extract value exports + const dir = path.dirname(filePath); + const resolved = resolveModulePath(dir, sourceSpecifier); + if (resolved) { + const starExports = extractValueExports(resolved); + for (const name of starExports) { + exports.push({ name, sourceSpecifier }); + } + } } - // Note: `export * from` (namespace re-exports) are skipped — we only handle named exports }); return exports; } +function resolveModulePath(dir: string, specifier: string): string | undefined { + for (const ext of ['.ts', '.tsx']) { + const candidate = path.join(dir, `${specifier}${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +function extractValueExports(filePath: string): string[] { + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const names: string[] = []; + + ts.forEachChild(sourceFile, (node) => { + // export function Foo() {} + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + names.push(node.name.text); + } + // export const Foo = ... + if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) { + names.push(decl.name.text); + } + } + } + // export class Foo {} + if ( + ts.isClassDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + names.push(node.name.text); + } + }); + + return names; +} + // ─── Export Classification ──────────────────────────────────────── function isFeatureBundle(name: string): boolean { @@ -156,6 +208,26 @@ function discoverPresetNames(htmlPresetsDir: string, reactPresetsDir: string): s return [...names].sort(); } +// ─── Description Extraction ────────────────────────────────────── + +function extractFileDescription(filePath: string): string | undefined { + if (!fs.existsSync(filePath)) return undefined; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + // File-level JSDoc attaches to the first statement + const firstStatement = sourceFile.statements[0]; + if (!firstStatement) return undefined; + + const jsDocNodes = (firstStatement as { jsDoc?: ts.JSDoc[] }).jsDoc; + if (!jsDocNodes || jsDocNodes.length === 0) return undefined; + + const doc = jsDocNodes[0]!; + if (typeof doc.comment === 'string') return doc.comment; + return undefined; +} + // ─── Preset Reference Building ──────────────────────────────────── function buildPresetReference( @@ -207,6 +279,9 @@ function buildPresetReference( } } + // Extract description from file-level JSDoc (try React first, fall back to HTML) + const description = extractFileDescription(reactPresetFile) ?? extractFileDescription(htmlPresetFile); + const ref: PresetReference = { name: presetName, featureBundle: bundleExport.name, @@ -215,6 +290,8 @@ function buildPresetReference( react: { skins: reactSkins, mediaElement: reactMediaElement ?? '' }, }; + if (description) ref.description = description; + return { name: presetName, reference: ref }; } diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index 42b63a18b..aa35c8dcd 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -873,6 +873,11 @@ describe('Preset pipeline (end-to-end)', () => { // ───────────────────────────────────────────────────────────────── describe('video preset', () => { + it('extracts description from file-level JSDoc', () => { + const ref = findPreset('video')!.reference; + expect(ref.description).toContain('Mock React video preset'); + }); + it('identifies the feature bundle', () => { const ref = findPreset('video')!.reference; expect(ref.featureBundle).toBe('videoFeatures'); diff --git a/site/scripts/api-docs-builder/src/types.ts b/site/scripts/api-docs-builder/src/types.ts index c734399d4..41c51a1b6 100644 --- a/site/scripts/api-docs-builder/src/types.ts +++ b/site/scripts/api-docs-builder/src/types.ts @@ -13,6 +13,12 @@ export type { export { ComponentReferenceSchema, PartReferenceSchema } from '../../../src/types/component-reference.js'; +export type { FeatureActionDef, FeatureReference, FeatureStateDef } from '../../../src/types/feature-reference.js'; +export { FeatureReferenceSchema } from '../../../src/types/feature-reference.js'; + +export type { PresetReference, PresetSkinDef } from '../../../src/types/preset-reference.js'; +export { PresetReferenceSchema } from '../../../src/types/preset-reference.js'; + /** * Discovered part within a multi-part component. */ diff --git a/site/src/components/docs/api-reference/ApiActionsTable.astro b/site/src/components/docs/api-reference/ApiActionsTable.astro new file mode 100644 index 000000000..e087d792a --- /dev/null +++ b/site/src/components/docs/api-reference/ApiActionsTable.astro @@ -0,0 +1,41 @@ +--- +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { FeatureActionDef } from '@/types/feature-reference'; +import StateRow from './StateRow.astro'; + +interface Props { + actions: Record; + featureName: string; +} + +const { actions, featureName } = Astro.props; + +const actionEntries = Object.entries(actions); +--- + + + + + + + + + + + { + actionEntries.map(([name, def]) => ( + + )) + } + +
ActionTypeDetails
diff --git a/site/src/components/docs/api-reference/FeatureReference.astro b/site/src/components/docs/api-reference/FeatureReference.astro new file mode 100644 index 000000000..e10c032b0 --- /dev/null +++ b/site/src/components/docs/api-reference/FeatureReference.astro @@ -0,0 +1,48 @@ +--- +import { getEntry } from 'astro:content'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; +import H2 from '@/components/typography/H2Markdown.astro'; +import H3 from '@/components/typography/H3Markdown.astro'; +import type { FeatureReference } from '@/types/feature-reference'; +import { createFeatureReferenceModel } from '@/utils/featureReferenceModel'; +import ApiActionsTable from './ApiActionsTable.astro'; +import ApiStateTable from './ApiStateTable.astro'; + +interface Props { + feature: string; +} + +const { feature } = Astro.props; + +const entry = await getEntry('featureReference', feature); +const ref: FeatureReference | null = entry?.data ?? null; +if (!ref) return; + +const model = createFeatureReferenceModel(feature, ref); +if (!model) return; + +const stateSection = model.sections.find((s) => s.key === 'state'); +const actionsSection = model.sections.find((s) => s.key === 'actions'); +--- + + +

{model.heading.text}

+ + { + stateSection && ( + <> +

{stateSection.title}

+ + + ) + } + + { + actionsSection && ( + <> +

{actionsSection.title}

+ + + ) + } +
diff --git a/site/src/components/docs/api-reference/PresetReference.astro b/site/src/components/docs/api-reference/PresetReference.astro new file mode 100644 index 000000000..6be1cf57d --- /dev/null +++ b/site/src/components/docs/api-reference/PresetReference.astro @@ -0,0 +1,175 @@ +--- +import { getCollection } from 'astro:content'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; +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 { isValidFramework } from '@/types/docs'; +import type { PresetReference as PresetReferenceType } from '@/types/preset-reference'; +import DocsLink from '../DocsLink.astro'; +import FrameworkCase from '../FrameworkCase.astro'; +import InlineMarkdown from './InlineMarkdown.astro'; + +const entries = await getCollection('presetReference'); +const presets: PresetReferenceType[] = entries.map((e) => e.data).sort((a, b) => a.name.localeCompare(b.name)); + +const { framework } = Astro.params; +const pkg = framework && isValidFramework(framework) ? `@videojs/${framework}` : '@videojs/html'; + +/** + * Feature slug overrides for cases where kebabCase(featureName) doesn't + * match the docs page slug. + */ +const FEATURE_SLUG_OVERRIDES: Record = { + textTrack: 'text-tracks', +}; + +function featureDocsSlug(featureName: string): string { + const override = FEATURE_SLUG_OVERRIDES[featureName]; + if (override) return `reference/feature-${override}`; + const kebab = featureName.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `reference/feature-${kebab}`; +} +--- + + + + + + + + + + + + { + presets.map((preset) => { + const id = `preset-${preset.name}`; + return ( + <> + + + + + + + + + + ); + }) + } + +
ImportDescriptionDetails
+ {`${pkg}/${preset.name}`} + + {preset.description && ( + + )} + + +
+
+ + diff --git a/site/src/content.config.ts b/site/src/content.config.ts index aecd5c638..1728baf35 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -3,6 +3,8 @@ import { file, glob } from 'astro/loaders'; import { z } from 'astro/zod'; import { ComponentReferenceSchema } from './types/component-reference'; import { SUPPORTED_FRAMEWORKS } from './types/docs'; +import { FeatureReferenceSchema } from './types/feature-reference'; +import { PresetReferenceSchema } from './types/preset-reference'; import { UtilReferenceSchema } from './types/util-reference'; import { defaultGitService } from './utils/gitService'; import { globWithParser } from './utils/globWithParser'; @@ -127,6 +129,22 @@ const utilReference = defineCollection({ schema: UtilReferenceSchema, }); +const featureReference = defineCollection({ + loader: glob({ + pattern: '*.json', + base: './src/content/generated-feature-reference', + }), + schema: FeatureReferenceSchema, +}); + +const presetReference = defineCollection({ + loader: glob({ + pattern: '*.json', + base: './src/content/generated-preset-reference', + }), + schema: PresetReferenceSchema, +}); + const ejectedSkins = defineCollection({ loader: file('./src/content/ejected-skins.json'), schema: z.object({ @@ -141,4 +159,13 @@ const ejectedSkins = defineCollection({ }), }); -export const collections = { blog, docs, authors, componentReference, utilReference, ejectedSkins }; +export const collections = { + blog, + docs, + authors, + componentReference, + utilReference, + featureReference, + presetReference, + ejectedSkins, +}; diff --git a/site/src/content/docs/concepts/overview.mdx b/site/src/content/docs/concepts/overview.mdx index e00d893fc..e9d4caba8 100644 --- a/site/src/content/docs/concepts/overview.mdx +++ b/site/src/content/docs/concepts/overview.mdx @@ -144,7 +144,13 @@ DASH, YouTube, Vimeo, Mux, and more media elements are currently under developme **Presets** preconfigure these parts for a specific use case. -The default presets are `/video` and `/audio`, covering the baseline set of controls you'd expect from the HTML `