From 2a8cbd0df4e888aed40698485d835e1443da5271 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Thu, 2 Jul 2026 11:04:31 -0700 Subject: [PATCH] fix(site): skip docs links for features without reference pages (#1394) Co-authored-by: Claude --- site/scripts/api-docs-builder/src/index.ts | 16 ++++++++ site/scripts/api-docs-builder/src/pipeline.ts | 8 +++- .../api-docs-builder/src/preset-handler.ts | 38 ++++++++++++++++-- .../api-docs-builder/src/tests/e2e.test.ts | 28 +++++++++++-- .../docs/reference/feature-playback.mdx | 4 ++ .../docs/api-reference/PresetReference.astro | 39 ++++++++----------- site/src/types/preset-reference.ts | 9 ++++- site/turbo.json | 7 +++- 8 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/site/src/content/docs/reference/feature-playback.mdx diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index c0a60ade..16d55683 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -193,6 +193,22 @@ function main() { log.info(`Done! Generated ${presetSuccessCount} preset files.`); + // Report features referenced by presets that lack a reference page. + // These render as plain text in the preset table instead of links. + const unlinked = new Map(); + for (const { reference } of presetResults) { + for (const feature of reference.features) { + if (!feature.hasReference) unlinked.set(feature.name, feature.slug); + } + } + + if (unlinked.size > 0) { + log.warn(`${unlinked.size} preset feature(s) have no reference page:`); + for (const [name, slug] of unlinked) { + log.warn(` - ${name} → site/src/content/docs/${slug}.mdx (missing)`); + } + } + 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 4554059e..2628211e 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -552,11 +552,17 @@ export interface PresetSkinDef { cssImport?: string; } +export interface PresetFeatureRef { + name: string; + slug: string; + hasReference: boolean; +} + export interface PresetReference { name: string; description?: string; featureBundle: string; - features: string[]; + features: PresetFeatureRef[]; html: { skins: PresetSkinDef[]; mediaElement?: string; diff --git a/site/scripts/api-docs-builder/src/preset-handler.ts b/site/scripts/api-docs-builder/src/preset-handler.ts index 60798f91..7ee42890 100644 --- a/site/scripts/api-docs-builder/src/preset-handler.ts +++ b/site/scripts/api-docs-builder/src/preset-handler.ts @@ -24,7 +24,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as ts from 'typescript'; -import type { PresetReference, PresetResult, PresetSkinDef } from './pipeline.js'; +import type { PresetFeatureRef, PresetReference, PresetResult, PresetSkinDef } from './pipeline.js'; // ─── Types ────────────────────────────────────────────────────────── @@ -307,6 +307,31 @@ function findReactMediaElement(filePath: string): string | undefined { // ─── Feature Bundle Resolution ────────────────────────────────────── +/** + * Feature names whose kebab-cased form doesn't match the docs page slug. + * Example: `textTrack` → `feature-text-tracks.mdx`. + */ +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}`; +} + +function featureReferenceExists(monorepoRoot: string, slug: string): boolean { + const mdxPath = path.join(monorepoRoot, 'site/src/content/docs', `${slug}.mdx`); + return fs.existsSync(mdxPath); +} + +function resolveFeatureRef(name: string, monorepoRoot: string): PresetFeatureRef { + const slug = featureDocsSlug(name); + return { name, slug, hasReference: featureReferenceExists(monorepoRoot, slug) }; +} + function parseFeatureBundles(presetsFilePath: string): Map { const map = new Map(); if (!fs.existsSync(presetsFilePath)) return map; @@ -416,7 +441,11 @@ function scanReactDirectory(scanDir: string, barrelPath: string, presetName: str // ─── Preset Reference Building ────────────────────────────────────── -function buildPresetReference(preset: PresetInfo, featureBundleMap: Map): PresetResult | null { +function buildPresetReference( + preset: PresetInfo, + featureBundleMap: Map, + monorepoRoot: string +): PresetResult | null { // Find feature bundle name from barrel files (try both frameworks) const bundleName = (preset.html && findFeatureBundleExport(preset.html.barrelPath)) ?? @@ -424,7 +453,8 @@ function buildPresetReference(preset: PresetInfo, featureBundleMap: Map resolveFeatureRef(name, monorepoRoot)); // Scan HTML directory const htmlResult = preset.html ? scanHtmlDirectory(preset.html.scanDir) : { skins: [] as PresetSkinDef[] }; @@ -465,7 +495,7 @@ export function generatePresetReferences(monorepoRoot: string): PresetResult[] { const results: PresetResult[] = []; for (const preset of presets) { - const result = buildPresetReference(preset, featureBundleMap); + const result = buildPresetReference(preset, featureBundleMap, monorepoRoot); if (result) results.push(result); } 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 23f17242..d6b322ed 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -917,10 +917,30 @@ describe('Preset pipeline (end-to-end)', () => { it('resolves feature names from the bundle', () => { const ref = findPreset('video')!.reference; - expect(ref.features).toEqual(expect.arrayContaining(['playback', 'volume'])); + expect(ref.features.map((f) => f.name)).toEqual(expect.arrayContaining(['playback', 'volume'])); expect(ref.features.length).toBe(2); }); + it('emits docs slugs for features', () => { + const ref = findPreset('video')!.reference; + const playback = ref.features.find((f) => f.name === 'playback'); + const volume = ref.features.find((f) => f.name === 'volume'); + expect(playback?.slug).toBe('reference/feature-playback'); + expect(volume?.slug).toBe('reference/feature-volume'); + }); + + it('flags hasReference true when the feature MDX page exists', () => { + const ref = findPreset('video')!.reference; + const playback = ref.features.find((f) => f.name === 'playback'); + expect(playback?.hasReference).toBe(true); + }); + + it('flags hasReference false when the feature MDX page is missing', () => { + const ref = findPreset('video')!.reference; + const volume = ref.features.find((f) => f.name === 'volume'); + expect(volume?.hasReference).toBe(false); + }); + it('detects HTML skins with tagNames', () => { const skins = findPreset('video')!.reference.html.skins; expect(skins).toEqual( @@ -974,7 +994,7 @@ describe('Preset pipeline (end-to-end)', () => { it('resolves feature names (subset of video)', () => { const ref = findPreset('audio')!.reference; - expect(ref.features).toEqual(['playback']); + expect(ref.features.map((f) => f.name)).toEqual(['playback']); }); it('detects single HTML skin', () => { @@ -1049,8 +1069,8 @@ describe('Preset pipeline (end-to-end)', () => { const featureSlugs = featureResults.map((r) => r.slug); const videoPreset = findPreset('video')!.reference; - for (const featureName of videoPreset.features) { - expect(featureSlugs).toContain(featureName); + for (const feature of videoPreset.features) { + expect(featureSlugs).toContain(feature.name); } }); }); diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/site/src/content/docs/reference/feature-playback.mdx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/site/src/content/docs/reference/feature-playback.mdx new file mode 100644 index 00000000..1a1fb04d --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/site/src/content/docs/reference/feature-playback.mdx @@ -0,0 +1,4 @@ +--- +title: Playback feature +description: Fixture stub used by the preset pipeline e2e test to exercise hasReference=true. +--- diff --git a/site/src/components/docs/api-reference/PresetReference.astro b/site/src/components/docs/api-reference/PresetReference.astro index 9214c34e..d96a5a41 100644 --- a/site/src/components/docs/api-reference/PresetReference.astro +++ b/site/src/components/docs/api-reference/PresetReference.astro @@ -30,14 +30,6 @@ const presets: PresetReferenceType[] = entries 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', -}; - /** * Native media elements used by presets that don't have a custom element. * The pipeline can't detect these since they're not classes with `static tagName`. @@ -47,13 +39,6 @@ const NATIVE_MEDIA_ELEMENTS: Record = { audio: 'audio', }; -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}`; -} - function htmlMediaElement(preset: PresetReferenceType): string | undefined { return preset.html.mediaElement ?? NATIVE_MEDIA_ELEMENTS[preset.name]; } @@ -122,19 +107,27 @@ const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'unit' });
{preset.features.length > 0 ? listFormat - .formatToParts(preset.features) - .map((part) => - part.type === "element" ? ( + .formatToParts(preset.features.map((f) => f.name)) + .map((part) => { + if (part.type !== "element") { + return {part.value}; + } + const feature = preset.features.find( + (f) => f.name === part.value, + )!; + return feature.hasReference ? ( - {part.value} + {feature.name} ) : ( - {part.value} - ), - ) + + {feature.name} + + ); + }) : "–"}
diff --git a/site/src/types/preset-reference.ts b/site/src/types/preset-reference.ts index ac3bd650..0863fa55 100644 --- a/site/src/types/preset-reference.ts +++ b/site/src/types/preset-reference.ts @@ -9,11 +9,17 @@ export const PresetSkinDefSchema = z.object({ cssImport: z.string().optional(), }); +export const PresetFeatureRefSchema = z.object({ + name: z.string(), + slug: z.string(), + hasReference: z.boolean(), +}); + export const PresetReferenceSchema = z.object({ name: z.string(), description: z.string().optional(), featureBundle: z.string(), - features: z.array(z.string()), + features: z.array(PresetFeatureRefSchema), html: z.object({ skins: z.array(PresetSkinDefSchema), mediaElement: z.string().optional(), @@ -25,4 +31,5 @@ export const PresetReferenceSchema = z.object({ }); export type PresetSkinDef = z.infer; +export type PresetFeatureRef = z.infer; export type PresetReference = z.infer; diff --git a/site/turbo.json b/site/turbo.json index a5f42188..3a44936d 100644 --- a/site/turbo.json +++ b/site/turbo.json @@ -4,7 +4,12 @@ "tasks": { "api-docs": { "dependsOn": ["^build"], - "outputs": ["src/content/generated-component-reference/**", "src/content/generated-util-reference/**"] + "outputs": [ + "src/content/generated-component-reference/**", + "src/content/generated-util-reference/**", + "src/content/generated-feature-reference/**", + "src/content/generated-preset-reference/**" + ] }, "ejected-skins": { "dependsOn": ["^build"],