@valkyrjaio/sindri 26.3.0 → 26.4.1
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/dist/bin/sindri.cjs +447 -107
- package/package.json +2 -2
- package/src/Sindri/Ast/Abstract/AstReader.ts +170 -29
- package/src/Sindri/Ast/Data/Result/RouteProviderResult.ts +7 -0
- package/src/Sindri/Ast/RouteProviderReader.ts +31 -1
- package/src/Sindri/Constant/SindriInfo.ts +2 -2
- package/src/Sindri/Generate/Abstract/GenerateDataFromAst.ts +157 -5
- package/src/Sindri/Generator/Abstract/AstFileGenerator.ts +15 -1
- package/src/Sindri/Generator/Ast/Cli/AstCliDataFileGenerator.ts +51 -13
- package/src/Sindri/Generator/Ast/Container/AstContainerDataFileGenerator.ts +16 -3
- package/src/Sindri/Generator/Ast/Event/AstEventDataFileGenerator.ts +23 -4
- package/src/Sindri/Generator/Ast/Http/AstHttpDataFileGenerator.ts +25 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valkyrjaio/sindri",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.4.1",
|
|
4
4
|
"description": "The Valkyrja Sindri build tool.",
|
|
5
5
|
"homepage": "https://www.valkyrja.io/sindri",
|
|
6
6
|
"keywords": [
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@valkyrjaio/valkyrja": "^26.
|
|
41
|
+
"@valkyrjaio/valkyrja": "^26.4.1",
|
|
42
42
|
"ts-morph": "^28.0.0"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
@@ -13,12 +13,12 @@ import * as path from 'path';
|
|
|
13
13
|
import {
|
|
14
14
|
ClassDeclaration,
|
|
15
15
|
Decorator,
|
|
16
|
+
Expression,
|
|
16
17
|
MethodDeclaration,
|
|
17
18
|
Node,
|
|
18
19
|
ParameterDeclaration,
|
|
19
20
|
Project,
|
|
20
21
|
SourceFile,
|
|
21
|
-
SyntaxKind,
|
|
22
22
|
ts,
|
|
23
23
|
} from 'ts-morph';
|
|
24
24
|
|
|
@@ -34,6 +34,20 @@ import { HandlerData } from '../Data/HandlerData.ts';
|
|
|
34
34
|
* All methods are protected so subclasses can override any step of the pipeline.
|
|
35
35
|
*/
|
|
36
36
|
export abstract class AstReader {
|
|
37
|
+
/**
|
|
38
|
+
* Module resolution settings for {@link resolveModuleSpecifierToFilePath}.
|
|
39
|
+
*
|
|
40
|
+
* NodeNext mirrors how the application itself resolves its imports, and
|
|
41
|
+
* `allowImportingTsExtensions` matches the framework convention of writing
|
|
42
|
+
* the `.ts` extension in every import specifier.
|
|
43
|
+
*/
|
|
44
|
+
protected static readonly MODULE_RESOLUTION_OPTIONS: ts.CompilerOptions = {
|
|
45
|
+
module: ts.ModuleKind.NodeNext,
|
|
46
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
47
|
+
allowImportingTsExtensions: true,
|
|
48
|
+
noEmit: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
protected parseFileToSourceFile(filePath: string): SourceFile {
|
|
38
52
|
if (!fs.existsSync(filePath)) {
|
|
39
53
|
throw new AstFileReadException(`Cannot read file '${filePath}'.`);
|
|
@@ -98,14 +112,44 @@ export abstract class AstReader {
|
|
|
98
112
|
return '';
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
return this.resolveModuleSpecifierToFilePath(moduleSpecifier, currentFilePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a module specifier to the absolute path of the TypeScript file it points at.
|
|
120
|
+
*
|
|
121
|
+
* Relative specifiers resolve against the importing file's directory. Bare
|
|
122
|
+
* package specifiers (`@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts`) go
|
|
123
|
+
* through the TypeScript compiler's NodeNext resolver, which honors the
|
|
124
|
+
* package's `exports` map — so a provider tree that reaches into an
|
|
125
|
+
* installed package (the framework's own component providers, reached from
|
|
126
|
+
* the app config) is walked exactly like application source.
|
|
127
|
+
*
|
|
128
|
+
* Returns '' when the specifier cannot be resolved to an existing `.ts`
|
|
129
|
+
* source file. A package shipping only compiled `.js` — or only `.d.ts`
|
|
130
|
+
* declarations, which carry no `getRoutes()` body to read — has nothing for
|
|
131
|
+
* the AST readers to walk.
|
|
132
|
+
*/
|
|
133
|
+
protected resolveModuleSpecifierToFilePath(moduleSpecifier: string, currentFilePath: string): string {
|
|
134
|
+
if (moduleSpecifier.startsWith('.')) {
|
|
135
|
+
const dir = path.dirname(currentFilePath);
|
|
136
|
+
const resolved = path.resolve(dir, moduleSpecifier.replace(/\.js$/, '.ts'));
|
|
137
|
+
|
|
138
|
+
return fs.existsSync(resolved) ? resolved : '';
|
|
103
139
|
}
|
|
104
140
|
|
|
105
|
-
const
|
|
106
|
-
|
|
141
|
+
const resolved = ts.resolveModuleName(
|
|
142
|
+
moduleSpecifier,
|
|
143
|
+
currentFilePath,
|
|
144
|
+
AstReader.MODULE_RESOLUTION_OPTIONS,
|
|
145
|
+
ts.sys,
|
|
146
|
+
).resolvedModule?.resolvedFileName;
|
|
147
|
+
|
|
148
|
+
if (resolved === undefined || !resolved.endsWith('.ts') || resolved.endsWith('.d.ts')) {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
107
151
|
|
|
108
|
-
return
|
|
152
|
+
return resolved;
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
/**
|
|
@@ -221,6 +265,42 @@ export abstract class AstReader {
|
|
|
221
265
|
return paths;
|
|
222
266
|
}
|
|
223
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Collect every identifier an expression references as a value.
|
|
270
|
+
*
|
|
271
|
+
* Expressions taken from a provider's `getRoutes()` body are emitted into
|
|
272
|
+
* the generated data cache verbatim, so the cache must import everything
|
|
273
|
+
* they name — not just the provider class, but the parameter/enum/constant
|
|
274
|
+
* classes the route arguments reach for. Property *names* are skipped (the
|
|
275
|
+
* `HELP` of `CommandName.HELP` is not an import) as are type positions,
|
|
276
|
+
* which erase at runtime and must never become a value import.
|
|
277
|
+
*/
|
|
278
|
+
protected collectReferencedIdentifiers(node: ts.Node, names: Set<string> = new Set<string>()): Set<string> {
|
|
279
|
+
if (ts.isTypeNode(node)) {
|
|
280
|
+
return names;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (ts.isIdentifier(node)) {
|
|
284
|
+
names.add(node.text);
|
|
285
|
+
|
|
286
|
+
return names;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
290
|
+
return this.collectReferencedIdentifiers(node.expression, names);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (ts.isPropertyAssignment(node)) {
|
|
294
|
+
return this.collectReferencedIdentifiers(node.initializer, names);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
ts.forEachChild(node, (child: ts.Node): void => {
|
|
298
|
+
this.collectReferencedIdentifiers(child, names);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return names;
|
|
302
|
+
}
|
|
303
|
+
|
|
224
304
|
/**
|
|
225
305
|
* Extract service ID strings from keys of an object-returning method.
|
|
226
306
|
*
|
|
@@ -376,7 +456,7 @@ export abstract class AstReader {
|
|
|
376
456
|
}
|
|
377
457
|
|
|
378
458
|
if (ts.isIdentifier(node)) {
|
|
379
|
-
return this.
|
|
459
|
+
return this.resolveConstant(node.text, useMap, currentFilePath);
|
|
380
460
|
}
|
|
381
461
|
|
|
382
462
|
if (ts.isPropertyAccessExpression(node)) {
|
|
@@ -391,47 +471,108 @@ export abstract class AstReader {
|
|
|
391
471
|
}
|
|
392
472
|
|
|
393
473
|
/**
|
|
394
|
-
* Resolve a static property value by reading the
|
|
474
|
+
* Resolve a static property value by reading the file that declares its class.
|
|
395
475
|
*/
|
|
396
476
|
protected resolveStaticProperty(
|
|
397
477
|
className: string,
|
|
398
|
-
propName: string
|
|
478
|
+
propName: string,
|
|
399
479
|
useMap: Record<string, string>,
|
|
400
480
|
currentFilePath: string,
|
|
401
481
|
): string | undefined {
|
|
402
|
-
const
|
|
482
|
+
const declaringClass = this.parseDeclaringSourceFile(className, useMap, currentFilePath)?.getClass(className);
|
|
403
483
|
|
|
404
|
-
if (
|
|
484
|
+
if (declaringClass === undefined) {
|
|
405
485
|
return undefined;
|
|
406
486
|
}
|
|
407
487
|
|
|
408
|
-
|
|
409
|
-
const project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
|
|
410
|
-
const importedSource = project.addSourceFileAtPath(importedFilePath);
|
|
411
|
-
const importedClass = importedSource.getClass(className);
|
|
488
|
+
const prop = declaringClass.getStaticProperty(propName);
|
|
412
489
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
490
|
+
if (prop === undefined || !Node.isPropertyDeclaration(prop)) {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
416
493
|
|
|
417
|
-
|
|
494
|
+
return this.stringValueOf(prop.getInitializer());
|
|
495
|
+
}
|
|
418
496
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Resolve a module-level `const` to its string value.
|
|
499
|
+
*
|
|
500
|
+
* Not every binding key is a class static — some are plain exported
|
|
501
|
+
* constants, e.g. `export const LoggerContractId = 'Valkyrja.Log.Logger.LoggerContract' as const;`
|
|
502
|
+
*/
|
|
503
|
+
protected resolveConstant(
|
|
504
|
+
name: string,
|
|
505
|
+
useMap: Record<string, string>,
|
|
506
|
+
currentFilePath: string,
|
|
507
|
+
): string | undefined {
|
|
508
|
+
const declaration = this.parseDeclaringSourceFile(name, useMap, currentFilePath)?.getVariableDeclaration(name);
|
|
422
509
|
|
|
423
|
-
|
|
424
|
-
|
|
510
|
+
return this.stringValueOf(declaration?.getInitializer());
|
|
511
|
+
}
|
|
425
512
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Parse the file that declares a name: the module it is imported from, or
|
|
515
|
+
* the current file when the name is not imported.
|
|
516
|
+
*
|
|
517
|
+
* A provider commonly declares the binding keys for its own services
|
|
518
|
+
* alongside the publishers that use them, so `[Provider.SomeId]` names a
|
|
519
|
+
* constant that no import map can resolve — it lives in the file being read.
|
|
520
|
+
*
|
|
521
|
+
* Returns undefined when the file cannot be read.
|
|
522
|
+
*/
|
|
523
|
+
protected parseDeclaringSourceFile(
|
|
524
|
+
name: string,
|
|
525
|
+
useMap: Record<string, string>,
|
|
526
|
+
currentFilePath: string,
|
|
527
|
+
): SourceFile | undefined {
|
|
528
|
+
const importedFilePath = this.resolveImportToFilePath(name, useMap, currentFilePath);
|
|
529
|
+
const filePath = importedFilePath !== '' ? importedFilePath : currentFilePath;
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
|
|
533
|
+
|
|
534
|
+
return project.addSourceFileAtPath(filePath);
|
|
430
535
|
} catch {
|
|
431
536
|
// Silently skip unresolvable references
|
|
537
|
+
return undefined;
|
|
432
538
|
}
|
|
539
|
+
}
|
|
433
540
|
|
|
434
|
-
|
|
541
|
+
/**
|
|
542
|
+
* The string a declaration's initializer holds, once its type-only wrappers are stripped.
|
|
543
|
+
*/
|
|
544
|
+
protected stringValueOf(initializer: Expression | undefined): string | undefined {
|
|
545
|
+
if (initializer === undefined) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const value = this.unwrapTypeAssertions(initializer.compilerNode);
|
|
550
|
+
|
|
551
|
+
return ts.isStringLiteral(value) ? value.text : undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Strip the type-only wrappers an expression may be dressed in — `as`,
|
|
556
|
+
* `satisfies` and parentheses — to reach the value node underneath.
|
|
557
|
+
*
|
|
558
|
+
* Binding-key constants are declared `static readonly Data = 'Some.Id' as
|
|
559
|
+
* const;`, so the initializer node is an `AsExpression`, not the
|
|
560
|
+
* `StringLiteral` holding the value. Without unwrapping, every such key
|
|
561
|
+
* resolves to nothing and the publishers it names are dropped from the
|
|
562
|
+
* generated cache.
|
|
563
|
+
*/
|
|
564
|
+
protected unwrapTypeAssertions(node: ts.Expression): ts.Expression {
|
|
565
|
+
let current = node;
|
|
566
|
+
|
|
567
|
+
while (
|
|
568
|
+
ts.isAsExpression(current) ||
|
|
569
|
+
ts.isSatisfiesExpression(current) ||
|
|
570
|
+
ts.isParenthesizedExpression(current)
|
|
571
|
+
) {
|
|
572
|
+
current = current.expression;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return current;
|
|
435
576
|
}
|
|
436
577
|
|
|
437
578
|
/**
|
|
@@ -10,15 +10,22 @@
|
|
|
10
10
|
import type { ts } from 'ts-morph';
|
|
11
11
|
|
|
12
12
|
export class RouteProviderResult {
|
|
13
|
+
/**
|
|
14
|
+
* @param routeImports Local name → absolute file path for every class the
|
|
15
|
+
* imperative `routes` expressions reference, so the
|
|
16
|
+
* generated data cache can import them.
|
|
17
|
+
*/
|
|
13
18
|
constructor(
|
|
14
19
|
readonly controllerClasses: readonly string[] = [],
|
|
15
20
|
readonly routes: readonly ts.Expression[] = [],
|
|
21
|
+
readonly routeImports: Readonly<Record<string, string>> = {},
|
|
16
22
|
) {}
|
|
17
23
|
|
|
18
24
|
merge(other: RouteProviderResult): RouteProviderResult {
|
|
19
25
|
return new RouteProviderResult(
|
|
20
26
|
[...new Set([...this.controllerClasses, ...other.controllerClasses])],
|
|
21
27
|
[...this.routes, ...other.routes],
|
|
28
|
+
{ ...this.routeImports, ...other.routeImports },
|
|
22
29
|
);
|
|
23
30
|
}
|
|
24
31
|
}
|
|
@@ -28,13 +28,43 @@ export class RouteProviderReader extends AstReader implements RouteProviderReade
|
|
|
28
28
|
|
|
29
29
|
const useMap = this.buildUseMap(sourceFile);
|
|
30
30
|
const methods = this.indexMethods(classDecl);
|
|
31
|
+
const routes = this.extractRoutes(methods[RouteProviderReader.METHOD_ROUTES], useMap, filePath);
|
|
31
32
|
|
|
32
33
|
return new RouteProviderResult(
|
|
33
34
|
this.extractClassListFromValues(methods[RouteProviderReader.METHOD_CONTROLLER_CLASSES], useMap, filePath),
|
|
34
|
-
|
|
35
|
+
routes,
|
|
36
|
+
this.extractRouteImports(routes, useMap, filePath),
|
|
35
37
|
);
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Resolve every class the imperative route expressions reference to its
|
|
42
|
+
* absolute file path, so the generated data cache can import them.
|
|
43
|
+
*
|
|
44
|
+
* Names that are not imported by the provider file — locals, globals, and
|
|
45
|
+
* the provider's own class (which the generator imports separately) — have
|
|
46
|
+
* no entry in the use map and are skipped.
|
|
47
|
+
*/
|
|
48
|
+
protected extractRouteImports(
|
|
49
|
+
routes: readonly ts.Expression[],
|
|
50
|
+
useMap: Record<string, string>,
|
|
51
|
+
filePath: string,
|
|
52
|
+
): Record<string, string> {
|
|
53
|
+
const imports: Record<string, string> = {};
|
|
54
|
+
|
|
55
|
+
for (const route of routes) {
|
|
56
|
+
for (const name of this.collectReferencedIdentifiers(route)) {
|
|
57
|
+
const resolved = this.resolveImportToFilePath(name, useMap, filePath);
|
|
58
|
+
|
|
59
|
+
if (resolved !== '') {
|
|
60
|
+
imports[name] = resolved;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return imports;
|
|
66
|
+
}
|
|
67
|
+
|
|
38
68
|
/**
|
|
39
69
|
* Extract the route objects returned by a provider's `getRoutes()` method.
|
|
40
70
|
*
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export class SindriInfo {
|
|
11
|
-
static readonly VERSION = '26.
|
|
12
|
-
static readonly VERSION_BUILD_DATE_TIME = 'July
|
|
11
|
+
static readonly VERSION = '26.4.1' as const;
|
|
12
|
+
static readonly VERSION_BUILD_DATE_TIME = 'July 28 2026 21:24:47 MST' as const;
|
|
13
13
|
static readonly ICON = `\
|
|
14
14
|
██████████
|
|
15
15
|
████ ████
|
|
@@ -222,6 +222,146 @@ export abstract class GenerateDataFromAst {
|
|
|
222
222
|
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
223
223
|
}
|
|
224
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
|
+
|
|
225
365
|
protected generateContainerData(
|
|
226
366
|
serviceProviders: readonly string[],
|
|
227
367
|
config: ConfigResult,
|
|
@@ -246,7 +386,7 @@ export abstract class GenerateDataFromAst {
|
|
|
246
386
|
if (Object.keys(result.publishers).length > 0) {
|
|
247
387
|
// The publisher callbacks reference the provider's static methods,
|
|
248
388
|
// so the generated data cache must import the provider class.
|
|
249
|
-
importMap[path.basename(filePath, '.ts')] = this.
|
|
389
|
+
importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
|
|
250
390
|
}
|
|
251
391
|
|
|
252
392
|
Object.assign(publishers, result.publishers);
|
|
@@ -331,9 +471,15 @@ export abstract class GenerateDataFromAst {
|
|
|
331
471
|
|
|
332
472
|
if (providerResult.routes.length > 0) {
|
|
333
473
|
// Imperative routes reference the provider's static handlers,
|
|
334
|
-
// so the generated data cache must import the provider class
|
|
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.
|
|
335
477
|
imperativeRoutes.push(...providerResult.routes);
|
|
336
|
-
importMap[path.basename(filePath, '.ts')] = this.
|
|
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
|
+
}
|
|
337
483
|
}
|
|
338
484
|
|
|
339
485
|
for (const controllerClass of providerResult.controllerClasses) {
|
|
@@ -389,9 +535,15 @@ export abstract class GenerateDataFromAst {
|
|
|
389
535
|
|
|
390
536
|
if (providerResult.routes.length > 0) {
|
|
391
537
|
// Imperative routes reference the provider's static handlers,
|
|
392
|
-
// so the generated data cache must import the provider class
|
|
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.
|
|
393
541
|
imperativeRoutes.push(...providerResult.routes);
|
|
394
|
-
importMap[path.basename(filePath, '.ts')] = this.
|
|
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
|
+
}
|
|
395
547
|
}
|
|
396
548
|
|
|
397
549
|
for (const controllerClass of providerResult.controllerClasses) {
|
|
@@ -35,15 +35,29 @@ export abstract class AstFileGenerator {
|
|
|
35
35
|
* Build the `import { X } from '...';` block for the provider/handler classes
|
|
36
36
|
* a generated data cache references, followed by a trailing newline (or an
|
|
37
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.
|
|
38
42
|
*/
|
|
39
|
-
protected buildUserImportsBlock(classImportMap: Record<string, string
|
|
43
|
+
protected buildUserImportsBlock(classImportMap: Record<string, string>, reservedNames: readonly string[]): string {
|
|
40
44
|
const userImports = Object.entries(classImportMap)
|
|
45
|
+
.filter(([name]) => !reservedNames.includes(name))
|
|
41
46
|
.map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
|
|
42
47
|
.join('\n');
|
|
43
48
|
|
|
44
49
|
return userImports ? `${userImports}\n` : '';
|
|
45
50
|
}
|
|
46
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
|
+
|
|
47
61
|
protected writeFile(directory: string, className: string, data: string): GenerateStatus {
|
|
48
62
|
const filePath = directory.replace(/\/$/, '') + `/${className}.ts`;
|
|
49
63
|
|