@valkyrjaio/sindri 26.1.3 → 26.3.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.
Files changed (24) hide show
  1. package/dist/bin/sindri.cjs +580 -309
  2. package/package.json +2 -2
  3. package/src/Sindri/Ast/Abstract/AstReader.ts +80 -0
  4. package/src/Sindri/Ast/CliRouteAttributeReader.ts +11 -11
  5. package/src/Sindri/Ast/ComponentProviderReader.ts +9 -5
  6. package/src/Sindri/Ast/ConfigReader.ts +14 -6
  7. package/src/Sindri/Ast/Contract/HttpRouteMiddlewareReaderContract.ts +1 -1
  8. package/src/Sindri/Ast/Data/CliRouteData.ts +1 -1
  9. package/src/Sindri/Ast/Data/Contract/CliRouteDataContract.ts +1 -1
  10. package/src/Sindri/Ast/Data/Contract/HttpRouteDataContract.ts +1 -1
  11. package/src/Sindri/Ast/Data/HttpRouteData.ts +1 -1
  12. package/src/Sindri/Ast/HttpRouteAttributeReader.ts +2 -2
  13. package/src/Sindri/Ast/HttpRouteMiddlewareReader.ts +9 -9
  14. package/src/Sindri/Ast/RouteProviderReader.ts +24 -3
  15. package/src/Sindri/Constant/SindriInfo.ts +2 -2
  16. package/src/Sindri/Generate/Abstract/GenerateDataFromAst.ts +66 -13
  17. package/src/Sindri/Generator/Abstract/AstFileGenerator.ts +13 -0
  18. package/src/Sindri/Generator/Ast/Cli/AstCliDataFileGenerator.ts +47 -11
  19. package/src/Sindri/Generator/Ast/Container/AstContainerDataFileGenerator.ts +4 -9
  20. package/src/Sindri/Generator/Ast/Event/AstEventDataFileGenerator.ts +13 -7
  21. package/src/Sindri/Generator/Ast/Http/AstHttpDataFileGenerator.ts +176 -12
  22. package/src/Sindri/Generator/Cli/Contract/CliDataFileGeneratorContract.ts +10 -0
  23. package/src/Sindri/Generator/Container/Contract/ContainerDataFileGeneratorContract.ts +3 -0
  24. package/src/Sindri/Generator/Http/Contract/HttpDataFileGeneratorContract.ts +10 -0
@@ -32,11 +32,7 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
32
32
  namespace: string,
33
33
  routes: Record<string, ts.Expression>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(routes);
42
38
 
@@ -64,6 +60,36 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
64
60
  return this.writeFile(directory, className, data);
65
61
  }
66
62
 
