@thomasrepnik/ts-implscan 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +292 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Repnik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,293 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ts-implscan
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@thomasrepnik/ts-implscan)
|
|
4
|
+
[](https://github.com/thomasrepnik/ts-implscan/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
**Spring-style `List<A>` injection for TypeScript.** Find every `implements A` and generate the registry.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install --save-dev @thomasrepnik/ts-implscan
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package contains two things:
|
|
15
|
+
|
|
16
|
+
| What | How you use it |
|
|
17
|
+
|---|---|
|
|
18
|
+
| The `ImplScan` marker interface | `import type { ImplScan } from '@thomasrepnik/ts-implscan'` |
|
|
19
|
+
| The `ts-implscan` CLI (the generator) | `npx ts-implscan`, or in a `package.json` script |
|
|
20
|
+
|
|
21
|
+
It's a dev dependency because it only runs at build time. The `ImplScan` import is type-only and
|
|
22
|
+
disappears when compiled, so nothing from this package ends up in your production bundle.
|
|
23
|
+
You need Node 22 or newer.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### 1. Mark an interface
|
|
28
|
+
|
|
29
|
+
Extend `ImplScan` to tell the generator to collect the interface's implementations:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// payment-handler.ts
|
|
33
|
+
import type { ImplScan } from '@thomasrepnik/ts-implscan';
|
|
34
|
+
|
|
35
|
+
export interface PaymentHandler extends ImplScan {
|
|
36
|
+
name: string;
|
|
37
|
+
pay(amount: number): string;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Implement it
|
|
42
|
+
|
|
43
|
+
Write implementations as usual, anywhere under `src/`. They must be exported:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// card-handler.ts
|
|
47
|
+
export class CardHandler implements PaymentHandler {
|
|
48
|
+
name = 'card';
|
|
49
|
+
pay(amount: number) {
|
|
50
|
+
return `charged ${amount} to credit card`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// paypal-handler.ts
|
|
55
|
+
export class PaypalHandler implements PaymentHandler { ... }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Generate the registry and use it
|
|
59
|
+
|
|
60
|
+
Pick the variant for your project.
|
|
61
|
+
|
|
62
|
+
#### With Angular
|
|
63
|
+
|
|
64
|
+
Add the generator to your scripts, so it runs before every `ng serve` and `ng build`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
"scripts": {
|
|
68
|
+
"generate": "ts-implscan --target angular --project tsconfig.app.json --out src/app/generated/implscan-registry.ts",
|
|
69
|
+
"prestart": "npm run generate",
|
|
70
|
+
"prebuild": "npm run generate"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Register all implementations once:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// app.config.ts
|
|
78
|
+
import { provideImplscan } from './generated/implscan-registry';
|
|
79
|
+
|
|
80
|
+
export const appConfig: ApplicationConfig = {
|
|
81
|
+
providers: [provideImplscan()],
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Inject the list anywhere. This is the equivalent of Spring's `List<PaymentHandler>`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { PAYMENT_HANDLER_LIST } from './generated/implscan-registry';
|
|
89
|
+
|
|
90
|
+
@Injectable({ providedIn: 'root' })
|
|
91
|
+
export class PaymentService {
|
|
92
|
+
private readonly handlers = inject(PAYMENT_HANDLER_LIST); // PaymentHandler[]
|
|
93
|
+
|
|
94
|
+
payWithAll(amount: number) {
|
|
95
|
+
return this.handlers.map((handler) => handler.pay(amount));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Implementations can use DI themselves, for example `private readonly http = inject(HttpClient)`.
|
|
101
|
+
Mark them with `@Injectable()`, but **without** `providedIn: 'root'`. `provideImplscan()` already
|
|
102
|
+
creates one instance per implementation, so a root-provided implementation would exist twice.
|
|
103
|
+
|
|
104
|
+
<details>
|
|
105
|
+
<summary>What gets generated</summary>
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/app/generated/implscan-registry.ts
|
|
109
|
+
// GENERATED by ts-implscan – do not edit
|
|
110
|
+
import { InjectionToken, type Provider } from '@angular/core';
|
|
111
|
+
import type { PaymentHandler } from '../payment/payment-handler';
|
|
112
|
+
import { CardHandler } from '../payment/card-handler';
|
|
113
|
+
import { PaypalHandler } from '../payment/paypal-handler';
|
|
114
|
+
|
|
115
|
+
export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
|
|
116
|
+
export const PAYMENT_HANDLER_LIST = new InjectionToken<PaymentHandler[]>('PAYMENT_HANDLER_LIST');
|
|
117
|
+
|
|
118
|
+
export function provideImplscan(): Provider[] {
|
|
119
|
+
return [
|
|
120
|
+
{ provide: PAYMENT_HANDLER_LIST, useClass: CardHandler, multi: true },
|
|
121
|
+
{ provide: PAYMENT_HANDLER_LIST, useClass: PaypalHandler, multi: true },
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
There is one token per marked interface, and `provideImplscan()` registers all of them.
|
|
127
|
+
</details>
|
|
128
|
+
|
|
129
|
+
The [example](example/main-angular.ts) in this repository resolves the generated providers
|
|
130
|
+
through Angular's real injector (`npm run start:angular`).
|
|
131
|
+
|
|
132
|
+
#### Without Angular
|
|
133
|
+
|
|
134
|
+
The default target generates plain arrays of classes, with no framework dependency. It works for
|
|
135
|
+
Node, other frameworks, or plain TypeScript:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
"scripts": {
|
|
139
|
+
"generate": "ts-implscan",
|
|
140
|
+
"prestart": "npm run generate",
|
|
141
|
+
"prebuild": "npm run generate"
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { PaymentHandlerImplementations } from './generated/implscan-registry';
|
|
147
|
+
|
|
148
|
+
const handlers = PaymentHandlerImplementations.map((Handler) => new Handler()); // PaymentHandler[]
|
|
149
|
+
|
|
150
|
+
for (const handler of handlers) {
|
|
151
|
+
console.log(handler.pay(42));
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
If your implementations have dependencies, register the classes from the array with your DI
|
|
156
|
+
container instead of calling `new`.
|
|
157
|
+
|
|
158
|
+
<details>
|
|
159
|
+
<summary>What gets generated</summary>
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// src/generated/implscan-registry.ts
|
|
163
|
+
// GENERATED by ts-implscan – do not edit
|
|
164
|
+
import type { PaymentHandler } from '../payment/payment-handler';
|
|
165
|
+
import { CardHandler } from '../payment/card-handler';
|
|
166
|
+
import { PaypalHandler } from '../payment/paypal-handler';
|
|
167
|
+
|
|
168
|
+
export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
|
|
169
|
+
```
|
|
170
|
+
</details>
|
|
171
|
+
|
|
172
|
+
The [example](example/main.ts) in this repository does exactly this (`npm start`).
|
|
173
|
+
|
|
174
|
+
**That's it.** A new implementation is picked up the next time the generator runs. A new
|
|
175
|
+
interface only needs `extends ImplScan`, and with Angular its token is generated too.
|
|
176
|
+
|
|
177
|
+
## Good to know
|
|
178
|
+
|
|
179
|
+
- **Check what would be collected** without writing anything: `npx ts-implscan --dry-run`.
|
|
180
|
+
- **Replace a whole list in Angular tests** with one provider:
|
|
181
|
+
`providers: [{ provide: PAYMENT_HANDLER_LIST, useValue: [fakeHandler] }]`.
|
|
182
|
+
- **A marked interface without implementations** gives an empty array, and in Angular a
|
|
183
|
+
`useValue: []` provider, so injecting it doesn't throw.
|
|
184
|
+
- **Commit the generated file**, so the IDE and type checks work right after a checkout. Let CI
|
|
185
|
+
catch a forgotten regeneration with `npm run generate && git diff --exit-code`.
|
|
186
|
+
- **In CI**, `prebuild` only runs through `npm run build`. If your pipeline calls `ng build`
|
|
187
|
+
directly, run `npm run generate` first.
|
|
188
|
+
|
|
189
|
+
## Why
|
|
190
|
+
|
|
191
|
+
In Spring you can inject `List<PaymentHandler>`, and the container hands you every bean that
|
|
192
|
+
implements the interface. TypeScript can't do this out of the box, for two reasons:
|
|
193
|
+
|
|
194
|
+
1. **Interfaces are erased.** At runtime nothing records that a class implements `PaymentHandler`.
|
|
195
|
+
2. **There is no classpath scanning.** JavaScript only runs files that something imports, so a
|
|
196
|
+
new implementation that nobody imports doesn't exist at runtime.
|
|
197
|
+
|
|
198
|
+
DI containers such as Angular, tsyringe or InversifyJS solve this with injection tokens, but
|
|
199
|
+
then you have to register every implementation by hand, and it's easy to forget one.
|
|
200
|
+
|
|
201
|
+
`ts-implscan` moves the scan to build time. It uses [ts-morph](https://ts-morph.com) and the
|
|
202
|
+
TypeScript type checker to find every exported class that `implements` a marked interface. The
|
|
203
|
+
generated file is ordinary TypeScript, so `tsc` fails if a listed class doesn't actually satisfy
|
|
204
|
+
its interface.
|
|
205
|
+
|
|
206
|
+
**Why a marker interface rather than a comment tag like `/** @implscan */`?** The marker is
|
|
207
|
+
checked by the compiler, so a typo doesn't silently drop the interface. Your IDE's "Find usages"
|
|
208
|
+
on `ImplScan` lists every collected interface, and renames are safe. It's the same idea as a Java
|
|
209
|
+
marker interface such as `Serializable`, and it keeps out the interfaces you implement for other
|
|
210
|
+
reasons, such as Angular's `OnInit`.
|
|
211
|
+
|
|
212
|
+
## When it's helpful
|
|
213
|
+
|
|
214
|
+
- **Plugin and strategy patterns**, such as payment providers, exporters, validators, notification
|
|
215
|
+
channels or feature handlers, where the set of implementations keeps growing.
|
|
216
|
+
- **Teams coming from Spring or Java** who want the open/closed principle that `List<A>` injection
|
|
217
|
+
gives them: adding behaviour means adding a class, not editing a central list.
|
|
218
|
+
- **Angular apps** with many multi-provider tokens, so nobody forgets to register a new
|
|
219
|
+
implementation, or to write the token.
|
|
220
|
+
|
|
221
|
+
It's probably overkill if you have two implementations that rarely change. A hand-written array
|
|
222
|
+
is fine then.
|
|
223
|
+
|
|
224
|
+
## CLI options
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/implscan-registry.ts] [--target plain|angular] [--dry-run]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
| Option | Default | Meaning |
|
|
231
|
+
|--------------|--------------------------------------|----------------------------------------------------------|
|
|
232
|
+
| `--project` | `tsconfig.json` | tsconfig to read compiler options from (paths, resolution) |
|
|
233
|
+
| `--src` | `src` | Folder to scan. `*.spec.ts` and `*.d.ts` are skipped. |
|
|
234
|
+
| `--out` | `src/generated/implscan-registry.ts` | File to generate |
|
|
235
|
+
| `--target` | `plain` | `angular` adds an `InjectionToken` per interface and `provideImplscan()` |
|
|
236
|
+
| `--dry-run` | off | Print the result to stdout instead of writing it |
|
|
237
|
+
| `-h, --help` | | Show the help |
|
|
238
|
+
|
|
239
|
+
The files to scan come from `--src`, not from the tsconfig, because a new implementation isn't
|
|
240
|
+
imported anywhere yet. The tsconfig is only read for compiler options. The import extension in
|
|
241
|
+
the generated file follows it too: `.ts` with `allowImportingTsExtensions`, `.js` with `NodeNext`,
|
|
242
|
+
and no extension with `bundler`, which is what Angular uses.
|
|
243
|
+
|
|
244
|
+
## Limits
|
|
245
|
+
|
|
246
|
+
- Only interfaces that extend `ImplScan` **directly** are collected. The CLI detects the marker by
|
|
247
|
+
its name, so a local `interface ImplScan {}` works too.
|
|
248
|
+
- Only explicit `implements X` clauses count. Classes that only match the interface structurally,
|
|
249
|
+
and subclasses of an implementing class, are not found.
|
|
250
|
+
- Implementations must be exported, non-abstract, named-export classes. A non-exported
|
|
251
|
+
implementation is skipped with a warning.
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
This repository contains the library in `src/` and a runnable demo in `example/`. The demo has
|
|
256
|
+
two marked interfaces, `PaymentHandler` and `Notifier`.
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
npm install
|
|
260
|
+
npm run generate:dry # print the plain registry for the example, write nothing
|
|
261
|
+
npm run generate:dry:angular # the same with Angular tokens and providers
|
|
262
|
+
npm start # regenerate, then run the example with plain arrays
|
|
263
|
+
npm run start:angular # regenerate, then resolve everything through Angular's real Injector
|
|
264
|
+
npm run typecheck
|
|
265
|
+
npm run test:package # pack, install into an empty project, run the CLI from there
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`@angular/core` and `rxjs` are dev dependencies only so that `start:angular` can run Angular's
|
|
269
|
+
injector in Node. The published package only depends on `ts-morph`.
|
|
270
|
+
|
|
271
|
+
### Releasing
|
|
272
|
+
|
|
273
|
+
Releases are uploaded to npm by `.github/workflows/release.yml`, using npm
|
|
274
|
+
[trusted publishing](https://docs.npmjs.com/trusted-publishers). It doesn't need an npm token.
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
npm version minor # or patch / major: bumps package.json, commits and tags vX.Y.Z
|
|
278
|
+
git push --follow-tags
|
|
279
|
+
gh release create v0.2.0 --generate-notes # or click "Publish release" on GitHub
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Publishing the GitHub release starts the workflow. It checks that the tag matches
|
|
283
|
+
`package.json`, type-checks, runs the package smoke test and **stages** the version on npm.
|
|
284
|
+
A staged version isn't public until a maintainer approves it with 2FA:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
npm stage list @thomasrepnik/ts-implscan # shows the stage id
|
|
288
|
+
npm stage approve <stage-id>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build-time "component scan": finds every exported interface that extends the
|
|
3
|
+
// `ImplScan` marker, then every exported class that implements one of them, and
|
|
4
|
+
// writes one array of implementations per interface. With `--target angular` it
|
|
5
|
+
// also writes an InjectionToken per interface and `provideImplscan()`.
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { Node, Project, ts } from 'ts-morph';
|
|
10
|
+
const MARKER = 'ImplScan';
|
|
11
|
+
const { values: args } = parseArgs({
|
|
12
|
+
options: {
|
|
13
|
+
project: { type: 'string', default: 'tsconfig.json' },
|
|
14
|
+
src: { type: 'string', default: 'src' },
|
|
15
|
+
out: { type: 'string', default: 'src/generated/implscan-registry.ts' },
|
|
16
|
+
target: { type: 'string', default: 'plain' },
|
|
17
|
+
'dry-run': { type: 'boolean', default: false },
|
|
18
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
if (args.help) {
|
|
22
|
+
console.log(`Usage: ts-implscan [options]
|
|
23
|
+
|
|
24
|
+
--project <file> tsconfig to read compiler options from
|
|
25
|
+
(default: tsconfig.json)
|
|
26
|
+
--src <dir> folder to scan (default: src)
|
|
27
|
+
--out <file> file to generate
|
|
28
|
+
(default: src/generated/implscan-registry.ts)
|
|
29
|
+
--target <target> plain | angular (default: plain)
|
|
30
|
+
--dry-run print the result instead of writing it
|
|
31
|
+
-h, --help show this help`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if (args.target !== 'plain' && args.target !== 'angular') {
|
|
35
|
+
console.error(`unknown --target '${args.target}', expected 'plain' or 'angular'`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const outFile = path.resolve(args.out);
|
|
39
|
+
const outDir = path.dirname(outFile);
|
|
40
|
+
// Use the tsconfig only for compiler options (paths, module resolution). Files come from
|
|
41
|
+
// a glob, because new implementations are not imported anywhere yet (Angular's
|
|
42
|
+
// tsconfig.app.json only lists main.ts).
|
|
43
|
+
const project = new Project({ tsConfigFilePath: args.project, skipAddingFilesFromTsConfig: true });
|
|
44
|
+
project.addSourceFilesAtPaths([`${args.src}/**/*.ts`, `!${args.src}/**/*.spec.ts`, `!${args.src}/**/*.d.ts`]);
|
|
45
|
+
const sourceFiles = project.getSourceFiles().filter((f) => f.getFilePath() !== outFile);
|
|
46
|
+
// Pick the import extension the tsconfig expects: `.ts` (Node type stripping),
|
|
47
|
+
// `.js` (NodeNext) or none (Angular's `bundler` resolution).
|
|
48
|
+
const options = project.getCompilerOptions();
|
|
49
|
+
const importExt = options.allowImportingTsExtensions
|
|
50
|
+
? '.ts'
|
|
51
|
+
: options.moduleResolution === ts.ModuleResolutionKind.Node16 ||
|
|
52
|
+
options.moduleResolution === ts.ModuleResolutionKind.NodeNext
|
|
53
|
+
? '.js'
|
|
54
|
+
: '';
|
|
55
|
+
// resolve through the type checker so aliased/renamed imports still match
|
|
56
|
+
function resolveInterface(clause) {
|
|
57
|
+
let symbol = clause.getExpression().getSymbol();
|
|
58
|
+
if (symbol?.isAlias())
|
|
59
|
+
symbol = symbol.getAliasedSymbol();
|
|
60
|
+
return symbol?.getDeclarations().find(Node.isInterfaceDeclaration);
|
|
61
|
+
}
|
|
62
|
+
const keyOf = (decl) => `${decl.getSourceFile().getFilePath()}#${decl.getName()}`;
|
|
63
|
+
const registry = new Map();
|
|
64
|
+
// 1. interfaces that opt in by extending the marker directly
|
|
65
|
+
for (const sourceFile of sourceFiles) {
|
|
66
|
+
for (const iface of sourceFile.getInterfaces()) {
|
|
67
|
+
if (!iface.isExported())
|
|
68
|
+
continue;
|
|
69
|
+
if (!iface.getExtends().some((clause) => resolveInterface(clause)?.getName() === MARKER))
|
|
70
|
+
continue;
|
|
71
|
+
registry.set(keyOf(iface), { iface: { name: iface.getName(), file: sourceFile.getFilePath() }, impls: [] });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 2. classes implementing one of those interfaces
|
|
75
|
+
for (const sourceFile of sourceFiles) {
|
|
76
|
+
for (const cls of sourceFile.getClasses()) {
|
|
77
|
+
const name = cls.getName();
|
|
78
|
+
if (!name || cls.isAbstract())
|
|
79
|
+
continue;
|
|
80
|
+
for (const clause of cls.getImplements()) {
|
|
81
|
+
const decl = resolveInterface(clause);
|
|
82
|
+
const entry = decl && registry.get(keyOf(decl));
|
|
83
|
+
if (!entry)
|
|
84
|
+
continue;
|
|
85
|
+
if (!cls.isExported() || cls.isDefaultExport()) {
|
|
86
|
+
console.warn(`skipped ${name} (${path.relative(process.cwd(), sourceFile.getFilePath())}): needs a named export`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
entry.impls.push({ name, file: sourceFile.getFilePath() });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function importPath(file) {
|
|
94
|
+
const rel = path.relative(outDir, file).split(path.sep).join('/').replace(/\.ts$/, importExt);
|
|
95
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
96
|
+
}
|
|
97
|
+
// PaymentHandler -> PAYMENT_HANDLER_LIST
|
|
98
|
+
function tokenName(interfaceName) {
|
|
99
|
+
const snake = interfaceName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2');
|
|
100
|
+
return `${snake.toUpperCase()}_LIST`;
|
|
101
|
+
}
|
|
102
|
+
const angular = args.target === 'angular';
|
|
103
|
+
const imports = angular ? [`import { InjectionToken, type Provider } from '@angular/core';`] : [];
|
|
104
|
+
const exports = [];
|
|
105
|
+
const providers = [];
|
|
106
|
+
const entries = [...registry.values()].sort((a, b) => a.iface.name.localeCompare(b.iface.name));
|
|
107
|
+
for (const { iface, impls } of entries) {
|
|
108
|
+
impls.sort((a, b) => a.name.localeCompare(b.name));
|
|
109
|
+
imports.push(`import type { ${iface.name} } from '${importPath(iface.file)}';`);
|
|
110
|
+
for (const impl of impls)
|
|
111
|
+
imports.push(`import { ${impl.name} } from '${importPath(impl.file)}';`);
|
|
112
|
+
// same shape as Angular's Type<T>, so it can be used directly as `useClass`
|
|
113
|
+
exports.push(`export const ${iface.name}Implementations: Array<new (...args: any[]) => ${iface.name}> = [${impls.map((i) => i.name).join(', ')}];`);
|
|
114
|
+
if (angular) {
|
|
115
|
+
const token = tokenName(iface.name);
|
|
116
|
+
exports.push(`export const ${token} = new InjectionToken<${iface.name}[]>('${token}');`);
|
|
117
|
+
// a multi token without any provider would throw on inject(), so fall back to []
|
|
118
|
+
if (impls.length === 0)
|
|
119
|
+
providers.push(` { provide: ${token}, useValue: [] },`);
|
|
120
|
+
for (const impl of impls)
|
|
121
|
+
providers.push(` { provide: ${token}, useClass: ${impl.name}, multi: true },`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (angular) {
|
|
125
|
+
exports.push('', 'export function provideImplscan(): Provider[] {', ' return [', ...providers, ' ];', '}');
|
|
126
|
+
}
|
|
127
|
+
const content = ['// GENERATED by ts-implscan – do not edit', ...imports, '', ...exports, ''].join('\n');
|
|
128
|
+
if (args['dry-run']) {
|
|
129
|
+
console.log(content);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
mkdirSync(outDir, { recursive: true });
|
|
133
|
+
writeFileSync(outFile, content);
|
|
134
|
+
for (const { iface, impls } of entries)
|
|
135
|
+
console.log(`${iface.name}: ${impls.map((i) => i.name).join(', ') || '(none)'}`);
|
|
136
|
+
console.log(`wrote ${path.relative(process.cwd(), outFile)}`);
|
|
137
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker interface: every interface that extends `ImplScan` gets its implementations
|
|
3
|
+
* collected by the `ts-implscan` CLI. It has no members and no runtime cost.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* export interface PaymentHandler extends ImplScan {
|
|
7
|
+
* pay(amount: number): string;
|
|
8
|
+
* }
|
|
9
|
+
*/
|
|
10
|
+
export interface ImplScan {
|
|
11
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomasrepnik/ts-implscan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Spring-style List<A> injection for TypeScript: find every class implementing a marked interface and generate a typed registry, plus Angular InjectionTokens and providers.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"dependency-injection",
|
|
8
|
+
"angular",
|
|
9
|
+
"multi-provider",
|
|
10
|
+
"injection-token",
|
|
11
|
+
"code-generation",
|
|
12
|
+
"ts-morph",
|
|
13
|
+
"spring",
|
|
14
|
+
"component-scan"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/thomasrepnik/ts-implscan#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/thomasrepnik/ts-implscan/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/thomasrepnik/ts-implscan.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Thomas Repnik",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"bin": {
|
|
35
|
+
"ts-implscan": "dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"prepack": "npm run build",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"generate": "node src/cli.ts --src example --out example/generated/implscan-registry.ts && node src/cli.ts --src example --target angular --out example/generated/implscan-registry.angular.ts",
|
|
51
|
+
"generate:dry": "node src/cli.ts --src example --dry-run",
|
|
52
|
+
"generate:dry:angular": "node src/cli.ts --src example --target angular --dry-run",
|
|
53
|
+
"prestart": "npm run generate",
|
|
54
|
+
"start": "node example/main.ts",
|
|
55
|
+
"prestart:angular": "npm run generate",
|
|
56
|
+
"start:angular": "node example/main-angular.ts",
|
|
57
|
+
"test:package": "bash scripts/smoke-test.sh"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"ts-morph": "^28.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@angular/core": "^22.2.4",
|
|
64
|
+
"@types/node": "^26.6.6",
|
|
65
|
+
"rxjs": "^7.8.2",
|
|
66
|
+
"typescript": "^7.0.2"
|
|
67
|
+
}
|
|
68
|
+
}
|