@valkyrjaio/sindri 26.5.1 → 26.6.0

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.
@@ -344,20 +344,20 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
344
344
  const attrResult = this.cliRouteAttributeReader.readFile(controllerPath);
345
345
 
346
346
  Object.assign(allRoutes, attrResult.routes);
347
+ this.mergeReaderImports(importMap, attrResult.importMap, config);
347
348
  }
348
349
  }
349
350
 
350
351
  this.cliGenerator.classImportMap = importMap;
351
352
 
352
- const status =
353
- imperativeRoutes.length > 0
354
- ? this.cliGenerator.generateFileFromRoutes(
355
- config.dataPath,
356
- 'AppCliRoutingData',
357
- config.dataNamespace,
358
- imperativeRoutes,
359
- )
360
- : this.cliGenerator.generateFile(config.dataPath, 'AppCliRoutingData', config.dataNamespace, allRoutes);
353
+ // Merge attribute-scanned command routes with imperative getRoutes() routes.
354
+ const status = this.cliGenerator.generateMergedFile(
355
+ config.dataPath,
356
+ 'AppCliRoutingData',
357
+ config.dataNamespace,
358
+ allRoutes,
359
+ imperativeRoutes,
360
+ );
361
361
 
362
362
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
363
363
  }
@@ -409,30 +409,43 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
409
409
 
410
410
  Object.assign(allRoutes, attrResult.routes);
411
411
  Object.assign(allRouteData, attrResult.routeData);
412
+ this.mergeReaderImports(importMap, attrResult.importMap, config);
412
413
  }
413
414
  }
414
415
 
415
416
  this.httpGenerator.classImportMap = importMap;
416
417
 
417
- const status =
418
- imperativeRoutes.length > 0
419
- ? this.httpGenerator.generateFileFromRoutes(
420
- config.dataPath,
421
- 'AppHttpRoutingData',
422
- config.dataNamespace,
423
- imperativeRoutes,
424
- )
425
- : this.httpGenerator.generateFile(
426
- config.dataPath,
427
- 'AppHttpRoutingData',
428
- config.dataNamespace,
429
- allRoutes,
430
- allRouteData,
431
- );
418
+ // Merge attribute-scanned routes with imperative getRoutes() routes.
419
+ const status = this.httpGenerator.generateMergedFile(
420
+ config.dataPath,
421
+ 'AppHttpRoutingData',
422
+ config.dataNamespace,
423
+ allRoutes,
424
+ allRouteData,
425
+ imperativeRoutes,
426
+ );
432
427
 
433
428
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
434
429
  }
435
430
 
431
+ /**
432
+ * Merge the class-name → absolute-file-path imports a route attribute reader
433
+ * resolved (handler/help-text/middleware classes) into the generated data
434
+ * file's import map, converting each absolute path to the specifier the
435
+ * generated file imports it by — relative to the output directory for
436
+ * application source, or the owning package's own specifier for a class an
437
+ * installed package exposes.
438
+ */
439
+ protected mergeReaderImports(
440
+ importMap: Record<string, string>,
441
+ readerImportMap: Record<string, string>,
442
+ config: ConfigResult,
443
+ ): void {
444
+ for (const [name, absolutePath] of Object.entries(readerImportMap)) {
445
+ importMap[name] = this.importSpecifier(config.dataPath, absolutePath);
446
+ }
447
+ }
448
+
436
449
  protected addMessagesForGenerateStatus(output: OutputContract, status: GenerateStatus): OutputContract {
437
450
  let text = 'Failed';
438
451
  let formatter = new ErrorFormatter();
@@ -84,7 +84,23 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
84
84
  namespace: string,
85
85
  routeExprs: readonly ts.Expression[],
86
86
  ): GenerateStatus {
87
- const routes: Record<string, ts.Expression> = {};
87
+ return this.generateMergedFile(directory, className, namespace, {}, routeExprs);
88
+ }
89
+
90
+ /**
91
+ * Generate the CLI routing data file by MERGING attribute-scanned command
92
+ * routes with imperative `getRoutes()` route objects — both are keyed by
93
+ * command name into a single routes map. Attribute entries come first;
94
+ * imperative entries override on a name collision.
95
+ */
96
+ public generateMergedFile(
97
+ directory: string,
98
+ className: string,
99
+ namespace: string,
100
+ routes: Record<string, ts.Expression>,
101
+ routeExprs: readonly ts.Expression[],
102
+ ): GenerateStatus {
103
+ const merged: Record<string, ts.Expression> = { ...routes };
88
104
 
89
105
  for (const expr of routeExprs) {
90
106
  if (!ts.isNewExpression(expr)) {
@@ -97,10 +113,10 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
97
113
  continue;
98
114
  }
99
115
 
100
- routes[key] = expr;
116
+ merged[key] = expr;
101
117
  }
102
118
 
103
- return this.generateFile(directory, className, namespace, routes);
119
+ return this.generateFile(directory, className, namespace, merged);
104
120
  }
