diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index ef1bc814288f..c4d844ca8d8e 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -11,7 +11,6 @@ import { import { logger } from 'storybook/internal/node-logger'; import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; -import { type ComponentManifestGenerator, type ComponentsManifest } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -167,7 +166,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ); if (features?.experimentalComponentsManifest) { - const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( + const componentManifestGenerator = await presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index b5ec0b04dc90..4c373f9f0723 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -144,7 +144,7 @@ export async function storybookDevServer(options: Options) { if (features?.experimentalComponentsManifest) { app.use('/manifests/components.json', async (req, res) => { try { - const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + const componentManifestGenerator = await options.presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; @@ -169,7 +169,7 @@ export async function storybookDevServer(options: Options) { app.get('/manifests/components.html', async (req, res) => { try { - const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + const componentManifestGenerator = await options.presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; diff --git a/code/core/src/core-server/manifest.ts b/code/core/src/core-server/manifest.ts index fb7f33677298..591d1f6aa382 100644 --- a/code/core/src/core-server/manifest.ts +++ b/code/core/src/core-server/manifest.ts @@ -12,26 +12,26 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { const analyses = entries.map(([, c]) => analyzeComponent(c)); const totals = { components: entries.length, - componentsWithError: analyses.filter((a) => a.hasComponentError).length, - componentsWithWarnings: analyses.filter((a) => a.hasWarns).length, - examples: analyses.reduce((sum, a) => sum + a.totalExamples, 0), - exampleErrors: analyses.reduce((sum, a) => sum + a.exampleErrors, 0), + componentsWithPropTypeError: analyses.filter((a) => a.hasPropTypeError).length, + warnings: analyses.filter((a) => a.hasWarns).length, + stories: analyses.reduce((sum, a) => sum + a.totalStories, 0), + storyErrors: analyses.reduce((sum, a) => sum + a.storyErrors, 0), }; // Top filters (clickable), no tags; 1px active ring lives in CSS via :target const allPill = `All`; const compErrorsPill = - totals.componentsWithError > 0 - ? `${totals.componentsWithError}/${totals.components} component ${plural(totals.componentsWithError, 'error')}` + totals.componentsWithPropTypeError > 0 + ? `${totals.componentsWithPropTypeError}/${totals.components} prop type ${plural(totals.componentsWithPropTypeError, 'error')}` : `${totals.components} components ok`; const compWarningsPill = - totals.componentsWithWarnings > 0 - ? `${totals.componentsWithWarnings}/${totals.components} component ${plural(totals.componentsWithWarnings, 'warning')}` + totals.warnings > 0 + ? `${totals.warnings}/${totals.components} ${plural(totals.warnings, 'warning')}` : ''; - const examplesPill = - totals.exampleErrors > 0 - ? `${totals.exampleErrors}/${totals.examples} example errors` - : `${totals.examples} examples ok`; + const storiesPill = + totals.storyErrors > 0 + ? `${totals.storyErrors}/${totals.stories} story errors` + : `${totals.stories} ${plural(totals.stories, 'story', 'stories')} ok`; const grid = entries.map(([key, c], idx) => renderComponentCard(key, c, `${idx}`)).join(''); @@ -188,7 +188,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { #filter-all:target ~ header .filter-pill[data-k='all'], #filter-errors:target ~ header .filter-pill[data-k='errors'], #filter-warnings:target ~ header .filter-pill[data-k='warnings'], - #filter-example-errors:target ~ header .filter-pill[data-k='example-errors'] { + #filter-story-errors:target ~ header .filter-pill[data-k='story-errors'] { box-shadow: 0 0 0 var(--active-ring) currentColor; border-color: currentColor; } @@ -197,18 +197,18 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { #filter-all, #filter-errors, #filter-warnings, - #filter-example-errors { + #filter-story-errors { display: none; } main { - padding: 24px 0 40px; + padding: 36px 0 40px; } .grid { display: grid; grid-template-columns: 1fr; - gap: 14px; + gap: 18px; } /* one card per row */ @@ -309,7 +309,8 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { /* 1px ring on active toggle */ .tg-err:checked + label.as-toggle, .tg-warn:checked + label.as-toggle, - .tg-ex:checked + label.as-toggle { + .tg-stories:checked + label.as-toggle, + .tg-props:checked + label.as-toggle { box-shadow: 0 0 0 var(--active-ring) currentColor; border-color: currentColor; } @@ -333,11 +334,16 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { gap: 8px; } - .tg-ex:checked ~ .panels .panel-ex { + .tg-stories:checked ~ .panels .panel-stories { + display: grid; + gap: 8px; + } + + .tg-props:checked ~ .panels .panel-props { display: grid; } - /* Colored notes for component error + warnings */ + /* Colored notes for prop type error + warnings */ .note { padding: 12px; border: 1px solid var(--border); @@ -356,6 +362,12 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { color: #ffe9a6; } + .note.ok { + border-color: color-mix(in srgb, var(--ok) 55%, var(--border)); + background: var(--ok-bg); + color: var(--fg); + } + .note-title { font-weight: 600; margin-bottom: 6px; @@ -365,7 +377,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { white-space: normal; } - /* Example error cards */ + /* Story error cards */ .ex { padding: 10px; border: 1px solid var(--border); @@ -475,7 +487,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { } /* CSS-only filtering of cards via top pills */ - #filter-errors:target ~ main .card:not(.has-error):not(.has-example-error) { + #filter-errors:target ~ main .card:not(.has-error):not(.has-story-error) { display: none; } @@ -483,7 +495,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { display: none; } - #filter-example-errors:target ~ main .card:not(.has-example-error) { + #filter-story-errors:target ~ main .card:not(.has-story-error) { display: none; } @@ -510,7 +522,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { display: grid; } - .card > .tg-ex:checked ~ .panels .panel-ex { + .card > .tg-stories:checked ~ .panels .panel-stories { display: grid; } @@ -518,7 +530,8 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { @supports selector(.card:has(.tg-err:checked)) { .card:has(.tg-err:checked) label[for$='-err'], .card:has(.tg-warn:checked) label[for$='-warn'], - .card:has(.tg-ex:checked) label[for$='-ex'] { + .card:has(.tg-stories:checked) label[for$='-stories'], + .card:has(.tg-props:checked) label[for$='-props'] { box-shadow: 0 0 0 1px currentColor; border-color: currentColor; } @@ -530,11 +543,11 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { - +

Components Manifest

-
${allPill}${compErrorsPill}${compWarningsPill}${examplesPill}
+
${allPill}${compErrorsPill}${compWarningsPill}${storiesPill}
@@ -547,7 +560,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { ${ errorGroups.length - ? `
${errorGroupsHTML}
` + ? `
${errorGroupsHTML}
` : '' } @@ -564,35 +577,29 @@ const esc = (s: unknown) => const plural = (n: number, one: string, many = `${one}s`) => (n === 1 ? one : many); function analyzeComponent(c: ComponentManifest) { - const hasComponentError = !!c.error; + const hasPropTypeError = !!c.error; const warns: string[] = []; - if (!c.description?.trim()) { - warns.push( - 'No description found. Write a jsdoc comment such as /** Component description */ on your component or on your stories meta.' - ); - } - if (!c.import?.trim()) { warns.push( `Specify an @import jsdoc tag on your component or your stories meta such as @import import { ${c.name} } from 'my-design-system';` ); } - const totalExamples = c.examples?.length ?? 0; - const exampleErrors = (c.examples ?? []).filter((e) => !!e?.error).length; - const exampleOk = totalExamples - exampleErrors; + const totalStories = c.stories?.length ?? 0; + const storyErrors = (c.stories ?? []).filter((e) => !!e?.error).length; + const storyOk = totalStories - storyErrors; - const hasAnyError = hasComponentError || exampleErrors > 0; // for status dot (red if any errors) + const hasAnyError = hasPropTypeError || storyErrors > 0; // for status dot (red if any errors) return { - hasComponentError, + hasPropTypeError, hasAnyError, hasWarns: warns.length > 0, warns, - totalExamples, - exampleErrors, - exampleOk, + totalStories, + storyErrors, + storyOk, }; } @@ -607,25 +614,54 @@ function note(title: string, bodyHTML: string, kind: 'warn' | 'err') { function renderComponentCard(key: string, c: ComponentManifest, id: string) { const a = analyzeComponent(c); const statusDot = a.hasAnyError ? 'dot-err' : 'dot-ok'; - const errorExamples = (c.examples ?? []).filter((ex) => !!ex?.error); + const allStories = c.stories ?? []; + const errorStories = allStories.filter((ex) => !!ex?.error); + const okStories = allStories.filter((ex) => !ex?.error); const slug = `c-${id}-${(c.id || key) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '')}`; - const componentErrorBadge = a.hasComponentError - ? `` + const componentErrorBadge = a.hasPropTypeError + ? `` : ''; const warningsBadge = a.hasWarns ? `` : ''; - const examplesBadge = - a.exampleErrors > 0 - ? `` - : `${a.totalExamples} examples ok`; + const storiesBadge = + a.totalStories > 0 + ? `` + : ''; + + // When there is no prop type error, try to read prop types from reactDocgen if present + const hasDocgen = !a.hasPropTypeError && 'reactDocgen' in c && c.reactDocgen; + const parsedDocgen = hasDocgen ? parseReactDocgen(c.reactDocgen) : undefined; + const propEntries = parsedDocgen ? Object.entries(parsedDocgen.props ?? {}) : []; + const propTypesBadge = + !a.hasPropTypeError && propEntries.length > 0 + ? `` + : ''; + + const primaryBadge = componentErrorBadge || propTypesBadge; + + const propsCode = + propEntries.length > 0 + ? propEntries + .sort(([aName], [bName]) => aName.localeCompare(bName)) + .map(([propName, info]) => { + const description = (info?.description ?? '').trim(); + const t = (info?.type ?? 'any').trim(); + const optional = info?.required ? '' : '?'; + const defaultVal = (info?.defaultValue ?? '').trim(); + const def = defaultVal ? ` = ${defaultVal}` : ''; + const doc = description ? `/** ${description} */\n` : ''; + return `${doc}${propName}${optional}: ${t}${def}`; + }) + .join('\n\n') + : ''; const tags = c.jsDocTags && typeof c.jsDocTags === 'object' @@ -642,18 +678,18 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { return `

${esc(c.name || key)}

- ${componentErrorBadge} + ${primaryBadge} ${warningsBadge} - ${examplesBadge} + ${storiesBadge}
${esc(c.id)} · ${esc(c.path)}
@@ -663,16 +699,17 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
- ${a.hasComponentError ? `` : ''} + ${a.hasPropTypeError ? `` : ''} ${a.hasWarns ? `` : ''} - ${a.exampleErrors > 0 ? `` : ''} + ${a.totalStories > 0 ? `` : ''} + ${!a.hasPropTypeError && propEntries.length > 0 ? `` : ''}
${ - a.hasComponentError + a.hasPropTypeError ? `
- ${note('Component error', `
${esc(c.error?.message || 'Unknown error')}
`, 'err')} + ${note('Prop type error', `
${esc(c.error?.message || 'Unknown error')}
`, 'err')}
` : '' } @@ -685,26 +722,149 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { : '' } ${ - a.exampleErrors > 0 + !a.hasPropTypeError && propEntries.length > 0 ? ` -
- ${errorExamples +
+
+
+ Prop types + ${propEntries.length} ${plural(propEntries.length, 'prop type')} +
+
${esc(propsCode)}
+
+
` + : '' + } + ${ + a.totalStories > 0 + ? ` +
+ ${errorStories .map( (ex, j) => ` -
+
- - ${esc(ex?.name ?? `Example ${j + 1}`)} - example error + ${esc(ex.name)} + story error
${ex?.snippet ? `
${esc(ex.snippet)}
` : ''} ${ex?.error?.message ? `
${esc(ex.error.message)}
` : ''}
` ) .join('')} + ${okStories + .map( + (ex, k) => ` +
+
+ ${esc(ex.name)} + story ok +
+ ${ex?.snippet ? `
${esc(ex.snippet)}
` : ''} +
` + ) + .join('')}
` : '' }
`; } + +export type ParsedDocgen = { + props: Record< + string, + { + description?: string; + type?: string; + defaultValue?: string; + required?: boolean; + } + >; +}; + +export const parseReactDocgen = (reactDocgen: any): ParsedDocgen => { + const props: Record = (reactDocgen as any)?.props ?? {}; + return { + props: Object.fromEntries( + Object.entries(props).map(([propName, prop]) => [ + propName, + { + description: prop.description, + type: serializeTsType(prop.tsType ?? prop.type), + defaultValue: prop.defaultValue?.value, + required: prop.required, + }, + ]) + ), + }; +}; + +// Serialize a react-docgen tsType into a TypeScript-like string when raw is not available +function serializeTsType(tsType: any): string | undefined { + if (!tsType) { + return undefined; + } + // Prefer raw if provided + // Prefer raw if provided + if ('raw' in tsType && typeof tsType.raw === 'string' && tsType.raw.trim().length > 0) { + return tsType.raw; + } + + if (!tsType.name) { + return undefined; + } + + if ('elements' in tsType) { + if (tsType.name === 'union') { + const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown'); + return parts.join(' | '); + } + if (tsType.name === 'intersection') { + const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown'); + return parts.join(' & '); + } + if (tsType.name === 'Array') { + // Prefer raw earlier; here build fallback + const el = (tsType.elements ?? [])[0]; + const inner = serializeTsType(el) ?? 'unknown'; + return `${inner}[]`; + } + if (tsType.name === 'tuple') { + const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown'); + return `[${parts.join(', ')}]`; + } + } + if ('value' in tsType && tsType.name === 'literal') { + return tsType.value; + } + if ('signature' in tsType && tsType.name === 'signature') { + if (tsType.type === 'function') { + const args = (tsType.signature?.arguments ?? []).map((a: any) => { + const argType = serializeTsType(a.type) ?? 'any'; + return `${a.name}: ${argType}`; + }); + const ret = serializeTsType(tsType.signature?.return) ?? 'void'; + return `(${args.join(', ')}) => ${ret}`; + } + if (tsType.type === 'object') { + const props = (tsType.signature?.properties ?? []).map((p: any) => { + const req: boolean = Boolean(p.value?.required); + const propType = serializeTsType(p.value) ?? 'any'; + return `${p.key}${req ? '' : '?'}: ${propType}`; + }); + return `{ ${props.join('; ')} }`; + } + return 'unknown'; + } + // Default case (Generic like Item) + if ('elements' in tsType) { + const inner = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown'); + + if (inner.length > 0) { + return `${tsType.name}<${inner.join(', ')}>`; + } + } + + return tsType.name; +} diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 4918d619c6c4..229e7489608e 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -352,7 +352,7 @@ export interface ComponentManifest { description?: string; import?: string; summary?: string; - examples: { name: string; snippet?: string; error?: { name: string; message: string } }[]; + stories: { name: string; snippet?: string; error?: { name: string; message: string } }[]; jsDocTags: Record; error?: { name: string; message: string }; } @@ -381,7 +381,7 @@ export interface StorybookConfigRaw { */ addons?: Preset[]; core?: CoreConfig; - componentManifestGenerator?: ComponentManifestGenerator; + experimental_componentManifestGenerator?: ComponentManifestGenerator; experimental_enrichCsf?: CsfEnricher; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index c330f3c511fd..fa13535eb68c 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -216,24 +216,6 @@ test('componentManifestGenerator generates correct id, name, description and exa "example-button": { "description": "Primary UI component for user interaction", "error": undefined, - "examples": [ - { - "name": "Primary", - "snippet": "const Primary = () => ;", - }, - { - "name": "Secondary", - "snippet": "const Secondary = () => ;", - }, - { - "name": "Large", - "snippet": "const Large = () => ;", - }, - { - "name": "Small", - "snippet": "const Small = () => ;", - }, - ], "id": "example-button", "import": undefined, "jsDocTags": {}, @@ -315,25 +297,29 @@ test('componentManifestGenerator generates correct id, name, description and exa }, }, }, - "summary": undefined, - }, - "example-header": { - "description": "Description from meta and very long.", - "error": undefined, - "examples": [ + "stories": [ { - "name": "LoggedIn", - "snippet": "const LoggedIn = () =>
;", + "name": "Primary", + "snippet": "const Primary = () => ;", }, { - "name": "LoggedOut", - "snippet": "const LoggedOut = () =>
;", + "name": "Secondary", + "snippet": "const Secondary = () => ;", + }, + { + "name": "Large", + "snippet": "const Large = () => ;", + }, + { + "name": "Small", + "snippet": "const Small = () => ;", }, ], + "summary": undefined, + }, + "example-header": { + "description": "Description from meta and very long.", + "error": undefined, "id": "example-header", "import": "import { Header } from '@design-system/components/Header';", "jsDocTags": { @@ -407,6 +393,20 @@ test('componentManifestGenerator generates correct id, name, description and exa }, }, }, + "stories": [ + { + "name": "LoggedIn", + "snippet": "const LoggedIn = () =>
;", + }, + { + "name": "LoggedOut", + "snippet": "const LoggedOut = () =>
;", + }, + ], "summary": "Component summary", }, }, @@ -496,12 +496,6 @@ test('fall back to index title when no component name', async () => { { "description": "Primary UI component for user interaction", "error": undefined, - "examples": [ - { - "name": "Primary", - "snippet": "const Primary = () =>