@forinda/kickjs-cli 2.1.0 → 2.2.1
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/dist/cli.mjs +1277 -78
- package/dist/index.d.mts +56 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +150 -55
- package/dist/index.mjs.map +1 -1
- package/dist/typegen-UejiKdXA.mjs +886 -0
- package/dist/typegen-UejiKdXA.mjs.map +1 -0
- package/package.json +16 -13
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forinda/kickjs-cli v2.2.1
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) Felix Orinda
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
13
|
+
//#region src/typegen/scanner.ts
|
|
14
|
+
/** Decorators that mark a class as DI-managed */
|
|
15
|
+
const DECORATOR_NAMES = [
|
|
16
|
+
"Service",
|
|
17
|
+
"Controller",
|
|
18
|
+
"Repository",
|
|
19
|
+
"Injectable",
|
|
20
|
+
"Component",
|
|
21
|
+
"Module"
|
|
22
|
+
];
|
|
23
|
+
const DEFAULT_EXTENSIONS = [
|
|
24
|
+
".ts",
|
|
25
|
+
".tsx",
|
|
26
|
+
".mts",
|
|
27
|
+
".cts"
|
|
28
|
+
];
|
|
29
|
+
const DEFAULT_EXCLUDES = [
|
|
30
|
+
"node_modules",
|
|
31
|
+
".kickjs",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
".test.",
|
|
35
|
+
".spec.",
|
|
36
|
+
".d.ts"
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* Match a class-level decorator immediately followed by an exported
|
|
40
|
+
* class declaration. Captures decorator name and class name.
|
|
41
|
+
*/
|
|
42
|
+
const DECORATED_CLASS_REGEX = new RegExp(String.raw`@(${DECORATOR_NAMES.join("|")})\s*\([^)]*\)` + String.raw`(?:\s*@[A-Z]\w*(?:\s*\([^)]*\))?)*` + String.raw`\s*export\s+(default\s+)?(?:abstract\s+)?class\s+(\w+)`, "g");
|
|
43
|
+
/**
|
|
44
|
+
* Match a `createToken<T>('name')` call with optional `export const X =`
|
|
45
|
+
* or `const X =` prefix. Tolerates whitespace and the type parameter
|
|
46
|
+
* being absent (`createToken('name')`).
|
|
47
|
+
*/
|
|
48
|
+
const CREATE_TOKEN_REGEX = /(?:export\s+)?const\s+(\w+)\s*(?::\s*[^=]+)?=\s*createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
49
|
+
/**
|
|
50
|
+
* Match a bare `createToken<T>('name')` call (no const assignment) so
|
|
51
|
+
* we still pick up dynamically-used tokens.
|
|
52
|
+
*/
|
|
53
|
+
const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
54
|
+
/** Match `@Inject('literal')` — only literals; computed args are skipped */
|
|
55
|
+
const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
56
|
+
/**
|
|
57
|
+
* Match a route decorator immediately followed by a method declaration.
|
|
58
|
+
* Captures the HTTP verb, path literal (or empty), and method name.
|
|
59
|
+
*
|
|
60
|
+
* Tolerates:
|
|
61
|
+
* - Optional second arg to the route decorator (`@Get('/path', { ... })`)
|
|
62
|
+
* - Stacked decorators between the route and the method (`@Get('/') @Use(...)`)
|
|
63
|
+
* - Path-less decorators (`@Get()` → defaults to `/`)
|
|
64
|
+
* - `async` modifier on the method
|
|
65
|
+
*
|
|
66
|
+
* Run within a class body slice (see extractRoutesFromSource) so the
|
|
67
|
+
* captured method name is unambiguously a method on that class.
|
|
68
|
+
*/
|
|
69
|
+
const ROUTE_METHOD_REGEX = new RegExp(String.raw`@(${[
|
|
70
|
+
"Get",
|
|
71
|
+
"Post",
|
|
72
|
+
"Put",
|
|
73
|
+
"Delete",
|
|
74
|
+
"Patch"
|
|
75
|
+
].join("|")})\s*\(` + String.raw`(?:\s*['"\`]([^'"\`]*)['"\`])?[^)]*\)` + String.raw`(?:\s*@[A-Z]\w*(?:\s*\([^)]*\))?)*` + String.raw`\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?` + String.raw`([a-zA-Z_]\w*)\s*\(`, "g");
|
|
76
|
+
/** Extract `:placeholder` segments from an Express route path */
|
|
77
|
+
function extractPathParams(path) {
|
|
78
|
+
return (path.match(/:([a-zA-Z_]\w*)/g) ?? []).map((m) => m.slice(1));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Given the matched text of a route decorator + method declaration, return
|
|
82
|
+
* the substring inside the route decorator's argument list (between the
|
|
83
|
+
* outermost `(` and `)`). Returns `null` if no parens are found.
|
|
84
|
+
*
|
|
85
|
+
* Example input:
|
|
86
|
+
* `@Post('/', { body: createTaskSchema, name: 'CreateTask' }) async create(`
|
|
87
|
+
* Returns:
|
|
88
|
+
* `'/', { body: createTaskSchema, name: 'CreateTask' }`
|
|
89
|
+
*/
|
|
90
|
+
function extractRouteOptionsArg(matchedText) {
|
|
91
|
+
const open = matchedText.indexOf("(");
|
|
92
|
+
if (open < 0) return null;
|
|
93
|
+
let depth = 1;
|
|
94
|
+
for (let i = open + 1; i < matchedText.length; i++) {
|
|
95
|
+
const ch = matchedText[i];
|
|
96
|
+
if (ch === "(") depth++;
|
|
97
|
+
else if (ch === ")") {
|
|
98
|
+
depth--;
|
|
99
|
+
if (depth === 0) return matchedText.slice(open + 1, i);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract a bare identifier value from a single field in an object literal
|
|
106
|
+
* embedded in a string. Returns `null` if the field is missing or its value
|
|
107
|
+
* isn't a bare identifier (e.g. an inline object, function call, etc.).
|
|
108
|
+
*
|
|
109
|
+
* Example: `extractObjectFieldIdentifier("'/' , { body: createTaskSchema }", 'body')`
|
|
110
|
+
* returns `'createTaskSchema'`.
|
|
111
|
+
*/
|
|
112
|
+
function extractObjectFieldIdentifier(text, field) {
|
|
113
|
+
const m = new RegExp(String.raw`\b${field}\s*:\s*([A-Za-z_$][\w$]*)`, "g").exec(text);
|
|
114
|
+
if (!m) return null;
|
|
115
|
+
return m[1];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a bare identifier to its module source by inspecting the file's
|
|
119
|
+
* top-level imports and same-file `const` declarations.
|
|
120
|
+
*
|
|
121
|
+
* - `import { X } from './path'` → returns `'./path'`
|
|
122
|
+
* - `import X from './path'` (default import) → returns `'./path'`
|
|
123
|
+
* - `import * as X from './path'` → returns `'./path'`
|
|
124
|
+
* - `const X = z.object(...)` (same file) → returns `null` (caller emits a self-import)
|
|
125
|
+
*
|
|
126
|
+
* Returns `null` when the identifier cannot be resolved.
|
|
127
|
+
*/
|
|
128
|
+
function resolveImportSource(source, identifier) {
|
|
129
|
+
const named = new RegExp(String.raw`import\s*(?:type\s+)?\{[^}]*\b${identifier}\b[^}]*\}\s*from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
130
|
+
if (named) return named[1];
|
|
131
|
+
const def = new RegExp(String.raw`import\s+(?:type\s+)?${identifier}\s+from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
132
|
+
if (def) return def[1];
|
|
133
|
+
const ns = new RegExp(String.raw`import\s*\*\s*as\s+${identifier}\s+from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
134
|
+
if (ns) return ns[1];
|
|
135
|
+
if (new RegExp(String.raw`(?:^|\n)\s*(?:export\s+)?const\s+${identifier}\b`).test(source)) return "";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract whitelist arrays from an `@ApiQueryParams(...)` decorator
|
|
140
|
+
* within `decoratorBlock`. Handles two forms:
|
|
141
|
+
*
|
|
142
|
+
* - Inline literal: `@ApiQueryParams({ filterable: ['a', 'b'], ... })`
|
|
143
|
+
* - Const reference: `@ApiQueryParams(SOME_CONFIG)` — looks up
|
|
144
|
+
* `const SOME_CONFIG = { ... }` in the same file (`fullSource`).
|
|
145
|
+
*
|
|
146
|
+
* Returns `null` if no `@ApiQueryParams` is present. Returns
|
|
147
|
+
* `{ filterable: [], sortable: [], searchable: [] }` if the decorator
|
|
148
|
+
* is present but no fields could be statically extracted (opaque
|
|
149
|
+
* imports, column-object configs, function calls, etc.).
|
|
150
|
+
*/
|
|
151
|
+
function extractApiQueryParams(decoratorBlock, fullSource) {
|
|
152
|
+
const apiMatch = /@ApiQueryParams\s*\(\s*([\s\S]*?)\s*\)\s*$/.exec(decoratorBlock);
|
|
153
|
+
if (!apiMatch) {
|
|
154
|
+
const loose = /@ApiQueryParams\s*\(([\s\S]*?)\)/.exec(decoratorBlock);
|
|
155
|
+
if (!loose) return null;
|
|
156
|
+
return parseApiQueryParamsArg(loose[1].trim(), fullSource);
|
|
157
|
+
}
|
|
158
|
+
return parseApiQueryParamsArg(apiMatch[1].trim(), fullSource);
|
|
159
|
+
}
|
|
160
|
+
function parseApiQueryParamsArg(arg, fullSource) {
|
|
161
|
+
if (arg.startsWith("{")) return parseInlineConfigLiteral(arg);
|
|
162
|
+
const idMatch = /^([A-Za-z_]\w*)/.exec(arg);
|
|
163
|
+
if (idMatch) {
|
|
164
|
+
const ident = idMatch[1];
|
|
165
|
+
const constMatch = new RegExp(String.raw`const\s+${ident}\s*(?::\s*[^=]+)?=\s*(\{[\s\S]*?\n\})`, "m").exec(fullSource);
|
|
166
|
+
if (constMatch) return parseInlineConfigLiteral(constMatch[1]);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
filterable: [],
|
|
170
|
+
sortable: [],
|
|
171
|
+
searchable: []
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Extract a string array literal for one config key from an inline object literal */
|
|
175
|
+
function extractStringArray(literal, key) {
|
|
176
|
+
const m = new RegExp(String.raw`${key}\s*:\s*\[([\s\S]*?)\]`).exec(literal);
|
|
177
|
+
if (!m) return [];
|
|
178
|
+
return Array.from(m[1].matchAll(/['"`]([^'"`]+)['"`]/g)).map((x) => x[1]);
|
|
179
|
+
}
|
|
180
|
+
/** Parse an inline `{ filterable: [...], sortable: [...], searchable: [...] }` literal */
|
|
181
|
+
function parseInlineConfigLiteral(literal) {
|
|
182
|
+
return {
|
|
183
|
+
filterable: extractStringArray(literal, "filterable"),
|
|
184
|
+
sortable: extractStringArray(literal, "sortable"),
|
|
185
|
+
searchable: extractStringArray(literal, "searchable")
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Recursively walk a directory and yield matching file paths */
|
|
189
|
+
async function walk(dir, opts) {
|
|
190
|
+
const exts = opts.extensions ?? DEFAULT_EXTENSIONS;
|
|
191
|
+
const excludes = opts.exclude ?? DEFAULT_EXCLUDES;
|
|
192
|
+
const out = [];
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = await readdir(dir, {
|
|
196
|
+
withFileTypes: true,
|
|
197
|
+
encoding: "utf-8"
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const full = join(dir, entry.name);
|
|
204
|
+
const rel = relative(opts.cwd, full);
|
|
205
|
+
if (excludes.some((ex) => rel.includes(ex))) continue;
|
|
206
|
+
if (entry.isDirectory()) out.push(...await walk(full, opts));
|
|
207
|
+
else if (entry.isFile()) {
|
|
208
|
+
if (exts.some((ext) => entry.name.endsWith(ext))) out.push(full);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/** Compute the forward-slash relative path used in scanner output */
|
|
214
|
+
function toRelative(filePath, cwd) {
|
|
215
|
+
return relative(cwd, filePath).split(sep).join("/");
|
|
216
|
+
}
|
|
217
|
+
/** Extract decorated classes from a single source file */
|
|
218
|
+
function extractClassesFromSource(source, filePath, cwd) {
|
|
219
|
+
const out = [];
|
|
220
|
+
const relPath = toRelative(filePath, cwd);
|
|
221
|
+
DECORATED_CLASS_REGEX.lastIndex = 0;
|
|
222
|
+
let match;
|
|
223
|
+
while ((match = DECORATED_CLASS_REGEX.exec(source)) !== null) {
|
|
224
|
+
const [, decorator, defaultMarker, className] = match;
|
|
225
|
+
out.push({
|
|
226
|
+
className,
|
|
227
|
+
decorator,
|
|
228
|
+
filePath,
|
|
229
|
+
relativePath: relPath,
|
|
230
|
+
isDefault: Boolean(defaultMarker)
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/** Extract `createToken('name')` definitions from a single source file */
|
|
236
|
+
function extractTokensFromSource(source, filePath, cwd) {
|
|
237
|
+
const out = [];
|
|
238
|
+
const relPath = toRelative(filePath, cwd);
|
|
239
|
+
const seen = /* @__PURE__ */ new Set();
|
|
240
|
+
CREATE_TOKEN_REGEX.lastIndex = 0;
|
|
241
|
+
let match;
|
|
242
|
+
while ((match = CREATE_TOKEN_REGEX.exec(source)) !== null) {
|
|
243
|
+
const [full, variable, name] = match;
|
|
244
|
+
seen.add(full);
|
|
245
|
+
out.push({
|
|
246
|
+
name,
|
|
247
|
+
variable,
|
|
248
|
+
filePath,
|
|
249
|
+
relativePath: relPath
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
BARE_CREATE_TOKEN_REGEX.lastIndex = 0;
|
|
253
|
+
while ((match = BARE_CREATE_TOKEN_REGEX.exec(source)) !== null) {
|
|
254
|
+
if (seen.has(match[0])) continue;
|
|
255
|
+
out.push({
|
|
256
|
+
name: match[1],
|
|
257
|
+
variable: null,
|
|
258
|
+
filePath,
|
|
259
|
+
relativePath: relPath
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Extract route handlers from a source file.
|
|
266
|
+
*
|
|
267
|
+
* For each decorated class in `classesInFile`, slices the source from
|
|
268
|
+
* the class declaration to the next class (or EOF) and runs the route
|
|
269
|
+
* decorator regex within that slice. The result is a list of routes
|
|
270
|
+
* tagged with their owning controller.
|
|
271
|
+
*
|
|
272
|
+
* Heuristic note: this assumes classes are not nested. KickJS controllers
|
|
273
|
+
* are top-level by convention so this holds in practice.
|
|
274
|
+
*/
|
|
275
|
+
function extractRoutesFromSource(source, filePath, cwd, classesInFile) {
|
|
276
|
+
const out = [];
|
|
277
|
+
if (classesInFile.length === 0) return out;
|
|
278
|
+
const relPath = toRelative(filePath, cwd);
|
|
279
|
+
const positions = [];
|
|
280
|
+
for (const cls of classesInFile) {
|
|
281
|
+
const m = new RegExp(String.raw`class\s+${cls.className}\b`).exec(source);
|
|
282
|
+
if (m?.index !== void 0) positions.push({
|
|
283
|
+
cls,
|
|
284
|
+
start: m.index
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
positions.sort((a, b) => a.start - b.start);
|
|
288
|
+
for (let i = 0; i < positions.length; i++) {
|
|
289
|
+
const { cls, start } = positions[i];
|
|
290
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : source.length;
|
|
291
|
+
const block = source.slice(start, end);
|
|
292
|
+
ROUTE_METHOD_REGEX.lastIndex = 0;
|
|
293
|
+
let match;
|
|
294
|
+
while ((match = ROUTE_METHOD_REGEX.exec(block)) !== null) {
|
|
295
|
+
const [matchedText, verb, pathLiteral, methodName] = match;
|
|
296
|
+
const path = pathLiteral && pathLiteral.length > 0 ? pathLiteral : "/";
|
|
297
|
+
const apiQp = extractApiQueryParams(matchedText, source);
|
|
298
|
+
const routeArgs = extractRouteOptionsArg(matchedText);
|
|
299
|
+
const bodyId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "body") : null;
|
|
300
|
+
const queryId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "query") : null;
|
|
301
|
+
const paramsId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "params") : null;
|
|
302
|
+
out.push({
|
|
303
|
+
controller: cls.className,
|
|
304
|
+
method: methodName,
|
|
305
|
+
httpMethod: verb.toUpperCase(),
|
|
306
|
+
path,
|
|
307
|
+
pathParams: extractPathParams(path),
|
|
308
|
+
queryFilterable: apiQp?.filterable ?? null,
|
|
309
|
+
querySortable: apiQp?.sortable ?? null,
|
|
310
|
+
querySearchable: apiQp?.searchable ?? null,
|
|
311
|
+
bodySchema: bodyId ? {
|
|
312
|
+
identifier: bodyId,
|
|
313
|
+
source: resolveImportSource(source, bodyId)
|
|
314
|
+
} : null,
|
|
315
|
+
querySchema: queryId ? {
|
|
316
|
+
identifier: queryId,
|
|
317
|
+
source: resolveImportSource(source, queryId)
|
|
318
|
+
} : null,
|
|
319
|
+
paramsSchema: paramsId ? {
|
|
320
|
+
identifier: paramsId,
|
|
321
|
+
source: resolveImportSource(source, paramsId)
|
|
322
|
+
} : null,
|
|
323
|
+
filePath,
|
|
324
|
+
relativePath: relPath
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
/** Extract `@Inject('literal')` calls from a single source file */
|
|
331
|
+
function extractInjectsFromSource(source, filePath, cwd) {
|
|
332
|
+
const out = [];
|
|
333
|
+
const relPath = toRelative(filePath, cwd);
|
|
334
|
+
INJECT_LITERAL_REGEX.lastIndex = 0;
|
|
335
|
+
let match;
|
|
336
|
+
while ((match = INJECT_LITERAL_REGEX.exec(source)) !== null) out.push({
|
|
337
|
+
name: match[1],
|
|
338
|
+
filePath,
|
|
339
|
+
relativePath: relPath
|
|
340
|
+
});
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Look for an env schema file at `<cwd>/<envFile>`. Returns a
|
|
345
|
+
* `DiscoveredEnv` if the file exists and contains both a
|
|
346
|
+
* `defineEnv(...)` call and a default export — the two markers we
|
|
347
|
+
* need before it's safe to emit `import type schema from '...'` in
|
|
348
|
+
* the generator.
|
|
349
|
+
*
|
|
350
|
+
* Returns `null` for any other state (file missing, no defineEnv, no
|
|
351
|
+
* default export) so the generator skips env typing silently. Users
|
|
352
|
+
* who want env typing must opt in by writing `src/env.ts` to the
|
|
353
|
+
* documented shape.
|
|
354
|
+
*/
|
|
355
|
+
async function detectEnvFile(cwd, envFile) {
|
|
356
|
+
const abs = resolve(cwd, envFile);
|
|
357
|
+
let source;
|
|
358
|
+
try {
|
|
359
|
+
source = await readFile(abs, "utf-8");
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (!/\bdefineEnv\s*\(/.test(source)) return null;
|
|
364
|
+
if (!/export\s+default\b/.test(source)) return null;
|
|
365
|
+
return {
|
|
366
|
+
filePath: abs,
|
|
367
|
+
relativePath: toRelative(abs, cwd)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/** Detect duplicate class names across files */
|
|
371
|
+
function findCollisions(classes) {
|
|
372
|
+
const groups = /* @__PURE__ */ new Map();
|
|
373
|
+
for (const cls of classes) {
|
|
374
|
+
const arr = groups.get(cls.className) ?? [];
|
|
375
|
+
arr.push(cls);
|
|
376
|
+
groups.set(cls.className, arr);
|
|
377
|
+
}
|
|
378
|
+
const collisions = [];
|
|
379
|
+
for (const [className, group] of groups) if (new Set(group.map((c) => c.filePath)).size > 1) collisions.push({
|
|
380
|
+
className,
|
|
381
|
+
classes: group
|
|
382
|
+
});
|
|
383
|
+
collisions.sort((a, b) => a.className.localeCompare(b.className));
|
|
384
|
+
return collisions;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Scan a project for decorated classes, createToken definitions, and
|
|
388
|
+
* `@Inject` literal usages.
|
|
389
|
+
*/
|
|
390
|
+
async function scanProject(opts) {
|
|
391
|
+
const files = await walk(resolve(opts.root), opts);
|
|
392
|
+
const classes = [];
|
|
393
|
+
const routes = [];
|
|
394
|
+
const tokens = [];
|
|
395
|
+
const injects = [];
|
|
396
|
+
const sources = /* @__PURE__ */ new Map();
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
let source;
|
|
399
|
+
try {
|
|
400
|
+
source = await readFile(file, "utf-8");
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
sources.set(file, source);
|
|
405
|
+
classes.push(...extractClassesFromSource(source, file, opts.cwd));
|
|
406
|
+
tokens.push(...extractTokensFromSource(source, file, opts.cwd));
|
|
407
|
+
injects.push(...extractInjectsFromSource(source, file, opts.cwd));
|
|
408
|
+
}
|
|
409
|
+
for (const [file, source] of sources) {
|
|
410
|
+
const classesInFile = classes.filter((c) => c.filePath === file);
|
|
411
|
+
routes.push(...extractRoutesFromSource(source, file, opts.cwd, classesInFile));
|
|
412
|
+
}
|
|
413
|
+
classes.sort((a, b) => {
|
|
414
|
+
if (a.className !== b.className) return a.className.localeCompare(b.className);
|
|
415
|
+
return a.relativePath.localeCompare(b.relativePath);
|
|
416
|
+
});
|
|
417
|
+
tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
418
|
+
injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
419
|
+
routes.sort((a, b) => a.controller.localeCompare(b.controller) || a.method.localeCompare(b.method));
|
|
420
|
+
return {
|
|
421
|
+
classes,
|
|
422
|
+
routes,
|
|
423
|
+
tokens,
|
|
424
|
+
injects,
|
|
425
|
+
collisions: findCollisions(classes),
|
|
426
|
+
env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts")
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/typegen/generator.ts
|
|
431
|
+
/**
|
|
432
|
+
* Generates `.d.ts` files inside `.kickjs/types/` from the discovered
|
|
433
|
+
* decorated classes and DI tokens. Pattern modeled on React Router's
|
|
434
|
+
* `.react-router/types/` directory.
|
|
435
|
+
*
|
|
436
|
+
* Outputs:
|
|
437
|
+
* - `.kickjs/types/registry.d.ts` — module augmentation for `KickJsRegistry`
|
|
438
|
+
* that gives `container.resolve('UserService')` the right return type.
|
|
439
|
+
* - `.kickjs/types/services.d.ts` — string-literal union of all known
|
|
440
|
+
* service-style tokens for tooling autocomplete.
|
|
441
|
+
* - `.kickjs/types/modules.d.ts` — string-literal union of discovered
|
|
442
|
+
* module class names.
|
|
443
|
+
* - `.kickjs/types/index.d.ts` — re-exports the above (single import target).
|
|
444
|
+
* - `.kickjs/.gitignore` — gitignores the whole folder so generated files
|
|
445
|
+
* never get committed.
|
|
446
|
+
*
|
|
447
|
+
* ## Collision behaviour
|
|
448
|
+
*
|
|
449
|
+
* If `findCollisions()` returns any duplicate class names:
|
|
450
|
+
* - **Default (`allowDuplicates: false`)** — `generateTypes` throws a
|
|
451
|
+
* `TokenCollisionError` with a clear message listing every conflicting
|
|
452
|
+
* file. The caller (CLI) prints it and exits non-zero. Nothing is
|
|
453
|
+
* written to disk.
|
|
454
|
+
* - **`allowDuplicates: true`** — colliding classes are auto-namespaced
|
|
455
|
+
* by their relative file path so the registry keys become e.g.
|
|
456
|
+
* `'modules/users/UserService'` instead of `'UserService'`. Non-colliding
|
|
457
|
+
* classes still get bare `'ClassName'` keys (smart default).
|
|
458
|
+
*
|
|
459
|
+
* @module @forinda/kickjs-cli/typegen/generator
|
|
460
|
+
*/
|
|
461
|
+
/** Header written to every generated file */
|
|
462
|
+
const HEADER = `/* eslint-disable */
|
|
463
|
+
// AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
|
|
464
|
+
// Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
|
|
465
|
+
`;
|
|
466
|
+
/** Decorators whose classes participate in the DI registry augmentation */
|
|
467
|
+
const REGISTRY_DECORATORS = new Set([
|
|
468
|
+
"Service",
|
|
469
|
+
"Repository",
|
|
470
|
+
"Injectable",
|
|
471
|
+
"Component"
|
|
472
|
+
]);
|
|
473
|
+
/** Thrown by `generateTypes` when collisions are found and not allowed */
|
|
474
|
+
var TokenCollisionError = class extends Error {
|
|
475
|
+
collisions;
|
|
476
|
+
constructor(collisions) {
|
|
477
|
+
super(formatCollisionMessage(collisions));
|
|
478
|
+
this.name = "TokenCollisionError";
|
|
479
|
+
this.collisions = collisions;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
/** Build a human-readable message describing every collision */
|
|
483
|
+
function formatCollisionMessage(collisions) {
|
|
484
|
+
const lines = ["kick typegen: token collision detected"];
|
|
485
|
+
for (const c of collisions) {
|
|
486
|
+
lines.push("");
|
|
487
|
+
lines.push(` ${c.classes.length} classes named '${c.className}':`);
|
|
488
|
+
for (const cls of c.classes) lines.push(` - ${cls.relativePath}`);
|
|
489
|
+
}
|
|
490
|
+
lines.push("");
|
|
491
|
+
lines.push("Resolutions:");
|
|
492
|
+
lines.push(" (a) Rename one of the classes");
|
|
493
|
+
lines.push(" (b) Use createToken<T>('namespaced/Name') and import the token explicitly — see @forinda/kickjs");
|
|
494
|
+
lines.push(" (c) Pass --allow-duplicates to namespace the registry keys automatically");
|
|
495
|
+
lines.push(" (e.g. 'modules/users/UserService' instead of 'UserService')");
|
|
496
|
+
return lines.join("\n");
|
|
497
|
+
}
|
|
498
|
+
/** Compute the module specifier (without extension) used inside `import('...')` */
|
|
499
|
+
function importSpecifierFor(targetFile, fromFile) {
|
|
500
|
+
let rel = relative(dirname(fromFile), targetFile).split(sep).join("/");
|
|
501
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
502
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
503
|
+
return rel;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Build the namespaced registry key for a colliding class.
|
|
507
|
+
* Strips the `src/` prefix and the file extension, then appends the
|
|
508
|
+
* class name. Example: `src/modules/users/user.service.ts` + `UserService`
|
|
509
|
+
* → `modules/users/UserService`.
|
|
510
|
+
*/
|
|
511
|
+
function namespacedKeyFor(cls) {
|
|
512
|
+
const parts = cls.relativePath.replace(/^src\//, "").replace(/\.(ts|tsx|mts|cts)$/i, "").split("/");
|
|
513
|
+
parts.pop();
|
|
514
|
+
const ns = parts.join("/");
|
|
515
|
+
return ns ? `${ns}/${cls.className}` : cls.className;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Render the `KickJsRegistry` module augmentation. Each entry maps a
|
|
519
|
+
* string token to the imported class type.
|
|
520
|
+
*
|
|
521
|
+
* Default-exported classes are imported as `import('...').default`.
|
|
522
|
+
*
|
|
523
|
+
* `collidingNames` lists class names that should be auto-namespaced;
|
|
524
|
+
* everything else gets a bare key.
|
|
525
|
+
*/
|
|
526
|
+
function renderRegistry(classes, outFile, collidingNames) {
|
|
527
|
+
const seen = /* @__PURE__ */ new Set();
|
|
528
|
+
const entries = [];
|
|
529
|
+
for (const c of classes) {
|
|
530
|
+
if (!REGISTRY_DECORATORS.has(c.decorator)) continue;
|
|
531
|
+
const key = collidingNames.has(c.className) ? namespacedKeyFor(c) : c.className;
|
|
532
|
+
if (seen.has(key)) continue;
|
|
533
|
+
seen.add(key);
|
|
534
|
+
const spec = importSpecifierFor(c.filePath, outFile);
|
|
535
|
+
const ref = c.isDefault ? `import('${spec}').default` : `import('${spec}').${c.className}`;
|
|
536
|
+
entries.push(` '${key}': ${ref}`);
|
|
537
|
+
}
|
|
538
|
+
return `${HEADER}
|
|
539
|
+
declare module '@forinda/kickjs' {
|
|
540
|
+
interface KickJsRegistry {
|
|
541
|
+
${entries.length ? entries.join("\n") : " // (no services discovered yet — run `kick g service <name>` to add one)"}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export {}
|
|
546
|
+
`;
|
|
547
|
+
}
|
|
548
|
+
/** Render a string-literal union type containing the given names */
|
|
549
|
+
function renderUnion(typeName, names, emptyComment) {
|
|
550
|
+
if (names.length === 0) return `${HEADER}
|
|
551
|
+
// ${emptyComment}
|
|
552
|
+
export type ${typeName} = never
|
|
553
|
+
`;
|
|
554
|
+
return `${HEADER}
|
|
555
|
+
export type ${typeName} =
|
|
556
|
+
${[...new Set(names)].sort().map((n) => ` | '${n}'`).join("\n")}
|
|
557
|
+
`;
|
|
558
|
+
}
|
|
559
|
+
/** Render the barrel index that re-exports the union types */
|
|
560
|
+
function renderIndex(includeEnv) {
|
|
561
|
+
return `${HEADER}
|
|
562
|
+
export type { ServiceToken } from './services'
|
|
563
|
+
export type { ModuleToken } from './modules'
|
|
564
|
+
|
|
565
|
+
// The registry, routes, and env augmentations are loaded as side-effects —
|
|
566
|
+
// importing this file (or having it on tsconfig include) is enough for
|
|
567
|
+
// \`container.resolve()\`, \`Ctx<KickRoutes.UserController['getUser']>\`,
|
|
568
|
+
// and \`@Value('PORT')\` to resolve.
|
|
569
|
+
import './registry'
|
|
570
|
+
import './routes'
|
|
571
|
+
${includeEnv ? "import './env'\n" : ""}`;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Render the `query` field's TypeScript type for a single route.
|
|
575
|
+
*
|
|
576
|
+
* - When `@ApiQueryParams` is absent (`queryFilterable === null`), emits
|
|
577
|
+
* `unknown` so the user gets nothing extra.
|
|
578
|
+
* - When the decorator is present, emits an object literal whose keys
|
|
579
|
+
* are the standard query string keys (`filter`, `sort`, `q`, `page`,
|
|
580
|
+
* `limit`). `sort` is narrowed to a string-literal union of allowed
|
|
581
|
+
* field names with optional `-` direction prefix.
|
|
582
|
+
*/
|
|
583
|
+
function renderQueryShape(m) {
|
|
584
|
+
if (m.queryFilterable === null) return "unknown";
|
|
585
|
+
const sortable = m.querySortable ?? [];
|
|
586
|
+
return `{ filter?: string | string[]; sort?: ${sortable.length > 0 ? sortable.flatMap((f) => [`'${f}'`, `'-${f}'`]).join(" | ") : "string"}; q?: string; page?: string; limit?: string }`;
|
|
587
|
+
}
|
|
588
|
+
/** Render JSDoc lines summarising the @ApiQueryParams whitelist */
|
|
589
|
+
function renderQueryDocLines(m) {
|
|
590
|
+
const lines = [];
|
|
591
|
+
if (m.queryFilterable && m.queryFilterable.length > 0) lines.push(`Filterable: ${m.queryFilterable.join(", ")}`);
|
|
592
|
+
if (m.querySortable && m.querySortable.length > 0) lines.push(`Sortable: ${m.querySortable.join(", ")}`);
|
|
593
|
+
if (m.querySearchable && m.querySearchable.length > 0) lines.push(`Searchable: ${m.querySearchable.join(", ")}`);
|
|
594
|
+
return lines;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Plan a schema import for hoisting at the top of `routes.ts`. Returns
|
|
598
|
+
* the alias the in-namespace code should use, or `null` if the schema
|
|
599
|
+
* cannot be referenced (no validator configured, or source unresolvable).
|
|
600
|
+
*
|
|
601
|
+
* Aliases are unique per (alias-counter) so two schemas named
|
|
602
|
+
* `createTaskSchema` from different modules don't collide.
|
|
603
|
+
*/
|
|
604
|
+
function planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, imports) {
|
|
605
|
+
if (!schema || schemaValidator !== "zod") return null;
|
|
606
|
+
if (schema.source === null) return null;
|
|
607
|
+
const specifier = resolveSchemaImportSpecifier(schema.source, routeFilePath, routesOutFile);
|
|
608
|
+
if (specifier === "unknown") return null;
|
|
609
|
+
const key = `${specifier}::${schema.identifier}`;
|
|
610
|
+
let alias = imports.get(key)?.specifier;
|
|
611
|
+
if (!alias) {
|
|
612
|
+
alias = `_S${imports.size}`;
|
|
613
|
+
imports.set(key, {
|
|
614
|
+
identifier: schema.identifier,
|
|
615
|
+
specifier: alias
|
|
616
|
+
});
|
|
617
|
+
} else alias = imports.get(key).specifier;
|
|
618
|
+
return alias;
|
|
619
|
+
}
|
|
620
|
+
/** Build the `import type { ... } from '...'` lines for hoisted schema imports */
|
|
621
|
+
function renderSchemaImports(imports) {
|
|
622
|
+
if (imports.size === 0) return "";
|
|
623
|
+
const lines = [];
|
|
624
|
+
for (const [key, value] of imports) {
|
|
625
|
+
const [path] = key.split("::");
|
|
626
|
+
lines.push(`import type { ${value.identifier} as ${value.specifier} } from '${path}'`);
|
|
627
|
+
}
|
|
628
|
+
return lines.join("\n") + "\n";
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Compute the import specifier the generated `routes.d.ts` should use to
|
|
632
|
+
* reach a schema declared either in the controller file (empty string)
|
|
633
|
+
* or imported from elsewhere (relative path or bare module name).
|
|
634
|
+
*
|
|
635
|
+
* - Bare module names (`zod`, `@scope/pkg`) are returned as-is.
|
|
636
|
+
* - Relative paths (`./users.dto`, `../shared/schema`) are resolved
|
|
637
|
+
* against the controller's file path, then re-relativised against the
|
|
638
|
+
* directory containing `routes.d.ts`.
|
|
639
|
+
* - Empty string (same-file schema) becomes a relative path from the
|
|
640
|
+
* `routes.d.ts` directory back to the controller file.
|
|
641
|
+
*/
|
|
642
|
+
function resolveSchemaImportSpecifier(source, routeFilePath, routesOutFile) {
|
|
643
|
+
if (source === null) return "unknown";
|
|
644
|
+
const routesDir = dirname(routesOutFile);
|
|
645
|
+
if (source === "") {
|
|
646
|
+
let rel = relative(routesDir, routeFilePath).split(sep).join("/");
|
|
647
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
648
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
649
|
+
return rel;
|
|
650
|
+
}
|
|
651
|
+
if (!source.startsWith(".") && !source.startsWith("/")) return source;
|
|
652
|
+
let rel = relative(routesDir, resolve(dirname(routeFilePath), source)).split(sep).join("/");
|
|
653
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
654
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
655
|
+
return rel;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Render the `KickEnv` + `NodeJS.ProcessEnv` augmentation file from a
|
|
659
|
+
* detected env schema. Mirrors the routes.ts pattern: emits as a `.ts`
|
|
660
|
+
* file (not `.d.ts`) so the top-level `import type schema from '...'`
|
|
661
|
+
* actually resolves under `moduleResolution: 'bundler'`.
|
|
662
|
+
*
|
|
663
|
+
* Returns `null` when no env file was discovered, so the caller can
|
|
664
|
+
* skip writing the file altogether (rather than emitting an empty
|
|
665
|
+
* augmentation that would shadow `KickEnv` to a useless `{}`).
|
|
666
|
+
*/
|
|
667
|
+
function renderEnv(env, envOutFile) {
|
|
668
|
+
if (!env) return null;
|
|
669
|
+
let rel = relative(dirname(envOutFile), env.filePath).split(sep).join("/");
|
|
670
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
671
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
672
|
+
return `${HEADER}
|
|
673
|
+
// Importing the schema as a type lets us infer its shape without
|
|
674
|
+
// pulling in any runtime code. \`Awaited<>\` strips an accidental
|
|
675
|
+
// Promise wrap on dynamic-imported defaults.
|
|
676
|
+
import type _envSchema from '${rel}'
|
|
677
|
+
|
|
678
|
+
// Local type alias — interfaces can only \`extend\` an identifier,
|
|
679
|
+
// not an inline import expression, so we resolve the schema's
|
|
680
|
+
// inferred shape into a named type first.
|
|
681
|
+
type _KickEnvShape = import('zod').infer<typeof _envSchema>
|
|
682
|
+
|
|
683
|
+
declare global {
|
|
684
|
+
/**
|
|
685
|
+
* Typed environment registry. Augmented from \`${env.relativePath}\`
|
|
686
|
+
* so \`@Value('PORT')\`, \`Env<'PORT'>\`, and \`process.env.PORT\` are
|
|
687
|
+
* all type-safe and autocomplete.
|
|
688
|
+
*/
|
|
689
|
+
interface KickEnv extends _KickEnvShape {}
|
|
690
|
+
|
|
691
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
692
|
+
namespace NodeJS {
|
|
693
|
+
/**
|
|
694
|
+
* Narrow \`process.env\` so known keys exist as \`string\` (the raw
|
|
695
|
+
* pre-Zod-coercion form). \`@Value\` and the \`ConfigService\` apply
|
|
696
|
+
* the schema's transforms internally; access \`process.env\` directly
|
|
697
|
+
* only when you need the raw string. Unknown keys still resolve to
|
|
698
|
+
* \`string | undefined\` via the base @types/node declaration.
|
|
699
|
+
*/
|
|
700
|
+
interface ProcessEnv extends Record<keyof KickEnv, string> {}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export {}
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Render the `KickRoutes` global namespace augmentation. Each interface
|
|
709
|
+
* inside corresponds to a controller class; each property is a single
|
|
710
|
+
* route method on that controller, conforming to `RouteShape`.
|
|
711
|
+
*
|
|
712
|
+
* Fills `params` from URL patterns, `query` from `@ApiQueryParams`, and
|
|
713
|
+
* `body`/`query`/`params` (when schema-validated) from the configured
|
|
714
|
+
* schema validator. `response` is emitted as `unknown`.
|
|
715
|
+
*/
|
|
716
|
+
function renderRoutes(routes, routesOutFile, schemaValidator) {
|
|
717
|
+
if (routes.length === 0) return `${HEADER}
|
|
718
|
+
// (no routes discovered yet — annotate a controller method with
|
|
719
|
+
// @Get/@Post/@Put/@Delete/@Patch and re-run \`kick typegen\`)
|
|
720
|
+
declare global {
|
|
721
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
722
|
+
namespace KickRoutes {}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export {}
|
|
726
|
+
`;
|
|
727
|
+
const byController = /* @__PURE__ */ new Map();
|
|
728
|
+
for (const r of routes) {
|
|
729
|
+
const arr = byController.get(r.controller) ?? [];
|
|
730
|
+
arr.push(r);
|
|
731
|
+
byController.set(r.controller, arr);
|
|
732
|
+
}
|
|
733
|
+
const schemaImports = /* @__PURE__ */ new Map();
|
|
734
|
+
const renderField = (schema, routeFilePath) => {
|
|
735
|
+
const alias = planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, schemaImports);
|
|
736
|
+
return alias ? `import('zod').infer<typeof ${alias}>` : null;
|
|
737
|
+
};
|
|
738
|
+
const interfaces = [];
|
|
739
|
+
for (const [controller, methods] of byController) {
|
|
740
|
+
const lines = [` interface ${controller} {`];
|
|
741
|
+
for (const m of methods) {
|
|
742
|
+
const urlParamsType = m.pathParams.length > 0 ? `{ ${m.pathParams.map((p) => `${p}: string`).join("; ")} }` : "{}";
|
|
743
|
+
const bodySchemaType = renderField(m.bodySchema, m.filePath);
|
|
744
|
+
const querySchemaType = renderField(m.querySchema, m.filePath);
|
|
745
|
+
const paramsType = renderField(m.paramsSchema, m.filePath) ?? urlParamsType;
|
|
746
|
+
const bodyType = bodySchemaType ?? "unknown";
|
|
747
|
+
const queryType = querySchemaType ?? renderQueryShape(m);
|
|
748
|
+
const docLines = renderQueryDocLines(m);
|
|
749
|
+
lines.push(` /**`, ` * ${m.httpMethod} ${m.path}`, ...docLines.map((d) => ` * ${d}`), ` */`, ` ${m.method}: {`, ` params: ${paramsType}`, ` body: ${bodyType}`, ` query: ${queryType}`, ` response: unknown`, ` }`);
|
|
750
|
+
}
|
|
751
|
+
lines.push(" }");
|
|
752
|
+
interfaces.push(lines.join("\n"));
|
|
753
|
+
}
|
|
754
|
+
return `${HEADER}${renderSchemaImports(schemaImports)}
|
|
755
|
+
declare global {
|
|
756
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
757
|
+
namespace KickRoutes {
|
|
758
|
+
${interfaces.join("\n")}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export {}
|
|
763
|
+
`;
|
|
764
|
+
}
|
|
765
|
+
/** Write all generated `.d.ts` files to `outDir` */
|
|
766
|
+
async function generateTypes(opts) {
|
|
767
|
+
const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, outDir, allowDuplicates = false, schemaValidator = false } = opts;
|
|
768
|
+
if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
|
|
769
|
+
await mkdir(outDir, { recursive: true });
|
|
770
|
+
const registryFile = join(outDir, "registry.d.ts");
|
|
771
|
+
const servicesFile = join(outDir, "services.d.ts");
|
|
772
|
+
const modulesFile = join(outDir, "modules.d.ts");
|
|
773
|
+
const routesFile = join(outDir, "routes.ts");
|
|
774
|
+
const envFile = join(outDir, "env.ts");
|
|
775
|
+
const indexFile = join(outDir, "index.d.ts");
|
|
776
|
+
const collidingNames = new Set(collisions.map((c) => c.className));
|
|
777
|
+
const registryContent = renderRegistry(classes, registryFile, collidingNames);
|
|
778
|
+
const classTokens = classes.filter((c) => REGISTRY_DECORATORS.has(c.decorator)).map((c) => collidingNames.has(c.className) ? namespacedKeyFor(c) : c.className);
|
|
779
|
+
const tokenLiterals = tokens.map((t) => t.name);
|
|
780
|
+
const injectLiterals = injects.map((i) => i.name);
|
|
781
|
+
const allServices = [
|
|
782
|
+
...classTokens,
|
|
783
|
+
...tokenLiterals,
|
|
784
|
+
...injectLiterals
|
|
785
|
+
];
|
|
786
|
+
const modules = classes.filter((c) => c.decorator === "Module").map((c) => c.className);
|
|
787
|
+
const servicesContent = renderUnion("ServiceToken", allServices, "(no tokens discovered — declare with createToken<T>() or `kick g service <name>`)");
|
|
788
|
+
const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
|
|
789
|
+
const routesContent = renderRoutes(routes, routesFile, schemaValidator);
|
|
790
|
+
const envContent = renderEnv(env, envFile);
|
|
791
|
+
const indexContent = renderIndex(envContent !== null);
|
|
792
|
+
await writeFile(registryFile, registryContent, "utf-8");
|
|
793
|
+
await writeFile(servicesFile, servicesContent, "utf-8");
|
|
794
|
+
await writeFile(modulesFile, modulesContent, "utf-8");
|
|
795
|
+
await writeFile(routesFile, routesContent, "utf-8");
|
|
796
|
+
await writeFile(indexFile, indexContent, "utf-8");
|
|
797
|
+
const written = [
|
|
798
|
+
registryFile,
|
|
799
|
+
servicesFile,
|
|
800
|
+
modulesFile,
|
|
801
|
+
routesFile,
|
|
802
|
+
indexFile
|
|
803
|
+
];
|
|
804
|
+
if (envContent) {
|
|
805
|
+
await writeFile(envFile, envContent, "utf-8");
|
|
806
|
+
written.push(envFile);
|
|
807
|
+
}
|
|
808
|
+
await writeFile(join(dirname(outDir), ".gitignore"), "# Auto-generated by kick typegen\n*\n", "utf-8");
|
|
809
|
+
return {
|
|
810
|
+
registryEntries: classTokens.length,
|
|
811
|
+
serviceTokens: new Set(allServices).size,
|
|
812
|
+
moduleTokens: modules.length,
|
|
813
|
+
routeEntries: routes.length,
|
|
814
|
+
envWritten: envContent !== null,
|
|
815
|
+
written,
|
|
816
|
+
resolvedCollisions: collisions.length
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
820
|
+
//#region src/typegen/index.ts
|
|
821
|
+
/**
|
|
822
|
+
* Public entry point for the KickJS typegen module.
|
|
823
|
+
*
|
|
824
|
+
* Used by:
|
|
825
|
+
* - `kick typegen` (one-shot or watch mode)
|
|
826
|
+
* - `kick dev` (auto-runs once before Vite starts; refreshes when files change)
|
|
827
|
+
*
|
|
828
|
+
* @module @forinda/kickjs-cli/typegen
|
|
829
|
+
*/
|
|
830
|
+
/** Resolve options to absolute paths */
|
|
831
|
+
function resolveOptions(opts) {
|
|
832
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
833
|
+
return {
|
|
834
|
+
cwd,
|
|
835
|
+
srcDir: resolve(cwd, opts.srcDir ?? "src"),
|
|
836
|
+
outDir: resolve(cwd, opts.outDir ?? ".kickjs/types"),
|
|
837
|
+
silent: opts.silent ?? false,
|
|
838
|
+
allowDuplicates: opts.allowDuplicates ?? false,
|
|
839
|
+
schemaValidator: opts.schemaValidator ?? false,
|
|
840
|
+
envFile: opts.envFile ?? "src/env.ts"
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Run a single typegen pass: scan source files, generate `.d.ts` files.
|
|
845
|
+
*
|
|
846
|
+
* Returns the discovered scan result alongside the generation result so
|
|
847
|
+
* callers (`kick dev`, devtools) can log them or feed them to other tools.
|
|
848
|
+
*
|
|
849
|
+
* Throws `TokenCollisionError` if duplicate class names are found and
|
|
850
|
+
* `allowDuplicates` is false.
|
|
851
|
+
*/
|
|
852
|
+
async function runTypegen(opts = {}) {
|
|
853
|
+
const { cwd, srcDir, outDir, silent, allowDuplicates, schemaValidator, envFile } = resolveOptions(opts);
|
|
854
|
+
const start = Date.now();
|
|
855
|
+
const scan = await scanProject({
|
|
856
|
+
root: srcDir,
|
|
857
|
+
cwd,
|
|
858
|
+
envFile: envFile === false ? void 0 : envFile
|
|
859
|
+
});
|
|
860
|
+
const result = await generateTypes({
|
|
861
|
+
classes: scan.classes,
|
|
862
|
+
routes: scan.routes,
|
|
863
|
+
tokens: scan.tokens,
|
|
864
|
+
injects: scan.injects,
|
|
865
|
+
collisions: scan.collisions,
|
|
866
|
+
env: envFile === false ? null : scan.env,
|
|
867
|
+
outDir,
|
|
868
|
+
allowDuplicates,
|
|
869
|
+
schemaValidator
|
|
870
|
+
});
|
|
871
|
+
const elapsed = Date.now() - start;
|
|
872
|
+
if (!silent) {
|
|
873
|
+
const where = outDir.replace(cwd + "/", "");
|
|
874
|
+
const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
|
|
875
|
+
const envNote = result.envWritten ? ", env typed" : "";
|
|
876
|
+
console.log(` kick typegen → ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${envNote}${collisionNote} → ${where} (${elapsed}ms)`);
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
scan,
|
|
880
|
+
result
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
//#endregion
|
|
884
|
+
export { runTypegen };
|
|
885
|
+
|
|
886
|
+
//# sourceMappingURL=typegen-UejiKdXA.mjs.map
|