@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-ng/dev-tools",
3
- "version": "0.5.0-beta.3",
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",
@@ -30,8 +30,8 @@ module.exports = {
30
30
  }
31
31
 
32
32
  const text = sourceCode.getText();
33
- // Cheap pre-filter: both the trigger and the delegated outcome must appear.
34
- if (!text.includes('handleExceptions') || !text.includes('globalError')) {
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
- // `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,8 +208,8 @@ function resolveCollectionVar(craftRoutesCall) {
208
208
  return undefined;
209
209
  }
210
210
 
211
- // A route array element is either `route('path', { … })` (optionally followed by
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, defArg] = routeCall.getArguments();
228
+ const [pathArg, , handlersArg] = routeCall.getArguments();
229
229
  if (
230
230
  !pathArg ||
231
- !defArg ||
232
- !Node.isObjectLiteralExpression(defArg) ||
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 `route(...).withProviders(...)` (any depth) down to the `route(` call.
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) === 'route') {
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
- context.report({
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 callee = node.callee;
40
- if (
41
- callee.type !== 'MemberExpression' ||
42
- callee.computed ||
43
- callee.object.type !== 'Identifier' ||
44
- !angularNamespaceImports.has(callee.object.name) ||
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: callee.property,
53
- message:
54
- 'Angular inject() is forbidden. Expose a craftService/toCraftService injector instead.',
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 = '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) {