105
121
 
106
122
  /**
@@ -75,19 +75,7 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
75
75
  routes: Record<string, ts.Expression>,
76
76
  routeData: Record<string, HttpRouteData>,
77
77
  ): GenerateStatus {
78
- const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
79
- ...Object.keys(AstHttpDataFileGenerator.FRAMEWORK_IMPORTS),
80
- ...Object.keys(AstHttpDataFileGenerator.FRAMEWORK_TYPE_IMPORTS),
81
- ]);
82
-
83
- const routesContent = this.getRoutesAsContent(routes, routeData);
84
- const paths = this.printNestedObject(this.buildPaths(routeData));
85
- const dynamicPaths = this.printNestedObject(this.buildDynamicPaths(routeData));
86
- const regexes = this.printNestedObject(this.buildRegexes(routeData));
87
-
88
- const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
89
-
90
- return this.writeFile(directory, className, data);
78
+ return this.generateMergedFile(directory, className, namespace, routes, routeData, []);
91
79
  }
92
80
 
93
81
  /**
@@ -100,9 +88,26 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
100
88
  * (literal path and regex, request methods defaulting to `[HEAD, GET]`).
101
89
  */
102
90
  public generateFileFromRoutes(
91
+ directory: string,
92
+ className: string,
93
+ namespace: string,
94
+ routeExprs: readonly ts.Expression[],
95
+ ): GenerateStatus {
96
+ return this.generateMergedFile(directory, className, namespace, {}, {}, routeExprs);
97
+ }
98
+
99
+ /**
100
+ * Generate the routing data file by MERGING attribute-scanned routes with
101
+ * imperative `getRoutes()` route objects — the four lookup maps (routes,
102
+ * paths, dynamicPaths, regexes) are built from both sources and combined.
103
+ * Attribute entries are emitted first, then imperative ones.
104
+ */
105
+ public generateMergedFile(
103
106
  directory: string,
104
107
  className: string,
105
108
  _namespace: string,
109
+ routes: Record<string, ts.Expression>,
110
+ routeData: Record<string, HttpRouteData>,
106
111
  routeExprs: readonly ts.Expression[],
107
112
  ): GenerateStatus {
108
113
  const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
@@ -114,16 +119,39 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
114
119
  .map((expr) => this.extractRouteMeta(expr))
115
120
  .filter((meta): meta is ImperativeRouteMeta => meta !== undefined);
116
121
 
117
- const routesContent = this.getImperativeRoutesContent(metas);
118
- const paths = this.printNestedObject(this.buildImperativePaths(metas, false));
119
- const dynamicPaths = this.printNestedObject(this.buildImperativePaths(metas, true));
120
- const regexes = this.printNestedObject(this.buildImperativeRegexes(metas));
122
+ const routesContent = this.wrapRoutes([
123
+ ...this.getRouteLines(routes, routeData),
124
+ ...this.getImperativeRouteLines(metas),
125
+ ]);
126
+ const paths = this.printNestedObject(
127
+ this.mergeNested(this.buildPaths(routeData), this.buildImperativePaths(metas, false)),
128
+ );
129
+ const dynamicPaths = this.printNestedObject(
130
+ this.mergeNested(this.buildDynamicPaths(routeData), this.buildImperativePaths(metas, true)),
131
+ );
132
+ const regexes = this.printNestedObject(
133
+ this.mergeNested(this.buildRegexes(routeData), this.buildImperativeRegexes(metas)),
134
+ );
121
135
 
122
136
  const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
123
137
 
124
138
  return this.writeFile(directory, className, data);
125
139
  }
126
140
 
141
+ /** Deep-merge two `method → key → name` maps; entries in `b` override `a`. */
142
+ protected mergeNested(
143
+ a: Record<string, Record<string, string>>,
144
+ b: Record<string, Record<string, string>>,
145
+ ): Record<string, Record<string, string>> {
146
+ const merged: Record<string, Record<string, string>> = {};
147
+
148
+ for (const [outerKey, innerMap] of [...Object.entries(a), ...Object.entries(b)]) {
149
+ merged[outerKey] = { ...(merged[outerKey] ?? {}), ...innerMap };
150
+ }
151
+
152
+ return merged;
153
+ }
154
+
127
155
  protected assembleFile(
128
156
  className: string,
129
157
  userImportsBlock: string,
@@ -339,23 +367,25 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
339
367
  routes: Record<string, ts.Expression>,
340
368
  routeData: Record<string, HttpRouteData>,
341
369
  ): string {
342
- const entries = Object.entries(routes);
370
+ return this.wrapRoutes(this.getRouteLines(routes, routeData));
371
+ }
343
372
 
