@ts-ioc-container/bundler 5.1.0 → 7.0.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/AGENTS.md CHANGED
@@ -7,10 +7,11 @@ nothing here runs inside the container, and the core package is not changed by
7
7
  it.
8
8
 
9
9
  - The CLI is `ts-ioc-container`, with `tic` as a shortcut (both `bin` entries,
10
- same program): `tic build [<config>]` is a Unix filter. It reads the config
11
- file `<config>` (JSON for `.json`, YAML for `.yaml` / `.yml`) — or, without
12
- one or with `-`, config text from stdin (parsed as YAML, which covers JSON) —
13
- and prints the bundle to **stdout**; it writes nothing. Saving is the shell's
10
+ same program): `tic build [<config>...]` is a Unix filter. It reads the config
11
+ file `<config>` (JSON for `.json`, YAML for `.yaml` / `.yml`) — several are
12
+ merged left to right into one bundle — or, without one or with `-`, config
13
+ text from stdin (parsed as YAML, which covers JSON; never merged) — and
14
+ prints the bundle to **stdout**; it writes nothing. Saving is the shell's
14
15
  job: `tic build app.bundle.json > src/di/app.bundle.ts`,
15
16
  `cat dev.bundle.json | tic build > src/di/dev.bundle.ts`; CI checks with
16
17
  `tic build app.bundle.json | diff - src/di/app.bundle.ts`. Warnings and errors
@@ -19,21 +20,22 @@ IContainerModule`, applied with `container.useModule(new AppBundle())`.
19
20
  - Every import in a bundle is written through a tsconfig `paths` alias, so the
20
21
  bundle works wherever it is saved; a selected class no alias covers fails the
21
22
  build naming its file. There are no relative imports.
22
- - Config fields (schema: `tic.schema.json`), flat: `glob` with `glob.glob`
23
- required; `className`, `name`, `tsconfig`, `importExtension` optional.
23
+ - Config fields (schema: `tic.schema.json`), flat: `paths` with `paths.include`
24
+ required; `match`, `name`, `tsconfig`, `importExtension` optional.
24
25
  Relative paths resolve against the config file; for piped text, against the
25
26
  working directory. `name` defaults to the file's stem (`prod.bundle.json` →
26
27
  `prod`) and is required for piped text or a file named otherwise; the class
27
28
  is `toClassName(name)` — `ProdBundle`.
28
29
  - A config does **not** extend its tsconfig and never takes files from it. The
29
30
  `tsconfig` field (default `./tsconfig.json`, may be absent; one named
30
- explicitly must exist) only supplies `paths` aliases — which `glob.glob` may
31
+ explicitly must exist) only supplies `paths` aliases — which `paths.include` may
31
32
  name and generated imports are written in (most specific alias, else a
32
33
  relative path) — and the module resolution that sets the import extension.
33
- - Selection has two stages, both shaped `{ glob, exclude }`, and both taking
34
- a string or a list as a shortcut for their `glob`. `glob: { glob, exclude }`
35
- decides by path which files are read and parsed at all (`glob: ./src` /
36
- `glob: [./a, ./b]` = `glob.glob`). `glob.glob` (required: one folder or a
34
+ - Selection has two stages, `paths: { include, exclude }` and
35
+ `match: { ..., glob, exclude }`. `paths` is always an object (a string
36
+ or a list in its place is an error); `match` also takes a string or a
37
+ list as a shortcut for `match.glob`. `paths` decides by path which files
38
+ are read and parsed at all. `paths.include` (required: one folder or a
37
39
  non-empty list) names the folders scanned, always with their sub-folders —
38
40
  relative to the config file (`./src/services`) or tsconfig aliases
39
41
  (`@app/services`); there is no `{ path, recursive }` form. A file in them is parsed unless it matches one
@@ -41,9 +43,9 @@ IContainerModule`, applied with `container.useModule(new AppBundle())`.
41
43
  with a tsconfig alias — `@generated/**`, or `@generated` for the whole folder;
42
44
  catch-all `"*"` aliases are skipped; default: test files, `__tests__/`,
43
45
  `node_modules/`; a non-empty value replaces it, and the build warns if it drops
44
- a default). There is no `include`. Files `tic build` generated are never input.
45
- - `className: { export, decorators, glob, exclude }` (or `className: '*Service'`
46
- / `['*Service', '*Repository']` = `className.glob`, default `export` and
46
+ a default). Files `tic build` generated are never input.
47
+ - `match: { export, decorators, glob, exclude }` (or `match: '*Service'`
48
+ / `['*Service', '*Repository']` = `match.glob`, default `export` and
47
49
  `decorators`) then picks classes of the parsed files; default is every exported,
48
50
  non-abstract class decorated with `@register` (`decorators` defaults to
49
51
  `["register"]`; `[]` requires none); positive criteria must all hold — `glob` is one glob
