From 466824f7d57bfed34e0096671602333bf85c86bc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 21 Jan 2026 11:11:08 +0100 Subject: [PATCH 1/2] add docs entries to manifest debugger. --- .../utils/manifests/manifests.test.ts | 36 ++- .../core-server/utils/manifests/manifests.ts | 18 +- .../manifests/render-components-manifest.ts | 267 +++++++++++++++++- 3 files changed, 296 insertions(+), 25 deletions(-) diff --git a/code/core/src/core-server/utils/manifests/manifests.test.ts b/code/core/src/core-server/utils/manifests/manifests.test.ts index 8111e48b7802..8cbeb17f19f4 100644 --- a/code/core/src/core-server/utils/manifests/manifests.test.ts +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -99,6 +99,30 @@ describe('manifests', () => { expect(files['/output/manifests/components.html']).toContain(''); }); + it('should write HTML file when docs manifest exists', async () => { + mockManifests = { + docs: { + v: 0, + docs: { + 'intro--docs': { + id: 'intro--docs', + name: 'docs', + path: './Intro.mdx', + title: 'Intro', + content: '# Introduction', + }, + }, + }, + }; + + await writeManifests('/output', mockPresets); + + const files = vol.toJSON(); + expect(files['/output/manifests/components.html']).toBeDefined(); + expect(files['/output/manifests/components.html']).toContain(''); + expect(files['/output/manifests/components.html']).toContain('Unattached Docs'); + }); + it('should handle errors when presets.apply fails', async () => { const error = new Error('Preset application failed'); vi.mocked(mockPresets.apply).mockRejectedValue(error); @@ -360,11 +384,11 @@ describe('manifests', () => { expect(res.end).toHaveBeenCalled(); const html = (res.end as any).mock.calls[0][0]; expect(html).toContain(''); - expect(html).toContain('Components Manifest'); + expect(html).toContain('Manifest Debugger'); expect(res.statusCode).toBeUndefined(); }); - it('should return 404 message when components manifest does not exist', async () => { + it('should return 404 message when no components or docs manifest exist', async () => { mockManifests = { other: { data: 'value' }, }; @@ -383,7 +407,9 @@ describe('manifests', () => { expect(res.statusCode).toBe(404); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); - expect(res.end).toHaveBeenCalledWith('
No components manifest configured.
'); + expect(res.end).toHaveBeenCalledWith( + '
No components or docs manifest configured.
' + ); }); it('should return 404 when manifests is empty', async () => { @@ -402,7 +428,9 @@ describe('manifests', () => { await handler(req, res); expect(res.statusCode).toBe(404); - expect(res.end).toHaveBeenCalledWith('
No components manifest configured.
'); + expect(res.end).toHaveBeenCalledWith( + '
No components or docs manifest configured.
' + ); }); it('should handle errors with 500 status and return error HTML', async () => { diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts index 386c920270ac..04cf2788aaec 100644 --- a/code/core/src/core-server/utils/manifests/manifests.ts +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -8,7 +8,7 @@ import type { Polka } from 'polka'; import invariant from 'tiny-invariant'; import { Tag } from '../../../shared/constants/tags'; -import { renderComponentsManifest } from './render-components-manifest'; +import { type DocsManifest, renderComponentsManifest } from './render-components-manifest'; async function getManifests(presets: Presets) { const generator = await presets.apply('storyIndexGenerator'); @@ -37,10 +37,13 @@ export async function writeManifests(outputDir: string, presets: Presets) { writeFile(join(outputDir, 'manifests', `${name}.json`), JSON.stringify(content)) ) ); - if ('components' in manifests) { + if ('components' in manifests || 'docs' in manifests) { await writeFile( join(outputDir, 'manifests', 'components.html'), - renderComponentsManifest(manifests.components as ComponentsManifest) + renderComponentsManifest( + manifests.components as ComponentsManifest | undefined, + manifests.docs as DocsManifest | undefined + ) ); } } catch (e) { @@ -72,17 +75,18 @@ export function registerManifests({ app, presets }: { app: Polka; presets: Prese app.get('/manifests/components.html', async (req, res) => { try { const manifests = await getManifests(presets); - const manifest = manifests.components; + const componentsManifest = manifests.components; + const docsManifest = manifests.docs as DocsManifest | undefined; - if (!manifest) { + if (!componentsManifest && !docsManifest) { res.statusCode = 404; res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(`
No components manifest configured.
`); + res.end(`
No components or docs manifest configured.
`); return; } res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderComponentsManifest(manifest)); + res.end(renderComponentsManifest(componentsManifest, docsManifest)); } catch (e) { res.statusCode = 500; res.setHeader('Content-Type', 'text/html; charset=utf-8'); diff --git a/code/core/src/core-server/utils/manifests/render-components-manifest.ts b/code/core/src/core-server/utils/manifests/render-components-manifest.ts index 9a22308e6b23..2c74e26c58bb 100644 --- a/code/core/src/core-server/utils/manifests/render-components-manifest.ts +++ b/code/core/src/core-server/utils/manifests/render-components-manifest.ts @@ -4,20 +4,53 @@ import { groupBy } from 'storybook/internal/common'; import type { ComponentManifest, ComponentsManifest } from '../../../types'; +/** Minimal docs entry type for rendering in the manifest debugger */ +interface DocsManifestEntry { + id: string; + name: string; + path: string; + title: string; + content?: string; + summary?: string; + error?: { name: string; message: string }; +} + +/** Minimal docs manifest type for rendering in the manifest debugger */ +export interface DocsManifest { + v: number; + docs: Record; +} + +/** Extended component manifest that may include docs from the docs addon */ +interface ComponentManifestWithDocs extends ComponentManifest { + docs?: Record; +} + // AI generated manifests/components.html page // Only HTML/CSS no JS -export function renderComponentsManifest(manifest: ComponentsManifest) { +export function renderComponentsManifest( + manifest: ComponentsManifest | undefined, + docsManifest?: DocsManifest +) { const entries = Object.entries(manifest?.components ?? {}).sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]) ); + // Get unattached docs entries + const docsEntries = Object.entries(docsManifest?.docs ?? {}).sort((a, b) => + (a[1].name || a[0]).localeCompare(b[1].name || b[0]) + ); + const analyses = entries.map(([, c]) => analyzeComponent(c)); + const docsAnalyses = docsEntries.map(([, d]) => analyzeDoc(d)); const totals = { components: entries.length, componentsWithPropTypeError: analyses.filter((a) => a.hasPropTypeError).length, infos: analyses.filter((a) => a.hasWarns).length, stories: analyses.reduce((sum, a) => sum + a.totalStories, 0), storyErrors: analyses.reduce((sum, a) => sum + a.storyErrors, 0), + docs: docsEntries.length, + docsWithError: docsAnalyses.filter((a) => a.hasError).length, }; // Top filters (clickable), no tags; 1px active ring lives in CSS via :target @@ -25,7 +58,9 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { const compErrorsPill = totals.componentsWithPropTypeError > 0 ? `${totals.componentsWithPropTypeError}/${totals.components} prop type ${plural(totals.componentsWithPropTypeError, 'error')}` - : `${totals.components} components ok`; + : totals.components > 0 + ? `${totals.components} components ok` + : ''; const compInfosPill = totals.infos > 0 ? `${totals.infos}/${totals.components} ${plural(totals.infos, 'info', 'infos')}` @@ -33,9 +68,18 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { const storiesPill = totals.storyErrors > 0 ? `${totals.storyErrors}/${totals.stories} story errors` - : `${totals.stories} ${plural(totals.stories, 'story', 'stories')} ok`; + : totals.stories > 0 + ? `${totals.stories} ${plural(totals.stories, 'story', 'stories')} ok` + : ''; + const docsPill = + totals.docsWithError > 0 + ? `${totals.docsWithError}/${totals.docs} doc ${plural(totals.docsWithError, 'error')}` + : totals.docs > 0 + ? `${totals.docs} ${plural(totals.docs, 'doc')} ok` + : ''; const grid = entries.map(([key, c], idx) => renderComponentCard(key, c, `${idx}`)).join(''); + const docsGrid = docsEntries.map(([key, d], idx) => renderDocCard(key, d, `doc-${idx}`)).join(''); const errorGroups = Object.entries( groupBy( @@ -312,6 +356,8 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { .tg-err:checked + label.as-toggle, .tg-info:checked + label.as-toggle, .tg-stories:checked + label.as-toggle, + .tg-docs:checked + label.as-toggle, + .tg-content:checked + label.as-toggle, .tg-props:checked + label.as-toggle { box-shadow: 0 0 0 var(--active-ring) currentColor; border-color: currentColor; @@ -341,6 +387,16 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { gap: 8px; } + .tg-docs:checked ~ .panels .panel-docs { + display: grid; + gap: 8px; + } + + .tg-content:checked ~ .panels .panel-content { + display: grid; + gap: 8px; + } + .tg-props:checked ~ .panels .panel-props { display: grid; } @@ -501,6 +557,10 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { display: none; } + #filter-doc-errors:target ~ main .card:not(.has-doc-error) { + display: none; + } + #filter-all:target ~ main .card { display: block; } @@ -515,6 +575,19 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { display: block; } + /* Section titles */ + .section-title { + font-size: 16px; + font-weight: 600; + color: var(--muted); + margin: 24px 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + .section-title:first-child { + margin-top: 0; + } + /* When a toggle is checked, show the corresponding panel */ .card > .tg-err:checked ~ .panels .panel-err { display: grid; @@ -528,10 +601,20 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { display: grid; } + .card > .tg-docs:checked ~ .panels .panel-docs { + display: grid; + } + + .card > .tg-content:checked ~ .panels .panel-content { + display: grid; + } + /* Add vertical spacing around panels only when any panel is visible */ .card > .tg-err:checked ~ .panels, .card > .tg-info:checked ~ .panels, .card > .tg-stories:checked ~ .panels, + .card > .tg-docs:checked ~ .panels, + .card > .tg-content:checked ~ .panels, .card > .tg-props:checked ~ .panels { margin: 10px 0; } @@ -541,6 +624,8 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { .card:has(.tg-err:checked) label[for$='-err'], .card:has(.tg-info:checked) label[for$='-info'], .card:has(.tg-stories:checked) label[for$='-stories'], + .card:has(.tg-docs:checked) label[for$='-docs'], + .card:has(.tg-content:checked) label[for$='-content'], .card:has(.tg-props:checked) label[for$='-props'] { box-shadow: 0 0 0 1px currentColor; border-color: currentColor; @@ -565,6 +650,17 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { word-break: inherit; inline-size: min(100%, 120ch); } + + /* MDX content container for docs */ + .mdx-content { + background: #0f131b; + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + max-height: 400px; + overflow-y: auto; + margin-top: 8px; + } @@ -573,25 +669,41 @@ export function renderComponentsManifest(manifest: ComponentsManifest) { +
-

Components Manifest

-
${allPill}${compErrorsPill}${compInfosPill}${storiesPill}
+

Manifest Debugger

+
${allPill}${compErrorsPill}${compInfosPill}${storiesPill}${docsPill}
+ ${ + grid + ? `

Components

- ${ - grid || - `
No components.
` - } -
+ ${grid} +
` + : '' + } ${ errorGroups.length ? `
${errorGroupsHTML}
` : '' } + ${ + docsGrid + ? `

Unattached Docs

+
+ ${docsGrid} +
` + : '' + } + ${ + !grid && !docsGrid + ? `
No components or docs.
` + : '' + }
@@ -605,7 +717,7 @@ const esc = (s: unknown) => ); const plural = (n: number, one: string, many = `${one}s`) => (n === 1 ? one : many); -function analyzeComponent(c: ComponentManifest) { +function analyzeComponent(c: ComponentManifestWithDocs) { const hasPropTypeError = !!c.error; const warns: string[] = []; @@ -623,7 +735,13 @@ function analyzeComponent(c: ComponentManifest) { const storyErrors = (c.stories ?? []).filter((e) => !!e?.error).length; const storyOk = totalStories - storyErrors; - const hasAnyError = hasPropTypeError || storyErrors > 0; // for status dot (red if any errors) + // Analyze attached docs + const docsEntries = c.docs ? Object.values(c.docs) : []; + const totalDocs = docsEntries.length; + const docsErrors = docsEntries.filter((d) => !!d?.error).length; + const docsOk = totalDocs - docsErrors; + + const hasAnyError = hasPropTypeError || storyErrors > 0 || docsErrors > 0; // for status dot (red if any errors) return { hasPropTypeError, @@ -633,6 +751,15 @@ function analyzeComponent(c: ComponentManifest) { totalStories, storyErrors, storyOk, + totalDocs, + docsErrors, + docsOk, + }; +} + +function analyzeDoc(d: DocsManifestEntry) { + return { + hasError: !!d.error, }; } @@ -644,13 +771,82 @@ function note(title: string, bodyHTML: string, kind: 'info' | 'err') { `; } -function renderComponentCard(key: string, c: ComponentManifest, id: string) { +function renderDocCard(key: string, d: DocsManifestEntry, id: string) { + const a = analyzeDoc(d); + const statusDot = a.hasError ? 'dot-err' : 'dot-ok'; + + const slug = `${id}-${(d.id || key) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '')}`; + + const errorBadge = a.hasError + ? `` + : ''; + + const contentBadge = d.content + ? `` + : ''; + + return ` +
+
+
+

${esc(d.title || d.name || key)}

+
+ ${errorBadge} + ${contentBadge} +
+
+
${esc(d.id)} · ${esc(d.path)}
+ ${d.summary ? `
${esc(d.summary)}
` : ''} +
+ + + ${a.hasError ? `` : ''} + ${d.content ? `` : ''} + +
+ ${ + a.hasError + ? ` +
+
+
${esc(d.error?.name || 'Error')}
+
${esc(d.error?.message || 'Unknown error')}
+
+
` + : '' + } + ${ + d.content + ? ` +
+
+
${esc(d.content)}
+
+
` + : '' + } +
+
`; +} + +function renderComponentCard(key: string, c: ComponentManifestWithDocs, id: string) { const a = analyzeComponent(c); const statusDot = a.hasAnyError ? 'dot-err' : 'dot-ok'; const allStories = c.stories ?? []; const errorStories = allStories.filter((ex) => !!ex?.error); const okStories = allStories.filter((ex) => !ex?.error); + // Get attached docs entries + const allDocs = c.docs ? Object.values(c.docs) : []; + const errorDocs = allDocs.filter((d) => !!d?.error); + const okDocs = allDocs.filter((d) => !d?.error); + const slug = `c-${id}-${(c.id || key) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') @@ -669,6 +865,11 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { ? `` : ''; + const docsBadge = + a.totalDocs > 0 + ? `` + : ''; + // When there is no prop type error, try to read prop types from reactDocgen if present const reactDocgen: any = !a.hasPropTypeError && 'reactDocgen' in c && c.reactDocgen; const parsedDocgen = reactDocgen ? parseReactDocgen(reactDocgen) : undefined; @@ -715,7 +916,8 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { class="card ${a.hasPropTypeError ? 'has-error' : 'no-error'} ${a.hasWarns ? 'has-info' : 'no-info'} - ${a.storyErrors ? 'has-story-error' : 'no-story-error'}" + ${a.storyErrors ? 'has-story-error' : 'no-story-error'} + ${a.docsErrors ? 'has-doc-error' : 'no-doc-error'}" role="listitem" aria-label="${esc(c.name || key)}">
@@ -725,6 +927,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { ${primaryBadge} ${infosBadge} ${storiesBadge} + ${docsBadge}
${esc(c.id)} · ${esc(c.path)}
@@ -737,6 +940,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { ${a.hasPropTypeError ? `` : ''} ${a.hasWarns ? `` : ''} ${a.totalStories > 0 ? `` : ''} + ${a.totalDocs > 0 ? `` : ''} ${!a.hasPropTypeError && propEntries.length > 0 ? `` : ''}
@@ -821,6 +1025,41 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
` : '' } + ${ + a.totalDocs > 0 + ? ` +
+ ${errorDocs + .map( + (doc) => ` +
+
+ ${esc(doc.name)} + doc error +
+
${esc(doc.path)}
+ ${doc?.summary ? `
${esc(doc.summary)}
` : ''} + ${doc?.error?.message ? `
${esc(doc.error.message)}
` : ''} +
` + ) + .join('')} + ${okDocs + .map( + (doc) => ` +
+
+ ${esc(doc.name)} + doc ok +
+
${esc(doc.path)}
+ ${doc?.summary ? `
${esc(doc.summary)}
` : ''} + ${doc?.content ? `
${esc(doc.content)}
` : ''} +
` + ) + .join('')} +
` + : '' + } `; } From ef9accb31d226a494e05d43475f90a2889e9c190 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 23 Jan 2026 11:42:32 +0100 Subject: [PATCH 2/2] support filtering by docs --- .../manifests/render-components-manifest.ts | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/code/core/src/core-server/utils/manifests/render-components-manifest.ts b/code/core/src/core-server/utils/manifests/render-components-manifest.ts index 2c74e26c58bb..73d65c1bbf7b 100644 --- a/code/core/src/core-server/utils/manifests/render-components-manifest.ts +++ b/code/core/src/core-server/utils/manifests/render-components-manifest.ts @@ -43,14 +43,17 @@ export function renderComponentsManifest( const analyses = entries.map(([, c]) => analyzeComponent(c)); const docsAnalyses = docsEntries.map(([, d]) => analyzeDoc(d)); + const attachedDocs = analyses.reduce((sum, a) => sum + a.totalDocs, 0); + const attachedDocsWithError = analyses.reduce((sum, a) => sum + a.docsErrors, 0); + const unattachedDocsWithError = docsAnalyses.filter((a) => a.hasError).length; const totals = { components: entries.length, componentsWithPropTypeError: analyses.filter((a) => a.hasPropTypeError).length, infos: analyses.filter((a) => a.hasWarns).length, stories: analyses.reduce((sum, a) => sum + a.totalStories, 0), storyErrors: analyses.reduce((sum, a) => sum + a.storyErrors, 0), - docs: docsEntries.length, - docsWithError: docsAnalyses.filter((a) => a.hasError).length, + docs: docsEntries.length + attachedDocs, + docsWithError: unattachedDocsWithError + attachedDocsWithError, }; // Top filters (clickable), no tags; 1px active ring lives in CSS via :target @@ -72,11 +75,11 @@ export function renderComponentsManifest( ? `${totals.stories} ${plural(totals.stories, 'story', 'stories')} ok` : ''; const docsPill = - totals.docsWithError > 0 - ? `${totals.docsWithError}/${totals.docs} doc ${plural(totals.docsWithError, 'error')}` - : totals.docs > 0 - ? `${totals.docs} ${plural(totals.docs, 'doc')} ok` - : ''; + totals.docs > 0 + ? totals.docsWithError > 0 + ? `${totals.docsWithError}/${totals.docs} doc ${plural(totals.docsWithError, 'error')}` + : `${totals.docs} ${plural(totals.docs, 'doc')} ok` + : ''; const grid = entries.map(([key, c], idx) => renderComponentCard(key, c, `${idx}`)).join(''); const docsGrid = docsEntries.map(([key, d], idx) => renderDocCard(key, d, `doc-${idx}`)).join(''); @@ -234,7 +237,9 @@ export function renderComponentsManifest( #filter-all:target ~ header .filter-pill[data-k='all'], #filter-errors:target ~ header .filter-pill[data-k='errors'], #filter-infos:target ~ header .filter-pill[data-k='infos'], - #filter-story-errors:target ~ header .filter-pill[data-k='story-errors'] { + #filter-story-errors:target ~ header .filter-pill[data-k='story-errors'], + #filter-doc-errors:target ~ header .filter-pill[data-k='docs'], + #filter-docs:target ~ header .filter-pill[data-k='docs'] { box-shadow: 0 0 0 var(--active-ring) currentColor; border-color: currentColor; } @@ -243,7 +248,9 @@ export function renderComponentsManifest( #filter-all, #filter-errors, #filter-infos, - #filter-story-errors { + #filter-story-errors, + #filter-doc-errors, + #filter-docs { display: none; } @@ -561,6 +568,15 @@ export function renderComponentsManifest( display: none; } + #filter-docs:target ~ main .card:has(> .tg-docs), + #filter-docs:target ~ main .card.is-doc { + display: block; + } + + #filter-docs:target ~ main .card:not(:has(> .tg-docs)):not(.is-doc) { + display: none; + } + #filter-all:target ~ main .card { display: block; } @@ -670,6 +686,7 @@ export function renderComponentsManifest( +

Manifest Debugger

@@ -790,7 +807,7 @@ function renderDocCard(key: string, d: DocsManifestEntry, id: string) { return `