Skip to content

React: Add component name to component manifest#32746

Closed
kasperpeulen wants to merge 24 commits into
nextfrom
kasper/manifest-component-name
Closed

React: Add component name to component manifest#32746
kasperpeulen wants to merge 24 commits into
nextfrom
kasper/manifest-component-name

Conversation

@kasperpeulen
Copy link
Copy Markdown
Member

@kasperpeulen kasperpeulen commented Oct 16, 2025

Closes #

What I did

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make 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/core team 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

  • New Features
    • Introduced experimental component manifest generation that automatically extracts component definitions and code snippets from stories.
    • Added /manifests/components.json endpoint to retrieve component metadata and usage examples.
    • New experimental_componentsManifest feature flag and componentManifestGenerator configuration option to enable custom manifest generation.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 16, 2025

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Type Definitions & Public API
code/core/src/types/modules/core-common.ts, code/core/src/types/modules/indexer.ts
Introduces ComponentManifest and ComponentManifestGenerator types; extends StorybookConfigRaw with componentManifestGenerator option and experimental_componentsManifest feature flag; adds optional componentPath field to index entry types.
Core Server Integration
code/core/src/core-server/build-static.ts, code/core/src/core-server/dev-server.ts
Adds feature-flagged manifest generation during build via ComponentManifestGenerator, writes to manifests/components.json; adds /manifests/components.json route handler in dev server with error handling and content-type headers.
Type Refinements
code/core/src/csf-tools/CsfFile.ts
Narrows _metaNode field type from t.Expression to t.ObjectExpression.
Type Relaxation
code/core/src/preview-api/modules/preview-web/render/mount-utils.ts
Relaxes generic type constraints for mountDestructured and getUsedProps signatures to accept flexible function types.
React Renderer: Code Snippet Generation
code/renderers/react/src/component-manifest/generateCodeSnippet.ts, code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx
New implementation for generating JSX code snippets from CSF story definitions; comprehensive test suite covering CSF3/CSF4 patterns, argument handling, nesting, render functions, and edge cases.
React Renderer: Manifest Generator
code/renderers/react/src/component-manifest/generator.ts, code/renderers/react/src/component-manifest/generator.test.ts
Main async generator that groups stories by component, reads source files, parses CSF, and produces component manifests with code snippets; includes test with in-memory filesystem setup.
React Renderer Export
code/renderers/react/src/preset.ts
Re-exports componentManifestGenerator as part of public API.
Test Infrastructure
code/renderers/react/__mocks__/fs/promises.cjs
New mock module for filesystem promises using memfs for in-memory testing.

Sequence Diagrams

sequenceDiagram
    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
Loading
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
Loading

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 docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kasper/manifest-component-name

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Oct 16, 2025

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit d3f1940

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

@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot Bot commented Oct 16, 2025

Package Benchmarks

Commit: d3f1940, ran on 16 October 2025 at 13:38:45 UTC

The following packages have significant changes to their size or dependencies:

@storybook/nextjs

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 types

Loosening to (...args: any[]) => any drops useful type-safety and editor help. Also make getUsedProps return 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 override a. Consider a single left-to-right fold over opening.attributes that 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: Import ts-dedent as default export

Per our learnings, ts-dedent exposes 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 to loadCsf for better diagnostics and stability

Supplying fileName helps 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 names

To validate JSX tag construction for component: Icons.Button or similar, add a case so the snippet prints <Icons.Button .../> correctly.

code/core/src/types/modules/indexer.ts (1)

67-74: Avoid duplicate componentPath in Base and Story types

BaseIndexEntry now has componentPath; StoryIndexEntry also declares it. Keep a single declaration in BaseIndexEntry to 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 signature

The 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 setup

beforeEach 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

📥 Commits

Reviewing files that changed from the base of the PR and between 57f9c36 and d3f1940.

📒 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 (use yarn lint:js:cmd <file>)