@@ -53,12 +55,23 @@ IContainerModule`, applied with `container.useModule(new AppBundle())`.
53
55
  two selected classes pass the same plain-identifier first decorator argument (a
54
56
  same-token heuristic), unless a decorator they share has differing arguments
55
57
  (scope-gated, e.g. `@perPage('a')` vs `@perPage('b')`).
58
+ - Several configs merge like webpack-merge (`mergeConfigs`):
59
+ `tic build common.bundle.yml prod.bundle.yml > src/di/prod.bundle.ts`.
60
+ Objects merge field by field; the list fields `paths.include`,
61
+ `paths.exclude`, `match.glob`, `match.exclude` and `match.decorators`
62
+ concatenate without duplicates (a later config only adds); any other value
63
+ set later replaces an earlier one; the `match` shorthand merges as
64
+ `match.glob`. Each file is validated alone (`BUNDLE_CONFIG_FILE_SCHEMA`:
65
+ `paths` optional; errors name the file), the merged result as one config
66
+ (`BUNDLE_CONFIG_SCHEMA`). Defaults apply to the merged config only. The last
67
+ file names the bundle unless one sets `name`; all must sit in one folder.
56
68
  - Never edit a `*.bundle.ts` by hand — change the classes or the config and
57
69
  rerun `tic build`.
58
70
  - Unknown config fields are errors, at every level.
59
- - Programmatic API: `build({ config, cwd })` or `build({ text, cwd })` returns
71
+ - Programmatic API: `build({ config, cwd })` (`config`: a file or a list merged
72
+ left to right) or `build({ text, cwd })` returns
60
73
  `{ content, bundle, registrations, warnings }` and writes nothing;
61
- `loadConfig(file)`, `run(argv, io)` (`io.stdin` backs piped configs); every error the
74
+ `loadConfig(file | files)`, `run(argv, io)` (`io.stdin` backs piped configs); every error the
62
75
  bundler raises on purpose is a `TicError` with a stable `code`
63
76
  (`TicConfigError` = `INVALID_CONFIG`, `NamespaceNotFoundError`, `UsageError`,
64
77
  …).
@@ -71,4 +84,5 @@ IContainerModule`, applied with `container.useModule(new AppBundle())`.
71
84
  services behind tokens (`IBundleBuilderKey`, `ITicConfigServiceKey`, …); pure
72
85
  logic in `lib/features/build/domain/`. A new command is a controller plus a module.
73
86
  - The config schema is `BUNDLE_CONFIG_SCHEMA` (zod); `tic.schema.json` is generated
74
- from it with `pnpm run generate:schema` — never edit it by hand.
87
+ from its per-file variant `BUNDLE_CONFIG_FILE_SCHEMA` with `pnpm run generate:schema`
88
+ — never edit it by hand.
package/README.md CHANGED
@@ -33,11 +33,12 @@ below use the shortcut.
33
33
  ## Build
34
34
 
35
35
  ```bash
36
- tic build [<config>]
36
+ tic build [<config>...]
37
37
  ```
38
38
 
39
39
  `tic build` prints the bundle to **stdout** and writes nothing. `<config>` is a
