@valkyrjaio/sindri 26.4.1 → 26.5.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.
@@ -32,6 +32,7 @@ import { AstEventDataFileGenerator } from '../../Generator/Ast/Event/AstEventDat
32
32
  import { AstHttpDataFileGenerator } from '../../Generator/Ast/Http/AstHttpDataFileGenerator.ts';
33
33
  import { GenerateStatus } from '../../Generator/Enum/GenerateStatus.ts';
34
34
  import { SindriInfo } from '../../Constant/SindriInfo.ts';
35
+ import { GenerateFromAst } from './GenerateFromAst.ts';
35
36
 
36
37
  import type { CliDataFileGeneratorContract } from '../../Generator/Cli/Contract/CliDataFileGeneratorContract.ts';
37
38
  import type { ContainerDataFileGeneratorContract } from '../../Generator/Container/Contract/ContainerDataFileGeneratorContract.ts';
@@ -53,7 +54,7 @@ import type { ts } from 'ts-morph';
53
54
  import type { HttpRouteData } from '../../Ast/Data/HttpRouteData.ts';
54
55
  import type { RouteContract } from '@valkyrjaio/valkyrja/Cli/Routing/Data/Contract/RouteContract.ts';
55
56
 
56
- export abstract class GenerateDataFromAst {
57
+ export abstract class GenerateDataFromAst extends GenerateFromAst {
57
58
  public constructor(
58
59
  protected readonly outputFactory: OutputFactoryContract,
59
60
  protected readonly route: RouteContract,
@@ -70,7 +71,9 @@ export abstract class GenerateDataFromAst {
70
71
  protected readonly eventGenerator: EventDataFileGeneratorContract = new AstEventDataFileGenerator(),
71
72
  protected readonly cliGenerator: CliDataFileGeneratorContract = new AstCliDataFileGenerator(),
72
73
  protected readonly httpGenerator: HttpDataFileGeneratorContract = new AstHttpDataFileGenerator(),
73
- ) {}
74
+ ) {
75
+ super();
76
+ }
74
77
 
75
78
  protected generateData(): OutputContract {
76
79
  let output = this.getOutput();
@@ -211,157 +214,6 @@ export abstract class GenerateDataFromAst {
211
214
  return '';
212
215
  }
213
216
 
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
-
365
217
  protected generateContainerData(
366
218
  serviceProviders: readonly string[],
367
219
  config: ConfigResult,
@@ -0,0 +1,173 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * (c) Melech Mizrachi <melechmizrachi@gmail.com>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+
13
+ /**
14
+ * Turns an absolute file path into the specifier a generated file must import
15
+ * it by.
16
+ *
17
+ * Every Sindri command that writes TypeScript needs this, because a generated
18
+ * file sits in a different directory from the files it references. Application
19
+ * source gets a path relative to the output directory. A file inside an
20
+ * installed package gets that package's own public specifier.
21
+ */
22
+ export abstract class GenerateFromAst {
23
+ /**
24
+ * Build the `import { X } from '...'` specifier for a class file, relative
25
+ * to the generated data file's output directory. Used to import the
26
+ * provider/handler classes referenced by the generated data caches.
27
+ */
28
+ protected relativeSpecifier(fromDir: string, toFile: string): string {
29
+ const relative = path.relative(fromDir, toFile).split(path.sep).join('/');
30
+
31
+ return relative.startsWith('.') ? relative : `./${relative}`;
32
+ }
33
+
34
+ /**
35
+ * Build the specifier a generated data file should import a class file by.
36
+ *
37
+ * Application source is imported by a path relative to the data directory;
38
+ * a file belonging to an installed package is imported by that package's
39
+ * own public specifier — so the framework classes the cache references are
40
+ * written exactly as hand-written application code writes them
41
+ * (`@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts`), never as a brittle
42
+ * reach into `node_modules`.
43
+ */
44
+ protected importSpecifier(fromDir: string, toFile: string): string {
45
+ return this.packageSpecifier(toFile) ?? this.relativeSpecifier(fromDir, toFile);
46
+ }
47
+
48
+ /**
49
+ * Map an absolute path inside an installed package back to the specifier
50
+ * that package publicly exposes it as, or undefined when the file is not
51
+ * part of one.
52
+ *
53
+ * The package's `exports` map is inverted: for `"./*.ts": "./src/*.ts"` a
54
+ * file at `<package>/src/Foo.ts` maps back to `<name>/Foo.ts`. Packages
55
+ * with no matching pattern fall back to the path as written under the
56
+ * package root, which is what a package without an `exports` map exposes.
57
+ */
58
+ protected packageSpecifier(filePath: string): string | undefined {
59
+ const normalized = filePath.split(path.sep).join('/');
60
+ const marker = normalized.lastIndexOf('/node_modules/');
61
+
62
+ if (marker === -1) {
63
+ return undefined;
64
+ }
65
+
66
+ const subject = normalized.substring(marker + '/node_modules/'.length);
67
+ const segments = subject.split('/');
68
+ const nameLength = subject.startsWith('@') ? 2 : 1;
69
+
70
+ if (segments.length <= nameLength) {
71
+ return undefined;
72
+ }
73
+
74
+ const packageName = segments.slice(0, nameLength).join('/');
75
+ const packageDir = normalized.substring(0, marker + '/node_modules/'.length) + packageName;
76
+ const relative = segments.slice(nameLength).join('/');
77
+
78
+ return `${packageName}/${this.exportedSubpath(packageDir, relative) ?? relative}`;
79
+ }
80
+
81
+ /**
82
+ * Invert a package's `exports` patterns to find the subpath a file inside
83
+ * it is exposed as, or undefined when no pattern matches it.
84
+ */
85
+ protected exportedSubpath(packageDir: string, relative: string): string | undefined {
86
+ for (const [key, target] of Object.entries(this.readPackageExports(packageDir))) {
87
+ const star = key.indexOf('*');
88
+ const targetStar = target.indexOf('*');
89
+
90
+ if (star === -1 || targetStar === -1) {
91
+ continue;
92
+ }
93
+
94
+ const prefix = target.substring(0, targetStar).replace(/^\.\//, '');
95
+ const suffix = target.substring(targetStar + 1);
96
+
97
+ if (!relative.startsWith(prefix) || !relative.endsWith(suffix)) {
98
+ continue;
99
+ }
100
+
101
+ const value = relative.substring(prefix.length, relative.length - suffix.length);
102
+
103
+ return (key.substring(0, star) + value + key.substring(star + 1)).replace(/^\.\//, '');
104
+ }
105
+
106
+ return undefined;
107
+ }
108
+
109
+ /**
110
+ * Read a package's `exports` map, flattened to subpath → file target.
111
+ *
112
+ * Conditional targets are collapsed to their first string value; every
113
+ * condition a package declares for a subpath points at the same source
114
+ * file in the layouts sindri generates for.
115
+ */
116
+ protected readPackageExports(packageDir: string): Record<string, string> {
117
+ const manifestPath = path.join(packageDir, 'package.json');
118
+
119
+ if (!fs.existsSync(manifestPath)) {
120
+ return {};
121
+ }
122
+
123
+ let manifest: { exports?: unknown };
124
+
125
+ try {
126
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { exports?: unknown };
127
+ } catch {
128
+ return {};
129
+ }
130
+
131
+ const exports = manifest.exports;
132
+
133
+ if (typeof exports !== 'object' || exports === null) {
134
+ return {};
135
+ }
136
+
137
+ const flattened: Record<string, string> = {};
138
+
139
+ for (const [subpath, target] of Object.entries(exports as Record<string, unknown>)) {
140
+ const resolved = this.firstStringTarget(target);
141
+
142
+ if (resolved !== undefined) {
143
+ flattened[subpath] = resolved;
144
+ }
145
+ }
146
+
147
+ return flattened;
148
+ }
149
+
150
+ /**
151
+ * Reduce an `exports` target — a string, or a nested map of conditions — to
152
+ * the first file path it names.
153
+ */
154
+ protected firstStringTarget(target: unknown): string | undefined {
155
+ if (typeof target === 'string') {
156
+ return target;
157
+ }
158
+
159
+ if (typeof target !== 'object' || target === null) {
160
+ return undefined;
161
+ }
162
+
163
+ for (const value of Object.values(target as Record<string, unknown>)) {
164
+ const resolved = this.firstStringTarget(value);
165
+
166
+ if (resolved !== undefined) {
167
+ return resolved;
168
+ }
169
+ }
170
+
171
+ return undefined;
172
+ }
173
+ }
@@ -0,0 +1,198 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * (c) Melech Mizrachi <melechmizrachi@gmail.com>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+
10
+ import { AstFileGenerator } from '../../Abstract/AstFileGenerator.ts';
11
+
12
+ import type { ConfigSourceResult } from '../../../Ast/Data/Result/ConfigSourceResult.ts';
13
+ import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
14
+ import type { CachedConfigFileGeneratorContract } from '../../Config/Contract/CachedConfigFileGeneratorContract.ts';
15
+
16
+ /**
17
+ * Writes a standalone cached config class.
18
+ *
19
+ * The generated class implements the config contract directly rather than
20
+ * extending a framework config. Extending one would import that base, and a
21
+ * framework config names a component provider in a default parameter value —
22
+ * which loads the whole component provider tree at module load, whether or not
23
+ * the default is ever used. That tree is exactly what a cached application does
24
+ * not need, so the generated config must not reach it by any path.
25
+ *
26
+ * Field initializers are copied as source text, so expressions the author wrote
27
+ * survive unevaluated. Two fields are not copied:
28
+ *
29
+ * - `providers` becomes empty. A cached application reads the generated data and
30
+ * never walks the provider tree.
31
+ * - `callbacks` becomes a single callback that publishes the generated container
32
+ * data. The application's own callbacks are dropped, because the callback a
33
+ * config normally carries is the one that chooses between cached and runtime
34
+ * publishing — and it lives on a component provider this file must not import.
35
+ */
36
+ export class AstCachedConfigFileGenerator extends AstFileGenerator implements CachedConfigFileGeneratorContract {
37
+ /** The service id constant each contract binds under. */
38
+ protected static readonly CONTRACT_SERVICE_IDS: Readonly<Record<string, string>> = {
39
+ ConfigContract: 'ApplicationServiceId.ConfigContract',
40
+ HttpConfigContract: 'ApplicationServiceId.HttpConfigContract',
41
+ CliConfigContract: 'ApplicationServiceId.CliConfigContract',
42
+ };
43
+
44
+ protected static readonly APPLICATION_SERVICE_ID =
45
+ '@valkyrjaio/valkyrja/Application/Constant/ApplicationServiceId.ts';
46
+
47
+ protected static readonly CONTAINER_SERVICE_ID = '@valkyrjaio/valkyrja/Container/Constant/ContainerServiceId.ts';
48
+
49
+ protected static readonly APPLICATION_CONTRACT =
50
+ '@valkyrjaio/valkyrja/Application/Kernel/Contract/ApplicationContract.ts';
51
+
52
+ protected static readonly COMPONENT_PROVIDER_CONTRACT =
53
+ '@valkyrjaio/valkyrja/Application/Provider/Contract/ComponentProviderContract.ts';
54
+
55
+ /** Fields the generated config replaces rather than copies. */
56
+ protected static readonly REPLACED_FIELDS: readonly string[] = ['providers', 'callbacks'];
57
+
58
+ /** Names the generated header already binds, so a copied import must not repeat them. */
59
+ protected static readonly RESERVED_IMPORT_NAMES: readonly string[] = [
60
+ 'ApplicationServiceId',
61
+ 'ContainerServiceId',
62
+ 'ApplicationContract',
63
+ 'ComponentProviderContract',
64
+ 'ConfigContract',
65
+ 'HttpConfigContract',
66
+ 'CliConfigContract',
67
+ ];
68
+
69
+ /**
70
+ * Fields whose copied value would break the generated config.
71
+ *
72
+ * Debug mode is pinned off rather than copied. In debug mode the routing
73
+ * service provider derives the route collection from the provider tree and
74
+ * writes the result over the routing data. This config has no providers, so
75
+ * that derivation produces an empty collection, and the empty collection
76
+ * replaces the generated data. Every route then fails to match. Debug mode
77
+ * and a cached config are opposite choices, so this file always states the
78
+ * cached one.
79
+ */
80
+ protected static readonly PINNED_FIELDS: Readonly<Record<string, string>> = {
81
+ debugMode: 'false',
82
+ };
83
+
84
+ generateFile(
85
+ directory: string,
86
+ className: string,
87
+ source: ConfigSourceResult,
88
+ containerDataClass: string,
89
+ containerDataSpecifier: string,
90
+ ): GenerateStatus {
91
+ const serviceId = AstCachedConfigFileGenerator.CONTRACT_SERVICE_IDS[source.contractName] ?? '';
92
+ const body = this.buildFields(source, containerDataClass);
93
+
94
+ const data = [
95
+ '// This file was automatically generated by Sindri.',
96
+ '',
97
+ `import { ApplicationServiceId } from '${AstCachedConfigFileGenerator.APPLICATION_SERVICE_ID}';`,
98
+ `import { ContainerServiceId } from '${AstCachedConfigFileGenerator.CONTAINER_SERVICE_ID}';`,
99
+ `import { ${containerDataClass} } from '${containerDataSpecifier}';`,
100
+ ...this.buildCopiedImportLines(source),
101
+ '',
102
+ `import type { ApplicationContract } from '${AstCachedConfigFileGenerator.APPLICATION_CONTRACT}';`,
103
+ `import type { ComponentProviderContract } from '${AstCachedConfigFileGenerator.COMPONENT_PROVIDER_CONTRACT}';`,
104
+ `import type { ${source.contractName} } from '${source.contractSpecifier}';`,
105
+ ...this.buildCopiedTypeImportLines(source),
106
+ '',
107
+ `export class ${className} implements ${source.contractName} {`,
108
+ ` static readonly id = ${serviceId};`,
109
+ '',
110
+ ...body,
111
+ '}',
112
+ '',
113
+ ].join('\n');
114
+
115
+ return this.writeFile(directory, className, data);
116
+ }
117
+
118
+ /**
119
+ * Render every field of the generated class.
120
+ */
121
+ protected buildFields(source: ConfigSourceResult, containerDataClass: string): string[] {
122
+ const lines: string[] = [];
123
+
124
+ for (const [name, value] of Object.entries(source.fields)) {
125
+ if (AstCachedConfigFileGenerator.REPLACED_FIELDS.includes(name)) {
126
+ continue;
127
+ }
128
+
129
+ const type = source.types[name];
130
+ const declaration = type !== undefined ? `${name}: ${type}` : name;
131
+
132
+ lines.push(` readonly ${declaration} = ${AstCachedConfigFileGenerator.PINNED_FIELDS[name] ?? value};`);
133
+ }
134
+
135
+ lines.push(' readonly providers: ComponentProviderContract[] = [];');
136
+ lines.push(' readonly callbacks: ((app: ApplicationContract) => void)[] = [');
137
+ lines.push(' (app: ApplicationContract): void => {');
138
+ lines.push(
139
+ ` app.getContainer().setSingleton(ContainerServiceId.Data, new ${containerDataClass}());`,
140
+ );
141
+ lines.push(' },');
142
+ lines.push(' ];');
143
+
144
+ return lines;
145
+ }
146
+
147
+ /**
148
+ * Re-emit the value imports the copied field text still needs.
149
+ */
150
+ protected buildCopiedImportLines(source: ConfigSourceResult): string[] {
151
+ return this.copiedImports(source, false).map((entry) => `import { ${entry.name} } from '${entry.specifier}';`);
152
+ }
153
+
154
+ /**
155
+ * Re-emit the type imports the copied field text still needs.
156
+ */
157
+ protected buildCopiedTypeImportLines(source: ConfigSourceResult): string[] {
158
+ return this.copiedImports(source, true).map(
159
+ (entry) => `import type { ${entry.name} } from '${entry.specifier}';`,
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Select the source file's imports that the copied text still references.
165
+ *
166
+ * An import used only by `providers` or `callbacks` is dropped with those
167
+ * fields — that is how the component provider import disappears.
168
+ */
169
+ protected copiedImports(source: ConfigSourceResult, isType: boolean): typeof source.imports {
170
+ const copiedText = Object.entries(source.fields)
171
+ .filter(([name]) => !AstCachedConfigFileGenerator.REPLACED_FIELDS.includes(name))
172
+ .filter(([name]) => AstCachedConfigFileGenerator.PINNED_FIELDS[name] === undefined)
173
+ .flatMap(([name, value]) => [value, source.types[name] ?? ''])
174
+ .join('\n');
175
+
176
+ const seen = new Set<string>();
177
+
178
+ return source.imports.filter((entry) => {
179
+ if (entry.isType !== isType || seen.has(entry.name)) {
180
+ return false;
181
+ }
182
+
183
+ // The generated header already binds these names. Emitting them a
184
+ // second time is a duplicate-identifier error.
185
+ if (AstCachedConfigFileGenerator.RESERVED_IMPORT_NAMES.includes(entry.name)) {
186
+ return false;
187
+ }
188
+
189
+ if (!new RegExp(`\\b${entry.name}\\b`).test(copiedText)) {
190
+ return false;
191
+ }
192
+
193
+ seen.add(entry.name);
194
+
195
+ return true;
196
+ });
197
+ }
198
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * (c) Melech Mizrachi <melechmizrachi@gmail.com>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+
10
+ import type { ConfigSourceResult } from '../../../Ast/Data/Result/ConfigSourceResult.ts';
11
+ import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
12
+
13
+ export interface CachedConfigFileGeneratorContract {
14
+ generateFile(
15
+ directory: string,
16
+ className: string,
17
+ source: ConfigSourceResult,
18
+ containerDataClass: string,
19
+ containerDataSpecifier: string,
20
+ ): GenerateStatus;
21
+ }