Files:

  • code/core/src/types/modules/indexer.ts
  • code/core/src/csf-tools/CsfFile.ts
  • code/core/src/preview-api/modules/preview-web/render/mount-utils.ts
  • code/core/src/core-server/dev-server.ts
  • code/renderers/react/src/preset.ts
  • code/renderers/react/src/component-manifest/generator.ts
  • code/renderers/react/src/component-manifest/generator.test.ts
  • code/core/src/core-server/build-static.ts
  • code/core/src/types/modules/core-common.ts
  • code/renderers/react/src/component-manifest/generateCodeSnippet.ts
  • code/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.ts
  • code/core/src/csf-tools/CsfFile.ts
  • code/core/src/preview-api/modules/preview-web/render/mount-utils.ts
  • code/core/src/core-server/dev-server.ts
  • code/renderers/react/src/preset.ts
  • code/renderers/react/src/component-manifest/generator.ts
  • code/renderers/react/src/component-manifest/generator.test.ts
  • code/core/src/core-server/build-static.ts
  • code/core/src/types/modules/core-common.ts
  • code/renderers/react/src/component-manifest/generateCodeSnippet.ts
  • code/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.ts
  • code/core/src/csf-tools/CsfFile.ts
  • code/core/src/preview-api/modules/preview-web/render/mount-utils.ts
  • code/core/src/core-server/dev-server.ts
  • code/renderers/react/src/preset.ts
  • code/renderers/react/src/component-manifest/generator.ts
  • code/renderers/react/src/component-manifest/generator.test.ts
  • code/core/src/core-server/build-static.ts
  • code/core/src/types/modules/core-common.ts
  • code/renderers/react/src/component-manifest/generateCodeSnippet.ts
  • code/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, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size

Files:

  • code/core/src/types/modules/indexer.ts
  • code/core/src/csf-tools/CsfFile.ts
  • code/core/src/preview-api/modules/preview-web/render/mount-utils.ts
  • code/core/src/core-server/dev-server.ts
  • code/renderers/react/src/preset.ts
  • code/renderers/react/src/component-manifest/generator.ts
  • code/renderers/react/src/component-manifest/generator.test.ts
  • code/core/src/core-server/build-static.ts
  • code/core/src/types/modules/core-common.ts
  • code/renderers/react/src/component-manifest/generateCodeSnippet.ts
  • code/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.ts
  • code/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.ts
  • code/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 using vi.mock() (e.g., filesystem, loggers)

Files:

  • code/renderers/react/src/component-manifest/generator.test.ts
  • code/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.Expression to t.ObjectExpression correctly aligns the type declaration with the actual usage throughout the code. All assignments to _metaNode (e.g., line 347) already pass t.ObjectExpression, and the narrowed type makes the contract more explicit and type-safe.

code/renderers/react/__mocks__/fs/promises.cjs (1)

1-3: Verify memfs availability and Jest mock configuration.

  • memfs is declared in code/core/package.json; ensure it’s hoisted or add it as a devDependency in code/renderers/react/package.json.
  • Confirm your Jest config (roots and/or moduleNameMapper) includes code/renderers/react so the __mocks__/fs/promises.cjs manual mock is discovered.
code/renderers/react/src/preset.ts (1)

11-12: Good addition: re-exporting the manifest generator

This surfaces the generator via the preset as intended.

code/core/src/core-server/build-static.ts (2)

1-1: LGTM on writeFile import

Needed for emitting the manifest file.


21-21: Type import looks correct

Imports ComponentManifestGenerator from the internal types surface.

code/core/src/core-server/dev-server.ts (1)

11-11: Type import is fine

Consistent 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

Comment on lines +168 to +183
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)
);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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 || true

Length of output: 2324


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
cat code/renderers/react/src/component-manifest/generator.ts

Length 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 || true

Length 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-calling presets.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), componentManifestGenerator is 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]

Comment on lines +139 to +163
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;
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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.ts

Length 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.

Comment on lines +221 to +236
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
)
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +246 to +247
const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(n);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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.json

Length 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.

Suggested change
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.

Comment on lines +11 to +46
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;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant