@craft-ng/dev-tools 0.5.1-beta.0 → 0.6.0-beta.2
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/README.md +66 -0
- package/generators.json +26 -0
- package/package.json +18 -1
- package/src/bin/craft.d.ts +2 -0
- package/src/bin/craft.js +166 -0
- package/src/bin/craft.js.map +1 -0
- package/src/eslint-rules/craft-computed-name-match.cjs +8 -125
- package/src/eslint-rules/craft-method-name-match.cjs +8 -166
- package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
- package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-source-name-match.cjs +8 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +78 -13
- package/src/eslint-rules/index.cjs +8 -2
- package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
- package/src/eslint-rules/require-primitive-generator-unwrap.cjs +267 -0
- package/src/generators/route/compat.d.ts +3 -0
- package/src/generators/route/compat.js +6 -0
- package/src/generators/route/compat.js.map +1 -0
- package/src/generators/route/generator.d.ts +31 -0
- package/src/generators/route/generator.js +434 -0
- package/src/generators/route/generator.js.map +1 -0
- package/src/generators/route/schema.json +50 -0
- package/src/generators/route/split-schema.json +19 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/scripts/primitives/migrate-primitive-generators.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitive-generators.js +315 -0
- package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
- package/src/scripts/primitives/migrate-primitive-generators.ts +402 -0
- package/src/scripts/primitives/migrate-primitives.js +36 -4
- package/src/scripts/primitives/migrate-primitives.js.map +1 -1
- package/src/scripts/primitives/migrate-primitives.spec.ts +43 -0
- package/src/scripts/primitives/migrate-primitives.ts +62 -4
- package/src/scripts/primitives/migration-diagnostic.d.ts +1 -1
- package/src/scripts/primitives/migration-diagnostic.ts +1 -0
- package/src/scripts/routes/migrate-routes.js +4 -4
- package/src/scripts/routes/migrate-routes.js.map +1 -1
- package/src/scripts/routes/migrate-routes.ts +4 -6
- package/src/scripts/routes/route-command.d.ts +75 -0
- package/src/scripts/routes/route-command.js +1187 -0
- package/src/scripts/routes/route-command.js.map +1 -0
- package/src/scripts/routes/route-command.spec.ts +553 -0
- package/src/scripts/routes/route-command.ts +1790 -0
- package/src/scripts/services/migrate-services.js +45 -11
- package/src/scripts/services/migrate-services.js.map +1 -1
- package/src/scripts/services/migrate-services.spec.ts +46 -4
- package/src/scripts/services/migrate-services.ts +64 -13
- package/src/scripts/services/migration-diagnostic.d.ts +1 -1
- package/src/scripts/services/migration-diagnostic.ts +1 -0
- package/src/eslint-rules/require-track-on-dependent-primitives.cjs +0 -219
|
@@ -0,0 +1,1790 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import {
|
|
5
|
+
basename,
|
|
6
|
+
dirname,
|
|
7
|
+
extname,
|
|
8
|
+
isAbsolute,
|
|
9
|
+
join,
|
|
10
|
+
relative,
|
|
11
|
+
resolve,
|
|
12
|
+
sep,
|
|
13
|
+
} from 'node:path';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import { names } from '@nx/devkit';
|
|
16
|
+
import {
|
|
17
|
+
ArrayLiteralExpression,
|
|
18
|
+
CallExpression,
|
|
19
|
+
Node,
|
|
20
|
+
Project,
|
|
21
|
+
QuoteKind,
|
|
22
|
+
SourceFile,
|
|
23
|
+
SyntaxKind,
|
|
24
|
+
ts,
|
|
25
|
+
} from 'ts-morph';
|
|
26
|
+
import {
|
|
27
|
+
findAngularDecoratedClass,
|
|
28
|
+
transformSourceFile as generateAngularDependencies,
|
|
29
|
+
} from '../angular-brand-codemod.js';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
const nodeRouteFileSystem: RouteFileSystem = {
|
|
34
|
+
exists: existsSync,
|
|
35
|
+
listFiles: listFilesOnDisk,
|
|
36
|
+
read: (filePath) =>
|
|
37
|
+
existsSync(filePath) ? readFileSync(filePath, 'utf8') : undefined,
|
|
38
|
+
write: async (filePath, content) => {
|
|
39
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
40
|
+
await writeFile(filePath, content, 'utf8');
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RouteCommandDiagnostic = {
|
|
45
|
+
code:
|
|
46
|
+
| 'AMBIGUOUS_PARENT'
|
|
47
|
+
| 'COMPONENT_GENERATION_FAILED'
|
|
48
|
+
| 'COMPONENT_NOT_FOUND'
|
|
49
|
+
| 'DUPLICATE_ROUTE'
|
|
50
|
+
| 'INVALID_ARGUMENT'
|
|
51
|
+
| 'LOCAL_DEPENDENCY'
|
|
52
|
+
| 'NO_ANGULAR_CLI'
|
|
53
|
+
| 'NO_MATCHING_ROUTES'
|
|
54
|
+
| 'PARENT_NOT_FOUND'
|
|
55
|
+
| 'TARGET_EXISTS'
|
|
56
|
+
| 'VALIDATION_FAILED';
|
|
57
|
+
message: string;
|
|
58
|
+
filePath?: string;
|
|
59
|
+
routePath?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type RouteCommandPlan = {
|
|
63
|
+
action: 'add' | 'split';
|
|
64
|
+
summary: string;
|
|
65
|
+
files: string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type RouteCommandResult = {
|
|
69
|
+
changedFiles: string[];
|
|
70
|
+
diagnostics: RouteCommandDiagnostic[];
|
|
71
|
+
exitCode: number;
|
|
72
|
+
plan?: RouteCommandPlan;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type RouteFileSystem = {
|
|
76
|
+
exists(filePath: string): boolean;
|
|
77
|
+
listFiles(rootDir: string): string[];
|
|
78
|
+
read(filePath: string): string | undefined;
|
|
79
|
+
write(filePath: string, content: string): Promise<void> | void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type RouteAddOptions = {
|
|
83
|
+
rootDir?: string;
|
|
84
|
+
project?: string;
|
|
85
|
+
path: string;
|
|
86
|
+
parent?: string;
|
|
87
|
+
component?: string;
|
|
88
|
+
createComponent?: string;
|
|
89
|
+
featureFile?: string;
|
|
90
|
+
redirectTo?: string;
|
|
91
|
+
dryRun?: boolean;
|
|
92
|
+
yes?: boolean;
|
|
93
|
+
json?: boolean;
|
|
94
|
+
validate?: boolean;
|
|
95
|
+
fileSystem?: RouteFileSystem;
|
|
96
|
+
log?: (message: string) => void;
|
|
97
|
+
confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type RouteSplitOptions = {
|
|
101
|
+
rootDir?: string;
|
|
102
|
+
project?: string;
|
|
103
|
+
parent: string;
|
|
104
|
+
prefix: string;
|
|
105
|
+
target: string;
|
|
106
|
+
dryRun?: boolean;
|
|
107
|
+
yes?: boolean;
|
|
108
|
+
json?: boolean;
|
|
109
|
+
validate?: boolean;
|
|
110
|
+
fileSystem?: RouteFileSystem;
|
|
111
|
+
log?: (message: string) => void;
|
|
112
|
+
confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type RouteCollection = {
|
|
116
|
+
collectionName: string;
|
|
117
|
+
routesName: string;
|
|
118
|
+
sourceFile: SourceFile;
|
|
119
|
+
call: CallExpression;
|
|
120
|
+
routes: ArrayLiteralExpression;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type ComponentTarget = {
|
|
124
|
+
filePath: string;
|
|
125
|
+
className: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export async function runRouteAdd(
|
|
129
|
+
options: RouteAddOptions,
|
|
130
|
+
): Promise<RouteCommandResult> {
|
|
131
|
+
const diagnostics: RouteCommandDiagnostic[] = [];
|
|
132
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
133
|
+
const fileSystem = options.fileSystem ?? nodeRouteFileSystem;
|
|
134
|
+
const routePath = normalizeRoutePath(options.path);
|
|
135
|
+
if (!routePath || routePath === '/') {
|
|
136
|
+
return failed(
|
|
137
|
+
'INVALID_ARGUMENT',
|
|
138
|
+
'Route path must contain at least one segment.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const actionCount = [
|
|
142
|
+
options.component,
|
|
143
|
+
options.createComponent,
|
|
144
|
+
options.redirectTo,
|
|
145
|
+
].filter(Boolean).length;
|
|
146
|
+
if (actionCount !== 1) {
|
|
147
|
+
return failed(
|
|
148
|
+
'INVALID_ARGUMENT',
|
|
149
|
+
'Choose exactly one of --component, --create-component, or --redirect-to.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { project, tsConfigFilePath, sourceRoot } = createRouteProject(
|
|
154
|
+
rootDir,
|
|
155
|
+
options.project,
|
|
156
|
+
fileSystem,
|
|
157
|
+
);
|
|
158
|
+
const initialTexts = snapshotProject(project);
|
|
159
|
+
const collections = discoverRouteCollections(project, sourceRoot);
|
|
160
|
+
const parent = resolveParentCollection(
|
|
161
|
+
collections,
|
|
162
|
+
options.parent,
|
|
163
|
+
routePath,
|
|
164
|
+
rootDir,
|
|
165
|
+
);
|
|
166
|
+
if ('diagnostic' in parent) return resultFromDiagnostics([parent.diagnostic]);
|
|
167
|
+
|
|
168
|
+
const segments = routePath.slice(1).split('/');
|
|
169
|
+
const featureDecision = decideFeatureTarget(
|
|
170
|
+
project,
|
|
171
|
+
collections,
|
|
172
|
+
parent.collection,
|
|
173
|
+
options,
|
|
174
|
+
segments,
|
|
175
|
+
rootDir,
|
|
176
|
+
);
|
|
177
|
+
const targetFilePath = featureDecision.targetFilePath;
|
|
178
|
+
const touched = new Set<string>([targetFilePath]);
|
|
179
|
+
if (featureDecision.parentMount)
|
|
180
|
+
touched.add(parent.collection.sourceFile.getFilePath());
|
|
181
|
+
|
|
182
|
+
let componentTarget: ComponentTarget | undefined;
|
|
183
|
+
if (options.component) {
|
|
184
|
+
componentTarget = parseComponentTarget(options.component, rootDir);
|
|
185
|
+
if (!componentTarget || !fileSystem.exists(componentTarget.filePath)) {
|
|
186
|
+
return failed(
|
|
187
|
+
'COMPONENT_NOT_FOUND',
|
|
188
|
+
`Component target does not exist: ${options.component}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
touched.add(componentTarget.filePath);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const plan: RouteCommandPlan = {
|
|
195
|
+
action: 'add',
|
|
196
|
+
summary: options.redirectTo
|
|
197
|
+
? `Add redirect ${routePath} -> ${options.redirectTo}`
|
|
198
|
+
: `Add component route ${routePath}`,
|
|
199
|
+
files: [...touched],
|
|
200
|
+
};
|
|
201
|
+
printPlan(plan, options.log ?? console.log, options.json === true);
|
|
202
|
+
if (!(await shouldContinue(options, plan))) {
|
|
203
|
+
return { changedFiles: [], diagnostics, exitCode: 0, plan };
|
|
204
|
+
}
|
|
205
|
+
if (options.dryRun) {
|
|
206
|
+
return { changedFiles: [], diagnostics, exitCode: 0, plan };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const eslintBaseline =
|
|
210
|
+
options.validate === false
|
|
211
|
+
? new Set<string>()
|
|
212
|
+
: await captureRouteEslintBaseline(rootDir, sourceRoot, plan.files);
|
|
213
|
+
|
|
214
|
+
if (options.createComponent) {
|
|
215
|
+
const created = await createAngularComponent(
|
|
216
|
+
rootDir,
|
|
217
|
+
options,
|
|
218
|
+
resolveAngularProjectName(rootDir, options.project, sourceRoot),
|
|
219
|
+
);
|
|
220
|
+
if ('diagnostic' in created)
|
|
221
|
+
return resultFromDiagnostics([created.diagnostic], plan);
|
|
222
|
+
componentTarget = created.component;
|
|
223
|
+
touched.add(componentTarget.filePath);
|
|
224
|
+
addSourceFileFromWorkspace(project, componentTarget.filePath, fileSystem);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const targetCollection = ensureFeatureCollection(
|
|
228
|
+
project,
|
|
229
|
+
featureDecision,
|
|
230
|
+
parent.collection,
|
|
231
|
+
);
|
|
232
|
+
if (hasRoutePath(targetCollection.routes, featureDecision.localRoutePath)) {
|
|
233
|
+
if (
|
|
234
|
+
routeMatchesRequest(
|
|
235
|
+
targetCollection.routes,
|
|
236
|
+
featureDecision.localRoutePath,
|
|
237
|
+
options,
|
|
238
|
+
componentTarget,
|
|
239
|
+
targetCollection.sourceFile.getFilePath(),
|
|
240
|
+
)
|
|
241
|
+
) {
|
|
242
|
+
return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
|
|
243
|
+
}
|
|
244
|
+
return resultFromDiagnostics(
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
code: 'DUPLICATE_ROUTE',
|
|
248
|
+
message: `Route ${featureDecision.localRoutePath || '<root>'} already exists in ${targetCollection.routesName}.`,
|
|
249
|
+
filePath: targetCollection.sourceFile.getFilePath(),
|
|
250
|
+
routePath,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
plan,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (options.redirectTo) {
|
|
258
|
+
targetCollection.routes.addElement(
|
|
259
|
+
`{ path: ${quote(featureDecision.localRoutePath)}, redirectTo: ${quote(
|
|
260
|
+
options.redirectTo,
|
|
261
|
+
)}, pathMatch: 'full' }`,
|
|
262
|
+
);
|
|
263
|
+
} else if (componentTarget) {
|
|
264
|
+
const componentSource =
|
|
265
|
+
project.getSourceFile(componentTarget.filePath) ??
|
|
266
|
+
addSourceFileFromWorkspace(project, componentTarget.filePath, fileSystem);
|
|
267
|
+
if (
|
|
268
|
+
!componentSource ||
|
|
269
|
+
!ensureGenDeps(componentSource, componentTarget.className)
|
|
270
|
+
) {
|
|
271
|
+
return failed(
|
|
272
|
+
'COMPONENT_NOT_FOUND',
|
|
273
|
+
`Could not generate GenDeps_${componentTarget.className} in ${componentTarget.filePath}.`,
|
|
274
|
+
plan,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const moduleSpecifier = relativeModuleSpecifier(
|
|
278
|
+
targetCollection.sourceFile.getFilePath(),
|
|
279
|
+
componentTarget.filePath,
|
|
280
|
+
);
|
|
281
|
+
targetCollection.routes.addElement(
|
|
282
|
+
`craftRoute(${quote(featureDecision.localRoutePath)}, {
|
|
283
|
+
componentDeps: {} as import(${quote(moduleSpecifier)}).GenDeps_${componentTarget.className},
|
|
284
|
+
loadComponent: ({ withRetry }: CraftRouteLazyLoadHelpers) => withRetry(import(${quote(
|
|
285
|
+
moduleSpecifier,
|
|
286
|
+
)})).then((m) => m.${componentTarget.className}),
|
|
287
|
+
})`,
|
|
288
|
+
);
|
|
289
|
+
ensureImport(targetCollection.sourceFile, 'craftRoute');
|
|
290
|
+
ensureTypeImport(targetCollection.sourceFile, 'CraftRouteLazyLoadHelpers');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
ensureCollectionBookkeeping(targetCollection);
|
|
294
|
+
if (featureDecision.parentMount) {
|
|
295
|
+
ensureParentMount(
|
|
296
|
+
parent.collection,
|
|
297
|
+
featureDecision.parentMount,
|
|
298
|
+
targetCollection,
|
|
299
|
+
);
|
|
300
|
+
ensureCollectionBookkeeping(parent.collection);
|
|
301
|
+
}
|
|
302
|
+
for (const file of new Set([
|
|
303
|
+
targetCollection.sourceFile,
|
|
304
|
+
parent.collection.sourceFile,
|
|
305
|
+
])) {
|
|
306
|
+
file.formatText();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const changedFiles = [...touched].filter((filePath) =>
|
|
310
|
+
sourceChanged(project, initialTexts, filePath),
|
|
311
|
+
);
|
|
312
|
+
await saveFiles(project, changedFiles, fileSystem);
|
|
313
|
+
if (options.validate !== false) {
|
|
314
|
+
diagnostics.push(
|
|
315
|
+
...(await validateRouteChangedFiles(
|
|
316
|
+
rootDir,
|
|
317
|
+
sourceRoot,
|
|
318
|
+
tsConfigFilePath,
|
|
319
|
+
changedFiles,
|
|
320
|
+
eslintBaseline,
|
|
321
|
+
)),
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
changedFiles,
|
|
326
|
+
diagnostics,
|
|
327
|
+
exitCode: diagnostics.length > 0 ? 1 : 0,
|
|
328
|
+
plan,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function runRouteSplit(
|
|
333
|
+
options: RouteSplitOptions,
|
|
334
|
+
): Promise<RouteCommandResult> {
|
|
335
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
336
|
+
const fileSystem = options.fileSystem ?? nodeRouteFileSystem;
|
|
337
|
+
const { project, tsConfigFilePath, sourceRoot } = createRouteProject(
|
|
338
|
+
rootDir,
|
|
339
|
+
options.project,
|
|
340
|
+
fileSystem,
|
|
341
|
+
);
|
|
342
|
+
const initialTexts = snapshotProject(project);
|
|
343
|
+
const collections = discoverRouteCollections(project, sourceRoot);
|
|
344
|
+
const parent = resolveParentCollection(
|
|
345
|
+
collections,
|
|
346
|
+
options.parent,
|
|
347
|
+
`/${options.prefix}`,
|
|
348
|
+
rootDir,
|
|
349
|
+
);
|
|
350
|
+
if ('diagnostic' in parent) return resultFromDiagnostics([parent.diagnostic]);
|
|
351
|
+
|
|
352
|
+
const prefix = normalizeRoutePath(options.prefix).slice(1);
|
|
353
|
+
const targetFilePath = resolveFromRoot(options.target, rootDir);
|
|
354
|
+
if (fileSystem.exists(targetFilePath)) {
|
|
355
|
+
return failed(
|
|
356
|
+
'TARGET_EXISTS',
|
|
357
|
+
`Split target already exists: ${targetFilePath}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const matches = parent.collection.routes.getElements().filter((element) => {
|
|
361
|
+
const path = readRoutePath(element);
|
|
362
|
+
return path === prefix || path?.startsWith(`${prefix}/`);
|
|
363
|
+
});
|
|
364
|
+
if (matches.length === 0) {
|
|
365
|
+
return failed(
|
|
366
|
+
'NO_MATCHING_ROUTES',
|
|
367
|
+
`No statically analyzable route starts with ${prefix} in ${parent.collection.routesName}.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const localDependencies = findLocalDependencies(
|
|
371
|
+
parent.collection.sourceFile,
|
|
372
|
+
matches,
|
|
373
|
+
);
|
|
374
|
+
if (localDependencies.length > 0) {
|
|
375
|
+
return resultFromDiagnostics(
|
|
376
|
+
localDependencies.map((name) => ({
|
|
377
|
+
code: 'LOCAL_DEPENDENCY',
|
|
378
|
+
message: `Cannot move routes using local declaration ${name}; extract or import it first.`,
|
|
379
|
+
filePath: parent.collection.sourceFile.getFilePath(),
|
|
380
|
+
})),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const collectionName = collectionNameFromFile(targetFilePath);
|
|
385
|
+
const helperRenames = deriveMovedHelperRenames(
|
|
386
|
+
parent.collection,
|
|
387
|
+
matches,
|
|
388
|
+
prefix,
|
|
389
|
+
collectionName,
|
|
390
|
+
);
|
|
391
|
+
const helperConsumers = findHelperConsumers(
|
|
392
|
+
project,
|
|
393
|
+
parent.collection.sourceFile,
|
|
394
|
+
helperRenames,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const plan: RouteCommandPlan = {
|
|
398
|
+
action: 'split',
|
|
399
|
+
summary: `Move ${matches.length} route(s) under ${prefix} to ${targetFilePath}`,
|
|
400
|
+
files: [
|
|
401
|
+
parent.collection.sourceFile.getFilePath(),
|
|
402
|
+
targetFilePath,
|
|
403
|
+
...helperConsumers.map((sourceFile) => sourceFile.getFilePath()),
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
printPlan(plan, options.log ?? console.log, options.json === true);
|
|
407
|
+
if (!(await shouldContinue(options, plan))) {
|
|
408
|
+
return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
|
|
409
|
+
}
|
|
410
|
+
if (options.dryRun) {
|
|
411
|
+
return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const eslintBaseline =
|
|
415
|
+
options.validate === false
|
|
416
|
+
? new Set<string>()
|
|
417
|
+
: await captureRouteEslintBaseline(rootDir, sourceRoot, plan.files);
|
|
418
|
+
|
|
419
|
+
const routesName = `${uncapitalize(toPascalCase(collectionName))}Routes`;
|
|
420
|
+
const movedText = matches.map((element) =>
|
|
421
|
+
rewriteMovedRouteText(element.getText(), prefix),
|
|
422
|
+
);
|
|
423
|
+
const imports = parent.collection.sourceFile
|
|
424
|
+
.getImportDeclarations()
|
|
425
|
+
.map((declaration) => declaration.getText())
|
|
426
|
+
.join('\n');
|
|
427
|
+
const context = readCascadeContext(parent.collection);
|
|
428
|
+
const target = project.createSourceFile(
|
|
429
|
+
targetFilePath,
|
|
430
|
+
`${imports}\n\nexport const { ${routesName} } = craftRoutes(${quote(collectionName)}, [\n${movedText
|
|
431
|
+
.map((text) => ` ${text}`)
|
|
432
|
+
.join(',\n')}\n]).withParent<ParentRoutes<${quote(prefix)}>>();\n\n` +
|
|
433
|
+
`assertExhaustiveRouteExceptions(${routesName});\n\n` +
|
|
434
|
+
`type _Check${toPascalCase(collectionName)}DI = ValidateCascadeRoutesFile<${
|
|
435
|
+
context.names
|
|
436
|
+
}, ${context.values}, typeof ${routesName}>;\n` +
|
|
437
|
+
`type _CanRun${toPascalCase(collectionName)} = CanRun<_Check${toPascalCase(
|
|
438
|
+
collectionName,
|
|
439
|
+
)}DI>;\n`,
|
|
440
|
+
{ overwrite: false },
|
|
441
|
+
);
|
|
442
|
+
rewriteRelativeSpecifiers(target, parent.collection.sourceFile.getFilePath());
|
|
443
|
+
ensureImport(target, 'craftRoutes');
|
|
444
|
+
ensureImport(target, 'assertExhaustiveRouteExceptions');
|
|
445
|
+
ensureTypeImport(target, 'ParentRoutes');
|
|
446
|
+
ensureTypeImport(target, 'ValidateCascadeRoutesFile');
|
|
447
|
+
ensureTypeImport(target, 'CanRun');
|
|
448
|
+
ensureTypeImport(target, 'Router', '@angular/router');
|
|
449
|
+
rewireMovedHelpers(project, parent.collection, target, helperRenames);
|
|
450
|
+
|
|
451
|
+
const insertionIndex = Math.min(
|
|
452
|
+
...matches.map((element) =>
|
|
453
|
+
parent.collection.routes.getElements().indexOf(element),
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
const matchedIndexes = matches
|
|
457
|
+
.map((element) => parent.collection.routes.getElements().indexOf(element))
|
|
458
|
+
.sort((a, b) => b - a);
|
|
459
|
+
for (const index of matchedIndexes)
|
|
460
|
+
parent.collection.routes.removeElement(index);
|
|
461
|
+
const moduleSpecifier = relativeModuleSpecifier(
|
|
462
|
+
parent.collection.sourceFile.getFilePath(),
|
|
463
|
+
targetFilePath,
|
|
464
|
+
);
|
|
465
|
+
parent.collection.routes.insertElement(
|
|
466
|
+
insertionIndex,
|
|
467
|
+
`{ path: ${quote(prefix)}, loadChildren: ({ withRetry }) => withRetry(import(${quote(
|
|
468
|
+
moduleSpecifier,
|
|
469
|
+
)})).then((m) => m.${routesName}) }`,
|
|
470
|
+
);
|
|
471
|
+
ensureCollectionBookkeeping(parent.collection);
|
|
472
|
+
target.organizeImports();
|
|
473
|
+
target.formatText();
|
|
474
|
+
parent.collection.sourceFile.formatText();
|
|
475
|
+
|
|
476
|
+
const changedFiles = [...new Set(plan.files)].filter((filePath) =>
|
|
477
|
+
sourceChanged(project, initialTexts, filePath),
|
|
478
|
+
);
|
|
479
|
+
await saveFiles(project, changedFiles, fileSystem);
|
|
480
|
+
const diagnostics =
|
|
481
|
+
options.validate === false
|
|
482
|
+
? []
|
|
483
|
+
: await validateRouteChangedFiles(
|
|
484
|
+
rootDir,
|
|
485
|
+
sourceRoot,
|
|
486
|
+
tsConfigFilePath,
|
|
487
|
+
changedFiles,
|
|
488
|
+
eslintBaseline,
|
|
489
|
+
);
|
|
490
|
+
return {
|
|
491
|
+
changedFiles,
|
|
492
|
+
diagnostics,
|
|
493
|
+
exitCode: diagnostics.length > 0 ? 1 : 0,
|
|
494
|
+
plan,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function discoverRouteCollections(
|
|
499
|
+
project: Project,
|
|
500
|
+
rootDir = process.cwd(),
|
|
501
|
+
): RouteCollection[] {
|
|
502
|
+
const result: RouteCollection[] = [];
|
|
503
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
504
|
+
if (
|
|
505
|
+
!isInside(sourceFile.getFilePath(), rootDir) ||
|
|
506
|
+
sourceFile.isDeclarationFile() ||
|
|
507
|
+
/\.(?:spec|test)\.ts$/.test(sourceFile.getFilePath())
|
|
508
|
+
)
|
|
509
|
+
continue;
|
|
510
|
+
for (const call of sourceFile.getDescendantsOfKind(
|
|
511
|
+
SyntaxKind.CallExpression,
|
|
512
|
+
)) {
|
|
513
|
+
if (call.getExpression().getText() !== 'craftRoutes') continue;
|
|
514
|
+
const routes = call.getArguments()[1];
|
|
515
|
+
const nameArg = call.getArguments()[0];
|
|
516
|
+
if (
|
|
517
|
+
!Node.isArrayLiteralExpression(routes) ||
|
|
518
|
+
!Node.isStringLiteral(nameArg)
|
|
519
|
+
)
|
|
520
|
+
continue;
|
|
521
|
+
const declaration = call.getFirstAncestorByKind(
|
|
522
|
+
SyntaxKind.VariableDeclaration,
|
|
523
|
+
);
|
|
524
|
+
if (declaration?.getVariableStatement()?.getParent() !== sourceFile)
|
|
525
|
+
continue;
|
|
526
|
+
const binding = declaration?.getNameNode();
|
|
527
|
+
if (!binding || !Node.isObjectBindingPattern(binding)) continue;
|
|
528
|
+
const expected = `${uncapitalize(toPascalCase(nameArg.getLiteralValue()))}Routes`;
|
|
529
|
+
const routeBinding =
|
|
530
|
+
binding
|
|
531
|
+
.getElements()
|
|
532
|
+
.find(
|
|
533
|
+
(element) =>
|
|
534
|
+
(element.getPropertyNameNode()?.getText() ??
|
|
535
|
+
element.getName()) === expected,
|
|
536
|
+
) ??
|
|
537
|
+
binding
|
|
538
|
+
.getElements()
|
|
539
|
+
.filter((element) =>
|
|
540
|
+
(
|
|
541
|
+
element.getPropertyNameNode()?.getText() ?? element.getName()
|
|
542
|
+
).endsWith('Routes'),
|
|
543
|
+
)[0];
|
|
544
|
+
if (!routeBinding) continue;
|
|
545
|
+
result.push({
|
|
546
|
+
collectionName: nameArg.getLiteralValue(),
|
|
547
|
+
routesName: routeBinding.getName(),
|
|
548
|
+
sourceFile,
|
|
549
|
+
call,
|
|
550
|
+
routes,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function listRouteCollections(
|
|
558
|
+
rootDir = process.cwd(),
|
|
559
|
+
projectOption?: string,
|
|
560
|
+
fileSystem: RouteFileSystem = nodeRouteFileSystem,
|
|
561
|
+
): Array<{ filePath: string; collectionName: string; routesName: string }> {
|
|
562
|
+
const absoluteRoot = resolve(rootDir);
|
|
563
|
+
const { project, sourceRoot } = createRouteProject(
|
|
564
|
+
absoluteRoot,
|
|
565
|
+
projectOption,
|
|
566
|
+
fileSystem,
|
|
567
|
+
);
|
|
568
|
+
return discoverRouteCollections(project, sourceRoot).map((collection) => ({
|
|
569
|
+
filePath: collection.sourceFile.getFilePath(),
|
|
570
|
+
collectionName: collection.collectionName,
|
|
571
|
+
routesName: collection.routesName,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function listAngularProjects(rootDir = process.cwd()): string[] {
|
|
576
|
+
const absoluteRoot = resolve(rootDir);
|
|
577
|
+
return findTsConfigApps(absoluteRoot, nodeRouteFileSystem).map((filePath) =>
|
|
578
|
+
relative(absoluteRoot, dirname(filePath)).replace(/\\/g, '/'),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function createRouteProject(
|
|
583
|
+
rootDir: string,
|
|
584
|
+
projectOption: string | undefined,
|
|
585
|
+
fileSystem: RouteFileSystem,
|
|
586
|
+
) {
|
|
587
|
+
const tsConfigFilePath = resolveTsConfig(rootDir, projectOption, fileSystem);
|
|
588
|
+
const sourceRoot =
|
|
589
|
+
basename(tsConfigFilePath) === 'tsconfig.app.json'
|
|
590
|
+
? dirname(tsConfigFilePath)
|
|
591
|
+
: rootDir;
|
|
592
|
+
const project =
|
|
593
|
+
fileSystem === nodeRouteFileSystem && fileSystem.exists(tsConfigFilePath)
|
|
594
|
+
? new Project({ tsConfigFilePath })
|
|
595
|
+
: new Project({
|
|
596
|
+
compilerOptions: {
|
|
597
|
+
module: ts.ModuleKind.Preserve,
|
|
598
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
599
|
+
target: ts.ScriptTarget.ES2022,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
if (fileSystem === nodeRouteFileSystem) {
|
|
603
|
+
project.addSourceFilesAtPaths([
|
|
604
|
+
join(sourceRoot, '**/*.ts'),
|
|
605
|
+
`!${join(sourceRoot, '**/node_modules/**')}`,
|
|
606
|
+
`!${join(sourceRoot, '**/dist/**')}`,
|
|
607
|
+
`!${join(sourceRoot, '**/.angular/**')}`,
|
|
608
|
+
]);
|
|
609
|
+
} else {
|
|
610
|
+
for (const filePath of fileSystem
|
|
611
|
+
.listFiles(sourceRoot)
|
|
612
|
+
.filter((filePath) => filePath.endsWith('.ts'))) {
|
|
613
|
+
const text = fileSystem.read(filePath);
|
|
614
|
+
if (text !== undefined) {
|
|
615
|
+
project.createSourceFile(filePath, text, { overwrite: true });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
project.manipulationSettings.set({ quoteKind: QuoteKind.Single });
|
|
620
|
+
return { project, tsConfigFilePath, sourceRoot };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function resolveTsConfig(
|
|
624
|
+
rootDir: string,
|
|
625
|
+
projectOption: string | undefined,
|
|
626
|
+
fileSystem: RouteFileSystem,
|
|
627
|
+
): string {
|
|
628
|
+
if (projectOption) {
|
|
629
|
+
const direct = resolve(rootDir, projectOption);
|
|
630
|
+
if (fileSystem.exists(direct) && direct.endsWith('.json')) return direct;
|
|
631
|
+
for (const candidate of [
|
|
632
|
+
join(direct, 'tsconfig.app.json'),
|
|
633
|
+
join(rootDir, 'apps', projectOption, 'tsconfig.app.json'),
|
|
634
|
+
join(rootDir, 'projects', projectOption, 'tsconfig.app.json'),
|
|
635
|
+
]) {
|
|
636
|
+
if (fileSystem.exists(candidate)) return candidate;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const detected = findTsConfigApps(rootDir, fileSystem);
|
|
640
|
+
if (detected.length === 1) return detected[0];
|
|
641
|
+
for (const candidate of [
|
|
642
|
+
join(rootDir, 'tsconfig.app.json'),
|
|
643
|
+
join(rootDir, 'tsconfig.json'),
|
|
644
|
+
]) {
|
|
645
|
+
if (fileSystem.exists(candidate)) return candidate;
|
|
646
|
+
}
|
|
647
|
+
return join(rootDir, 'tsconfig.json');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function findTsConfigApps(
|
|
651
|
+
rootDir: string,
|
|
652
|
+
fileSystem: RouteFileSystem,
|
|
653
|
+
): string[] {
|
|
654
|
+
return fileSystem
|
|
655
|
+
.listFiles(rootDir)
|
|
656
|
+
.filter((filePath) => basename(filePath) === 'tsconfig.app.json')
|
|
657
|
+
.sort();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function resolveParentCollection(
|
|
661
|
+
collections: RouteCollection[],
|
|
662
|
+
parentOption: string | undefined,
|
|
663
|
+
routePath: string,
|
|
664
|
+
rootDir: string,
|
|
665
|
+
): { collection: RouteCollection } | { diagnostic: RouteCommandDiagnostic } {
|
|
666
|
+
if (parentOption) {
|
|
667
|
+
const [filePart, collectionPart] = parentOption.split('#');
|
|
668
|
+
const filePath = resolveFromRoot(filePart, rootDir);
|
|
669
|
+
const matches = collections.filter(
|
|
670
|
+
(collection) =>
|
|
671
|
+
resolve(collection.sourceFile.getFilePath()) === resolve(filePath) &&
|
|
672
|
+
(!collectionPart ||
|
|
673
|
+
collection.routesName === collectionPart ||
|
|
674
|
+
collection.collectionName === collectionPart),
|
|
675
|
+
);
|
|
676
|
+
if (matches.length === 1) return { collection: matches[0] };
|
|
677
|
+
return {
|
|
678
|
+
diagnostic: {
|
|
679
|
+
code: 'PARENT_NOT_FOUND',
|
|
680
|
+
message: `Cannot resolve parent collection ${parentOption}.`,
|
|
681
|
+
filePath,
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const first = routePath.slice(1).split('/')[0];
|
|
687
|
+
const featureMatches = collections.filter((collection) =>
|
|
688
|
+
[
|
|
689
|
+
collection.collectionName,
|
|
690
|
+
basename(collection.sourceFile.getFilePath(), '.routes.ts'),
|
|
691
|
+
]
|
|
692
|
+
.map((value) => value.toLowerCase())
|
|
693
|
+
.includes(first.toLowerCase()),
|
|
694
|
+
);
|
|
695
|
+
if (featureMatches.length === 1) return { collection: featureMatches[0] };
|
|
696
|
+
const roots = collections.filter((collection) =>
|
|
697
|
+
/(?:^|[/\\])app\.routes\.ts$/.test(collection.sourceFile.getFilePath()),
|
|
698
|
+
);
|
|
699
|
+
if (roots.length === 1) return { collection: roots[0] };
|
|
700
|
+
if (collections.length === 1) return { collection: collections[0] };
|
|
701
|
+
return {
|
|
702
|
+
diagnostic: {
|
|
703
|
+
code: 'AMBIGUOUS_PARENT',
|
|
704
|
+
message: `Found ${collections.length} craftRoutes collections; pass --parent <file#collection>.`,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function decideFeatureTarget(
|
|
710
|
+
project: Project,
|
|
711
|
+
collections: RouteCollection[],
|
|
712
|
+
parent: RouteCollection,
|
|
713
|
+
options: RouteAddOptions,
|
|
714
|
+
segments: string[],
|
|
715
|
+
rootDir: string,
|
|
716
|
+
) {
|
|
717
|
+
if (options.featureFile) {
|
|
718
|
+
return {
|
|
719
|
+
targetFilePath: resolveFromRoot(options.featureFile, rootDir),
|
|
720
|
+
localRoutePath: segments.slice(1).join('/'),
|
|
721
|
+
parentMount: segments[0],
|
|
722
|
+
collectionName: collectionNameFromFile(options.featureFile),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (options.redirectTo) {
|
|
726
|
+
const parentIsFeature =
|
|
727
|
+
parent.collectionName.toLowerCase() === segments[0].toLowerCase();
|
|
728
|
+
return {
|
|
729
|
+
targetFilePath: parent.sourceFile.getFilePath(),
|
|
730
|
+
localRoutePath: parentIsFeature
|
|
731
|
+
? segments.slice(1).join('/')
|
|
732
|
+
: segments.join('/'),
|
|
733
|
+
collectionName: parent.collectionName,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (options.parent) {
|
|
737
|
+
return {
|
|
738
|
+
targetFilePath: parent.sourceFile.getFilePath(),
|
|
739
|
+
localRoutePath: segments.join('/'),
|
|
740
|
+
collectionName: parent.collectionName,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
if (parent.collectionName.toLowerCase() === segments[0].toLowerCase()) {
|
|
744
|
+
return {
|
|
745
|
+
targetFilePath: parent.sourceFile.getFilePath(),
|
|
746
|
+
localRoutePath: segments.slice(1).join('/'),
|
|
747
|
+
collectionName: parent.collectionName,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const existingFeature = collections.find(
|
|
751
|
+
(collection) =>
|
|
752
|
+
collection !== parent &&
|
|
753
|
+
collection.collectionName.toLowerCase() === segments[0].toLowerCase(),
|
|
754
|
+
);
|
|
755
|
+
if (existingFeature) {
|
|
756
|
+
return {
|
|
757
|
+
targetFilePath: existingFeature.sourceFile.getFilePath(),
|
|
758
|
+
localRoutePath: segments.slice(1).join('/'),
|
|
759
|
+
collectionName: existingFeature.collectionName,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const targetFilePath = join(
|
|
763
|
+
dirname(parent.sourceFile.getFilePath()),
|
|
764
|
+
segments[0],
|
|
765
|
+
`${segments[0]}.routes.ts`,
|
|
766
|
+
);
|
|
767
|
+
return {
|
|
768
|
+
targetFilePath,
|
|
769
|
+
localRoutePath: segments.slice(1).join('/'),
|
|
770
|
+
parentMount: segments[0],
|
|
771
|
+
collectionName: segments[0],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function ensureFeatureCollection(
|
|
776
|
+
project: Project,
|
|
777
|
+
decision: ReturnType<typeof decideFeatureTarget>,
|
|
778
|
+
parent: RouteCollection,
|
|
779
|
+
): RouteCollection {
|
|
780
|
+
const existing = discoverRouteCollections(
|
|
781
|
+
project,
|
|
782
|
+
dirname(decision.targetFilePath),
|
|
783
|
+
).find(
|
|
784
|
+
(collection) =>
|
|
785
|
+
resolve(collection.sourceFile.getFilePath()) ===
|
|
786
|
+
resolve(decision.targetFilePath),
|
|
787
|
+
);
|
|
788
|
+
if (existing) return existing;
|
|
789
|
+
const routesName = `${uncapitalize(toPascalCase(decision.collectionName))}Routes`;
|
|
790
|
+
const context = readCascadeContext(parent);
|
|
791
|
+
const parentSuffix = decision.parentMount
|
|
792
|
+
? `.withParent<ParentRoutes<${quote(decision.parentMount)}>>()`
|
|
793
|
+
: '';
|
|
794
|
+
const sourceFile = project.createSourceFile(
|
|
795
|
+
decision.targetFilePath,
|
|
796
|
+
`import { assertExhaustiveRouteExceptions, craftRoutes, type CanRun, type ParentRoutes, type ValidateCascadeRoutesFile } from '@craft-ng/core';\n` +
|
|
797
|
+
`import type { Router } from '@angular/router';\n\n` +
|
|
798
|
+
`export const { ${routesName} } = craftRoutes(${quote(
|
|
799
|
+
decision.collectionName,
|
|
800
|
+
)}, [])${parentSuffix};\n\n` +
|
|
801
|
+
`assertExhaustiveRouteExceptions(${routesName});\n\n` +
|
|
802
|
+
`type _Check${toPascalCase(
|
|
803
|
+
decision.collectionName,
|
|
804
|
+
)}DI = ValidateCascadeRoutesFile<${context.names}, ${context.values}, typeof ${routesName}>;\n` +
|
|
805
|
+
`type _CanRun${toPascalCase(decision.collectionName)} = CanRun<_Check${toPascalCase(
|
|
806
|
+
decision.collectionName,
|
|
807
|
+
)}DI>;\n`,
|
|
808
|
+
{ overwrite: false },
|
|
809
|
+
);
|
|
810
|
+
const created = discoverRouteCollections(
|
|
811
|
+
project,
|
|
812
|
+
dirname(decision.targetFilePath),
|
|
813
|
+
).find((collection) => collection.sourceFile === sourceFile);
|
|
814
|
+
if (!created)
|
|
815
|
+
throw new Error(`Could not create route collection ${routesName}.`);
|
|
816
|
+
return created;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function ensureParentMount(
|
|
820
|
+
parent: RouteCollection,
|
|
821
|
+
mountPath: string,
|
|
822
|
+
child: RouteCollection,
|
|
823
|
+
): void {
|
|
824
|
+
const alreadyMounted = parent.routes
|
|
825
|
+
.getElements()
|
|
826
|
+
.some(
|
|
827
|
+
(element) =>
|
|
828
|
+
readRoutePath(element) === mountPath &&
|
|
829
|
+
element.getText().includes('loadChildren'),
|
|
830
|
+
);
|
|
831
|
+
if (!alreadyMounted) {
|
|
832
|
+
const moduleSpecifier = relativeModuleSpecifier(
|
|
833
|
+
parent.sourceFile.getFilePath(),
|
|
834
|
+
child.sourceFile.getFilePath(),
|
|
835
|
+
);
|
|
836
|
+
parent.routes.addElement(
|
|
837
|
+
`{ path: ${quote(mountPath)}, loadChildren: ({ withRetry }) => withRetry(import(${quote(
|
|
838
|
+
moduleSpecifier,
|
|
839
|
+
)})).then((m) => m.${child.routesName}) }`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
ensureImport(parent.sourceFile, 'assertChildRouteMounts');
|
|
843
|
+
if (
|
|
844
|
+
!hasCall(parent.sourceFile, 'assertChildRouteMounts', parent.routesName)
|
|
845
|
+
) {
|
|
846
|
+
parent.sourceFile.addStatements(
|
|
847
|
+
`assertChildRouteMounts(${parent.routesName});`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function ensureCollectionBookkeeping(collection: RouteCollection): void {
|
|
853
|
+
ensureImport(collection.sourceFile, 'assertExhaustiveRouteExceptions');
|
|
854
|
+
if (
|
|
855
|
+
!hasCall(
|
|
856
|
+
collection.sourceFile,
|
|
857
|
+
'assertExhaustiveRouteExceptions',
|
|
858
|
+
collection.routesName,
|
|
859
|
+
)
|
|
860
|
+
) {
|
|
861
|
+
collection.sourceFile.addStatements(
|
|
862
|
+
`assertExhaustiveRouteExceptions(${collection.routesName});`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (!hasCascadeCheck(collection)) {
|
|
866
|
+
ensureTypeImport(collection.sourceFile, 'ValidateCascadeRoutesFile');
|
|
867
|
+
ensureTypeImport(collection.sourceFile, 'CanRun');
|
|
868
|
+
ensureTypeImport(collection.sourceFile, 'Router', '@angular/router');
|
|
869
|
+
const suffix = toPascalCase(collection.collectionName);
|
|
870
|
+
collection.sourceFile.addStatements(
|
|
871
|
+
`type _Check${suffix}DI = ValidateCascadeRoutesFile<never, Router, typeof ${collection.routesName}>;\ntype _CanRun${suffix} = CanRun<_Check${suffix}DI>;`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function hasCascadeCheck(collection: RouteCollection): boolean {
|
|
877
|
+
return collection.sourceFile.getTypeAliases().some((alias) => {
|
|
878
|
+
const type = alias.getTypeNode();
|
|
879
|
+
return (
|
|
880
|
+
Node.isTypeReference(type) &&
|
|
881
|
+
type.getTypeName().getText() === 'ValidateCascadeRoutesFile' &&
|
|
882
|
+
type.getTypeArguments()[2]?.getText() ===
|
|
883
|
+
`typeof ${collection.routesName}`
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function readCascadeContext(collection: RouteCollection): {
|
|
889
|
+
names: string;
|
|
890
|
+
values: string;
|
|
891
|
+
} {
|
|
892
|
+
for (const alias of collection.sourceFile.getTypeAliases()) {
|
|
893
|
+
const type = alias.getTypeNode();
|
|
894
|
+
if (!Node.isTypeReference(type)) continue;
|
|
895
|
+
if (type.getTypeName().getText() !== 'ValidateCascadeRoutesFile') continue;
|
|
896
|
+
const args = type.getTypeArguments();
|
|
897
|
+
if (args[2]?.getText() !== `typeof ${collection.routesName}`) continue;
|
|
898
|
+
return {
|
|
899
|
+
names: args[0]?.getText() ?? 'never',
|
|
900
|
+
values: args[1]?.getText() ?? 'Router',
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
return { names: 'never', values: 'Router' };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function parseComponentTarget(
|
|
907
|
+
value: string,
|
|
908
|
+
rootDir: string,
|
|
909
|
+
): ComponentTarget | undefined {
|
|
910
|
+
const separator = value.lastIndexOf('#');
|
|
911
|
+
if (separator <= 0 || separator === value.length - 1) return undefined;
|
|
912
|
+
return {
|
|
913
|
+
filePath: resolveFromRoot(value.slice(0, separator), rootDir),
|
|
914
|
+
className: value.slice(separator + 1),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function createAngularComponent(
|
|
919
|
+
rootDir: string,
|
|
920
|
+
options: RouteAddOptions,
|
|
921
|
+
projectName: string | undefined,
|
|
922
|
+
): Promise<
|
|
923
|
+
{ component: ComponentTarget } | { diagnostic: RouteCommandDiagnostic }
|
|
924
|
+
> {
|
|
925
|
+
const angularBinary = findLocalBinary(rootDir, 'ng');
|
|
926
|
+
const nxBinary = findLocalBinary(rootDir, 'nx');
|
|
927
|
+
const angularWorkspace = existsSync(join(rootDir, 'angular.json'));
|
|
928
|
+
const nxWorkspace = existsSync(join(rootDir, 'nx.json'));
|
|
929
|
+
const runner =
|
|
930
|
+
angularWorkspace && angularBinary
|
|
931
|
+
? {
|
|
932
|
+
binary: angularBinary,
|
|
933
|
+
args: (name: string) => [
|
|
934
|
+
'generate',
|
|
935
|
+
'component',
|
|
936
|
+
name,
|
|
937
|
+
'--inline-template',
|
|
938
|
+
'--inline-style',
|
|
939
|
+
'--skip-tests',
|
|
940
|
+
...(projectName ? ['--project', projectName] : []),
|
|
941
|
+
],
|
|
942
|
+
}
|
|
943
|
+
: nxWorkspace && nxBinary
|
|
944
|
+
? {
|
|
945
|
+
binary: nxBinary,
|
|
946
|
+
args: (name: string) => [
|
|
947
|
+
'generate',
|
|
948
|
+
'@schematics/angular:component',
|
|
949
|
+
name,
|
|
950
|
+
'--inline-template',
|
|
951
|
+
'--inline-style',
|
|
952
|
+
...(projectName ? ['--project', projectName] : []),
|
|
953
|
+
'--skip-tests',
|
|
954
|
+
],
|
|
955
|
+
}
|
|
956
|
+
: undefined;
|
|
957
|
+
if (!runner) {
|
|
958
|
+
return {
|
|
959
|
+
diagnostic: {
|
|
960
|
+
code: 'NO_ANGULAR_CLI',
|
|
961
|
+
message:
|
|
962
|
+
'No compatible local Angular or Nx CLI workspace was found. Expected angular.json + node_modules/.bin/ng, or nx.json + node_modules/.bin/nx.',
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const name = options.createComponent;
|
|
967
|
+
if (!name)
|
|
968
|
+
throw new Error('createAngularComponent requires createComponent.');
|
|
969
|
+
const expectedFileName = `${names(basename(name)).fileName}.ts`;
|
|
970
|
+
const filesBefore = new Set(await findFiles(rootDir, expectedFileName));
|
|
971
|
+
try {
|
|
972
|
+
await execFileAsync(runner.binary, runner.args(name), { cwd: rootDir });
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return {
|
|
975
|
+
diagnostic: {
|
|
976
|
+
code: 'COMPONENT_GENERATION_FAILED',
|
|
977
|
+
message: `Component generation failed through ${basename(runner.binary)}: ${commandError(error)}`,
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const candidates = (await findFiles(rootDir, expectedFileName)).sort(
|
|
982
|
+
(left, right) =>
|
|
983
|
+
Number(filesBefore.has(left)) - Number(filesBefore.has(right)),
|
|
984
|
+
);
|
|
985
|
+
const filePath = candidates
|
|
986
|
+
.filter((candidate) => !candidate.endsWith('.spec.ts'))
|
|
987
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
988
|
+
if (!filePath) {
|
|
989
|
+
return {
|
|
990
|
+
diagnostic: {
|
|
991
|
+
code: 'COMPONENT_NOT_FOUND',
|
|
992
|
+
message: `Angular CLI completed but ${basename(name)}.ts could not be located.`,
|
|
993
|
+
},
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
const source = new Project().addSourceFileAtPath(filePath);
|
|
997
|
+
const decorated = findAngularDecoratedClass(source);
|
|
998
|
+
if (decorated.skipped || !decorated.className) {
|
|
999
|
+
return {
|
|
1000
|
+
diagnostic: {
|
|
1001
|
+
code: 'COMPONENT_NOT_FOUND',
|
|
1002
|
+
message: `Generated component class could not be inferred in ${filePath}.`,
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return { component: { filePath, className: decorated.className } };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function resolveAngularProjectName(
|
|
1010
|
+
rootDir: string,
|
|
1011
|
+
projectOption: string | undefined,
|
|
1012
|
+
sourceRoot: string,
|
|
1013
|
+
): string | undefined {
|
|
1014
|
+
const projectFile = join(sourceRoot, 'project.json');
|
|
1015
|
+
if (existsSync(projectFile)) {
|
|
1016
|
+
try {
|
|
1017
|
+
const parsed = JSON.parse(readFileSync(projectFile, 'utf8')) as {
|
|
1018
|
+
name?: unknown;
|
|
1019
|
+
};
|
|
1020
|
+
if (typeof parsed.name === 'string' && parsed.name) return parsed.name;
|
|
1021
|
+
} catch {
|
|
1022
|
+
// Fall through to the explicit option/basename heuristic.
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (
|
|
1026
|
+
projectOption &&
|
|
1027
|
+
!projectOption.endsWith('.json') &&
|
|
1028
|
+
!projectOption.includes('/') &&
|
|
1029
|
+
!projectOption.includes('\\')
|
|
1030
|
+
) {
|
|
1031
|
+
return projectOption;
|
|
1032
|
+
}
|
|
1033
|
+
const relativeSourceRoot = relative(rootDir, sourceRoot);
|
|
1034
|
+
return relativeSourceRoot && !relativeSourceRoot.startsWith('..')
|
|
1035
|
+
? basename(sourceRoot)
|
|
1036
|
+
: undefined;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function ensureGenDeps(sourceFile: SourceFile, className: string): boolean {
|
|
1040
|
+
generateAngularDependencies(sourceFile);
|
|
1041
|
+
return Boolean(sourceFile.getTypeAlias(`GenDeps_${className}`));
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function hasRoutePath(routes: ArrayLiteralExpression, path: string): boolean {
|
|
1045
|
+
return routes
|
|
1046
|
+
.getElements()
|
|
1047
|
+
.some((element) => readRoutePath(element) === path);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function routeMatchesRequest(
|
|
1051
|
+
routes: ArrayLiteralExpression,
|
|
1052
|
+
path: string,
|
|
1053
|
+
options: RouteAddOptions,
|
|
1054
|
+
component: ComponentTarget | undefined,
|
|
1055
|
+
routesFilePath: string,
|
|
1056
|
+
): boolean {
|
|
1057
|
+
const element = routes
|
|
1058
|
+
.getElements()
|
|
1059
|
+
.find((candidate) => readRoutePath(candidate) === path);
|
|
1060
|
+
if (!element) return false;
|
|
1061
|
+
const text = element.getText();
|
|
1062
|
+
if (options.redirectTo) {
|
|
1063
|
+
return (
|
|
1064
|
+
text.includes(`redirectTo: ${quote(options.redirectTo)}`) ||
|
|
1065
|
+
text.includes(`redirectTo: "${options.redirectTo}"`)
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
if (!component) return false;
|
|
1069
|
+
const moduleSpecifier = relativeModuleSpecifier(
|
|
1070
|
+
routesFilePath,
|
|
1071
|
+
component.filePath,
|
|
1072
|
+
);
|
|
1073
|
+
return (
|
|
1074
|
+
text.includes(`GenDeps_${component.className}`) &&
|
|
1075
|
+
text.includes(moduleSpecifier)
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function readRoutePath(node: Node): string | undefined {
|
|
1080
|
+
if (
|
|
1081
|
+
Node.isCallExpression(node) &&
|
|
1082
|
+
node.getExpression().getText() === 'craftRoute'
|
|
1083
|
+
) {
|
|
1084
|
+
const arg = node.getArguments()[0];
|
|
1085
|
+
return Node.isStringLiteral(arg) ? arg.getLiteralValue() : undefined;
|
|
1086
|
+
}
|
|
1087
|
+
if (!Node.isObjectLiteralExpression(node)) return undefined;
|
|
1088
|
+
const property = node.getProperty('path');
|
|
1089
|
+
if (!Node.isPropertyAssignment(property)) return undefined;
|
|
1090
|
+
const initializer = property.getInitializer();
|
|
1091
|
+
return Node.isStringLiteral(initializer)
|
|
1092
|
+
? initializer.getLiteralValue()
|
|
1093
|
+
: undefined;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function rewriteMovedRouteText(text: string, prefix: string): string {
|
|
1097
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1098
|
+
return text
|
|
1099
|
+
.replace(new RegExp(`(craftRoute\\(\\s*['"])${escaped}(?:/)?`), '$1')
|
|
1100
|
+
.replace(new RegExp(`(path\\s*:\\s*['"])${escaped}(?:/)?`), '$1');
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function deriveMovedHelperRenames(
|
|
1104
|
+
parent: RouteCollection,
|
|
1105
|
+
nodes: Node[],
|
|
1106
|
+
prefix: string,
|
|
1107
|
+
childCollectionName: string,
|
|
1108
|
+
): Map<string, string> {
|
|
1109
|
+
const parentName = toPascalCase(parent.collectionName);
|
|
1110
|
+
const childName = toPascalCase(childCollectionName);
|
|
1111
|
+
const candidates = new Map<string, string>();
|
|
1112
|
+
for (const node of nodes) {
|
|
1113
|
+
const originalPath = readRoutePath(node);
|
|
1114
|
+
if (originalPath === undefined) continue;
|
|
1115
|
+
const childPath =
|
|
1116
|
+
originalPath === prefix ? '' : originalPath.slice(prefix.length + 1);
|
|
1117
|
+
for (const segment of originalPath.split('/')) {
|
|
1118
|
+
if (!segment.startsWith(':')) continue;
|
|
1119
|
+
const param = segment.slice(1).replace(/\?$/, '');
|
|
1120
|
+
candidates.set(
|
|
1121
|
+
`inject${parentName}${toPascalCase(param)}Params`,
|
|
1122
|
+
`inject${childName}${toPascalCase(param)}Params`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
const definition = getRouteDefinition(node);
|
|
1126
|
+
if (!definition) continue;
|
|
1127
|
+
const parentBase = routeBaseServiceName(originalPath);
|
|
1128
|
+
const childBase = routeBaseServiceName(childPath);
|
|
1129
|
+
for (const [property, suffix] of [
|
|
1130
|
+
['data', 'Data'],
|
|
1131
|
+
['queryParams', 'QueryParams'],
|
|
1132
|
+
['resolve', 'ResolvedData'],
|
|
1133
|
+
['withLoaderViewTransitionImage', 'ViewTransition'],
|
|
1134
|
+
] as const) {
|
|
1135
|
+
if (!definition.getProperty(property)) continue;
|
|
1136
|
+
candidates.set(
|
|
1137
|
+
`inject${parentName}${parentBase}${suffix}`,
|
|
1138
|
+
`inject${childName}${childBase}${suffix}`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (
|
|
1142
|
+
definition.getProperty('canActivate') ||
|
|
1143
|
+
definition.getProperty('canMatch')
|
|
1144
|
+
) {
|
|
1145
|
+
candidates.set(
|
|
1146
|
+
`inject${parentName}${parentBase}GuardedData`,
|
|
1147
|
+
`inject${childName}${childBase}GuardedData`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const declaration = parent.call.getFirstAncestorByKind(
|
|
1153
|
+
SyntaxKind.VariableDeclaration,
|
|
1154
|
+
);
|
|
1155
|
+
const binding = declaration?.getNameNode();
|
|
1156
|
+
if (!binding || !Node.isObjectBindingPattern(binding)) return new Map();
|
|
1157
|
+
const exportedBindings = new Set(
|
|
1158
|
+
binding
|
|
1159
|
+
.getElements()
|
|
1160
|
+
.map(
|
|
1161
|
+
(element) =>
|
|
1162
|
+
element.getPropertyNameNode()?.getText() ?? element.getName(),
|
|
1163
|
+
),
|
|
1164
|
+
);
|
|
1165
|
+
return new Map(
|
|
1166
|
+
[...candidates].filter(([oldName]) => exportedBindings.has(oldName)),
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function getRouteDefinition(node: Node) {
|
|
1171
|
+
if (Node.isObjectLiteralExpression(node)) return node;
|
|
1172
|
+
if (
|
|
1173
|
+
Node.isCallExpression(node) &&
|
|
1174
|
+
node.getExpression().getText() === 'craftRoute'
|
|
1175
|
+
) {
|
|
1176
|
+
const definition = node.getArguments()[1];
|
|
1177
|
+
return Node.isObjectLiteralExpression(definition) ? definition : undefined;
|
|
1178
|
+
}
|
|
1179
|
+
return undefined;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function routeBaseServiceName(path: string): string {
|
|
1183
|
+
if (!path) return 'Root';
|
|
1184
|
+
return path
|
|
1185
|
+
.split('/')
|
|
1186
|
+
.map((segment) => {
|
|
1187
|
+
if (segment === '**') return 'Wildcard';
|
|
1188
|
+
return toPascalCase(segment.replace(/^:/, '').replace(/\?$/, ''));
|
|
1189
|
+
})
|
|
1190
|
+
.join('');
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function findHelperConsumers(
|
|
1194
|
+
project: Project,
|
|
1195
|
+
parentFile: SourceFile,
|
|
1196
|
+
renames: Map<string, string>,
|
|
1197
|
+
): SourceFile[] {
|
|
1198
|
+
if (renames.size === 0) return [];
|
|
1199
|
+
return project.getSourceFiles().filter((sourceFile) =>
|
|
1200
|
+
sourceFile.getImportDeclarations().some((declaration) => {
|
|
1201
|
+
if (declaration.getModuleSpecifierSourceFile() !== parentFile)
|
|
1202
|
+
return false;
|
|
1203
|
+
return declaration
|
|
1204
|
+
.getNamedImports()
|
|
1205
|
+
.some((namedImport) => renames.has(namedImport.getName()));
|
|
1206
|
+
}),
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function rewireMovedHelpers(
|
|
1211
|
+
project: Project,
|
|
1212
|
+
parent: RouteCollection,
|
|
1213
|
+
target: SourceFile,
|
|
1214
|
+
renames: Map<string, string>,
|
|
1215
|
+
): void {
|
|
1216
|
+
if (renames.size === 0) return;
|
|
1217
|
+
const parentDeclaration = parent.call.getFirstAncestorByKind(
|
|
1218
|
+
SyntaxKind.VariableDeclaration,
|
|
1219
|
+
);
|
|
1220
|
+
const parentBinding = parentDeclaration?.getNameNode();
|
|
1221
|
+
const targetDeclaration = target
|
|
1222
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
1223
|
+
.find((call) => call.getExpression().getText() === 'craftRoutes')
|
|
1224
|
+
?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
|
1225
|
+
const targetBinding = targetDeclaration?.getNameNode();
|
|
1226
|
+
if (
|
|
1227
|
+
!parentBinding ||
|
|
1228
|
+
!Node.isObjectBindingPattern(parentBinding) ||
|
|
1229
|
+
!targetBinding ||
|
|
1230
|
+
!Node.isObjectBindingPattern(targetBinding)
|
|
1231
|
+
) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const oldNames = new Set(renames.keys());
|
|
1236
|
+
const parentElements = parentBinding
|
|
1237
|
+
.getElements()
|
|
1238
|
+
.filter(
|
|
1239
|
+
(element) =>
|
|
1240
|
+
!oldNames.has(
|
|
1241
|
+
element.getPropertyNameNode()?.getText() ?? element.getName(),
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
1244
|
+
.map((element) => element.getText());
|
|
1245
|
+
parentBinding.replaceWithText(`{ ${parentElements.join(', ')} }`);
|
|
1246
|
+
|
|
1247
|
+
const targetElements = targetBinding
|
|
1248
|
+
.getElements()
|
|
1249
|
+
.map((element) => element.getText());
|
|
1250
|
+
for (const newName of renames.values()) {
|
|
1251
|
+
if (!targetElements.includes(newName)) targetElements.push(newName);
|
|
1252
|
+
}
|
|
1253
|
+
targetBinding.replaceWithText(`{ ${targetElements.join(', ')} }`);
|
|
1254
|
+
|
|
1255
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
1256
|
+
for (const declaration of [...sourceFile.getImportDeclarations()]) {
|
|
1257
|
+
if (declaration.getModuleSpecifierSourceFile() !== parent.sourceFile)
|
|
1258
|
+
continue;
|
|
1259
|
+
for (const namedImport of [...declaration.getNamedImports()]) {
|
|
1260
|
+
const newName = renames.get(namedImport.getName());
|
|
1261
|
+
if (!newName) continue;
|
|
1262
|
+
const localName =
|
|
1263
|
+
namedImport.getAliasNode()?.getText() ?? namedImport.getName();
|
|
1264
|
+
let targetImport = sourceFile
|
|
1265
|
+
.getImportDeclarations()
|
|
1266
|
+
.find(
|
|
1267
|
+
(candidate) => candidate.getModuleSpecifierSourceFile() === target,
|
|
1268
|
+
);
|
|
1269
|
+
if (!targetImport) {
|
|
1270
|
+
targetImport = sourceFile.addImportDeclaration({
|
|
1271
|
+
moduleSpecifier: relativeModuleSpecifier(
|
|
1272
|
+
sourceFile.getFilePath(),
|
|
1273
|
+
target.getFilePath(),
|
|
1274
|
+
),
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
targetImport.addNamedImport({
|
|
1278
|
+
name: newName,
|
|
1279
|
+
alias: localName === newName ? undefined : localName,
|
|
1280
|
+
});
|
|
1281
|
+
namedImport.remove();
|
|
1282
|
+
}
|
|
1283
|
+
if (
|
|
1284
|
+
declaration.getNamedImports().length === 0 &&
|
|
1285
|
+
!declaration.getDefaultImport() &&
|
|
1286
|
+
!declaration.getNamespaceImport()
|
|
1287
|
+
) {
|
|
1288
|
+
declaration.remove();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function findLocalDependencies(
|
|
1295
|
+
sourceFile: SourceFile,
|
|
1296
|
+
nodes: Node[],
|
|
1297
|
+
): string[] {
|
|
1298
|
+
const imported = new Set(
|
|
1299
|
+
sourceFile.getImportDeclarations().flatMap((declaration) => {
|
|
1300
|
+
const defaultImport = declaration.getDefaultImport();
|
|
1301
|
+
const namespaceImport = declaration.getNamespaceImport();
|
|
1302
|
+
return [
|
|
1303
|
+
...declaration
|
|
1304
|
+
.getNamedImports()
|
|
1305
|
+
.map((item) => item.getAliasNode()?.getText() ?? item.getName()),
|
|
1306
|
+
...(defaultImport ? [defaultImport.getText()] : []),
|
|
1307
|
+
...(namespaceImport ? [namespaceImport.getText()] : []),
|
|
1308
|
+
];
|
|
1309
|
+
}),
|
|
1310
|
+
);
|
|
1311
|
+
const locals = new Set([
|
|
1312
|
+
...sourceFile
|
|
1313
|
+
.getVariableStatements()
|
|
1314
|
+
.flatMap((statement) => statement.getDeclarations())
|
|
1315
|
+
.map((declaration) => declaration.getName()),
|
|
1316
|
+
...sourceFile
|
|
1317
|
+
.getFunctions()
|
|
1318
|
+
.map((declaration) => declaration.getName())
|
|
1319
|
+
.filter(Boolean),
|
|
1320
|
+
...sourceFile.getEnums().map((declaration) => declaration.getName()),
|
|
1321
|
+
...sourceFile.getTypeAliases().map((declaration) => declaration.getName()),
|
|
1322
|
+
...sourceFile.getInterfaces().map((declaration) => declaration.getName()),
|
|
1323
|
+
...sourceFile
|
|
1324
|
+
.getClasses()
|
|
1325
|
+
.map((declaration) => declaration.getName())
|
|
1326
|
+
.filter(Boolean),
|
|
1327
|
+
] as string[]);
|
|
1328
|
+
const ignored = new Set([
|
|
1329
|
+
'craftRoute',
|
|
1330
|
+
'import',
|
|
1331
|
+
'm',
|
|
1332
|
+
'withRetry',
|
|
1333
|
+
'true',
|
|
1334
|
+
'false',
|
|
1335
|
+
'undefined',
|
|
1336
|
+
]);
|
|
1337
|
+
const used = new Set(
|
|
1338
|
+
nodes.flatMap((node) =>
|
|
1339
|
+
node
|
|
1340
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
1341
|
+
.map((identifier) => identifier.getText()),
|
|
1342
|
+
),
|
|
1343
|
+
);
|
|
1344
|
+
return [...used].filter(
|
|
1345
|
+
(name) => locals.has(name) && !imported.has(name) && !ignored.has(name),
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function rewriteRelativeSpecifiers(
|
|
1350
|
+
target: SourceFile,
|
|
1351
|
+
previousFilePath: string,
|
|
1352
|
+
): void {
|
|
1353
|
+
for (const declaration of target.getImportDeclarations()) {
|
|
1354
|
+
const specifier = declaration.getModuleSpecifierValue();
|
|
1355
|
+
if (!specifier.startsWith('.')) continue;
|
|
1356
|
+
const absolute = resolve(dirname(previousFilePath), specifier);
|
|
1357
|
+
declaration.setModuleSpecifier(
|
|
1358
|
+
relativeModuleSpecifier(target.getFilePath(), absolute),
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
for (const literal of target.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
|
1362
|
+
const parent = literal.getParent();
|
|
1363
|
+
if (!literal.getLiteralValue().startsWith('.')) continue;
|
|
1364
|
+
if (
|
|
1365
|
+
Node.isCallExpression(parent) &&
|
|
1366
|
+
(parent.getExpression().getText() === 'import' ||
|
|
1367
|
+
parent.getExpression().getText() === 'require')
|
|
1368
|
+
) {
|
|
1369
|
+
const absolute = resolve(
|
|
1370
|
+
dirname(previousFilePath),
|
|
1371
|
+
literal.getLiteralValue(),
|
|
1372
|
+
);
|
|
1373
|
+
literal.setLiteralValue(
|
|
1374
|
+
relativeModuleSpecifier(target.getFilePath(), absolute),
|
|
1375
|
+
);
|
|
1376
|
+
} else if (literal.getFirstAncestorByKind(SyntaxKind.ImportType)) {
|
|
1377
|
+
const absolute = resolve(
|
|
1378
|
+
dirname(previousFilePath),
|
|
1379
|
+
literal.getLiteralValue(),
|
|
1380
|
+
);
|
|
1381
|
+
literal.setLiteralValue(
|
|
1382
|
+
relativeModuleSpecifier(target.getFilePath(), absolute),
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function ensureImport(
|
|
1389
|
+
sourceFile: SourceFile,
|
|
1390
|
+
name: string,
|
|
1391
|
+
moduleSpecifier = '@craft-ng/core',
|
|
1392
|
+
): void {
|
|
1393
|
+
const declaration = sourceFile
|
|
1394
|
+
.getImportDeclarations()
|
|
1395
|
+
.find(
|
|
1396
|
+
(item) =>
|
|
1397
|
+
item.getModuleSpecifierValue() === moduleSpecifier &&
|
|
1398
|
+
!item.isTypeOnly(),
|
|
1399
|
+
);
|
|
1400
|
+
if (!declaration) {
|
|
1401
|
+
sourceFile.addImportDeclaration({ moduleSpecifier, namedImports: [name] });
|
|
1402
|
+
} else if (
|
|
1403
|
+
!declaration.getNamedImports().some((item) => item.getName() === name)
|
|
1404
|
+
) {
|
|
1405
|
+
declaration.addNamedImport(name);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function ensureTypeImport(
|
|
1410
|
+
sourceFile: SourceFile,
|
|
1411
|
+
name: string,
|
|
1412
|
+
moduleSpecifier = '@craft-ng/core',
|
|
1413
|
+
): void {
|
|
1414
|
+
if (
|
|
1415
|
+
sourceFile
|
|
1416
|
+
.getImportDeclarations()
|
|
1417
|
+
.some(
|
|
1418
|
+
(item) =>
|
|
1419
|
+
item.getModuleSpecifierValue() === moduleSpecifier &&
|
|
1420
|
+
item.getNamedImports().some((named) => named.getName() === name),
|
|
1421
|
+
)
|
|
1422
|
+
) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const valueImport = sourceFile
|
|
1426
|
+
.getImportDeclarations()
|
|
1427
|
+
.find(
|
|
1428
|
+
(item) =>
|
|
1429
|
+
item.getModuleSpecifierValue() === moduleSpecifier &&
|
|
1430
|
+
!item.isTypeOnly(),
|
|
1431
|
+
);
|
|
1432
|
+
if (valueImport) valueImport.addNamedImport({ name, isTypeOnly: true });
|
|
1433
|
+
else
|
|
1434
|
+
sourceFile.addImportDeclaration({
|
|
1435
|
+
moduleSpecifier,
|
|
1436
|
+
isTypeOnly: true,
|
|
1437
|
+
namedImports: [name],
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function hasCall(
|
|
1442
|
+
sourceFile: SourceFile,
|
|
1443
|
+
functionName: string,
|
|
1444
|
+
argument: string,
|
|
1445
|
+
): boolean {
|
|
1446
|
+
return sourceFile
|
|
1447
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
1448
|
+
.some(
|
|
1449
|
+
(call) =>
|
|
1450
|
+
call.getExpression().getText() === functionName &&
|
|
1451
|
+
call.getArguments()[0]?.getText() === argument,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
async function saveFiles(
|
|
1456
|
+
project: Project,
|
|
1457
|
+
filePaths: string[],
|
|
1458
|
+
fileSystem: RouteFileSystem,
|
|
1459
|
+
): Promise<void> {
|
|
1460
|
+
await Promise.all(
|
|
1461
|
+
[...new Set(filePaths)].map(async (filePath) => {
|
|
1462
|
+
const sourceFile = project.getSourceFile(filePath);
|
|
1463
|
+
if (sourceFile) {
|
|
1464
|
+
await fileSystem.write(filePath, sourceFile.getFullText());
|
|
1465
|
+
}
|
|
1466
|
+
}),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function addSourceFileFromWorkspace(
|
|
1471
|
+
project: Project,
|
|
1472
|
+
filePath: string,
|
|
1473
|
+
fileSystem: RouteFileSystem,
|
|
1474
|
+
): SourceFile | undefined {
|
|
1475
|
+
const existing = project.getSourceFile(filePath);
|
|
1476
|
+
if (existing) return existing;
|
|
1477
|
+
const text = fileSystem.read(filePath);
|
|
1478
|
+
return text === undefined
|
|
1479
|
+
? undefined
|
|
1480
|
+
: project.createSourceFile(filePath, text, { overwrite: true });
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function snapshotProject(project: Project): Map<string, string> {
|
|
1484
|
+
return new Map(
|
|
1485
|
+
project
|
|
1486
|
+
.getSourceFiles()
|
|
1487
|
+
.map((sourceFile) => [
|
|
1488
|
+
sourceFile.getFilePath(),
|
|
1489
|
+
sourceFile.getFullText(),
|
|
1490
|
+
]),
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function sourceChanged(
|
|
1495
|
+
project: Project,
|
|
1496
|
+
initialTexts: Map<string, string>,
|
|
1497
|
+
filePath: string,
|
|
1498
|
+
): boolean {
|
|
1499
|
+
const sourceFile = project.getSourceFile(filePath);
|
|
1500
|
+
return Boolean(
|
|
1501
|
+
sourceFile && initialTexts.get(filePath) !== sourceFile.getFullText(),
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export async function validateRouteChangedFiles(
|
|
1506
|
+
rootDir: string,
|
|
1507
|
+
sourceRoot: string,
|
|
1508
|
+
tsConfigFilePath: string,
|
|
1509
|
+
files: string[],
|
|
1510
|
+
eslintBaseline: Set<string>,
|
|
1511
|
+
options: { skipTypeCheck?: boolean } = {},
|
|
1512
|
+
): Promise<RouteCommandDiagnostic[]> {
|
|
1513
|
+
const diagnostics: RouteCommandDiagnostic[] = [];
|
|
1514
|
+
const eslint = findLocalBinary(rootDir, 'eslint');
|
|
1515
|
+
if (eslint && files.length > 0) {
|
|
1516
|
+
const lint = await runEslintJson(eslint, ['--fix', ...files], sourceRoot);
|
|
1517
|
+
if (lint.failure) {
|
|
1518
|
+
diagnostics.push({
|
|
1519
|
+
code: 'VALIDATION_FAILED',
|
|
1520
|
+
message: `ESLint failed after writing changes: ${lint.failure}`,
|
|
1521
|
+
});
|
|
1522
|
+
} else if (lint.failed) {
|
|
1523
|
+
const newMessages = lint.results.flatMap((result) =>
|
|
1524
|
+
result.messages
|
|
1525
|
+
.filter(
|
|
1526
|
+
(message) =>
|
|
1527
|
+
!eslintBaseline.has(eslintFingerprint(result.filePath, message)),
|
|
1528
|
+
)
|
|
1529
|
+
.map(
|
|
1530
|
+
(message) =>
|
|
1531
|
+
`${result.filePath}:${message.line ?? 0}:${message.column ?? 0} ${message.message}${message.ruleId ? ` (${message.ruleId})` : ''}`,
|
|
1532
|
+
),
|
|
1533
|
+
);
|
|
1534
|
+
if (newMessages.length > 0) {
|
|
1535
|
+
diagnostics.push({
|
|
1536
|
+
code: 'VALIDATION_FAILED',
|
|
1537
|
+
message: `ESLint failed after writing changes: ${newMessages.join('\n')}`,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (options.skipTypeCheck) return diagnostics;
|
|
1543
|
+
const ngc = findLocalBinary(rootDir, 'ngc');
|
|
1544
|
+
const tsc = findLocalBinary(rootDir, 'tsc');
|
|
1545
|
+
const typeChecker =
|
|
1546
|
+
basename(tsConfigFilePath) === 'tsconfig.app.json' && ngc ? ngc : tsc;
|
|
1547
|
+
if (typeChecker && existsSync(tsConfigFilePath)) {
|
|
1548
|
+
try {
|
|
1549
|
+
await execFileAsync(
|
|
1550
|
+
typeChecker,
|
|
1551
|
+
['--project', tsConfigFilePath, '--noEmit'],
|
|
1552
|
+
{
|
|
1553
|
+
cwd: sourceRoot,
|
|
1554
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
1555
|
+
},
|
|
1556
|
+
);
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
const detail = commandError(error);
|
|
1559
|
+
diagnostics.push({
|
|
1560
|
+
code: 'VALIDATION_FAILED',
|
|
1561
|
+
message: detail.includes('TS2589')
|
|
1562
|
+
? `TypeScript hit TS2589 after writing changes. Keep the DI check and split the collection with loadChildren; rerun with --feature-file or a more specific --parent. ${detail}`
|
|
1563
|
+
: `TypeScript diagnostics failed after writing changes: ${detail}`,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return diagnostics;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
type EslintMessage = {
|
|
1571
|
+
column?: number;
|
|
1572
|
+
line?: number;
|
|
1573
|
+
message: string;
|
|
1574
|
+
ruleId?: string | null;
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
type EslintResult = {
|
|
1578
|
+
filePath: string;
|
|
1579
|
+
messages: EslintMessage[];
|
|
1580
|
+
};
|
|
1581
|
+
|
|
1582
|
+
export async function captureRouteEslintBaseline(
|
|
1583
|
+
rootDir: string,
|
|
1584
|
+
sourceRoot: string,
|
|
1585
|
+
files: string[],
|
|
1586
|
+
): Promise<Set<string>> {
|
|
1587
|
+
const eslint = findLocalBinary(rootDir, 'eslint');
|
|
1588
|
+
const existingFiles = files.filter((file) => existsSync(file));
|
|
1589
|
+
if (!eslint || existingFiles.length === 0) return new Set();
|
|
1590
|
+
const lint = await runEslintJson(eslint, existingFiles, sourceRoot);
|
|
1591
|
+
if (lint.failure) return new Set();
|
|
1592
|
+
return new Set(
|
|
1593
|
+
lint.results.flatMap((result) =>
|
|
1594
|
+
result.messages.map((message) =>
|
|
1595
|
+
eslintFingerprint(result.filePath, message),
|
|
1596
|
+
),
|
|
1597
|
+
),
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function runEslintJson(
|
|
1602
|
+
eslint: string,
|
|
1603
|
+
args: string[],
|
|
1604
|
+
cwd: string,
|
|
1605
|
+
): Promise<{
|
|
1606
|
+
failed: boolean;
|
|
1607
|
+
failure?: string;
|
|
1608
|
+
results: EslintResult[];
|
|
1609
|
+
}> {
|
|
1610
|
+
try {
|
|
1611
|
+
const { stdout } = await execFileAsync(
|
|
1612
|
+
eslint,
|
|
1613
|
+
['--format', 'json', ...args],
|
|
1614
|
+
{ cwd, maxBuffer: 20 * 1024 * 1024 },
|
|
1615
|
+
);
|
|
1616
|
+
return { failed: false, results: parseEslintResults(stdout) ?? [] };
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
const stdout = commandStdout(error);
|
|
1619
|
+
const results = parseEslintResults(stdout);
|
|
1620
|
+
return results
|
|
1621
|
+
? { failed: true, results }
|
|
1622
|
+
: { failed: true, failure: commandError(error), results: [] };
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function parseEslintResults(output: string): EslintResult[] | undefined {
|
|
1627
|
+
if (!output.trim()) return undefined;
|
|
1628
|
+
try {
|
|
1629
|
+
const value: unknown = JSON.parse(output);
|
|
1630
|
+
return Array.isArray(value) ? (value as EslintResult[]) : undefined;
|
|
1631
|
+
} catch {
|
|
1632
|
+
return undefined;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function eslintFingerprint(filePath: string, message: EslintMessage): string {
|
|
1637
|
+
return `${resolve(filePath)}\0${message.ruleId ?? ''}\0${message.message}`;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function findLocalBinary(rootDir: string, name: string): string | undefined {
|
|
1641
|
+
let current = rootDir;
|
|
1642
|
+
while (true) {
|
|
1643
|
+
const candidate = join(current, 'node_modules', '.bin', name);
|
|
1644
|
+
if (existsSync(candidate)) return candidate;
|
|
1645
|
+
const parent = dirname(current);
|
|
1646
|
+
if (parent === current) return undefined;
|
|
1647
|
+
current = parent;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function listFilesOnDisk(rootDir: string): string[] {
|
|
1652
|
+
if (!existsSync(rootDir)) return [];
|
|
1653
|
+
const result: string[] = [];
|
|
1654
|
+
const visit = (directory: string): void => {
|
|
1655
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1656
|
+
if (['node_modules', 'dist', '.angular', '.git'].includes(entry.name)) {
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
const filePath = join(directory, entry.name);
|
|
1660
|
+
if (entry.isDirectory()) visit(filePath);
|
|
1661
|
+
else if (entry.isFile()) result.push(filePath);
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
visit(rootDir);
|
|
1665
|
+
return result;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function findFiles(rootDir: string, fileName: string): Promise<string[]> {
|
|
1669
|
+
const entries = await readdir(rootDir, {
|
|
1670
|
+
recursive: true,
|
|
1671
|
+
withFileTypes: true,
|
|
1672
|
+
});
|
|
1673
|
+
return entries
|
|
1674
|
+
.filter((entry) => entry.isFile() && entry.name === fileName)
|
|
1675
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
1676
|
+
.filter((path) => !path.includes(`${sep}node_modules${sep}`));
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function printPlan(
|
|
1680
|
+
plan: RouteCommandPlan,
|
|
1681
|
+
log: (message: string) => void,
|
|
1682
|
+
json: boolean,
|
|
1683
|
+
) {
|
|
1684
|
+
if (json) return;
|
|
1685
|
+
log(
|
|
1686
|
+
`${plan.summary}\nFiles:\n${plan.files.map((file) => ` ${file}`).join('\n')}`,
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async function shouldContinue(
|
|
1691
|
+
options: {
|
|
1692
|
+
dryRun?: boolean;
|
|
1693
|
+
yes?: boolean;
|
|
1694
|
+
confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
|
|
1695
|
+
},
|
|
1696
|
+
plan: RouteCommandPlan,
|
|
1697
|
+
): Promise<boolean> {
|
|
1698
|
+
if (options.dryRun || options.yes) return true;
|
|
1699
|
+
return options.confirm ? options.confirm(plan) : false;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function failed(
|
|
1703
|
+
code: RouteCommandDiagnostic['code'],
|
|
1704
|
+
message: string,
|
|
1705
|
+
plan?: RouteCommandPlan,
|
|
1706
|
+
): RouteCommandResult {
|
|
1707
|
+
return resultFromDiagnostics([{ code, message }], plan);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function resultFromDiagnostics(
|
|
1711
|
+
diagnostics: RouteCommandDiagnostic[],
|
|
1712
|
+
plan?: RouteCommandPlan,
|
|
1713
|
+
): RouteCommandResult {
|
|
1714
|
+
return { changedFiles: [], diagnostics, exitCode: 1, plan };
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function resolveFromRoot(filePath: string, rootDir: string): string {
|
|
1718
|
+
return isAbsolute(filePath) ? resolve(filePath) : resolve(rootDir, filePath);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function normalizeRoutePath(path: string): string {
|
|
1722
|
+
const clean = path
|
|
1723
|
+
.trim()
|
|
1724
|
+
.replace(/^\/+|\/+$/g, '')
|
|
1725
|
+
.replace(/\/{2,}/g, '/');
|
|
1726
|
+
return clean ? `/${clean}` : '/';
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function relativeModuleSpecifier(fromFile: string, toFile: string): string {
|
|
1730
|
+
let value = relative(dirname(fromFile), toFile).replace(/\\/g, '/');
|
|
1731
|
+
const extension = extname(value);
|
|
1732
|
+
if (['.ts', '.tsx', '.js', '.jsx'].includes(extension)) {
|
|
1733
|
+
value = value.slice(0, -extension.length);
|
|
1734
|
+
}
|
|
1735
|
+
if (!value.startsWith('.')) value = `./${value}`;
|
|
1736
|
+
return value;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function collectionNameFromFile(filePath: string): string {
|
|
1740
|
+
return basename(filePath)
|
|
1741
|
+
.replace(/\.routes\.ts$/, '')
|
|
1742
|
+
.replace(/\.ts$/, '');
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function isInside(filePath: string, rootDir: string): boolean {
|
|
1746
|
+
const path = relative(resolve(rootDir), resolve(filePath));
|
|
1747
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function quote(value: string): string {
|
|
1751
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function toPascalCase(value: string): string {
|
|
1755
|
+
return (
|
|
1756
|
+
value
|
|
1757
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1758
|
+
.filter(Boolean)
|
|
1759
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1760
|
+
.join('') || 'Routes'
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function uncapitalize(value: string): string {
|
|
1765
|
+
return value.charAt(0).toLowerCase() + value.slice(1);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function commandError(error: unknown): string {
|
|
1769
|
+
if (typeof error === 'object' && error) {
|
|
1770
|
+
const value = error as {
|
|
1771
|
+
stdout?: unknown;
|
|
1772
|
+
stderr?: unknown;
|
|
1773
|
+
message?: unknown;
|
|
1774
|
+
};
|
|
1775
|
+
const output = [value.stdout, value.stderr]
|
|
1776
|
+
.filter((item) => typeof item === 'string' && item.trim())
|
|
1777
|
+
.join('\n')
|
|
1778
|
+
.trim();
|
|
1779
|
+
if (output) return output;
|
|
1780
|
+
if (value.message) return String(value.message).trim();
|
|
1781
|
+
}
|
|
1782
|
+
return error instanceof Error ? error.message : String(error);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function commandStdout(error: unknown): string {
|
|
1786
|
+
if (typeof error === 'object' && error && 'stdout' in error) {
|
|
1787
|
+
return String((error as { stdout?: unknown }).stdout ?? '');
|
|
1788
|
+
}
|
|
1789
|
+
return '';
|
|
1790
|
+
}
|