Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cd3ca92
react: expose per-component RCM extraction (phase 2)
JReinhold May 28, 2026
3e7c339
simplify definition
JReinhold May 28, 2026
be76928
Merge branch 'claude/wizardly-bartik-cc2705' into claude/docgen-phase…
JReinhold May 28, 2026
c96ad49
react,core,addon-docs: real RCM-backed docgen provider (phase 3)
JReinhold May 28, 2026
1d78a97
Merge branch 'claude/wizardly-bartik-cc2705' into claude/docgen-phase…
JReinhold May 29, 2026
d47e375
Merge branch 'claude/wizardly-bartik-cc2705' into claude/docgen-phase…
JReinhold May 29, 2026
c6c6fed
Merge branch 'claude/wizardly-bartik-cc2705' into claude/docgen-phase…
JReinhold May 29, 2026
2edcc49
react,core: pre-boot TS manager + loosen docgen prop schema
JReinhold Jun 1, 2026
113f82e
react: remove unused extractDocForComponent
JReinhold Jun 1, 2026
88897cf
react: extract shared per-file component resolution
JReinhold Jun 1, 2026
0d5336b
react: preserve component @summary in docgen payload
JReinhold Jun 2, 2026
450255b
Merge commit '8715ba8ed2f' into claude/docgen-phase-3-rcm-provider
JReinhold Jun 3, 2026
db15807
Merge branch 'jeppe-cursor/docgen-subscription-referential-equality-5…
JReinhold Jun 3, 2026
d8d7323
rename manager -> componentMetaManager
JReinhold Jun 4, 2026
bbc3c07
Align docgen with manifest via shared React component docgen builder.
JReinhold Jun 4, 2026
58b8104
Fix docgen static build for componentIds without eligible index entries.
JReinhold Jun 4, 2026
9cd80c4
Share one ComponentMetaManager between docgen and manifest
JReinhold Jun 4, 2026
ffd42a2
Align docgen service output with the component manifest
JReinhold Jun 4, 2026
76c5792
Merge branch 'next' into claude/docgen-phase-3-rcm-provider
JReinhold Jun 5, 2026
3269f9f
Fix docgen server test for last-wins component entry selection
JReinhold Jun 5, 2026
1c98573
Omit empty subcomponents from manifest output
JReinhold Jun 5, 2026
3fd0cb6
Merge branch 'next' into claude/docgen-phase-3-rcm-provider
JReinhold Jun 5, 2026
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
1 change: 1 addition & 0 deletions code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const config = defineMain({
developmentModeForBuild: true,
experimentalTestSyntax: true,
experimentalDocgenServer: true,
experimentalReactComponentMeta: true,
changeDetection: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
Expand Down
1 change: 0 additions & 1 deletion code/addons/docs/src/docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const experimental_docgenProvider: DocgenProviderPreset = async (nextDocg
description: downstream.description
? `${downstream.description} (docs enabled)`
: 'docs enabled',
props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }],
};
};
};
1 change: 1 addition & 0 deletions code/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export * from './utils/satisfies.ts';
export * from './utils/formatter.ts';
export * from './utils/get-story-id.ts';
export * from './utils/component-id.ts';
export * from './utils/select-component-entry.ts';
export * from './utils/posix.ts';
export * from './utils/sync-main-preview-addons.ts';
export * from './utils/setup-addon-in-config.ts';
Expand Down
62 changes: 62 additions & 0 deletions code/core/src/common/utils/select-component-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';

import { Tag } from '../../shared/constants/tags.ts';
import type { DocsIndexEntry, IndexEntry } from '../../types/modules/indexer.ts';

import {
getStoryImportPathFromEntry,
selectComponentEntriesByComponentId,
} from './select-component-entry.ts';

function makeStoryEntry(id: string, title = 'Comp'): IndexEntry {
return {
id,
name: 'Default',
title,
type: 'story',
subtype: 'story',
importPath: `./${title.toLowerCase()}.stories.tsx`,
};
}

