@valkyrjaio/sindri 26.2.0 → 26.4.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.
@@ -211,6 +211,157 @@ export abstract class GenerateDataFromAst {
211
211
  return '';
212
212
  }
213
213
 
214
+ /**
215
+ * Build the `import { X } from '...'` specifier for a class file, relative
216
+ * to the generated data file's output directory. Used to import the
217
+ * provider/handler classes referenced by the generated data caches.
218
+ */
219
+ protected relativeSpecifier(fromDir: string, toFile: string): string {
220
+ const relative = path.relative(fromDir, toFile).split(path.sep).join('/');
221
+
222
+ return relative.startsWith('.') ? relative : `./${relative}`;
223
+ }
224
+
225
+ /**
226
+ * Build the specifier a generated data file should import a class file by.
227
+ *
228
+ * Application source is imported by a path relative to the data directory;
229
+ * a file belonging to an installed package is imported by that package's
230
+ * own public specifier — so the framework classes the cache references are
231
+ * written exactly as hand-written application code writes them
232
+ * (`@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts`), never as a brittle
233
+ * reach into `node_modules`.
234
+ */
235
+ protected importSpecifier(fromDir: string, toFile: string): string {
236
+ return this.packageSpecifier(toFile) ?? this.relativeSpecifier(fromDir, toFile);
237
+ }
238
+
239
+ /**
240
+ * Map an absolute path inside an installed package back to the specifier
241
+ * that package publicly exposes it as, or undefined when the file is not
242
+ * part of one.
243
+ *
244
+ * The package's `exports` map is inverted: for `"./*.ts": "./src/*.ts"` a
245
+ * file at `<package>/src/Foo.ts` maps back to `<name>/Foo.ts`. Packages
246
+ * with no matching pattern fall back to the path as written under the
247
+ * package root, which is what a package without an `exports` map exposes.
248
+ */
249
+ protected packageSpecifier(filePath: string): string | undefined {
250
+ const normalized = filePath.split(path.sep).join('/');
251
+ const marker = normalized.lastIndexOf('/node_modules/');
252
+
253
+ if (marker === -1) {
254
+ return undefined;
255
+ }
256
+
257
+ const subject = normalized.substring(marker + '/node_modules/'.length);
258
+ const segments = subject.split('/');
259
+ const nameLength = subject.startsWith('@') ? 2 : 1;
260
+
261
+ if (segments.length <= nameLength) {
262
+ return undefined;
263
+ }
264
+
265
+ const packageName = segments.slice(0, nameLength).join('/');
266
+ const packageDir = normalized.substring(0, marker + '/node_modules/'.length) + packageName;
267
+ const relative = segments.slice(nameLength).join('/');
268
+
269
+ return `${packageName}/${this.exportedSubpath(packageDir, relative) ?? relative}`;
270
+ }
271
+
272
+ /**
273
+ * Invert a package's `exports` patterns to find the subpath a file inside
274
+ * it is exposed as, or undefined when no pattern matches it.
275
+ */
276
+ protected exportedSubpath(packageDir: string, relative: string): string | undefined {
277
+ for (const [key, target] of Object.entries(this.readPackageExports(packageDir))) {
278
+ const star = key.indexOf('*');
279
+ const targetStar = target.indexOf('*');
280
+
281
+ if (star === -1 || targetStar === -1) {
282
+ continue;
283
+ }
284
+
285
+ const prefix = target.substring(0, targetStar).replace(/^\.\//, '');
286
+ const suffix = target.substring(targetStar + 1);
287
+
288
+ if (!relative.startsWith(prefix) || !relative.endsWith(suffix)) {
289
+ continue;
290
+ }
291
+
292
+ const value = relative.substring(prefix.length, relative.length - suffix.length);
293
+
294
+ return (key.substring(0, star) + value + key.substring(star + 1)).replace(/^\.\//, '');
295
+ }
296
+
297
+ return undefined;
298
+ }
299
+
300
+ /**
301
+ * Read a package's `exports` map, flattened to subpath → file target.
302
+ *
303
+ * Conditional targets are collapsed to their first string value; every
304
+ * condition a package declares for a subpath points at the same source
305
+ * file in the layouts sindri generates for.
306
+ */
307
+ protected readPackageExports(packageDir: string): Record<string, string> {
308
+ const manifestPath = path.join(packageDir, 'package.json');
309
+
310
+ if (!fs.existsSync(manifestPath)) {
311
+ return {};
312
+ }
313
+
314
+ let manifest: { exports?: unknown };
315
+
316
+ try {
317
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { exports?: unknown };
318
+ } catch {
319
+ return {};
320
+ }
321
+
322
+ const exports = manifest.exports;
323
+
324
+ if (typeof exports !== 'object' || exports === null) {
325
+ return {};
326
+ }
327
+
328
+ const flattened: Record<string, string> = {};
329
+
330
+ for (const [subpath, target] of Object.entries(exports as Record<string, unknown>)) {
331
+ const resolved = this.firstStringTarget(target);
332
+
333
+ if (resolved !== undefined) {
334
+ flattened[subpath] = resolved;
335
+ }
336
+ }
337
+
338
+ return flattened;
339
+ }
340
+
341
+ /**
342
+ * Reduce an `exports` target — a string, or a nested map of conditions — to
343
+ * the first file path it names.
344
+ */
345
+ protected firstStringTarget(target: unknown): string | undefined {
346
+ if (typeof target === 'string') {
347
+ return target;
348
+ }
349
+
350
+ if (typeof target !== 'object' || target === null) {
351
+ return undefined;
352
+ }
353
+
354
+ for (const value of Object.values(target as Record<string, unknown>)) {
355
+ const resolved = this.firstStringTarget(value);
356
+
357
+ if (resolved !== undefined) {
358
+ return resolved;
359
+ }
360
+ }
361
+
362
+ return undefined;
363
+ }
364
+
214
365
  protected generateContainerData(
215
366
  serviceProviders: readonly string[],
216
367
  config: ConfigResult,
@@ -221,6 +372,7 @@ export abstract class GenerateDataFromAst {
221
372
  .writeMessages();
222
373
 
223
374
  const publishers: Record<string, readonly [string, string]> = {};
375
+ const importMap: Record<string, string> = {};
224
376
 
225
377
  for (const providerClass of serviceProviders) {
226
378
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -231,9 +383,17 @@ export abstract class GenerateDataFromAst {
231
383
 
232
384
  const result = this.serviceProviderReader.readFile(filePath);
233
385
 
386
+ if (Object.keys(result.publishers).length > 0) {
387
+ // The publisher callbacks reference the provider's static methods,
388
+ // so the generated data cache must import the provider class.
389
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
390
+ }
391
+
234
392
  Object.assign(publishers, result.publishers);
235
393
  }
236
394
 
395
+ this.containerGenerator.classImportMap = importMap;
396
+
237
397
  const status = this.containerGenerator.generateFile(
238
398
  config.dataPath,
239
399
  'AppContainerData',
@@ -297,6 +457,8 @@ export abstract class GenerateDataFromAst {
297
457
  .writeMessages();
298
458
 
299
459
  const allRoutes: Record<string, ts.Expression> = {};
460
+ const imperativeRoutes: ts.Expression[] = [];
461
+ const importMap: Record<string, string> = {};
300
462
 
301
463
  for (const providerClass of cliRouteProviders) {
302
464
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -307,6 +469,19 @@ export abstract class GenerateDataFromAst {
307
469
 
308
470
  const providerResult = this.routeProviderReader.readFile(filePath);
309
471
 
472
+ if (providerResult.routes.length > 0) {
473
+ // Imperative routes reference the provider's static handlers,
474
+ // so the generated data cache must import the provider class —
475
+ // along with every parameter/constant class the route arguments
476
+ // themselves name, since those are emitted verbatim.
477
+ imperativeRoutes.push(...providerResult.routes);
478
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
479
+
480
+ for (const [name, importPath] of Object.entries(providerResult.routeImports)) {
481
+ importMap[name] = this.importSpecifier(config.dataPath, importPath);
482
+ }
483
+ }
484
+
310
485
  for (const controllerClass of providerResult.controllerClasses) {
311
486
  const controllerPath = this.fqnToFilePath(controllerClass, config.namespace, config.dir);
312
487
 
@@ -320,12 +495,17 @@ export abstract class GenerateDataFromAst {
320
495
  }
321
496
  }
322
497
 
323
- const status = this.cliGenerator.generateFile(
324
- config.dataPath,
325
- 'AppCliRoutingData',
326
- config.dataNamespace,
327
- allRoutes,
328
- );
498
+ this.cliGenerator.classImportMap = importMap;
499
+
500
+ const status =
501
+ imperativeRoutes.length > 0
502
+ ? this.cliGenerator.generateFileFromRoutes(
503
+ config.dataPath,
504
+ 'AppCliRoutingData',
505
+ config.dataNamespace,
506
+ imperativeRoutes,
507
+ )
508
+ : this.cliGenerator.generateFile(config.dataPath, 'AppCliRoutingData', config.dataNamespace, allRoutes);
329
509
 
330
510
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
331
511
  }
@@ -341,6 +521,8 @@ export abstract class GenerateDataFromAst {
341
521
 
342
522
  const allRoutes: Record<string, ts.Expression> = {};
343
523
  const allRouteData: Record<string, HttpRouteData> = {};
524
+ const imperativeRoutes: ts.Expression[] = [];
525
+ const importMap: Record<string, string> = {};
344
526
 
345
527
  for (const providerClass of httpRouteProviders) {
346
528
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -351,6 +533,19 @@ export abstract class GenerateDataFromAst {
351
533
 
352
534
  const providerResult = this.routeProviderReader.readFile(filePath);
353
535
 
536
+ if (providerResult.routes.length > 0) {
537
+ // Imperative routes reference the provider's static handlers,
538
+ // so the generated data cache must import the provider class —
539
+ // along with every parameter/constant class the route arguments
540
+ // themselves name, since those are emitted verbatim.
541
+ imperativeRoutes.push(...providerResult.routes);
542
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
543
+
544
+ for (const [name, importPath] of Object.entries(providerResult.routeImports)) {
545
+ importMap[name] = this.importSpecifier(config.dataPath, importPath);
546
+ }
547
+ }
548
+
354
549
  for (const controllerClass of providerResult.controllerClasses) {
355
550
  const controllerPath = this.fqnToFilePath(controllerClass, config.namespace, config.dir);
356
551
 
@@ -365,13 +560,23 @@ export abstract class GenerateDataFromAst {
365
560
  }
366
561
  }
367
562
 
368
- const status = this.httpGenerator.generateFile(
369
- config.dataPath,
370
- 'AppHttpRoutingData',
371
- config.dataNamespace,
372
- allRoutes,
373
- allRouteData,
374
- );
563
+ this.httpGenerator.classImportMap = importMap;
564
+
565
+ const status =
566
+ imperativeRoutes.length > 0
567
+ ? this.httpGenerator.generateFileFromRoutes(
568
+ config.dataPath,
569
+ 'AppHttpRoutingData',
570
+ config.dataNamespace,
571
+ imperativeRoutes,
572
+ )
573
+ : this.httpGenerator.generateFile(
574
+ config.dataPath,
575
+ 'AppHttpRoutingData',
576
+ config.dataNamespace,
577
+ allRoutes,
578
+ allRouteData,
579
+ );
375
580
 
376
581
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
377
582
  }
@@ -31,6 +31,33 @@ export abstract class AstFileGenerator {
31
31
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(className), caseName);
32
32
  }
33
33
 
34
+ /**
35
+ * Build the `import { X } from '...';` block for the provider/handler classes
36
+ * a generated data cache references, followed by a trailing newline (or an
37
+ * empty string when there are no such imports).
38
+ *
39
+ * Names the generated file's fixed framework header already binds are
40
+ * dropped: those are the same framework classes under the same names, and
41
+ * emitting them twice is a duplicate-identifier error.
42
+ */
43
+ protected buildUserImportsBlock(classImportMap: Record<string, string>, reservedNames: readonly string[]): string {
44
+ const userImports = Object.entries(classImportMap)
45
+ .filter(([name]) => !reservedNames.includes(name))
46
+ .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
47
+ .join('\n');
48
+
49
+ return userImports ? `${userImports}\n` : '';
50
+ }
51
+
52
+ /**
53
+ * Render the fixed framework imports a generated data file always carries.
54
+ */
55
+ protected buildFrameworkImportLines(imports: Readonly<Record<string, string>>, isType: boolean = false): string[] {
56
+ const keyword = isType ? 'import type' : 'import';
57
+
58
+ return Object.entries(imports).map(([name, specifier]) => `${keyword} { ${name} } from '${specifier}';`);
59
+ }
60
+
34
61
  protected writeFile(directory: string, className: string, data: string): GenerateStatus {
35
62
  const filePath = directory.replace(/\/$/, '') + `/${className}.ts`;
36
63
 
@@ -14,6 +14,23 @@ import type { CliDataFileGeneratorContract } from '../../Cli/Contract/CliDataFil
14
14
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
15
15
 
16
16
  export class AstCliDataFileGenerator extends AstFileGenerator implements CliDataFileGeneratorContract {
17
+ /** The framework classes every generated CLI data file imports as values. */
18
+ protected static readonly FRAMEWORK_IMPORTS: Readonly<Record<string, string>> = {
19
+ CliRoutingData: '@valkyrjaio/valkyrja/Cli/Routing/Data/CliRoutingData.ts',
20
+ Route: '@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts',
21
+ ArgumentParameter: '@valkyrjaio/valkyrja/Cli/Routing/Data/ArgumentParameter.ts',
22
+ OptionParameter: '@valkyrjaio/valkyrja/Cli/Routing/Data/OptionParameter.ts',
23
+ ArgumentMode: '@valkyrjaio/valkyrja/Cli/Routing/Enum/ArgumentMode.ts',
24
+ ArgumentValueMode: '@valkyrjaio/valkyrja/Cli/Routing/Enum/ArgumentValueMode.ts',
25
+ OptionMode: '@valkyrjaio/valkyrja/Cli/Routing/Enum/OptionMode.ts',
26
+ OptionValueMode: '@valkyrjaio/valkyrja/Cli/Routing/Enum/OptionValueMode.ts',
27
+ };
28
+
29
+ /** The framework types every generated CLI data file imports. */
30
+ protected static readonly FRAMEWORK_TYPE_IMPORTS: Readonly<Record<string, string>> = {
31
+ RouteContract: '@valkyrjaio/valkyrja/Cli/Routing/Data/Contract/RouteContract.ts',
32
+ };
33
+
17
34
  public classImportMap: Record<string, string> = {};
18
35
 
19
36
  private readonly printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
@@ -32,26 +49,18 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
32
49
  namespace: string,
33
50
  routes: Record<string, ts.Expression>,
34
51
  ): 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` : '';
52
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
53
+ ...Object.keys(AstCliDataFileGenerator.FRAMEWORK_IMPORTS),
54
+ ...Object.keys(AstCliDataFileGenerator.FRAMEWORK_TYPE_IMPORTS),
55
+ ]);
40
56
 
41
57
  const contents = this.generateClassContents(routes);
42
58
 
43
59
  const data = [
44
60
  '// This file was automatically generated by Sindri.',
45
61
  '',
46
- `import { CliRoutingData } from '@valkyrjaio/valkyrja/Cli/Routing/Data/CliRoutingData.ts';`,
47
- `import { Route } from '@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts';`,
48
- `import { ArgumentParameter } from '@valkyrjaio/valkyrja/Cli/Routing/Data/ArgumentParameter.ts';`,
49
- `import { OptionParameter } from '@valkyrjaio/valkyrja/Cli/Routing/Data/OptionParameter.ts';`,
50
- `import { ArgumentMode } from '@valkyrjaio/valkyrja/Cli/Routing/Enum/ArgumentMode.ts';`,
51
- `import { ArgumentValueMode } from '@valkyrjaio/valkyrja/Cli/Routing/Enum/ArgumentValueMode.ts';`,
52
- `import { OptionMode } from '@valkyrjaio/valkyrja/Cli/Routing/Enum/OptionMode.ts';`,
53
- `import { OptionValueMode } from '@valkyrjaio/valkyrja/Cli/Routing/Enum/OptionValueMode.ts';`,
54
- `import type { RouteContract } from '@valkyrjaio/valkyrja/Cli/Routing/Data/Contract/RouteContract.ts';`,
62
+ ...this.buildFrameworkImportLines(AstCliDataFileGenerator.FRAMEWORK_IMPORTS),
63
+ ...this.buildFrameworkImportLines(AstCliDataFileGenerator.FRAMEWORK_TYPE_IMPORTS, true),
55
64
  userImportsBlock,
56
65
  `export class ${className} extends CliRoutingData {`,
57
66
  ` constructor() {`,
@@ -64,6 +73,61 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
64
73
  return this.writeFile(directory, className, data);
65
74
  }
66
75
 
76
+ /**
77
+ * Generate the CLI routing data file from imperative `getRoutes()` route
78
+ * objects — each `new Route(name, description, handler, ...)` is keyed by
79
+ * its command name (the first constructor argument) and emitted verbatim.
80
+ */
81
+ public generateFileFromRoutes(
82
+ directory: string,
83
+ className: string,
84
+ namespace: string,
85
+ routeExprs: readonly ts.Expression[],
86
+ ): GenerateStatus {
87
+ const routes: Record<string, ts.Expression> = {};
88
+
89
+ for (const expr of routeExprs) {
90
+ if (!ts.isNewExpression(expr)) {
91
+ continue;
92
+ }
93
+
94
+ const key = this.routeKey((expr.arguments ?? [])[0]);
95
+
96
+ if (key === undefined) {
97
+ continue;
98
+ }
99
+
100
+ routes[key] = expr;
101
+ }
102
+
103
+ return this.generateFile(directory, className, namespace, routes);
104
+ }
105
+
106
+ /**
107
+ * Derive the map key for a route from its name argument.
108
+ *
109
+ * A literal name keys the map by its own text. A constant reference
110
+ * (`CliCommandName.LIST` — how the framework's own commands name
111
+ * themselves) is kept as a `Class::CASE` marker, so the generated file
112
+ * emits the constant reference as a computed key rather than baking in the
113
+ * value it happens to hold today.
114
+ */
115
+ protected routeKey(nameArg: ts.Expression | undefined): string | undefined {
116
+ if (nameArg === undefined) {
117
+ return undefined;
118
+ }
119
+
120
+ if (ts.isStringLiteral(nameArg)) {
121
+ return nameArg.text;
122
+ }
123
+
124
+ if (ts.isPropertyAccessExpression(nameArg) && ts.isIdentifier(nameArg.expression)) {
125
+ return `${nameArg.expression.text}::${nameArg.name.text}`;
126
+ }
127
+
128
+ return undefined;
129
+ }
130
+
67
131
  public generateClassContents(routes: Record<string, ts.Expression>): string {
68
132
  const entries = Object.entries(routes);
69
133
 
@@ -74,15 +138,25 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
74
138
  const lines: string[] = [];
75
139
 
76
140
  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}']`;
141
+ const printedValue = this.printExpr(valueExpr);
142
+ const formattedKey = key.includes('::')
143
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(key), this.dummySourceFile)}]`
144
+ : `['${key}']`;
82
145
 
83
146
  lines.push(` ${formattedKey}: (): RouteContract => ${printedValue},`);
84
147
  }
85
148
 
86
- return ['super({', ' routes: {', ...lines, ' },', ' });'].join('\n ');
149
+ return ['super({', ...lines, ' });'].join('\n ');
150
+ }
151
+
152
+ /**
153
+ * Print an expression to source. Parsed nodes (from a `getRoutes()` body)
154
+ * are emitted via their original source text; synthesized nodes (which
155
+ * carry no source positions) are emitted through the printer.
156
+ */
157
+ protected printExpr(expr: ts.Expression): string {
158
+ return expr.pos >= 0
159
+ ? expr.getText()
160
+ : this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
87
161
  }
88
162
  }
@@ -14,6 +14,16 @@ import type { ContainerDataFileGeneratorContract } from '../../Container/Contrac
14
14
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
15
15
 
16
16
  export class AstContainerDataFileGenerator extends AstFileGenerator implements ContainerDataFileGeneratorContract {
17
+ /** The framework class every generated container data file imports as a value. */
18
+ protected static readonly FRAMEWORK_IMPORTS: Readonly<Record<string, string>> = {
19
+ ContainerData: '@valkyrjaio/valkyrja/Container/Data/ContainerData.ts',
20
+ };
21
+
22
+ /** The framework type every generated container data file imports. */
23
+ protected static readonly FRAMEWORK_TYPE_IMPORTS: Readonly<Record<string, string>> = {
24
+ ContainerContract: '@valkyrjaio/valkyrja/Container/Manager/Contract/ContainerContract.ts',
25
+ };
26
+
17
27
  public classImportMap: Record<string, string> = {};
18
28
 
19
29
  private readonly printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
@@ -32,19 +42,18 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
32
42
  namespace: string,
33
43
  publishers: Readonly<Record<string, readonly [string, string]>>,
34
44
  ): 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` : '';
45
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
46
+ ...Object.keys(AstContainerDataFileGenerator.FRAMEWORK_IMPORTS),
47
+ ...Object.keys(AstContainerDataFileGenerator.FRAMEWORK_TYPE_IMPORTS),
48
+ ]);
40
49
 
41
50
  const contents = this.generateClassContents(publishers);
42
51
 
43
52
  const data = [
44
53
  '// This file was automatically generated by Sindri.',
45
54
  '',
46
- `import { ContainerData } from '@valkyrjaio/valkyrja/Container/Data/ContainerData.ts';`,
47
- `import type { ContainerContract } from '@valkyrjaio/valkyrja/Container/Manager/Contract/ContainerContract.ts';`,
55
+ ...this.buildFrameworkImportLines(AstContainerDataFileGenerator.FRAMEWORK_IMPORTS),
56
+ ...this.buildFrameworkImportLines(AstContainerDataFileGenerator.FRAMEWORK_TYPE_IMPORTS, true),
48
57
  userImportsBlock,
49
58
  `export class ${className} extends ContainerData {`,
50
59
  ` constructor() {`,
@@ -67,12 +76,11 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
67
76
  const lines: string[] = [];
68
77
 
69
78
  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
79
  const shortProviderClass = providerClass.slice(providerClass.lastIndexOf('\\') + 1);
74
80
 
75
- const formattedKey = serviceId.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
81
+ const formattedKey = serviceId.includes('::')
82
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(serviceId), this.dummySourceFile)}]`
83
+ : `['${serviceId}']`;
76
84
 
77
85
  lines.push(
78
86
  ` ${formattedKey}: (container: ContainerContract) => ${shortProviderClass}.${methodName}(container),`,
@@ -14,6 +14,21 @@ import type { EventDataFileGeneratorContract } from '../../Event/Contract/EventD
14
14
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
15
15
 
16
16
  export class AstEventDataFileGenerator extends AstFileGenerator implements EventDataFileGeneratorContract {
17
+ /** The framework class every generated event data file imports as a value. */
18
+ protected static readonly FRAMEWORK_IMPORTS: Readonly<Record<string, string>> = {
19
+ EventData: '@valkyrjaio/valkyrja/Event/Data/EventData.ts',
20
+ };
21
+
22
+ /** The framework classes a generated event data file imports only when it holds listeners. */
23
+ protected static readonly LISTENER_IMPORTS: Readonly<Record<string, string>> = {
24
+ Listener: '@valkyrjaio/valkyrja/Event/Data/Listener.ts',
25
+ };
26
+
27
+ /** The framework types a generated event data file imports only when it holds listeners. */
28
+ protected static readonly LISTENER_TYPE_IMPORTS: Readonly<Record<string, string>> = {
29
+ ListenerContract: '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts',
30
+ };
31
+
17
32
  public classImportMap: Record<string, string> = {};
18
33
 
19
34
  private readonly printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
@@ -32,20 +47,30 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
32
47
  namespace: string,
33
48
  listeners: Record<string, ts.Expression>,
34
49
  ): 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` : '';
50
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
51
+ ...Object.keys(AstEventDataFileGenerator.FRAMEWORK_IMPORTS),
52
+ ...Object.keys(AstEventDataFileGenerator.LISTENER_IMPORTS),
53
+ ...Object.keys(AstEventDataFileGenerator.LISTENER_TYPE_IMPORTS),
54
+ ]);
40
55
 
41
56
  const contents = this.generateClassContents(listeners);
42
57
 
58
+ // The Listener / ListenerContract imports are only referenced by the
59
+ // generated listener closures — omit them when there are no listeners
60
+ // so empty event data does not carry unused (and unresolved) imports.
61
+ const listenerImports =
62
+ Object.keys(listeners).length > 0
63
+ ? [
64
+ ...this.buildFrameworkImportLines(AstEventDataFileGenerator.LISTENER_IMPORTS),
65
+ ...this.buildFrameworkImportLines(AstEventDataFileGenerator.LISTENER_TYPE_IMPORTS, true),
66
+ ]
67
+ : [];
68
+
43
69
  const data = [
44
70
  '// This file was automatically generated by Sindri.',
45
71
  '',
46
- `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';`,
72
+ ...this.buildFrameworkImportLines(AstEventDataFileGenerator.FRAMEWORK_IMPORTS),
73
+ ...listenerImports,
49
74
  userImportsBlock,
50
75
  `export class ${className} extends EventData {`,
51
76
  ` constructor() {`,