From 220870b2e51878384d36c0538fce7e7da00fbb2c Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Tue, 7 Apr 2026 10:27:14 -0500 Subject: [PATCH 1/4] feat(site): feature and preset reference UI components + docs integration Wire feature and preset pipelines into the api-docs-builder entry point, create Zod schemas and content collections, build FeatureReference and PresetReference Astro components, update the remark plugin for TOC injection, and replace all hand-written tables in 11 feature pages + presets.mdx + skins.mdx with generated references. - Fix preset handler to resolve `export *` re-exports (React skins) - FeatureReference renders ## API Reference > ### State > ### Actions - PresetReference renders framework-aware tables with feature cross-links - Feature pages: drop , change ## Selector to ### Selector Closes #1246 Closes #1247 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + site/scripts/api-docs-builder/src/index.ts | 74 +++++++++- .../api-docs-builder/src/preset-handler.ts | 54 +++++++- site/scripts/api-docs-builder/src/types.ts | 6 + .../docs/api-reference/ApiActionsTable.astro | 41 ++++++ .../docs/api-reference/FeatureReference.astro | 48 +++++++ .../docs/api-reference/PresetReference.astro | 129 ++++++++++++++++++ site/src/content.config.ts | 29 +++- site/src/content/docs/concepts/presets.mdx | 22 +-- site/src/content/docs/concepts/skins.mdx | 23 +--- .../content/docs/reference/feature-buffer.mdx | 13 +- .../docs/reference/feature-controls.mdx | 13 +- .../content/docs/reference/feature-error.mdx | 18 +-- .../docs/reference/feature-fullscreen.mdx | 20 +-- .../content/docs/reference/feature-pip.mdx | 20 +-- .../docs/reference/feature-playback-rate.mdx | 19 +-- .../docs/reference/feature-playback.mdx | 22 +-- .../content/docs/reference/feature-source.mdx | 19 +-- .../docs/reference/feature-text-tracks.mdx | 22 +-- .../content/docs/reference/feature-time.mdx | 20 +-- .../content/docs/reference/feature-volume.mdx | 21 +-- site/src/types/feature-reference.ts | 24 ++++ site/src/types/preset-reference.ts | 25 ++++ site/src/utils/featureReferenceModel.js | 59 ++++++++ site/src/utils/remarkConditionalHeadings.js | 30 ++++ 25 files changed, 555 insertions(+), 218 deletions(-) create mode 100644 site/src/components/docs/api-reference/ApiActionsTable.astro create mode 100644 site/src/components/docs/api-reference/FeatureReference.astro create mode 100644 site/src/components/docs/api-reference/PresetReference.astro create mode 100644 site/src/types/feature-reference.ts create mode 100644 site/src/types/preset-reference.ts create mode 100644 site/src/utils/featureReferenceModel.js 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/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/preset-handler.ts b/site/scripts/api-docs-builder/src/preset-handler.ts index f6644c3e1..c38d95783 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 { 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..1d88d61e3 --- /dev/null +++ b/site/src/components/docs/api-reference/PresetReference.astro @@ -0,0 +1,129 @@ +--- +import { getEntry } 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 type { PresetReference } from '@/types/preset-reference'; +import DocsLink from '../DocsLink.astro'; +import FrameworkCase from '../FrameworkCase.astro'; + +interface Props { + preset: string; +} + +const { preset } = Astro.props; + +const entry = await getEntry('presetReference', preset); +const ref: PresetReference | null = entry?.data ?? null; +if (!ref) return; + +/** + * Feature slug overrides for cases where kebabCase(featureName) doesn't + * match the docs page slug. The feature pipeline uses the name from + * definePlayerFeature(), which may differ from the docs filename. + */ +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}`; + // Simple camelCase → kebab: insert hyphen before uppercase letters + const kebab = featureName.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `reference/feature-${kebab}`; +} +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValue
Feature bundle{ref.featureBundle}
Features + { + ref.features.map((f, i) => ( + <> + {i > 0 && ", "} + {f} + + )) + } +
Skins + { + ref.react.skins.map((skin, i) => ( + <> + {i > 0 && ", "} + {`<${skin.name}>`} + + )) + } +
Default media element{`<${ref.react.mediaElement}>`}
+
+ + + + + + + + + + + + + + + + + + + +
PropertyValue
Features + { + ref.features.map((f, i) => ( + <> + {i > 0 && ", "} + {f} + + )) + } +
Skins + { + ref.html.skins.map((skin, i) => ( + <> + {i > 0 && ", "} + {`<${skin.tagName}>`} + + )) + } +
+
+
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/presets.mdx b/site/src/content/docs/concepts/presets.mdx index db631d6ca..2cdd4d750 100644 --- a/site/src/content/docs/concepts/presets.mdx +++ b/site/src/content/docs/concepts/presets.mdx @@ -3,6 +3,7 @@ title: Presets description: Pre-packaged player configurations that bundle state management, skins, and media elements for specific use cases. --- +import PresetReference from '@/components/docs/api-reference/PresetReference.astro'; import FrameworkCase from '@/components/docs/FrameworkCase.astro'; import Aside from '@/components/Aside.astro'; import DocsLink from '@/components/docs/DocsLink.astro'; @@ -49,24 +50,9 @@ function Hero() { The default presets are `/video` and `/audio`. These cover the baseline controls you'd expect from the HTML `