@craft-ng/dev-tools 0.5.0-beta.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-ng/dev-tools",
3
- "version": "0.5.0-beta.4",
3
+ "version": "0.5.0-beta.5",
4
4
  "description": "Development tools for ng-craft: ESLint configs, ESLint rules, and codemods",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -176,7 +176,7 @@ function getCallExpressionName(callExpression) {
176
176
  if (Node.isIdentifier(expression)) {
177
177
  return expression.getText();
178
178
  }
179
- // `route(...).withProviders` etc. — the leftmost call's callee.
179
+ // `craftRoute(...).withProviders` etc. — the leftmost call's callee.
180
180
  if (Node.isPropertyAccessExpression(expression)) {
181
181
  return expression.getName();
182
182
  }
@@ -208,7 +208,7 @@ function resolveCollectionVar(craftRoutesCall) {
208
208
  return undefined;
209
209
  }
210
210
 
211
- // A route array element is either `route('path', { … }, { … })` (optionally
211
+ // A route array element is either `craftRoute('path', { … }, { … })` (optionally
212
212
  // followed by `.withProviders(…)`) or a plain `{ path: '…', handleExceptions: { … } }`.
213
213
  function extractRouteDefinition(element) {
214
214
  if (Node.isObjectLiteralExpression(element)) {
@@ -245,11 +245,11 @@ function extractRouteDefinition(element) {
245
245
  return undefined;
246
246
  }
247
247
 
248
- // Walks `route(...).withProviders(...)` (any depth) down to the `route(` call.
248
+ // Walks `craftRoute(...).withProviders(...)` (any depth) down to the `craftRoute(` call.
249
249
  function findRouteCall(node) {
250
250
  let current = node;
251
251
  while (Node.isCallExpression(current)) {
252
- if (getCallExpressionName(current) === 'route') {
252
+ if (getCallExpressionName(current) === 'craftRoute') {
253
253
  return current;
254
254
  }
255
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
  };
@@ -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 = 'route';
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 = `route(s) with a pendingComponent must be verified with ${CHECK_TYPE}(): ${issues
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 `route('<path>', { … })` or a `{ path: '<path>', … }`
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) {