40
- config file — JSON for `.json`, YAML for `.yaml` / `.yml`. Without it (or with
40
+ config file — JSON for `.json`, YAML for `.yaml` / `.yml`. Several are
41
+ [merged](#merging-configs) left to right into one bundle. Without one (or with
41
42
  `-`) the config is read from **stdin**. Warnings and errors go to stderr, so
42
43
  they never end up in the bundle.
43
44
 
@@ -46,6 +47,9 @@ they never end up in the bundle.
46
47
  tic build app.bundle.json > src/di/app.bundle.ts
47
48
  tic build prod.bundle.yml > src/di/prod.bundle.ts
48
49
 
50
+ # several configs merged into one bundle, like webpack-merge
51
+ tic build common.bundle.yml prod.bundle.yml > src/di/prod.bundle.ts
52
+
49
53
  # the config piped in — it has no file name, so it sets `name` ("name": "dev")
50
54
  cat dev.bundle.json | tic build > src/di/dev.bundle.ts
51
55
  tic build < dev.bundle.yml > src/di/dev.bundle.ts
@@ -55,7 +59,7 @@ tic build app.bundle.json | npx prettier --stdin-filepath app.bundle.ts > src/di
55
59
  cat dev.bundle.json | tic build | tee src/di/dev.bundle.ts
56
60
 
57
61
  # a config built on the fly
58
- printf 'name: app\nglob: "@app/services"\n' | tic build > src/di/app.bundle.ts
62
+ printf 'name: app\npaths:\n include: "@app/services"\n' | tic build > src/di/app.bundle.ts
59
63
  jq '.name = "admin"' app.bundle.json | tic build > src/di/admin.bundle.ts
60
64
 
61
65
  # CI: is the saved bundle current?
@@ -69,8 +73,9 @@ covers is an error naming its file; add a `paths` entry for its folder.
69
73
 
70
74
  Relative paths in a config file resolve against the file; in a piped config,
71
75
  against the working directory. The bundle is named after the config file —
72
- `prod.bundle.json` exports `ProdBundle` — unless the config sets `name`, which a
73
- piped config, or a file not named `<name>.bundle.json`, must.
76
+ `prod.bundle.json` exports `ProdBundle`; of several merged, after the last —
77
+ unless the config sets `name`, which a piped config, or a file not named
78
+ `<name>.bundle.json`, must.
74
79
 
75
80
  ## Configure
76
81
 
@@ -80,19 +85,22 @@ registers. Point your editor at the schema:
80
85
  ```json
81
86
  {
82
87
  "$schema": "./node_modules/@ts-ioc-container/bundler/tic.schema.json",
83
- "glob": ["@app/services", "./src/infra"]
88
+ "paths": { "include": ["@app/services", "./src/infra"] }
84
89
  }
85
90
  ```
86
91
 
87
92
  ```yaml
88
93
  # yaml-language-server: $schema=./node_modules/@ts-ioc-container/bundler/tic.schema.json
89
- glob: ['@app/services', ./src/infra]
94
+ paths:
95
+ include:
96
+ - '@app/services'
97
+ - ./src/infra
90
98
  ```
91
99
 
92
100
  | Field | Default | Meaning |
93
101
  | ----------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
94
- | `glob` | — (required) | Which files are parsed — see [Selecting files](#selecting-files) |
95
- | `className` | exported classes with `@register` | Which classes of a parsed file are registered — see [Selecting classes](#selecting-classes) |
102
+ | `paths` | — (required) | Which files are parsed — see [Selecting files](#selecting-files). Merged configs need it in one of them |
103
+ | `match` | exported classes with `@register` | Which classes of a parsed file are registered — see [Selecting classes](#selecting-classes) |
96
104
  | `name` | the config file's (`app.bundle.json` → `app`) | The bundle's name: names the class, `AppBundle`. Required for a piped config or a file named otherwise |
97
105
  | `tsconfig` | `./tsconfig.json` | Source of the `paths` aliases every import is written through, and of the import extension — never of files |
98
106
  | `importExtension` | `.js` under node16/nodenext, else none | Extension of generated imports |
@@ -102,30 +110,38 @@ unnoticed.
102
110
 
103
111
  ## Several bundles
104
112
 
105
- Need more than one bundle — per environment, per app? Keep a config per bundle
106
- and build each one:
113
+ Need more than one bundle — per environment, per app? Keep what they share in
114
+ one config, what each adds in another, and build each environment from both:
107
115
 
108
116
  ```bash
109
117
  for env in prod dev test; do
110
- tic build "$env.bundle.yml" > "src/di/$env.bundle.ts"
118
+ tic build common.bundle.yml "$env.bundle.yml" > "src/di/$env.bundle.ts"
111
119
  done
112
120
  ```
113
121
 
114
122
  ```json
115
123
  {
116
124
  "scripts": {
117
- "bundle": "tic build app.bundle.json > src/di/app.bundle.ts",
118
- "bundle:check": "tic build app.bundle.json | diff - src/di/app.bundle.ts"
125
+ "bundle": "tic build common.bundle.yml prod.bundle.yml > src/di/prod.bundle.ts",
126
+ "bundle:check": "tic build common.bundle.yml prod.bundle.yml | diff - src/di/prod.bundle.ts"
119
127
  }
120
128
  }
121
129
  ```
122
130
 
123
- Each config selects its own files and classes:
131
+ ```yaml
132
+ # common.bundle.yml: what every environment registers
133
+ paths:
134
+ include:
135
+ - '@app/services'
136
+ ```
137
+
138
+ Each environment's config adds its own files and classes:
124
139
 
125
140
  ```yaml
126
- # prod.bundle.yml: services + adapters, minus dev-only *.dev.ts files
127
- glob:
128
- glob: ['@app/services', ./src/infra]
141
+ # prod.bundle.yml: + the adapters, minus dev-only *.dev.ts files
142
+ paths:
143
+ include:
144
+ - ./src/infra
129
145
  exclude:
130
146
  - '**/*.dev.ts'
131
147
  - '**/*.spec.ts'
@@ -137,12 +153,39 @@ glob:
137
153
  ```
138
154
 
139
155
  ```yaml
140
- # dev.bundle.yml: the *.dev.ts logger replaces the production one, dropped by class name
141
- glob: ['@app/services', ./src/infra]
142
- className:
156
+ # dev.bundle.yml: + the adapters; the *.dev.ts logger replaces the production one, dropped by class name
157
+ paths:
158
+ include:
159
+ - ./src/infra
160
+ match:
143
161
  exclude: JsonLogger
144
162
  ```
145
163
 
164
+ `tic build common.bundle.yml prod.bundle.yml` then scans `@app/services` and
165
+ `./src/infra`, and exports `ProdBundle`.
166
+
167
+ ### Merging configs
168
+
169
+ `tic build a.bundle.yml b.bundle.yml c.bundle.yml` merges the configs left to
170
+ right, the way webpack-merge merges webpack configs:
171
+
172
+ - objects merge field by field;
173
+ - list fields — `paths.include`, `paths.exclude`, `match.glob`, `match.exclude`,
174
+ `match.decorators` — concatenate in order, without duplicates (one folder or
175
+ glob counts as a list of one), so a later config can only add to them;
176
+ - any other value (`name`, `tsconfig`, `importExtension`, `match.export`) set
177
+ by a later config replaces an earlier one;
178
+ - the `match` shorthand merges as `match.glob`.
179
+
180
+ Each config is validated on its own — an error names the file it is in — so a
181
+ shared config may leave `paths` out; the merged config must have
182
+ `paths.include`. Defaults apply to the merged config only: a `paths.exclude` in
183
+ any of them replaces the default excludes, as it does in a single config, so
184
+ list the defaults in the shared config to keep them. The bundle is named after
185
+ the last config, unless one sets `name` — so leave `name` out of a shared
186
+ config. Merged configs must sit in one folder, which their relative paths
187
+ resolve against. A piped config (`-`) is never merged.
188
+
146
189
  A saved bundle is never registered by another build, even when it sits in a
147
190
  folder that build scans.
148
191
 
@@ -150,51 +193,47 @@ folder that build scans.
150
193
 
151
194
  A bundle picks its registrations in two stages:
152
195
 
153
- 1. **`glob`** — decided by path alone, before anything is read. Only files that
196
+ 1. **`paths`** — decided by path alone, before anything is read. Only files that
154
197
  pass are parsed.
155
- 2. **`className`** — decided per class, on the files stage 1 let through.
198
+ 2. **`match`** — decided per class, on the files stage 1 let through.
156
199
 
157
- Parsing is where the time goes, so point `glob` at the folders that hold your
200
+ Parsing is where the time goes, so point `paths` at the folders that hold your
158
201
  registrations, and `exclude` what they should not contribute:
159
202
 
160
203
  ```json
161
204
  {
162
- "glob": {
163
- "glob": "@app/services",
205
+ "paths": {
206
+ "include": "@app/services",
164
207
  "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/__tests__/**", "**/node_modules/**", "**/legacy/**"]
165
208
  },
166
- "className": { "decorators": ["register"] }
209
+ "match": { "decorators": ["register"] }
167
210
  }
168
211
  ```
169
212
 
170
213
  ## Selecting files
171
214
 
172
- `glob` takes the same shape as [`className`](#selecting-classes):
173
- `{ glob, exclude }`. `glob.glob` names the folders a bundle's files come from,
215
+ `paths` is `{ include, exclude }`. `paths.include` names the folders a bundle's files come from,
174
216
  and the only ones: what your tsconfig compiles plays no part. It is one folder
175
217
  or a list, each relative to the config file (`./src/services`) or a tsconfig
176
218
  `paths` alias (`@app/services`, for a `"@app/*": ["./src/*"]` alias), and
177
- always scanned with its sub-folders.
178
-
179
- When the default `exclude` is all you need, `glob` itself can be the folders —
180
- a string or a list is a shortcut for `glob.glob`:
219
+ always scanned with its sub-folders. `paths` is always an object — a folder or a
220
+ list in place of it is an error — and `exclude` may be left out to keep the default:
181
221
 
182
222
  ```yaml
183
- glob: ./src/services # = glob: { glob: ./src/services }
223
+ paths:
224
+ include: ./src/services # one folder
184
225
  ```
185
226
 
186
227
  ```yaml
187
- glob: ['@app/services', ./src/infra] # = glob: { glob: ['@app/services', ./src/infra] }
188
- ```
189
-
190
- ```yaml
191
- glob: # the object form, for a custom exclude
192
- glob: ['@app/services', ./src/infra]
193
- exclude: '**/*.dev.ts'
228
+ paths:
229
+ include: # or a list
230
+ - '@app/services'
231
+ - ./src/infra
232
+ exclude: '**/*.dev.ts' # a custom exclude
194
233
  ```
195
234
 
196
235
  Within them, a file is parsed unless it matches **one of** `exclude`. Globs are relative to the config file and `/`-separated; `**`
197
- spans folders. Like a folder in `glob.glob`, an exclude glob may start with a
236
+ spans folders. Like a folder in `paths.include`, an exclude glob may start with a
198
237
  tsconfig `paths` alias: with `"@generated/*": ["./src/.generated/*"]`,
199
238
  `@generated/**` reads `src/.generated/**`, and `@generated` (or `@generated/`)
200
239
  drops the whole folder. A catch-all `"*"` alias is never applied, so plain globs
@@ -202,13 +241,13 @@ such as `**/*.spec.ts` keep their meaning.
202
241
 
203
242
  | Rule | Default | Meaning |
204
243
  | --------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
205
- | `glob` | — (required) | Folder, or folders, to scan |
244
+ | `include` | — (required) | Folder, or folders, to scan |
206
245
  | `exclude` | test files, `__tests__/`, `node_modules/` | A glob, or a list of them, of files never read; may start with a tsconfig alias; replaces the default when given |
207
246
 
208
247
  A non-empty `exclude` replaces the defaults, so restate the ones you still want:
209
248
 
210
249
  ```json
211
- "glob": {
250
+ "paths": {
212
251
  "exclude": [
213
252
  "**/*.spec.ts",
214
253
  "**/*.test.ts",
@@ -229,11 +268,11 @@ parses everything, including tests.
229
268
  ## Selecting classes
230
269
 
231
270
  By default every exported, non-abstract class of a parsed file that carries
232
- `@register` is registered. `className` changes that; a class must meet every
271
+ `@register` is registered. `match` changes that; a class must meet every
233
272
  criterion that is set:
234
273
 
235
274
  ```json
236
- "className": {
275
+ "match": {
237
276
  "export": "named",
238
277
  "decorators": ["register", "repository"],
239
278
  "glob": "*Service"
@@ -257,7 +296,7 @@ bind the same token, registration is last-wins and the stand-in silently wins.
257
296
  matches only itself, so one list drops a class by name and a family by pattern:
258
297
 
259
298
  ```json
260
- "className": {
299
+ "match": {
261
300
  "decorators": ["repository", "service"],
262
301
  "exclude": ["MockDashboardRepository", "*Fake"]
263
302
  }
@@ -266,18 +305,18 @@ matches only itself, so one list drops a class by name and a family by pattern:
266
305
  `exclude` applies after every other criterion, so it combines with
267
306
  `decorators` and `glob` freely.
268
307
 
269
- ### The `className` shortcut
308
+ ### The `match` shortcut
270
309
 
271
- When the default `export` and `decorators` are all you need, `className`
310
+ When the default `export` and `decorators` are all you need, `match`
272
311
  itself can be the class-name globs — a string or a list is a shortcut for
273
- `className.glob`, just as on `glob`:
312
+ `match.glob`:
274
313
 
275
314
  ```yaml
276
- className: '*Service' # = className: { glob: '*Service' }
315
+ match: '*Service' # = match: { glob: '*Service' }
277
316
  ```
278
317
 
279
318
  ```yaml
280
- className: ['*Service', '*Repository'] # = className: { glob: ['*Service', '*Repository'] }
319
+ match: ['*Service', '*Repository'] # = match: { glob: ['*Service', '*Repository'] }
281
320
  ```
282
321
 
283
322
  As a safety net, the build warns when two selected classes pass the same
@@ -288,7 +327,7 @@ are not resolved), so it can only be a heuristic:
288
327
  ```text
289
328
  tic: warning: app.bundle.json: decorator token "IDashboardRepositoryToken" is passed by
290
329
  HttpDashboardRepository, MockDashboardRepository; registration is last-wins,
291
- exclude one with className.exclude
330
+ exclude one with match.exclude
292
331
  ```
293
332
 
294
333
  Classes that are scope-gated are not last-wins, so they do not warn: when the
@@ -304,15 +343,14 @@ The output is plain TypeScript over the public `ts-ioc-container` API:
304
343
  // src/di/app.bundle.ts
305
344
  // Generated by `tic build`. Do not edit by hand.
306
345
  // Paths: @app/services, @app/infra
307
- import { type IContainer, type IContainerModule, type IRegistration, Registration } from 'ts-ioc-container';
346
+ import { type IContainer, type IContainerModule, Registration } from 'ts-ioc-container';
308
347
  import { MemoryLogger } from '@app/infra/logging/MemoryLogger';
309
348
  import { Greeter } from '@app/services/Greeter';
310
349
 
311
- export const registrations: IRegistration[] = [Registration.fromClass(MemoryLogger), Registration.fromClass(Greeter)];
312
-
313
350
  export class AppBundle implements IContainerModule {
314
351
  applyTo(container: IContainer): void {
315
- for (const registration of registrations) container.addRegistration(registration);
352
+ container.addRegistration(Registration.fromClass(MemoryLogger));
353
+ container.addRegistration(Registration.fromClass(Greeter));
316
354
  }
317
355
  }
318
356
  ```
@@ -341,5 +379,5 @@ const { content, warnings } = build({ config: 'app.bundle.json' });
341
379
  writeFileSync('src/di/app.bundle.ts', content);
342
380
 
343
381
  // or config text, as `tic build` reads from stdin
344
- build({ text: 'name: app\nglob:\n glob: ["@app/services"]\n' });
382
+ build({ text: 'name: app\npaths:\n include: ["@app/services"]\n' });
345
383
  ```
package/cjm/app.js CHANGED
@@ -34,6 +34,6 @@ function run(argv, io = (0, CliIo_1.processIo)()) {
34
34
  function build({ cwd = process.cwd(), ...request }) {
35
35
  return withContainer({ ...(0, CliIo_1.processIo)(), cwd }, (container) => BundleBuilder_1.IBundleBuilderKey.resolve(container).build(request));
36
36
  }
37
- function loadConfig(file) {
38
- return withContainer((0, CliIo_1.processIo)(), (container) => TicConfigService_1.ITicConfigServiceKey.resolve(container).load(file));
37
+ function loadConfig(files) {
38
+ return withContainer((0, CliIo_1.processIo)(), (container) => TicConfigService_1.ITicConfigServiceKey.resolve(container).load(files));
39
39
  }
package/cjm/cli/usage.js CHANGED
@@ -6,10 +6,11 @@ exports.USAGE = [
6
6
  ' tic is a shortcut for ts-ioc-container',
7
7
  '',
8
8
  'Commands:',
9
- ' ts-ioc-container build [<config>] print the bundle a config file (.json, .yaml, .yml) describes;',
10
- ' without one (or with -), read the config from stdin',
11
- ' ts-ioc-container help show this help',
12
- ' ts-ioc-container version show the version',
9
+ ' ts-ioc-container build [<config>...] print the bundle a config file (.json, .yaml, .yml) describes;',
10
+ ' several are merged left to right into one bundle;',
11
+ ' without one (or with -), read the config from stdin',
12
+ ' ts-ioc-container help show this help',
13
+ ' ts-ioc-container version show the version',
13
14
  '',
14
15
  'Options:',
15
16
  ' -h, --help show this help',
@@ -17,6 +18,7 @@ exports.USAGE = [
17
18
  '',
18
19
  'The bundle goes to stdout and warnings to stderr; saving it is up to you:',
19
20
  ' tic build app.bundle.json > src/di/app.bundle.ts',
21
+ ' tic build common.bundle.yml prod.bundle.yml > src/di/prod.bundle.ts # merged left to right',
20
22
  ' cat app.bundle.yml | tic build > src/di/app.bundle.ts # config from stdin (must set name)',
21
23
  ' tic build app.bundle.json | diff - src/di/app.bundle.ts # CI: is it current?',
22
24
  ].join('\n');
@@ -3,9 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.configFormat = exports.STDIN = exports.CONFIG_FILE_SUFFIXES = exports.BUNDLE_CONFIG_SCHEMA = exports.DEFAULT_EXCLUDE = exports.DEFAULT_DECORATORS = exports.DEFAULT_TSCONFIG = void 0;
6
+ exports.configFormat = exports.STDIN = exports.CONFIG_FILE_SUFFIXES = exports.BUNDLE_CONFIG_FILE_SCHEMA = exports.BUNDLE_CONFIG_SCHEMA = exports.DEFAULT_EXCLUDE = exports.DEFAULT_DECORATORS = exports.DEFAULT_TSCONFIG = void 0;
7
7
  exports.parseConfig = parseConfig;
8
8
  exports.toClassName = toClassName;
9
+ exports.mergeConfigs = mergeConfigs;
9
10
  exports.resolveConfig = resolveConfig;
10
11
  const node_path_1 = __importDefault(require("node:path"));
11
12
  const yaml_1 = require("yaml");
@@ -42,21 +43,17 @@ const globs = () => globList('a glob or a non-empty array of globs', 1);
42
43
  const toList = (value) => (Array.isArray(value) ? value : [value]);
43
44
  const toGlobs = (value) => value === undefined ? undefined : toList(value).map(glob_1.globToRegExp);
44
45
  const FOLDERS_SCHEMA = zod_1.z.array(nonEmptyString(), expected('a non-empty array')).min(1, expected('a non-empty array'));
45
- const GLOB_OBJECT_SCHEMA = zod_1.z.strictObject({
46
- glob: zod_1.z
46
+ const PATHS_DESCRIPTION = 'Which files this bundle reads and parses — decided by path alone, before parsing. `include` names the folders the files come from; within them a file is parsed unless it matches one of `exclude`. Globs are relative to the config file.';
47
+ const PATHS_SCHEMA = zod_1.z
48
+ .strictObject({
49
+ include: zod_1.z
47
50
  .union([nonEmptyString(), FOLDERS_SCHEMA], expected('a folder or a non-empty array of folders'))
48
51
  .describe('Folder, or folders, to scan, sub-folders included: paths relative to the config file (./src/services) or tsconfig paths aliases (@app/services).'),
49
52
  exclude: globList('a glob or an array of globs', 0)
50
53
  .default(exports.DEFAULT_EXCLUDE)
51
54
  .describe('Glob, or globs, of files never read, relative to the config file or starting with a tsconfig paths alias (@generated/**, or @generated for the whole folder). Replaces the default when given. A non-empty value that omits a default glob makes `tic build` warn.'),
52
- }, expected('an object'));
53
- const GLOB_SCHEMA = zod_1.z
54
- .union([
55
- nonEmptyString().describe('One folder to scan, with the default exclude: shorthand for { "glob": [...] }.'),
56
- FOLDERS_SCHEMA.describe('Folders to scan, with the default exclude: shorthand for { "glob": [...] }.'),
57
- GLOB_OBJECT_SCHEMA,
58
- ], expected('a folder, a non-empty array of folders or an object with "glob"'))
59
- .describe('Which files this bundle reads and parses — decided by path alone, before parsing. `glob` names the folders the files come from; within them a file is parsed unless it matches one of `exclude`. Globs are relative to the config file. A folder, or a list of folders, is shorthand for { "glob": [...] }.');
55
+ }, expected('an object with "include"'))
56
+ .describe(PATHS_DESCRIPTION);
60
57
  const CLASS_OBJECT_SCHEMA = zod_1.z.strictObject({
61
58
  export: zod_1.z
62
59
  .enum(['any', 'named', 'default'], expected('"any", "named" or "default"'))
@@ -92,14 +89,17 @@ exports.BUNDLE_CONFIG_SCHEMA = zod_1.z.strictObject({
92
89
  tsconfig: nonEmptyString()
93
90
  .optional()
94
91
  .meta({ default: exports.DEFAULT_TSCONFIG })
95
- .describe('The tsconfig whose `paths` aliases glob.glob may name and generated imports are written in, and whose module resolution sets the import extension. It adds no files: the bundle scans glob.glob alone. Relative to the config file. The default may be absent; a tsconfig named here must exist.'),
92
+ .describe('The tsconfig whose `paths` aliases paths.include may name and generated imports are written in, and whose module resolution sets the import extension. It adds no files: the bundle scans paths.include alone. Relative to the config file. The default may be absent; a tsconfig named here must exist.'),
96
93
  importExtension: zod_1.z
97
94
  .string(expected('a string'))
98
95
  .optional()
99
96
  .describe('Extension of generated imports, e.g. ".js". Inferred from the tsconfig: ".js" under node16/nodenext resolution, none otherwise.'),
100
- glob: GLOB_SCHEMA,
101
- className: CLASSES_SCHEMA.optional(),
97
+ paths: PATHS_SCHEMA,
98
+ match: CLASSES_SCHEMA.optional(),
102
99
  }, expected('an object'));
100
+ exports.BUNDLE_CONFIG_FILE_SCHEMA = exports.BUNDLE_CONFIG_SCHEMA.extend({
101
+ paths: PATHS_SCHEMA.partial().optional().describe(`${PATHS_DESCRIPTION} Required in the merged config.`),
102
+ });
103
103
  function formatPath(segments) {
104
104
  const formatted = segments
105
105
  .map((segment) => (typeof segment === 'number' ? `[${segment}]` : `.${String(segment)}`))
@@ -121,24 +121,19 @@ function formatIssue(issue, prefix = []) {
121
121
  return [`${formatPath(at)}: ${issue.message}`];
122
122
  }
123
123
  const formatIssues = (error) => error.issues.flatMap((issue) => formatIssue(issue)).join('; ');
124
- function toGlobObject(glob) {
125
- if (typeof glob === 'string' || Array.isArray(glob))
126
- return { glob, exclude: exports.DEFAULT_EXCLUDE };
127
- return glob;
128
- }
129
- function toGlobSelector(glob) {
124
+ function toPathSelector(paths) {
130
125
  return {
131
- glob: toList(glob.glob),
132
- exclude: toList(glob.exclude),
126
+ include: toList(paths.include),
127
+ exclude: toList(paths.exclude),
133
128
  };
134
129
  }
135
- function toClassSelector(className) {
136
- if (className === undefined)
130
+ function toClassSelector(match) {
131
+ if (match === undefined)
137
132
  return { export: 'any', decorators: exports.DEFAULT_DECORATORS };
138
- if (typeof className === 'string' || Array.isArray(className)) {
139
- return { export: 'any', decorators: exports.DEFAULT_DECORATORS, glob: toGlobs(className) };
133
+ if (typeof match === 'string' || Array.isArray(match)) {
134
+ return { export: 'any', decorators: exports.DEFAULT_DECORATORS, glob: toGlobs(match) };
140
135
  }
141
- const { decorators, glob, exclude, ...rest } = className;
136
+ const { decorators, glob, exclude, ...rest } = match;
142
137
  return {
143
138
  ...rest,
144
139
  decorators: decorators.length > 0 ? decorators : undefined,
@@ -146,15 +141,15 @@ function toClassSelector(className) {
146
141
  exclude: toGlobs(exclude),
147
142
  };
148
143
  }
149
- function excludeWarnings(glob) {
150
- const exclude = toList(glob.exclude);
144
+ function excludeWarnings(paths) {
145
+ const exclude = toList(paths.exclude);
151
146
  if (exclude.length === 0)
152
147
  return [];
153
148
  const missing = exports.DEFAULT_EXCLUDE.filter((glob) => !exclude.includes(glob));
154
149
  if (missing.length === 0)
155
150
  return [];
156
151
  return [
157
- `glob.exclude replaces the default excludes and omits ${missing.map((glob) => `"${glob}"`).join(', ')}; ` +
152
+ `paths.exclude replaces the default excludes and omits ${missing.map((glob) => `"${glob}"`).join(', ')}; ` +
158
153
  `add them back`,
159
154
  ];
160
155
  }
@@ -180,11 +175,38 @@ function toClassName(name) {
180
175
  const words = name.split(/[-_]+/).filter(Boolean);
181
176
  return `${words.map((word) => word[0].toUpperCase() + word.slice(1)).join('')}Bundle`;
182
177
  }
178
+ const LIST_FIELDS = new Set(['paths.include', 'paths.exclude', 'match.decorators', 'match.glob', 'match.exclude']);
179
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
180
+ function mergeInto(base, next, at = '') {
181
+ const merged = { ...base };
182
+ for (const [key, value] of Object.entries(next)) {
183
+ const field = `${at}${key}`;
184
+ const previous = merged[key];
185
+ if (LIST_FIELDS.has(field)) {
186
+ merged[key] = [
187
+ ...new Set([...toList((previous ?? [])), ...toList(value)]),
188
+ ];
189
+ }
190
+ else {
191
+ merged[key] = isObject(previous) && isObject(value) ? mergeInto(previous, value, `${field}.`) : value;
192
+ }
193
+ }
194
+ return merged;
195
+ }
196
+ const expandMatch = ({ match, ...rest }) => match === undefined ? rest : { ...rest, match: isObject(match) ? match : { glob: match } };
197
+ function mergeConfigs(configs) {
198
+ return configs.reduce((merged, { file, content }) => {
199
+ const result = exports.BUNDLE_CONFIG_FILE_SCHEMA.safeParse(content);
200
+ if (!result.success)
201
+ throw new DomainException_1.TicConfigError(`${node_path_1.default.basename(file)}: ${formatIssues(result.error)}`);
202
+ return mergeInto(merged, expandMatch(content));
203
+ }, {});
204
+ }
183
205
  function resolveConfig(content, file) {
184
206
  const result = exports.BUNDLE_CONFIG_SCHEMA.safeParse(content);
185
207
  if (!result.success)
186
208
  throw new DomainException_1.TicConfigError(formatIssues(result.error));
187
- const { name, tsconfig, importExtension, glob, className } = result.data;
209
+ const { name, tsconfig, importExtension, paths, match } = result.data;
188
210
  const bundleName = name ?? configStem(file);
189
211
  if (bundleName === undefined) {
190
212
  throw new DomainException_1.TicConfigError(node_path_1.default.basename(file) === exports.STDIN
@@ -192,7 +214,6 @@ function resolveConfig(content, file) {
192
214
  : `name: required when the config file is not named <name>${exports.CONFIG_FILE_SUFFIXES.join(' / ')}`);
193
215
  }
194
216
  const dir = node_path_1.default.dirname(file);
195
- const globObject = toGlobObject(glob);
196
217
  return {
197
218
  file,
198
219
  dir,
@@ -200,8 +221,8 @@ function resolveConfig(content, file) {
200
221
  bundleClassName: toClassName(bundleName),
201
222
  tsconfig: { file: node_path_1.default.resolve(dir, tsconfig ?? exports.DEFAULT_TSCONFIG), required: tsconfig !== undefined },
202
223
  importExtension,
203
- glob: toGlobSelector(globObject),
204
- className: toClassSelector(className),
205
- warnings: excludeWarnings(globObject),
224
+ paths: toPathSelector(paths),
225
+ match: toClassSelector(match),
226
+ warnings: excludeWarnings(paths),
206
227
  };
207
228
  }