Skip to content
Closed
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 @@ -138,6 +138,7 @@ const config = defineMain({
developmentModeForBuild: true,
experimentalTestSyntax: true,
experimentalComponentsManifest: true,
experimentalCodeExamples: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
viteFinal: async (viteConfig, { configType }) => {
Expand Down
10 changes: 9 additions & 1 deletion code/addons/docs/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';

import { logger } from 'storybook/internal/node-logger';
import type { Options, PresetProperty, StorybookConfigRaw } from 'storybook/internal/types';
import { type CsfEnricher } from 'storybook/internal/types';

import type { CsfPluginOptions } from '@storybook/csf-plugin';

Expand Down Expand Up @@ -41,6 +42,8 @@ async function webpack(

const { csfPluginOptions = {}, mdxPluginOptions = {} } = options;

const enrichCsf = await options.presets.apply('experimental_enrichCsf');

const rehypeSlug = (await import('rehype-slug')).default;
const rehypeExternalLinks = (await import('rehype-external-links')).default;

Expand Down Expand Up @@ -100,7 +103,12 @@ async function webpack(
...(webpackConfig.plugins || []),

...(csfPluginOptions
? [(await import('@storybook/csf-plugin')).webpack(csfPluginOptions)]
? [
(await import('@storybook/csf-plugin')).webpack({
...csfPluginOptions,
enrichCsf,
}),
]
: []),
],
resolve: {
Expand Down
7 changes: 6 additions & 1 deletion code/builders/builder-vite/src/plugins/csf-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export async function csfPlugin(config: Options): Promise<Plugin> {
// @ts-expect-error - not sure what type to use here
addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {};

const enrichCsf = await presets.apply('experimental_enrichCsf');

// TODO: looks like unplugin can return an array of plugins
return vite(docsOptions?.csfPluginOptions) as Plugin;
return vite({
...docsOptions?.csfPluginOptions,
enrichCsf,
}) as Plugin;
}
2 changes: 1 addition & 1 deletion code/core/src/common/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export async function getPresets(
const loadedPresets: LoadedPreset[] = await loadPresets(presets, 0, storybookOptions);

return {
apply: async (extension: string, config: any, args = {}) =>
apply: async (extension: string, config?: any, args = {}) =>
applyPresets(loadedPresets, extension, config, args, storybookOptions),
};
}
Expand Down
2 changes: 1 addition & 1 deletion code/core/src/common/utils/formatter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
async function getPrettier() {
export async function getPrettier() {
return import('prettier').catch((e) => ({
resolveConfig: async () => null,
format: (content: string) => content,
Expand Down
5 changes: 4 additions & 1 deletion code/core/src/csf-tools/enrichCsf.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { generate, types as t } from 'storybook/internal/babel';
import { type CsfEnricher } from 'storybook/internal/types';

import type { CsfFile } from './CsfFile';

export interface EnrichCsfOptions {
disableSource?: boolean;
disableDescription?: boolean;
enrichCsf?: CsfEnricher;
}

export const enrichCsfStory = (
Expand Down Expand Up @@ -139,8 +141,9 @@ export const enrichCsfMeta = (csf: CsfFile, csfSource: CsfFile, options?: Enrich
}
};

export const enrichCsf = (csf: CsfFile, csfSource: CsfFile, options?: EnrichCsfOptions) => {
export const enrichCsf = async (csf: CsfFile, csfSource: CsfFile, options?: EnrichCsfOptions) => {
enrichCsfMeta(csf, csfSource, options);
await options?.enrichCsf?.(csf, csfSource);
Object.keys(csf._storyExports).forEach((key) => {
enrichCsfStory(csf, csfSource, key, options);
});
Expand Down
14 changes: 14 additions & 0 deletions code/core/src/types/modules/core-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export interface Presets {
config?: StorybookConfigRaw['staticDirs'],
args?: any
): Promise<StorybookConfigRaw['staticDirs']>;

/** The second and third parameter are not needed. And make type inference easier. */
apply<T extends keyof StorybookConfigRaw>(extension: T): Promise<StorybookConfigRaw[T]>;
apply<T>(extension: string, config?: T, args?: unknown): Promise<T>;
}

Expand Down Expand Up @@ -359,6 +362,8 @@ export type ComponentManifestGenerator = (
storyIndexGenerator: StoryIndexGenerator
) => Promise<ComponentsManifest>;

export type CsfEnricher = (csf: CsfFile, csfSource: CsfFile) => Promise<void>;

export interface StorybookConfigRaw {
/**
* Sets the addons you want to use with Storybook.
Expand All @@ -373,6 +378,7 @@ export interface StorybookConfigRaw {
addons?: Preset[];
core?: CoreConfig;
componentManifestGenerator?: ComponentManifestGenerator;
experimental_enrichCsf?: CsfEnricher;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add JSDoc documentation for experimental configuration option.

This experimental configuration option needs documentation explaining its purpose and usage, especially since it's part of the public configuration API.

Apply this diff to add documentation:

+  /**
+   * @experimental This feature is experimental and may change in future versions.
+   * 
+   * A function to enrich CSF files before per-story enrichment. This allows for
+   * custom transformations of the CSF AST, such as generating code examples.
+   * 
+   * @example
+   * 
+   * ```ts
+   * experimental_enrichCsf: (csf, csfSource) => {
+   *   // Custom enrichment logic
+   * }
+   * ```
+   */
   experimental_enrichCsf?: CsfEnricher;
🤖 Prompt for AI Agents
In code/core/src/types/modules/core-common.ts around line 384, the
experimental_enrichCsf field lacks JSDoc; add a JSDoc block immediately above
the field that documents purpose, expected signature and parameters, shows a
short usage example matching the provided diff (function signature: (csf,
csfSource) => { /* Custom enrichment logic */ }), and mark it as experimental;
ensure formatting follows existing JSDoc style and TypeScript comment
conventions so IDEs and generated docs pick it up.

staticDirs?: (DirectoryMapping | string)[];
logLevel?: string;
features?: {
Expand Down Expand Up @@ -472,6 +478,14 @@ export interface StorybookConfigRaw {
angularFilterNonInputControls?: boolean;

experimentalComponentsManifest?: boolean;
/**
* @experimental This feature is experimental and may change in future versions.
*
* Enable experimental code examples generation for components.
*
* @default false
*/
experimentalCodeExamples?: boolean;
};

build?: TestBuildConfig;
Expand Down
2 changes: 1 addition & 1 deletion code/lib/csf-plugin/src/rollup-based-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function rollupBasedPlugin(options: EnrichCsfOptions): Partial<RollupPlug
const csfSource = loadCsf(sourceCode, {
makeTitle,
}).parse();
enrichCsf(csf, csfSource, options);
await enrichCsf(csf, csfSource, options);
const inputSourceMap = this.getCombinedSourcemap();
return formatCsf(csf, { sourceMaps: true, inputSourceMap }, code);
} catch (err: any) {
Expand Down
2 changes: 1 addition & 1 deletion code/lib/csf-plugin/src/webpack-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function loader(this: LoaderContext, content: string, map: any) {
const makeTitle = (userTitle: string) => userTitle || 'default';
const csf = loadCsf(content, { makeTitle }).parse();
const csfSource = loadCsf(sourceCode, { makeTitle }).parse();
enrichCsf(csf, csfSource, options);
await enrichCsf(csf, csfSource, options);
const formattedCsf = formatCsf(
csf,
{ sourceMaps: true, inputSourceMap: map, sourceFileName: id },
Expand Down
4 changes: 4 additions & 0 deletions code/renderers/react/src/docs/jsxDecorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { createElement, isValidElement } from 'react';
import { logger } from 'storybook/internal/client-logger';
import { SourceType, getDocgenSection } from 'storybook/internal/docs-tools';
import type { PartialStoryFn, StoryContext } from 'storybook/internal/types';
import { type StorybookConfigRaw } from 'storybook/internal/types';

import type { Options } from 'react-element-to-jsx-string';
import type reactElementToJSXStringType from 'react-element-to-jsx-string';
Expand Down Expand Up @@ -231,6 +232,9 @@ const mdxToJsx = (node: any) => {
return createElement(originalType, rest, ...jsxChildren);
};

// eslint-disable-next-line no-var
declare var FEATURES: NonNullable<StorybookConfigRaw['features']>;

export const jsxDecorator = (
storyFn: PartialStoryFn<ReactRenderer>,
context: StoryContext<ReactRenderer>
Expand Down
97 changes: 97 additions & 0 deletions code/renderers/react/src/enrichCsf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { type NodePath, recast, types as t } from 'storybook/internal/babel';
import { getPrettier } from 'storybook/internal/common';
import { type CsfFile } from 'storybook/internal/csf-tools';
import type { PresetPropertyFn } from 'storybook/internal/types';

import { join } from 'pathe';
import invariant from 'tiny-invariant';

import { getCodeSnippet } from './componentManifest/generateCodeSnippet';

export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (input, options) => {
const features = await options.presets.apply('features');
if (!features.experimentalCodeExamples) {
return;
}
return async (csf: CsfFile, csfSource: CsfFile) => {
await Promise.all(
Object.entries(csf._storyPaths).map(async ([key, storyExport]) => {
if (csfSource._meta?.component) {
const { format } = await getPrettier();

const code = recast.print(
getCodeSnippet(storyExport, csfSource._metaNode, csfSource._meta?.component)
).code;

// TODO read the user config
const snippet = await format(code, { filepath: join(process.cwd(), 'component.tsx') });

const declaration = storyExport.get('declaration') as NodePath<t.Declaration>;
invariant(declaration.isVariableDeclaration(), 'Expected variable declaration');

const declarator = declaration.get('declarations')[0] as NodePath<t.VariableDeclarator>;
const init = declarator.get('init') as NodePath<t.Expression>;
invariant(init.isExpression(), 'Expected story initializer to be an expression');

const parameters = [];
const isCsfFactory =
t.isCallExpression(init.node) &&
t.isMemberExpression(init.node.callee) &&
t.isIdentifier(init.node.callee.object) &&
init.node.callee.object.name === 'meta';

// in csf 1/2/3 use Story.parameters; CSF factories use Story.input.parameters
const baseStoryObject = isCsfFactory
? t.memberExpression(t.identifier(key), t.identifier('input'))
: t.identifier(key);

const originalParameters = t.memberExpression(
baseStoryObject,
t.identifier('parameters')
);
parameters.push(t.spreadElement(originalParameters));
const optionalDocs = t.optionalMemberExpression(
originalParameters,
t.identifier('docs'),
false,
true
);
const extraDocsParameters = [];

if (snippet) {
const optionalSource = t.optionalMemberExpression(
optionalDocs,
t.identifier('source'),
false,
true
);

extraDocsParameters.push(
t.objectProperty(
t.identifier('source'),
t.objectExpression([
t.objectProperty(t.identifier('code'), t.stringLiteral(snippet)),
t.spreadElement(optionalSource),
])
)
);
}

// docs: { description: { story: %%description%% } },
if (extraDocsParameters.length > 0) {
parameters.push(
t.objectProperty(
t.identifier('docs'),
t.objectExpression([t.spreadElement(optionalDocs), ...extraDocsParameters])
)
);
const addParameter = t.expressionStatement(
t.assignmentExpression('=', originalParameters, t.objectExpression(parameters))
);
csf._ast.program.body.push(addParameter);
}
}
})
);
};
};
8 changes: 7 additions & 1 deletion code/renderers/react/src/entry-preview-docs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { DecoratorFunction } from 'storybook/internal/types';
import type { StorybookConfigRaw } from 'storybook/internal/types';

import { jsxDecorator } from './docs/jsxDecorator';
import type { ReactRenderer } from './types';

export const decorators: DecoratorFunction<ReactRenderer>[] = [jsxDecorator];
// eslint-disable-next-line no-var
declare var FEATURES: NonNullable<StorybookConfigRaw['features']>;

export const decorators: DecoratorFunction<ReactRenderer>[] = FEATURES.experimentalCodeExamples

Check failure on line 10 in code/renderers/react/src/entry-preview-docs.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/__test__/portable-stories.test.tsx

ReferenceError: FEATURES is not defined ❯ src/entry-preview-docs.ts:10:63 ❯ src/preview.tsx:20:1

Check failure on line 10 in code/renderers/react/src/entry-preview-docs.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/__test__/portable-stories-legacy.test.tsx

ReferenceError: FEATURES is not defined ❯ src/entry-preview-docs.ts:10:63 ❯ src/preview.tsx:20:1

Check failure on line 10 in code/renderers/react/src/entry-preview-docs.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/__test__/portable-stories-factory.test.tsx

ReferenceError: FEATURES is not defined ❯ src/entry-preview-docs.ts:10:63 ❯ src/preview.tsx:20:1

Check failure on line 10 in code/renderers/react/src/entry-preview-docs.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/csf-factories.test.tsx

ReferenceError: FEATURES is not defined ❯ src/entry-preview-docs.ts:10:63 ❯ src/preview.tsx:20:1
? []
: [jsxDecorator];

export { applyDecorators } from './docs/applyDecorators';

Expand Down
2 changes: 2 additions & 0 deletions code/renderers/react/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const addons: PresetProperty<'addons'> = [

export { componentManifestGenerator as experimental_componentManifestGenerator } from './componentManifest/generator';

export { enrichCsf as experimental_enrichCsf } from './enrichCsf';

export const previewAnnotations: PresetProperty<'previewAnnotations'> = async (
input = [],
options
Expand Down
Loading