/
no-uninstalled-addons.ts
165 lines (145 loc) · 6.26 KB
/
no-uninstalled-addons.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/**
* @fileoverview This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.
* @author Andre Santos
*/
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'
import { createStorybookRule } from '../utils/create-storybook-rule'
import { CategoryId } from '../utils/constants'
import {
isObjectExpression,
isProperty,
isIdentifier,
isArrayExpression,
isLiteral,
} from '../utils/ast'
import { Property } from '@typescript-eslint/types/dist/ast-spec'
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
export = createStorybookRule({
name: 'no-uninstalled-addons',
defaultOptions: [],
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description:
'This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.',
// Add the categories that suit this rule.
categories: [CategoryId.RECOMMENDED],
recommended: 'error', // or 'error'
},
messages: {
// find out how to make this message dynamic
addonIsNotInstalled: `The {{ addonName }} is not installed. Did you forget to install it?`,
},
schema: [], // Add a schema if the rule has options. Otherwise remove this
},
create(context) {
// variables should be defined here
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
type MergeDepsWithDevDeps = (packageJson: Record<string, string>) => string[]
const mergeDepsWithDevDeps: MergeDepsWithDevDeps = (packageJson) => {
const deps = Object.keys(packageJson.dependencies || {})
const devDeps = Object.keys(packageJson.devDependencies || {})
return [...deps, ...devDeps]
}
type IsAddonInstalled = (addon: string, installedAddons: string[]) => boolean
const isAddonInstalled: IsAddonInstalled = (addon, installedAddons) => {
return installedAddons.includes(addon)
}
type AreThereAddonsNotInstalled = (
addons: string[],
installedSbAddons: string[]
) => false | { name: string }[]
const areThereAddonsNotInstalled: AreThereAddonsNotInstalled = (addons, installedSbAddons) => {
const result = addons
.filter((addon) => !isAddonInstalled(addon, installedSbAddons))
.map((addon) => ({ name: addon }))
return result.length ? result : false
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
// TODO: will this show for every addon not installed in the main.js or only for the first one found that was not installed?
AssignmentExpression: function (node) {
// when this is running for .storybook/main.js, we get the path to the folder which contains the package.json of the
// project. This will be handy for monorepos that may be running ESLint in a node process in another folder.
const projectRoot = context.getPhysicalFilename
? resolve(context.getPhysicalFilename(), '../../')
: './'
const packageJson = readFileSync(`${projectRoot}/package.json`, 'utf8')
const packageJsonObject = JSON.parse(packageJson)
const depsAndDevDeps = mergeDepsWithDevDeps(packageJsonObject)
if (isObjectExpression(node.right)) {
const addonsProp = node.right.properties.find(
(prop) => isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'addons'
) as Property | undefined
if (addonsProp) {
if (isArrayExpression(addonsProp.value)) {
// extract all nodes taht are a string inside the addons array
const listOfAddonsInString: string[] = addonsProp.value.elements
.map((elem) => (isLiteral(elem) ? elem.value : undefined))
.filter((elem) => !!elem) as string[]
// extract all nodes that are an object inside the addons array
const listOfAddonsInObj = addonsProp.value.elements
.map((elem) => (isObjectExpression(elem) ? elem : { properties: [] }))
.map((elem) => {
const property: Property = elem.properties.find(
(prop) => isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'name'
) as Property
return isLiteral(property?.value) ? property.value.value : ''
})
.filter((elem) => !!elem) as string[]
const listOfAddons = [...listOfAddonsInString, ...listOfAddonsInObj]
const result = areThereAddonsNotInstalled(listOfAddons, depsAndDevDeps)
result
? context.report({
node,
messageId: 'addonIsNotInstalled',
data: { addonName: result[0].name },
})
: null
}
}
}
},
}
},
})
/**
* Notes about this new feature
*
*
* The issues that this rule is trying to solve are:
* 1 - Addon is listed in the main.js file of Storybook, but is not installed
* 2 - Addon is listed in the main.js file of Storybook, but it contains a typo in its name
*
* Obs:
*
* addons: [
* // usual way to register addons
* '@storybook/addon-actions',
* {
*
* // alternative way to register addons
* name: '@storybook/addon-actions',
* options: {
* docs: false
* }
* ]
*
* Not every addon is starts with @storybook/addon or storybook-addon. But most of them do and this is a recommended way to register them.
*
* The solution:
*
* - When the addon is listed but not installed or there is a typo but it is not prefixed in the recommended way:
* The addon ${addonName} is not installed. Did you forget to install it?
*
* - When the addon is listed but has a typo and it is prefixed in the recommended way:
* The addon ${addonName} is not installed. Did you mean CORRECT_NAME instead of NAME_WITH_TYPO?
*/