React: Add component name to component manifest#32746
Conversation
Co-authored-by: Jeppe Reinhold <jeppe@chromatic.com>
Co-authored-by: Jeppe Reinhold <jeppe@chromatic.com>
Co-authored-by: Jeppe Reinhold <jeppe@chromatic.com>
Co-authored-by: Jeppe Reinhold <jeppe@chromatic.com>
…r/code-snippets # Conflicts: # code/core/src/core-server/dev-server.ts
📝 WalkthroughWalkthroughThis PR introduces a ComponentManifest feature that generates component manifests with code snippet examples from Storybook stories. It adds type definitions, build-time and runtime manifest generation, server route handlers, CSF code snippet parsing, and comprehensive tests for React renderer integration. Changes
Sequence DiagramssequenceDiagram
participant Build as Build Process
participant Generator as componentManifestGenerator
participant FileSystem as File System
participant Output as manifests/components.json
Build->>Generator: Run with StoryIndexGenerator
Generator->>FileSystem: Read story files
Generator->>Generator: Parse CSF & extract meta
Generator->>Generator: Generate code snippets
Generator->>Generator: Group by component
Generator->>FileSystem: Create manifests dir
Generator->>FileSystem: Write manifest JSON
FileSystem->>Output: Persisted manifest
sequenceDiagram
participant Client as Client Request
participant Server as Dev Server
participant Presets as Presets/DI
participant Generator as componentManifestGenerator
participant Response as /manifests/components.json
Client->>Server: GET /manifests/components.json
alt experimental_componentsManifest disabled
Server->>Presets: Resolve ComponentManifestGenerator
Presets->>Generator: Load from preset
end
Server->>Generator: Run with StoryIndexGenerator
Generator->>Generator: Generate manifest
Server->>Response: Return JSON with content-type
Response->>Client: 200 manifest data
alt Error handling
Generator-->>Server: Error thrown
Server->>Response: 500 + error message
Response->>Client: Error response
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes The changes span multiple interconnected modules with heterogeneous logic patterns. Code snippet generation requires understanding AST manipulation and story definition patterns. The feature integrates across build, dev server, and renderer layers with new types, feature flags, and I/O operations. However, the changes are cohesive around a single feature, which helps manage complexity. Possibly related PRs
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run-many -t check -c production --parallel=7 |
❌ Failed | 16s | View ↗ |
nx run-many -t build -c production --parallel=3 |
✅ Succeeded | 3s | View ↗ |
☁️ Nx Cloud last updated this comment at 2025-10-16 13:33:12 UTC
Package BenchmarksCommit: The following packages have significant changes to their size or dependencies:
|
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 532 | 532 | 0 |
| Self size | 950 KB | 950 KB | 🎉 -188 B 🎉 |
| Dependency size | 58.57 MB | 58.59 MB | 🚨 +22 KB 🚨 |
| Bundle Size Analyzer | Link | Link |
@storybook/nextjs-vite
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 124 | 124 | 0 |
| Self size | 4.10 MB | 4.10 MB | 0 B |
| Dependency size | 21.63 MB | 21.66 MB | 🚨 +23 KB 🚨 |
| Bundle Size Analyzer | Link | Link |
@storybook/react-native-web-vite
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 157 | 157 | 0 |
| Self size | 31 KB | 31 KB | 0 B |
| Dependency size | 23.02 MB | 23.04 MB | 🚨 +23 KB 🚨 |
| Bundle Size Analyzer | Link | Link |
@storybook/react-vite
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 114 | 114 | 0 |
| Self size | 37 KB | 37 KB | 0 B |
| Dependency size | 19.58 MB | 19.60 MB | 🚨 +23 KB 🚨 |
| Bundle Size Analyzer | Link | Link |
@storybook/react-webpack5
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 272 | 272 | 0 |
| Self size | 25 KB | 25 KB | 🚨 +18 B 🚨 |
| Dependency size | 43.53 MB | 43.55 MB | 🚨 +22 KB 🚨 |
| Bundle Size Analyzer | Link | Link |
@storybook/react
| Before | After | Difference | |
|---|---|---|---|
| Dependency count | 2 | 2 | 0 |
| Self size | 894 KB | 917 KB | 🚨 +23 KB 🚨 |
| Dependency size | 28 KB | 28 KB | 🎉 -6 B 🎉 |
| Bundle Size Analyzer | Link | Link |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (8)
code/core/src/preview-api/modules/preview-web/render/mount-utils.ts (1)
3-5: Restore stronger types and explicit return typesLoosening to
(...args: any[]) => anydrops useful type-safety and editor help. Also makegetUsedPropsreturn type explicit.-import type { /* nothing */ } from '...' -export function mountDestructured(playFunction?: (...args: any[]) => any): boolean { +import type { PlayFunction, Renderer } from 'storybook/internal/types'; +export function mountDestructured<TRenderer extends Renderer>( + playFunction?: PlayFunction<TRenderer> +): boolean { return playFunction != null && getUsedProps(playFunction).includes('mount'); } -export function getUsedProps(fn: (...args: any[]) => any) { +export function getUsedProps(fn: (...args: unknown[]) => unknown): string[] {Also applies to: 7-8
code/renderers/react/src/component-manifest/generateCodeSnippet.ts (1)
499-533: Multi-spread precedence isn’t preserved (last-wins across interleaved spreads/attrs)Dropping all
{...args}and injecting once doesn’t replicate cases like<X {...args} a="x" {...args} />where the second spread should overridea. Consider a single left-to-right fold overopening.attributesthat materializes each spread at its position and applies last-wins.Can you add a test covering this precedence to confirm current behavior and guide the refactor?
code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx (3)
8-9: Importts-dedentas default exportPer our learnings,
ts-dedentexposes a default export. Named import may be undefined in some setups.Based on learnings
-import { dedent } from 'ts-dedent'; +import dedent from 'ts-dedent';
12-16: Pass a filename toloadCsffor better diagnostics and stabilitySupplying
fileNamehelps parsing/features that rely on it.-function generateExample(code: string) { - const csf = loadCsf(code, { makeTitle: (userTitle?: string) => userTitle ?? 'title' }).parse(); +function generateExample(code: string) { + const csf = loadCsf(code, { + makeTitle: (userTitle?: string) => userTitle ?? 'title', + fileName: 'Component.stories.tsx', + }).parse();
25-40: Add a test for dotted/namespaced component namesTo validate JSX tag construction for
component: Icons.Buttonor similar, add a case so the snippet prints<Icons.Button .../>correctly.code/core/src/types/modules/indexer.ts (1)
67-74: Avoid duplicatecomponentPathin Base and Story types
BaseIndexEntrynow hascomponentPath;StoryIndexEntryalso declares it. Keep a single declaration inBaseIndexEntryto prevent drift.export type StoryIndexEntry = BaseIndexEntry & { type: 'story'; subtype: 'story' | 'test'; - componentPath?: string; exportName?: string; parent?: StoryId; // exists only on tests parentName?: StoryName; // exists only on tests };Also applies to: 75-82
code/renderers/react/src/component-manifest/generator.ts (1)
48-59: Tidy groupBy reducer signatureThe default value on the reducer param is unnecessary; keep the init value only.
- return items.reduce<Partial<Record<K, T[]>>>((acc = {}, item, index) => { + return items.reduce<Partial<Record<K, T[]>>>((acc, item, index) => { const key = keySelector(item, index); acc[key] ??= []; acc[key].push(item); return acc; - }, {}); + }, {});code/renderers/react/src/component-manifest/generator.test.ts (1)
94-192: Move cleanup to afterEach; keep beforeEach focused on setupbeforeEach return value is ignored. Reset memfs in afterEach and optionally reset mocks.
-import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, afterEach, expect, test, vi } from 'vitest'; @@ -beforeEach(() => { +beforeEach(() => { vol.fromJSON({ // ... }); - return () => vol.reset(); }); + +afterEach(() => { + vol.reset(); + vi.resetAllMocks(); +});As per coding guidelines
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
code/core/src/core-server/build-static.ts(3 hunks)code/core/src/core-server/dev-server.ts(2 hunks)code/core/src/csf-tools/CsfFile.ts(1 hunks)code/core/src/preview-api/modules/preview-web/render/mount-utils.ts(1 hunks)code/core/src/types/modules/core-common.ts(4 hunks)code/core/src/types/modules/indexer.ts(1 hunks)code/renderers/react/__mocks__/fs/promises.cjs(1 hunks)code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx(1 hunks)code/renderers/react/src/component-manifest/generateCodeSnippet.ts(1 hunks)code/renderers/react/src/component-manifest/generator.test.ts(1 hunks)code/renderers/react/src/component-manifest/generator.ts(1 hunks)code/renderers/react/src/preset.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{js,jsx,json,html,ts,tsx,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{js,jsx,json,html,ts,tsx,mjs}: Run Prettier formatting on changed files before committing
Run ESLint on changed files and fix all errors/warnings before committing (useyarn lint:js:cmd <file>)
Files:
code/core/src/types/modules/indexer.tscode/core/src/csf-tools/CsfFile.tscode/core/src/preview-api/modules/preview-web/render/mount-utils.tscode/core/src/core-server/dev-server.tscode/renderers/react/src/preset.tscode/renderers/react/src/component-manifest/generator.tscode/renderers/react/src/component-manifest/generator.test.tscode/core/src/core-server/build-static.tscode/core/src/types/modules/core-common.tscode/renderers/react/src/component-manifest/generateCodeSnippet.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
**/*.{ts,tsx,js,jsx,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Export functions from modules when they need to be unit-tested
Files:
code/core/src/types/modules/indexer.tscode/core/src/csf-tools/CsfFile.tscode/core/src/preview-api/modules/preview-web/render/mount-utils.tscode/core/src/core-server/dev-server.tscode/renderers/react/src/preset.tscode/renderers/react/src/component-manifest/generator.tscode/renderers/react/src/component-manifest/generator.test.tscode/core/src/core-server/build-static.tscode/core/src/types/modules/core-common.tscode/renderers/react/src/component-manifest/generateCodeSnippet.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
code/**/*.{ts,tsx,js,jsx,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
In application code, use Storybook loggers instead of
console.*(client code:storybook/internal/client-logger; server code:storybook/internal/node-logger)
Files:
code/core/src/types/modules/indexer.tscode/core/src/csf-tools/CsfFile.tscode/core/src/preview-api/modules/preview-web/render/mount-utils.tscode/core/src/core-server/dev-server.tscode/renderers/react/src/preset.tscode/renderers/react/src/component-manifest/generator.tscode/renderers/react/src/component-manifest/generator.test.tscode/core/src/core-server/build-static.tscode/core/src/types/modules/core-common.tscode/renderers/react/src/component-manifest/generateCodeSnippet.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
{code/**,scripts/**}/**/*.{ts,tsx,js,jsx,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Do not use
console.log,console.warn, orconsole.errordirectly unless in isolated files where importing loggers would significantly increase bundle size
Files:
code/core/src/types/modules/indexer.tscode/core/src/csf-tools/CsfFile.tscode/core/src/preview-api/modules/preview-web/render/mount-utils.tscode/core/src/core-server/dev-server.tscode/renderers/react/src/preset.tscode/renderers/react/src/component-manifest/generator.tscode/renderers/react/src/component-manifest/generator.test.tscode/core/src/core-server/build-static.tscode/core/src/types/modules/core-common.tscode/renderers/react/src/component-manifest/generateCodeSnippet.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
code/**/*.{test,spec}.{ts,tsx}
📄 CodeRabbit inference engine (.cursorrules)
code/**/*.{test,spec}.{ts,tsx}: Place all test files under the code/ directory
Name test files as *.test.ts, *.test.tsx, *.spec.ts, or *.spec.tsx
Files:
code/renderers/react/src/component-manifest/generator.test.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
**/*.test.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (.cursor/rules/spy-mocking.mdc)
**/*.test.{ts,tsx,js,jsx}: Use vi.mock() with the spy: true option for all package and file mocks in Vitest tests
Place all mocks at the top of the test file before any test cases
Use vi.mocked() to type and access mocked functions
Implement mock behaviors in beforeEach blocks
Mock all required dependencies that the test subject uses
Mock implementations should be placed in beforeEach blocks
Each mock implementation should return a Promise for async functions
Mock implementations should match the expected return type of the original function
Use vi.mocked() to access and implement mock behaviors
Mock all required properties and methods that the test subject uses
Avoid direct function mocking without vi.mocked()
Avoid mock implementations outside of beforeEach blocks
Avoid mocking without the spy: true option
Avoid inline mock implementations within test cases
Avoid mocking only a subset of required dependencies
Mock at the highest level of abstraction needed
Keep mock implementations simple and focused
Use type-safe mocking with vi.mocked()
Document complex mock behaviors
Group related mocks together
Files:
code/renderers/react/src/component-manifest/generator.test.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
**/*.@(test|spec).{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.@(test|spec).{ts,tsx,js,jsx}: Unit tests should import and execute the functions under test rather than only asserting on syntax patterns
Mock external dependencies in tests usingvi.mock()(e.g., filesystem, loggers)
Files:
code/renderers/react/src/component-manifest/generator.test.tscode/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
🧬 Code graph analysis (6)
code/core/src/core-server/dev-server.ts (3)
code/core/src/core-server/presets/common-preset.ts (1)
features(197-210)code/renderers/react/src/preset.ts (1)
componentManifestGenerator(11-11)code/core/src/types/modules/core-common.ts (1)
ComponentManifestGenerator(354-356)
code/renderers/react/src/component-manifest/generator.ts (2)
code/renderers/react/src/component-manifest/generateCodeSnippet.ts (1)
getCodeSnippet(18-241)code/core/src/types/modules/core-common.ts (1)
ComponentManifestGenerator(354-356)
code/renderers/react/src/component-manifest/generator.test.ts (2)
code/renderers/react/src/component-manifest/generator.ts (1)
componentManifestGenerator(11-46)code/core/src/core-server/utils/StoryIndexGenerator.ts (1)
StoryIndexGenerator(103-878)
code/core/src/core-server/build-static.ts (2)
code/core/src/core-server/presets/common-preset.ts (1)
features(197-210)code/core/src/types/modules/core-common.ts (1)
ComponentManifestGenerator(354-356)
code/core/src/types/modules/core-common.ts (1)
code/core/src/core-server/utils/StoryIndexGenerator.ts (1)
StoryIndexGenerator(103-878)
code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx (2)
code/core/src/csf-tools/CsfFile.ts (1)
loadCsf(1021-1025)code/renderers/react/src/component-manifest/generateCodeSnippet.ts (1)
getCodeSnippet(18-241)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: normal
- GitHub Check: Core Unit Tests, windows-latest
🔇 Additional comments (7)
code/core/src/csf-tools/CsfFile.ts (1)
298-298: LGTM! Type narrowing improves clarity.The type change from
t.Expressiontot.ObjectExpressioncorrectly aligns the type declaration with the actual usage throughout the code. All assignments to_metaNode(e.g., line 347) already passt.ObjectExpression, and the narrowed type makes the contract more explicit and type-safe.code/renderers/react/__mocks__/fs/promises.cjs (1)
1-3: Verifymemfsavailability and Jest mock configuration.
memfsis declared incode/core/package.json; ensure it’s hoisted or add it as a devDependency incode/renderers/react/package.json.- Confirm your Jest config (roots and/or moduleNameMapper) includes
code/renderers/reactso the__mocks__/fs/promises.cjsmanual mock is discovered.code/renderers/react/src/preset.ts (1)
11-12: Good addition: re-exporting the manifest generatorThis surfaces the generator via the preset as intended.
code/core/src/core-server/build-static.ts (2)
1-1: LGTM on writeFile importNeeded for emitting the manifest file.
21-21: Type import looks correctImports ComponentManifestGenerator from the internal types surface.
code/core/src/core-server/dev-server.ts (1)
11-11: Type import is fineConsistent with the core types module.
code/core/src/types/modules/core-common.ts (1)
9-9: Types and feature flag surface look good
- ComponentManifest and ComponentManifestGenerator definitions are coherent.
- StorybookConfigRaw gains componentManifestGenerator (preset-provided) and the experimental_componentsManifest flag.
No further changes needed here.
Also applies to: 348-357, 371-371, 470-471
| const features = await presets.apply('features'); | ||
|
|
||
| if (features?.experimental_componentsManifest) { | ||
| const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( | ||
| 'componentManifestGenerator' | ||
| ); | ||
| const indexGenerator = await initializedStoryIndexGenerator; | ||
| if (componentManifestGenerator && indexGenerator) { | ||
| const manifests = await componentManifestGenerator(indexGenerator); | ||
| await mkdir(join(options.outputDir, 'manifests'), { recursive: true }); | ||
| await writeFile( | ||
| join(options.outputDir, 'manifests', 'components.json'), | ||
| JSON.stringify(manifests) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
Avoid re-applying features; consider parallelizing manifest write; verify generator shape
- Reuse the earlier computed features instead of re-calling presets.apply('features').
- Consider pushing the manifest generation into effects to run in parallel with other IO (like index.json), matching the existing pattern.
- Verify that presets.apply('componentManifestGenerator') resolves to a ComponentManifestGenerator (not a zero‑arg factory returning it), otherwise this call will fail at runtime.
Suggested refactor:
- const features = await presets.apply('features');
-
- if (features?.experimental_componentsManifest) {
- const componentManifestGenerator: ComponentManifestGenerator = await presets.apply(
- 'componentManifestGenerator'
- );
- const indexGenerator = await initializedStoryIndexGenerator;
- if (componentManifestGenerator && indexGenerator) {
- const manifests = await componentManifestGenerator(indexGenerator);
- await mkdir(join(options.outputDir, 'manifests'), { recursive: true });
- await writeFile(
- join(options.outputDir, 'manifests', 'components.json'),
- JSON.stringify(manifests)
- );
- }
- }
+ if (features?.experimental_componentsManifest) {
+ effects.push((async () => {
+ const componentManifestGenerator: ComponentManifestGenerator = await presets.apply(
+ 'componentManifestGenerator'
+ );
+ const indexGenerator = await initializedStoryIndexGenerator;
+ if (componentManifestGenerator && indexGenerator) {
+ const manifests = await componentManifestGenerator(indexGenerator);
+ await mkdir(join(options.outputDir, 'manifests'), { recursive: true });
+ await writeFile(
+ join(options.outputDir, 'manifests', 'components.json'),
+ JSON.stringify(manifests)
+ );
+ }
+ })());
+ }To confirm the generator shape provided by the React preset, run:
🏁 Script executed:
#!/usr/bin/env bash
# Inspect how the preset exports componentManifestGenerator
set -euo pipefail
rg -nC3 -g 'code/**' -P '\bcomponentManifestGenerator\b' code/renderers/react/src/preset.ts
cat code/renderers/react/src/preset.ts || trueLength of output: 2324
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
cat code/renderers/react/src/component-manifest/generator.tsLength of output: 2096
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
echo "Occurrences of presets.apply and features in build-static.ts:"
rg -nC3 "presets.apply" -n code/core/src/core-server/build-static.ts || true
echo
rg -nC3 "features" -n code/core/src/core-server/build-static.ts || trueLength of output: 1982
Fix componentManifestGenerator factory, reuse features, parallelize manifest IO
- Reuse the existing
features(from the Promise.all at line 101) instead of re-callingpresets.apply('features')at line 168. - Wrap manifest generation in
effects.push(...)(like other IO) so it runs in parallel with index generation. - In the React preset (
code/renderers/react/src/component-manifest/generator.ts),componentManifestGeneratoris an async zero-arg factory; you must invoke it after awaiting:const manifestFactory = await presets.apply('componentManifestGenerator'); const componentManifestGenerator = await manifestFactory();
[data-files: code/core/src/core-server/build-static.ts, code/renderers/react/src/component-manifest/generator.ts]
| app.use('/manifests/components.json', async (req, res) => { | ||
| try { | ||
| const features = await options.presets.apply('features'); | ||
| if (!features?.experimental_componentsManifest) { | ||
| const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( | ||
| 'componentManifestGenerator' | ||
| ); | ||
| const indexGenerator = await initializedStoryIndexGenerator; | ||
| if (componentManifestGenerator && indexGenerator) { | ||
| const manifest = await componentManifestGenerator(indexGenerator); | ||
| 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; | ||
| } | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
Feature-flag logic inverted; also prefer GET and clearer status
The route serves a manifest only when experimental_componentsManifest is disabled; this contradicts build-static (which writes manifests when enabled). Flip the condition. Also use app.get for clarity, and return 404 when not configured.
- app.use('/manifests/components.json', async (req, res) => {
+ app.get('/manifests/components.json', async (req, res) => {
try {
const features = await options.presets.apply('features');
- if (!features?.experimental_componentsManifest) {
+ if (features?.experimental_componentsManifest) {
const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
'componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;
if (componentManifestGenerator && indexGenerator) {
const manifest = await componentManifestGenerator(indexGenerator);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(manifest));
return;
}
}
- res.statusCode = 400;
- res.end('No component manifest generator configured.');
+ res.statusCode = 404;
+ res.end('Component manifest not 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;
}
});Also validate that presets.apply('componentManifestGenerator') returns a ComponentManifestGenerator (not a factory) as used here (see the verification script in build-static.ts comment).
🏁 Script executed:
#!/bin/bash
# Show imports and app setup in dev-server.ts
echo "=== dev-server.ts (first 200 lines) ==="
sed -n '1,200p' code/core/src/core-server/dev-server.ts
# Find presets.apply('componentManifestGenerator') usage in build-static.ts
echo "=== build-static.ts presets.apply occurrences ==="
rg -n "presets.apply('componentManifestGenerator')" -C3 code/core/src/core-server/build-static.tsLength of output: 7071
🏁 Script executed:
#!/bin/bash
# Locate build-static.ts
build_file=$(fd build-static.ts | head -n1)
echo "=== build-static.ts path: $build_file ==="
sed -n '1,200p' "$build_file"
grep -R -n "experimental_componentsManifest" "$build_file"
grep -R -n "componentManifestGenerator" "$build_file"
# Inspect ComponentManifestGenerator type
type_file=$(fd types.ts | head -n1)
echo "=== types.ts path: $type_file ==="
grep -R -n "ComponentManifestGenerator" "$type_file"
sed -n '1,200p' "$type_file"
# Search for presets.apply('componentManifestGenerator') usage
echo "=== presets.apply('componentManifestGenerator') across code ==="
rg -n "presets.apply('componentManifestGenerator')" -C2 .Length of output: 8668
Invert feature flag, use GET, and return 404
In dev-server.ts, update the /manifests/components.json route:
- app.use('/manifests/components.json', async (req, res) => {
+ app.get('/manifests/components.json', async (req, res) => {
try {
const features = await options.presets.apply('features');
- if (!features?.experimental_componentsManifest) {
+ if (features?.experimental_componentsManifest) {
const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
'componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;
if (componentManifestGenerator && indexGenerator) {
const manifest = await componentManifestGenerator(indexGenerator);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(manifest));
return;
}
}
- res.statusCode = 400;
- res.end('No component manifest generator configured.');
+ res.statusCode = 404;
+ res.end('Component manifest not 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;
}
});🤖 Prompt for AI Agents
In code/core/src/core-server/dev-server.ts around lines 139 to 163, change the
route to use app.get instead of app.use, invert the feature flag logic so the
manifest is only generated when features?.experimental_componentsManifest is
true, and when the feature is disabled or no
componentManifestGenerator/indexGenerator is configured return a 404 (not 400)
with a clear message; keep the existing try/catch but preserve logging and
return a 500 on unexpected errors.
| const name = t.jsxIdentifier(componentName); | ||
|
|
||
| const openingElAttrs: Array<t.JSXAttribute | t.JSXSpreadAttribute> = [ | ||
| ...injectedAttrs, | ||
| ...(invalidSpread ? [invalidSpread] : []), | ||
| ]; | ||
|
|
||
| const arrow = t.arrowFunctionExpression( | ||
| [], | ||
| t.jsxElement( | ||
| t.jsxOpeningElement(name, openingElAttrs, false), | ||
| t.jsxClosingElement(name), | ||
| toJsxChildren(merged.children), | ||
| false | ||
| ) | ||
| ); |
There was a problem hiding this comment.
JSX tag name must support dotted component names (build JSXMemberExpression)
Using t.jsxIdentifier(componentName) breaks when componentName contains dots (e.g., Icons.Arrow). Build a JSXMemberExpression chain instead.
- const name = t.jsxIdentifier(componentName);
+ const name = buildJsxName(componentName);
const openingElAttrs: Array<t.JSXAttribute | t.JSXSpreadAttribute> = [
...injectedAttrs,
...(invalidSpread ? [invalidSpread] : []),
];
const arrow = t.arrowFunctionExpression(
[],
t.jsxElement(
t.jsxOpeningElement(name, openingElAttrs, false),
t.jsxClosingElement(name),
toJsxChildren(merged.children),
false
)
);Add helper (outside the selected range):
function buildJsxName(name: string): t.JSXIdentifier | t.JSXMemberExpression {
if (name.includes('.')) {
const parts = name.split('.');
let acc: t.JSXIdentifier | t.JSXMemberExpression = t.jsxIdentifier(parts[0]!);
for (let i = 1; i < parts.length; i++) {
acc = t.jsxMemberExpression(acc, t.jsxIdentifier(parts[i]!));
}
return acc;
}
return t.jsxIdentifier(name);
}🤖 Prompt for AI Agents
In code/renderers/react/src/component-manifest/generateCodeSnippet.ts around
lines 221 to 236, the current code uses t.jsxIdentifier(componentName) which
fails for dotted component names like "Icons.Arrow"; add a helper function
(outside this range) that splits the name on '.' and builds a nested
t.jsxMemberExpression chain (falling back to t.jsxIdentifier for single-part
names), then replace the t.jsxIdentifier(componentName) call with a call to that
helper (e.g., const name = buildJsxName(componentName)); ensure the helper
returns the correct t.JSXIdentifier | t.JSXMemberExpression type and is used
wherever JSX tag names are created so dotted names render correctly.
| const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(n); | ||
|
|
There was a problem hiding this comment.
Attribute validation is too permissive (disallow '.' and handle ':' or treat as invalid)
/^[A-Za-z_][A-Za-z0-9_.:-]*$/ allows . and :, but downstream you always build t.jsxIdentifier(key). Dotted or namespaced attributes require different AST nodes. Safer: disallow . and : here (they’ll go into the invalid spread), or add explicit handling for namespaced attributes.
-const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(n);
+// Permit standard JSX/React prop names including dashes (data-*, aria-*), but exclude '.' and ':'.
+const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(n);Optionally, if you want to allow namespaced attributes (e.g., xlink:href), build t.jsxNamespacedName in toAttr when n.includes(':').
🤖 Prompt for AI Agents
In code/renderers/react/src/component-manifest/generateCodeSnippet.ts around
lines 246-247, the isValidJsxAttrName regex currently allows '.' and ':' which
is incompatible with the downstream use of t.jsxIdentifier; update validation to
disallow '.' and ':' (e.g. use a regex like /^[A-Za-z_][A-Za-z0-9_-]*$/) so
dotted or namespaced names fall into the invalid-spread path, and optionally
implement explicit handling in toAttr: if the attribute name contains ':' build
a t.jsxNamespacedName instead of t.jsxIdentifier.
|
|
||
| import { componentManifestGenerator } from './generator'; | ||
|
|
||
| vi.mock('node:fs/promises'); |
There was a problem hiding this comment.
🧩 Analysis chain
Fix fs/promises mocking to use memfs and enable spying
Auto-mocking node:fs/promises returns undefined fns; map it to memfs’ promises instead.
-vi.mock('node:fs/promises');
+vi.mock(
+ 'node:fs/promises',
+ async () => {
+ const { fs } = await import('memfs');
+ return fs.promises as any;
+ },
+ { spy: true }
+);As per coding guidelines
🏁 Script executed:
#!/bin/bash
set -e
# Locate the test file
echo "=== Finding generator.test.ts ==="
fd generator.test.ts
# Show first 50 lines of the test file
TEST_PATH=$(fd generator.test.ts)
echo "=== generator.test.ts content ==="
sed -n '1,50p' "$TEST_PATH"
# Check for memfs in package.json
echo "=== Checking package.json for memfs ==="
grep -R '"memfs"' -n package.jsonLength of output: 354
🏁 Script executed:
#!/bin/bash
set -e
echo "=== Listing component-manifest directory ==="
ls -R code/renderers/react/src/component-manifest
echo
echo "=== Test and spec files in component-manifest ==="
fd -t f '' code/renderers/react/src/component-manifest | grep -E '\.(test|spec)\.tsx?$'
echo
echo "=== Checking for memfs in package.json ==="
grep -n memfs package.json || echo "memfs not found in package.json"
echo
echo "=== Checking for memfs in yarn.lock ==="
grep -n memfs yarn.lock || echo "memfs not found in yarn.lock"Length of output: 504
🏁 Script executed:
#!/bin/bash
set -e
# Locate generator.test.ts
TEST_PATH=$(fd -t f '^generator\.test\.ts$' code/renderers/react/src/component-manifest)
echo "=== Test file path: $TEST_PATH ==="
# Show import and mock lines in test
grep -nE "import|vi.mock" "$TEST_PATH" || echo "No import or vi.mock found"
echo
# Locate generator.ts
GEN_PATH=$(fd -t f '^generator\.ts$' code/renderers/react/src/component-manifest)
echo "=== Source file path: $GEN_PATH ==="
# Show fs/promises imports in source
grep -n "fs/promises" "$GEN_PATH" || echo "No fs/promises import found"Length of output: 1314
🏁 Script executed:
#!/bin/bash
set -e
# Locate package.json in code/renderers/react
PKG_PATH=$(fd -t f -I package.json code/renderers/react)
echo "=== Using package.json at $PKG_PATH ==="
# Show first 200 lines of package.json
sed -n '1,200p' "$PKG_PATH"
# Check for memfs in dependencies/devDependencies
echo "=== Searching for memfs in $PKG_PATH ==="
grep -n '"memfs"' "$PKG_PATH" || echo "memfs not found in code/renderers/react/package.json"Length of output: 2847
Mock fs/promises with memfs and enable spying in generator.test.ts
Apply this diff in code/renderers/react/src/component-manifest/generator.test.ts:
- vi.mock('node:fs/promises');
+ vi.mock(
+ 'node:fs/promises',
+ async () => {
+ const { fs } = await import('memfs');
+ return fs.promises as any;
+ },
+ { spy: true }
+ );Also add memfs to devDependencies in code/renderers/react/package.json so the import resolves.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| vi.mock('node:fs/promises'); | |
| // Replace the existing fs/promises mock: | |
| vi.mock( | |
| 'node:fs/promises', | |
| async () => { | |
| const { fs } = await import('memfs'); | |
| return fs.promises as any; | |
| }, | |
| { spy: true } | |
| ); |
🤖 Prompt for AI Agents
In code/renderers/react/src/component-manifest/generator.test.ts around line 10,
the test currently mocks 'node:fs/promises' directly which prevents using an
in-memory filesystem and spying; replace that mock with a memfs-based mock by
importing memfs and mocking 'node:fs/promises' to use memfs.promises so tests
can read/write an in-memory FS and allow spies; update the test to enable spying
on fs.promises methods as needed (e.g., jest.spyOn) rather than a full module
mock. Also add "memfs" to devDependencies in code/renderers/react/package.json
so the import resolves.
| export const componentManifestGenerator = async () => { | ||
| return (async (storyIndexGenerator) => { | ||
| const index = await storyIndexGenerator.getIndex(); | ||
| const groupByComponentId = groupBy( | ||
| Object.values(index.entries).filter( | ||
| (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath | ||
| ), | ||
| (it) => it.id.split('--')[0] | ||
| ); | ||
| const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => | ||
| group && group?.length > 0 ? [group[0]] : [] | ||
| ); | ||
| const components = await Promise.all( | ||
| singleEntryPerComponent.map(async (entry) => { | ||
| const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); | ||
| const csf = loadCsf(code, { makeTitle: (title) => title ?? 'No title' }).parse(); | ||
| const componentName = csf._meta?.component; | ||
| return { | ||
| id: entry.id.split('--')[0], | ||
| name: componentName, | ||
| examples: !componentName | ||
| ? [] | ||
| : Object.entries(csf._storyPaths) | ||
| .map(([name, path]) => ({ | ||
| name, | ||
| snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)) | ||
| .code, | ||
| })) | ||
| .filter(Boolean), | ||
| }; | ||
| }) | ||
| ); | ||
|
|
||
| return Object.fromEntries(components.map((component) => [component.id, component])); | ||
| }) satisfies ComponentManifestGenerator; | ||
| }; |
There was a problem hiding this comment.
Use StoryIndexGenerator.workingDir; remove redundant filter; confirm preset wiring
- Resolve story file paths against storyIndexGenerator.options.workingDir, not process.cwd(), to handle non-root working dirs.
- The final .filter(Boolean) on an array of objects is redundant.
- Ensure the preset exposes a resolved ComponentManifestGenerator (not this zero‑arg factory), or core must call the factory before use.
Apply:
export const componentManifestGenerator = async () => {
return (async (storyIndexGenerator) => {
const index = await storyIndexGenerator.getIndex();
+ const cwd = (storyIndexGenerator as any)?.options?.workingDir ?? process.cwd();
@@
- const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8');
+ const code = await readFile(path.join(cwd, entry.importPath), 'utf-8');
@@
- .map(([name, path]) => ({
+ .map(([name, path]) => ({
name,
snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName))
.code,
}))
- .filter(Boolean),
+ ,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const componentManifestGenerator = async () => { | |
| return (async (storyIndexGenerator) => { | |
| const index = await storyIndexGenerator.getIndex(); | |
| const groupByComponentId = groupBy( | |
| Object.values(index.entries).filter( | |
| (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath | |
| ), | |
| (it) => it.id.split('--')[0] | |
| ); | |
| const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => | |
| group && group?.length > 0 ? [group[0]] : [] | |
| ); | |
| const components = await Promise.all( | |
| singleEntryPerComponent.map(async (entry) => { | |
| const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); | |
| const csf = loadCsf(code, { makeTitle: (title) => title ?? 'No title' }).parse(); | |
| const componentName = csf._meta?.component; | |
| return { | |
| id: entry.id.split('--')[0], | |
| name: componentName, | |
| examples: !componentName | |
| ? [] | |
| : Object.entries(csf._storyPaths) | |
| .map(([name, path]) => ({ | |
| name, | |
| snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)) | |
| .code, | |
| })) | |
| .filter(Boolean), | |
| }; | |
| }) | |
| ); | |
| return Object.fromEntries(components.map((component) => [component.id, component])); | |
| }) satisfies ComponentManifestGenerator; | |
| }; | |
| export const componentManifestGenerator = async () => { | |
| return (async (storyIndexGenerator) => { | |
| const index = await storyIndexGenerator.getIndex(); | |
| const cwd = (storyIndexGenerator as any)?.options?.workingDir ?? process.cwd(); | |
| const groupByComponentId = groupBy( | |
| Object.values(index.entries).filter( | |
| (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath | |
| ), | |
| (it) => it.id.split('--')[0] | |
| ); | |
| const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => | |
| group && group?.length > 0 ? [group[0]] : [] | |
| ); | |
| const components = await Promise.all( | |
| singleEntryPerComponent.map(async (entry) => { | |
| const code = await readFile(path.join(cwd, entry.importPath), 'utf-8'); | |
| const csf = loadCsf(code, { makeTitle: (title) => title ?? 'No title' }).parse(); | |
| const componentName = csf._meta?.component; | |
| return { | |
| id: entry.id.split('--')[0], | |
| name: componentName, | |
| examples: !componentName | |
| ? [] | |
| : Object.entries(csf._storyPaths) | |
| .map(([name, path]) => ({ | |
| name, | |
| snippet: recast.print( | |
| getCodeSnippet(path, csf._metaNode ?? null, componentName) | |
| ).code, | |
| })), | |
| }; | |
| }) | |
| ); | |
| return Object.fromEntries(components.map((component) => [component.id, component])); | |
| }) satisfies ComponentManifestGenerator; | |
| }; |
🤖 Prompt for AI Agents
In code/renderers/react/src/component-manifest/generator.ts around lines 11 to
46, update the generator to resolve story file paths using
storyIndexGenerator.options.workingDir instead of process.cwd(), remove the
final .filter(Boolean) since the array holds objects (not falsy values), and
change the export from a zero-arg factory to a proper ComponentManifestGenerator
that accepts the storyIndexGenerator parameter (or ensure the preset returns a
resolved generator rather than this no-arg wrapper) so the core receives a
ready-to-use generator; specifically, replace path.join(process.cwd(),
entry.importPath) with path.join(storyIndexGenerator.options.workingDir,
entry.importPath), drop the .filter(Boolean) call on examples, and adjust the
export/signature so the preset wiring provides the generator instance (or call
the factory with the storyIndexGenerator before use).
Closes #
What I did
Checklist for Contributors
Testing
The changes in this PR are covered in the following automated tests:
Manual testing
This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!
Documentation
MIGRATION.MD
Checklist for Maintainers
When this PR is ready for testing, make sure to add
ci:normal,ci:mergedorci:dailyGH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found incode/lib/cli-storybook/src/sandbox-templates.tsMake sure this PR contains one of the labels below:
Available labels
bug: Internal changes that fixes incorrect behavior.maintenance: User-facing maintenance tasks.dependencies: Upgrading (sometimes downgrading) dependencies.build: Internal-facing build tooling & test updates. Will not show up in release changelog.cleanup: Minor cleanup style change. Will not show up in release changelog.documentation: Documentation only changes. Will not show up in release changelog.feature request: Introducing a new feature.BREAKING CHANGE: Changes that break compatibility in some way with current major version.other: Changes that don't fit in the above categories.🦋 Canary release
This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the
@storybookjs/coreteam here.core team members can create a canary release here or locally with
gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>Summary by CodeRabbit
/manifests/components.jsonendpoint to retrieve component metadata and usage examples.experimental_componentsManifestfeature flag andcomponentManifestGeneratorconfiguration option to enable custom manifest generation.