Skip to content
Merged
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 code/core/src/core-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions code/core/src/shared/open-service/services/story-docs/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<x-button>' } },
};

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 <T>(key: string, fallback: T): Promise<T> =>
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<StoryDocsPayload | undefined>;
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'
);
});
});
71 changes: 71 additions & 0 deletions code/core/src/shared/open-service/services/story-docs/provider.ts
Original file line number Diff line number Diff line change
@@ -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<StoryDocsPayload | undefined>;
}

/**
* 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<StoryDocsProvider> => {
const descriptors = await options.presets.apply<DocgenProviderDescriptor[]>(
'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 };
};
};
}
5 changes: 2 additions & 3 deletions code/frameworks/angular-vite/src/docgen/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,9 +21,7 @@ export const experimental_docgenProvider = async (
const features = await options?.presets?.apply('features', {});

const descriptor: DocgenProviderDescriptor<AngularDocgenOptions> = {
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),
},
Expand Down
31 changes: 9 additions & 22 deletions code/frameworks/angular-vite/src/docgen/story-docs-preset.ts
Original file line number Diff line number Diff line change
@@ -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<AngularDocgenPayload | undefined> => {
Expand All @@ -28,19 +26,8 @@ const getDocgenPayload = async (componentId: string): Promise<AngularDocgenPaylo
}
};

export const experimental_storyDocsProvider: StoryDocsProviderPreset = async (nextStoryDocs) => {
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 }),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DOCGEN_WORKER_SPECIFIER = '@storybook/angular-vite/internal/docgen-worker';
37 changes: 7 additions & 30 deletions code/renderers/svelte/src/docgen/story-docs-provider.ts
Original file line number Diff line number Diff line change
@@ -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<DocgenProviderDescriptor[]>(
'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),
});
44 changes: 6 additions & 38 deletions code/renderers/vue3/src/docgen/story-docs-provider.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,15 @@
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';

/**
* 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<DocgenProviderDescriptor[]>(
'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),
});
Loading
Loading