@craft-ng/dev-tools 0.5.0-beta.3 → 0.5.0-beta.5
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 +1 -1
- package/src/eslint-rules/global-exception-registry-match.cjs +11 -15
- package/src/eslint-rules/index.cjs +4 -0
- package/src/eslint-rules/no-angular-inject.cjs +141 -17
- package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
- package/src/eslint-rules/require-exception-component-di-check.cjs +258 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +3 -3
package/package.json
CHANGED
|
@@ -30,8 +30,8 @@ module.exports = {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const text = sourceCode.getText();
|
|
33
|
-
// Cheap pre-filter:
|
|
34
|
-
if (!text.includes('
|
|
33
|
+
// Cheap pre-filter: the delegated outcome must appear.
|
|
34
|
+
if (!text.includes('globalError')) {
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -176,7 +176,7 @@ function getCallExpressionName(callExpression) {
|
|
|
176
176
|
if (Node.isIdentifier(expression)) {
|
|
177
177
|
return expression.getText();
|
|
178
178
|
}
|
|
179
|
-
// `
|
|
179
|
+
// `craftRoute(...).withProviders` etc. — the leftmost call's callee.
|
|
180
180
|
if (Node.isPropertyAccessExpression(expression)) {
|
|
181
181
|
return expression.getName();
|
|
182
182
|
}
|
|
@@ -208,8 +208,8 @@ function resolveCollectionVar(craftRoutesCall) {
|
|
|
208
208
|
return undefined;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// A route array element is either `
|
|
212
|
-
// `.withProviders(…)`) or a plain `{ path: '…', handleExceptions: { … } }`.
|
|
211
|
+
// A route array element is either `craftRoute('path', { … }, { … })` (optionally
|
|
212
|
+
// followed by `.withProviders(…)`) or a plain `{ path: '…', handleExceptions: { … } }`.
|
|
213
213
|
function extractRouteDefinition(element) {
|
|
214
214
|
if (Node.isObjectLiteralExpression(element)) {
|
|
215
215
|
const pathValue = readStringProperty(element, 'path');
|
|
@@ -225,23 +225,19 @@ function extractRouteDefinition(element) {
|
|
|
225
225
|
if (!routeCall) {
|
|
226
226
|
return undefined;
|
|
227
227
|
}
|
|
228
|
-
const [pathArg,
|
|
228
|
+
const [pathArg, , handlersArg] = routeCall.getArguments();
|
|
229
229
|
if (
|
|
230
230
|
!pathArg ||
|
|
231
|
-
!
|
|
232
|
-
!Node.isObjectLiteralExpression(
|
|
231
|
+
!handlersArg ||
|
|
232
|
+
!Node.isObjectLiteralExpression(handlersArg) ||
|
|
233
233
|
(!Node.isStringLiteral(pathArg) &&
|
|
234
234
|
!Node.isNoSubstitutionTemplateLiteral(pathArg))
|
|
235
235
|
) {
|
|
236
236
|
return undefined;
|
|
237
237
|
}
|
|
238
|
-
const handleExceptions = readObjectProperty(defArg, 'handleExceptions');
|
|
239
|
-
if (!handleExceptions) {
|
|
240
|
-
return undefined;
|
|
241
|
-
}
|
|
242
238
|
return {
|
|
243
239
|
path: pathArg.getLiteralText(),
|
|
244
|
-
handleExceptions,
|
|
240
|
+
handleExceptions: handlersArg,
|
|
245
241
|
reportNode: routeCall,
|
|
246
242
|
};
|
|
247
243
|
}
|
|
@@ -249,11 +245,11 @@ function extractRouteDefinition(element) {
|
|
|
249
245
|
return undefined;
|
|
250
246
|
}
|
|
251
247
|
|
|
252
|
-
// Walks `
|
|
248
|
+
// Walks `craftRoute(...).withProviders(...)` (any depth) down to the `craftRoute(` call.
|
|
253
249
|
function findRouteCall(node) {
|
|
254
250
|
let current = node;
|
|
255
251
|
while (Node.isCallExpression(current)) {
|
|
256
|
-
if (getCallExpressionName(current) === '
|
|
252
|
+
if (getCallExpressionName(current) === 'craftRoute') {
|
|
257
253
|
return current;
|
|
258
254
|
}
|
|
259
255
|
const expression = current.getExpression();
|
|
@@ -19,6 +19,8 @@ const requireTrackOnDependentPrimitives = require('./require-track-on-dependent-
|
|
|
19
19
|
const requireAssertExhaustiveRouteExceptions = require('./require-assert-exhaustive-route-exceptions.cjs');
|
|
20
20
|
const preferCraftRouterOutlet = require('./prefer-craft-router-outlet.cjs');
|
|
21
21
|
const requirePendingComponentDiCheck = require('./require-pending-component-di-check.cjs');
|
|
22
|
+
const requireCraftExceptionHandler = require('./require-craft-exception-handler.cjs');
|
|
23
|
+
const requireExceptionComponentDiCheck = require('./require-exception-component-di-check.cjs');
|
|
22
24
|
const requireChildRouteMountCheck = require('./require-child-route-mount-check.cjs');
|
|
23
25
|
|
|
24
26
|
module.exports = {
|
|
@@ -45,6 +47,8 @@ module.exports = {
|
|
|
45
47
|
requireAssertExhaustiveRouteExceptions,
|
|
46
48
|
'prefer-craft-router-outlet': preferCraftRouterOutlet,
|
|
47
49
|
'require-pending-component-di-check': requirePendingComponentDiCheck,
|
|
50
|
+
'require-craft-exception-handler': requireCraftExceptionHandler,
|
|
51
|
+
'require-exception-component-di-check': requireExceptionComponentDiCheck,
|
|
48
52
|
'require-child-route-mount-check': requireChildRouteMountCheck,
|
|
49
53
|
},
|
|
50
54
|
};
|
|
@@ -5,10 +5,14 @@ module.exports = {
|
|
|
5
5
|
description:
|
|
6
6
|
'Disallow Angular inject() usage so dependencies go through craftService or toCraftService.',
|
|
7
7
|
},
|
|
8
|
+
hasSuggestions: true,
|
|
8
9
|
schema: [],
|
|
9
10
|
},
|
|
10
11
|
create(context) {
|
|
11
12
|
const angularNamespaceImports = new Set();
|
|
13
|
+
const angularInjectImports = new Map();
|
|
14
|
+
const reportedInjectImports = new Set();
|
|
15
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
12
16
|
|
|
13
17
|
return {
|
|
14
18
|
ImportDeclaration(node) {
|
|
@@ -22,11 +26,7 @@ module.exports = {
|
|
|
22
26
|
specifier.imported.type === 'Identifier' &&
|
|
23
27
|
specifier.imported.name === 'inject'
|
|
24
28
|
) {
|
|
25
|
-
|
|
26
|
-
node: specifier,
|
|
27
|
-
message:
|
|
28
|
-
'Angular inject() is forbidden. Expose a craftService/toCraftService injector instead.',
|
|
29
|
-
});
|
|
29
|
+
angularInjectImports.set(specifier.local.name, specifier);
|
|
30
30
|
continue;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -36,24 +36,50 @@ module.exports = {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
CallExpression(node) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
callee.property.type !== 'Identifier' ||
|
|
46
|
-
callee.property.name !== 'inject'
|
|
47
|
-
) {
|
|
39
|
+
const angularInjectNode = getAngularInjectCallee(
|
|
40
|
+
node.callee,
|
|
41
|
+
angularInjectImports,
|
|
42
|
+
angularNamespaceImports,
|
|
43
|
+
);
|
|
44
|
+
if (!angularInjectNode) {
|
|
48
45
|
return;
|
|
49
46
|
}
|
|
50
47
|
|
|
48
|
+
if (node.callee.type === 'Identifier') {
|
|
49
|
+
reportedInjectImports.add(node.callee.name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tokenText = getInjectTokenText(node.arguments[0], sourceCode);
|
|
53
|
+
const helperName = getRecommendedInjectorName(node.arguments[0]);
|
|
54
|
+
|
|
51
55
|
context.report({
|
|
52
|
-
node:
|
|
53
|
-
message:
|
|
54
|
-
|
|
56
|
+
node: angularInjectNode,
|
|
57
|
+
message: createInjectMessage(tokenText, helperName),
|
|
58
|
+
suggest: [
|
|
59
|
+
createTemporaryDisableSuggestion(
|
|
60
|
+
sourceCode,
|
|
61
|
+
angularInjectNode,
|
|
62
|
+
'no-angular-inject',
|
|
63
|
+
helperName
|
|
64
|
+
? `replace this Angular inject(${tokenText}) call with ${helperName} from a craftService/toCraftService adapter`
|
|
65
|
+
: 'replace this Angular inject() call with a craftService/toCraftService injector',
|
|
66
|
+
),
|
|
67
|
+
],
|
|
55
68
|
});
|
|
56
69
|
},
|
|
70
|
+
'Program:exit'() {
|
|
71
|
+
for (const [localName, specifier] of angularInjectImports) {
|
|
72
|
+
if (reportedInjectImports.has(localName)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
context.report({
|
|
77
|
+
node: specifier,
|
|
78
|
+
message:
|
|
79
|
+
'Angular inject() is forbidden. Import and use the injectX helper exposed by a craftService/toCraftService adapter instead.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
57
83
|
};
|
|
58
84
|
},
|
|
59
85
|
};
|
|
@@ -61,3 +87,101 @@ module.exports = {
|
|
|
61
87
|
function isAngularModule(sourceValue) {
|
|
62
88
|
return typeof sourceValue === 'string' && sourceValue.startsWith('@angular/');
|
|
63
89
|
}
|
|
90
|
+
|
|
91
|
+
function getAngularInjectCallee(
|
|
92
|
+
callee,
|
|
93
|
+
angularInjectImports,
|
|
94
|
+
angularNamespaceImports,
|
|
95
|
+
) {
|
|
96
|
+
if (callee.type === 'Identifier' && angularInjectImports.has(callee.name)) {
|
|
97
|
+
return callee;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
callee.type === 'MemberExpression' &&
|
|
102
|
+
!callee.computed &&
|
|
103
|
+
callee.object.type === 'Identifier' &&
|
|
104
|
+
angularNamespaceImports.has(callee.object.name) &&
|
|
105
|
+
callee.property.type === 'Identifier' &&
|
|
106
|
+
callee.property.name === 'inject'
|
|
107
|
+
) {
|
|
108
|
+
return callee.property;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createInjectMessage(tokenText, helperName) {
|
|
115
|
+
if (!helperName) {
|
|
116
|
+
return 'Angular inject() is forbidden. Use the injectX helper exposed by a craftService/toCraftService adapter instead.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return `Angular inject(${tokenText}) is forbidden. Use ${helperName} from a craftService/toCraftService adapter instead.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getInjectTokenText(argument, sourceCode) {
|
|
123
|
+
if (!argument) {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sourceCode.getText(argument);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getRecommendedInjectorName(argument) {
|
|
131
|
+
const tokenName = getTokenName(argument);
|
|
132
|
+
if (!tokenName) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `inject${toPascalCase(tokenName)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getTokenName(argument) {
|
|
140
|
+
if (!argument) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (argument.type === 'Identifier') {
|
|
145
|
+
return argument.name;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
argument.type === 'MemberExpression' &&
|
|
150
|
+
!argument.computed &&
|
|
151
|
+
argument.property.type === 'Identifier'
|
|
152
|
+
) {
|
|
153
|
+
return argument.property.name;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toPascalCase(value) {
|
|
160
|
+
return value
|
|
161
|
+
.replace(/^[^A-Za-z$]+/, '')
|
|
162
|
+
.split(/[^A-Za-z0-9$]+|(?<=[a-z0-9])(?=[A-Z])/)
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
165
|
+
.join('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createTemporaryDisableSuggestion(
|
|
169
|
+
sourceCode,
|
|
170
|
+
node,
|
|
171
|
+
ruleName,
|
|
172
|
+
migrationNote,
|
|
173
|
+
) {
|
|
174
|
+
return {
|
|
175
|
+
desc: 'Insert a temporary eslint-disable-next-line comment with a migration note.',
|
|
176
|
+
fix(fixer) {
|
|
177
|
+
const lineStart = sourceCode.getIndexFromLoc({
|
|
178
|
+
line: node.loc.start.line,
|
|
179
|
+
column: 0,
|
|
180
|
+
});
|
|
181
|
+
return fixer.insertTextBeforeRange(
|
|
182
|
+
[lineStart, lineStart],
|
|
183
|
+
`// eslint-disable-next-line craft-ng/${ruleName} -- ${migrationNote}\n`,
|
|
184
|
+
);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Enforces craftExceptionHandler(function* (...) {}) in craftRoute() handler maps. */
|
|
2
|
+
module.exports = {
|
|
3
|
+
meta: {
|
|
4
|
+
type: 'problem',
|
|
5
|
+
fixable: 'code',
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: {
|
|
8
|
+
wrap: 'Route exception handlers must use craftExceptionHandler(function* (...) {}).',
|
|
9
|
+
redirect:
|
|
10
|
+
'Raw redirect(...) is ambiguous: migrate internal routes to redirectTo(...) and opaque URLs to redirectUrl(...).',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const source = context.sourceCode ?? context.getSourceCode();
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
'Program:exit'(program) {
|
|
18
|
+
const issues = [];
|
|
19
|
+
|
|
20
|
+
for (const statement of program.body) {
|
|
21
|
+
walk(statement, (node) => {
|
|
22
|
+
if (
|
|
23
|
+
node.type !== 'CallExpression' ||
|
|
24
|
+
node.callee?.type !== 'Identifier' ||
|
|
25
|
+
node.callee.name !== 'craftRoute' ||
|
|
26
|
+
node.arguments.length < 3
|
|
27
|
+
) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handlers = node.arguments[2];
|
|
32
|
+
if (handlers?.type !== 'ObjectExpression') return;
|
|
33
|
+
|
|
34
|
+
for (const property of handlers.properties) {
|
|
35
|
+
if (property.type !== 'Property') continue;
|
|
36
|
+
const value = property.value;
|
|
37
|
+
if (
|
|
38
|
+
value.type === 'CallExpression' &&
|
|
39
|
+
value.callee?.type === 'Identifier' &&
|
|
40
|
+
value.callee.name === 'craftExceptionHandler'
|
|
41
|
+
) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
value.type !== 'ArrowFunctionExpression' &&
|
|
47
|
+
value.type !== 'FunctionExpression'
|
|
48
|
+
) {
|
|
49
|
+
issues.push({ node: value, manual: true });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = source.getText(value);
|
|
54
|
+
issues.push({
|
|
55
|
+
node: value,
|
|
56
|
+
manual: /\bredirect\s*\(/.test(text),
|
|
57
|
+
replacement: wrapHandler(value, source),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (issues.length === 0) return;
|
|
64
|
+
const manual = issues.filter((issue) => issue.manual);
|
|
65
|
+
if (manual.length > 0) {
|
|
66
|
+
for (const issue of manual) {
|
|
67
|
+
context.report({ node: issue.node, messageId: 'redirect' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fixable = issues.filter((item) => !item.manual);
|
|
72
|
+
if (fixable.length === 0) return;
|
|
73
|
+
const original = source.getText();
|
|
74
|
+
const replacements = fixable
|
|
75
|
+
.map((issue) => ({
|
|
76
|
+
range: issue.node.range,
|
|
77
|
+
text: issue.replacement,
|
|
78
|
+
}))
|
|
79
|
+
.sort((a, b) => b.range[0] - a.range[0]);
|
|
80
|
+
let fixed = original;
|
|
81
|
+
for (const replacement of replacements) {
|
|
82
|
+
fixed =
|
|
83
|
+
fixed.slice(0, replacement.range[0]) +
|
|
84
|
+
replacement.text +
|
|
85
|
+
fixed.slice(replacement.range[1]);
|
|
86
|
+
}
|
|
87
|
+
fixed = ensureImport(fixed);
|
|
88
|
+
|
|
89
|
+
context.report({
|
|
90
|
+
node: fixable[0].node,
|
|
91
|
+
messageId: 'wrap',
|
|
92
|
+
fix: (fixer) => fixer.replaceTextRange([0, original.length], fixed),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function wrapHandler(node, source) {
|
|
100
|
+
const params = node.params.map((param) => source.getText(param)).join(', ');
|
|
101
|
+
const body =
|
|
102
|
+
node.body.type === 'BlockStatement'
|
|
103
|
+
? source.getText(node.body)
|
|
104
|
+
: `{ return ${source.getText(node.body)}; }`;
|
|
105
|
+
return `craftExceptionHandler(function* (${params}) ${body})`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ensureImport(text) {
|
|
109
|
+
if (
|
|
110
|
+
/\bcraftExceptionHandler\b/.test(
|
|
111
|
+
text.match(/import[\s\S]*?from\s+['"]@craft-ng\/core['"]/g)?.join('\n') ??
|
|
112
|
+
'',
|
|
113
|
+
)
|
|
114
|
+
) {
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
const pattern = /import\s*\{([\s\S]*?)\}\s*from\s*(['"]@craft-ng\/core['"])/;
|
|
118
|
+
if (pattern.test(text)) {
|
|
119
|
+
return text.replace(pattern, (_all, names, source) => {
|
|
120
|
+
const separator = names.includes('\n') ? '\n ' : ' ';
|
|
121
|
+
return `import {${names.trimEnd()},${separator}craftExceptionHandler\n} from ${source}`;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return `import { craftExceptionHandler } from '@craft-ng/core';\n${text}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function walk(node, visit) {
|
|
128
|
+
if (!node || typeof node !== 'object') return;
|
|
129
|
+
visit(node);
|
|
130
|
+
for (const [key, value] of Object.entries(node)) {
|
|
131
|
+
if (key === 'parent' || key === 'range' || key === 'loc') continue;
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
for (const child of value) walk(child, visit);
|
|
134
|
+
} else if (
|
|
135
|
+
value &&
|
|
136
|
+
typeof value === 'object' &&
|
|
137
|
+
typeof value.type === 'string'
|
|
138
|
+
) {
|
|
139
|
+
walk(value, visit);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/** Generates O(1) DI checks for renderComponent/errorComponent/withErrorComponent. */
|
|
2
|
+
module.exports = {
|
|
3
|
+
meta: { type: 'problem', fixable: 'code', schema: [] },
|
|
4
|
+
create(context) {
|
|
5
|
+
const source = context.sourceCode ?? context.getSourceCode();
|
|
6
|
+
return {
|
|
7
|
+
'Program:exit'(program) {
|
|
8
|
+
const text = source.getText();
|
|
9
|
+
if (!/renderComponent|errorComponent|withErrorComponent/.test(text))
|
|
10
|
+
return;
|
|
11
|
+
|
|
12
|
+
const cascade = readCascadeContext(text);
|
|
13
|
+
const checks = [];
|
|
14
|
+
walk(program, (node) => {
|
|
15
|
+
if (isCall(node, 'craftRoutes')) {
|
|
16
|
+
collectRouteChecks(node, source, cascade, checks);
|
|
17
|
+
} else if (isCall(node, 'withErrorComponent')) {
|
|
18
|
+
const descriptor = node.arguments[0];
|
|
19
|
+
const deps = readProperty(descriptor, 'componentDeps', source);
|
|
20
|
+
if (deps) {
|
|
21
|
+
checks.push({
|
|
22
|
+
deps,
|
|
23
|
+
names: joinNames(cascade.names, ['CraftGlobalError']),
|
|
24
|
+
values: cascade.values,
|
|
25
|
+
label: 'global error component',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const missing = checks.filter(
|
|
32
|
+
(check) =>
|
|
33
|
+
!text.includes(
|
|
34
|
+
`RouteExceptionComponentCheckedDI<\n ${check.deps},`,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
if (missing.length === 0) return;
|
|
38
|
+
|
|
39
|
+
let fixed = ensureImports(text);
|
|
40
|
+
fixed += missing.map(renderCheck).join('');
|
|
41
|
+
context.report({
|
|
42
|
+
node: program,
|
|
43
|
+
message: `${missing.length} exception component(s) must be checked with RouteExceptionComponentCheckedDI.`,
|
|
44
|
+
fix: (fixer) => fixer.replaceTextRange([0, text.length], fixed),
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function collectRouteChecks(call, source, cascade, checks) {
|
|
52
|
+
const collection = literal(call.arguments[0]);
|
|
53
|
+
const array = call.arguments[1];
|
|
54
|
+
if (!collection || array?.type !== 'ArrayExpression') return;
|
|
55
|
+
|
|
56
|
+
for (const entry of array.elements) {
|
|
57
|
+
const routeCall = unwrapRouteCall(entry);
|
|
58
|
+
const def = routeCall ? routeCall.arguments[1] : entry;
|
|
59
|
+
const path = routeCall
|
|
60
|
+
? literal(routeCall.arguments[0])
|
|
61
|
+
: literal(propertyValue(def, 'path'));
|
|
62
|
+
if (path === undefined || def?.type !== 'ObjectExpression') continue;
|
|
63
|
+
const baseNames = routeNames(collection, path, def);
|
|
64
|
+
if (routeCall && routeCall !== entry) {
|
|
65
|
+
walk(entry.arguments[0], (node) => {
|
|
66
|
+
if (
|
|
67
|
+
node?.type === 'CallExpression' &&
|
|
68
|
+
node.callee?.type === 'Identifier' &&
|
|
69
|
+
node.callee.name.startsWith('provide')
|
|
70
|
+
) {
|
|
71
|
+
baseNames.push(node.callee.name.slice('provide'.length));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const routeError = propertyValue(def, 'errorComponent');
|
|
77
|
+
const routeDeps = readProperty(routeError, 'componentDeps', source);
|
|
78
|
+
if (routeDeps) {
|
|
79
|
+
checks.push({
|
|
80
|
+
deps: routeDeps,
|
|
81
|
+
names: joinNames(cascade.names, [...baseNames, 'CraftGlobalError']),
|
|
82
|
+
values: cascade.values,
|
|
83
|
+
label: `error component: ${path}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const handlers = routeCall
|
|
88
|
+
? routeCall.arguments[2]
|
|
89
|
+
: propertyValue(def, 'handleExceptions');
|
|
90
|
+
if (handlers?.type !== 'ObjectExpression') continue;
|
|
91
|
+
for (const handler of handlers.properties) {
|
|
92
|
+
if (handler.type !== 'Property') continue;
|
|
93
|
+
const code = propertyName(handler);
|
|
94
|
+
walk(handler.value, (node) => {
|
|
95
|
+
if (!isCall(node, 'renderComponent')) return;
|
|
96
|
+
const deps = readProperty(node.arguments[0], 'componentDeps', source);
|
|
97
|
+
if (!deps) return;
|
|
98
|
+
const helper = `${pascal(collection)}${routeBase(path)}${pascal(code)}Exception`;
|
|
99
|
+
checks.push({
|
|
100
|
+
deps,
|
|
101
|
+
names: joinNames(cascade.names, [...baseNames, helper]),
|
|
102
|
+
values: cascade.values,
|
|
103
|
+
label: `render component: ${path}#${code}`,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unwrapRouteCall(entry) {
|
|
111
|
+
if (isCall(entry, 'craftRoute')) return entry;
|
|
112
|
+
if (
|
|
113
|
+
entry?.type === 'CallExpression' &&
|
|
114
|
+
entry.callee?.type === 'MemberExpression' &&
|
|
115
|
+
entry.callee.property?.type === 'Identifier' &&
|
|
116
|
+
entry.callee.property.name === 'withProviders' &&
|
|
117
|
+
isCall(entry.callee.object, 'craftRoute')
|
|
118
|
+
) {
|
|
119
|
+
return entry.callee.object;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function routeNames(collection, path, def) {
|
|
125
|
+
const prefix = pascal(collection);
|
|
126
|
+
const base = routeBase(path);
|
|
127
|
+
const names = path
|
|
128
|
+
.split('/')
|
|
129
|
+
.filter((x) => x.startsWith(':'))
|
|
130
|
+
.map((x) => `${prefix}${pascal(x.slice(1).replace(/\?$/, ''))}Params`);
|
|
131
|
+
if (propertyValue(def, 'data')) names.push(`${prefix}${base}Data`);
|
|
132
|
+
if (propertyValue(def, 'queryParams'))
|
|
133
|
+
names.push(`${prefix}${base}QueryParams`);
|
|
134
|
+
if (propertyValue(def, 'withLoaderViewTransitionImage'))
|
|
135
|
+
names.push(`${prefix}${base}ViewTransition`);
|
|
136
|
+
const providers = propertyValue(def, 'providers');
|
|
137
|
+
if (providers?.type === 'ArrayExpression') {
|
|
138
|
+
for (const provider of providers.elements) {
|
|
139
|
+
if (
|
|
140
|
+
provider?.type === 'CallExpression' &&
|
|
141
|
+
provider.callee?.type === 'Identifier' &&
|
|
142
|
+
provider.callee.name.startsWith('provide')
|
|
143
|
+
) {
|
|
144
|
+
names.push(provider.callee.name.slice('provide'.length));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return names;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderCheck(check, index) {
|
|
152
|
+
const id = check.label
|
|
153
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
154
|
+
.trim()
|
|
155
|
+
.split(/\s+/)
|
|
156
|
+
.map(pascal)
|
|
157
|
+
.join('');
|
|
158
|
+
return `\n\ntype _Check${id}${index ?? ''}DI = RouteExceptionComponentCheckedDI<\n ${check.deps},\n ${check.names || 'never'},\n ${check.values},\n '${check.label}',\n>;\ntype _CanRun${id}${index ?? ''} = CanRun<_Check${id}${index ?? ''}DI>;`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readCascadeContext(text) {
|
|
162
|
+
const match = text.match(
|
|
163
|
+
/ValidateCascadeRoutesFile<\s*([^,]+),\s*([^,]+),\s*typeof\s+\w+\s*>/m,
|
|
164
|
+
);
|
|
165
|
+
return {
|
|
166
|
+
names: match?.[1]?.trim() ?? 'never',
|
|
167
|
+
values: match?.[2]?.trim() ?? 'never',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function joinNames(parent, names) {
|
|
172
|
+
return (
|
|
173
|
+
[parent === 'never' ? '' : parent, ...names.map((name) => `'${name}'`)]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(' | ') || 'never'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureImports(text) {
|
|
180
|
+
const names = ['CanRun', 'RouteExceptionComponentCheckedDI'];
|
|
181
|
+
const match = text.match(
|
|
182
|
+
/import\s*\{([\s\S]*?)\}\s*from\s*(['"]@craft-ng\/core['"])/,
|
|
183
|
+
);
|
|
184
|
+
if (!match)
|
|
185
|
+
return `import type { ${names.join(', ')} } from '@craft-ng/core';\n${text}`;
|
|
186
|
+
const missing = names.filter(
|
|
187
|
+
(name) => !new RegExp(`\\b${name}\\b`).test(match[1]),
|
|
188
|
+
);
|
|
189
|
+
if (!missing.length) return text;
|
|
190
|
+
return text.replace(
|
|
191
|
+
match[0],
|
|
192
|
+
`import {${match[1].trimEnd()},\n type ${missing.join(',\n type ')},\n} from ${match[2]}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readProperty(node, name, source) {
|
|
197
|
+
const value = propertyValue(node, name);
|
|
198
|
+
return value ? source.getText(value) : undefined;
|
|
199
|
+
}
|
|
200
|
+
function propertyValue(node, name) {
|
|
201
|
+
if (node?.type !== 'ObjectExpression') return undefined;
|
|
202
|
+
return node.properties.find(
|
|
203
|
+
(p) => p.type === 'Property' && propertyName(p) === name,
|
|
204
|
+
)?.value;
|
|
205
|
+
}
|
|
206
|
+
function propertyName(property) {
|
|
207
|
+
return property.key?.type === 'Identifier'
|
|
208
|
+
? property.key.name
|
|
209
|
+
: (literal(property.key) ?? '');
|
|
210
|
+
}
|
|
211
|
+
function literal(node) {
|
|
212
|
+
return node?.type === 'Literal'
|
|
213
|
+
? node.value
|
|
214
|
+
: node?.type === 'StringLiteral'
|
|
215
|
+
? node.value
|
|
216
|
+
: undefined;
|
|
217
|
+
}
|
|
218
|
+
function isCall(node, name) {
|
|
219
|
+
return (
|
|
220
|
+
node?.type === 'CallExpression' &&
|
|
221
|
+
node.callee?.type === 'Identifier' &&
|
|
222
|
+
node.callee.name === name
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
function pascal(value) {
|
|
226
|
+
return String(value)
|
|
227
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.map(
|
|
230
|
+
(x) =>
|
|
231
|
+
x[0].toUpperCase() +
|
|
232
|
+
(x === x.toUpperCase() ? x.slice(1).toLowerCase() : x.slice(1)),
|
|
233
|
+
)
|
|
234
|
+
.join('');
|
|
235
|
+
}
|
|
236
|
+
function routeBase(path) {
|
|
237
|
+
return (
|
|
238
|
+
String(path)
|
|
239
|
+
.split('/')
|
|
240
|
+
.filter(Boolean)
|
|
241
|
+
.map((x) => pascal(x.replace(/^:/, '').replace(/\?$/, '')))
|
|
242
|
+
.join('') || 'Root'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
function walk(node, visit) {
|
|
246
|
+
if (!node || typeof node !== 'object') return;
|
|
247
|
+
visit(node);
|
|
248
|
+
for (const [key, value] of Object.entries(node)) {
|
|
249
|
+
if (key === 'parent' || key === 'range' || key === 'loc') continue;
|
|
250
|
+
if (Array.isArray(value)) value.forEach((child) => walk(child, visit));
|
|
251
|
+
else if (
|
|
252
|
+
value &&
|
|
253
|
+
typeof value === 'object' &&
|
|
254
|
+
typeof value.type === 'string'
|
|
255
|
+
)
|
|
256
|
+
walk(value, visit);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -5,7 +5,7 @@ const { IndentationText, Node, Project, QuoteKind, SyntaxKind } = require('ts-mo
|
|
|
5
5
|
const projectCache = new Map();
|
|
6
6
|
|
|
7
7
|
const ROUTES_FACTORY = 'craftRoutes';
|
|
8
|
-
const ROUTE_FN = '
|
|
8
|
+
const ROUTE_FN = 'craftRoute';
|
|
9
9
|
const PENDING_PROP = 'pendingComponent';
|
|
10
10
|
const VT_PROP = 'withLoaderViewTransitionImage';
|
|
11
11
|
const CHECK_TYPE = 'RouteCheckedDI';
|
|
@@ -99,7 +99,7 @@ module.exports = {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const fixedText = sourceFile.getFullText();
|
|
102
|
-
const message = `
|
|
102
|
+
const message = `craftRoute(s) with a pendingComponent must be verified with ${CHECK_TYPE}(): ${issues
|
|
103
103
|
.map((i) => i.path)
|
|
104
104
|
.join(', ')}`;
|
|
105
105
|
|
|
@@ -178,7 +178,7 @@ function collectCollections(sourceFile, filePath) {
|
|
|
178
178
|
return collections;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// A route element is either `
|
|
181
|
+
// A route element is either `craftRoute('<path>', { … })` or a `{ path: '<path>', … }`
|
|
182
182
|
// object literal. Returns the path string and the route's object literal.
|
|
183
183
|
function readRoute(element) {
|
|
184
184
|
if (Node.isCallExpression(element) && element.getExpression().getText() === ROUTE_FN) {
|