Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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;
}
4 changes: 2 additions & 2 deletions code/core/src/common/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getInterpretedFile } from './utils/interpret-files';
import { stripAbsNodeModulesPath } from './utils/strip-abs-node-modules-path';
import { validateConfigurationFiles } from './utils/validate-configuration-files';

type InterPresetOptions = Omit<
export type InterPresetOptions = Omit<
CLIOptions &
LoadOptions &
BuilderOptions & { isCritical?: boolean; build?: StorybookConfigRaw['build'] },
Expand Down 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
20 changes: 20 additions & 0 deletions code/core/src/types/modules/core-common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// should be node:http, but that caused the ui/manager to fail to build, might be able to switch this back once ui/manager is in the core
import type { FileSystemCache } from 'storybook/internal/common';
import { type StoryIndexGenerator } from 'storybook/internal/core-server';
import { type CsfFile } from 'storybook/internal/csf-tools';

import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http';
import type { Server as NetServer } from 'net';
Expand Down Expand Up @@ -106,6 +107,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 +363,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 +379,7 @@ export interface StorybookConfigRaw {
addons?: Preset[];
core?: CoreConfig;
componentManifestGenerator?: ComponentManifestGenerator;
experimental_enrichCsf?: CsfEnricher;
staticDirs?: (DirectoryMapping | string)[];
logLevel?: string;
features?: {
Expand Down Expand Up @@ -472,6 +479,19 @@ export interface StorybookConfigRaw {
angularFilterNonInputControls?: boolean;

experimentalComponentsManifest?: boolean;

/**
* Enables the new code example generation for React components. You can see those examples when
* clicking on the "Show code" button in the Storybook UI.
*
* We refactored the code examples by reading the actual source file. This should make the code
* examples a lot faster, more readable and more accurate. They are not dynamic though, it won't
* change if you change when using the control panel.
*
* @default false
* @experimental This feature is in early development and may change significantly in future releases.
*/
experimentalCodeExamples?: boolean;
Comment on lines +484 to +495
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

Fix grammatical error in JSDoc and consider addressing past review feedback.

The JSDoc has a grammatical issue in the sentence "it won't change if you change when using the control panel."

Apply this diff to fix the grammar:

      * We refactored the code examples by reading the actual source file. This should make the code
      * examples a lot faster, more readable and more accurate. They are not dynamic though, it won't
-     * change if you change when using the control panel.
+     * change when you modify controls in the control panel.

Additionally, the past review comment mentions "better support for render functions." If this is a key improvement, consider adding it explicitly to the JSDoc:

      * Enables the new code example generation for React components. You can see those examples when
      * clicking on the "Show code" button in the Storybook UI.
      *
      * We refactored the code examples by reading the actual source file. This should make the code
-     * examples a lot faster, more readable and more accurate. They are not dynamic though, it won't
-     * change when you modify controls in the control panel.
+     * examples a lot faster, more readable and more accurate, with improved support for render
+     * functions. They are not dynamic though, meaning they won't change when you modify controls
+     * in the control panel.
🤖 Prompt for AI Agents
In code/core/src/types/modules/core-common.ts around lines 484 to 495, the JSDoc
contains a grammatical error ("it won't change if you change when using the
control panel"); update the sentence to a clear form such as "The examples are
static and will not update when you change controls in the control panel." Also,
if desired, add a brief note about improved support for render functions (e.g.,
"May include better support for render functions") to the JSDoc to address the
past review feedback, keeping the @default and @experimental tags unchanged.

};

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
9 changes: 3 additions & 6 deletions code/renderers/react/src/componentManifest/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { type StoryIndexGenerator } from 'storybook/internal/core-server';

import { vol } from 'memfs';
import { dedent } from 'ts-dedent';
import * as TsconfigPaths from 'tsconfig-paths';
import { loadConfig } from 'tsconfig-paths';

import { componentManifestGenerator } from './generator';

vi.mock('tsconfig-paths', { spy: true });
vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises);
vi.mock('node:fs', async () => (await import('memfs')).fs);
vi.mock('tsconfig-paths', { spy: true });

// Use the provided indexJson from this file
const indexJson = {
Expand Down Expand Up @@ -95,10 +95,7 @@ const indexJson = {
};

beforeEach(() => {
vi.mocked(TsconfigPaths.loadConfig).mockImplementation(() => ({
resultType: null!,
message: null!,
}));
vi.mocked(loadConfig).mockImplementation(() => ({ resultType: null!, message: null! }));
vi.spyOn(process, 'cwd').mockReturnValue('/app');
vol.fromJSON(
{
Expand Down
10 changes: 4 additions & 6 deletions code/renderers/react/src/componentManifest/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { type ComponentManifest } from 'storybook/internal/types';
import path from 'pathe';

import { getCodeSnippet } from './generateCodeSnippet';
import { extractJSDocTags, removeTags } from './jsdocTags';
import { extractJSDocInfo } from './jsdocTags';
import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen';
import { groupBy } from './utils';

Expand Down Expand Up @@ -69,16 +69,14 @@ export const componentManifestGenerator = async () => {

const metaDescription = extractDescription(csf._metaStatement);
const jsdocComment = metaDescription || docgen?.description;
const tags = jsdocComment ? extractJSDocTags(jsdocComment) : {};
const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};

const manifestDescription = jsdocComment
? removeTags(tags.describe?.[0] || tags.desc?.[0] || jsdocComment).trim()
: undefined;
const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description;

return {
id,
name: componentName,
description: manifestDescription,
description: manifestDescription?.trim(),
summary: tags.summary?.[0],
import: tags.import?.[0],
reactDocgen: docgen,
Expand Down
30 changes: 18 additions & 12 deletions code/renderers/react/src/componentManifest/jsdocTags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import { expect, it } from 'vitest';

import { dedent } from 'ts-dedent';

import { extractJSDocTags } from './jsdocTags';
import { extractJSDocInfo } from './jsdocTags';

it('should extract @summary tag', () => {
const code = dedent`@summary This is the summary`;
const tags = extractJSDocTags(code);
const code = dedent`description\n@summary\n my summary`;
const tags = extractJSDocInfo(code);
expect(tags).toMatchInlineSnapshot(`
{
"summary": [
"This is the summary",
],
"description": "description",
"tags": {
"summary": [
" my summary",
],
},
}
`);
});
Expand All @@ -21,14 +24,17 @@ it('should extract @param tag with type', () => {
@param {Object} employee - The employee who is responsible for the project.
@param {string} employee.name - The name of the employee.
@param {string} employee.department - The employee's department.`;
const tags = extractJSDocTags(code);
const tags = extractJSDocInfo(code);
expect(tags).toMatchInlineSnapshot(`
{
"param": [
"{Object} employee - The employee who is responsible for the project.",
"{string} employee.name - The name of the employee.",
"{string} employee.department - The employee's department.",
],
"description": "",
"tags": {
"param": [
"{Object} employee - The employee who is responsible for the project.",
"{string} employee.name - The name of the employee.",
"{string} employee.department - The employee's department.",
],
},
}
`);
});
25 changes: 11 additions & 14 deletions code/renderers/react/src/componentManifest/jsdocTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,21 @@ import { parse } from 'comment-parser';

import { groupBy } from './utils';

export function extractJSDocTags(jsdocComment: string) {
export function extractJSDocInfo(jsdocComment: string) {
const lines = jsdocComment.split('\n');
const jsDoc = ['/**', ...lines.map((line) => ` * ${line}`), ' */'].join('\n');

const parsed = parse(jsDoc);

return Object.fromEntries(
Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [
key,
tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`) ??
[],
])
);
return {
description: parsed[0].description,
tags: Object.fromEntries(
Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [
key,
tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`) ??
[],
])
),
Comment on lines +13 to +19
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against undefined tag properties.

The string concatenation on line 16 may produce "undefined" text if tag.name or tag.description is undefined. This could result in malformed output strings like "{string} undefined undefined".

Apply this diff to safely handle undefined values:

      Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [
        key,
-        tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`) ??
+        tags?.map((tag) => {
+          const typePrefix = tag.type ? `{${tag.type}} ` : '';
+          const name = tag.name || '';
+          const description = tag.description || '';
+          return `${typePrefix}${name} ${description}`.trim();
+        }) ??
          [],
      ])
🤖 Prompt for AI Agents
In code/renderers/react/src/componentManifest/jsdocTags.ts around lines 13 to
19, the map builds strings using tag.type, tag.name and tag.description which
can be undefined and produce literal "undefined" in output; update the mapping
to coerce each value to an empty string when undefined (e.g. use tag.type ?
`{${tag.type}} ` : '' and `${tag.name ?? ''}` and `${tag.description ?? ''}`),
then trim the resulting string to remove extra spaces so malformed strings like
"{string} undefined undefined" cannot occur.

};
}

export function removeTags(jsdocComment: string) {
return jsdocComment
.split('\n')
.filter((line) => !line.trim().startsWith('@'))
.join('\n');
}
1 change: 1 addition & 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
Loading
Loading