describe('selectComponentEntriesByComponentId', () => {
it('prefers stories over attached docs for the same componentId', () => {
const storyEntry = makeStoryEntry('comp--default', 'Comp');
const docsEntry = {
id: 'comp--docs',
name: 'Docs',
title: 'Comp/Docs',
type: 'docs',
importPath: './comp.mdx',
storiesImports: ['./wrong.stories.tsx'],
tags: [Tag.ATTACHED_MDX, 'docs'],
} satisfies DocsIndexEntry;

const map = selectComponentEntriesByComponentId([docsEntry, storyEntry]);
expect(map.get('comp')).toEqual(storyEntry);
});

it('falls back to attached docs when no story entry exists', () => {
const docsEntry = {
id: 'comp--docs',
name: 'Docs',
title: 'Comp/Docs',
type: 'docs',
importPath: './comp.mdx',
storiesImports: ['./comp.stories.tsx'],
tags: [Tag.ATTACHED_MDX, 'docs'],
} satisfies DocsIndexEntry;

const map = selectComponentEntriesByComponentId([docsEntry]);
expect(map.get('comp')).toEqual(docsEntry);
expect(getStoryImportPathFromEntry(docsEntry)).toBe('./comp.stories.tsx');
});

it('last story entry wins when multiple files share a componentId', () => {
const first = { ...makeStoryEntry('comp--a', 'Comp'), importPath: './comp-a.stories.tsx' };
const second = { ...makeStoryEntry('comp--b', 'Comp'), importPath: './comp-b.stories.tsx' };

const map = selectComponentEntriesByComponentId([first, second]);
expect(map.get('comp')).toEqual(second);
});
});
68 changes: 68 additions & 0 deletions code/core/src/common/utils/select-component-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Tag } from '../../shared/constants/tags.ts';
import type { DocsIndexEntry, IndexEntry } from '../../types/modules/indexer.ts';

import { getComponentIdFromEntry } from './component-id.ts';

/**
* Filename test for CSF story files (e.g. `Button.stories.tsx`, `stories.ts`). Single source of
* truth shared by the CSF indexer and the React docgen provider so both agree on which files count
* as story files. Has no `g` flag, so the shared instance is safe to reuse across `.test()` calls.
*/
export const STORY_FILE_TEST_REGEXP = /(stories|story)\.(m?js|ts)x?$/;

function isAttachedDocsEntry(
entry: IndexEntry
): entry is DocsIndexEntry & { storiesImports: [string, ...string[]] } {
return (
entry.type === 'docs' &&
entry.tags?.includes(Tag.ATTACHED_MDX) === true &&
entry.storiesImports.length > 0
);
}

function isEligibleStoryEntry(entry: IndexEntry): boolean {
return entry.type === 'story' && entry.subtype === 'story';
}

/**
* CSF story file path used for component resolution — the story entry's `importPath`, or the first
* `storiesImports` entry for attached MDX docs (same rule as the React component manifest generator).
*/
export function getStoryImportPathFromEntry(entry: IndexEntry): string | undefined {
if (entry.type === 'story') {
return entry.importPath;
}
if (isAttachedDocsEntry(entry)) {
return entry.storiesImports[0];
}
return undefined;
}

/**
* Picks one index entry per componentId: story entries win; attached docs fill gaps only where no
* story exists for that componentId.
*/
export function selectComponentEntriesByComponentId(
indexEntries: IndexEntry[]
): Map<string, IndexEntry> {
const entriesByComponentId = new Map<string, IndexEntry>();

for (const entry of indexEntries) {
if (!isEligibleStoryEntry(entry)) {
continue;
}
entriesByComponentId.set(getComponentIdFromEntry(entry), entry);
}

for (const entry of indexEntries) {
if (!isAttachedDocsEntry(entry)) {
continue;
}
const componentId = getComponentIdFromEntry(entry);
if (!entriesByComponentId.has(componentId)) {
entriesByComponentId.set(componentId, entry);
}
}

return entriesByComponentId;
}
8 changes: 6 additions & 2 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';

