Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add meta-satisfies-type rule #124

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -114,6 +114,7 @@ This plugin does not support MDX files.
| [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | <ul><li>csf</li></ul> |
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/meta-satisfies-type`](./docs/rules/meta-satisfies-type.md) | Meta should use `satisfies Meta` | 🔧 | (none) |
| [`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>recommended</li></ul> |
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li></ul> |
| [`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></ul> |
Expand Down
55 changes: 55 additions & 0 deletions docs/rules/meta-satisfies-type.md
@@ -0,0 +1,55 @@
# Meta should be followed by `satisfies Meta` (meta-satisfies-type)

<!-- RULE-CATEGORIES:START -->

**Included in these configurations**: (none)

<!-- RULE-CATEGORIES:END -->

## Rule Details

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.

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.

Examples of **incorrect** code for this rule:

```js
export default {
title: 'Button',
args: { primary: true },
component: Button,
}

const meta: Meta<typeof Button> = {
title: 'Button',
args: { primary: true },
component: Button,
}
export default meta
```

Examples of **correct** code for this rule:

```js
export default {
title: 'Button',
args: { primary: true },
component: Button,
} satisfies Meta<typeof Button>

const meta = {
title: 'Button',
args: { primary: true },
component: Button,
} satisfies Meta<typeof Button>
export default meta
```

## When Not To Use It

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.

## Further Reading

- [Improved type safety in Storybook 7](https://storybook.js.org/blog/improved-type-safety-in-storybook-7/?ref=storybookblog.ghost.io)
114 changes: 114 additions & 0 deletions lib/rules/meta-satisfies-type.ts
@@ -0,0 +1,114 @@
/**
* @fileoverview Meta should be followed by `satisfies Meta`
* @author Tiger Oakes
*/

import { AST_NODE_TYPES, ASTUtils, TSESTree, TSESLint } from '@typescript-eslint/utils'
import { getMetaObjectExpression } from '../utils'
import { createStorybookRule } from '../utils/create-storybook-rule'
import { isTSSatisfiesExpression } from '../utils/ast'

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

export = createStorybookRule({
name: 'meta-satisfies-type',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
description: 'Meta should use `satisfies Meta`',
categories: [],
recommended: 'error',
},
messages: {
metaShouldSatisfyType: 'Meta should be followed by `satisfies Meta`',
},
fixable: 'code',
schema: [],
},

create(context) {
// variables should be defined here
const sourceCode = context.getSourceCode()

//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
const getTextWithParentheses = (node: TSESTree.Node): string => {
// Capture parentheses before and after the node
let beforeCount = 0
let afterCount = 0

if (ASTUtils.isParenthesized(node, sourceCode)) {
const bodyOpeningParen = sourceCode.getTokenBefore(node, ASTUtils.isOpeningParenToken)
const bodyClosingParen = sourceCode.getTokenAfter(node, ASTUtils.isClosingParenToken)

if (bodyOpeningParen && bodyClosingParen) {
beforeCount = node.range[0] - bodyOpeningParen.range[0]
afterCount = bodyClosingParen.range[1] - node.range[1]
}
}

return sourceCode.getText(node, beforeCount, afterCount)
}

const getFixer = (meta: TSESTree.ObjectExpression): TSESLint.ReportFixFunction | undefined => {
const { parent } = meta
if (!parent) {
return undefined
}

switch (parent.type) {
// {} as Meta
case AST_NODE_TYPES.TSAsExpression:
return (fixer) => [
fixer.replaceText(parent, getTextWithParentheses(meta)),
fixer.insertTextAfter(
parent,
` satisfies ${getTextWithParentheses(parent.typeAnnotation)}`
),
]
// const meta: Meta = {}
case AST_NODE_TYPES.VariableDeclarator: {
const { typeAnnotation } = parent.id
if (typeAnnotation) {
return (fixer) => [
fixer.remove(typeAnnotation),
fixer.insertTextAfter(
meta,
` satisfies ${getTextWithParentheses(typeAnnotation.typeAnnotation)}`
),
]
}
return undefined
}
default:
return undefined
}
}
// any helper functions should go here or else delete this section

//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------

return {
ExportDefaultDeclaration(node) {
const meta = getMetaObjectExpression(node, context)
if (!meta) {
return null
}

if (!meta.parent || !isTSSatisfiesExpression(meta.parent)) {
context.report({
node: meta,
messageId: 'metaShouldSatisfyType',
fix: getFixer(meta),
})
}
},
}
},
})
72 changes: 72 additions & 0 deletions tests/lib/rules/meta-satisfies-type.test.ts
@@ -0,0 +1,72 @@
/**
* @fileoverview Meta should use `satisfies Meta`
* @author Tiger Oakes
*/

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

import rule from '../../../lib/rules/meta-satisfies-type'
import ruleTester from '../../utils/rule-tester'

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

ruleTester.run('meta-satisfies-type', rule, {
valid: [
"export default { title: 'Button', args: { primary: true } } satisfies Meta<typeof Button>",
`const meta = {
component: AccountForm,
} satisfies Meta<typeof AccountForm>;
export default meta;`,
],

invalid: [
{
code: `export default { title: 'Button', args: { primary: true } }`,
errors: [{ messageId: 'metaShouldSatisfyType' }],
},
{
code: `
const meta = {
component: AccountForm,
}
export default meta;
`,
errors: [{ messageId: 'metaShouldSatisfyType' }],
},
{
code: `
const meta: Meta<typeof AccountForm> = {
component: AccountForm,
}
export default meta;`,
output: `
const meta = {
component: AccountForm,
} satisfies Meta<typeof AccountForm>
export default meta;`,
errors: [{ messageId: 'metaShouldSatisfyType' }],
},
{
code: `export default { title: 'Button', args: { primary: true } } as Meta<typeof Button>`,
output: `export default { title: 'Button', args: { primary: true } } satisfies Meta<typeof Button>`,
errors: [{ messageId: 'metaShouldSatisfyType' }],
},
{
code: `
const meta = ( {
component: AccountForm,
}) as (Meta<typeof AccountForm> )
export default ( meta );`,
output: `
const meta = ( {
component: AccountForm,
}) satisfies (Meta<typeof AccountForm> )
export default ( meta );`,
errors: [{ messageId: 'metaShouldSatisfyType' }],
},
],
})
4 changes: 2 additions & 2 deletions tools/update-rules-list.ts
Expand Up @@ -30,9 +30,9 @@ const rulesList: TRulesList[] = Object.entries(rules)
createRuleLink(rule.name),
rule.meta.docs.description,
rule.meta.fixable ? emojiKey.fixable : '',
rule.meta.docs.categories
rule.meta.docs.categories && rule.meta.docs.categories.length > 0
? `<ul>${rule.meta.docs.categories.map((c) => `<li>${c}</li>`).join('')}</ul>`
: '',
: '(none)',
]
})

Expand Down
6 changes: 1 addition & 5 deletions tools/utils/categories.ts
Expand Up @@ -25,12 +25,8 @@ for (const categoryId of categoryIds) {

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

if (ruleCategories.includes(categoryId)) {
if (ruleCategories?.includes(categoryId)) {
categoriesConfig[categoryId].rules?.push(rule)
}
}
Expand Down