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
4 changes: 4 additions & 0 deletions packages/design-system-mcp/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'',
Expand Down
21 changes: 18 additions & 3 deletions packages/design-system-mcp/src/parse-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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 || [];

Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/design-system-mcp/src/test/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
] );
} );
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
56 changes: 56 additions & 0 deletions packages/design-system-mcp/src/test/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.`
);
} );
} );
99 changes: 99 additions & 0 deletions packages/design-system-mcp/src/test/parse-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.'
);
} );
} );
3 changes: 3 additions & 0 deletions packages/design-system-mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -35,6 +37,7 @@ export interface ComponentDetail {
description: string;
packageName: string;
importStatement: string | null;
notes?: string;
props: ComponentProp[];
stories: Array< {
name: string;
Expand Down
1 change: 1 addition & 0 deletions storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
63 changes: 63 additions & 0 deletions storybook/presets/component-status-manifest.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This only includes the notes rather than the full status object. That should be okay based on what we're doing now because the manifest already only contains status: 'recommended' + whereUsed: 'global' components, but I've mentioned before that it could be useful for the MCP server to surface some of our "use-with-caution" components depending on the situation. So maybe it'd be more future-proof to pass through the whole object, or at least status + notes. Then again, it could be perfectly fine to add status as a sibling property, rather than maintain the componentStatus object wrapper.

}

return {
...existing,
components: { ...components, components: next },
};
};
Loading