import type { Channel } from 'storybook/internal/channels';
import { normalizeStories, optionalEnvToBoolean } from 'storybook/internal/common';
import {
STORY_FILE_TEST_REGEXP,
normalizeStories,
optionalEnvToBoolean,
} from 'storybook/internal/common';
import {
JsPackageManagerFactory,
type RemoveAddonOptions,
Expand Down Expand Up @@ -224,7 +228,7 @@ export const features: PresetProperty<'features'> = async (existing) => ({
});

export const csfIndexer: Indexer = {
test: /(stories|story)\.(m?js|ts)x?$/,
test: STORY_FILE_TEST_REGEXP,
createIndex: async (fileName, options) => {
const code = (await readFile(fileName, 'utf-8')).toString();
if (code.trim().length === 0) {
Expand Down
80 changes: 49 additions & 31 deletions code/core/src/shared/open-service/services/docgen/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,66 @@ import * as v from 'valibot';
import { defineService } from '../../service-definition.ts';
import type { DocgenPayload } from './types.ts';

/** Caller-facing input to the `getDocgen` query and the `extractDocgen` command. */
export const docgenInputSchema = v.object({ componentId: v.string() });
const docgenInputSchema = v.object({ componentId: v.string() });

/**
* Phase-1 docgen payload schema.
*
* `props` is intentionally a permissive `array(unknown)` slot so the service can ship before its
* real shape is designed in phase 3 (RCM-backed extraction).
*/
export const docgenPayloadSchema = v.object({
componentId: v.string(),
export type DocgenServiceState = {
/** Extracted docgen keyed by componentId. Populated by the `extractDocgen` command. */
components: Record<string, DocgenPayload | undefined>;
};

const docgenErrorSchema = v.object({
name: v.string(),
description: v.string(),
props: v.array(v.unknown()),
message: v.string(),
});

/** Output of `getDocgen` — undefined when the component has not been extracted yet. */
export const docgenOutputSchema = v.optional(docgenPayloadSchema);
const docgenJsDocTagsSchema = v.record(v.string(), v.array(v.string()));

// Compile-time guard that the schema's inferred output matches the published DocgenPayload type.
// If a future schema change diverges from the public type the file will fail typecheck here, so
// the two definitions stay in lockstep without a runtime duplication.
type _DocgenPayloadShapeMatches =
DocgenPayload extends v.InferOutput<typeof docgenPayloadSchema>
? v.InferOutput<typeof docgenPayloadSchema> extends DocgenPayload
? true
: never
: never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _assertDocgenPayloadShapeMatches: _DocgenPayloadShapeMatches = true;
// Props are deliberately untyped: their shape is integration-specific (react-docgen-typescript,
// vue-docgen, etc. all differ). See DocgenPayload in ./types.ts for the rationale.
const docgenPropsSchema = v.array(v.unknown());

export type DocgenServiceState = {
/** Extracted docgen keyed by componentId. Populated by the `extractDocgen` command. */
components: Record<string, DocgenPayload | undefined>;
};
const docgenStorySchema = v.object({
id: v.string(),
name: v.string(),
snippet: v.optional(v.string()),
description: v.optional(v.string()),
summary: v.optional(v.string()),
error: v.optional(docgenErrorSchema),
});

const docgenSubcomponentSchema = v.object({
name: v.string(),
path: v.string(),
description: v.optional(v.string()),
summary: v.optional(v.string()),
import: v.optional(v.string()),
jsDocTags: v.optional(docgenJsDocTagsSchema),
props: docgenPropsSchema,
error: v.optional(docgenErrorSchema),
});

const docgenPayloadSchema = v.object({
componentId: v.string(),
name: v.string(),
path: v.string(),
description: v.string(),
import: v.optional(v.string()),
summary: v.optional(v.string()),
jsDocTags: v.optional(docgenJsDocTagsSchema),
props: docgenPropsSchema,
subcomponents: v.optional(v.record(v.string(), docgenSubcomponentSchema)),
stories: v.optional(v.array(docgenStorySchema)),
error: v.optional(docgenErrorSchema),
});

const docgenOutputSchema = v.optional(docgenPayloadSchema);

/**
* Definition for the `core/docgen` open service.
*
* The query is a thin synchronous read of `state.components[componentId]` — 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, extractor invocation, error handling —
* for sync reads. The real work — story index lookup, provider invocation, error handling —
* lives in the `extractDocgen` command, whose body is supplied at registration time because it
* needs to close over the server-only story index and the composed `experimental_docgenProvider`
* chain. The query's `load` hook (also supplied at registration) just calls `extractDocgen`, so
Expand All @@ -67,7 +85,7 @@ export const docgenServiceDef = defineService({
commands: {
extractDocgen: {
description:
'Resolves story entries for a componentId, runs the registered provider chain, writes the result into state, and returns it (or undefined when no provider produced docgen).',
'Resolves the story entry for a componentId, runs the registered provider chain, writes the result into state, and returns it (or undefined when no provider produced docgen).',
input: docgenInputSchema,
output: docgenOutputSchema,
// Handler is supplied at registration time so it can close over the story index and the
Expand Down
Loading