Skip to content

Commit 5fa61ae

Browse files
committed
feat: add only-csf3 rule
1 parent 3f75087 commit 5fa61ae

File tree

5 files changed

+471
-0
lines changed

5 files changed

+471
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ This plugin does not support MDX files.
185185
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li><li>flat/csf-strict</li></ul> |
186186
| [`storybook/no-title-property-in-meta`](./docs/rules/no-title-property-in-meta.md) | Do not define a title in meta | 🔧 | <ul><li>csf-strict</li><li>flat/csf-strict</li></ul> |
187187
| [`storybook/no-uninstalled-addons`](./docs/rules/no-uninstalled-addons.md) | This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name. | | <ul><li>recommended</li><li>flat/recommended</li></ul> |
188+
| [`storybook/only-csf3`](./docs/rules/only-csf3.md) | Enforce Component Story Format 3.0 (CSF3) for stories | | N/A |
188189
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | <ul><li>recommended</li><li>flat/recommended</li></ul> |
189190
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | <ul><li>recommended</li><li>flat/recommended</li><li>csf</li><li>flat/csf</li><li>csf-strict</li><li>flat/csf-strict</li></ul> |
190191
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/test`, `storybook/test` or `@storybook/jest` | 🔧 | <ul><li>addon-interactions</li><li>flat/addon-interactions</li><li>recommended</li><li>flat/recommended</li></ul> |

docs/rules/only-csf3.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Enforce CSF3 format for stories (only-csf3)
2+
3+
[Component Story Format 3.0 (CSF3)](https://storybook.js.org/blog/component-story-format-3-0/) is the latest iteration of Storybook's story format, offering a simpler and more maintainable way to write stories. This rule enforces the use of CSF3 by identifying and reporting CSF2 patterns.
4+
5+
<!-- RULE-CATEGORIES:START -->
6+
7+
**Included in these configurations**: N/A
8+
9+
<!-- RULE-CATEGORIES:END -->
10+
11+
## Rule Details
12+
13+
This rule aims to prevent the use of CSF2 patterns in story files and encourage migration to CSF3.
14+
15+
Examples of **incorrect** code:
16+
17+
```js
18+
// ❌ CSF2: Using Template.bind({})
19+
const Template = (args) => <Button {...args} />
20+
export const Primary = Template.bind({})
21+
Primary.args = { label: 'Primary' }
22+
23+
// ❌ CSF2: Story function declaration
24+
export function Secondary(args) {
25+
return <Button {...args} />
26+
}
27+
28+
// ❌ CSF2: Story arrow function
29+
export const Tertiary = () => <Button>Click me</Button>
30+
31+
// ❌ CSF2: Story with property assignments
32+
export const WithArgs = Template.bind({})
33+
WithArgs.args = { label: 'With Args' }
34+
WithArgs.parameters = { layout: 'centered' }
35+
```
36+
37+
Examples of **correct** code:
38+
39+
```js
40+
// ✅ CSF3: Object literal with args
41+
export const Primary = {
42+
args: {
43+
label: 'Primary',
44+
},
45+
}
46+
47+
// ✅ CSF3: Object literal with render function
48+
export const Secondary = {
49+
render: (args) => <Button {...args}>Secondary</Button>,
50+
}
51+
```
52+
53+
## When Not To Use It
54+
55+
If you're maintaining a legacy Storybook project that extensively uses CSF2 patterns and cannot migrate to CSF3 yet, you might want to disable this rule.
56+
57+
## Migration Examples
58+
59+
Here are examples of how to migrate common CSF2 patterns to CSF3:
60+
61+
1. Template.bind({}) with args:
62+
63+
```js
64+
// ❌ CSF2
65+
const Template = (args) => <Button {...args} />
66+
export const Primary = Template.bind({})
67+
Primary.args = { label: 'Primary' }
68+
69+
// ✅ CSF3
70+
export const Primary = {
71+
args: { label: 'Primary' },
72+
render: (args) => <Button {...args} />,
73+
}
74+
```
75+
76+
2. Function declaration stories:
77+
78+
```js
79+
// ❌ CSF2
80+
export function Primary(args) {
81+
return <Button {...args}>Primary</Button>
82+
}
83+
84+
// ✅ CSF3
85+
export const Primary = {
86+
render: (args) => <Button {...args}>Primary</Button>,
87+
}
88+
```
89+
90+
3. Story with multiple properties:
91+
92+
```js
93+
// ❌ CSF2
94+
export const Primary = Template.bind({})
95+
Primary.args = { label: 'Primary' }
96+
Primary.parameters = { layout: 'centered' }
97+
Primary.decorators = [
98+
(Story) => (
99+
<div style={{ padding: '1rem' }}>
100+
<Story />
101+
</div>
102+
),
103+
]
104+
105+
// ✅ CSF3
106+
export const Primary = {
107+
args: { label: 'Primary' },
108+
parameters: { layout: 'centered' },
109+
decorators: [
110+
(Story) => (
111+
<div style={{ padding: '1rem' }}>
112+
<Story />
113+
</div>
114+
),
115+
],
116+
render: (args) => <Button {...args} />,
117+
}
118+
```
119+
120+
## Further Reading
121+
122+
- [Component Story Format 3.0](https://storybook.js.org/blog/component-story-format-3-0/)
123+
- [Migrating to CSF3](https://storybook.js.org/docs/migration-guide/from-older-version#csf-2-to-csf-3)
124+
- [Upgrading from CSF 2.0 to 3.0](https://storybook.js.org/docs/api/csf/index#upgrading-from-csf-2-to-csf-3)
125+
- [Writing Stories in Storybook](https://storybook.js.org/docs/writing-stories#component-story-format)

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import noRedundantStoryName from './rules/no-redundant-story-name'
2525
import noStoriesOf from './rules/no-stories-of'
2626
import noTitlePropertyInMeta from './rules/no-title-property-in-meta'
2727
import noUninstalledAddons from './rules/no-uninstalled-addons'
28+
import onlyCsf3 from './rules/only-csf3'
2829
import preferPascalCase from './rules/prefer-pascal-case'
2930
import storyExports from './rules/story-exports'
3031
import useStorybookExpect from './rules/use-storybook-expect'
@@ -57,6 +58,7 @@ export = {
5758
'no-stories-of': noStoriesOf,
5859
'no-title-property-in-meta': noTitlePropertyInMeta,
5960
'no-uninstalled-addons': noUninstalledAddons,
61+
'only-csf3': onlyCsf3,
6062
'prefer-pascal-case': preferPascalCase,
6163
'story-exports': storyExports,
6264
'use-storybook-expect': useStorybookExpect,

lib/rules/only-csf3.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @fileoverview Enforce CSF3 format for stories.
3+
* @see https://storybook.js.org/blog/component-story-format-3-0/
4+
*/
5+
6+
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'
7+
import { createStorybookRule } from '../utils/create-storybook-rule'
8+
import { isIdentifier, isMemberExpression } from '../utils/ast'
9+
10+
// Properties that indicate CSF2 format when assigned to a story
11+
const CSF2_PROPERTIES = new Set(['args', 'parameters', 'decorators', 'play', 'storyName'])
12+
13+
export = createStorybookRule({
14+
name: 'only-csf3',
15+
defaultOptions: [],
16+
meta: {
17+
type: 'problem',
18+
severity: 'error',
19+
docs: {
20+
description: 'Enforce Component Story Format 3.0 (CSF3) for stories',
21+
excludeFromConfig: true,
22+
},
23+
schema: [],
24+
messages: {
25+
noCSF2Format: 'Story "{{storyName}}" uses CSF2 {{pattern}}. Please migrate to CSF3.',
26+
},
27+
},
28+
create(context) {
29+
const reportedStories = new Set<string>()
30+
const pendingReports = new Map<string, { node: TSESTree.Node; pattern: string }>()
31+
32+
const report = (storyName: string, node: TSESTree.Node, pattern: string): void => {
33+
if (!reportedStories.has(storyName)) {
34+
reportedStories.add(storyName)
35+
context.report({
36+
node,
37+
messageId: 'noCSF2Format',
38+
data: {
39+
storyName,
40+
pattern,
41+
},
42+
})
43+
}
44+
}
45+
46+
return {
47+
ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration) {
48+
const decl = node.declaration
49+
if (!decl) return
50+
51+
// Function declarations
52+
if (decl.type === AST_NODE_TYPES.FunctionDeclaration && decl.id) {
53+
report(decl.id.name, decl, 'function declaration')
54+
return
55+
}
56+
57+
// Arrow/function expressions: delay reporting until we know if there are property assignments
58+
if (decl.type === AST_NODE_TYPES.VariableDeclaration) {
59+
const declarator = decl.declarations[0]
60+
if (
61+
declarator?.init &&
62+
declarator.id &&
63+
declarator.id.type === AST_NODE_TYPES.Identifier
64+
) {
65+
if (declarator.init.type === AST_NODE_TYPES.ArrowFunctionExpression) {
66+
pendingReports.set(declarator.id.name, {
67+
node: declarator.init,
68+
pattern: 'arrow function',
69+
})
70+
} else if (declarator.init.type === AST_NODE_TYPES.FunctionExpression) {
71+
pendingReports.set(declarator.id.name, {
72+
node: declarator.init,
73+
pattern: 'function expression',
74+
})
75+
}
76+
}
77+
}
78+
},
79+
80+
AssignmentExpression(node: TSESTree.AssignmentExpression) {
81+
if (
82+
!isMemberExpression(node.left) ||
83+
!isIdentifier(node.left.object) ||
84+
!isIdentifier(node.left.property)
85+
) {
86+
return
87+
}
88+
const propertyName = node.left.property.name
89+
const storyName = node.left.object.name
90+
91+
if (CSF2_PROPERTIES.has(propertyName) && !reportedStories.has(storyName)) {
92+
// Remove any pending arrow/function report for this story
93+
pendingReports.delete(storyName)
94+
report(storyName, node, `property assignment (.${propertyName})`)
95+
}
96+
},
97+
98+
'Program:exit'() {
99+
// Report any remaining arrow/function expression reports
100+
for (const [storyName, { node, pattern }] of pendingReports) {
101+
if (!reportedStories.has(storyName)) {
102+
report(storyName, node, pattern)
103+
}
104+
}
105+
pendingReports.clear()
106+
},
107+
}
108+
},
109+
})

0 commit comments

Comments
 (0)