63
+ /**
64
+ * Generate the CLI routing data file from imperative `getRoutes()` route
65
+ * objects — each `new Route(name, description, handler, ...)` is keyed by
66
+ * its command name (the first constructor argument) and emitted verbatim.
67
+ */
68
+ public generateFileFromRoutes(
69
+ directory: string,
70
+ className: string,
71
+ namespace: string,
72
+ routeExprs: readonly ts.Expression[],
73
+ ): GenerateStatus {
74
+ const routes: Record<string, ts.Expression> = {};
75
+
76
+ for (const expr of routeExprs) {
77
+ if (!ts.isNewExpression(expr)) {
78
+ continue;
79
+ }
80
+
81
+ const nameArg = (expr.arguments ?? [])[0];
82
+
83
+ if (nameArg === undefined || !ts.isStringLiteral(nameArg)) {
84
+ continue;
85
+ }
86
+
87
+ routes[nameArg.text] = expr;
88
+ }
89
+
90
+ return this.generateFile(directory, className, namespace, routes);
91
+ }
92
+
67
93
  public generateClassContents(routes: Record<string, ts.Expression>): string {
68
94
  const entries = Object.entries(routes);
69
95
 
@@ -74,15 +100,25 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
74
100
  const lines: string[] = [];
75
101
 
76
102
  for (const [key, valueExpr] of entries) {
77
- const keyExpr = this.buildEnumCaseExpr(key);
78
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
79
- const printedValue = this.printer.printNode(ts.EmitHint.Unspecified, valueExpr, this.dummySourceFile);
80
-
81
- const formattedKey = key.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
103
+ const printedValue = this.printExpr(valueExpr);
104
+ const formattedKey = key.includes('::')
105
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(key), this.dummySourceFile)}]`
106
+ : `['${key}']`;
82
107
 
83
108
  lines.push(` ${formattedKey}: (): RouteContract => ${printedValue},`);
84
109
  }
85
110
 
86
- return ['super({', ' routes: {', ...lines, ' },', ' });'].join('\n ');
111
+ return ['super({', ...lines, ' });'].join('\n ');
112
+ }
113
+
114
+ /**
115
+ * Print an expression to source. Parsed nodes (from a `getRoutes()` body)
116
+ * are emitted via their original source text; synthesized nodes (which
117
+ * carry no source positions) are emitted through the printer.
118
+ */
119
+ protected printExpr(expr: ts.Expression): string {
120
+ return expr.pos >= 0
121
+ ? expr.getText()
122
+ : this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
87
123
  }
88
124
  }
@@ -32,11 +32,7 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
32
32
  namespace: string,
33
33
  publishers: Readonly<Record<string, readonly [string, string]>>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(publishers);
42
38
 
@@ -67,12 +63,11 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
67
63
  const lines: string[] = [];
68
64
 
69
65
  for (const [serviceId, [providerClass, methodName]] of entries) {
70
- const keyExpr = this.buildEnumCaseExpr(serviceId);
71
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
72
-
73
66
  const shortProviderClass = providerClass.slice(providerClass.lastIndexOf('\\') + 1);
74
67
 
75
- const formattedKey = serviceId.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
68
+ const formattedKey = serviceId.includes('::')
69
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(serviceId), this.dummySourceFile)}]`
70
+ : `['${serviceId}']`;
76
71
 
