eslint-plugin-stamhoofd 2.121.0 → 2.122.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -17
- package/src/index.js +101 -8
- package/src/rules/async-component-with-properties.js +284 -0
- package/src/rules/async-route-components.js +284 -0
package/package.json
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-stamhoofd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.122.1",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"main": "./src/index.js",
|
|
5
|
-
"license": "UNLICENCED",
|
|
6
6
|
"sideEffects": false,
|
|
7
|
-
"type": "module",
|
|
8
7
|
"files": [
|
|
9
8
|
"src"
|
|
10
9
|
],
|
|
11
|
-
"
|
|
12
|
-
"
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run"
|
|
13
12
|
},
|
|
14
13
|
"dependencies": {
|
|
15
|
-
"@eslint/js": "
|
|
16
|
-
"@stylistic/eslint-plugin": "
|
|
17
|
-
"@vitest/eslint-plugin": "
|
|
18
|
-
"eslint": "
|
|
19
|
-
"eslint-import-resolver-typescript": "
|
|
20
|
-
"eslint-plugin-import": "
|
|
21
|
-
"eslint-plugin-n": "
|
|
22
|
-
"eslint-plugin-regexp": "
|
|
23
|
-
"eslint-plugin-vue": "
|
|
24
|
-
"typescript-eslint": "
|
|
25
|
-
"vue-eslint-parser": "
|
|
14
|
+
"@eslint/js": "10.0.1",
|
|
15
|
+
"@stylistic/eslint-plugin": "5.10.0",
|
|
16
|
+
"@vitest/eslint-plugin": "1.6.20",
|
|
17
|
+
"eslint": "10.5.0",
|
|
18
|
+
"eslint-import-resolver-typescript": "4.4.5",
|
|
19
|
+
"eslint-plugin-import": "2.32.0",
|
|
20
|
+
"eslint-plugin-n": "18.1.0",
|
|
21
|
+
"eslint-plugin-regexp": "3.1.0",
|
|
22
|
+
"eslint-plugin-vue": "10.9.2",
|
|
23
|
+
"typescript-eslint": "8.61.0",
|
|
24
|
+
"vue-eslint-parser": "10.4.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"vitest": "4.1.8"
|
|
28
|
+
},
|
|
29
|
+
"license": "UNLICENCED",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
26
32
|
},
|
|
27
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "7f9c0637e743d1f7b5f1b161ab6b09ecd7a199e5"
|
|
28
34
|
}
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,16 @@ import defaultRules from './configs/default.js';
|
|
|
11
11
|
import frontend from './configs/frontend.js';
|
|
12
12
|
import node from './configs/node.js';
|
|
13
13
|
import typescript from './configs/typescript.js';
|
|
14
|
+
import asyncComponentWithProperties from './rules/async-component-with-properties.js';
|
|
15
|
+
import asyncRouteComponents from './rules/async-route-components.js';
|
|
16
|
+
|
|
17
|
+
const rules = {
|
|
18
|
+
'async-component-with-properties': asyncComponentWithProperties,
|
|
19
|
+
'async-route-components': asyncRouteComponents,
|
|
20
|
+
};
|
|
21
|
+
const stamhoofdPlugin = {
|
|
22
|
+
rules,
|
|
23
|
+
};
|
|
14
24
|
|
|
15
25
|
const baseRules = [
|
|
16
26
|
...defaultRules,
|
|
@@ -19,6 +29,7 @@ const baseRules = [
|
|
|
19
29
|
settings: {
|
|
20
30
|
'import/parsers': {
|
|
21
31
|
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
|
32
|
+
'vue-eslint-parser': ['.vue'],
|
|
22
33
|
},
|
|
23
34
|
'import/resolver': {
|
|
24
35
|
typescript: {
|
|
@@ -28,6 +39,12 @@ const baseRules = [
|
|
|
28
39
|
},
|
|
29
40
|
},
|
|
30
41
|
eslint.configs.recommended,
|
|
42
|
+
{
|
|
43
|
+
rules: {
|
|
44
|
+
'preserve-caught-error': 'warn',
|
|
45
|
+
'no-useless-assignment': 'warn',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
31
48
|
...tseslint.configs.recommendedTypeChecked.map(config => ({
|
|
32
49
|
...config,
|
|
33
50
|
files: ['*.ts', '**/*.ts', '*.vue', '**/*.vue'], // We use TS config only for TS files
|
|
@@ -49,16 +66,23 @@ const baseRules = [
|
|
|
49
66
|
},
|
|
50
67
|
rules: {
|
|
51
68
|
'import/no-cycle': ['warn', { maxDepth: 100, ignoreExternal: false }],
|
|
69
|
+
'import/no-extraneous-dependencies': ['error'],
|
|
70
|
+
'import/no-relative-packages': 'error',
|
|
52
71
|
},
|
|
53
72
|
},
|
|
73
|
+
|
|
74
|
+
stylistic.configs.customize({
|
|
75
|
+
// the following options are the default values
|
|
76
|
+
indent: 4,
|
|
77
|
+
quotes: 'single',
|
|
78
|
+
semi: true,
|
|
79
|
+
jsx: false,
|
|
80
|
+
severity: 'warn',
|
|
81
|
+
braceStyle: '1tbs',
|
|
82
|
+
}),
|
|
54
83
|
{
|
|
55
|
-
files: ['**/*.js', '**/*.ts'],
|
|
56
|
-
plugins: {
|
|
57
|
-
'@stylistic': stylistic,
|
|
58
|
-
},
|
|
59
84
|
rules: {
|
|
60
|
-
|
|
61
|
-
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: true, avoidEscape: true }],
|
|
85
|
+
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: true, avoidEscape: true }],
|
|
62
86
|
},
|
|
63
87
|
},
|
|
64
88
|
{
|
|
@@ -79,7 +103,7 @@ const baseRules = [
|
|
|
79
103
|
...vitest.configs.recommended.rules,
|
|
80
104
|
'vitest/no-conditional-expect': 'warn',
|
|
81
105
|
'vitest/expect-expect': 'warn',
|
|
82
|
-
'vitest/valid-expect': ['warn', {maxArgs: 2}] // Allow to pass a message by variable
|
|
106
|
+
'vitest/valid-expect': ['warn', { maxArgs: 2 }], // Allow to pass a message by variable
|
|
83
107
|
},
|
|
84
108
|
},
|
|
85
109
|
|
|
@@ -95,15 +119,84 @@ const baseRules = [
|
|
|
95
119
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
96
120
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
|
97
121
|
},
|
|
98
|
-
}
|
|
122
|
+
},
|
|
99
123
|
];
|
|
100
124
|
|
|
101
125
|
export default {
|
|
126
|
+
rules,
|
|
102
127
|
configs: {
|
|
103
128
|
base: baseRules,
|
|
104
129
|
frontend: [
|
|
105
130
|
...baseRules,
|
|
106
131
|
...frontend,
|
|
132
|
+
{
|
|
133
|
+
plugins: {
|
|
134
|
+
stamhoofd: stamhoofdPlugin,
|
|
135
|
+
},
|
|
136
|
+
rules: {
|
|
137
|
+
'stamhoofd/async-component-with-properties': ['error', {
|
|
138
|
+
allow: [
|
|
139
|
+
// Add all components that should work if internet breaks, such as error messages, toasts...
|
|
140
|
+
// Also add all overlays, because animations are broken for async display of overlays (tooltips etc)
|
|
141
|
+
'App',
|
|
142
|
+
'AuthenticatedView',
|
|
143
|
+
'CenteredMessageView',
|
|
144
|
+
'ContextProvider',
|
|
145
|
+
'CoverImageContainer',
|
|
146
|
+
'ModalStackComponent',
|
|
147
|
+
'NavigationController',
|
|
148
|
+
'PromiseView',
|
|
149
|
+
'SplitViewController',
|
|
150
|
+
'TabBarController',
|
|
151
|
+
'ToastView',
|
|
152
|
+
'Tooltip',
|
|
153
|
+
'CustomHooksContainer',
|
|
154
|
+
'ContextNavigationBar',
|
|
155
|
+
'OrganizationSwitcher',
|
|
156
|
+
'AccountSwitcher',
|
|
157
|
+
'LoadingView',
|
|
158
|
+
'ColumnSelectorContextMenu',
|
|
159
|
+
'ColumnSortingContextMenu',
|
|
160
|
+
'TableActionsContextMenu',
|
|
161
|
+
'GeneralContextMenuView',
|
|
162
|
+
'ImportErrorView',
|
|
163
|
+
],
|
|
164
|
+
}],
|
|
165
|
+
'stamhoofd/async-route-components': 'error',
|
|
166
|
+
// TODO: restore to 'error' once the existing dependency cycles are resolved.
|
|
167
|
+
'import/no-cycle': ['warn', { maxDepth: 100, ignoreExternal: false, allowUnsafeDynamicCyclicDependency: true }],
|
|
168
|
+
// Disallow importing from barrel files (index files that only re-export).
|
|
169
|
+
// Import directly from the source module instead, e.g. '#components/Foo.js'
|
|
170
|
+
// or '@stamhoofd/package-name/components/Bar' rather than a barrel.
|
|
171
|
+
'no-restricted-imports': ['error', {
|
|
172
|
+
// Bare package-root imports that resolve to a barrel index.ts.
|
|
173
|
+
// These packages expose subpath imports (e.g. '@stamhoofd/components/components/Foo'),
|
|
174
|
+
// which should be used instead of the barrel root.
|
|
175
|
+
paths: [
|
|
176
|
+
'@stamhoofd/components',
|
|
177
|
+
'@stamhoofd/frontend-excel-export',
|
|
178
|
+
'@stamhoofd/frontend-i18n',
|
|
179
|
+
'@stamhoofd/frontend-pdf-builder',
|
|
180
|
+
'@stamhoofd/networking',
|
|
181
|
+
].map(name => ({
|
|
182
|
+
name,
|
|
183
|
+
message: 'Do not import from the barrel root. Import directly from the source module instead, e.g. \'' + name + '/organizations/Foo.vue\'.',
|
|
184
|
+
})),
|
|
185
|
+
// Explicit imports of an index file (e.g. '../models/index.js').
|
|
186
|
+
patterns: [{
|
|
187
|
+
regex: '(^|/)index(\\.(js|ts))?$',
|
|
188
|
+
message: 'Do not import from barrel files (index files). Import directly from the source module instead.',
|
|
189
|
+
}],
|
|
190
|
+
}],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
files: ['**/*.test.js', '**/*.test.ts'],
|
|
195
|
+
rules: {
|
|
196
|
+
'stamhoofd/async-component-with-properties': 'off',
|
|
197
|
+
'stamhoofd/async-route-components': 'off',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
107
200
|
],
|
|
108
201
|
backend: [
|
|
109
202
|
...baseRules,
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const navigationPackage = '@simonbackx/vue-app-navigation';
|
|
2
|
+
const asyncComponentImportNames = new Set([
|
|
3
|
+
'#containers/AsyncComponent.ts',
|
|
4
|
+
'@stamhoofd/components/containers/AsyncComponent.ts',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
function unwrapExpression(node) {
|
|
8
|
+
while (
|
|
9
|
+
node?.type === 'TSAsExpression'
|
|
10
|
+
|| node?.type === 'TSNonNullExpression'
|
|
11
|
+
|| node?.type === 'TSSatisfiesExpression'
|
|
12
|
+
|| node?.type === 'TSTypeAssertion'
|
|
13
|
+
|| node?.type === 'ChainExpression'
|
|
14
|
+
) {
|
|
15
|
+
node = node.expression;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findVariable(sourceCode, identifier) {
|
|
22
|
+
let scope = sourceCode.getScope(identifier);
|
|
23
|
+
|
|
24
|
+
while (scope) {
|
|
25
|
+
const variable = scope.set.get(identifier.name);
|
|
26
|
+
if (variable) {
|
|
27
|
+
return variable;
|
|
28
|
+
}
|
|
29
|
+
scope = scope.upper;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getImportDefinition(variable) {
|
|
36
|
+
return variable?.defs.find(definition => definition.type === 'ImportBinding') ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getImportedName(definition) {
|
|
40
|
+
if (definition?.node.type !== 'ImportSpecifier') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return definition.node.imported.name ?? definition.node.imported.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isImportedFrom(definition, packageName) {
|
|
48
|
+
return definition?.parent.type === 'ImportDeclaration'
|
|
49
|
+
&& definition.parent.source.value === packageName;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isComponentWithPropertiesConstructor(sourceCode, node) {
|
|
53
|
+
const callee = unwrapExpression(node.callee);
|
|
54
|
+
if (callee.type !== 'Identifier') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const definition = getImportDefinition(findVariable(sourceCode, callee));
|
|
59
|
+
return isImportedFrom(definition, navigationPackage)
|
|
60
|
+
&& getImportedName(definition) === 'ComponentWithProperties';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getDefaultImport(sourceCode, node) {
|
|
64
|
+
node = unwrapExpression(node);
|
|
65
|
+
if (node.type !== 'Identifier') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const variable = findVariable(sourceCode, node);
|
|
70
|
+
const definition = getImportDefinition(variable);
|
|
71
|
+
if (definition?.node.type !== 'ImportDefaultSpecifier') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { variable, definition };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getNavigationImportName(sourceCode, node) {
|
|
79
|
+
node = unwrapExpression(node);
|
|
80
|
+
if (node.type !== 'Identifier') {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const definition = getImportDefinition(findVariable(sourceCode, node));
|
|
85
|
+
return isImportedFrom(definition, navigationPackage)
|
|
86
|
+
? getImportedName(definition)
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getComponentConstruction(sourceCode, identifier) {
|
|
91
|
+
let argument = identifier;
|
|
92
|
+
|
|
93
|
+
while (argument.parent?.expression === argument) {
|
|
94
|
+
const parent = unwrapExpression(argument.parent);
|
|
95
|
+
if (parent === argument.parent) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
argument = argument.parent;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const construction = argument.parent;
|
|
102
|
+
return construction?.type === 'NewExpression'
|
|
103
|
+
&& construction.arguments[0] === argument
|
|
104
|
+
&& isComponentWithPropertiesConstructor(sourceCode, construction)
|
|
105
|
+
? construction
|
|
106
|
+
: null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findVariableByName(sourceCode, node, name) {
|
|
110
|
+
let scope = sourceCode.getScope(node);
|
|
111
|
+
|
|
112
|
+
while (scope) {
|
|
113
|
+
const variable = scope.set.get(name);
|
|
114
|
+
if (variable) {
|
|
115
|
+
return variable;
|
|
116
|
+
}
|
|
117
|
+
scope = scope.upper;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getAsyncComponent(sourceCode, node, filename) {
|
|
124
|
+
const existingVariable = findVariableByName(sourceCode, node, 'AsyncComponent');
|
|
125
|
+
const existingDefinition = getImportDefinition(existingVariable);
|
|
126
|
+
if (
|
|
127
|
+
existingDefinition?.node.type === 'ImportSpecifier'
|
|
128
|
+
&& getImportedName(existingDefinition) === 'AsyncComponent'
|
|
129
|
+
&& asyncComponentImportNames.has(existingDefinition.parent.source.value)
|
|
130
|
+
) {
|
|
131
|
+
return {
|
|
132
|
+
name: existingVariable.name,
|
|
133
|
+
importDeclaration: null,
|
|
134
|
+
importSource: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const scope of [sourceCode.getScope(node), sourceCode.getScope(node).upper]) {
|
|
139
|
+
for (const variable of scope?.variables ?? []) {
|
|
140
|
+
const definition = getImportDefinition(variable);
|
|
141
|
+
if (
|
|
142
|
+
definition?.node.type === 'ImportSpecifier'
|
|
143
|
+
&& getImportedName(definition) === 'AsyncComponent'
|
|
144
|
+
&& asyncComponentImportNames.has(definition.parent.source.value)
|
|
145
|
+
) {
|
|
146
|
+
return {
|
|
147
|
+
name: variable.name,
|
|
148
|
+
importDeclaration: null,
|
|
149
|
+
importSource: null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (existingVariable) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const navigationImport = sourceCode.ast.body.find(statement =>
|
|
160
|
+
statement.type === 'ImportDeclaration'
|
|
161
|
+
&& statement.source.value === navigationPackage,
|
|
162
|
+
);
|
|
163
|
+
if (!navigationImport) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
name: 'AsyncComponent',
|
|
169
|
+
importDeclaration: navigationImport,
|
|
170
|
+
importSource: filename.includes('/frontend/shared/components/')
|
|
171
|
+
? '#containers/AsyncComponent.ts'
|
|
172
|
+
: '@stamhoofd/components/containers/AsyncComponent.ts',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getFix(sourceCode, node, importedComponent, filename) {
|
|
177
|
+
const asyncComponent = getAsyncComponent(sourceCode, node, filename);
|
|
178
|
+
if (!asyncComponent || node.arguments.length > 3) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const importDeclaration = importedComponent.definition.parent;
|
|
183
|
+
const importSource = sourceCode.getText(importDeclaration.source);
|
|
184
|
+
const constructions = importedComponent.variable.references.map(
|
|
185
|
+
reference => getComponentConstruction(sourceCode, reference.identifier),
|
|
186
|
+
);
|
|
187
|
+
const canRemoveImport = constructions.every(Boolean);
|
|
188
|
+
const nodesToReplace = canRemoveImport ? constructions : [node];
|
|
189
|
+
|
|
190
|
+
return function fix(fixer) {
|
|
191
|
+
const fixes = nodesToReplace.map((construction) => {
|
|
192
|
+
const properties = construction.arguments[1]
|
|
193
|
+
? sourceCode.getText(construction.arguments[1])
|
|
194
|
+
: '{}';
|
|
195
|
+
const options = construction.arguments[2]
|
|
196
|
+
? `, ${sourceCode.getText(construction.arguments[2])}`
|
|
197
|
+
: '';
|
|
198
|
+
return fixer.replaceText(
|
|
199
|
+
construction,
|
|
200
|
+
`${asyncComponent.name}(() => import(${importSource}), ${properties}${options})`,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (asyncComponent.importDeclaration && asyncComponent.importSource) {
|
|
205
|
+
const indent = ' '.repeat(asyncComponent.importDeclaration.loc.start.column);
|
|
206
|
+
fixes.push(fixer.insertTextAfter(
|
|
207
|
+
asyncComponent.importDeclaration,
|
|
208
|
+
`\n${indent}import { AsyncComponent } from '${asyncComponent.importSource}';`,
|
|
209
|
+
));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (canRemoveImport) {
|
|
213
|
+
if (importDeclaration.specifiers.length === 1) {
|
|
214
|
+
fixes.push(fixer.remove(importDeclaration));
|
|
215
|
+
} else {
|
|
216
|
+
const nextSpecifier = importDeclaration.specifiers[1];
|
|
217
|
+
const tokenBeforeNextSpecifier = sourceCode.getTokenBefore(nextSpecifier);
|
|
218
|
+
fixes.push(fixer.removeRange([
|
|
219
|
+
importedComponent.definition.node.range[0],
|
|
220
|
+
tokenBeforeNextSpecifier.range[0],
|
|
221
|
+
]));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return fixes;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default {
|
|
230
|
+
meta: {
|
|
231
|
+
type: 'suggestion',
|
|
232
|
+
docs: {
|
|
233
|
+
description: 'Require imported Vue components to be loaded through AsyncComponent.',
|
|
234
|
+
},
|
|
235
|
+
fixable: 'code',
|
|
236
|
+
schema: [{
|
|
237
|
+
type: 'object',
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
properties: {
|
|
240
|
+
allow: {
|
|
241
|
+
type: 'array',
|
|
242
|
+
items: { type: 'string' },
|
|
243
|
+
uniqueItems: true,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}],
|
|
247
|
+
messages: {
|
|
248
|
+
asyncComponent: 'Use AsyncComponent(() => import(...), properties) instead of eagerly constructing an imported component.',
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
create(context) {
|
|
252
|
+
const sourceCode = context.sourceCode;
|
|
253
|
+
const allowedComponentNames = new Set(context.options[0]?.allow ?? []);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
NewExpression(node) {
|
|
257
|
+
if (!isComponentWithPropertiesConstructor(sourceCode, node) || !node.arguments[0]) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const component = unwrapExpression(node.arguments[0]);
|
|
262
|
+
if (component.type === 'Identifier' && allowedComponentNames.has(component.name)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const navigationImportName = getNavigationImportName(sourceCode, node.arguments[0]);
|
|
267
|
+
if (navigationImportName && allowedComponentNames.has(navigationImportName)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const importedComponent = getDefaultImport(sourceCode, node.arguments[0]);
|
|
272
|
+
if (!importedComponent) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
context.report({
|
|
277
|
+
node: node.arguments[0],
|
|
278
|
+
messageId: 'asyncComponent',
|
|
279
|
+
fix: getFix(sourceCode, node, importedComponent, context.filename),
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const navigationPackage = '@simonbackx/vue-app-navigation';
|
|
2
|
+
const routeFunctionNames = new Set(['defineRoute', 'defineRoutes']);
|
|
3
|
+
|
|
4
|
+
function unwrapExpression(node) {
|
|
5
|
+
while (
|
|
6
|
+
node?.type === 'TSAsExpression'
|
|
7
|
+
|| node?.type === 'TSNonNullExpression'
|
|
8
|
+
|| node?.type === 'TSSatisfiesExpression'
|
|
9
|
+
|| node?.type === 'TSTypeAssertion'
|
|
10
|
+
|| node?.type === 'ChainExpression'
|
|
11
|
+
) {
|
|
12
|
+
node = node.expression;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return node;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findVariable(sourceCode, identifier) {
|
|
19
|
+
let scope = sourceCode.getScope(identifier);
|
|
20
|
+
|
|
21
|
+
while (scope) {
|
|
22
|
+
const variable = scope.set.get(identifier.name);
|
|
23
|
+
if (variable) {
|
|
24
|
+
return variable;
|
|
25
|
+
}
|
|
26
|
+
scope = scope.upper;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getImportedName(variable) {
|
|
33
|
+
const definition = variable?.defs.find(def => def.type === 'ImportBinding');
|
|
34
|
+
const importDeclaration = definition?.parent;
|
|
35
|
+
|
|
36
|
+
if (importDeclaration?.type !== 'ImportDeclaration' || importDeclaration.source.value !== navigationPackage) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (definition.node.type !== 'ImportSpecifier') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return definition.node.imported.name ?? definition.node.imported.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRouteFunction(sourceCode, node) {
|
|
48
|
+
if (node.type !== 'Identifier') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return routeFunctionNames.has(getImportedName(findVariable(sourceCode, node)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isComponentProperty(node) {
|
|
56
|
+
return node.type === 'Property'
|
|
57
|
+
&& !node.computed
|
|
58
|
+
&& (
|
|
59
|
+
(node.key.type === 'Identifier' && node.key.name === 'component')
|
|
60
|
+
|| (node.key.type === 'Literal' && node.key.value === 'component')
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isInsideRouteDefinition(sourceCode, node) {
|
|
65
|
+
let current = node.parent;
|
|
66
|
+
let containingObject = null;
|
|
67
|
+
|
|
68
|
+
while (current) {
|
|
69
|
+
if (current.type === 'ObjectExpression') {
|
|
70
|
+
if (containingObject) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
containingObject = current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
current.type === 'CallExpression'
|
|
78
|
+
&& isRouteFunction(sourceCode, current.callee)
|
|
79
|
+
&& current.arguments[0]
|
|
80
|
+
&& current.arguments[0].range[0] <= node.range[0]
|
|
81
|
+
&& current.arguments[0].range[1] >= node.range[1]
|
|
82
|
+
) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
current = current.parent;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getFunctionNode(sourceCode, node) {
|
|
92
|
+
node = unwrapExpression(node);
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
node.type === 'ArrowFunctionExpression'
|
|
96
|
+
|| node.type === 'FunctionExpression'
|
|
97
|
+
) {
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (node.type !== 'Identifier') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const variable = findVariable(sourceCode, node);
|
|
106
|
+
for (const definition of variable?.defs ?? []) {
|
|
107
|
+
if (definition.type === 'FunctionName') {
|
|
108
|
+
return definition.node;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (definition.type === 'Variable') {
|
|
112
|
+
const init = unwrapExpression(definition.node.init);
|
|
113
|
+
if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
|
|
114
|
+
return init;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasTypeNamed(type, name, seen = new Set()) {
|
|
123
|
+
if (!type || seen.has(type)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
seen.add(type);
|
|
127
|
+
|
|
128
|
+
if (type.symbol?.getName() === name || type.aliasSymbol?.getName() === name) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return type.types?.some(childType => hasTypeNamed(childType, name, seen)) ?? false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAllowedComponentFunction(sourceCode, parserServices, node) {
|
|
136
|
+
const functionNode = getFunctionNode(sourceCode, node);
|
|
137
|
+
if (!functionNode) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (functionNode.async) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!parserServices?.program || !parserServices.esTreeNodeToTSNodeMap) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const checker = parserServices.program.getTypeChecker();
|
|
150
|
+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(functionNode);
|
|
151
|
+
const signature = checker.getTypeAtLocation(tsNode).getCallSignatures()[0];
|
|
152
|
+
|
|
153
|
+
return signature
|
|
154
|
+
? hasTypeNamed(checker.getReturnTypeOfSignature(signature), 'ComponentWithProperties')
|
|
155
|
+
: false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isSelfComponent(node) {
|
|
159
|
+
node = unwrapExpression(node);
|
|
160
|
+
return node.type === 'Literal' && node.value === 'self';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getRouteComponentProperty(sourceCode, identifier) {
|
|
164
|
+
let value = identifier;
|
|
165
|
+
|
|
166
|
+
while (
|
|
167
|
+
value.parent?.expression === value
|
|
168
|
+
&& (
|
|
169
|
+
value.parent.type === 'TSAsExpression'
|
|
170
|
+
|| value.parent.type === 'TSNonNullExpression'
|
|
171
|
+
|| value.parent.type === 'TSSatisfiesExpression'
|
|
172
|
+
|| value.parent.type === 'TSTypeAssertion'
|
|
173
|
+
|| value.parent.type === 'ChainExpression'
|
|
174
|
+
)
|
|
175
|
+
) {
|
|
176
|
+
value = value.parent;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const property = value.parent;
|
|
180
|
+
if (
|
|
181
|
+
!isComponentProperty(property)
|
|
182
|
+
|| property.value !== value
|
|
183
|
+
|| property.shorthand
|
|
184
|
+
|| !isInsideRouteDefinition(sourceCode, property)
|
|
185
|
+
) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return property;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getImportedVariable(sourceCode, node) {
|
|
193
|
+
const value = unwrapExpression(node.value);
|
|
194
|
+
if (value.type !== 'Identifier') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const variable = findVariable(sourceCode, value);
|
|
199
|
+
return variable?.defs.some(def => def.type === 'ImportBinding')
|
|
200
|
+
? variable
|
|
201
|
+
: null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isImportedComponentUsedOutsideRoutes(sourceCode, node) {
|
|
205
|
+
const variable = getImportedVariable(sourceCode, node);
|
|
206
|
+
return variable?.references.some(
|
|
207
|
+
reference => !getRouteComponentProperty(sourceCode, reference.identifier),
|
|
208
|
+
) ?? false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getDefaultImportFix(sourceCode, node) {
|
|
212
|
+
const variable = getImportedVariable(sourceCode, node);
|
|
213
|
+
const definition = variable?.defs.find(def => def.type === 'ImportBinding');
|
|
214
|
+
if (
|
|
215
|
+
!definition
|
|
216
|
+
|| definition.node.type !== 'ImportDefaultSpecifier'
|
|
217
|
+
) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const componentProperties = variable.references.map(
|
|
222
|
+
reference => getRouteComponentProperty(sourceCode, reference.identifier),
|
|
223
|
+
);
|
|
224
|
+
if (componentProperties.some(property => property === null)) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const importDeclaration = definition.parent;
|
|
229
|
+
const importSource = sourceCode.getText(importDeclaration.source);
|
|
230
|
+
|
|
231
|
+
return function fix(fixer) {
|
|
232
|
+
const fixes = componentProperties.map(property =>
|
|
233
|
+
fixer.replaceText(property.value, `async () => (await import(${importSource})).default`),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (importDeclaration.specifiers.length === 1) {
|
|
237
|
+
fixes.push(fixer.remove(importDeclaration));
|
|
238
|
+
} else {
|
|
239
|
+
const nextSpecifier = importDeclaration.specifiers[1];
|
|
240
|
+
const tokenBeforeNextSpecifier = sourceCode.getTokenBefore(nextSpecifier);
|
|
241
|
+
fixes.push(fixer.removeRange([definition.node.range[0], tokenBeforeNextSpecifier.range[0]]));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return fixes;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default {
|
|
249
|
+
meta: {
|
|
250
|
+
type: 'suggestion',
|
|
251
|
+
docs: {
|
|
252
|
+
description: 'Require route components to be loaded by async functions.',
|
|
253
|
+
},
|
|
254
|
+
fixable: 'code',
|
|
255
|
+
schema: [],
|
|
256
|
+
messages: {
|
|
257
|
+
asyncComponent: 'Route components must be async functions or synchronous functions returning ComponentWithProperties.',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
create(context) {
|
|
261
|
+
const sourceCode = context.sourceCode;
|
|
262
|
+
const parserServices = sourceCode.parserServices;
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
Property(node) {
|
|
266
|
+
if (
|
|
267
|
+
!isComponentProperty(node)
|
|
268
|
+
|| !isInsideRouteDefinition(sourceCode, node)
|
|
269
|
+
|| isSelfComponent(node.value)
|
|
270
|
+
|| isAllowedComponentFunction(sourceCode, parserServices, node.value)
|
|
271
|
+
|| isImportedComponentUsedOutsideRoutes(sourceCode, node)
|
|
272
|
+
) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
context.report({
|
|
277
|
+
node: node.value,
|
|
278
|
+
messageId: 'asyncComponent',
|
|
279
|
+
fix: getDefaultImportFix(sourceCode, node),
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|