344
- if (entries.length === 0) {
373
+ /** Wrap route closure lines in the `{ ... }` object-literal body (or `{}`). */
374
+ protected wrapRoutes(lines: readonly string[]): string {
375
+ if (lines.length === 0) {
345
376
  return '{}';
346
377
  }
347
378
 
379
+ return ['{', ...lines, ' }'].join('\n ');
380
+ }
381
+
382
+ /** Build the per-route closure lines for attribute-scanned routes. */
383
+ protected getRouteLines(routes: Record<string, ts.Expression>, routeData: Record<string, HttpRouteData>): string[] {
348
384
  const lines: string[] = [];
349
385
 
350
- for (const [key, routeExpr] of entries) {
386
+ for (const [key, routeExpr] of Object.entries(routes)) {
351
387
  const data = routeData[key] ?? null;
352
- let expr = routeExpr;
353
-
354
- if (data !== null && data.isDynamic && ts.isNewExpression(expr)) {
355
- const computedRegex = data.parameters.length > 0 ? this.computeRegex(data) : '';
356
- const args = [...(expr.arguments ?? []), ts.factory.createStringLiteral(computedRegex)];
357
- expr = ts.factory.createNewExpression(expr.expression, expr.typeArguments, args);
358
- }
388
+ const expr = data !== null ? this.injectRegex(routeExpr, data) : routeExpr;
359
389
 
360
390
  const printedRoute = this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
361
391
  const formattedKey = key.includes('::')
@@ -365,7 +395,25 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
365
395
  lines.push(` ${formattedKey}: (): RouteContract => ${printedRoute},`);
366
396
  }
367
397
 
368
- return ['{', ...lines, ' }'].join('\n ');
398
+ return lines;
399
+ }
400
+
401
+ /**
402
+ * Replace the regex argument (slot 2) of a dynamic `new DynamicRoute(...)`
403
+ * expression with the regex computed from the route's parameters. The reader
404
+ * emits an empty-string placeholder there; the generator owns the routing
405
+ * `Processor` and computes the real value here.
406
+ */
407
+ protected injectRegex(expr: ts.Expression, data: HttpRouteData): ts.Expression {
408
+ if (!(data.isDynamic && ts.isNewExpression(expr))) {
409
+ return expr;
410
+ }
411
+
412
+ const computedRegex = data.parameters.length > 0 ? this.computeRegex(data) : '';
413
+ const original = expr.arguments ?? ts.factory.createNodeArray<ts.Expression>();
414
+ const args = original.map((arg, index) => (index === 2 ? ts.factory.createStringLiteral(computedRegex) : arg));
415
+
416
+ return ts.factory.createNewExpression(expr.expression, expr.typeArguments, args);
369
417
  }
370
418
 
371
419
  // -------------------------------------------------------------------------
@@ -433,20 +481,11 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
433
481
  return methods;
434
482
  }
435
483
 
436
- protected getImperativeRoutesContent(metas: readonly ImperativeRouteMeta[]): string {
437
- if (metas.length === 0) {
438
- return '{}';
439
- }
440
-
441
- const lines: string[] = [];
442
-
443
- for (const meta of metas) {
444
- // Imperative route expressions come parsed from a provider's `getRoutes()`
445
- // body, so they are emitted verbatim via their original source text.
446
- lines.push(` ['${meta.name}']: (): RouteContract => ${meta.expr.getText()},`);
447
- }
448
-
449
- return ['{', ...lines, ' }'].join('\n ');
484
+ /** Build the per-route closure lines for imperative `getRoutes()` routes. */
485
+ protected getImperativeRouteLines(metas: readonly ImperativeRouteMeta[]): string[] {
486
+ // Imperative route expressions come parsed from a provider's `getRoutes()`
487
+ // body, so they are emitted verbatim via their original source text.
488
+ return metas.map((meta) => ` ['${meta.name}']: (): RouteContract => ${meta.expr.getText()},`);
450
489
  }
451
490
 
452
491
  protected buildImperativePaths(
@@ -29,5 +29,13 @@ export interface CliDataFileGeneratorContract {
29
29
  routeExprs: readonly ts.Expression[],
30
30
  ): GenerateStatus;
31
31
 
32
+ generateMergedFile(
33
+ directory: string,
34
+ className: string,
35
+ namespace: string,
36
+ routes: Record<string, ts.Expression>,
37
+ routeExprs: readonly ts.Expression[],
38
+ ): GenerateStatus;
39
+
32
40
  generateClassContents(routes: Record<string, ts.Expression>): string;
33
41
  }
@@ -31,5 +31,14 @@ export interface HttpDataFileGeneratorContract {
31
31
  routeExprs: readonly ts.Expression[],
32
32
  ): GenerateStatus;
33
33
 
34
+ generateMergedFile(
35
+ directory: string,
36
+ className: string,
37
+ namespace: string,
38
+ routes: Record<string, ts.Expression>,
39
+ routeData: Record<string, HttpRouteData>,
40
+ routeExprs: readonly ts.Expression[],
41
+ ): GenerateStatus;
42
+
34
43
  generateClassContents(routes: Record<string, ts.Expression>, routeData: Record<string, HttpRouteData>): string;
35
44
  }