diff --git a/packages/design-system-mcp/src/format.ts b/packages/design-system-mcp/src/format.ts index b3f1ce0e0710dd..721fa390f5c98f 100644 --- a/packages/design-system-mcp/src/format.ts +++ b/packages/design-system-mcp/src/format.ts @@ -48,6 +48,10 @@ export function formatComponentDetail( detail: ComponentDetail ): string { lines.push( '', `**Package:** \`${ detail.packageName }\`` ); + if ( detail.notes ) { + lines.push( '', `**Notes:** ${ detail.notes }` ); + } + if ( detail.importStatement ) { lines.push( '', diff --git a/packages/design-system-mcp/src/parse-components.ts b/packages/design-system-mcp/src/parse-components.ts index 4870af73e637d0..cc0b7c6317678c 100644 --- a/packages/design-system-mcp/src/parse-components.ts +++ b/packages/design-system-mcp/src/parse-components.ts @@ -135,13 +135,21 @@ export function parseComponents( const key = `${ packageName }:${ name }`; const existing = byKey.get( key ); const description = component.description || ''; + const notes = component.notes || ''; if ( ! existing ) { - byKey.set( key, { name, description, packageName } ); + const entry: Component = { name, description, packageName }; + if ( notes ) { + entry.notes = notes; + } + byKey.set( key, entry ); } else { - // Prefer a non-empty description from a later entry over an - // empty one from the first. + // Prefer a non-empty value from a later entry over an empty one + // from the first. existing.description ||= description; + if ( ! existing.notes && notes ) { + existing.notes = notes; + } } } @@ -180,6 +188,7 @@ export function parseComponentDetail( } const description = component.description || ''; + const notes = component.notes || ''; const props = parseProps( component.reactDocgen?.props || {} ); const stories = component.stories || []; @@ -192,8 +201,14 @@ export function parseComponentDetail( props, stories: [ ...stories ], }; + if ( notes ) { + detail.notes = notes; + } } else if ( detail.packageName === pkg ) { detail.description ||= description; + if ( ! detail.notes && notes ) { + detail.notes = notes; + } if ( detail.props.length === 0 ) { detail.props = props; } diff --git a/packages/design-system-mcp/src/test/data.ts b/packages/design-system-mcp/src/test/data.ts index 76d89a914365e4..388e784663dd60 100644 --- a/packages/design-system-mcp/src/test/data.ts +++ b/packages/design-system-mcp/src/test/data.ts @@ -62,6 +62,7 @@ describe( 'data', () => { name: 'Button', description: 'A button.', packageName: '@wordpress/components', + notes: 'Will be superseded by `Button` in `@wordpress/ui`, but continue using for now.', }, ] ); } ); @@ -102,6 +103,7 @@ describe( 'data', () => { packageName: '@wordpress/components', importStatement: "import { Button } from '@wordpress/components';", + notes: 'Will be superseded by `Button` in `@wordpress/ui`, but continue using for now.', props: [ { name: 'variant', diff --git a/packages/design-system-mcp/src/test/fixtures/manifest.json b/packages/design-system-mcp/src/test/fixtures/manifest.json index 68250ce7276f6b..4cbe472992f659 100644 --- a/packages/design-system-mcp/src/test/fixtures/manifest.json +++ b/packages/design-system-mcp/src/test/fixtures/manifest.json @@ -12,6 +12,7 @@ "name": "Button", "path": "../packages/components/src/button/stories/index.story.tsx", "description": "A button.", + "notes": "Will be superseded by `Button` in `@wordpress/ui`, but continue using for now.", "reactDocgen": { "props": { "variant": { diff --git a/packages/design-system-mcp/src/test/format.ts b/packages/design-system-mcp/src/test/format.ts index 3b93eede2e0ef9..b735cb11a79934 100644 --- a/packages/design-system-mcp/src/test/format.ts +++ b/packages/design-system-mcp/src/test/format.ts @@ -49,6 +49,20 @@ A badge.` ## Button` ); } ); + + it( 'should not surface notes in the list output', () => { + const result = formatComponents( [ + { + name: 'Button', + description: 'A button.', + packageName: '@wordpress/components', + notes: 'Will be superseded by `Button` in `@wordpress/ui`.', + }, + ] ); + + expect( result ).not.toContain( 'superseded' ); + expect( result ).not.toContain( 'Notes:' ); + } ); } ); describe( 'formatComponentDetail', () => { @@ -184,4 +198,46 @@ Button content. **Package:** \`@wordpress/ui\`` ); } ); + + it( 'should render notes as a labeled line after the package', () => { + const result = formatComponentDetail( { + name: 'Button', + description: 'A button.', + packageName: '@wordpress/components', + importStatement: null, + notes: 'Will be superseded by `Button` in `@wordpress/ui`.', + props: [], + stories: [], + } ); + + expect( result ).toBe( + `# Button + +A button. + +**Package:** \`@wordpress/components\` + +**Notes:** Will be superseded by \`Button\` in \`@wordpress/ui\`.` + ); + } ); + + it( 'should render notes when description is absent', () => { + const result = formatComponentDetail( { + name: 'Button', + description: '', + packageName: '@wordpress/components', + importStatement: null, + notes: 'A short note.', + props: [], + stories: [], + } ); + + expect( result ).toBe( + `# Button + +**Package:** \`@wordpress/components\` + +**Notes:** A short note.` + ); + } ); } ); diff --git a/packages/design-system-mcp/src/test/parse-components.ts b/packages/design-system-mcp/src/test/parse-components.ts index 728550afc44061..23a90e66ee83ce 100644 --- a/packages/design-system-mcp/src/test/parse-components.ts +++ b/packages/design-system-mcp/src/test/parse-components.ts @@ -299,6 +299,58 @@ describe( 'parseComponents', () => { }, ] ); } ); + + it( 'should propagate notes from the manifest', () => { + const components = createComponents( { + button: { + name: 'Button', + description: 'A button.', + notes: 'Will be superseded by `Button` in `@wordpress/ui`.', + path: '../packages/components/src/button/stories/index.story.tsx', + }, + } ); + + expect( parseComponents( components ) ).toEqual( [ + { + name: 'Button', + description: 'A button.', + packageName: '@wordpress/components', + notes: 'Will be superseded by `Button` in `@wordpress/ui`.', + }, + ] ); + } ); + + it( 'should omit notes when the manifest entry has none', () => { + const components = createComponents( { + badge: { + name: 'Badge', + path: '../packages/ui/src/badge/stories/index.story.tsx', + }, + } ); + + const [ result ] = parseComponents( components ); + expect( result ).not.toHaveProperty( 'notes' ); + } ); + + it( 'should prefer non-empty notes when merging entries', () => { + const components = createComponents( { + 'badge-index': { + name: 'Badge', + // First entry has no notes + path: '../packages/ui/src/badge/stories/index.story.tsx', + }, + 'badge-intent': { + name: 'Badge', + notes: 'Use intent="high" for the most important badges.', + path: '../packages/ui/src/badge/stories/choosing-intent.story.tsx', + }, + } ); + + const [ result ] = parseComponents( components ); + expect( result.notes ).toBe( + 'Use intent="high" for the most important badges.' + ); + } ); } ); describe( 'parseComponentDetail', () => { @@ -524,4 +576,51 @@ describe( 'parseComponentDetail', () => { } ) ); } ); + + it( 'should propagate notes from the manifest', () => { + const components = createComponents( { + button: { + name: 'Button', + notes: 'Will be superseded by `Button` in `@wordpress/ui`.', + path: '../packages/components/src/button/stories/index.story.tsx', + }, + } ); + + const result = parseComponentDetail( components, 'Button' ); + expect( result?.notes ).toBe( + 'Will be superseded by `Button` in `@wordpress/ui`.' + ); + } ); + + it( 'should omit notes when the manifest entry has none', () => { + const components = createComponents( { + button: { + name: 'Button', + path: '../packages/ui/src/button/stories/index.story.tsx', + }, + } ); + + const result = parseComponentDetail( components, 'Button' ); + expect( result ).not.toHaveProperty( 'notes' ); + } ); + + it( 'should prefer non-empty notes when merging story files', () => { + const components = createComponents( { + 'badge-index': { + name: 'Badge', + // First entry has no notes + path: '../packages/ui/src/badge/stories/index.story.tsx', + }, + 'badge-intent': { + name: 'Badge', + notes: 'Use intent="high" for the most important badges.', + path: '../packages/ui/src/badge/stories/choosing-intent.story.tsx', + }, + } ); + + const result = parseComponentDetail( components, 'Badge' ); + expect( result?.notes ).toBe( + 'Use intent="high" for the most important badges.' + ); + } ); } ); diff --git a/packages/design-system-mcp/src/types.ts b/packages/design-system-mcp/src/types.ts index 9f9668768f8125..0b77aa09394f04 100644 --- a/packages/design-system-mcp/src/types.ts +++ b/packages/design-system-mcp/src/types.ts @@ -14,12 +14,14 @@ export interface ManifestComponent extends ComponentManifest { } >; }; + notes?: string; } export interface Component { name: string; description: string; packageName: string; + notes?: string; } export interface ComponentProp { @@ -35,6 +37,7 @@ export interface ComponentDetail { description: string; packageName: string; importStatement: string | null; + notes?: string; props: ComponentProp[]; stories: Array< { name: string; diff --git a/storybook/main.ts b/storybook/main.ts index ae886c7d141c41..f6663fc2cdc413 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -54,6 +54,7 @@ const config: StorybookConfig = { import.meta.resolve( './addons/source-link/preset.ts' ), 'storybook-addon-tag-badges', import.meta.resolve( './addons/design-system-theme/preset.ts' ), + import.meta.resolve( './presets/component-status-manifest.ts' ), ], framework: '@storybook/react-vite', features: { diff --git a/storybook/presets/component-status-manifest.ts b/storybook/presets/component-status-manifest.ts new file mode 100644 index 00000000000000..56c126dd4a206d --- /dev/null +++ b/storybook/presets/component-status-manifest.ts @@ -0,0 +1,63 @@ +/** + * Storybook preset that merges `notes` from the component-status registry + * onto each entry in the components manifest. + */ +import type { ComponentManifest, Manifests } from 'storybook/internal/types'; +import { COMPONENT_STATUS } from '../component-status'; + +type ComponentManifestWithNotes = ComponentManifest & { notes?: string }; + +const REGISTRY = COMPONENT_STATUS as Record< + string, + Record< string, { notes?: string } > +>; + +/** + * Derive the npm package name from a manifest entry's story file path. + * + * @param storyPath - The story file path recorded on the manifest entry. + * @return The npm package name, or `null` for paths outside `packages/*`. + */ +function packageNameFromPath( storyPath: string ): string | null { + const match = storyPath.match( /\.\.\/packages\/([^/]+)\// ); + return match ? `@wordpress/${ match[ 1 ] }` : null; +} + +/** + * Reduce a namespace component name (e.g. `AlertDialog.Root`) to the + * top-level importable identifier used as the registry key. + * + * @param name - The component name from the manifest. + * @return The top-level importable identifier. + */ +function canonicalComponentName( name: string ): string { + return name.split( '.', 1 )[ 0 ]; +} + +// Disable reason: This is the name that Storybook expects to use for overriding +// experimental manifests behavior. +// eslint-disable-next-line camelcase +export const experimental_manifests = async ( + existing: Manifests | undefined +): Promise< Manifests > => { + const components = existing?.components; + if ( ! components ) { + return existing ?? {}; + } + + const next: Record< string, ComponentManifestWithNotes > = {}; + for ( const [ id, entry ] of Object.entries( components.components ) ) { + const packageName = packageNameFromPath( entry.path ); + const componentName = canonicalComponentName( entry.name ); + const status = packageName + ? REGISTRY[ packageName ]?.[ componentName ] + : undefined; + + next[ id ] = status?.notes ? { ...entry, notes: status.notes } : entry; + } + + return { + ...existing, + components: { ...components, components: next }, + }; +};