diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index b920c2509f3c..499d42bf67b0 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -131,6 +131,7 @@ const localAlias = { 'storybook/actions': join(CORE_ROOT_DIR, 'src', 'actions'), 'storybook/preview-api': join(CORE_ROOT_DIR, 'src', 'preview-api'), 'storybook/manager-api': join(CORE_ROOT_DIR, 'src', 'manager-api'), + 'storybook/open-service': join(CORE_ROOT_DIR, 'src', 'shared', 'open-service'), storybook: join(CORE_ROOT_DIR, 'src'), }; async function generateExportsFile(): Promise { diff --git a/code/core/src/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index 024a34422a7c..524abd84f641 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,6 +1,7 @@ /* these imports are in the exact order in which the panels need to be registered */ // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! +import docgenManager from '../../shared/open-service/services/docgen/manager.tsx'; import controlsManager from '../../controls/manager.tsx'; import actionsManager from '../../actions/manager.tsx'; import componentTestingManager from '../../component-testing/manager.tsx'; @@ -10,6 +11,7 @@ import outlineManager from '../../outline/manager.tsx'; import viewportManager from '../../viewport/manager.tsx'; export default [ + docgenManager, measureManager, actionsManager, backgroundsManager, diff --git a/code/core/src/csf/core-annotations.ts b/code/core/src/csf/core-annotations.ts index 6e58a1a2bec3..ae5c32c5442d 100644 --- a/code/core/src/csf/core-annotations.ts +++ b/code/core/src/csf/core-annotations.ts @@ -8,6 +8,7 @@ import ghostStoriesAnnotations from '../core-server/utils/ghost-stories/test-ann import highlightAnnotations, { type HighlightTypes } from '../highlight/preview.ts'; import measureAnnotations, { type MeasureTypes } from '../measure/preview.ts'; import outlineAnnotations, { type OutlineTypes } from '../outline/preview.ts'; +import docgenAnnotations from '../shared/open-service/services/docgen/preview.ts'; import testAnnotations, { type TestTypes } from '../test/preview.ts'; import viewportAnnotations, { type ViewportTypes } from '../viewport/preview.ts'; @@ -86,5 +87,7 @@ export function getCoreAnnotations() { (testAnnotations.default ?? testAnnotations)(), // @ts-expect-error CJS fallback (ghostStoriesAnnotations.default ?? ghostStoriesAnnotations)(), + // @ts-expect-error CJS fallback + (docgenAnnotations.default ?? docgenAnnotations)(), ]; } diff --git a/code/core/src/shared/open-service/index.ts b/code/core/src/shared/open-service/index.ts index b9c8a932b795..a476243fdfee 100644 --- a/code/core/src/shared/open-service/index.ts +++ b/code/core/src/shared/open-service/index.ts @@ -7,6 +7,9 @@ */ export { defineService } from './service-definition.ts'; +export type { DocgenService } from './services/docgen/definition.ts'; +export type { DocgenPayload } from './services/docgen/types.ts'; + export type { AnyServiceDefinition, Command, diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index e4cef536178d..b2c17c641350 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -1,3 +1,5 @@ +import type { StrictArgTypes } from 'storybook/internal/types'; + import * as v from 'valibot'; import { defineService } from 'storybook/open-service'; @@ -6,8 +8,14 @@ import type { DocgenPayload } from './types.ts'; import { docgenQueryStaticPath } from './paths.ts'; const docgenInputSchema = v.object({ id: v.string() }); +// Typed as `StrictArgTypes` (a static Storybook construct) but validated loosely: spelling out the +// full recursive valibot shape is deferred, so this only checks "is a plain object" at runtime +// while keeping the payload's `argTypes` typed for consumers. +const argTypesSchema = v.custom( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) +); -export type DocgenServiceState = { +type DocgenServiceState = { /** Extracted docgen keyed by component id. Populated by the `extractDocgen` command. */ components: Record; }; @@ -36,6 +44,7 @@ const docgenEntryBaseFields = { summary: v.optional(v.string()), import: v.optional(v.string()), jsDocTags: docgenJsDocTagsSchema, + argTypes: v.optional(argTypesSchema), error: v.optional(docgenErrorSchema), }; @@ -65,6 +74,11 @@ const docgenOutputSchema = v.optional(docgenPayloadSchema); /** * Definition for the `core/docgen` open service. * + * The service carries only provider-extracted docgen (component name, description, props, JSDoc + * tags, and the renderer-converted argTypes). Story/meta/project custom argTypes are NOT stored + * here — consumers layer those in from their own sources (the docs blocks resolve the prepared + * meta/story locally; the manager Controls panel reads them from the `STORY_PREPARED` channel). + * * The query is a thin synchronous read of `state.components[id]` — it returns undefined when * nothing has been extracted yet rather than throwing, matching the open-service convention for * sync reads. The real work — story index lookup, provider invocation, error handling — lives in @@ -84,8 +98,7 @@ export const docgenServiceDef = defineService({ description: 'Returns the docgen payload for one component id, or undefined when not loaded.', input: docgenInputSchema, output: docgenOutputSchema, - handler: (input, ctx) => - input.id in ctx.self.state.components ? ctx.self.state.components[input.id] : undefined, + handler: (input, ctx) => ctx.self.state.components[input.id], load: async (input, ctx) => { await ctx.self.commands.extractDocgen(input); }, diff --git a/code/core/src/shared/open-service/services/docgen/manager.tsx b/code/core/src/shared/open-service/services/docgen/manager.tsx new file mode 100644 index 000000000000..20edf9dde6ca --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/manager.tsx @@ -0,0 +1,12 @@ +import { addons } from 'storybook/manager-api'; + +import { registerService } from '../../manager.ts'; +import { docgenServiceDef } from './definition.ts'; + +const ADDON_ID = 'core/docgen'; + +export default addons.register(ADDON_ID, () => { + if (globalThis.FEATURES?.experimentalDocgenServer) { + registerService(docgenServiceDef); + } +}); diff --git a/code/core/src/shared/open-service/services/docgen/preview.ts b/code/core/src/shared/open-service/services/docgen/preview.ts new file mode 100644 index 000000000000..0813314a5180 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/preview.ts @@ -0,0 +1,17 @@ +import { definePreviewAddon } from 'storybook/internal/csf'; + +import type { ServiceInstanceOf } from 'storybook/open-service'; + +import { registerService } from '../../preview.ts'; +import { docgenServiceDef } from './definition.ts'; + +export type DocgenService = ServiceInstanceOf; + +export default () => + definePreviewAddon({ + beforeAll: () => { + if (globalThis.FEATURES?.experimentalDocgenServer) { + registerService(docgenServiceDef); + } + }, + }); diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index bc3ab70756a2..bbbf8f4bb16a 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -1,3 +1,4 @@ +import type { StrictArgTypes } from '../../../../types/modules/csf.ts'; import type { Options } from '../../../../types/modules/core-common.ts'; import type { IndexEntry } from '../../../../types/modules/indexer.ts'; @@ -48,6 +49,8 @@ export interface DocgenPayload { import?: string; summary?: string; jsDocTags: DocgenJsDocTags; + /** Renderer-converted argTypes derived from integration-specific docgen data at write time. */ + argTypes?: StrictArgTypes; stories: DocgenStory[]; subcomponents?: Record; error?: DocgenError; @@ -62,6 +65,8 @@ export interface DocgenSubcomponent { summary?: string; import?: string; jsDocTags: DocgenJsDocTags; + /** Renderer-converted argTypes derived from integration-specific docgen data at write time. */ + argTypes?: StrictArgTypes; error?: DocgenError; [key: string]: unknown; } diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts index 8c113783b1c8..c285c291a72f 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts @@ -44,7 +44,7 @@ describe('prop extraction via story JSX', () => { parent: { name: 'ButtonProps' }, }, disabled: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, parent: { name: 'ButtonProps' }, }, @@ -247,7 +247,7 @@ describe('prop extraction via story JSX', () => { }, props: { multiple: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, description: 'Whether multiple items can be open', defaultValue: { value: 'true' }, @@ -302,7 +302,7 @@ describe('prop extraction via story JSX', () => { parent: { name: 'PanelProps' }, }, open: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, description: 'Whether the panel is open', defaultValue: { value: 'false' }, diff --git a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts index 8c1269e0123e..1c2074c763cf 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts @@ -698,6 +698,16 @@ function serializeType( (t) => !(t.getFlags() & typescript.TypeFlags.Undefined) ); + // `boolean` is modeled as the union `true | false`. For optional props the implicit + // `| undefined` keeps this a union, so `typeToString` would yield `boolean | undefined` + // (mapped to `other` downstream). Collapse the boolean-literal pair back to `boolean`. + const booleanLiterals = nonUndefinedTypes.filter( + (t) => t.getFlags() & typescript.TypeFlags.BooleanLiteral + ); + if (booleanLiterals.length === 2 && booleanLiterals.length === nonUndefinedTypes.length) { + return { name: 'boolean' }; + } + const literalMembers = nonUndefinedTypes.filter(isLiteralType); if (literalMembers.length > 0 && literalMembers.length === nonUndefinedTypes.length) { diff --git a/code/renderers/react/src/docgen/buildDocgen.test.ts b/code/renderers/react/src/docgen/buildDocgen.test.ts index 719e198be1be..6bb50f93f3a9 100644 --- a/code/renderers/react/src/docgen/buildDocgen.test.ts +++ b/code/renderers/react/src/docgen/buildDocgen.test.ts @@ -42,7 +42,7 @@ afterEach(() => { describe('buildDocgenPayload', () => { it( - 'extracts name, description, props, and a snippet for one component', + 'extracts name, description, props, argTypes, and a snippet for one component', { timeout: 30_000 }, async () => { tempDir = createTempDir('docgen-build'); @@ -84,6 +84,14 @@ describe('buildDocgenPayload', () => { const meta = payload!.reactComponentMeta as ComponentDoc | undefined; expect(Object.keys(meta?.props ?? {}).sort()).toEqual(['disabled', 'label']); expect(meta?.props.label.required).toBe(true); + expect(payload!.argTypes?.label).toMatchObject({ + name: 'label', + type: { name: 'string', required: true }, + }); + expect(payload!.argTypes?.disabled).toMatchObject({ + name: 'disabled', + type: { name: 'boolean', required: false }, + }); expect(payload!.stories).toHaveLength(1); expect(payload!.stories?.[0]).toMatchObject({ id: expect.stringMatching(/--primary$/), @@ -155,5 +163,9 @@ describe('buildDocgenPayload', () => { | ComponentDoc | undefined; expect(Object.keys(cardHeaderMeta?.props ?? {})).toContain('level'); + expect(payload!.subcomponents?.CardHeader.argTypes?.level).toMatchObject({ + name: 'level', + type: { name: 'number', required: false }, + }); }); }); diff --git a/code/renderers/react/src/docgen/buildDocgen.ts b/code/renderers/react/src/docgen/buildDocgen.ts index 460a12f80b98..86af74d51905 100644 --- a/code/renderers/react/src/docgen/buildDocgen.ts +++ b/code/renderers/react/src/docgen/buildDocgen.ts @@ -1,16 +1,23 @@ -import type { DocgenPayload, DocgenProviderInput } from 'storybook/internal/types'; +import type { + DocgenPayload, + DocgenProviderInput, + DocgenSubcomponent, + StrictArgTypes, +} from 'storybook/internal/types'; import { getStoryImportPathFromEntry } from 'storybook/internal/common'; import path from 'pathe'; import { buildReactComponentDocgenFromResolved } from '../componentManifest/buildReactComponentDocgen.ts'; import type { ComponentMetaManager } from '../componentManifest/componentMeta/ComponentMetaManager.ts'; +import type { ComponentDoc } from '../componentManifest/componentMeta/componentMetaExtractor.ts'; import type { ComponentRef, StoryRef, TypescriptOptions, } from '../componentManifest/getComponentImports.ts'; import { resolveStoryFileComponents } from '../componentManifest/resolveComponents.ts'; +import { extractArgTypes } from '../extractArgTypes.ts'; export interface BuildDocgenContext { componentMetaManager: ComponentMetaManager; @@ -19,6 +26,47 @@ export interface BuildDocgenContext { resolvePath?: (importPath: string) => string; } +type ReactDocgenPayload = DocgenPayload & { + reactComponentMeta?: ComponentDoc; + subcomponents?: Record; +}; + +/** Converts one RCM `ComponentDoc` into the `StrictArgTypes` shape consumed by args tables. */ +function extractArgTypesFromComponentMeta( + componentMeta: ComponentDoc | undefined +): StrictArgTypes | undefined { + return componentMeta + ? (extractArgTypes({ __docgenInfo: componentMeta }) ?? undefined) + : undefined; +} + +/** + * Adds renderer-converted argTypes to the manifest-shaped React docgen payload. + * + * The service keeps the raw `reactComponentMeta` data for non-UI consumers, but UI consumers should + * read `argTypes` so they do not need to know about React-specific docgen engine output. + */ +function addArgTypesFromComponentMeta(payload: ReactDocgenPayload): DocgenPayload { + const argTypes = extractArgTypesFromComponentMeta(payload.reactComponentMeta); + const subcomponents = payload.subcomponents + ? Object.fromEntries( + Object.entries(payload.subcomponents).map(([name, subcomponent]) => [ + name, + { + ...subcomponent, + argTypes: extractArgTypesFromComponentMeta(subcomponent.reactComponentMeta), + }, + ]) + ) + : undefined; + + return { + ...payload, + ...(argTypes ? { argTypes } : {}), + ...(subcomponents ? { subcomponents } : {}), + }; +} + /** * Build a {@link DocgenPayload} for the component found in one CSF story file. * @@ -82,5 +130,5 @@ export async function buildDocgenPayload( docgenEngine: 'react-component-meta', }); - return componentDocgen; + return addArgTypesFromComponentMeta(componentDocgen); } diff --git a/scripts/build/utils/generate-bundle.ts b/scripts/build/utils/generate-bundle.ts index 780923842825..3566c96aec02 100644 --- a/scripts/build/utils/generate-bundle.ts +++ b/scripts/build/utils/generate-bundle.ts @@ -147,6 +147,7 @@ export async function generateBundle({ 'storybook/measure': './src/measure', 'storybook/actions': './src/actions', 'storybook/viewport': './src/viewport', + 'storybook/open-service': './src/shared/open-service', // The following aliases ensures that the manager has a single version of React, // even if transitive dependencies would depend on other versions. react: resolvePackageDir('react'),