Skip to content

Commit 40c0476

Browse files
committed
Improve error handling of component manifest generation
1 parent 26d8df6 commit 40c0476

File tree

8 files changed

+381
-73
lines changed

8 files changed

+381
-73
lines changed

code/core/src/csf-tools/CsfFile.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface BabelFile {
3333
opts: any;
3434
hub: any;
3535
metadata: object;
36-
path: any;
36+
path: NodePath<t.Program>;
3737
scope: any;
3838
inputMap: object | null;
3939
code: string;
@@ -301,6 +301,8 @@ export class CsfFile {
301301

302302
_metaStatement: t.Statement | undefined;
303303

304+
_metaStatementPath: NodePath<t.Statement> | undefined;
305+
304306
_metaNode: t.ObjectExpression | undefined;
305307

306308
_metaPath: NodePath<t.ExportDefaultDeclaration> | undefined;
@@ -484,11 +486,22 @@ export class CsfFile {
484486
t.isVariableDeclaration(topLevelNode) &&
485487
topLevelNode.declarations.find(isVariableDeclarator)
486488
);
489+
490+
self._metaStatementPath =
491+
self._file.path
492+
.get('body')
493+
.find(
494+
(topLevelPath) =>
495+
topLevelPath.isVariableDeclaration() &&
496+
topLevelPath.node.declarations.some(isVariableDeclarator)
497+
) ?? undefined;
498+
487499
decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find(
488500
isVariableDeclarator
489501
)?.init;
490502
} else {
491503
self._metaStatement = node;
504+
self._metaStatementPath = path;
492505
decl = node.declaration;
493506
}
494507

@@ -1036,7 +1049,10 @@ export const babelParseFile = ({
10361049
filename?: string;
10371050
ast?: t.File;
10381051
}): BabelFile => {
1039-
return new BabelFileClass({ filename }, { code, ast: ast ?? babelParse(code) });
1052+
return new BabelFileClass(
1053+
{ filename, highlightCode: false },
1054+
{ code, ast: ast ?? babelParse(code) }
1055+
);
10401056
};
10411057

10421058
export const loadCsf = (code: string, options: CsfOptions) => {

code/core/src/types/modules/core-common.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,12 +347,14 @@ export type TagsOptions = Record<Tag, Partial<TagOptions>>;
347347

348348
export interface ComponentManifest {
349349
id: string;
350-
name: string;
350+
path: string;
351+
name?: string;
351352
description?: string;
352353
import?: string;
353354
summary?: string;
354-
examples: { name: string; snippet: string }[];
355+
examples: { name: string; snippet?: string; error?: { message: string } }[];
355356
jsDocTags: Record<string, string[]>;
357+
error?: { message: string };
356358
}
357359

358360
export interface ComponentsManifest {

code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,15 @@ test('Edge case identifier we can not find', () => {
8181
const input = withCSF3(`
8282
export const Default = someImportOrWhatever;
8383
`);
84-
expect(generateExample(input)).toMatchInlineSnapshot(
85-
`"const Default = () => <Button>Click me</Button>;"`
84+
expect(() => generateExample(input)).toThrowErrorMatchingInlineSnapshot(
85+
`
86+
[SyntaxError: Expected story to be csf factory, function or an object expression
87+
11 |
88+
12 |
89+
> 13 | export const Default = someImportOrWhatever;
90+
| ^^^^^^^^^^^^^^^^^^^^
91+
14 | ]
92+
`
8693
);
8794
});
8895

code/renderers/react/src/componentManifest/generateCodeSnippet.ts

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import { type NodePath, types as t } from 'storybook/internal/babel';
22

3-
function invariant(condition: any, message?: string | (() => string)): asserts condition {
4-
if (condition) {
5-
return;
6-
}
7-
throw new Error(typeof message === 'function' ? message() : message);
8-
}
3+
import { invariant } from './utils';
94

105
function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttribute | null {
116
if (entries.length === 0) {
@@ -23,19 +18,34 @@ function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttrib
2318
export function getCodeSnippet(
2419
storyExportPath: NodePath<t.ExportNamedDeclaration>,
2520
metaObj: t.ObjectExpression | null | undefined,
26-
componentName: string
21+
componentName?: string
2722
): t.VariableDeclaration {
28-
const declaration = storyExportPath.get('declaration') as NodePath<t.Declaration>;
29-
invariant(declaration.isVariableDeclaration(), 'Expected variable declaration');
23+
const declaration = storyExportPath.get('declaration');
24+
invariant(
25+
declaration.isVariableDeclaration(),
26+
() => storyExportPath.buildCodeFrameError('Expected story to be a variable declaration').message
27+
);
3028

31-
const declarator = declaration.get('declarations')[0] as NodePath<t.VariableDeclarator>;
32-
const init = declarator.get('init') as NodePath<t.Expression>;
33-
invariant(init.isExpression(), 'Expected story initializer to be an expression');
29+
const declarations = declaration.get('declarations');
30+
invariant(
31+
declarations.length === 1,
32+
storyExportPath.buildCodeFrameError('Expected one story declaration').message
33+
);
34+
35+
const declarator = declarations[0];
36+
const init = declarator.get('init');
37+
invariant(
38+
init.isExpression(),
39+
() => declarator.buildCodeFrameError('Expected story initializer to be an expression').message
40+
);
3441

3542
const storyId = declarator.get('id');
36-
invariant(storyId.isIdentifier(), 'Expected named const story export');
43+
invariant(
44+
storyId.isIdentifier(),
45+
() => declaration.buildCodeFrameError('Expected story to have a name').message
46+
);
3747

38-
let story: NodePath<t.Expression> | null = init;
48+
let normalizedInit: NodePath<t.Expression> = init;
3949

4050
if (init.isCallExpression()) {
4151
const callee = init.get('callee');
@@ -50,47 +60,63 @@ export function getCodeSnippet(
5060
if (obj.isIdentifier() && isBind) {
5161
const resolved = resolveBindIdentifierInit(storyExportPath, obj);
5262
if (resolved) {
53-
story = resolved;
63+
normalizedInit = resolved;
5464
}
5565
}
5666
}
5767

5868
// Fallback: treat call expression as story factory and use first argument
59-
if (story === init) {
69+
if (init === normalizedInit) {
6070
const args = init.get('arguments');
61-
if (args.length === 0) {
62-
story = null;
63-
} else {
64-
const storyArgument = args[0];
65-
invariant(storyArgument.isExpression());
66-
story = storyArgument;
67-
}
71+
invariant(
72+
args.length === 1,
73+
() => init.buildCodeFrameError('Could not evaluate story expression').message
74+
);
75+
const storyArgument = args[0];
76+
invariant(
77+
storyArgument.isExpression(),
78+
() => init.buildCodeFrameError('Could not evaluate story expression').message
79+
);
80+
normalizedInit = storyArgument;
6881
}
6982
}
7083

84+
normalizedInit = normalizedInit.isTSSatisfiesExpression()
85+
? normalizedInit.get('expression')
86+
: normalizedInit.isTSAsExpression()
87+
? normalizedInit.get('expression')
88+
: normalizedInit;
89+
7190
// If the story is already a function, try to inline args like in render() when using `{...args}`
7291

73-
// Otherwise it must be an object story
74-
const storyObjPath =
75-
story == null || story.isArrowFunctionExpression() || story.isFunctionExpression()
76-
? null
77-
: story.isTSSatisfiesExpression()
78-
? story.get('expression')
79-
: story.isTSAsExpression()
80-
? story.get('expression')
81-
: story;
92+
let story: NodePath<t.ArrowFunctionExpression | t.FunctionExpression | t.ObjectExpression>;
93+
if (normalizedInit.isArrowFunctionExpression() || normalizedInit.isFunctionExpression()) {
94+
story = normalizedInit;
95+
} else if (normalizedInit.isObjectExpression()) {
96+
story = normalizedInit;
97+
} else {
98+
throw normalizedInit.buildCodeFrameError(
99+
'Expected story to be csf factory, function or an object expression'
100+
);
101+
}
82102

83-
const storyProperties = storyObjPath?.isObjectExpression()
84-
? storyObjPath.get('properties').filter((p) => p.isObjectProperty())
85-
: [];
103+
const storyProperties = story?.isObjectExpression()
104+
? story.get('properties').filter((p) => p.isObjectProperty())
105+
: // Find CSF2 properties
106+
[];
86107

87108
// Prefer an explicit render() when it is a function (arrow/function)
88109
const renderPath = storyProperties
89110
.filter((p) => keyOf(p.node) === 'render')
90111
.map((p) => p.get('value'))
91-
.find((value) => value.isExpression());
112+
.find(
113+
(value): value is NodePath<t.ArrowFunctionExpression | t.FunctionExpression> =>
114+
value.isArrowFunctionExpression() || value.isFunctionExpression()
115+
);
92116

93-
const storyFn = renderPath ?? story;
117+
const storyFn =
118+
renderPath ??
119+
(story.isArrowFunctionExpression() ? story : story.isFunctionExpression() ? story : undefined);
94120

95121
// Collect args: meta.args and story.args as Record<string, t.Node>
96122
const metaArgs = metaArgsRecord(metaObj ?? null);
@@ -112,7 +138,7 @@ export function getCodeSnippet(
112138
.map(([k, v]) => toAttr(k, v))
113139
.filter((a): a is t.JSXAttribute => Boolean(a));
114140

115-
if (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) {
141+
if (storyFn) {
116142
const fn = storyFn.node;
117143

118144
// Only handle arrow function with direct JSX expression body for now
@@ -224,6 +250,8 @@ export function getCodeSnippet(
224250
// Build spread for invalid-only props, if any
225251
const invalidSpread = buildInvalidSpread(invalidEntries);
226252

253+
invariant(componentName, 'Could not generate snippet without component name.');
254+
227255
const name = t.jsxIdentifier(componentName);
228256

229257
const openingElAttrs: Array<t.JSXAttribute | t.JSXSpreadAttribute> = [

0 commit comments

Comments
 (0)