From 662191e52a4498ebe26ebe7f812f1e368ab3a3b6 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 8 Oct 2026 15:57:41 +0200 Subject: [PATCH 1/2] refactor(core): share the worker-gated story-docs provider --- code/core/src/core-server/index.ts | 4 + .../services/story-docs/README.md | 7 + .../services/story-docs/provider.test.ts | 123 ++++++++++++++++++ .../services/story-docs/provider.ts | 71 ++++++++++ .../svelte/src/docgen/story-docs-provider.ts | 37 +----- .../vue3/src/docgen/story-docs-provider.ts | 44 +------ .../src/docgen/story-docs-preset.ts | 49 +------ 7 files changed, 224 insertions(+), 111 deletions(-) create mode 100644 code/core/src/shared/open-service/services/story-docs/provider.test.ts create mode 100644 code/core/src/shared/open-service/services/story-docs/provider.ts diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index 0b816bc23cbb..44dbda3e8a1e 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -55,6 +55,10 @@ export { listServices, registerService as experimental_registerService, } from '../shared/open-service/server.ts'; +export { + createWorkerGatedStoryDocsProvider, + type WorkerGatedStoryDocsProviderOptions, +} from '../shared/open-service/services/story-docs/provider.ts'; /** * Toolset registration and server-side primitives for addons that host a public toolset. diff --git a/code/core/src/shared/open-service/services/story-docs/README.md b/code/core/src/shared/open-service/services/story-docs/README.md index f68f9c210874..2a46d02b5824 100644 --- a/code/core/src/shared/open-service/services/story-docs/README.md +++ b/code/core/src/shared/open-service/services/story-docs/README.md @@ -8,6 +8,13 @@ When `docgenServer` is enabled, the preview `storyDocsSourceBeforeEach` hook emi snippets to the manager Code panel via `SNIPPET_RENDERED`, replacing renderer `jsxDecorator` while preserving `parameters.docs.source.transform` handling in preview. +## Providers + +Renderers contribute payloads through the `experimental_storyDocsProvider` preset chain. +A renderer whose snippets depend on its own docgen worker builds that preset with `createWorkerGatedStoryDocsProvider` from `storybook/internal/core-server`. +The helper skips the renderer when its worker is not registered, reads only matching story files, logs a file it cannot read at debug level and hands it to the next provider, and merges its payload over the next provider's. +Vue, Svelte and Web Components use it; React and Angular have no worker gate and keep their own presets. + ## Components spread over several story files CSF files that share a `title` share a component id. The service extracts each of those files diff --git a/code/core/src/shared/open-service/services/story-docs/provider.test.ts b/code/core/src/shared/open-service/services/story-docs/provider.test.ts new file mode 100644 index 000000000000..a7db1225cdde --- /dev/null +++ b/code/core/src/shared/open-service/services/story-docs/provider.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { logger } from 'storybook/internal/node-logger'; + +import type { Options } from '../../../../types/modules/core-common.ts'; +import type { IndexEntry } from '../../../../types/modules/indexer.ts'; +import { createWorkerGatedStoryDocsProvider } from './provider.ts'; +import type { StoryDocsPayload, StoryDocsProviderInput } from './types.ts'; + +vi.mock('storybook/internal/node-logger', { spy: true }); + +const WORKER = '/renderer/dist/docgen-worker.js'; + +const DOWNSTREAM: StoryDocsPayload = { + id: 'button', + name: 'Button', + path: './Button.stories.ts', + import: "import { Button } from './Button';", + stories: {}, +}; + +const OURS: StoryDocsPayload = { + id: 'button', + name: 'Button', + path: './Button.stories.ts', + stories: { 'button--primary': { id: 'button--primary', name: 'Primary', snippet: '' } }, +}; + +const entryFor = (importPath: string): IndexEntry => ({ + id: 'button--primary', + name: 'Primary', + title: 'Button', + type: 'story', + subtype: 'story', + importPath, +}); + +const optionsWith = (workers: string[]): Options => + ({ + presets: { + apply: async (key: string, fallback: T): Promise => + key === 'experimental_docgenProvider' + ? (workers.map((moduleSpecifier) => ({ moduleSpecifier })) as T) + : fallback, + }, + }) as Options; + +beforeEach(() => { + vi.mocked(logger.debug).mockImplementation(() => {}); +}); + +describe('createWorkerGatedStoryDocsProvider', () => { + it('returns the next provider unchanged when its docgen worker is not registered', async () => { + const next = vi.fn(async () => DOWNSTREAM); + const preset = createWorkerGatedStoryDocsProvider({ + label: 'Test', + docgenWorker: WORKER, + build: async () => OURS, + }); + + await expect(preset(next, optionsWith(['/other/docgen-worker.js']))).resolves.toBe(next); + }); + + it.each<{ + name: string; + importPath: string; + storyFileTest?: RegExp; + build: (input: StoryDocsProviderInput) => Promise; + expected: StoryDocsPayload; + }>([ + { + name: 'merges its payload over the next provider', + importPath: './Button.stories.ts', + build: async () => OURS, + expected: { ...OURS, import: DOWNSTREAM.import }, + }, + { + name: 'hands the story on when the build returns nothing', + importPath: './Button.stories.ts', + build: async () => undefined, + expected: DOWNSTREAM, + }, + { + name: 'hands the story on when the file is not a CSF story file', + importPath: './Button.mdx', + build: async () => OURS, + expected: DOWNSTREAM, + }, + { + name: 'uses a renderer-specific story file pattern', + importPath: './Button.stories.svelte', + storyFileTest: /\.stories\.svelte$/, + build: async () => OURS, + expected: { ...OURS, import: DOWNSTREAM.import }, + }, + ])('$name', async ({ importPath, storyFileTest, build, expected }) => { + const preset = createWorkerGatedStoryDocsProvider({ + label: 'Test', + docgenWorker: WORKER, + storyFileTest, + build, + }); + const provider = await preset(async () => DOWNSTREAM, optionsWith([WORKER])); + + await expect(provider({ entry: entryFor(importPath) })).resolves.toEqual(expected); + }); + + it('logs a story file it cannot read and hands it to the next provider', async () => { + const preset = createWorkerGatedStoryDocsProvider({ + label: 'Test', + docgenWorker: WORKER, + build: async () => { + throw new Error('parse failed'); + }, + }); + const provider = await preset(async () => DOWNSTREAM, optionsWith([WORKER])); + + await expect(provider({ entry: entryFor('./Button.stories.ts') })).resolves.toBe(DOWNSTREAM); + expect(logger.debug).toHaveBeenCalledWith( + 'Test story snippets are unavailable for ./Button.stories.ts: parse failed' + ); + }); +}); diff --git a/code/core/src/shared/open-service/services/story-docs/provider.ts b/code/core/src/shared/open-service/services/story-docs/provider.ts new file mode 100644 index 000000000000..8d6a9124c852 --- /dev/null +++ b/code/core/src/shared/open-service/services/story-docs/provider.ts @@ -0,0 +1,71 @@ +import { logger } from 'storybook/internal/node-logger'; + +import { + STORY_FILE_TEST_REGEXP, + getStoryImportPathFromEntry, +} from '../../../../common/utils/select-component-entry.ts'; +import type { DocgenProviderDescriptor } from '../docgen/types.ts'; +import type { + StoryDocsPayload, + StoryDocsProvider, + StoryDocsProviderInput, + StoryDocsProviderPreset, +} from './types.ts'; + +export interface WorkerGatedStoryDocsProviderOptions { + /** Name shown in the debug log when a story file cannot be read, such as `Vue`. */ + label: string; + /** Absolute path of the docgen worker module that must be registered for this provider to run. */ + docgenWorker: string; + /** Story files this provider reads. Defaults to CSF story files. */ + storyFileTest?: RegExp; + /** Reads one story file; `undefined` hands the story to the next provider. */ + build: (input: StoryDocsProviderInput) => Promise; +} + +/** + * Create an `experimental_storyDocsProvider` preset that only runs when the renderer's docgen worker + * is registered, and merges its payload over the next provider's. + * + * A story file the provider cannot read falls back to the next provider and is logged at debug level. + */ +export function createWorkerGatedStoryDocsProvider({ + label, + docgenWorker, + storyFileTest = STORY_FILE_TEST_REGEXP, + build, +}: WorkerGatedStoryDocsProviderOptions): StoryDocsProviderPreset { + return async (nextStoryDocs, options): Promise => { + const descriptors = await options.presets.apply( + 'experimental_docgenProvider', + [] + ); + if (!descriptors.some((descriptor) => descriptor.moduleSpecifier === docgenWorker)) { + return nextStoryDocs; + } + + return async (input) => { + const storyImportPath = getStoryImportPathFromEntry(input.entry); + if (!storyImportPath || !storyFileTest.test(storyImportPath)) { + return nextStoryDocs(input); + } + + let ours: StoryDocsPayload | undefined; + try { + ours = await build(input); + } catch (error) { + logger.debug( + `${label} story snippets are unavailable for ${storyImportPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return nextStoryDocs(input); + } + + if (!ours) { + return nextStoryDocs(input); + } + return { ...(await nextStoryDocs(input)), ...ours }; + }; + }; +} diff --git a/code/renderers/svelte/src/docgen/story-docs-provider.ts b/code/renderers/svelte/src/docgen/story-docs-provider.ts index 06c8308ec8c5..a18c1b7c9764 100644 --- a/code/renderers/svelte/src/docgen/story-docs-provider.ts +++ b/code/renderers/svelte/src/docgen/story-docs-provider.ts @@ -1,37 +1,14 @@ import { fileURLToPath } from 'node:url'; -import { getStoryImportPathFromEntry } from 'storybook/internal/common'; -import type { DocgenProviderDescriptor, StoryDocsProviderPreset } from 'storybook/internal/types'; +import { createWorkerGatedStoryDocsProvider } from 'storybook/internal/core-server'; import { buildStoryDocsPayload } from './story-docs/build-story-docs.ts'; import { SVELTE_STORY_FILE_TEST_REGEXP } from './story-file.ts'; import { DOCGEN_WORKER_SPECIFIER } from './worker-specifier.ts'; -export const experimental_storyDocsProvider: StoryDocsProviderPreset = async ( - nextStoryDocs, - options -) => { - const descriptors = await options.presets.apply( - 'experimental_docgenProvider', - [] - ); - const svelteWorker = fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)); - - if (!descriptors.some((descriptor) => descriptor.moduleSpecifier === svelteWorker)) { - return nextStoryDocs; - } - - return async (input) => { - const storyImportPath = getStoryImportPathFromEntry(input.entry); - if (!storyImportPath || !SVELTE_STORY_FILE_TEST_REGEXP.test(storyImportPath)) { - return nextStoryDocs(input); - } - - const ours = await buildStoryDocsPayload(input); - if (!ours) { - return nextStoryDocs(input); - } - - return { ...(await nextStoryDocs(input)), ...ours }; - }; -}; +export const experimental_storyDocsProvider = createWorkerGatedStoryDocsProvider({ + label: 'Svelte', + docgenWorker: fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)), + storyFileTest: SVELTE_STORY_FILE_TEST_REGEXP, + build: (input) => buildStoryDocsPayload(input), +}); diff --git a/code/renderers/vue3/src/docgen/story-docs-provider.ts b/code/renderers/vue3/src/docgen/story-docs-provider.ts index ca1b30d85884..b81e590a07e7 100644 --- a/code/renderers/vue3/src/docgen/story-docs-provider.ts +++ b/code/renderers/vue3/src/docgen/story-docs-provider.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'node:url'; -import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; -import type { DocgenProviderDescriptor, StoryDocsProviderPreset } from 'storybook/internal/types'; +import { createWorkerGatedStoryDocsProvider } from 'storybook/internal/core-server'; import { DOCGEN_WORKER_SPECIFIER } from './worker-specifier.ts'; import { buildStoryDocsPayload } from './story-docs/build-story-docs.ts'; @@ -9,39 +8,8 @@ import { buildStoryDocsPayload } from './story-docs/build-story-docs.ts'; /** * Vue renderer story-docs provider, enabled only when Vue's docgen worker is active. */ -export const experimental_storyDocsProvider: StoryDocsProviderPreset = async ( - nextStoryDocs, - options -) => { - const descriptors = await options.presets.apply( - 'experimental_docgenProvider', - [] - ); - const vueWorker = fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)); - const active = descriptors.some((descriptor) => descriptor.moduleSpecifier === vueWorker); - - if (!active) { - return nextStoryDocs; - } - - return async (input) => { - const storyImportPath = getStoryImportPathFromEntry(input.entry); - if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { - return nextStoryDocs(input); - } - - let ours; - try { - ours = await buildStoryDocsPayload(input); - } catch { - return nextStoryDocs(input); - } - - if (!ours) { - return nextStoryDocs(input); - } - - const downstream = await nextStoryDocs(input); - return { ...downstream, ...ours }; - }; -}; +export const experimental_storyDocsProvider = createWorkerGatedStoryDocsProvider({ + label: 'Vue', + docgenWorker: fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)), + build: (input) => buildStoryDocsPayload(input), +}); diff --git a/code/renderers/web-components/src/docgen/story-docs-preset.ts b/code/renderers/web-components/src/docgen/story-docs-preset.ts index ecc5777a6387..8a5ee86d1ce9 100644 --- a/code/renderers/web-components/src/docgen/story-docs-preset.ts +++ b/code/renderers/web-components/src/docgen/story-docs-preset.ts @@ -1,9 +1,7 @@ import { fileURLToPath } from 'node:url'; -import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; -import { getService } from 'storybook/internal/core-server'; +import { createWorkerGatedStoryDocsProvider, getService } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; -import type { DocgenProviderDescriptor, StoryDocsProviderPreset } from 'storybook/internal/types'; import type { WebComponentsDocgenPayload } from './component-docgen/build-docgen.ts'; import { DOCGEN_WORKER_SPECIFIER } from './worker-specifier.ts'; @@ -30,43 +28,8 @@ const getDocgenPayload = async ( } }; -export const experimental_storyDocsProvider: StoryDocsProviderPreset = async ( - nextStoryDocs, - options -) => { - const descriptors = await options.presets.apply( - 'experimental_docgenProvider', - [] - ); - const worker = fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)); - const active = descriptors.some((descriptor) => descriptor.moduleSpecifier === worker); - - if (!active) { - return nextStoryDocs; - } - - return async (input) => { - const storyImportPath = getStoryImportPathFromEntry(input.entry); - if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { - return nextStoryDocs(input); - } - - let ours; - try { - ours = await buildStoryDocsPayload(input, { getDocgenPayload }); - } catch (error) { - logger.debug( - `Web Components story snippets are unavailable for ${storyImportPath}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - return nextStoryDocs(input); - } - - if (!ours) { - return nextStoryDocs(input); - } - const downstream = await nextStoryDocs(input); - return { ...downstream, ...ours }; - }; -}; +export const experimental_storyDocsProvider = createWorkerGatedStoryDocsProvider({ + label: 'Web Components', + docgenWorker: fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)), + build: (input) => buildStoryDocsPayload(input, { getDocgenPayload }), +}); From 858ab1999910cf5a5371a30ce7c8b6dd2ea1c581 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Fri, 9 Oct 2026 14:52:52 +0200 Subject: [PATCH 2/2] refactor: apply to angular --- .../angular-vite/src/docgen/preset.ts | 5 ++- .../src/docgen/story-docs-preset.ts | 31 ++++++------------- .../src/docgen/worker-specifier.ts | 1 + 3 files changed, 12 insertions(+), 25 deletions(-) create mode 100644 code/frameworks/angular-vite/src/docgen/worker-specifier.ts diff --git a/code/frameworks/angular-vite/src/docgen/preset.ts b/code/frameworks/angular-vite/src/docgen/preset.ts index 3ddab4a4d786..891eac1f9113 100644 --- a/code/frameworks/angular-vite/src/docgen/preset.ts +++ b/code/frameworks/angular-vite/src/docgen/preset.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'; import { resolvePropsTable } from '../props-table.ts'; import type { AngularDocgenOptions } from './build-docgen.ts'; +import { DOCGEN_WORKER_SPECIFIER } from './worker-specifier.ts'; /** Contribute the descriptor for the worker module core imports and runs off the main thread. */ export const experimental_docgenProvider = async ( @@ -20,9 +21,7 @@ export const experimental_docgenProvider = async ( const features = await options?.presets?.apply('features', {}); const descriptor: DocgenProviderDescriptor = { - moduleSpecifier: fileURLToPath( - import.meta.resolve('@storybook/angular-vite/internal/docgen-worker') - ), + moduleSpecifier: fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)), options: { propsTable: resolvePropsTable(await options?.presets?.apply('frameworkOptions'), features), }, diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts index a8cae8bd0e2e..e1cb9eefc4bd 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts @@ -1,14 +1,12 @@ -import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; -import { getService } from 'storybook/internal/core-server'; +import { fileURLToPath } from 'node:url'; + +import { createWorkerGatedStoryDocsProvider, getService } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; -import type { StoryDocsProviderPreset } from 'storybook/internal/types'; import type { AngularDocgenPayload } from './build-docgen.ts'; import { buildStoryDocsPayload } from './story-docs-build.ts'; +import { DOCGEN_WORKER_SPECIFIER } from './worker-specifier.ts'; -// `core/docgen` is only registered when `docgenServer` set up its worker (see -// `common-preset.ts`); both services are gated by the same feature, but registration order isn't -// a type-level guarantee, so this stays defensive rather than asserting the service exists. let warnedMissingDocgenService = false; const getDocgenPayload = async (componentId: string): Promise => { @@ -28,19 +26,8 @@ const getDocgenPayload = async (componentId: string): Promise { - return async (input) => { - const storyImportPath = getStoryImportPathFromEntry(input.entry); - if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { - return nextStoryDocs(input); - } - - const ours = await buildStoryDocsPayload(input, { getDocgenPayload }); - - if (!ours) { - return nextStoryDocs(input); - } - const downstream = await nextStoryDocs(input); - return { ...downstream, ...ours }; - }; -}; +export const experimental_storyDocsProvider = createWorkerGatedStoryDocsProvider({ + label: 'Angular', + docgenWorker: fileURLToPath(import.meta.resolve(DOCGEN_WORKER_SPECIFIER)), + build: (input) => buildStoryDocsPayload(input, { getDocgenPayload }), +}); diff --git a/code/frameworks/angular-vite/src/docgen/worker-specifier.ts b/code/frameworks/angular-vite/src/docgen/worker-specifier.ts new file mode 100644 index 000000000000..c7e28c836a82 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/worker-specifier.ts @@ -0,0 +1 @@ +export const DOCGEN_WORKER_SPECIFIER = '@storybook/angular-vite/internal/docgen-worker';