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
1 change: 1 addition & 0 deletions code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ const config = defineMain({
features: {
developmentModeForBuild: true,
experimentalTestSyntax: true,
experimentalDocgenServer: true,
changeDetection: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
Expand Down
24 changes: 24 additions & 0 deletions code/addons/docs/src/docgen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { DocgenProviderPreset } from 'storybook/internal/types';

/**
* Addon-docs docgen provider.
*
* A small enrichment layer: appends a `(docs enabled)` marker to the downstream description so
* consumers can tell that addon-docs participated. Does NOT produce docgen on its own — when no
* downstream provider supplied a payload it returns undefined so the chain falls through.
*/
export const experimental_docgenProvider: DocgenProviderPreset = async (nextDocgen) => {
return async (input) => {
const downstream = await nextDocgen(input);
if (!downstream) {
return undefined;
}
return {
...downstream,
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/addons/docs/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,4 @@ const optimizeViteDeps = [

export { webpackX as webpack, docsX as docs, optimizeViteDeps };
export { manifests as experimental_manifests } from './manifest';
export { experimental_docgenProvider } from './docgen';
1 change: 1 addition & 0 deletions code/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export * from './utils/validate-configuration-files.ts';
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/posix.ts';
export * from './utils/sync-main-preview-addons.ts';
export * from './utils/setup-addon-in-config.ts';
Expand Down
13 changes: 13 additions & 0 deletions code/core/src/common/utils/component-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { IndexEntry } from 'storybook/internal/types';

/**
* Derives the componentId portion of a story index entry id.
*
* Storybook story ids have the shape `<componentId>--<storyName>`; the prefix before the first
* `--` is the stable component identifier shared by every story (and attached docs entry) that
* targets the same component. Centralising the split keeps the docgen service, manifest generator,
* and any future consumers on one definition.
*/
export function getComponentIdFromEntry(entry: Pick<IndexEntry, 'id'>): string {
return entry.id.split('--')[0];
}
11 changes: 9 additions & 2 deletions code/core/src/core-server/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import { global } from '@storybook/global';
import { join, relative, resolve } from 'pathe';
import picocolors from 'picocolors';

import {
getRegisteredServices,
writeOpenServiceStaticFiles,
} from '../shared/open-service/server.ts';
import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts';
import { writeOpenServiceStaticFiles } from '../shared/open-service/server.ts';
import { resolvePackageDir } from '../shared/utils/module.ts';
import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts';
import { buildOrThrow } from './utils/build-or-throw.ts';
Expand Down Expand Up @@ -147,7 +150,11 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption

const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser');
effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true }));
effects.push(writeOpenServiceStaticFiles(options.outputDir));

if (getRegisteredServices().length > 0) {
logger.info('Building open services..');
effects.push(writeOpenServiceStaticFiles(options.outputDir));
}

let storyIndexGeneratorPromise: Promise<StoryIndexGenerator | undefined> =
Promise.resolve(undefined);
Expand Down
34 changes: 33 additions & 1 deletion code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import { logger } from 'storybook/internal/node-logger';
import { telemetry } from 'storybook/internal/telemetry';
import type {
CoreConfig,
DocgenProvider,
Indexer,
Options,
PresetProperty,
PresetPropertyFn,
StorybookConfigRaw,
} from 'storybook/internal/types';

import { registerDocgenService } from '../../shared/open-service/services/docgen/server.ts';

import { isAbsolute, join } from 'pathe';
import * as pathe from 'pathe';
import { dedent } from 'ts-dedent';
Expand Down Expand Up @@ -311,13 +314,42 @@ export const managerEntries = async (existing: any) => {
};

globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false;
export const services = async () => {

export const services = async (_value: void, options: Options): Promise<void> => {
if (globalThis.STORYBOOK_SERVICES_LOADED) {
throw new Error(
'The "services" preset property was applied twice, but should only be applied once. Multiple code paths applying it will cause service registration to fail.'
);
}
globalThis.STORYBOOK_SERVICES_LOADED = true;

const features = await options.presets.apply('features');

// Skip when previewing is off — the docgen service's staticInputs depends on the story index,
// so registering it would force full story-index generation during manager-only builds (and
// produce docgen files that wouldn't be served anywhere). Mirrors the !options.ignorePreview
// gate around index.json and writeManifests in build-static.ts.
if (features?.experimentalDocgenServer && !options.ignorePreview) {
const generator =
await options.presets.apply<Promise<StoryIndexGenerator>>('storyIndexGenerator');

const provider = await options.presets.apply<DocgenProvider>(
'experimental_docgenProvider',
/**
* Seed provider for the experimental_docgenProvider middleware chain.
*
* Returns `undefined` so the bottom of the chain signals "no docgen here" — each upstream
* provider can either replace this with its own payload, return its own undefined, or call
* `nextDocgen` and merge with downstream output.
*/
async () => undefined
);

registerDocgenService({
getIndex: () => generator.getIndex(),
provider,
});
}
};

// Store the promise (not the result) to prevent race conditions.
Expand Down
11 changes: 11 additions & 0 deletions code/core/src/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ export class OpenServiceLoadedDrainExceededError extends StorybookError {
}
}

export class OpenServiceDocgenMissingComponentError extends StorybookError {
constructor(public data: { componentId: string }) {
super({
name: 'OpenServiceDocgenMissingComponentError',
category: Category.CORE_COMMON,
code: 12,
message: `No story or attached docs entry was found for componentId "${data.componentId}". The docgen service can only return docs for components that are present in the story index.`,
});
}
}

export class WebpackMissingStatsError extends StorybookError {
constructor() {
super({
Expand Down
9 changes: 9 additions & 0 deletions code/core/src/shared/open-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ Query handlers do **not** receive `commands` or `setState`. Mutations belong in

`load` mutations must go through commands. Cross-service `getService(...).queries.*` calls inside a load body are not auto-tracked for the drain; use `await ctx.getService(id).queries.foo.loaded(input)` when you need a cross-service dependency awaited before your own load completes.

**Keep `load` bodies as small as possible.** Almost always, `load` should be a one-liner that calls a command — the real work (input resolution, side effects, validation, state mutation) belongs in the command. This pays off for three reasons:

- **Reusability.** Anyone can call the command directly (other services, tests, integrations) without going through the query's load path. Logic stuck inside a load is unreachable from outside the drain.
- **Testability.** Commands have a typed input/output contract you can assert against. Load bodies don't return anything useful.
- **Clear contract.** A query says "read state". A command says "do work that produces state". A bloated load blurs the line and makes the service harder to reason about.

A good rule of thumb: if `load` does anything more than `await ctx.self.commands.someCommand(input)`, ask whether that "more" belongs in the command instead.

### Command

A command is:
Expand Down Expand Up @@ -376,6 +384,7 @@ const ready = await exampleService.queries.getValue.loaded({ entryId: 'a' });

- Always declare both `input` and `output` schemas on every query and command.
- Use `load` for read-side warming. The hook is async and must mutate via commands.
- **Keep `load` bodies minimal — ideally one line that calls a command.** Push input resolution, side effects, and state mutation into the command itself so it stays callable, testable, and reusable on its own.
- Query handlers are strict readers: sync, no commands, no `setState`.
- Use commands for all state mutation.
- Keep environment-agnostic imports on [index.ts](./index.ts) and server-only imports on [server.ts](./server.ts). Import internal modules directly only from tests or implementation code in this directory.
Expand Down
77 changes: 77 additions & 0 deletions code/core/src/shared/open-service/services/docgen/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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() });

/**
* 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(),
name: v.string(),
description: v.string(),
props: v.array(v.unknown()),
});

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

// 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;

export type DocgenServiceState = {
/** Extracted docgen keyed by componentId. Populated by the `extractDocgen` command. */
components: Record<string, DocgenPayload | undefined>;
};

/**
* 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 —
* 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
* `getDocgen.loaded()` is the awaitable form and surfaces extraction errors.
*/
export const docgenServiceDef = defineService({
id: 'core/docgen',
description:
'Component documentation (name, description, props, JSDoc tags) keyed by componentId.',
initialState: { components: {} } as DocgenServiceState,
queries: {
getDocgen: {
description: 'Returns the docgen payload for one componentId, or undefined when not loaded.',
input: docgenInputSchema,
output: docgenOutputSchema,
handler: (input, ctx) => ctx.self.state.components[input.componentId],
staticPath: (input) => `${input.componentId}.json`,
},
},
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).',
input: docgenInputSchema,
output: docgenOutputSchema,
// Handler is supplied at registration time so it can close over the story index and the
// composed experimental_docgenProvider chain.
},
},
});
Loading