From 74b82865fd6a77a97d47178a9d7a6bfa3e3a6319 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 18 Dec 2025 13:33:16 +0100 Subject: [PATCH 1/7] refactor manifest generator to an extensible manifests preset property --- code/core/src/core-server/build-static.ts | 35 +- code/core/src/core-server/dev-server.ts | 59 +-- .../utils/manifests/manifests.test.ts | 391 ++++++++++++++++++ .../core-server/utils/manifests/manifests.ts | 87 ++++ .../manifests/render-components-manifest.ts} | 8 +- code/core/src/types/modules/core-common.ts | 12 +- .../src/componentManifest/generator.test.ts | 6 +- .../react/src/componentManifest/generator.ts | 304 +++++++------- code/renderers/react/src/preset.ts | 2 +- 9 files changed, 660 insertions(+), 244 deletions(-) create mode 100644 code/core/src/core-server/utils/manifests/manifests.test.ts create mode 100644 code/core/src/core-server/utils/manifests/manifests.ts rename code/core/src/core-server/{manifest.ts => utils/manifests/render-components-manifest.ts} (99%) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 0ef50cd6e77c..3fea2789ad65 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, writeFile } from 'node:fs/promises'; +import { cp, mkdir } from 'node:fs/promises'; import { rm } from 'node:fs/promises'; import { @@ -10,7 +10,12 @@ import { } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry'; -import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; +import type { + BuilderOptions, + CLIOptions, + LoadOptions, + Options, +} from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -18,8 +23,8 @@ import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; import { resolvePackageDir } from '../shared/utils/module'; -import { renderManifestComponentsPage } from './manifest'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; +import { writeManifests } from './utils/manifests/manifests'; import { buildOrThrow } from './utils/build-or-throw'; import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'; import { getBuilders } from './utils/get-builders'; @@ -148,29 +153,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ); if (features?.experimentalComponentsManifest) { - const componentManifestGenerator = await presets.apply( - 'experimental_componentManifestGenerator' - ); - const indexGenerator = await storyIndexGeneratorPromise; - if (componentManifestGenerator && indexGenerator) { - try { - const manifests = await componentManifestGenerator( - indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator - ); - await mkdir(join(options.outputDir, 'manifests'), { recursive: true }); - await writeFile( - join(options.outputDir, 'manifests', 'components.json'), - JSON.stringify(manifests) - ); - await writeFile( - join(options.outputDir, 'manifests', 'components.html'), - renderManifestComponentsPage(manifests) - ); - } catch (e) { - logger.error('Failed to generate manifests/components.json'); - logger.error(e instanceof Error ? e : String(e)); - } - } + effects.push(writeManifests(options.outputDir, presets)); } } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 3b9b7cff3598..da9cf0cb6042 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -1,15 +1,13 @@ import { logConfig, normalizeStories } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { MissingBuilderError } from 'storybook/internal/server-errors'; -import type { ComponentsManifest, Options } from 'storybook/internal/types'; -import { type ComponentManifestGenerator } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; import invariant from 'tiny-invariant'; import { telemetry } from '../telemetry'; -import { renderManifestComponentsPage } from './manifest'; import { type StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; @@ -17,6 +15,7 @@ import { getCachingMiddleware } from './utils/get-caching-middleware'; import { getServerChannel } from './utils/get-server-channel'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; import { registerIndexJsonRoute } from './utils/index-json'; +import { registerManifests } from './utils/manifests/manifests'; import { useStorybookMetadata } from './utils/metadata'; import { getMiddleware } from './utils/middleware'; import { openInBrowser } from './utils/open-browser/open-in-browser'; @@ -162,59 +161,7 @@ export async function storybookDevServer(options: Options) { const features = await options.presets.apply('features'); if (features?.experimentalComponentsManifest) { - app.use('/manifests/components.json', async (req, res) => { - try { - const componentManifestGenerator = await options.presets.apply( - 'experimental_componentManifestGenerator' - ); - const indexGenerator = await storyIndexGeneratorPromise; - if (componentManifestGenerator && indexGenerator) { - const manifest = await componentManifestGenerator( - indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator - ); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(manifest)); - return; - } - res.statusCode = 400; - res.end('No component manifest generator configured.'); - return; - } catch (e) { - logger.error(e instanceof Error ? e : String(e)); - res.statusCode = 500; - res.end(e instanceof Error ? e.toString() : String(e)); - return; - } - }); - - app.get('/manifests/components.html', async (req, res) => { - try { - const componentManifestGenerator = await options.presets.apply( - 'experimental_componentManifestGenerator' - ); - const indexGenerator = await storyIndexGeneratorPromise; - - if (!componentManifestGenerator || !indexGenerator) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(`
No component manifest generator configured.
`); - return; - } - - const manifest = (await componentManifestGenerator( - indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator - )) as ComponentsManifest; - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderManifestComponentsPage(manifest)); - } catch (e) { - // logger?.error?.(e instanceof Error ? e : String(e)); - res.statusCode = 500; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - invariant(e instanceof Error); - res.end(`
${e.stack}
`); - } - }); + registerManifests({ app, presets: options.presets }); } // Now the preview has successfully started, we can count this as a 'dev' event. doTelemetry(app, core, storyIndexGeneratorPromise, options); diff --git a/code/core/src/core-server/utils/manifests/manifests.test.ts b/code/core/src/core-server/utils/manifests/manifests.test.ts new file mode 100644 index 000000000000..a7f9c3532563 --- /dev/null +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -0,0 +1,391 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { logger } from 'storybook/internal/node-logger'; +import type { ComponentsManifest, Manifests, Presets, StoryIndex } from 'storybook/internal/types'; + +import { vol } from 'memfs'; +import type { Polka, Request, Response } from 'polka'; + +import { registerManifests, writeManifests } from './manifests'; + +// Mock dependencies +vi.mock('node:fs/promises', async () => { + const fs = (await import('memfs')).fs.promises; + return { default: fs, ...fs }; +}); +vi.mock('storybook/internal/node-logger'); + +describe('manifests', () => { + let mockGenerator: { getIndex: ReturnType }; + let mockManifests: Manifests; + + const setupMockPresets = () => { + mockGenerator = { getIndex: vi.fn().mockResolvedValue({} as StoryIndex) }; + mockManifests = {}; + + return { + apply: vi.fn().mockImplementation((key: string) => { + switch (key) { + case 'storyIndexGenerator': + return Promise.resolve(mockGenerator); + case 'experimental_manifests': + return Promise.resolve(mockManifests); + default: + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }), + } as any as Presets; + }; + + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + }); + + describe('writeManifests', () => { + let mockPresets: Presets; + + beforeEach(() => { + mockPresets = setupMockPresets(); + }); + + it('should do nothing when manifests are empty', async () => { + mockManifests = {}; + + await writeManifests('/output', mockPresets); + + expect(vol.toJSON()).toEqual({}); + }); + + it('should create manifests directory and write JSON files', async () => { + mockManifests = { + custom: { data: 'value' }, + another: { items: [1, 2, 3] }, + }; + + await writeManifests('/output', mockPresets); + + const files = vol.toJSON(); + expect(files['/output/manifests/custom.json']).toBe(JSON.stringify({ data: 'value' })); + expect(files['/output/manifests/another.json']).toBe(JSON.stringify({ items: [1, 2, 3] })); + }); + + it('should write HTML file when components manifest exists', async () => { + const componentsManifest: ComponentsManifest = { + v: 0, + components: { + Button: { + id: 'button', + name: 'Button', + path: './Button.tsx', + stories: [], + jsDocTags: {}, + }, + }, + }; + mockManifests = { + components: componentsManifest, + }; + + await writeManifests('/output', mockPresets); + + const files = vol.toJSON(); + expect(files['/output/manifests/components.html']).toBeDefined(); + expect(files['/output/manifests/components.html']).toContain(''); + }); + + it('should handle errors when presets.apply fails', async () => { + const error = new Error('Preset application failed'); + vi.mocked(mockPresets.apply).mockRejectedValue(error); + + await writeManifests('/output', mockPresets); + + expect(vi.mocked(logger).error).toHaveBeenCalledWith('Failed to generate manifests'); + expect(vi.mocked(logger).error).toHaveBeenCalledWith(error); + expect(vol.toJSON()).toEqual({}); + }); + + it('should handle non-Error objects in catch block', async () => { + const errorString = 'Something went wrong'; + vi.mocked(mockPresets.apply).mockRejectedValue(errorString); + + await writeManifests('/output', mockPresets); + + expect(vi.mocked(logger).error).toHaveBeenCalledWith('Failed to generate manifests'); + expect(vi.mocked(logger).error).toHaveBeenCalledWith(errorString); + }); + }); + + describe('registerManifests', () => { + let mockApp: Polka; + let mockGet: ReturnType; + let mockPresets: Presets; + + beforeEach(() => { + mockGet = vi.fn(); + mockApp = { get: mockGet } as any; + mockPresets = setupMockPresets(); + }); + + describe('route registration', () => { + it('should register two routes', () => { + registerManifests({ app: mockApp, presets: mockPresets }); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockGet).toHaveBeenCalledWith('/manifests/:name.json', expect.any(Function)); + expect(mockGet).toHaveBeenCalledWith('/manifests/components.html', expect.any(Function)); + }); + }); + + describe('/manifests/:name.json route', () => { + it('should return manifest as JSON when it exists', async () => { + mockManifests = { + custom: { data: 'value' }, + }; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'custom' } } as any as Request; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + } as any as Response; + + await handler(req, res); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(res.end).toHaveBeenCalledWith(JSON.stringify({ data: 'value' })); + expect(res.statusCode).toBeUndefined(); + }); + + it('should return 404 when manifest does not exist', async () => { + mockManifests = { + existing: { data: 'value' }, + }; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'nonexistent' } }; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.end).toHaveBeenCalledWith('Manifest "nonexistent" not found'); + }); + + it('should return 404 when manifests object is empty', async () => { + mockManifests = {}; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'any' } }; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.end).toHaveBeenCalledWith('Manifest "any" not found'); + }); + + it('should handle errors with 500 status and log the error', async () => { + const error = new Error('Preset failed'); + vi.mocked(mockPresets.apply).mockRejectedValue(error); + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'custom' } } as any as Request; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + } as any as Response; + + await handler(req, res); + + expect(vi.mocked(logger).error).toHaveBeenCalledWith(error); + expect(res.statusCode).toBe(500); + expect(res.end).toHaveBeenCalledWith(error.toString()); + }); + + it('should handle non-Error objects in error handler', async () => { + const errorString = 'Something went wrong'; + vi.mocked(mockPresets.apply).mockRejectedValue(errorString); + + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'custom' } } as any as Request; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + } as any as Response; + + await handler(req, res); + + expect(vi.mocked(logger).error).toHaveBeenCalledWith(errorString); + expect(res.statusCode).toBe(500); + expect(res.end).toHaveBeenCalledWith(errorString); + }); + + it('should handle when presets.apply returns null/undefined', async () => { + mockManifests = null as any; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[0][1]; + const req = { params: { name: 'custom' } } as any as Request; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + } as any as Response; + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.end).toHaveBeenCalledWith('Manifest "custom" not found'); + }); + }); + + describe('/manifests/components.html route', () => { + it('should return rendered HTML when components manifest exists', async () => { + const componentsManifest: ComponentsManifest = { + v: 0, + components: { + Button: { + id: 'button', + name: 'Button', + path: './Button.tsx', + stories: [], + jsDocTags: {}, + }, + }, + }; + mockManifests = { + components: componentsManifest, + }; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[1][1]; + const req = {} as any as Request; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + } as any as Response; + + await handler(req, res); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + expect(res.end).toHaveBeenCalled(); + const html = (res.end as any).mock.calls[0][0]; + expect(html).toContain(''); + expect(html).toContain('Components Manifest'); + expect(res.statusCode).toBeUndefined(); + }); + + it('should return 404 message when components manifest does not exist', async () => { + mockManifests = { + other: { data: 'value' }, + }; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[1][1]; + const req = {}; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + expect(res.end).toHaveBeenCalledWith('
No components manifest configured.
'); + }); + + it('should return 404 when manifests is empty', async () => { + mockManifests = {}; + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[1][1]; + const req = {}; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.end).toHaveBeenCalledWith('
No components manifest configured.
'); + }); + + it('should handle errors with 500 status and return error HTML', async () => { + const error = new Error('Rendering failed'); + error.stack = 'Error: Rendering failed\n at test.ts:123'; + vi.mocked(mockPresets.apply).mockRejectedValue(error); + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[1][1]; + const req = {}; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + expect(res.end).toHaveBeenCalledWith( + '
Error: Rendering failed\n  at test.ts:123
' + ); + }); + + it('should handle non-Error objects in error handler', async () => { + const errorString = 'Something went wrong'; + vi.mocked(mockPresets.apply).mockRejectedValue(errorString); + + + registerManifests({ app: mockApp, presets: mockPresets }); + + const handler = mockGet.mock.calls[1][1]; + const req = {}; + const res = { + setHeader: vi.fn(), + end: vi.fn(), + statusCode: undefined as number | undefined, + }; + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + expect(res.end).toHaveBeenCalledWith(`
${errorString}
`); + }); + }); + }); +}); diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts new file mode 100644 index 000000000000..ea607e18051d --- /dev/null +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -0,0 +1,87 @@ +import { mkdir, writeFile } from 'node:fs/promises'; + +import invariant from 'tiny-invariant'; + +import { logger } from 'storybook/internal/node-logger'; +import type { ComponentsManifest, Manifests, Presets } from 'storybook/internal/types'; + +import { join } from 'pathe'; +import type { Polka } from 'polka'; + +import { renderComponentsManifest } from './render-components-manifest'; + +export async function writeManifests(outputDir: string, presets: Presets) { + try { + const generator = await presets.apply('storyIndexGenerator'); + invariant(generator, 'storyIndexGenerator must be configured'); + const index = await generator.getIndex(); + const manifests = await presets.apply('experimental_manifests', {}, { index }); + if (Object.keys(manifests).length === 0) { + return; + } + await mkdir(join(outputDir, 'manifests'), { recursive: true }); + await Promise.all( + Object.entries(manifests).map(([name, content]) => + writeFile(join(outputDir, 'manifests', `${name}.json`), JSON.stringify(content)) + ) + ); + if ('components' in manifests) { + await writeFile( + join(outputDir, 'manifests', 'components.html'), + renderComponentsManifest(manifests.components as ComponentsManifest) + ); + } + } catch (e) { + logger.error('Failed to generate manifests'); + logger.error(e instanceof Error ? e : String(e)); + } +} + +export function registerManifests({ app, presets }: { app: Polka; presets: Presets }) { + async function getManifest(manifestName: string) { + const generator = await presets.apply('storyIndexGenerator'); + invariant(generator, 'storyIndexGenerator must be configured'); + const index = await generator.getIndex(); + const manifests = ((await presets.apply('experimental_manifests', {}, { index })) ?? + {}) as Manifests; + return manifests[manifestName]; + } + + app.get('/manifests/:name.json', async (req, res) => { + try { + const manifest = await getManifest(req.params.name); + + if (manifest) { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(manifest)); + } else { + res.statusCode = 404; + res.end(`Manifest "${req.params.name}" not found`); + } + } catch (e) { + logger.error(e instanceof Error ? e : String(e)); + res.statusCode = 500; + res.end(e instanceof Error ? e.toString() : String(e)); + } + }); + + app.get('/manifests/components.html', async (req, res) => { + try { + const manifest = (await getManifest('components')) as ComponentsManifest | undefined; + + if (!manifest) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(`
No components manifest configured.
`); + return; + } + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderComponentsManifest(manifest)); + } catch (e) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(`
${e instanceof Error ? e.stack : String(e)}
`); + } + }); +} diff --git a/code/core/src/core-server/manifest.ts b/code/core/src/core-server/utils/manifests/render-components-manifest.ts similarity index 99% rename from code/core/src/core-server/manifest.ts rename to code/core/src/core-server/utils/manifests/render-components-manifest.ts index 6e6f18b23533..9a22308e6b23 100644 --- a/code/core/src/core-server/manifest.ts +++ b/code/core/src/core-server/utils/manifests/render-components-manifest.ts @@ -2,11 +2,11 @@ import path from 'node:path'; import { groupBy } from 'storybook/internal/common'; -import type { ComponentManifest, ComponentsManifest } from '../types'; +import type { ComponentManifest, ComponentsManifest } from '../../../types'; // AI generated manifests/components.html page // Only HTML/CSS no JS -export function renderManifestComponentsPage(manifest: ComponentsManifest) { +export function renderComponentsManifest(manifest: ComponentsManifest) { const entries = Object.entries(manifest?.components ?? {}).sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]) ); @@ -825,7 +825,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { `; } -export type ParsedDocgen = { +type ParsedDocgen = { props: Record< string, { @@ -837,7 +837,7 @@ export type ParsedDocgen = { >; }; -export const parseReactDocgen = (reactDocgen: any): ParsedDocgen => { +const parseReactDocgen = (reactDocgen: any): ParsedDocgen => { const props: Record = (reactDocgen as any)?.props ?? {}; return { props: Object.fromEntries( diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d3a983905910..a97a4eb444ee 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -373,9 +373,13 @@ export interface ComponentsManifest { components: Record; } -export type ComponentManifestGenerator = ( - storyIndexGenerator: StoryIndexGenerator -) => Promise; +type ManifestName = string; + +export type Manifests = { components?: ComponentsManifest } & Record< + ManifestName, + // TODO: type this so it can only be JSON-serializable data + any +>; export type CsfEnricher = (csf: CsfFile, csfSource: CsfFile) => Promise; @@ -392,7 +396,7 @@ export interface StorybookConfigRaw { */ addons?: Preset[]; core?: CoreConfig; - experimental_componentManifestGenerator?: ComponentManifestGenerator; + experimental_manifests?: Manifests; experimental_enrichCsf?: CsfEnricher; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 4129953ac264..91494af919ad 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -6,7 +6,7 @@ import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; import { fsMocks, indexJson } from './fixtures'; -import { componentManifestGenerator } from './generator'; +import { manifests } from './generator'; beforeEach(() => { vi.spyOn(process, 'cwd').mockReturnValue('/app'); @@ -14,7 +14,7 @@ beforeEach(() => { }); test('componentManifestGenerator generates correct id, name, description and examples ', async () => { - const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); + const generator = await manifest(undefined, { configDir: '.storybook' } as any); const manifest = await generator?.({ getIndex: async () => indexJson, } as unknown as StoryIndexGenerator); @@ -265,7 +265,7 @@ async function getManifestForStory(code: string) { '/app' ); - const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); + const generator = await manifest(undefined, { configDir: '.storybook' } as any); const indexJson = { v: 5, entries: { diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 9d9da2ce7cf8..d9ecabd2b389 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -4,8 +4,10 @@ import { extractDescription, loadCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { type ComponentManifest, - type ComponentManifestGenerator, + ComponentsManifest, type PresetPropertyFn, + StoryIndex, + StorybookConfigRaw, } from 'storybook/internal/types'; import path from 'pathe'; @@ -20,167 +22,169 @@ interface ReactComponentManifest extends ComponentManifest { reactDocgen?: DocObj; } -export const componentManifestGenerator: PresetPropertyFn< - 'experimental_componentManifestGenerator' -> = async () => { - return (async (storyIndexGenerator) => { - invalidateCache(); - - const startIndex = performance.now(); - const index = await storyIndexGenerator.getIndex(); - logger.verbose(`Story index generation took ${performance.now() - startIndex}ms`); - - const startPerformance = performance.now(); - - const groupByComponentId = groupBy( - Object.values(index.entries) - .filter((entry) => entry.type === 'story') - .filter((entry) => entry.subtype === 'story'), - (it) => it.id.split('--')[0] - ); - const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => - group && group?.length > 0 ? [group[0]] : [] - ); - const components = singleEntryPerComponent.map((entry): ReactComponentManifest | undefined => { - const absoluteImportPath = path.join(process.cwd(), entry.importPath); - const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string; - const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); - - const manifestEnabled = csf.stories - .map((it) => combineTags('manifest', ...(csf.meta.tags ?? []), ...(it.tags ?? []))) - .some((it) => it.includes('manifest')); - - if (!manifestEnabled) { - return; - } - const componentName = csf._meta?.component; - - const id = entry.id.split('--')[0]; - const importPath = entry.importPath; - - const components = getComponents({ csf, storyFilePath: absoluteImportPath }); - - const trimmedTitle = entry.title.replace(/\s+/g, ''); - - const component = components.find((it) => { - return componentName - ? [it.componentName, it.localImportName, it.importName].includes(componentName) - : trimmedTitle.includes(it.componentName) || - (it.localImportName && trimmedTitle.includes(it.localImportName)) || - (it.importName && trimmedTitle.includes(it.importName)); - }); - - const stories = Object.keys(csf._stories) - .map((storyName) => { - const story = csf._stories[storyName]; - - const manifestEnabled = combineTags( - 'manifest', - ...(csf.meta.tags ?? []), - ...(story.tags ?? []) - ).includes('manifest'); - - if (!manifestEnabled) { - return; - } - try { - const jsdocComment = extractDescription(csf._storyStatements[storyName]); - const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; - const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; - - return { - name: storyName, - snippet: recast.print(getCodeSnippet(csf, storyName, component?.componentName)).code, - description: finalDescription?.trim(), - summary: tags.summary?.[0], - }; - } catch (e) { - invariant(e instanceof Error); - return { - name: storyName, - error: { name: e.name, message: e.message }, - }; - } - }) - .filter((it) => it != null); - - const nearestPkg = cachedFindUp('package.json', { - cwd: path.dirname(component?.path ?? absoluteImportPath), - }); - - let packageName; - try { - packageName = nearestPkg - ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name - : undefined; - } catch {} - - const fallbackImport = - packageName && componentName ? `import { ${componentName} } from "${packageName}";` : ''; - - const imports = getImports({ components, packageName }).join('\n').trim() || fallbackImport; - - const title = entry.title.split('/').at(-1)!.replace(/\s+/g, ''); - - const base = { - id, - name: componentName ?? title, - path: importPath, - stories, - import: imports, - jsDocTags: {}, - } satisfies Partial; - - if (!component?.reactDocgen) { - const error = !csf._meta?.component - ? { - name: 'No component found', - message: - 'We could not detect the component from your story file. Specify meta.component.', - } - : { - name: 'No component import found', - message: `No component file found for the "${csf.meta.component}" component.`, - }; - return { - ...base, - error: { - name: error.name, - message: - (csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? - error.message) + `\n\n${entry.importPath}:\n${storyFile}`, - }, - }; - } - - const docgenResult = component.reactDocgen; - - const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined; - const error = docgenResult.type === 'error' ? docgenResult.error : undefined; +export const manifests: PresetPropertyFn< + 'experimental_manifests', + StorybookConfigRaw, + { index: StoryIndex } +> = async (existingManifests = {}, { index }) => { + invalidateCache(); + + const startIndex = performance.now(); + logger.verbose(`Story index generation took ${performance.now() - startIndex}ms`); + + const startPerformance = performance.now(); + + const groupByComponentId = groupBy( + Object.values(index.entries) + .filter((entry) => entry.type === 'story') + .filter((entry) => entry.subtype === 'story'), + (it) => it.id.split('--')[0] + ); + const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => + group && group?.length > 0 ? [group[0]] : [] + ); + const components = singleEntryPerComponent.map((entry): ReactComponentManifest | undefined => { + const absoluteImportPath = path.join(process.cwd(), entry.importPath); + const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string; + const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + + const manifestEnabled = csf.stories + .map((it) => combineTags('manifest', ...(csf.meta.tags ?? []), ...(it.tags ?? []))) + .some((it) => it.includes('manifest')); + + if (!manifestEnabled) { + return; + } + const componentName = csf._meta?.component; + + const id = entry.id.split('--')[0]; + const importPath = entry.importPath; + + const components = getComponents({ csf, storyFilePath: absoluteImportPath }); + + const trimmedTitle = entry.title.replace(/\s+/g, ''); + + const component = components.find((it) => { + return componentName + ? [it.componentName, it.localImportName, it.importName].includes(componentName) + : trimmedTitle.includes(it.componentName) || + (it.localImportName && trimmedTitle.includes(it.localImportName)) || + (it.importName && trimmedTitle.includes(it.importName)); + }); - const jsdocComment = extractDescription(csf._metaStatement) || docgen?.description; - const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; + const stories = Object.keys(csf._stories) + .map((storyName) => { + const story = csf._stories[storyName]; + + const manifestEnabled = combineTags( + 'manifest', + ...(csf.meta.tags ?? []), + ...(story.tags ?? []) + ).includes('manifest'); + + if (!manifestEnabled) { + return; + } + try { + const jsdocComment = extractDescription(csf._storyStatements[storyName]); + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; + const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; + + return { + name: storyName, + snippet: recast.print(getCodeSnippet(csf, storyName, component?.componentName)).code, + description: finalDescription?.trim(), + summary: tags.summary?.[0], + }; + } catch (e) { + invariant(e instanceof Error); + return { + name: storyName, + error: { name: e.name, message: e.message }, + }; + } + }) + .filter((it) => it != null); + + const nearestPkg = cachedFindUp('package.json', { + cwd: path.dirname(component?.path ?? absoluteImportPath), + }); + let packageName; + try { + packageName = nearestPkg + ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name + : undefined; + } catch {} + + const fallbackImport = + packageName && componentName ? `import { ${componentName} } from "${packageName}";` : ''; + + const imports = getImports({ components, packageName }).join('\n').trim() || fallbackImport; + + const title = entry.title.split('/').at(-1)!.replace(/\s+/g, ''); + + const base = { + id, + name: componentName ?? title, + path: importPath, + stories, + import: imports, + jsDocTags: {}, + } satisfies Partial; + + if (!component?.reactDocgen) { + const error = !csf._meta?.component + ? { + name: 'No component found', + message: + 'We could not detect the component from your story file. Specify meta.component.', + } + : { + name: 'No component import found', + message: `No component file found for the "${csf.meta.component}" component.`, + }; return { ...base, - description: ((tags?.describe?.[0] || tags?.desc?.[0]) ?? description)?.trim(), - summary: tags.summary?.[0], - import: imports, - reactDocgen: docgen, - jsDocTags: tags, - error, + error: { + name: error.name, + message: + (csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message) + + `\n\n${entry.importPath}:\n${storyFile}`, + }, }; - }); + } + + const docgenResult = component.reactDocgen; + + const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined; + const error = docgenResult.type === 'error' ? docgenResult.error : undefined; - logger.verbose(`Component manifest generation took ${performance.now() - startPerformance}ms`); + const jsdocComment = extractDescription(csf._metaStatement) || docgen?.description; + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; return { + ...base, + description: ((tags?.describe?.[0] || tags?.desc?.[0]) ?? description)?.trim(), + summary: tags.summary?.[0], + import: imports, + reactDocgen: docgen, + jsDocTags: tags, + error, + }; + }); + + logger.verbose(`Component manifest generation took ${performance.now() - startPerformance}ms`); + + return { + ...existingManifests, + components: { v: 0, components: Object.fromEntries( components .filter((component) => component != null) .map((component) => [component.id, component]) ), - }; - }) satisfies ComponentManifestGenerator; + }, + }; }; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index 6e36130b3a27..7e2796527480 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -8,7 +8,7 @@ export const addons: PresetProperty<'addons'> = [ import.meta.resolve('@storybook/react-dom-shim/preset'), ]; -export { componentManifestGenerator as experimental_componentManifestGenerator } from './componentManifest/generator'; +export { manifests as experimental_manifests } from './componentManifest/generator'; export { enrichCsf as experimental_enrichCsf } from './enrichCsf'; From 3bc9636add09493e263fead94e74c9c38a25fc95 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 18 Dec 2025 13:42:36 +0100 Subject: [PATCH 2/7] fix generator tests --- .../react/src/componentManifest/generator.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 91494af919ad..2d0175ddf9de 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -14,12 +14,9 @@ beforeEach(() => { }); test('componentManifestGenerator generates correct id, name, description and examples ', async () => { - const generator = await manifest(undefined, { configDir: '.storybook' } as any); - const manifest = await generator?.({ - getIndex: async () => indexJson, - } as unknown as StoryIndexGenerator); + const result = await manifests(undefined, { index: indexJson } as any); - expect(manifest).toMatchInlineSnapshot(` + expect(result?.components).toMatchInlineSnapshot(` { "components": { "example-button": { @@ -265,7 +262,6 @@ async function getManifestForStory(code: string) { '/app' ); - const generator = await manifest(undefined, { configDir: '.storybook' } as any); const indexJson = { v: 5, entries: { @@ -283,11 +279,9 @@ async function getManifestForStory(code: string) { }, }; - const manifest = await generator?.({ - getIndex: async () => indexJson, - } as unknown as StoryIndexGenerator); + const result = await manifests(undefined, { index: indexJson } as any); - return manifest?.components?.['example-button']; + return result?.components?.components?.['example-button']; } function withCSF3(body: string) { From 0f9efe08839ea192a03de8f83e9c253ab464965c Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 18 Dec 2025 14:05:48 +0100 Subject: [PATCH 3/7] lint fix --- code/core/src/core-server/build-static.ts | 9 ++------- .../src/core-server/utils/manifests/manifests.test.ts | 6 ++---- code/core/src/core-server/utils/manifests/manifests.ts | 3 +-- code/renderers/react/src/componentManifest/generator.ts | 5 ++--- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 3fea2789ad65..5d12f4551104 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -10,12 +10,7 @@ import { } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry'; -import type { - BuilderOptions, - CLIOptions, - LoadOptions, - Options, -} from 'storybook/internal/types'; +import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -24,11 +19,11 @@ import picocolors from 'picocolors'; import { resolvePackageDir } from '../shared/utils/module'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; -import { writeManifests } from './utils/manifests/manifests'; import { buildOrThrow } from './utils/build-or-throw'; import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'; import { getBuilders } from './utils/get-builders'; import { writeIndexJson } from './utils/index-json'; +import { writeManifests } from './utils/manifests/manifests'; import { extractStorybookMetadata } from './utils/metadata'; import { outputStats } from './utils/output-stats'; import { summarizeIndex } from './utils/summarizeIndex'; diff --git a/code/core/src/core-server/utils/manifests/manifests.test.ts b/code/core/src/core-server/utils/manifests/manifests.test.ts index a7f9c3532563..87bfe1203355 100644 --- a/code/core/src/core-server/utils/manifests/manifests.test.ts +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -224,9 +224,8 @@ describe('manifests', () => { it('should handle non-Error objects in error handler', async () => { const errorString = 'Something went wrong'; vi.mocked(mockPresets.apply).mockRejectedValue(errorString); - - registerManifests({ app: mockApp, presets: mockPresets }); + registerManifests({ app: mockApp, presets: mockPresets }); const handler = mockGet.mock.calls[0][1]; const req = { params: { name: 'custom' } } as any as Request; @@ -368,9 +367,8 @@ describe('manifests', () => { it('should handle non-Error objects in error handler', async () => { const errorString = 'Something went wrong'; vi.mocked(mockPresets.apply).mockRejectedValue(errorString); - - registerManifests({ app: mockApp, presets: mockPresets }); + registerManifests({ app: mockApp, presets: mockPresets }); const handler = mockGet.mock.calls[1][1]; const req = {}; diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts index ea607e18051d..bbbf7c5f1ce6 100644 --- a/code/core/src/core-server/utils/manifests/manifests.ts +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -1,12 +1,11 @@ import { mkdir, writeFile } from 'node:fs/promises'; -import invariant from 'tiny-invariant'; - import { logger } from 'storybook/internal/node-logger'; import type { ComponentsManifest, Manifests, Presets } from 'storybook/internal/types'; import { join } from 'pathe'; import type { Polka } from 'polka'; +import invariant from 'tiny-invariant'; import { renderComponentsManifest } from './render-components-manifest'; diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index d9ecabd2b389..8eac0fa14662 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -4,10 +4,9 @@ import { extractDescription, loadCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { type ComponentManifest, - ComponentsManifest, type PresetPropertyFn, - StoryIndex, - StorybookConfigRaw, + type StoryIndex, + type StorybookConfigRaw, } from 'storybook/internal/types'; import path from 'pathe'; From e3b32dafb56c25c6ebb79e3b22eb741e520c0e2b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 18 Dec 2025 14:36:52 +0100 Subject: [PATCH 4/7] cleanup --- code/core/src/core-server/utils/manifests/manifests.test.ts | 1 - code/renderers/react/src/componentManifest/generator.test.ts | 4 +--- code/renderers/react/src/componentManifest/generator.ts | 3 --- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/code/core/src/core-server/utils/manifests/manifests.test.ts b/code/core/src/core-server/utils/manifests/manifests.test.ts index 87bfe1203355..5728f1c8ba95 100644 --- a/code/core/src/core-server/utils/manifests/manifests.test.ts +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -33,7 +33,6 @@ describe('manifests', () => { default: return Promise.resolve(undefined); } - return Promise.resolve(undefined); }), } as any as Presets; }; diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 2d0175ddf9de..1bda84f98fab 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -1,7 +1,5 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import { type StoryIndexGenerator } from 'storybook/internal/core-server'; - import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; @@ -13,7 +11,7 @@ beforeEach(() => { vol.fromJSON(fsMocks, '/app'); }); -test('componentManifestGenerator generates correct id, name, description and examples ', async () => { +test('manifests generates correct id, name, description and examples ', async () => { const result = await manifests(undefined, { index: indexJson } as any); expect(result?.components).toMatchInlineSnapshot(` diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 8eac0fa14662..afc307185b00 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -28,9 +28,6 @@ export const manifests: PresetPropertyFn< > = async (existingManifests = {}, { index }) => { invalidateCache(); - const startIndex = performance.now(); - logger.verbose(`Story index generation took ${performance.now() - startIndex}ms`); - const startPerformance = performance.now(); const groupByComponentId = groupBy( From 5d1e8a9468cf0a0de2c257c3b50732bef967ffba Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 18 Dec 2025 15:46:45 +0100 Subject: [PATCH 5/7] split manifest generator into helper functions for readability --- .../react/src/componentManifest/generator.ts | 296 ++++++++++-------- 1 file changed, 158 insertions(+), 138 deletions(-) diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index afc307185b00..52514fd40f97 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -9,18 +9,94 @@ import { type StorybookConfigRaw, } from 'storybook/internal/types'; +import { uniqBy } from 'es-toolkit'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; import { getComponents, getImports } from './getComponentImports'; import { extractJSDocInfo } from './jsdocTags'; import { type DocObj } from './reactDocgen'; -import { cachedFindUp, cachedReadFileSync, groupBy, invalidateCache, invariant } from './utils'; +import { cachedFindUp, cachedReadFileSync, invalidateCache, invariant } from './utils'; interface ReactComponentManifest extends ComponentManifest { reactDocgen?: DocObj; } +function findMatchingComponent( + components: ReturnType, + componentName: string | undefined, + trimmedTitle: string +) { + return components.find((it) => + componentName + ? [it.componentName, it.localImportName, it.importName].includes(componentName) + : trimmedTitle.includes(it.componentName) || + (it.localImportName && trimmedTitle.includes(it.localImportName)) || + (it.importName && trimmedTitle.includes(it.importName)) + ); +} + +function getPackageInfo(componentPath: string | undefined, fallbackPath: string) { + const nearestPkg = cachedFindUp('package.json', { + cwd: path.dirname(componentPath ?? fallbackPath), + }); + + try { + return nearestPkg + ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name + : undefined; + } catch { + return undefined; + } +} + +function extractStories( + csf: ReturnType['parse']>, + componentName: string | undefined +) { + return Object.keys(csf._stories) + .filter((storyName) => + combineTags( + 'manifest', + ...(csf.meta.tags ?? []), + ...(csf._stories[storyName].tags ?? []) + ).includes('manifest') + ) + .map((storyName) => { + try { + const jsdocComment = extractDescription(csf._storyStatements[storyName]); + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; + const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; + + return { + name: storyName, + snippet: recast.print(getCodeSnippet(csf, storyName, componentName)).code, + description: finalDescription?.trim(), + summary: tags.summary?.[0], + }; + } catch (e) { + invariant(e instanceof Error); + return { + name: storyName, + error: { name: e.name, message: e.message }, + }; + } + }); +} + +function extractComponentDescription( + csf: ReturnType['parse']>, + docgen: DocObj | undefined +) { + const jsdocComment = extractDescription(csf._metaStatement) || docgen?.description; + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; + return { + description: ((tags?.describe?.[0] || tags?.desc?.[0]) ?? description)?.trim(), + summary: tags.summary?.[0], + jsDocTags: tags, + }; +} + export const manifests: PresetPropertyFn< 'experimental_manifests', StorybookConfigRaw, @@ -30,145 +106,93 @@ export const manifests: PresetPropertyFn< const startPerformance = performance.now(); - const groupByComponentId = groupBy( - Object.values(index.entries) - .filter((entry) => entry.type === 'story') - .filter((entry) => entry.subtype === 'story'), - (it) => it.id.split('--')[0] - ); - const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => - group && group?.length > 0 ? [group[0]] : [] + const entriesByUniqueComponent = uniqBy( + Object.values(index.entries).filter( + (entry) => entry.type === 'story' && entry.subtype === 'story' + ), + (entry) => entry.id.split('--')[0] ); - const components = singleEntryPerComponent.map((entry): ReactComponentManifest | undefined => { - const absoluteImportPath = path.join(process.cwd(), entry.importPath); - const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string; - const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); - const manifestEnabled = csf.stories - .map((it) => combineTags('manifest', ...(csf.meta.tags ?? []), ...(it.tags ?? []))) - .some((it) => it.includes('manifest')); - - if (!manifestEnabled) { - return; - } - const componentName = csf._meta?.component; - - const id = entry.id.split('--')[0]; - const importPath = entry.importPath; - - const components = getComponents({ csf, storyFilePath: absoluteImportPath }); - - const trimmedTitle = entry.title.replace(/\s+/g, ''); - - const component = components.find((it) => { - return componentName - ? [it.componentName, it.localImportName, it.importName].includes(componentName) - : trimmedTitle.includes(it.componentName) || - (it.localImportName && trimmedTitle.includes(it.localImportName)) || - (it.importName && trimmedTitle.includes(it.importName)); - }); + const components = entriesByUniqueComponent + .map((entry): ReactComponentManifest | undefined => { + const absoluteImportPath = path.join(process.cwd(), entry.importPath); + const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string; + const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + + const hasManifestTag = csf.stories + .map((it) => combineTags('manifest', ...(csf.meta.tags ?? []), ...(it.tags ?? []))) + .some((it) => it.includes('manifest')); + + if (!hasManifestTag) { + return; + } + + const componentName = csf._meta?.component; + const id = entry.id.split('--')[0]; + const title = entry.title.split('/').at(-1)!.replace(/\s+/g, ''); + + const allComponents = getComponents({ csf, storyFilePath: absoluteImportPath }); + const component = findMatchingComponent( + allComponents, + componentName, + entry.title.replace(/\s+/g, '') + ); + + const packageName = getPackageInfo(component?.path, absoluteImportPath); + const fallbackImport = + packageName && componentName ? `import { ${componentName} } from "${packageName}";` : ''; + const imports = + getImports({ components: allComponents, packageName }).join('\n').trim() || fallbackImport; + + const stories = extractStories(csf, component?.componentName); + + const base = { + id, + name: componentName ?? title, + path: entry.importPath, + stories, + import: imports, + jsDocTags: {}, + } satisfies Partial; + + if (!component?.reactDocgen) { + const error = !csf._meta?.component + ? { + name: 'No component found', + message: + 'We could not detect the component from your story file. Specify meta.component.', + } + : { + name: 'No component import found', + message: `No component file found for the "${csf.meta.component}" component.`, + }; + + return { + ...base, + error: { + name: error.name, + message: + (csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? + error.message) + `\n\n${entry.importPath}:\n${storyFile}`, + }, + }; + } - const stories = Object.keys(csf._stories) - .map((storyName) => { - const story = csf._stories[storyName]; - - const manifestEnabled = combineTags( - 'manifest', - ...(csf.meta.tags ?? []), - ...(story.tags ?? []) - ).includes('manifest'); - - if (!manifestEnabled) { - return; - } - try { - const jsdocComment = extractDescription(csf._storyStatements[storyName]); - const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; - const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; - - return { - name: storyName, - snippet: recast.print(getCodeSnippet(csf, storyName, component?.componentName)).code, - description: finalDescription?.trim(), - summary: tags.summary?.[0], - }; - } catch (e) { - invariant(e instanceof Error); - return { - name: storyName, - error: { name: e.name, message: e.message }, - }; - } - }) - .filter((it) => it != null); - - const nearestPkg = cachedFindUp('package.json', { - cwd: path.dirname(component?.path ?? absoluteImportPath), - }); + const docgenResult = component.reactDocgen; + const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined; + const { description, summary, jsDocTags } = extractComponentDescription(csf, docgen); - let packageName; - try { - packageName = nearestPkg - ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name - : undefined; - } catch {} - - const fallbackImport = - packageName && componentName ? `import { ${componentName} } from "${packageName}";` : ''; - - const imports = getImports({ components, packageName }).join('\n').trim() || fallbackImport; - - const title = entry.title.split('/').at(-1)!.replace(/\s+/g, ''); - - const base = { - id, - name: componentName ?? title, - path: importPath, - stories, - import: imports, - jsDocTags: {}, - } satisfies Partial; - - if (!component?.reactDocgen) { - const error = !csf._meta?.component - ? { - name: 'No component found', - message: - 'We could not detect the component from your story file. Specify meta.component.', - } - : { - name: 'No component import found', - message: `No component file found for the "${csf.meta.component}" component.`, - }; return { ...base, - error: { - name: error.name, - message: - (csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message) + - `\n\n${entry.importPath}:\n${storyFile}`, - }, + description, + summary, + import: imports, + reactDocgen: docgen, + jsDocTags, + error: docgenResult.type === 'error' ? docgenResult.error : undefined, }; - } - - const docgenResult = component.reactDocgen; - - const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined; - const error = docgenResult.type === 'error' ? docgenResult.error : undefined; - - const jsdocComment = extractDescription(csf._metaStatement) || docgen?.description; - const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; - - return { - ...base, - description: ((tags?.describe?.[0] || tags?.desc?.[0]) ?? description)?.trim(), - summary: tags.summary?.[0], - import: imports, - reactDocgen: docgen, - jsDocTags: tags, - error, - }; - }); + }) + .filter((component) => component !== undefined); logger.verbose(`Component manifest generation took ${performance.now() - startPerformance}ms`); @@ -176,11 +200,7 @@ export const manifests: PresetPropertyFn< ...existingManifests, components: { v: 0, - components: Object.fromEntries( - components - .filter((component) => component != null) - .map((component) => [component.id, component]) - ), + components: Object.fromEntries(components.map((component) => [component.id, component])), }, }; }; From 100f2fc44aae5a3db7a845f6a53b4bc178bea95d Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 19 Dec 2025 09:01:43 +0100 Subject: [PATCH 6/7] fix react size regression, upgrade es-toolkit, enforce use of sub-exports via eslint --- code/.eslintrc.js | 6 +++++ code/addons/docs/package.json | 2 +- code/addons/vitest/package.json | 2 +- code/core/package.json | 2 +- code/lib/codemod/package.json | 2 +- code/package.json | 2 +- code/renderers/react/package.json | 2 +- .../react/src/componentManifest/generator.ts | 2 +- scripts/package.json | 2 +- yarn.lock | 22 +++++++++---------- 10 files changed, 25 insertions(+), 19 deletions(-) diff --git a/code/.eslintrc.js b/code/.eslintrc.js index c5545420b80b..db97d0167828 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -51,6 +51,12 @@ module.exports = { "Don't import from react-aria-components root, but use the react-aria-components/patched-dist/ComponentX entrypoints which are optimised for tree-shaking. Might require addition patching of the package if using new, unpatched components. See https://github.com/storybookjs/storybook/pull/32594", allowTypeImports: true, }, + { + name: 'es-toolkit', + message: + "Don't import from es-toolkit root, but use the sub-exports like es-toolkit/array entrypoints instead which are optimised for tree-shaking.", + allowTypeImports: true, + }, ], }, ], diff --git a/code/addons/docs/package.json b/code/addons/docs/package.json index c977df260cc4..12bf687fb100 100644 --- a/code/addons/docs/package.json +++ b/code/addons/docs/package.json @@ -93,7 +93,7 @@ "@types/color-convert": "^2.0.0", "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "color-convert": "^2.0.1", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "github-slugger": "^2.0.0", "markdown-to-jsx": "^7.7.2", "memoizerific": "^1.11.3", diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 42ac5aea0411..5e21782ae3ad 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -79,7 +79,7 @@ "@vitest/browser-playwright": "^4.0.14", "@vitest/runner": "^4.0.14", "empathic": "^2.0.0", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "istanbul-lib-report": "^3.0.1", "micromatch": "^4.0.8", "pathe": "^1.1.2", diff --git a/code/core/package.json b/code/core/package.json index 47ccb34b750d..f34455335c1a 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -274,7 +274,7 @@ "downshift": "^9.0.4", "ejs": "^3.1.10", "empathic": "^2.0.0", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "execa": "^8.0.1", "exsolve": "^1.0.7", diff --git a/code/lib/codemod/package.json b/code/lib/codemod/package.json index 5c797056f375..212d65e001fe 100644 --- a/code/lib/codemod/package.json +++ b/code/lib/codemod/package.json @@ -42,7 +42,7 @@ "dependencies": { "@types/cross-spawn": "^6.0.6", "cross-spawn": "^7.0.6", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "jscodeshift": "^0.15.1", "prettier": "^3.5.3", "storybook": "workspace:*", diff --git a/code/package.json b/code/package.json index f71d981a7ca7..9bb36fb7db98 100644 --- a/code/package.json +++ b/code/package.json @@ -134,7 +134,7 @@ "create-storybook": "workspace:*", "cross-env": "^7.0.3", "danger": "^13.0.4", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "esbuild-loader": "^4.3.0", "eslint": "8.57.1", diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index 0970a4c23974..09580e106ba7 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -64,7 +64,7 @@ "babel-plugin-react-docgen": "^4.2.1", "comment-parser": "^1.4.1", "empathic": "^2.0.0", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "escodegen": "^2.1.0", "expect-type": "^0.15.0", "html-tags": "^3.1.0", diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 52514fd40f97..fd4453d2a5f7 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -9,7 +9,7 @@ import { type StorybookConfigRaw, } from 'storybook/internal/types'; -import { uniqBy } from 'es-toolkit'; +import { uniqBy } from 'es-toolkit/array'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; diff --git a/scripts/package.json b/scripts/package.json index 7c8103890315..26f2d36cac14 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -105,7 +105,7 @@ "ejs": "^3.1.10", "ejs-lint": "^2.0.1", "empathic": "^2.0.0", - "es-toolkit": "^1.36.0", + "es-toolkit": "^1.43.0", "esbuild": "^0.27.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", diff --git a/yarn.lock b/yarn.lock index 37c5934bfb6c..5fc74f637626 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7888,7 +7888,7 @@ __metadata: "@types/color-convert": "npm:^2.0.0" "@types/react": "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" color-convert: "npm:^2.0.1" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" github-slugger: "npm:^2.0.0" markdown-to-jsx: "npm:^7.7.2" memoizerific: "npm:^1.11.3" @@ -7965,7 +7965,7 @@ __metadata: "@vitest/browser-playwright": "npm:^4.0.14" "@vitest/runner": "npm:^4.0.14" empathic: "npm:^2.0.0" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" istanbul-lib-report: "npm:^3.0.1" micromatch: "npm:^4.0.8" pathe: "npm:^1.1.2" @@ -8225,7 +8225,7 @@ __metadata: create-storybook: "workspace:*" cross-env: "npm:^7.0.3" danger: "npm:^13.0.4" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" esbuild-loader: "npm:^4.3.0" eslint: "npm:8.57.1" @@ -8303,7 +8303,7 @@ __metadata: "@types/jscodeshift": "npm:^0.11.10" ansi-regex: "npm:^6.0.1" cross-spawn: "npm:^7.0.6" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" jscodeshift: "npm:^0.15.1" prettier: "npm:^3.5.3" storybook: "workspace:*" @@ -8709,7 +8709,7 @@ __metadata: babel-plugin-react-docgen: "npm:^4.2.1" comment-parser: "npm:^1.4.1" empathic: "npm:^2.0.0" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" escodegen: "npm:^2.1.0" expect-type: "npm:^0.15.0" html-tags: "npm:^3.1.0" @@ -8795,7 +8795,7 @@ __metadata: ejs: "npm:^3.1.10" ejs-lint: "npm:^2.0.1" empathic: "npm:^2.0.0" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" esbuild: "npm:^0.27.0" eslint: "npm:^8.57.0" eslint-config-airbnb-typescript: "npm:^18.0.0" @@ -16338,15 +16338,15 @@ __metadata: languageName: node linkType: hard -"es-toolkit@npm:^1.36.0": - version: 1.36.0 - resolution: "es-toolkit@npm:1.36.0" +"es-toolkit@npm:^1.43.0": + version: 1.43.0 + resolution: "es-toolkit@npm:1.43.0" dependenciesMeta: "@trivago/prettier-plugin-sort-imports@4.3.0": unplugged: true prettier-plugin-sort-re-exports@0.0.1: unplugged: true - checksum: 10c0/ddc13b8b2dc935f517f0959b971b0a75488bb54a131368c3aec1682b8fd48467d39c4f1d46a7bcdc3d9424e9a4924d968487540ed77ec245c5a2d7b129f5ed62 + checksum: 10c0/bbff0b591fd01be9f37a34dad7964b590e4952fc594c1230140771687f05136caa6ab21962a6e9cde7c4b529a149171ed5179d6379d4a8e656dbf7e8d126999c languageName: node linkType: hard @@ -29242,7 +29242,7 @@ __metadata: downshift: "npm:^9.0.4" ejs: "npm:^3.1.10" empathic: "npm:^2.0.0" - es-toolkit: "npm:^1.36.0" + es-toolkit: "npm:^1.43.0" esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" execa: "npm:^8.0.1" exsolve: "npm:^1.0.7" From ce8e6b913d4d9896c85effe0c155133fd2a72f84 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 19 Dec 2025 11:07:47 +0100 Subject: [PATCH 7/7] type manifest content as unknown --- code/core/src/types/modules/core-common.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index a97a4eb444ee..9a9514cd3707 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -375,11 +375,7 @@ export interface ComponentsManifest { type ManifestName = string; -export type Manifests = { components?: ComponentsManifest } & Record< - ManifestName, - // TODO: type this so it can only be JSON-serializable data - any ->; +export type Manifests = { components?: ComponentsManifest } & Record; export type CsfEnricher = (csf: CsfFile, csfSource: CsfFile) => Promise;