Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

# -------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/html/src/presets/audio.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/html/src/presets/background.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/** Ambient background video preset with no user controls. */
export { backgroundFeatures } from '@videojs/core/dom';
1 change: 1 addition & 0 deletions packages/html/src/presets/video.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/presets/audio/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/presets/background/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions packages/react/src/presets/video/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
74 changes: 69 additions & 5 deletions site/scripts/api-docs-builder/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log messages overcount generated feature and preset files

Low Severity

The "Done!" log messages for features and presets currently report the total items found, using featureResults.length and presetResults.length. This differs from component and util logging, which track successful generations. As a result, these messages can overstate the number of files actually written if validation errors occur.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 220870b. Configure here.


// 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) {
Expand Down
1 change: 1 addition & 0 deletions site/scripts/api-docs-builder/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ export interface PresetSkinDef {

export interface PresetReference {
name: string;
description?: string;
featureBundle: string;
features: string[];
html: {
Expand Down
79 changes: 78 additions & 1 deletion site/scripts/api-docs-builder/src/preset-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -215,6 +290,8 @@ function buildPresetReference(
react: { skins: reactSkins, mediaElement: reactMediaElement ?? '' },
};

if (description) ref.description = description;

return { name: presetName, reference: ref };
}

Expand Down
5 changes: 5 additions & 0 deletions site/scripts/api-docs-builder/src/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions site/scripts/api-docs-builder/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
41 changes: 41 additions & 0 deletions site/src/components/docs/api-reference/ApiActionsTable.astro
Original file line number Diff line number Diff line change
@@ -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<string, FeatureActionDef>;
featureName: string;
}

const { actions, featureName } = Astro.props;

const actionEntries = Object.entries(actions);
---

<Table maxWidth={false} outerClass="my-6">
<Thead>
<Tr>
<Th>Action</Th>
<Th>Type</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{
actionEntries.map(([name, def]) => (
<StateRow
name={name}
type={def.type}
detailedType={def.detailedType}
description={def.description}
componentName={featureName}
/>
))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actions table rows get wrong IDs from StateRow

Medium Severity

The ApiActionsTable component incorrectly reuses StateRow, causing action entries to generate DOM IDs like ${componentName}-state-${name} instead of ${componentName}-action-${name}. This leads to incorrect anchor links, misleading ARIA semantics, and a potential for duplicate IDs if a feature has a state and an action with the same name.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 220870b. Configure here.

}
</Tbody>
</Table>
48 changes: 48 additions & 0 deletions site/src/components/docs/api-reference/FeatureReference.astro
Original file line number Diff line number Diff line change
@@ -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');
---

<ContentWidth>
<H2 id={model.heading.id}>{model.heading.text}</H2>

{
stateSection && (
<>
<H3 id={stateSection.id}>{stateSection.title}</H3>
<ApiStateTable state={model.data.state} componentName={feature} />
</>
)
}

{
actionsSection && (
<>
<H3 id={actionsSection.id}>{actionsSection.title}</H3>
<ApiActionsTable actions={model.data.actions} featureName={feature} />
</>
)
}
</ContentWidth>
Loading
Loading