Skip to content

Commit 29e5062

Browse files
authored
Merge pull request #200 from storybookjs/feat/meta-satisfies-type
Add meta-satisfies-type rule (rebased)
2 parents 8611e56 + 0748c68 commit 29e5062

File tree

6 files changed

+250
-5
lines changed

6 files changed

+250
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ This plugin does not support MDX files.
180180
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>flat/csf</li><li>recommended</li><li>flat/recommended</li><li>csf-strict</li><li>flat/csf-strict</li></ul> |
181181
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>flat/csf</li><li>recommended</li><li>flat/recommended</li><li>csf-strict</li><li>flat/csf-strict</li></ul> |
182182
| [`storybook/meta-inline-properties`](./docs/rules/meta-inline-properties.md) | Meta should only have inline properties | | N/A |
183+
| [`storybook/meta-satisfies-type`](./docs/rules/meta-satisfies-type.md) | Meta should use `satisfies Meta` | 🔧 | N/A |
183184
| [`storybook/no-redundant-story-name`](./docs/rules/no-redundant-story-name.md) | A story should not have a redundant name property | 🔧 | <ul><li>csf</li><li>flat/csf</li><li>recommended</li><li>flat/recommended</li><li>csf-strict</li><li>flat/csf-strict</li></ul> |
184185
| [`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> |
185186
| [`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> |

docs/rules/meta-satisfies-type.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Meta should be followed by `satisfies Meta` (meta-satisfies-type)
2+
3+
<!-- RULE-CATEGORIES:START -->
4+
5+
**Included in these configurations**: N/A
6+
7+
<!-- RULE-CATEGORIES:END -->
8+
9+
## Rule Details
10+
11+
This rule enforces writing `satisfies Meta` after the meta object definition. This is useful to ensure that stories use the correct properties in the metadata.
12+
13+
Additionally, `satisfies` is preferred over type annotations (`const meta: Meta = {...}`) and type assertions (`const meta = {...} as Meta`). This is because other types like `StoryObj` will check to see which properties are defined in meta and use it for increased type safety. Using type annotations or assertions hides this information from the type-checker, so satisfies should be used instead.
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```ts
18+
export default {
19+
title: 'Button',
20+
args: { primary: true },
21+
component: Button,
22+
}
23+
```
24+
25+
```ts
26+
const meta: Meta<typeof Button> = {
27+
title: 'Button',
28+
args: { primary: true },
29+
component: Button,
30+
}
31+
export default meta
32+
```
33+
34+
Examples of **correct** code for this rule:
35+
36+
```ts
37+
export default {
38+
title: 'Button',
39+
args: { primary: true },
40+
component: Button,
41+
} satisfies Meta<typeof Button>
42+
```
43+
44+
```ts
45+
const meta = {
46+
title: 'Button',
47+
args: { primary: true },
48+
component: Button,
49+
} satisfies Meta<typeof Button>
50+
export default meta
51+
```
52+
53+
## When Not To Use It
54+
55+
If you aren't using TypeScript or you're using a version older than TypeScript 4.9, `satisfies` is not supported and you can avoid this rule.
56+
57+
## Further Reading
58+
59+
- [Improved type safety in Storybook 7](https://storybook.js.org/blog/improved-type-safety-in-storybook-7/?ref=storybookblog.ghost.io)

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import csfComponent from './rules/csf-component'
2020
import defaultExports from './rules/default-exports'
2121
import hierarchySeparator from './rules/hierarchy-separator'
2222
import metaInlineProperties from './rules/meta-inline-properties'
23+
import metaSatisfiesType from './rules/meta-satisfies-type'
2324
import noRedundantStoryName from './rules/no-redundant-story-name'
2425
import noStoriesOf from './rules/no-stories-of'
2526
import noTitlePropertyInMeta from './rules/no-title-property-in-meta'
@@ -51,6 +52,7 @@ export = {
5152
'default-exports': defaultExports,
5253
'hierarchy-separator': hierarchySeparator,
5354
'meta-inline-properties': metaInlineProperties,
55+
'meta-satisfies-type': metaSatisfiesType,
5456
'no-redundant-story-name': noRedundantStoryName,
5557
'no-stories-of': noStoriesOf,
5658
'no-title-property-in-meta': noTitlePropertyInMeta,

lib/rules/meta-satisfies-type.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @fileoverview Meta should be followed by `satisfies Meta`
3+
* @author Tiger Oakes
4+
*/
5+
6+
import { AST_NODE_TYPES, ASTUtils, TSESTree, TSESLint } from '@typescript-eslint/utils'
7+
import { getMetaObjectExpression } from '../utils'
8+
import { createStorybookRule } from '../utils/create-storybook-rule'
9+
import { isTSSatisfiesExpression } from '../utils/ast'
10+
11+
//------------------------------------------------------------------------------
12+
// Rule Definition
13+
//------------------------------------------------------------------------------
14+
15+
export = createStorybookRule({
16+
name: 'meta-satisfies-type',
17+
defaultOptions: [],
18+
meta: {
19+
type: 'problem',
20+
fixable: 'code',
21+
severity: 'error',
22+
docs: {
23+
description: 'Meta should use `satisfies Meta`',
24+
categories: [],
25+
excludeFromConfig: true,
26+
},
27+
messages: {
28+
metaShouldSatisfyType: 'CSF Meta should use `satisfies` for type safety',
29+
},
30+
schema: [],
31+
},
32+
33+
create(context) {
34+
// variables should be defined here
35+
const sourceCode = context.getSourceCode()
36+
37+
//----------------------------------------------------------------------
38+
// Helpers
39+
//----------------------------------------------------------------------
40+
const getTextWithParentheses = (node: TSESTree.Node): string => {
41+
// Capture parentheses before and after the node
42+
let beforeCount = 0
43+
let afterCount = 0
44+
45+
if (ASTUtils.isParenthesized(node, sourceCode)) {
46+
const bodyOpeningParen = sourceCode.getTokenBefore(node, ASTUtils.isOpeningParenToken)
47+
const bodyClosingParen = sourceCode.getTokenAfter(node, ASTUtils.isClosingParenToken)
48+
49+
if (bodyOpeningParen && bodyClosingParen) {
50+
beforeCount = node.range[0] - bodyOpeningParen.range[0]
51+
afterCount = bodyClosingParen.range[1] - node.range[1]
52+
}
53+
}
54+
55+
return sourceCode.getText(node, beforeCount, afterCount)
56+
}
57+
58+
const getFixer = (meta: TSESTree.ObjectExpression): TSESLint.ReportFixFunction | undefined => {
59+
const { parent } = meta
60+
if (!parent) {
61+
return undefined
62+
}
63+
64+
switch (parent.type) {
65+
// {} as Meta
66+
case AST_NODE_TYPES.TSAsExpression:
67+
return (fixer) => [
68+
fixer.replaceText(parent, getTextWithParentheses(meta)),
69+
fixer.insertTextAfter(
70+
parent,
71+
` satisfies ${getTextWithParentheses(parent.typeAnnotation)}`
72+
),
73+
]
74+
// const meta: Meta = {}
75+
case AST_NODE_TYPES.VariableDeclarator: {
76+
const { typeAnnotation } = parent.id
77+
if (typeAnnotation) {
78+
return (fixer) => [
79+
fixer.remove(typeAnnotation),
80+
fixer.insertTextAfter(
81+
meta,
82+
` satisfies ${getTextWithParentheses(typeAnnotation.typeAnnotation)}`
83+
),
84+
]
85+
}
86+
return undefined
87+
}
88+
default:
89+
return undefined
90+
}
91+
}
92+
// any helper functions should go here or else delete this section
93+
94+
//----------------------------------------------------------------------
95+
// Public
96+
//----------------------------------------------------------------------
97+
98+
return {
99+
ExportDefaultDeclaration(node) {
100+
const meta = getMetaObjectExpression(node, context)
101+
if (!meta) {
102+
return null
103+
}
104+
105+
if (!meta.parent || !isTSSatisfiesExpression(meta.parent)) {
106+
context.report({
107+
node: meta,
108+
messageId: 'metaShouldSatisfyType',
109+
fix: getFixer(meta),
110+
})
111+
}
112+
},
113+
}
114+
},
115+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @fileoverview Meta should use `satisfies Meta`
3+
* @author Tiger Oakes
4+
*/
5+
6+
//------------------------------------------------------------------------------
7+
// Requirements
8+
//------------------------------------------------------------------------------
9+
10+
import rule from '../../../lib/rules/meta-satisfies-type'
11+
import ruleTester from '../../utils/rule-tester'
12+
13+
//------------------------------------------------------------------------------
14+
// Tests
15+
//------------------------------------------------------------------------------
16+
17+
ruleTester.run('meta-satisfies-type', rule, {
18+
valid: [
19+
"export default { title: 'Button', args: { primary: true } } satisfies Meta<typeof Button>",
20+
`const meta = {
21+
component: AccountForm,
22+
} satisfies Meta<typeof AccountForm>;
23+
export default meta;`,
24+
],
25+
26+
invalid: [
27+
{
28+
code: `export default { title: 'Button', args: { primary: true } }`,
29+
errors: [{ messageId: 'metaShouldSatisfyType' }],
30+
},
31+
{
32+
code: `
33+
const meta = {
34+
component: AccountForm,
35+
}
36+
export default meta;
37+
`,
38+
errors: [{ messageId: 'metaShouldSatisfyType' }],
39+
},
40+
{
41+
code: `
42+
const meta: Meta<typeof AccountForm> = {
43+
component: AccountForm,
44+
}
45+
export default meta;`,
46+
output: `
47+
const meta = {
48+
component: AccountForm,
49+
} satisfies Meta<typeof AccountForm>
50+
export default meta;`,
51+
errors: [{ messageId: 'metaShouldSatisfyType' }],
52+
},
53+
{
54+
code: `export default { title: 'Button', args: { primary: true } } as Meta<typeof Button>`,
55+
output: `export default { title: 'Button', args: { primary: true } } satisfies Meta<typeof Button>`,
56+
errors: [{ messageId: 'metaShouldSatisfyType' }],
57+
},
58+
{
59+
code: `
60+
const meta = ( {
61+
component: AccountForm,
62+
}) as (Meta<typeof AccountForm> )
63+
export default ( meta );`,
64+
output: `
65+
const meta = ( {
66+
component: AccountForm,
67+
}) satisfies (Meta<typeof AccountForm> )
68+
export default ( meta );`,
69+
errors: [{ messageId: 'metaShouldSatisfyType' }],
70+
},
71+
],
72+
})

tools/utils/categories.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,8 @@ for (const categoryId of categoryIds) {
2525

2626
for (const rule of rules) {
2727
const ruleCategories = rule.meta.docs?.categories
28-
// Throw if rule does not have a category
29-
if (!ruleCategories?.length) {
30-
throw new Error(`Rule "${rule.ruleId}" does not have any category.`)
31-
}
3228

33-
if (ruleCategories.includes(categoryId) && rule.meta.docs?.excludeFromConfig !== true) {
29+
if (ruleCategories?.includes(categoryId) && rule.meta.docs?.excludeFromConfig !== true) {
3430
categoriesConfig[categoryId].rules?.push(rule)
3531
}
3632
}

0 commit comments

Comments
 (0)