Skip to content
Merged
1 change: 1 addition & 0 deletions code/core/scripts/generate-source-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/core-server/presets/common-manager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +11,7 @@ import outlineManager from '../../outline/manager.tsx';
import viewportManager from '../../viewport/manager.tsx';

export default [
docgenManager,
measureManager,
actionsManager,
backgroundsManager,
Expand Down
3 changes: 3 additions & 0 deletions code/core/src/csf/core-annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)(),
];
}
3 changes: 3 additions & 0 deletions code/core/src/shared/open-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions code/core/src/shared/open-service/services/docgen/definition.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { StrictArgTypes } from 'storybook/internal/types';

import * as v from 'valibot';

import { defineService } from 'storybook/open-service';
Expand All @@ -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<StrictArgTypes>(
(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<string, DocgenPayload>;
};
Expand Down Expand Up @@ -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),
};

Expand Down Expand Up @@ -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
Expand All @@ -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);
},
Expand Down
12 changes: 12 additions & 0 deletions code/core/src/shared/open-service/services/docgen/manager.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
});
17 changes: 17 additions & 0 deletions code/core/src/shared/open-service/services/docgen/preview.ts
Original file line number Diff line number Diff line change
@@ -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<typeof docgenServiceDef>;

export default () =>
definePreviewAddon({
beforeAll: () => {
if (globalThis.FEATURES?.experimentalDocgenServer) {
registerService(docgenServiceDef);
}
},
});
5 changes: 5 additions & 0 deletions code/core/src/shared/open-service/services/docgen/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, DocgenSubcomponent>;
error?: DocgenError;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 13 additions & 1 deletion code/renderers/react/src/docgen/buildDocgen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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$/),
Expand Down Expand Up @@ -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 },
});
});
});
52 changes: 50 additions & 2 deletions code/renderers/react/src/docgen/buildDocgen.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,6 +26,47 @@ export interface BuildDocgenContext {
resolvePath?: (importPath: string) => string;
}

type ReactDocgenPayload = DocgenPayload & {
reactComponentMeta?: ComponentDoc;
subcomponents?: Record<string, DocgenSubcomponent & { reactComponentMeta?: ComponentDoc }>;
};

/** 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.
*
Expand Down Expand Up @@ -82,5 +130,5 @@ export async function buildDocgenPayload(
docgenEngine: 'react-component-meta',
});

return componentDocgen;
return addArgTypesFromComponentMeta(componentDocgen);
}
1 change: 1 addition & 0 deletions scripts/build/utils/generate-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down