@thomasrepnik/ts-implscan 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +219 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +135 -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,220 @@
|
|
|
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** by extending `ImplScan`:
|
|
28
|
+
```ts
|
|
29
|
+
import type { ImplScan } from '@thomasrepnik/ts-implscan';
|
|
30
|
+
|
|
31
|
+
export interface PaymentHandler extends ImplScan {
|
|
32
|
+
name: string;
|
|
33
|
+
pay(amount: number): string;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
2. **Write implementations** as usual, anywhere under `src/`:
|
|
37
|
+
```ts
|
|
38
|
+
export class CardHandler implements PaymentHandler { ... }
|
|
39
|
+
export class PaypalHandler implements PaymentHandler { ... }
|
|
40
|
+
```
|
|
41
|
+
3. **Generate the registry**, ideally before every start and build:
|
|
42
|
+
```json
|
|
43
|
+
"scripts": {
|
|
44
|
+
"generate": "ts-implscan --target angular",
|
|
45
|
+
"prestart": "npm run generate",
|
|
46
|
+
"prebuild": "npm run generate"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
4. **Use it.** In Angular, register everything once and inject the list anywhere:
|
|
50
|
+
```ts
|
|
51
|
+
// app.config.ts
|
|
52
|
+
import { provideImplscan } from './generated/registry';
|
|
53
|
+
export const appConfig: ApplicationConfig = { providers: [provideImplscan()] };
|
|
54
|
+
|
|
55
|
+
// any component or service: the equivalent of Spring's List<PaymentHandler>
|
|
56
|
+
import { PAYMENT_HANDLER_LIST } from './generated/registry';
|
|
57
|
+
private readonly handlers = inject(PAYMENT_HANDLER_LIST); // PaymentHandler[]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A new implementation is picked up the next time you regenerate. A new interface only needs
|
|
61
|
+
`extends ImplScan`; its token and providers are generated too.
|
|
62
|
+
|
|
63
|
+
## Why
|
|
64
|
+
|
|
65
|
+
In Spring you can inject `List<PaymentHandler>`, and the container hands you every bean that
|
|
66
|
+
implements the interface. TypeScript can't do this out of the box, for two reasons:
|
|
67
|
+
|
|
68
|
+
1. **Interfaces are erased.** At runtime nothing records that a class implements `PaymentHandler`.
|
|
69
|
+
2. **There is no classpath scanning.** JavaScript only runs files that something imports, so a
|
|
70
|
+
new implementation that nobody imports doesn't exist at runtime.
|
|
71
|
+
|
|
72
|
+
DI containers such as Angular, tsyringe or InversifyJS solve this with injection tokens, but
|
|
73
|
+
then you have to register every implementation by hand, and it's easy to forget one.
|
|
74
|
+
|
|
75
|
+
`ts-implscan` moves the scan to build time. It uses [ts-morph](https://ts-morph.com) and the
|
|
76
|
+
TypeScript type checker to find every exported class that `implements` a marked interface, and
|
|
77
|
+
writes a typed registry file:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/generated/registry.ts – GENERATED, do not edit
|
|
81
|
+
export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
|
|
82
|
+
|
|
83
|
+
// only with --target angular
|
|
84
|
+
export const PAYMENT_HANDLER_LIST = new InjectionToken<PaymentHandler[]>('PAYMENT_HANDLER_LIST');
|
|
85
|
+
|
|
86
|
+
export function provideImplscan(): Provider[] {
|
|
87
|
+
return [
|
|
88
|
+
{ provide: PAYMENT_HANDLER_LIST, useClass: CardHandler, multi: true },
|
|
89
|
+
{ provide: PAYMENT_HANDLER_LIST, useClass: PaypalHandler, multi: true },
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The registry is ordinary TypeScript, so `tsc` fails if a listed class doesn't actually satisfy
|
|
95
|
+
its interface.
|
|
96
|
+
|
|
97
|
+
**Why a marker interface rather than a comment tag like `/** @implscan */`?** The marker is
|
|
98
|
+
checked by the compiler, so a typo doesn't silently drop the interface. Your IDE's "Find usages"
|
|
99
|
+
on `ImplScan` lists every collected interface, and renames are safe. It's the same idea as a Java
|
|
100
|
+
marker interface such as `Serializable`, and it keeps out the interfaces you implement for other
|
|
101
|
+
reasons, such as Angular's `OnInit`.
|
|
102
|
+
|
|
103
|
+
## When it's helpful
|
|
104
|
+
|
|
105
|
+
- **Plugin and strategy patterns**, such as payment providers, exporters, validators, notification
|
|
106
|
+
channels or feature handlers, where the set of implementations keeps growing.
|
|
107
|
+
- **Teams coming from Spring or Java** who want the open/closed principle that `List<A>` injection
|
|
108
|
+
gives them: adding behaviour means adding a class, not editing a central list.
|
|
109
|
+
- **Angular apps** with many multi-provider tokens, so nobody forgets to register a new
|
|
110
|
+
implementation, or to write the token.
|
|
111
|
+
|
|
112
|
+
It's probably overkill if you have two implementations that rarely change. A hand-written array
|
|
113
|
+
is fine then.
|
|
114
|
+
|
|
115
|
+
## CLI options
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/registry.ts] [--target plain|angular] [--dry-run]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Option | Default | Meaning |
|
|
122
|
+
|-------------|-----------------------------|--------------------------------------------------------------|
|
|
123
|
+
| `--project` | `tsconfig.json` | tsconfig to read compiler options from (paths, resolution) |
|
|
124
|
+
| `--src` | `src` | Folder to scan. `*.spec.ts` and `*.d.ts` are skipped. |
|
|
125
|
+
| `--out` | `src/generated/registry.ts` | File to generate |
|
|
126
|
+
| `--target` | `plain` | `angular` adds an `InjectionToken` per interface and `provideImplscan()` |
|
|
127
|
+
| `--dry-run` | off | Print the result to stdout instead of writing it |
|
|
128
|
+
| `-h, --help`| | Show the help |
|
|
129
|
+
|
|
130
|
+
Use `--dry-run` to check which classes would be collected, without writing anything.
|
|
131
|
+
|
|
132
|
+
The files to scan come from `--src`, not from the tsconfig, because a new implementation isn't
|
|
133
|
+
imported anywhere yet. The tsconfig is only read for compiler options. The import extension in
|
|
134
|
+
the generated file follows it too: `.ts` with `allowImportingTsExtensions`, `.js` with `NodeNext`,
|
|
135
|
+
and no extension with `bundler`, which is what Angular uses.
|
|
136
|
+
|
|
137
|
+
## Angular
|
|
138
|
+
|
|
139
|
+
A typical setup in an Angular CLI project:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
"generate": "ts-implscan --target angular --project tsconfig.app.json --out src/app/generated/registry.ts"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **Implementations can use DI themselves**, for example `private readonly http = inject(HttpClient)`.
|
|
146
|
+
Angular creates them through `useClass`, so `inject()` works inside them.
|
|
147
|
+
- **Mark them with `@Injectable()`, but without `providedIn: 'root'`.** `useClass` creates its own
|
|
148
|
+
instance, so a root-provided class would exist twice.
|
|
149
|
+
- **In tests, replace a whole list** with one provider:
|
|
150
|
+
`providers: [{ provide: PAYMENT_HANDLER_LIST, useValue: [fakeHandler] }]`.
|
|
151
|
+
- **A marked interface without any implementation** still gets a provider (`useValue: []`), so
|
|
152
|
+
injecting it returns an empty list instead of throwing.
|
|
153
|
+
|
|
154
|
+
### Without the generated tokens (`--target plain`)
|
|
155
|
+
|
|
156
|
+
The plain target only generates the arrays of classes, with no Angular dependency. You can use
|
|
157
|
+
it with any framework, or none at all:
|
|
158
|
+
|
|
159
|
+
- **Plain `new`:** `PaymentHandlerImplementations.map((C) => new C())`. This only works for
|
|
160
|
+
classes without dependencies. In Angular, `inject()` outside a DI context throws `NG0203`.
|
|
161
|
+
- **Angular, each class `providedIn: 'root'`**, and the consumer calls
|
|
162
|
+
`PaymentHandlerImplementations.map((C) => inject(C))`. Dependencies work, but every consumer
|
|
163
|
+
imports the generated file, and a test has to override each class one by one.
|
|
164
|
+
|
|
165
|
+
## In CI
|
|
166
|
+
|
|
167
|
+
`prebuild` only runs through `npm run build`. If your pipeline calls `ng build` directly, run
|
|
168
|
+
`npm run generate` first.
|
|
169
|
+
|
|
170
|
+
Commit the generated file, so the IDE and type checks work right after a checkout. Then let CI
|
|
171
|
+
catch a forgotten regeneration:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npm run generate && git diff --exit-code
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Limits
|
|
178
|
+
|
|
179
|
+
- Only interfaces that extend `ImplScan` **directly** are collected. The CLI detects the marker by
|
|
180
|
+
its name, so a local `interface ImplScan {}` works too.
|
|
181
|
+
- Only explicit `implements X` clauses count. Classes that only match the interface structurally,
|
|
182
|
+
and subclasses of an implementing class, are not found.
|
|
183
|
+
- Implementations must be exported, non-abstract, named-export classes. A non-exported
|
|
184
|
+
implementation is skipped with a warning.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
This repository contains the library in `src/` and a runnable demo in `example/`. The demo has
|
|
189
|
+
two marked interfaces, `PaymentHandler` and `Notifier`.
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
npm install
|
|
193
|
+
npm run generate:dry # print the plain registry for the example, write nothing
|
|
194
|
+
npm run generate:dry:angular # the same with Angular tokens and providers
|
|
195
|
+
npm start # regenerate, then run the example with plain arrays
|
|
196
|
+
npm run start:angular # regenerate, then resolve everything through Angular's real Injector
|
|
197
|
+
npm run typecheck
|
|
198
|
+
npm run test:package # pack, install into an empty project, run the CLI from there
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`@angular/core` and `rxjs` are dev dependencies only so that `start:angular` can run Angular's
|
|
202
|
+
injector in Node. The published package only depends on `ts-morph`.
|
|
203
|
+
|
|
204
|
+
### Releasing
|
|
205
|
+
|
|
206
|
+
Releases are published to npm by `.github/workflows/release.yml`, using npm
|
|
207
|
+
[trusted publishing](https://docs.npmjs.com/trusted-publishers). It doesn't need an npm token.
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
npm version minor # or patch / major: bumps package.json, commits and tags vX.Y.Z
|
|
211
|
+
git push --follow-tags
|
|
212
|
+
gh release create v0.2.0 --generate-notes # or click "Publish release" on GitHub
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Publishing the GitHub release starts the workflow. It checks that the tag matches
|
|
216
|
+
`package.json`, type-checks, runs the package smoke test and publishes.
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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/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 (default: tsconfig.json)
|
|
25
|
+
--src <dir> folder to scan (default: src)
|
|
26
|
+
--out <file> file to generate (default: src/generated/registry.ts)
|
|
27
|
+
--target <target> plain | angular (default: plain)
|
|
28
|
+
--dry-run print the result instead of writing it
|
|
29
|
+
-h, --help show this help`);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
if (args.target !== 'plain' && args.target !== 'angular') {
|
|
33
|
+
console.error(`unknown --target '${args.target}', expected 'plain' or 'angular'`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const outFile = path.resolve(args.out);
|
|
37
|
+
const outDir = path.dirname(outFile);
|
|
38
|
+
// Use the tsconfig only for compiler options (paths, module resolution). Files come from
|
|
39
|
+
// a glob, because new implementations are not imported anywhere yet (Angular's
|
|
40
|
+
// tsconfig.app.json only lists main.ts).
|
|
41
|
+
const project = new Project({ tsConfigFilePath: args.project, skipAddingFilesFromTsConfig: true });
|
|
42
|
+
project.addSourceFilesAtPaths([`${args.src}/**/*.ts`, `!${args.src}/**/*.spec.ts`, `!${args.src}/**/*.d.ts`]);
|
|
43
|
+
const sourceFiles = project.getSourceFiles().filter((f) => f.getFilePath() !== outFile);
|
|
44
|
+
// Pick the import extension the tsconfig expects: `.ts` (Node type stripping),
|
|
45
|
+
// `.js` (NodeNext) or none (Angular's `bundler` resolution).
|
|
46
|
+
const options = project.getCompilerOptions();
|
|
47
|
+
const importExt = options.allowImportingTsExtensions
|
|
48
|
+
? '.ts'
|
|
49
|
+
: options.moduleResolution === ts.ModuleResolutionKind.Node16 ||
|
|
50
|
+
options.moduleResolution === ts.ModuleResolutionKind.NodeNext
|
|
51
|
+
? '.js'
|
|
52
|
+
: '';
|
|
53
|
+
// resolve through the type checker so aliased/renamed imports still match
|
|
54
|
+
function resolveInterface(clause) {
|
|
55
|
+
let symbol = clause.getExpression().getSymbol();
|
|
56
|
+
if (symbol?.isAlias())
|
|
57
|
+
symbol = symbol.getAliasedSymbol();
|
|
58
|
+
return symbol?.getDeclarations().find(Node.isInterfaceDeclaration);
|
|
59
|
+
}
|
|
60
|
+
const keyOf = (decl) => `${decl.getSourceFile().getFilePath()}#${decl.getName()}`;
|
|
61
|
+
const registry = new Map();
|
|
62
|
+
// 1. interfaces that opt in by extending the marker directly
|
|
63
|
+
for (const sourceFile of sourceFiles) {
|
|
64
|
+
for (const iface of sourceFile.getInterfaces()) {
|
|
65
|
+
if (!iface.isExported())
|
|
66
|
+
continue;
|
|
67
|
+
if (!iface.getExtends().some((clause) => resolveInterface(clause)?.getName() === MARKER))
|
|
68
|
+
continue;
|
|
69
|
+
registry.set(keyOf(iface), { iface: { name: iface.getName(), file: sourceFile.getFilePath() }, impls: [] });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 2. classes implementing one of those interfaces
|
|
73
|
+
for (const sourceFile of sourceFiles) {
|
|
74
|
+
for (const cls of sourceFile.getClasses()) {
|
|
75
|
+
const name = cls.getName();
|
|
76
|
+
if (!name || cls.isAbstract())
|
|
77
|
+
continue;
|
|
78
|
+
for (const clause of cls.getImplements()) {
|
|
79
|
+
const decl = resolveInterface(clause);
|
|
80
|
+
const entry = decl && registry.get(keyOf(decl));
|
|
81
|
+
if (!entry)
|
|
82
|
+
continue;
|
|
83
|
+
if (!cls.isExported() || cls.isDefaultExport()) {
|
|
84
|
+
console.warn(`skipped ${name} (${path.relative(process.cwd(), sourceFile.getFilePath())}): needs a named export`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
entry.impls.push({ name, file: sourceFile.getFilePath() });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function importPath(file) {
|
|
92
|
+
const rel = path.relative(outDir, file).split(path.sep).join('/').replace(/\.ts$/, importExt);
|
|
93
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
94
|
+
}
|
|
95
|
+
// PaymentHandler -> PAYMENT_HANDLER_LIST
|
|
96
|
+
function tokenName(interfaceName) {
|
|
97
|
+
const snake = interfaceName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2');
|
|
98
|
+
return `${snake.toUpperCase()}_LIST`;
|
|
99
|
+
}
|
|
100
|
+
const angular = args.target === 'angular';
|
|
101
|
+
const imports = angular ? [`import { InjectionToken, type Provider } from '@angular/core';`] : [];
|
|
102
|
+
const exports = [];
|
|
103
|
+
const providers = [];
|
|
104
|
+
const entries = [...registry.values()].sort((a, b) => a.iface.name.localeCompare(b.iface.name));
|
|
105
|
+
for (const { iface, impls } of entries) {
|
|
106
|
+
impls.sort((a, b) => a.name.localeCompare(b.name));
|
|
107
|
+
imports.push(`import type { ${iface.name} } from '${importPath(iface.file)}';`);
|
|
108
|
+
for (const impl of impls)
|
|
109
|
+
imports.push(`import { ${impl.name} } from '${importPath(impl.file)}';`);
|
|
110
|
+
// same shape as Angular's Type<T>, so it can be used directly as `useClass`
|
|
111
|
+
exports.push(`export const ${iface.name}Implementations: Array<new (...args: any[]) => ${iface.name}> = [${impls.map((i) => i.name).join(', ')}];`);
|
|
112
|
+
if (angular) {
|
|
113
|
+
const token = tokenName(iface.name);
|
|
114
|
+
exports.push(`export const ${token} = new InjectionToken<${iface.name}[]>('${token}');`);
|
|
115
|
+
// a multi token without any provider would throw on inject(), so fall back to []
|
|
116
|
+
if (impls.length === 0)
|
|
117
|
+
providers.push(` { provide: ${token}, useValue: [] },`);
|
|
118
|
+
for (const impl of impls)
|
|
119
|
+
providers.push(` { provide: ${token}, useClass: ${impl.name}, multi: true },`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (angular) {
|
|
123
|
+
exports.push('', 'export function provideImplscan(): Provider[] {', ' return [', ...providers, ' ];', '}');
|
|
124
|
+
}
|
|
125
|
+
const content = ['// GENERATED by ts-implscan – do not edit', ...imports, '', ...exports, ''].join('\n');
|
|
126
|
+
if (args['dry-run']) {
|
|
127
|
+
console.log(content);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
mkdirSync(outDir, { recursive: true });
|
|
131
|
+
writeFileSync(outFile, content);
|
|
132
|
+
for (const { iface, impls } of entries)
|
|
133
|
+
console.log(`${iface.name}: ${impls.map((i) => i.name).join(', ') || '(none)'}`);
|
|
134
|
+
console.log(`wrote ${path.relative(process.cwd(), outFile)}`);
|
|
135
|
+
}
|
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.0",
|
|
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/registry.ts && node src/cli.ts --src example --target angular --out example/generated/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
|
+
}
|