Skip to content

Commit d1bb264

Browse files
authored
Merge pull request #96 from andrelas1/feat/no-uninstalled-addons
feat(no-uninstalled-addons): add uninstalled plugin rule
2 parents c1e8ddc + f2bae3a commit d1bb264

File tree

8 files changed

+675
-17
lines changed

8 files changed

+675
-17
lines changed

README.md

+26-14
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ npm install eslint-plugin-storybook --save-dev
4848
yarn add eslint-plugin-storybook --dev
4949
```
5050

51+
And finally, add this to your `.eslintignore` file:
52+
53+
```
54+
// Inside your .eslintignore file
55+
!.storybook
56+
```
57+
58+
This allows for this plugin to also lint your configuration files inside the .storybook folder, so that you always have a correct configuration and don't face any issues regarding mistyped addon names, for instance.
59+
60+
> For more info on why this line is required in the .eslintignore file, check this [ESLint documentation](https://eslint.org/docs/latest/user-guide/configuring/ignoring-code#:~:text=In%20addition%20to,contents%2C%20are%20ignored).
61+
5162
## Usage
5263

5364
Use `.eslintrc.*` file to configure rules. See also: https://eslint.org/docs/user-guide/configuring
@@ -96,20 +107,21 @@ This plugin does not support MDX files.
96107

97108
**Configurations**: csf, csf-strict, addon-interactions, recommended
98109

99-
| Name | Description | 🔧 | Included in configurations |
100-
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --- | -------------------------------------------------------- |
101-
| [`storybook/await-interactions`](./docs/rules/await-interactions.md) | Interactions should be awaited | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
102-
| [`storybook/context-in-play-function`](./docs/rules/context-in-play-function.md) | Pass a context when invoking play function of another story | | <ul><li>recommended</li><li>addon-interactions</li></ul> |
103-
| [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | <ul><li>csf</li></ul> |
104-
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
105-
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
106-
| [`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> |
107-
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li></ul> |
108-
| [`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> |
109-
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | <ul><li>recommended</li></ul> |
110-
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | <ul><li>recommended</li><li>csf</li></ul> |
111-
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/jest` | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
112-
| [`storybook/use-storybook-testing-library`](./docs/rules/use-storybook-testing-library.md) | Do not use testing-library directly on stories | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
110+
| Name | Description | 🔧 | Included in configurations |
111+
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --- | -------------------------------------------------------- |
112+
| [`storybook/await-interactions`](./docs/rules/await-interactions.md) | Interactions should be awaited | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
113+
| [`storybook/context-in-play-function`](./docs/rules/context-in-play-function.md) | Pass a context when invoking play function of another story | | <ul><li>recommended</li><li>addon-interactions</li></ul> |
114+
| [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | <ul><li>csf</li></ul> |
115+
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
116+
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
117+
| [`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> |
118+
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li></ul> |
119+
| [`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> |
120+
| [`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></ul> |
121+
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | <ul><li>recommended</li></ul> |
122+
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | <ul><li>recommended</li><li>csf</li></ul> |
123+
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/jest` | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
124+
| [`storybook/use-storybook-testing-library`](./docs/rules/use-storybook-testing-library.md) | Do not use testing-library directly on stories | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
113125

114126
<!-- RULES-LIST:END -->
115127

docs/rules/no-uninstalled-addons.md

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# no-uninstalled-addons
2+
3+
<!-- RULE-CATEGORIES:START -->
4+
5+
**Included in these configurations**: <ul><li>recommended</li></ul>
6+
7+
<!-- RULE-CATEGORIES:END -->
8+
9+
## Rule Details
10+
11+
This rule checks if all addons in the storybook main.js file are properly listed in the root package.json of the npm project.
12+
13+
For instance, if the `@storybook/addon-links` is in the `.storybook/main.js` but is not listed in the `package.json` of the project, this rule will notify the user to add the addon to the package.json and install it.
14+
15+
As an important side note, this rule will check for the package.json in the same level of the .storybook folder.
16+
17+
Another very important side note: your ESLint config must allow the linting of the .storybook folder. By default, ESLint ignores all dot-files so this folder will be ignored. In order to allow this rule to lint the .storybook/main.js file, it's important to configure ESLint to lint this file. This can be achieved by writing something like:
18+
19+
```
20+
// Inside your .eslintignore file
21+
!.storybook
22+
```
23+
24+
For more info, check this [ESLint documentation](https://eslint.org/docs/latest/user-guide/configuring/ignoring-code#:~:text=In%20addition%20to,contents%2C%20are%20ignored).
25+
26+
Examples of **incorrect** code for this rule:
27+
28+
```js
29+
// in .storybook/main.js
30+
module.exports = {
31+
addons: [
32+
'@storybook/addon-links',
33+
'@storybook/addon-essentials',
34+
'@storybook/addon-interactions', // <-- this addon is not listed in the package.json
35+
],
36+
}
37+
38+
// package.json
39+
{
40+
"devDependencies": {
41+
"@storybook/addon-links": "0.0.1",
42+
"@storybook/addon-essentials": "0.0.1",
43+
'
44+
}
45+
}
46+
```
47+
48+
Examples of **correct** code for this rule:
49+
50+
```js
51+
// in .storybook/main.js
52+
module.exports = {
53+
addons: [
54+
'@storybook/addon-links',
55+
'@storybook/addon-essentials',
56+
'@storybook/addon-interactions',
57+
],
58+
}
59+
60+
// package.json
61+
{
62+
"devDependencies": {
63+
"@storybook/addon-links": "0.0.1",
64+
"@storybook/addon-essentials": "0.0.1",
65+
"@storybook/addon-interactions": "0.0.1"
66+
}
67+
}
68+
```
69+
70+
### Configure
71+
72+
Some Storybook folders use a different name for their config directory other than `.storybook`. This rule will not be applied there by default. If you want to have it, then you must add an override in your `.eslintrc.js` file, defining your config directory:
73+
74+
```js
75+
{
76+
overrides: [
77+
{
78+
files: ['your-config-dir/main.@(js|cjs|mjs|ts)'],
79+
rules: {
80+
'storybook/no-uninstalled-addons': 'error',
81+
},
82+
},
83+
],
84+
}
85+
```
86+
87+
## When Not To Use It
88+
89+
This rule is very handy to be used because if the user tries to start storybook but has forgotten to install the plugin, storybook will throw very weird errors that will give no clue to the user to what's going wrong. To prevent that, this rule should be always on.
90+
91+
## Further Reading
92+
93+
Check the issue in GitHub: https://github.com/storybookjs/eslint-plugin-storybook/issues/95

lib/configs/addon-interactions.ts

+6
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,11 @@ export = {
1616
'storybook/use-storybook-testing-library': 'error',
1717
},
1818
},
19+
{
20+
files: ['main.@(js|cjs|mjs|ts)'],
21+
rules: {
22+
'storybook/no-uninstalled-addons': 'error',
23+
},
24+
},
1925
],
2026
}

lib/configs/csf.ts

+6
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,11 @@ export = {
1717
'storybook/story-exports': 'error',
1818
},
1919
},
20+
{
21+
files: ['main.@(js|cjs|mjs|ts)'],
22+
rules: {
23+
'storybook/no-uninstalled-addons': 'error',
24+
},
25+
},
2026
],
2127
}