77
72
  lines.push(
78
73
  ` ${formattedKey}: (container: ContainerContract) => ${shortProviderClass}.${methodName}(container),`,
@@ -32,20 +32,26 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
32
32
  namespace: string,
33
33
  listeners: Record<string, ts.Expression>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(listeners);
42
38
 
39
+ // The Listener / ListenerContract imports are only referenced by the
40
+ // generated listener closures — omit them when there are no listeners
41
+ // so empty event data does not carry unused (and unresolved) imports.
42
+ const listenerImports =
43
+ Object.keys(listeners).length > 0
44
+ ? [
45
+ `import { Listener } from '@valkyrjaio/valkyrja/Event/Data/Listener.ts';`,
46
+ `import type { ListenerContract } from '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts';`,
47
+ ]
48
+ : [];
49
+
43
50
  const data = [
44
51
  '// This file was automatically generated by Sindri.',
45
52
  '',
46
53
  `import { EventData } from '@valkyrjaio/valkyrja/Event/Data/EventData.ts';`,
47
- `import { Listener } from '@valkyrjaio/valkyrja/Event/Data/Listener.ts';`,
48
- `import type { ListenerContract } from '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts';`,
54
+ ...listenerImports,
49
55
  userImportsBlock,
50
56
  `export class ${className} extends EventData {`,
51
57
  ` constructor() {`,
@@ -13,6 +13,7 @@ import { DynamicRoute } from '@valkyrjaio/valkyrja/Http/Routing/Data/DynamicRout
13
13
  import { Parameter } from '@valkyrjaio/valkyrja/Http/Routing/Data/Parameter.ts';
14
14
  import { Regex } from '@valkyrjaio/valkyrja/Http/Routing/Constant/Regex.ts';
15
15
  import { Processor } from '@valkyrjaio/valkyrja/Http/Routing/Processor/Processor.ts';
16
+ import { RequestMethod, allRequestMethods } from '@valkyrjaio/valkyrja/Http/Message/Enum/RequestMethod.ts';
16
17
 
17
18
  import { AstFileGenerator } from '../../Abstract/AstFileGenerator.ts';
18
19
  import { GeneratorUnreachableException } from '../../Throwable/Exception/GeneratorUnreachableException.ts';
@@ -22,6 +23,16 @@ import type { HttpParameterData } from '../../../Ast/Data/HttpParameterData.ts';
22
23
  import type { HttpRouteData } from '../../../Ast/Data/HttpRouteData.ts';
23
24
  import type { ProcessorContract } from '@valkyrjaio/valkyrja/Http/Routing/Processor/Contract/ProcessorContract.ts';
24
25
 
26
+ /** Metadata extracted from an imperative `new Route(...)` / `new DynamicRoute(...)` expression. */
27
+ interface ImperativeRouteMeta {
28
+ readonly expr: ts.Expression;
29
+ readonly name: string;
30
+ readonly path: string;
31
+ readonly isDynamic: boolean;
32
+ readonly regex: string;
33
+ readonly methods: readonly string[];
34
+ }
35
+
25
36
  export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDataFileGeneratorContract {
26
37
  public classImportMap: Record<string, string> = {};
27
38
 
@@ -49,18 +60,58 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
49
60
  routes: Record<string, ts.Expression>,
50
61
  routeData: Record<string, HttpRouteData>,
51
62
  ): GenerateStatus {
52
- const userImports = Object.entries(this.classImportMap)
53
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
54
- .join('\n');
55
-
56
- const userImportsBlock = userImports ? `${userImports}\n` : '';
63
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
57
64
 
58
65
  const routesContent = this.getRoutesAsContent(routes, routeData);
59
66
  const paths = this.printNestedObject(this.buildPaths(routeData));
60
67
  const dynamicPaths = this.printNestedObject(this.buildDynamicPaths(routeData));
61
68
  const regexes = this.printNestedObject(this.buildRegexes(routeData));
62
69
 
63
- const data = [
70
+ const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
71
+
72
+ return this.writeFile(directory, className, data);
73
+ }
74
+
75
+ /**
76
+ * Generate the routing data file from imperative `getRoutes()` route
77
+ * objects (as opposed to attribute-scanned routes).
78
+ *
79
+ * Each route expression is emitted verbatim as a route closure and the
80
+ * path/dynamic-path/regex lookup maps are derived from the route objects'
81
+ * own arguments, mirroring the framework's runtime `RouteCollection`
82
+ * (literal path and regex, request methods defaulting to `[HEAD, GET]`).
83
+ */
84
+ public generateFileFromRoutes(
85
+ directory: string,
86
+ className: string,
87
+ _namespace: string,
88
+ routeExprs: readonly ts.Expression[],
89
+ ): GenerateStatus {
90
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
91
+
92
+ const metas = routeExprs
93
+ .map((expr) => this.extractRouteMeta(expr))
94
+ .filter((meta): meta is ImperativeRouteMeta => meta !== undefined);
95
+
96
+ const routesContent = this.getImperativeRoutesContent(metas);
97
+ const paths = this.printNestedObject(this.buildImperativePaths(metas, false));
98
+ const dynamicPaths = this.printNestedObject(this.buildImperativePaths(metas, true));
99
+ const regexes = this.printNestedObject(this.buildImperativeRegexes(metas));
100
+
101
+ const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
102
+
103
+ return this.writeFile(directory, className, data);
104
+ }
105
+
106
+ protected assembleFile(
107
+ className: string,
108
+ userImportsBlock: string,
109
+ routesContent: string,
110
+ paths: string,
111
+ dynamicPaths: string,
112
+ regexes: string,
113
+ ): string {
114
+ return [
64
115
  '// This file was automatically generated by Sindri.',
65
116
  '',
66
117
  `import { HttpRoutingData } from '@valkyrjaio/valkyrja/Http/Routing/Data/HttpRoutingData.ts';`,
@@ -83,8 +134,6 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
83
134
  `}`,
84
135
  '',
85
136
  ].join('\n');
86
-
87
- return this.writeFile(directory, className, data);
88
137
  }
89
138
 
90
139
  public generateClassContents(
@@ -292,15 +341,130 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
292
341
  expr = ts.factory.createNewExpression(expr.expression, expr.typeArguments, args);
293
342
  }
294
343
 
295
- const keyExpr = this.buildEnumCaseExpr(key);
296
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
297
344
  const printedRoute = this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
298
-
299
- const formattedKey = key.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
345
+ const formattedKey = key.includes('::')
346
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(key), this.dummySourceFile)}]`
347
+ : `['${key}']`;
300
348
 
301
349
  lines.push(` ${formattedKey}: (): RouteContract => ${printedRoute},`);
302
350
  }
303
351
 
304
352
  return ['{', ...lines, ' }'].join('\n ');
305
353
  }
354
+
355
+ // -------------------------------------------------------------------------
356
+ // Imperative `getRoutes()` support
357
+ // -------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Extract routing metadata from a `new Route(...)` / `new DynamicRoute(...)`
361
+ * expression, matching the framework constructor argument order:
362
+ * Route(path, name, handler, requestMethods?)
363
+ * DynamicRoute(path, name, regex, parameters, handler, requestMethods?)
364
+ */
365
+ protected extractRouteMeta(expr: ts.Expression): ImperativeRouteMeta | undefined {
366
+ if (!ts.isNewExpression(expr) || !ts.isIdentifier(expr.expression)) {
367
+ return undefined;
368
+ }
369
+
370
+ const isDynamic = expr.expression.text === 'DynamicRoute';
371
+ const args = expr.arguments ?? ts.factory.createNodeArray<ts.Expression>();
372
+
373
+ const path = this.stringArg(args[0]);
374
+ const name = this.stringArg(args[1]);
375
+
376
+ if (name === undefined || path === undefined) {
377
+ return undefined;
378
+ }
379
+
380
+ const regex = isDynamic ? (this.stringArg(args[2]) ?? '') : '';
381
+ const methodsArg = isDynamic ? args[5] : args[3];
382
+ const methods = this.extractMethodNamesFromArg(methodsArg);
383
+
384
+ return { expr, name, path, isDynamic, regex, methods };
385
+ }
386
+
387
+ protected stringArg(node: ts.Expression | undefined): string | undefined {
388
+ return node !== undefined && ts.isStringLiteral(node) ? node.text : undefined;
389
+ }
390
+
391
+ /**
392
+ * Resolve a `[RequestMethod.GET, ...]` argument to method-name strings,
393
+ * defaulting to `[HEAD, GET]` (the framework `Route` default) when omitted
394
+ * and expanding `RequestMethod.ANY` to every request method.
395
+ */
396
+ protected extractMethodNamesFromArg(node: ts.Expression | undefined): string[] {
397
+ if (node === undefined || !ts.isArrayLiteralExpression(node)) {
398
+ return [RequestMethod.HEAD, RequestMethod.GET];
399
+ }
400
+
401
+ const methods: string[] = [];
402
+
403
+ for (const element of node.elements) {
404
+ if (!ts.isPropertyAccessExpression(element)) {
405
+ continue;
406
+ }
407
+
408
+ const method = element.name.text;
409
+
410
+ if (method === 'ANY') {
411
+ return allRequestMethods();
412
+ }
413
+
414
+ methods.push(method);
415
+ }
416
+
417
+ return methods;
418
+ }
419
+
420
+ protected getImperativeRoutesContent(metas: readonly ImperativeRouteMeta[]): string {
421
+ if (metas.length === 0) {
422
+ return '{}';
423
+ }
424
+
425
+ const lines: string[] = [];
426
+
427
+ for (const meta of metas) {
428
+ // Imperative route expressions come parsed from a provider's `getRoutes()`
429
+ // body, so they are emitted verbatim via their original source text.
430
+ lines.push(` ['${meta.name}']: (): RouteContract => ${meta.expr.getText()},`);
431
+ }
432
+
433
+ return ['{', ...lines, ' }'].join('\n ');
434
+ }
435
+
436
+ protected buildImperativePaths(
437
+ metas: readonly ImperativeRouteMeta[],
438
+ dynamic: boolean,
439
+ ): Record<string, Record<string, string>> {
440
+ const paths: Record<string, Record<string, string>> = {};
441
+
442
+ for (const meta of metas) {
443
+ if (meta.isDynamic !== dynamic) {
444
+ continue;
445
+ }
446
+
447
+ for (const method of meta.methods) {
448
+ (paths[method] ??= {})[meta.path] = meta.name;
449
+ }
450
+ }
451
+
452
+ return paths;
453
+ }
454
+
455
+ protected buildImperativeRegexes(metas: readonly ImperativeRouteMeta[]): Record<string, Record<string, string>> {
456
+ const regexes: Record<string, Record<string, string>> = {};
457
+
458
+ for (const meta of metas) {
459
+ if (!meta.isDynamic || meta.regex === '') {
460
+ continue;
461
+ }
462
+
463
+ for (const method of meta.methods) {
464
+ (regexes[method] ??= {})[meta.regex] = meta.name;
465
+ }
466
+ }
467
+
468
+ return regexes;
469
+ }
306
470
  }
@@ -12,6 +12,9 @@ import type { ts } from 'ts-morph';
12
12
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
13
13
 
14
14
  export interface CliDataFileGeneratorContract {
15
+ /** Map of class name → module specifier for the provider imports the data cache references. */
16
+ classImportMap: Record<string, string>;
17
+
15
18
  generateFile(
16
19
  directory: string,
17
20
  className: string,
@@ -19,5 +22,12 @@ export interface CliDataFileGeneratorContract {
19
22
  routes: Record<string, ts.Expression>,
20
23
  ): GenerateStatus;
21
24
 
25
+ generateFileFromRoutes(
26
+ directory: string,
27
+ className: string,
28
+ namespace: string,
29
+ routeExprs: readonly ts.Expression[],
30
+ ): GenerateStatus;
31
+
22
32
  generateClassContents(routes: Record<string, ts.Expression>): string;
23
33
  }
@@ -10,6 +10,9 @@
10
10
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
11
11
 
12
12
  export interface ContainerDataFileGeneratorContract {
13
+ /** Map of class name → module specifier for the provider imports the data cache references. */
14
+ classImportMap: Record<string, string>;
15
+
13
16
  generateFile(
14
17
  directory: string,
15
18
  className: string,
@@ -13,6 +13,9 @@ import type { HttpRouteData } from '../../../Ast/Data/HttpRouteData.ts';
13
13
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
14
14
 
15
15
  export interface HttpDataFileGeneratorContract {
16
+ /** Map of class name → module specifier for the provider imports the data cache references. */
17
+ classImportMap: Record<string, string>;
18
+
16
19
  generateFile(
17
20
  directory: string,
18
21
  className: string,
@@ -21,5 +24,12 @@ export interface HttpDataFileGeneratorContract {
21
24
  routeData: Record<string, HttpRouteData>,
22
25
  ): GenerateStatus;
23
26
 
27
+ generateFileFromRoutes(
28
+ directory: string,
29
+ className: string,
30
+ namespace: string,
31
+ routeExprs: readonly ts.Expression[],
32
+ ): GenerateStatus;
33
+
24
34
  generateClassContents(routes: Record<string, ts.Expression>, routeData: Record<string, HttpRouteData>): string;
25
35
  }