From aadbe2caf71fe6f59206882d89df2128a5d9abed Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 19 Dec 2025 14:31:13 +0100 Subject: [PATCH 1/4] add 'manifest' tag to all index entries by default, experimental_manifest preset now takes a pre-filtered list of entries, support users globally disabling manifest with '!manifest' in preview.js --- .../core-server/utils/StoryIndexGenerator.ts | 2 +- .../core-server/utils/manifests/manifests.ts | 33 +++++++++-------- .../react/src/componentManifest/generator.ts | 37 ++++++------------- 3 files changed, 31 insertions(+), 41 deletions(-) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 783630a08543..b71367d03742 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -875,7 +875,7 @@ export class StoryIndexGenerator { getProjectTags(previewCode?: string) { let projectTags = [] as Tag[]; - const defaultTags = ['dev', 'test']; + const defaultTags = ['dev', 'test', 'manifest']; if (previewCode) { try { const projectAnnotations = loadConfig(previewCode).parse(); diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts index bbbf7c5f1ce6..6e6a40fe646e 100644 --- a/code/core/src/core-server/utils/manifests/manifests.ts +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -9,12 +9,22 @@ import invariant from 'tiny-invariant'; import { renderComponentsManifest } from './render-components-manifest'; +async function getManifests(presets: Presets) { + const generator = await presets.apply('storyIndexGenerator'); + invariant(generator, 'storyIndexGenerator must be configured'); + const index = await generator.getIndex(); + const manifestEntries = Object.values(index.entries).filter( + (entry) => entry.tags?.includes('manifest') ?? false + ); + + return await presets.apply('experimental_manifests', undefined, { + manifestEntries, + }); +} + 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 }); + const manifests = await getManifests(presets); if (Object.keys(manifests).length === 0) { return; } @@ -37,18 +47,10 @@ export async function writeManifests(outputDir: string, presets: Presets) { } 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); + const manifests = await getManifests(presets); + const manifest = manifests[req.params.name]; if (manifest) { res.setHeader('Content-Type', 'application/json'); @@ -66,7 +68,8 @@ export function registerManifests({ app, presets }: { app: Polka; presets: Prese app.get('/manifests/components.html', async (req, res) => { try { - const manifest = (await getManifest('components')) as ComponentsManifest | undefined; + const manifests = await getManifests(presets); + const manifest = manifests.components; if (!manifest) { res.statusCode = 404; diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index fd4453d2a5f7..1ebd5e43f4e5 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -1,11 +1,10 @@ import { recast } from 'storybook/internal/babel'; -import { combineTags } from 'storybook/internal/csf'; import { extractDescription, loadCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; +import type { IndexEntry } from 'storybook/internal/types'; import { type ComponentManifest, type PresetPropertyFn, - type StoryIndex, type StorybookConfigRaw, } from 'storybook/internal/types'; @@ -52,17 +51,15 @@ function getPackageInfo(componentPath: string | undefined, fallbackPath: string) function extractStories( csf: ReturnType['parse']>, - componentName: string | undefined + componentName: string | undefined, + manifestEntries: IndexEntry[] ) { - return Object.keys(csf._stories) - .filter((storyName) => - combineTags( - 'manifest', - ...(csf.meta.tags ?? []), - ...(csf._stories[storyName].tags ?? []) - ).includes('manifest') + return Object.entries(csf._stories) + .filter(([, story]) => + // Only include stories that are in the list of entries already filtered for the 'manifest' tag + manifestEntries.some((entry) => entry.id === story.id) ) - .map((storyName) => { + .map(([storyName]) => { try { const jsdocComment = extractDescription(csf._storyStatements[storyName]); const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; @@ -100,16 +97,14 @@ function extractComponentDescription( export const manifests: PresetPropertyFn< 'experimental_manifests', StorybookConfigRaw, - { index: StoryIndex } -> = async (existingManifests = {}, { index }) => { + { manifestEntries: IndexEntry[] } +> = async (existingManifests = {}, { manifestEntries }) => { invalidateCache(); const startPerformance = performance.now(); const entriesByUniqueComponent = uniqBy( - Object.values(index.entries).filter( - (entry) => entry.type === 'story' && entry.subtype === 'story' - ), + manifestEntries.filter((entry) => entry.type === 'story' && entry.subtype === 'story'), (entry) => entry.id.split('--')[0] ); @@ -119,14 +114,6 @@ export const manifests: PresetPropertyFn< 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, ''); @@ -144,7 +131,7 @@ export const manifests: PresetPropertyFn< const imports = getImports({ components: allComponents, packageName }).join('\n').trim() || fallbackImport; - const stories = extractStories(csf, component?.componentName); + const stories = extractStories(csf, component?.componentName, manifestEntries); const base = { id, From 0a2ee4237a43a0f91ec370004172fa4d49ee57fc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 22 Dec 2025 10:13:40 +0100 Subject: [PATCH 2/4] fix tests --- .../utils/manifests/manifests.test.ts | 67 ++++++++++++++++++- .../core-server/utils/manifests/manifests.ts | 8 ++- .../react/src/componentManifest/fixtures.ts | 14 ++-- .../src/componentManifest/generator.test.ts | 51 +++++++------- 4 files changed, 106 insertions(+), 34 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 5728f1c8ba95..41ceddf8a452 100644 --- a/code/core/src/core-server/utils/manifests/manifests.test.ts +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -20,7 +20,11 @@ describe('manifests', () => { let mockManifests: Manifests; const setupMockPresets = () => { - mockGenerator = { getIndex: vi.fn().mockResolvedValue({} as StoryIndex) }; + mockGenerator = { + getIndex: vi.fn().mockResolvedValue({ + entries: {}, + } as StoryIndex), + }; mockManifests = {}; return { @@ -114,6 +118,67 @@ describe('manifests', () => { expect(vi.mocked(logger).error).toHaveBeenCalledWith('Failed to generate manifests'); expect(vi.mocked(logger).error).toHaveBeenCalledWith(errorString); }); + + it('should filter entries by manifest tag and pass manifestEntries to preset', async () => { + mockGenerator.getIndex.mockResolvedValue({ + v: 5, + entries: { + 'story-with-manifest': { + type: 'story', + subtype: 'story', + id: 'story-with-manifest', + name: 'Story', + title: 'Example', + importPath: './Example.stories.tsx', + tags: ['manifest', 'other'], + }, + 'story-without-manifest': { + type: 'story', + subtype: 'story', + id: 'story-without-manifest', + name: 'Other', + title: 'Other', + importPath: './Other.stories.tsx', + tags: ['other'], + }, + 'docs-entry': { + type: 'docs', + id: 'docs', + name: 'Docs', + title: 'Docs', + importPath: './Docs.mdx', + tags: ['manifest'], + storiesImports: [], + }, + }, + } as StoryIndex); + + mockManifests = { custom: { data: 'value' } }; + + await writeManifests('/output', mockPresets); + + expect(mockPresets.apply).toHaveBeenCalledWith( + 'experimental_manifests', + undefined, + expect.objectContaining({ + manifestEntries: expect.arrayContaining([ + expect.objectContaining({ id: 'story-with-manifest' }), + ]), + }) + ); + + // Get the specific apply call to the experimental_manifests preset + const manifestsPresetCall = (mockPresets.apply as any).mock.calls.find( + (call: any) => call[0] === 'experimental_manifests' + ); + // Should include both story and docs entries with manifest tag + expect(manifestsPresetCall[2].manifestEntries).toHaveLength(2); + const entryIds = manifestsPresetCall[2].manifestEntries.map((entry: any) => entry.id); + expect(entryIds).toContain('story-with-manifest'); + expect(entryIds).toContain('docs'); + // Should NOT include story without manifest tag + expect(entryIds).not.toContain('story-without-manifest'); + }); }); describe('registerManifests', () => { diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts index 6e6a40fe646e..a77907df7a59 100644 --- a/code/core/src/core-server/utils/manifests/manifests.ts +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -17,9 +17,11 @@ async function getManifests(presets: Presets) { (entry) => entry.tags?.includes('manifest') ?? false ); - return await presets.apply('experimental_manifests', undefined, { - manifestEntries, - }); + return ( + (await presets.apply('experimental_manifests', undefined, { + manifestEntries, + })) ?? {} + ); } export async function writeManifests(outputDir: string, presets: Presets) { diff --git a/code/renderers/react/src/componentManifest/fixtures.ts b/code/renderers/react/src/componentManifest/fixtures.ts index e7597601f306..2a3f5df215b3 100644 --- a/code/renderers/react/src/componentManifest/fixtures.ts +++ b/code/renderers/react/src/componentManifest/fixtures.ts @@ -8,6 +8,7 @@ export const fsMocks = { import { Button } from './Button'; const meta = { + title: 'Example/Button', component: Button, args: { onClick: fn() }, } satisfies Meta; @@ -62,6 +63,7 @@ export const fsMocks = { * @summary Component summary */ const meta = { + title: 'Example/Header', component: Header, args: { onLogin: fn(), @@ -118,7 +120,7 @@ export const indexJson = { title: 'Example/Button', importPath: './src/stories/Button.stories.ts', componentPath: './src/stories/Button.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'Primary', }, 'example-button--secondary': { @@ -129,7 +131,7 @@ export const indexJson = { title: 'Example/Button', importPath: './src/stories/Button.stories.ts', componentPath: './src/stories/Button.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'Secondary', }, 'example-button--large': { @@ -140,7 +142,7 @@ export const indexJson = { title: 'Example/Button', importPath: './src/stories/Button.stories.ts', componentPath: './src/stories/Button.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'Large', }, 'example-button--small': { @@ -151,7 +153,7 @@ export const indexJson = { title: 'Example/Button', importPath: './src/stories/Button.stories.ts', componentPath: './src/stories/Button.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'Small', }, 'example-header--docs': { @@ -171,7 +173,7 @@ export const indexJson = { title: 'Example/Header', importPath: './src/stories/Header.stories.ts', componentPath: './src/stories/Header.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'LoggedIn', }, 'example-header--logged-out': { @@ -182,7 +184,7 @@ export const indexJson = { title: 'Example/Header', importPath: './src/stories/Header.stories.ts', componentPath: './src/stories/Header.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], exportName: 'LoggedOut', }, }, diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 1bda84f98fab..f33b2b88862b 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -12,7 +12,10 @@ beforeEach(() => { }); test('manifests generates correct id, name, description and examples ', async () => { - const result = await manifests(undefined, { index: indexJson } as any); + const manifestEntries = Object.values(indexJson.entries).filter( + (entry) => entry.tags?.includes('manifest') ?? false + ); + const result = await manifests(undefined, { manifestEntries } as any); expect(result?.components).toMatchInlineSnapshot(` { @@ -260,24 +263,21 @@ async function getManifestForStory(code: string) { '/app' ); - const indexJson = { - v: 5, - entries: { - 'example-button--primary': { - type: 'story', - subtype: 'story', - id: 'example-button--primary', - name: 'Primary', - title: 'Example/Button', - importPath: './src/stories/Button.stories.ts', - componentPath: './src/stories/Button.tsx', - tags: ['dev', 'test', 'vitest', 'autodocs'], - exportName: 'Primary', - }, + const manifestEntries = [ + { + type: 'story', + subtype: 'story', + id: 'example-button--primary', + name: 'Primary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs', 'manifest'], + exportName: 'Primary', }, - }; + ]; - const result = await manifests(undefined, { index: indexJson } as any); + const result = await manifests(undefined, { manifestEntries } as any); return result?.components?.components?.['example-button']; } @@ -288,6 +288,7 @@ function withCSF3(body: string) { import { Button } from './Button'; const meta = { + title: 'Example/Button', component: Button, args: { onClick: fn() }, } satisfies Meta; @@ -303,7 +304,9 @@ test('fall back to index title when no component name', async () => { import { Button } from './Button'; export default { + title: 'Example/Button', args: { onClick: fn() }, + tags: ['manifest'], }; export const Primary = () =>