lib/configs/recommended.ts

+6
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,11 @@ export = {
2121
'storybook/use-storybook-testing-library': 'error',
2222
},
2323
},
24+
{
25+
files: ['main.@(js|cjs|mjs|ts)'],
26+
rules: {
27+
'storybook/no-uninstalled-addons': 'error',
28+
},
29+
},
2430
],
2531
}

lib/rules/no-uninstalled-addons.ts

+214
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* @fileoverview This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.
3+
* @author Andre "andrelas1" Santos
4+
*/
5+
6+
import { readFileSync } from 'fs'
7+
import { resolve } from 'path'
8+
9+
import { createStorybookRule } from '../utils/create-storybook-rule'
10+
import { CategoryId } from '../utils/constants'
11+
import {
12+
isObjectExpression,
13+
isProperty,
14+
isIdentifier,
15+
isArrayExpression,
16+
isLiteral,
17+
isVariableDeclarator,
18+
isVariableDeclaration,
19+
} from '../utils/ast'
20+
import { Property, ArrayExpression } from '@typescript-eslint/types/dist/ast-spec'
21+
22+
//------------------------------------------------------------------------------
23+
// Rule Definition
24+
//------------------------------------------------------------------------------
25+
26+
export = createStorybookRule({
27+
name: 'no-uninstalled-addons',
28+
defaultOptions: [],
29+
meta: {
30+
type: 'problem',
31+
docs: {
32+
description:
33+
'This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.',
34+
categories: [CategoryId.RECOMMENDED],
35+
recommended: 'error', // or 'error'
36+
},
37+
messages: {
38+
addonIsNotInstalled: `The {{ addonName }} is not installed. Did you forget to install it?`,
39+
},
40+
41+
schema: [], // Add a schema if the rule has options. Otherwise remove this
42+
},
43+
44+
create(context) {
45+
// variables should be defined here
46+
47+
//----------------------------------------------------------------------
48+
// Helpers
49+
//----------------------------------------------------------------------
50+
51+
// this will not only exclude the nullables but it will also exclude the type undefined from them, so that TS does not complain
52+
function excludeNullable<T>(item: T | undefined): item is T {
53+
return !!item
54+
}
55+
56+
type MergeDepsWithDevDeps = (packageJson: Record<string, string>) => string[]
57+
const mergeDepsWithDevDeps: MergeDepsWithDevDeps = (packageJson) => {
58+
const deps = Object.keys(packageJson.dependencies || {})
59+
const devDeps = Object.keys(packageJson.devDependencies || {})
60+
return [...deps, ...devDeps]
61+
}
62+
63+
type IsAddonInstalled = (addon: string, installedAddons: string[]) => boolean
64+
const isAddonInstalled: IsAddonInstalled = (addon, installedAddons) => {
65+
// cleanup /register or /preset from registered addon
66+
const addonName = addon.replace(/\/register$/, '').replace(/\/preset$/, '')
67+
return installedAddons.includes(addonName)
68+
}
69+
70+
type AreThereAddonsNotInstalled = (
71+
addons: string[],
72+
installedSbAddons: string[]
73+
) => false | { name: string }[]
74+
const areThereAddonsNotInstalled: AreThereAddonsNotInstalled = (addons, installedSbAddons) => {
75+
const result = addons
76+
.filter((addon) => !isAddonInstalled(addon, installedSbAddons))
77+
.map((addon) => ({ name: addon }))
78+
return result.length ? result : false
79+
}
80+
81+
type GetPackageJson = (path: string) => Record<string, any>
82+
83+
const getPackageJson: GetPackageJson = (path) => {
84+
const packageJson = {
85+
devDependencies: {},
86+
dependencies: {},
87+
}
88+
try {
89+
const file = readFileSync(path, 'utf8')
90+
const parsedFile = JSON.parse(file)
91+
packageJson.dependencies = parsedFile.dependencies || {}
92+
packageJson.devDependencies = parsedFile.devDependencies || {}
93+
} catch (e) {
94+
throw new Error(
95+
'Could not fetch package.json - it is probably not in the same directory as the .storybook folder'
96+
)
97+
}
98+
99+
return packageJson
100+
}
101+
102+
const extractAllAddonsFromTheStorybookConfig = (
103+
addonsExpression: ArrayExpression | undefined
104+
) => {
105+
if (addonsExpression?.elements) {
106+
// extract all nodes taht are a string inside the addons array
107+
const nodesWithAddons = addonsExpression.elements
108+
.map((elem) => (isLiteral(elem) ? { value: elem.value, node: elem } : undefined))
109+
.filter(excludeNullable)
110+
111+
const listOfAddonsInString = nodesWithAddons.map((elem) => elem.value) as string[]
112+
113+
// extract all nodes that are an object inside the addons array
114+
const nodesWithAddonsInObj = addonsExpression.elements
115+
.map((elem) => (isObjectExpression(elem) ? elem : { properties: [] }))
116+
.map((elem) => {
117+
const property: Property = elem.properties.find(
118+
(prop) => isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'name'
119+
) as Property
120+
return isLiteral(property?.value)
121+
? { value: property.value.value, node: property.value }
122+
: undefined
123+
})
124+
.filter(excludeNullable)
125+
126+
const listOfAddonsInObj = nodesWithAddonsInObj.map((elem) => elem.value) as string[]
127+
128+
const listOfAddons = [...listOfAddonsInString, ...listOfAddonsInObj]
129+
const listOfAddonElements = [...nodesWithAddons, ...nodesWithAddonsInObj]
130+
return { listOfAddons, listOfAddonElements }
131+
}
132+
133+
return { listOfAddons: [], listOfAddonElements: [] }
134+
}
135+
136+
function reportUninstalledAddons(addonsProp: ArrayExpression) {
137+
// when this is running for .storybook/main.js, we get the path to the folder which contains the package.json of the
138+
// project. This will be handy for monorepos that may be running ESLint in a node process in another folder.
139+
const projectRoot = context.getPhysicalFilename
140+
? resolve(context.getPhysicalFilename(), '../../')
141+
: './'
142+
let packageJsonObject: Record<string, any>
143+
try {
144+
packageJsonObject = getPackageJson(`${projectRoot}/package.json`)
145+
} catch (e) {
146+
// if we cannot find the package.json, we cannot check if the addons are installed
147+
console.error(e)
148+
return
149+
}
150+
151+
const depsAndDevDeps = mergeDepsWithDevDeps(packageJsonObject)
152+
153+
const { listOfAddons, listOfAddonElements } =
154+
extractAllAddonsFromTheStorybookConfig(addonsProp)
155+
const result = areThereAddonsNotInstalled(listOfAddons, depsAndDevDeps)
156+
157+
if (result) {
158+
const elemsWithErrors = listOfAddonElements.filter(
159+
(elem) => !!result.find((addon) => addon.name === elem.value)
160+
)
161+
elemsWithErrors.forEach((elem) => {
162+
context.report({
163+
node: elem.node,
164+
messageId: 'addonIsNotInstalled',
165+
data: { addonName: elem.value },
166+
})
167+
})
168+
}
169+
}
170+
171+
//----------------------------------------------------------------------
172+
// Public
173+
//----------------------------------------------------------------------
174+
175+
return {
176+
AssignmentExpression: function (node) {
177+
if (isObjectExpression(node.right)) {
178+
const addonsProp = node.right.properties.find(
179+
(prop): prop is Property =>
180+
isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'addons'
181+
)
182+
183+
if (addonsProp && addonsProp.value && isArrayExpression(addonsProp.value)) {
184+
reportUninstalledAddons(addonsProp.value)
185+
}
186+
}
187+
},
188+
ExportDefaultDeclaration: function (node) {
189+
if (isObjectExpression(node.declaration)) {
190+
const addonsProp = node.declaration.properties.find(
191+
(prop): prop is Property =>
192+
isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'addons'
193+
)
194+
195+
if (addonsProp && addonsProp.value && isArrayExpression(addonsProp.value)) {
196+
reportUninstalledAddons(addonsProp.value)
197+
}
198+
}
199+
},
200+
ExportNamedDeclaration: function (node) {
201+
const addonsProp =
202+
isVariableDeclaration(node.declaration) &&
203+
node.declaration.declarations.find(
204+
(decl) =>
205+
isVariableDeclarator(decl) && isIdentifier(decl.id) && decl.id.name === 'addons'
206+
)
207+
208+
if (addonsProp && isArrayExpression(addonsProp.init)) {
209+
reportUninstalledAddons(addonsProp.init)
210+
}
211+
},
212+
}
213+
},
214+
})

0 commit comments

Comments
 (0)