@ts-ioc-container/bundler 0.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 +40 -0
- package/README.md +335 -0
- package/cjm/ImportPaths.js +134 -0
- package/cjm/bin.js +5 -0
- package/cjm/build.js +90 -0
- package/cjm/cli.js +82 -0
- package/cjm/config.js +118 -0
- package/cjm/emit.js +33 -0
- package/cjm/errors.js +15 -0
- package/cjm/exportPredicate.js +13 -0
- package/cjm/glob.js +27 -0
- package/cjm/inclusion.js +24 -0
- package/cjm/index.js +36 -0
- package/cjm/predicateFile.js +30 -0
- package/cjm/render.js +15 -0
- package/cjm/scan.js +144 -0
- package/cjm/utils.js +2 -0
- package/lib/ImportPaths.ts +127 -0
- package/lib/bin.ts +4 -0
- package/lib/build.ts +157 -0
- package/lib/cli.ts +88 -0
- package/lib/config.ts +206 -0
- package/lib/emit.ts +47 -0
- package/lib/errors.ts +14 -0
- package/lib/exportPredicate.ts +45 -0
- package/lib/glob.ts +24 -0
- package/lib/inclusion.ts +61 -0
- package/lib/index.ts +40 -0
- package/lib/predicateFile.ts +36 -0
- package/lib/render.ts +19 -0
- package/lib/scan.ts +133 -0
- package/lib/utils.ts +2 -0
- package/package.json +84 -0
- package/tic.schema.json +134 -0
- package/tpl/index.cjs +81 -0
- package/typings/ImportPaths.d.ts +37 -0
- package/typings/ImportPaths.d.ts.map +1 -0
- package/typings/bin.d.ts +3 -0
- package/typings/bin.d.ts.map +1 -0
- package/typings/build.d.ts +39 -0
- package/typings/build.d.ts.map +1 -0
- package/typings/cli.d.ts +8 -0
- package/typings/cli.d.ts.map +1 -0
- package/typings/config.d.ts +95 -0
- package/typings/config.d.ts.map +1 -0
- package/typings/emit.d.ts +18 -0
- package/typings/emit.d.ts.map +1 -0
- package/typings/errors.d.ts +13 -0
- package/typings/errors.d.ts.map +1 -0
- package/typings/exportPredicate.d.ts +36 -0
- package/typings/exportPredicate.d.ts.map +1 -0
- package/typings/glob.d.ts +7 -0
- package/typings/glob.d.ts.map +1 -0
- package/typings/inclusion.d.ts +42 -0
- package/typings/inclusion.d.ts.map +1 -0
- package/typings/index.d.ts +12 -0
- package/typings/index.d.ts.map +1 -0
- package/typings/predicateFile.d.ts +16 -0
- package/typings/predicateFile.d.ts.map +1 -0
- package/typings/render.d.ts +8 -0
- package/typings/render.d.ts.map +1 -0
- package/typings/scan.d.ts +22 -0
- package/typings/scan.d.ts.map +1 -0
- package/typings/utils.d.ts +3 -0
- package/typings/utils.d.ts.map +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as ts from 'typescript';
|
|
4
|
+
import { NamespaceNotFoundError, TicConfigError } from './errors';
|
|
5
|
+
import { toPosix } from './glob';
|
|
6
|
+
|
|
7
|
+
interface Alias {
|
|
8
|
+
pattern: string;
|
|
9
|
+
/** Absolute, `/`-separated. */
|
|
10
|
+
targets: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SOURCE_EXTENSION = /\.(tsx?|mts|cts)$/;
|
|
14
|
+
/** tsconfig "No inputs were found": irrelevant here, the bundler scans its own folders. */
|
|
15
|
+
const NO_INPUTS = 18003;
|
|
16
|
+
|
|
17
|
+
const isDirectory = (dir: string) => existsSync(dir) && statSync(dir).isDirectory();
|
|
18
|
+
const isRelative = (spec: string) => spec.startsWith('.') || path.isAbsolute(spec);
|
|
19
|
+
|
|
20
|
+
/** Splits a `paths` pattern at its `*`, if it has one. */
|
|
21
|
+
function splitWildcard(pattern: string): [string, string] | undefined {
|
|
22
|
+
const star = pattern.indexOf('*');
|
|
23
|
+
return star === -1 ? undefined : [pattern.slice(0, star), pattern.slice(star + 1)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Import paths as a project's `tsconfig.json` sees them: resolves namespaces
|
|
28
|
+
* written as `paths` aliases to folders, and writes imports back in alias form.
|
|
29
|
+
*/
|
|
30
|
+
export class ImportPaths {
|
|
31
|
+
/**
|
|
32
|
+
* Reads `paths` (following `extends`) and the extension imports need under the
|
|
33
|
+
* tsconfig's `moduleResolution`; `importExtension` overrides the latter.
|
|
34
|
+
*
|
|
35
|
+
* @throws {TicConfigError} when a required tsconfig is missing or cannot be parsed.
|
|
36
|
+
*/
|
|
37
|
+
static load(tsconfig: { file: string; required: boolean }, importExtension?: string): ImportPaths {
|
|
38
|
+
if (!existsSync(tsconfig.file)) {
|
|
39
|
+
if (tsconfig.required) throw new TicConfigError(`tsconfig not found: ${tsconfig.file}`);
|
|
40
|
+
return new ImportPaths([], importExtension ?? '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { config, error } = ts.readConfigFile(tsconfig.file, ts.sys.readFile);
|
|
44
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(tsconfig.file), undefined, tsconfig.file);
|
|
45
|
+
const [fatal] = [error, ...parsed.errors].filter((d): d is ts.Diagnostic => !!d && d.code !== NO_INPUTS);
|
|
46
|
+
if (fatal) {
|
|
47
|
+
throw new TicConfigError(`${tsconfig.file}: ${ts.flattenDiagnosticMessageText(fatal.messageText, '\n')}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { paths = {}, baseUrl, pathsBasePath, moduleResolution } = parsed.options;
|
|
51
|
+
// Without baseUrl, paths resolve against the tsconfig that declared them (which `extends` may make another file).
|
|
52
|
+
const base = baseUrl ?? (typeof pathsBasePath === 'string' ? pathsBasePath : path.dirname(tsconfig.file));
|
|
53
|
+
const aliases = Object.entries(paths).map(([pattern, targets]) => ({
|
|
54
|
+
pattern,
|
|
55
|
+
targets: targets.map((target) => toPosix(path.resolve(base, target))),
|
|
56
|
+
}));
|
|
57
|
+
const nodeEsm =
|
|
58
|
+
moduleResolution === ts.ModuleResolutionKind.Node16 || moduleResolution === ts.ModuleResolutionKind.NodeNext;
|
|
59
|
+
return new ImportPaths(aliases, importExtension ?? (nodeEsm ? '.js' : ''));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly aliases: Alias[],
|
|
64
|
+
readonly extension: string,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The folder a namespace names: a path relative to `baseDir`, or a `paths` alias.
|
|
69
|
+
*
|
|
70
|
+
* @throws {NamespaceNotFoundError} when the namespace is neither an existing folder nor an alias of one.
|
|
71
|
+
*/
|
|
72
|
+
resolveNamespace(namespace: string, baseDir: string): string {
|
|
73
|
+
const folder = path.resolve(baseDir, namespace);
|
|
74
|
+
if (isDirectory(folder)) return folder;
|
|
75
|
+
if (!isRelative(namespace)) {
|
|
76
|
+
// `@app` also matches an `@app/*` pattern, naming the alias root itself.
|
|
77
|
+
const found = [namespace, `${namespace}/`].flatMap((spec) => this.aliasTargets(spec)).find(isDirectory);
|
|
78
|
+
if (found) return path.normalize(found);
|
|
79
|
+
}
|
|
80
|
+
throw new NamespaceNotFoundError(
|
|
81
|
+
`namespace "${namespace}" is neither a folder relative to ${baseDir} nor a tsconfig paths alias of one`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The specifier `fromFile` imports `toFile` by: its most specific alias, else a relative path. */
|
|
86
|
+
specifier(fromFile: string, toFile: string): string {
|
|
87
|
+
const target = toPosix(toFile);
|
|
88
|
+
const bare = target.replace(SOURCE_EXTENSION, '');
|
|
89
|
+
let best: { spec: string; score: number } | undefined;
|
|
90
|
+
for (const { pattern, targets } of this.aliases) {
|
|
91
|
+
for (const aliasTarget of targets) {
|
|
92
|
+
const match = this.matchAlias(pattern, aliasTarget, target, bare);
|
|
93
|
+
if (match && (!best || match.score > best.score)) best = match;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (best) return best.spec;
|
|
97
|
+
|
|
98
|
+
const relative = toPosix(path.relative(path.dirname(fromFile), bare));
|
|
99
|
+
return `${relative.startsWith('.') ? relative : `./${relative}`}${this.extension}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private aliasTargets(spec: string): string[] {
|
|
103
|
+
return this.aliases.flatMap(({ pattern, targets }) => {
|
|
104
|
+
const wildcard = splitWildcard(pattern);
|
|
105
|
+
if (!wildcard) return pattern === spec ? targets : [];
|
|
106
|
+
const [prefix, suffix] = wildcard;
|
|
107
|
+
if (!spec.startsWith(prefix) || !spec.endsWith(suffix) || spec.length < prefix.length + suffix.length) return [];
|
|
108
|
+
const captured = spec.slice(prefix.length, spec.length - suffix.length);
|
|
109
|
+
return targets.map((target) => target.replace('*', captured));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A longer matched target prefix is a more specific alias; an exact file alias beats every wildcard. */
|
|
114
|
+
private matchAlias(pattern: string, aliasTarget: string, file: string, bare: string) {
|
|
115
|
+
const wildcard = splitWildcard(aliasTarget);
|
|
116
|
+
if (!wildcard) {
|
|
117
|
+
const exact = aliasTarget === file || aliasTarget.replace(SOURCE_EXTENSION, '') === bare;
|
|
118
|
+
return exact && !splitWildcard(pattern) ? { spec: pattern, score: Infinity } : undefined;
|
|
119
|
+
}
|
|
120
|
+
const [prefix, suffix] = wildcard;
|
|
121
|
+
if (!bare.startsWith(prefix) || !bare.endsWith(suffix) || bare.length < prefix.length + suffix.length) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const captured = bare.slice(prefix.length, bare.length - suffix.length);
|
|
125
|
+
return { spec: `${pattern.replace('*', captured)}${this.extension}`, score: prefix.length };
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/bin.ts
ADDED
package/lib/build.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_CONFIG_FILE, loadConfig, type ResolvedConfig, type ResolvedBundle } from './config';
|
|
4
|
+
import { emitBundle } from './emit';
|
|
5
|
+
import { globToRegExp, toPosix } from './glob';
|
|
6
|
+
import { ImportPaths } from './ImportPaths';
|
|
7
|
+
import { type ExportPredicate, findConventionalExportPredicate, loadExportPredicate } from './exportPredicate';
|
|
8
|
+
import { fileTags, findConventionalPredicate, type InclusionPredicate, loadInclusionPredicate } from './inclusion';
|
|
9
|
+
import { findClasses, listSourceFiles } from './scan';
|
|
10
|
+
|
|
11
|
+
export interface BuildOptions {
|
|
12
|
+
/** Path of `tic.config.json`, relative to `cwd`. Default `tic.config.json`. */
|
|
13
|
+
config?: string;
|
|
14
|
+
/** Default `process.cwd()`. */
|
|
15
|
+
cwd?: string;
|
|
16
|
+
/** Compare instead of writing: outputs that would change are reported `stale`. */
|
|
17
|
+
check?: boolean;
|
|
18
|
+
/** Decides which scanned files take part; overrides every inclusion predicate file. */
|
|
19
|
+
include?: InclusionPredicate;
|
|
20
|
+
/** Decides which parsed classes become registrations; overrides every export predicate file. */
|
|
21
|
+
filterExports?: ExportPredicate;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `written` / `unchanged` after a build; `stale` / `unchanged` after a check. */
|
|
25
|
+
export type OutputStatus = 'written' | 'unchanged' | 'stale';
|
|
26
|
+
|
|
27
|
+
export interface OutputResult {
|
|
28
|
+
/** Absolute path of the generated file. */
|
|
29
|
+
file: string;
|
|
30
|
+
/** Name of the generated class. */
|
|
31
|
+
bundle: string;
|
|
32
|
+
status: OutputStatus;
|
|
33
|
+
registrations: number;
|
|
34
|
+
content: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BuildResult {
|
|
38
|
+
/** Absolute path of the config that was built. */
|
|
39
|
+
config: string;
|
|
40
|
+
outputs: OutputResult[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates one bundle per config entry. Every bundle is generated before any
|
|
45
|
+
* file is written, so a failing namespace leaves the previous outputs intact.
|
|
46
|
+
*
|
|
47
|
+
* @throws {TicConfigError} when the config, the tsconfig or a predicate file it names is missing or invalid.
|
|
48
|
+
* @throws {NamespaceNotFoundError} when a namespace is neither a folder nor a tsconfig paths alias of one.
|
|
49
|
+
*/
|
|
50
|
+
export function build({
|
|
51
|
+
config = DEFAULT_CONFIG_FILE,
|
|
52
|
+
cwd = process.cwd(),
|
|
53
|
+
check = false,
|
|
54
|
+
include,
|
|
55
|
+
filterExports,
|
|
56
|
+
}: BuildOptions = {}): BuildResult {
|
|
57
|
+
const resolved = loadConfig(path.resolve(cwd, config));
|
|
58
|
+
const paths = ImportPaths.load(resolved.tsconfig, resolved.importExtension);
|
|
59
|
+
const includeFor = predicateResolver({
|
|
60
|
+
override: include,
|
|
61
|
+
conventional: findConventionalPredicate(resolved.dir),
|
|
62
|
+
fileOf: (bundle) => bundle.include,
|
|
63
|
+
load: loadInclusionPredicate,
|
|
64
|
+
});
|
|
65
|
+
const filterExportsFor = predicateResolver({
|
|
66
|
+
override: filterExports,
|
|
67
|
+
conventional: findConventionalExportPredicate(resolved.dir),
|
|
68
|
+
fileOf: (bundle) => bundle.filterExports,
|
|
69
|
+
load: loadExportPredicate,
|
|
70
|
+
});
|
|
71
|
+
const generated = resolved.bundles.map((bundle) =>
|
|
72
|
+
generate(resolved, bundle, paths, { include: includeFor(bundle), filterExports: filterExportsFor(bundle) }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const outputs = generated.map(({ bundle, content, registrations }): OutputResult => {
|
|
76
|
+
const current = existsSync(bundle.output) ? readFileSync(bundle.output, 'utf8') : undefined;
|
|
77
|
+
const status: OutputStatus = current === content ? 'unchanged' : check ? 'stale' : 'written';
|
|
78
|
+
if (status === 'written') {
|
|
79
|
+
mkdirSync(path.dirname(bundle.output), { recursive: true });
|
|
80
|
+
writeFileSync(bundle.output, content);
|
|
81
|
+
}
|
|
82
|
+
return { file: bundle.output, bundle: bundle.name, status, registrations, content };
|
|
83
|
+
});
|
|
84
|
+
return { config: resolved.file, outputs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which predicate of one kind a bundle uses: the one passed to `build`, else the bundle's own file,
|
|
89
|
+
* else the conventional file next to the config, else none. Each file is loaded once, and only when
|
|
90
|
+
* a bundle needs it.
|
|
91
|
+
*
|
|
92
|
+
* @throws {TicConfigError} when a predicate file a bundle needs is missing or does not export a function.
|
|
93
|
+
*/
|
|
94
|
+
function predicateResolver<P>({
|
|
95
|
+
override,
|
|
96
|
+
conventional,
|
|
97
|
+
fileOf,
|
|
98
|
+
load,
|
|
99
|
+
}: {
|
|
100
|
+
override?: P;
|
|
101
|
+
conventional?: string;
|
|
102
|
+
fileOf: (bundle: ResolvedBundle) => string | undefined;
|
|
103
|
+
load: (file: string) => P;
|
|
104
|
+
}) {
|
|
105
|
+
const loaded = new Map<string, P>();
|
|
106
|
+
return (bundle: ResolvedBundle): P | undefined => {
|
|
107
|
+
if (override) return override;
|
|
108
|
+
const file = fileOf(bundle) ?? conventional;
|
|
109
|
+
if (file === undefined) return undefined;
|
|
110
|
+
if (!loaded.has(file)) loaded.set(file, load(file));
|
|
111
|
+
return loaded.get(file);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @throws {NamespaceNotFoundError} when a namespace is neither a folder nor a tsconfig paths alias of one.
|
|
117
|
+
*/
|
|
118
|
+
function generate(
|
|
119
|
+
config: ResolvedConfig,
|
|
120
|
+
bundle: ResolvedBundle,
|
|
121
|
+
paths: ImportPaths,
|
|
122
|
+
{ include, filterExports }: { include?: InclusionPredicate; filterExports?: ExportPredicate },
|
|
123
|
+
) {
|
|
124
|
+
const relative = (file: string) => toPosix(path.relative(config.dir, file));
|
|
125
|
+
const outputs = new Set(config.bundles.map((m) => m.output));
|
|
126
|
+
const excludes = bundle.exclude.map(globToRegExp);
|
|
127
|
+
const isExcluded = (file: string) => {
|
|
128
|
+
if (outputs.has(file)) return true;
|
|
129
|
+
const filename = relative(file);
|
|
130
|
+
return excludes.some((glob) => glob.test(filename)) || (include !== undefined && !include({ filename }));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const files = new Set<string>();
|
|
134
|
+
for (const namespace of bundle.namespaces) {
|
|
135
|
+
const dir = paths.resolveNamespace(namespace.path, config.dir);
|
|
136
|
+
for (const file of listSourceFiles(dir, namespace.recursive, isExcluded)) files.add(file);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const classes = [...files]
|
|
140
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
141
|
+
.flatMap((file) => findClasses(file, bundle.select))
|
|
142
|
+
.filter((cls) => {
|
|
143
|
+
if (!filterExports) return true;
|
|
144
|
+
const filename = relative(cls.file);
|
|
145
|
+
const { exportName, className, isDefault, decorators } = cls;
|
|
146
|
+
return filterExports({ filename, exportName, className, isDefault, decorators, tags: fileTags(filename) });
|
|
147
|
+
})
|
|
148
|
+
.map((cls) => ({ ...cls, specifier: paths.specifier(bundle.output, cls.file) }));
|
|
149
|
+
|
|
150
|
+
const content = emitBundle({
|
|
151
|
+
name: bundle.name,
|
|
152
|
+
configPath: toPosix(path.relative(path.dirname(bundle.output), config.file)),
|
|
153
|
+
namespaces: bundle.namespaces.map((ns) => ns.path),
|
|
154
|
+
classes,
|
|
155
|
+
});
|
|
156
|
+
return { bundle, content, registrations: classes.length };
|
|
157
|
+
}
|
package/lib/cli.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { build, type OutputStatus } from './build';
|
|
4
|
+
import { TicError } from './errors';
|
|
5
|
+
|
|
6
|
+
export interface CliIo {
|
|
7
|
+
cwd: string;
|
|
8
|
+
stdout: (line: string) => void;
|
|
9
|
+
stderr: (line: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const USAGE = [
|
|
13
|
+
'Usage: ts-ioc-container <command> [options]',
|
|
14
|
+
' tic is a shortcut for ts-ioc-container',
|
|
15
|
+
'',
|
|
16
|
+
'Commands:',
|
|
17
|
+
' ts-ioc-container build [--config <path>] [--check] generate the bundles described by tic.config.json',
|
|
18
|
+
'',
|
|
19
|
+
'Options:',
|
|
20
|
+
' -c, --config <path> config file (default: tic.config.json)',
|
|
21
|
+
' --check write nothing; exit 1 when a generated bundle is out of date',
|
|
22
|
+
' -h, --help show this help',
|
|
23
|
+
' -v, --version show the version',
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
26
|
+
const VERB: Record<OutputStatus, string> = { written: 'wrote', unchanged: 'unchanged', stale: 'stale' };
|
|
27
|
+
|
|
28
|
+
const plural = (n: number) => `${n} registration${n === 1 ? '' : 's'}`;
|
|
29
|
+
|
|
30
|
+
function version(): string {
|
|
31
|
+
return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class UsageError extends TicError {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @throws {UsageError} when an option is unknown or `--config` has no value.
|
|
38
|
+
*/
|
|
39
|
+
function parseBuildArgs(args: string[]) {
|
|
40
|
+
const options: { config?: string; check: boolean } = { check: false };
|
|
41
|
+
for (let i = 0; i < args.length; i++) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
if (arg === '--check') options.check = true;
|
|
44
|
+
else if (arg === '--config' || arg === '-c') {
|
|
45
|
+
options.config = args[++i];
|
|
46
|
+
if (!options.config) throw new UsageError(`${arg} needs a path`);
|
|
47
|
+
} else if (arg.startsWith('--config=')) options.config = arg.slice('--config='.length);
|
|
48
|
+
else throw new UsageError(`unknown option "${arg}"`);
|
|
49
|
+
}
|
|
50
|
+
return options;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Runs the `tic` CLI and returns its exit code; output goes through `io`. */
|
|
54
|
+
export function run(
|
|
55
|
+
argv: string[],
|
|
56
|
+
io: CliIo = { cwd: process.cwd(), stdout: console.log, stderr: console.error },
|
|
57
|
+
): number {
|
|
58
|
+
const [command, ...rest] = argv;
|
|
59
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
60
|
+
io.stdout(USAGE);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (command === '--version' || command === '-v') {
|
|
64
|
+
io.stdout(version());
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
if (command !== 'build') {
|
|
69
|
+
io.stderr(command ? `tic: unknown command "${command}"` : USAGE);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
const { config, check } = parseBuildArgs(rest);
|
|
73
|
+
const { outputs } = build({ config, check, cwd: io.cwd });
|
|
74
|
+
for (const { status, file, registrations } of outputs) {
|
|
75
|
+
io.stdout(`${VERB[status].padEnd(10)}${path.relative(io.cwd, file)} (${plural(registrations)})`);
|
|
76
|
+
}
|
|
77
|
+
const stale = outputs.filter((o) => o.status === 'stale').length;
|
|
78
|
+
if (stale > 0) {
|
|
79
|
+
io.stderr(`tic: ${stale} generated bundle${stale === 1 ? ' is' : 's are'} out of date — run \`tic build\``);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (!(e instanceof TicError)) throw e;
|
|
85
|
+
io.stderr(`tic: ${e.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { TicConfigError } from './errors';
|
|
4
|
+
import { globToRegExp } from './glob';
|
|
5
|
+
|
|
6
|
+
/** Which exports of a file count: both kinds, only named exports, or only the default export. */
|
|
7
|
+
export type ExportKind = 'any' | 'named' | 'default';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How the bundler picks target classes out of a file. A class is selected when
|
|
11
|
+
* it is exported, not abstract, and meets every criterion set here; an empty
|
|
12
|
+
* rule selects every exported class.
|
|
13
|
+
*/
|
|
14
|
+
export interface ClassSelector {
|
|
15
|
+
/** Default `any`. */
|
|
16
|
+
export?: ExportKind;
|
|
17
|
+
/** The class must carry one of these decorators, recognised by name (`register`, or a composed one). */
|
|
18
|
+
decorators?: string[];
|
|
19
|
+
/** A glob the class name must match, e.g. `*Service`. An anonymous default export is named after its file. */
|
|
20
|
+
nameGlob?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A {@link ClassSelector} with its defaults filled in and its `nameGlob` compiled. */
|
|
24
|
+
export interface ResolvedSelector {
|
|
25
|
+
export: ExportKind;
|
|
26
|
+
decorators?: string[];
|
|
27
|
+
nameGlob?: RegExp;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NamespaceConfig {
|
|
31
|
+
/** A folder relative to the config file (`./src/services`) or a tsconfig `paths` alias (`@app/services`). */
|
|
32
|
+
path: string;
|
|
33
|
+
/** Scan sub-folders too. Default `true`. */
|
|
34
|
+
recursive?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BundleConfig {
|
|
38
|
+
/** The generated file, relative to the config file. */
|
|
39
|
+
output: string;
|
|
40
|
+
/** Name of the generated class, an `IContainerModule`. Default `Bundle`. */
|
|
41
|
+
name?: string;
|
|
42
|
+
namespaces: (string | NamespaceConfig)[];
|
|
43
|
+
/** Which classes of a scanned file are registered. Default: every exported class. */
|
|
44
|
+
select?: ClassSelector;
|
|
45
|
+
/** Globs, relative to the config file, of files never scanned. Replaces {@link DEFAULT_EXCLUDE} when given. */
|
|
46
|
+
exclude?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* A file exporting an `InclusionPredicate`, relative to the config file. Overrides the
|
|
49
|
+
* conventional `tic.include.*` next to the config.
|
|
50
|
+
*/
|
|
51
|
+
include?: string;
|
|
52
|
+
/**
|
|
53
|
+
* A file exporting an `ExportPredicate`, relative to the config file. Overrides the
|
|
54
|
+
* conventional `tic.exports.*` next to the config.
|
|
55
|
+
*/
|
|
56
|
+
filterExports?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The shape of `tic.config.json`. */
|
|
60
|
+
export interface TicConfig {
|
|
61
|
+
$schema?: string;
|
|
62
|
+
/** Relative to the config file. Default `tsconfig.json`, which may be absent. */
|
|
63
|
+
tsconfig?: string;
|
|
64
|
+
/** Extension of generated imports. Inferred from the tsconfig's `moduleResolution` when omitted. */
|
|
65
|
+
importExtension?: string;
|
|
66
|
+
bundles: BundleConfig[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_CONFIG_FILE = 'tic.config.json';
|
|
70
|
+
export const DEFAULT_BUNDLE_NAME = 'Bundle';
|
|
71
|
+
export const DEFAULT_EXCLUDE = [
|
|
72
|
+
'**/*.spec.ts',
|
|
73
|
+
'**/*.test.ts',
|
|
74
|
+
'**/*.spec.tsx',
|
|
75
|
+
'**/*.test.tsx',
|
|
76
|
+
'**/__tests__/**',
|
|
77
|
+
'**/node_modules/**',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** A config whose defaults are filled in and whose paths are absolute. */
|
|
81
|
+
export interface ResolvedConfig {
|
|
82
|
+
file: string;
|
|
83
|
+
dir: string;
|
|
84
|
+
tsconfig: { file: string; required: boolean };
|
|
85
|
+
importExtension?: string;
|
|
86
|
+
bundles: ResolvedBundle[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ResolvedBundle {
|
|
90
|
+
output: string;
|
|
91
|
+
name: string;
|
|
92
|
+
namespaces: Required<NamespaceConfig>[];
|
|
93
|
+
select: ResolvedSelector;
|
|
94
|
+
exclude: string[];
|
|
95
|
+
/** Absolute path of the bundle's own predicate file, when it names one. */
|
|
96
|
+
include?: string;
|
|
97
|
+
/** Absolute path of the bundle's own export predicate file, when it names one. */
|
|
98
|
+
filterExports?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Json = Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
const isObject = (value: unknown): value is Json =>
|
|
104
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
105
|
+
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
|
106
|
+
const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isNonEmptyString);
|
|
107
|
+
const isIdentifier = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z_$][\w$]*$/.test(value);
|
|
108
|
+
|
|
109
|
+
const fail = (field: string, expected: string): never => {
|
|
110
|
+
throw new TicConfigError(`${field}: expected ${expected}`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @throws {TicConfigError} when a namespace entry is neither a non-empty string nor an object with a `path`.
|
|
115
|
+
*/
|
|
116
|
+
function toNamespace(value: unknown, field: string): Required<NamespaceConfig> {
|
|
117
|
+
if (isNonEmptyString(value)) return { path: value, recursive: true };
|
|
118
|
+
if (!isObject(value)) return fail(field, 'a string or an object with "path"');
|
|
119
|
+
if (!isNonEmptyString(value.path)) return fail(`${field}.path`, 'a non-empty string');
|
|
120
|
+
if (value.recursive !== undefined && typeof value.recursive !== 'boolean')
|
|
121
|
+
return fail(`${field}.recursive`, 'a boolean');
|
|
122
|
+
return { path: value.path, recursive: value.recursive ?? true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SELECTOR_FIELDS = new Set(['export', 'decorators', 'nameGlob']);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @throws {TicConfigError} when the rule is not an object, has an unknown field, or a field has the wrong type.
|
|
129
|
+
*/
|
|
130
|
+
function toSelector(value: unknown, field: string): ResolvedSelector {
|
|
131
|
+
if (value === undefined) return { export: 'any' };
|
|
132
|
+
if (!isObject(value)) return fail(field, 'an object');
|
|
133
|
+
const unknown = Object.keys(value).find((key) => !SELECTOR_FIELDS.has(key));
|
|
134
|
+
if (unknown) throw new TicConfigError(`${field}.${unknown}: unknown field`);
|
|
135
|
+
const { export: kind, decorators, nameGlob } = value;
|
|
136
|
+
if (kind !== undefined && kind !== 'any' && kind !== 'named' && kind !== 'default') {
|
|
137
|
+
return fail(`${field}.export`, '"any", "named" or "default"');
|
|
138
|
+
}
|
|
139
|
+
if (decorators !== undefined && !(isStringArray(decorators) && decorators.length > 0)) {
|
|
140
|
+
return fail(`${field}.decorators`, 'a non-empty array of strings');
|
|
141
|
+
}
|
|
142
|
+
if (nameGlob !== undefined && !isNonEmptyString(nameGlob)) return fail(`${field}.nameGlob`, 'a non-empty string');
|
|
143
|
+
return { export: kind ?? 'any', decorators, nameGlob: nameGlob === undefined ? undefined : globToRegExp(nameGlob) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @throws {TicConfigError} when a bundle field is missing or has the wrong type.
|
|
148
|
+
*/
|
|
149
|
+
function toBundle(value: unknown, field: string, dir: string): ResolvedBundle {
|
|
150
|
+
if (!isObject(value)) return fail(field, 'an object');
|
|
151
|
+
const { output, name, namespaces, select, exclude, include, filterExports } = value;
|
|
152
|
+
if (!isNonEmptyString(output)) return fail(`${field}.output`, 'a non-empty string');
|
|
153
|
+
if (name !== undefined && !isIdentifier(name)) return fail(`${field}.name`, 'a valid identifier');
|
|
154
|
+
if (!Array.isArray(namespaces) || namespaces.length === 0) return fail(`${field}.namespaces`, 'a non-empty array');
|
|
155
|
+
if (exclude !== undefined && !isStringArray(exclude)) return fail(`${field}.exclude`, 'an array of strings');
|
|
156
|
+
if (include !== undefined && !isNonEmptyString(include)) return fail(`${field}.include`, 'a non-empty string');
|
|
157
|
+
if (filterExports !== undefined && !isNonEmptyString(filterExports)) {
|
|
158
|
+
return fail(`${field}.filterExports`, 'a non-empty string');
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
output: path.resolve(dir, output),
|
|
162
|
+
name: name ?? DEFAULT_BUNDLE_NAME,
|
|
163
|
+
namespaces: namespaces.map((ns, i) => toNamespace(ns, `${field}.namespaces[${i}]`)),
|
|
164
|
+
select: toSelector(select, `${field}.select`),
|
|
165
|
+
exclude: exclude ?? DEFAULT_EXCLUDE,
|
|
166
|
+
include: include === undefined ? undefined : path.resolve(dir, include),
|
|
167
|
+
filterExports: filterExports === undefined ? undefined : path.resolve(dir, filterExports),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validates parsed `tic.config.json` content and resolves its paths against `file`'s directory.
|
|
173
|
+
*
|
|
174
|
+
* @throws {TicConfigError} when the content does not match the config shape; the message names the field.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveConfig(content: unknown, file: string): ResolvedConfig {
|
|
177
|
+
const dir = path.dirname(file);
|
|
178
|
+
if (!isObject(content)) return fail('config', 'a JSON object');
|
|
179
|
+
const { tsconfig, importExtension, bundles } = content;
|
|
180
|
+
if (tsconfig !== undefined && !isNonEmptyString(tsconfig)) return fail('tsconfig', 'a non-empty string');
|
|
181
|
+
if (importExtension !== undefined && typeof importExtension !== 'string') return fail('importExtension', 'a string');
|
|
182
|
+
if (!Array.isArray(bundles) || bundles.length === 0) return fail('bundles', 'a non-empty array');
|
|
183
|
+
return {
|
|
184
|
+
file,
|
|
185
|
+
dir,
|
|
186
|
+
tsconfig: { file: path.resolve(dir, tsconfig ?? 'tsconfig.json'), required: tsconfig !== undefined },
|
|
187
|
+
importExtension,
|
|
188
|
+
bundles: bundles.map((m, i) => toBundle(m, `bundles[${i}]`, dir)),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reads and validates a `tic.config.json`.
|
|
194
|
+
*
|
|
195
|
+
* @throws {TicConfigError} when the file is missing, is not valid JSON, or does not match the config shape.
|
|
196
|
+
*/
|
|
197
|
+
export function loadConfig(file: string): ResolvedConfig {
|
|
198
|
+
if (!existsSync(file)) throw new TicConfigError(`config file not found: ${file}`);
|
|
199
|
+
let content: unknown;
|
|
200
|
+
try {
|
|
201
|
+
content = JSON.parse(readFileSync(file, 'utf8'));
|
|
202
|
+
} catch (e) {
|
|
203
|
+
throw new TicConfigError(`${file} is not valid JSON: ${(e as Error).message}`);
|
|
204
|
+
}
|
|
205
|
+
return resolveConfig(content, file);
|
|
206
|
+
}
|
package/lib/emit.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { renderProtocol } from './render';
|
|
2
|
+
import type { DiscoveredClass } from './scan';
|
|
3
|
+
|
|
4
|
+
export interface EmitInput {
|
|
5
|
+
/** Name of the generated class. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Shown in the header: where the output came from. */
|
|
8
|
+
configPath: string;
|
|
9
|
+
namespaces: string[];
|
|
10
|
+
classes: (DiscoveredClass & { specifier: string })[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RUNTIME_IMPORTS = ['IContainer', 'IContainerModule', 'IRegistration', 'Registration'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the generated bundle — static imports, `registrations`, and the bundle applying them —
|
|
17
|
+
* from its protocol, `protocols/Bundle.ts.hbs`. This function only prepares the data: unique local
|
|
18
|
+
* names, and one import per file in file order.
|
|
19
|
+
*/
|
|
20
|
+
export function emitBundle({ name, configPath, namespaces, classes }: EmitInput): string {
|
|
21
|
+
const taken = new Set([...RUNTIME_IMPORTS, 'registrations', name]);
|
|
22
|
+
const uniqueName = (wanted: string) => {
|
|
23
|
+
let candidate = wanted;
|
|
24
|
+
for (let n = 2; taken.has(candidate); n++) candidate = `${wanted}_${n}`;
|
|
25
|
+
taken.add(candidate);
|
|
26
|
+
return candidate;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// One import statement per file, in file order.
|
|
30
|
+
const byFile = new Map<string, { specifier: string; defaultName?: string; named: string[] }>();
|
|
31
|
+
const bindings = classes.map((cls) => {
|
|
32
|
+
const binding = uniqueName(cls.localName);
|
|
33
|
+
const entry = byFile.get(cls.file) ?? { specifier: cls.specifier, named: [] };
|
|
34
|
+
byFile.set(cls.file, entry);
|
|
35
|
+
if (cls.exportName === 'default') entry.defaultName = binding;
|
|
36
|
+
else entry.named.push(binding === cls.exportName ? binding : `${cls.exportName} as ${binding}`);
|
|
37
|
+
return binding;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return renderProtocol('Bundle.ts.hbs', {
|
|
41
|
+
configPath,
|
|
42
|
+
namespaces,
|
|
43
|
+
name,
|
|
44
|
+
imports: [...byFile.values()],
|
|
45
|
+
registrations: bindings,
|
|
46
|
+
});
|
|
47
|
+
}
|
package/lib/errors.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Base class of every error the bundler raises on purpose; the CLI prints its message without a stack. */
|
|
2
|
+
export class TicError extends Error {
|
|
3
|
+
name = 'TicError';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** The config file is missing, is not JSON, or does not match the `tic.config.json` shape. */
|
|
7
|
+
export class TicConfigError extends TicError {
|
|
8
|
+
name = 'TicConfigError';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A namespace is neither an existing folder nor a `tsconfig.json` `paths` alias of one. */
|
|
12
|
+
export class NamespaceNotFoundError extends TicError {
|
|
13
|
+
name = 'NamespaceNotFoundError';
|
|
14
|
+
}
|