ertk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,653 @@
1
+ /**
2
+ * ERTK Codegen Engine
3
+ *
4
+ * Reads endpoint definition files and generates:
5
+ * - api.ts (RTK Query API + hooks)
6
+ * - store.ts (Redux store config)
7
+ * - invalidation.ts (cache invalidation helpers)
8
+ * - route.ts files (Next.js route handlers) — if routes config is present
9
+ */
10
+ import { Project, SyntaxKind } from "ts-morph";
11
+ import * as crypto from "node:crypto";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ // ─── AST Parsing ──────────────────────────────────────────────
15
+ function parseEndpointFile(project, filePath, config) {
16
+ const absPath = path.join(config.endpointsDir, filePath);
17
+ const sourceFile = project.addSourceFileAtPath(absPath);
18
+ // Find the default export
19
+ const defaultExport = sourceFile.getDefaultExportSymbol();
20
+ if (!defaultExport) {
21
+ console.warn(`ERTK: No default export in ${filePath}, skipping`);
22
+ return null;
23
+ }
24
+ // Find the endpoint.{method}(...) call
25
+ const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
26
+ let endpointCall = null;
27
+ let method = "";
28
+ for (const call of callExpressions) {
29
+ const expr = call.getExpression();
30
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
31
+ const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
32
+ const objectText = propAccess.getExpression().getText();
33
+ const methodName = propAccess.getName();
34
+ if (objectText === "endpoint" &&
35
+ ["get", "post", "put", "patch", "delete"].includes(methodName)) {
36
+ endpointCall = call;
37
+ method = methodName;
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ if (!endpointCall || !method) {
43
+ console.warn(`ERTK: No endpoint.{method}() call found in ${filePath}, skipping`);
44
+ return null;
45
+ }
46
+ // Extract type arguments
47
+ const typeArgs = endpointCall.getTypeArguments();
48
+ const responseType = typeArgs[0]?.getText() ?? "unknown";
49
+ const argsType = typeArgs[1]?.getText() ?? "void";
50
+ // Extract config object
51
+ const configArg = endpointCall.getArguments()[0];
52
+ if (!configArg ||
53
+ configArg.getKind() !== SyntaxKind.ObjectLiteralExpression) {
54
+ console.warn(`ERTK: Config argument not found in ${filePath}, skipping`);
55
+ return null;
56
+ }
57
+ const configObj = configArg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
58
+ // Extract name
59
+ const nameProp = configObj.getProperty("name");
60
+ const name = nameProp
61
+ ? nameProp
62
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
63
+ .getInitializerOrThrow()
64
+ .getText()
65
+ .replace(/['"]/g, "")
66
+ : "";
67
+ if (!name) {
68
+ console.warn(`ERTK: No name property in ${filePath}, skipping`);
69
+ return null;
70
+ }
71
+ // Extract protected
72
+ const protectedProp = configObj.getProperty("protected");
73
+ const isProtected = protectedProp
74
+ ? protectedProp
75
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
76
+ .getInitializerOrThrow()
77
+ .getText() !== "false"
78
+ : true;
79
+ // Check for request schema
80
+ const requestProp = configObj.getProperty("request");
81
+ const hasRequest = !!requestProp;
82
+ // Check for handler
83
+ const handlerProp = configObj.getProperty("handler");
84
+ const hasHandler = !!handlerProp;
85
+ // Extract query function source
86
+ const queryProp = configObj.getProperty("query");
87
+ let queryFnSource = "";
88
+ if (queryProp) {
89
+ const init = queryProp
90
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
91
+ .getInitializerOrThrow();
92
+ queryFnSource = init.getText();
93
+ }
94
+ // Extract tags
95
+ let providesTagsSource = null;
96
+ let invalidatesTagsSource = null;
97
+ const tagsProp = configObj.getProperty("tags");
98
+ if (tagsProp) {
99
+ const tagsObj = tagsProp
100
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
101
+ .getInitializerOrThrow();
102
+ if (tagsObj.getKind() === SyntaxKind.ObjectLiteralExpression) {
103
+ const tagsObjLit = tagsObj.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
104
+ const providesProp = tagsObjLit.getProperty("provides");
105
+ if (providesProp) {
106
+ providesTagsSource = providesProp
107
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
108
+ .getInitializerOrThrow()
109
+ .getText();
110
+ }
111
+ const invalidatesProp = tagsObjLit.getProperty("invalidates");
112
+ if (invalidatesProp) {
113
+ invalidatesTagsSource = invalidatesProp
114
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
115
+ .getInitializerOrThrow()
116
+ .getText();
117
+ }
118
+ }
119
+ }
120
+ // Extract optimistic updates
121
+ const optimisticProp = configObj.getProperty("optimistic");
122
+ let optimisticSource = null;
123
+ if (optimisticProp) {
124
+ optimisticSource = optimisticProp
125
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
126
+ .getInitializerOrThrow()
127
+ .getText();
128
+ }
129
+ // Derive route path from file path
130
+ const routePath = deriveRoutePath(filePath, config);
131
+ const endpointType = method === "get" ? "query" : "mutation";
132
+ // Resolve type imports
133
+ const typeImports = new Map();
134
+ let responseTypeImport = null;
135
+ let argsTypeImport = null;
136
+ for (const importDecl of sourceFile.getImportDeclarations()) {
137
+ const namedImports = importDecl.getNamedImports();
138
+ for (const named of namedImports) {
139
+ const importName = named.getName();
140
+ const moduleSpecifier = importDecl.getModuleSpecifierValue();
141
+ const aliasPath = resolveToAlias(moduleSpecifier, absPath, config);
142
+ if (responseType.includes(importName)) {
143
+ responseTypeImport = aliasPath;
144
+ addToMapSet(typeImports, aliasPath, importName);
145
+ }
146
+ if (argsType.includes(importName)) {
147
+ argsTypeImport = aliasPath;
148
+ addToMapSet(typeImports, aliasPath, importName);
149
+ }
150
+ }
151
+ }
152
+ // Build import path for endpoint file
153
+ const endpointsRelative = path.relative(config.aliasRoot, config.endpointsDir);
154
+ const importPath = `${config.pathAlias}/${endpointsRelative}/${filePath.replace(/\.ts$/, "")}`;
155
+ return {
156
+ name,
157
+ method,
158
+ filePath,
159
+ importPath,
160
+ routePath,
161
+ isProtected,
162
+ hasRequest,
163
+ hasHandler,
164
+ responseType,
165
+ responseTypeImport,
166
+ argsType,
167
+ argsTypeImport,
168
+ queryFnSource,
169
+ endpointType,
170
+ providesTagsSource,
171
+ invalidatesTagsSource,
172
+ optimisticSource,
173
+ typeImports,
174
+ };
175
+ }
176
+ function resolveToAlias(moduleSpecifier, fromFile, config) {
177
+ if (moduleSpecifier.startsWith(config.pathAlias + "/")) {
178
+ return moduleSpecifier;
179
+ }
180
+ // Resolve relative path to absolute, then convert to alias
181
+ const dir = path.dirname(fromFile);
182
+ const resolved = path.resolve(dir, moduleSpecifier);
183
+ if (resolved.startsWith(config.aliasRoot)) {
184
+ return `${config.pathAlias}/${path.relative(config.aliasRoot, resolved).replace(/\.ts$/, "")}`;
185
+ }
186
+ return moduleSpecifier;
187
+ }
188
+ function addToMapSet(map, key, value) {
189
+ if (!map.has(key)) {
190
+ map.set(key, new Set());
191
+ }
192
+ map.get(key).add(value);
193
+ }
194
+ function deriveRoutePath(filePath, config) {
195
+ const parts = filePath.replace(/\.ts$/, "").split("/");
196
+ const fileName = parts.pop();
197
+ const segments = [];
198
+ for (const part of parts) {
199
+ segments.push(part);
200
+ }
201
+ if (!config.crudFilenames.has(fileName)) {
202
+ segments.push(fileName);
203
+ }
204
+ return `/api/${segments.join("/")}`;
205
+ }
206
+ // ─── Route Grouping ───────────────────────────────────────────
207
+ function groupEndpointsByRoute(endpoints, config) {
208
+ const groups = new Map();
209
+ if (!config.routes)
210
+ return groups;
211
+ for (const ep of endpoints) {
212
+ if (!ep.hasHandler)
213
+ continue; // Skip client-only endpoints
214
+ if (!groups.has(ep.routePath)) {
215
+ const appRouteDir = path.join(config.routes.dir, ep.routePath.replace(/^\/api\//, ""));
216
+ groups.set(ep.routePath, {
217
+ routePath: ep.routePath,
218
+ appRouteDir,
219
+ methods: new Map(),
220
+ });
221
+ }
222
+ const httpMethod = ep.method.toUpperCase();
223
+ groups.get(ep.routePath).methods.set(httpMethod, ep);
224
+ }
225
+ return groups;
226
+ }
227
+ // ─── Code Generation ──────────────────────────────────────────
228
+ function generateApiTs(endpoints, config) {
229
+ // Collect all type imports
230
+ const allTypeImports = new Map();
231
+ for (const ep of endpoints) {
232
+ for (const [importPath, types] of ep.typeImports) {
233
+ if (!allTypeImports.has(importPath)) {
234
+ allTypeImports.set(importPath, new Set());
235
+ }
236
+ for (const t of types) {
237
+ allTypeImports.get(importPath).add(t);
238
+ }
239
+ }
240
+ }
241
+ // Collect all tag types used
242
+ const tagTypes = new Set();
243
+ for (const ep of endpoints) {
244
+ extractTagTypes(ep.providesTagsSource, tagTypes);
245
+ extractTagTypes(ep.invalidatesTagsSource, tagTypes);
246
+ }
247
+ const lines = [];
248
+ lines.push("// AUTO-GENERATED by ERTK codegen. Do not edit.");
249
+ lines.push('import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";');
250
+ // Add type imports
251
+ for (const [importPath, types] of [...allTypeImports.entries()].sort()) {
252
+ const typeList = [...types].sort().join(", ");
253
+ lines.push(`import type { ${typeList} } from "${importPath}";`);
254
+ }
255
+ lines.push("");
256
+ lines.push("export const api = createApi({");
257
+ lines.push('\treducerPath: "api",');
258
+ // baseQuery — use custom source if provided, otherwise default
259
+ if (config.baseQuery) {
260
+ lines.push(`\tbaseQuery: ${config.baseQuery},`);
261
+ }
262
+ else {
263
+ lines.push(`\tbaseQuery: fetchBaseQuery({ baseUrl: "${config.baseUrl}" }),`);
264
+ }
265
+ const tagTypesList = [...tagTypes].sort();
266
+ lines.push(`\ttagTypes: [${tagTypesList.map((t) => `"${t}"`).join(", ")}],`);
267
+ lines.push("\trefetchOnFocus: false,");
268
+ lines.push("\trefetchOnReconnect: true,");
269
+ lines.push("\tendpoints: (builder) => ({");
270
+ for (const ep of endpoints) {
271
+ lines.push(...generateEndpointDef(ep));
272
+ }
273
+ lines.push("\t}),");
274
+ lines.push("});");
275
+ // Export hooks
276
+ lines.push("");
277
+ const hookExports = [];
278
+ for (const ep of endpoints) {
279
+ if (ep.endpointType === "query") {
280
+ hookExports.push(`use${capitalize(ep.name)}Query`);
281
+ }
282
+ else {
283
+ hookExports.push(`use${capitalize(ep.name)}Mutation`);
284
+ }
285
+ }
286
+ lines.push("export const {");
287
+ for (const hook of hookExports) {
288
+ lines.push(`\t${hook},`);
289
+ }
290
+ lines.push("} = api;");
291
+ return lines.join("\n") + "\n";
292
+ }
293
+ function generateEndpointDef(ep) {
294
+ const lines = [];
295
+ const builderType = ep.endpointType === "query" ? "builder.query" : "builder.mutation";
296
+ lines.push(`\t\t${ep.name}: ${builderType}<${ep.responseType}, ${ep.argsType}>({`);
297
+ if (ep.queryFnSource) {
298
+ lines.push(`\t\t\tquery: ${ep.queryFnSource},`);
299
+ }
300
+ if (ep.providesTagsSource) {
301
+ lines.push(`\t\t\tprovidesTags: ${ep.providesTagsSource},`);
302
+ }
303
+ if (ep.invalidatesTagsSource) {
304
+ lines.push(`\t\t\tinvalidatesTags: ${ep.invalidatesTagsSource},`);
305
+ }
306
+ if (ep.optimisticSource) {
307
+ const onQueryStarted = generateOnQueryStarted(ep);
308
+ if (onQueryStarted) {
309
+ lines.push(...onQueryStarted);
310
+ }
311
+ }
312
+ lines.push("\t\t}),");
313
+ return lines;
314
+ }
315
+ function generateOnQueryStarted(ep) {
316
+ if (!ep.optimisticSource)
317
+ return null;
318
+ const lines = [];
319
+ const isSingle = ep.optimisticSource.includes("target:");
320
+ const isMulti = ep.optimisticSource.includes("updates:");
321
+ if (isSingle && !isMulti) {
322
+ const targetMatch = ep.optimisticSource.match(/target:\s*["'](\w+)["']/);
323
+ const argsMatch = ep.optimisticSource.match(/args:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,\s*update:)/);
324
+ const updateMatch = ep.optimisticSource.match(/update:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,?\s*}$)/);
325
+ if (targetMatch && argsMatch && updateMatch) {
326
+ const target = targetMatch[1];
327
+ const argsFn = argsMatch[1].trim();
328
+ const updateFn = updateMatch[1].trim();
329
+ lines.push(`\t\t\tasync onQueryStarted(params, { dispatch, queryFulfilled }) {`);
330
+ lines.push(`\t\t\t\tconst patchResult = dispatch(`);
331
+ lines.push(`\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
332
+ lines.push(`\t\t\t\t\t\t(${updateFn})(draft, params);`);
333
+ lines.push(`\t\t\t\t\t}),`);
334
+ lines.push(`\t\t\t\t);`);
335
+ lines.push(`\t\t\t\ttry { await queryFulfilled; } catch { patchResult.undo(); }`);
336
+ lines.push(`\t\t\t},`);
337
+ }
338
+ }
339
+ else if (isMulti) {
340
+ lines.push(`\t\t\tasync onQueryStarted(params, { dispatch, queryFulfilled }) {`);
341
+ lines.push(`\t\t\t\tconst patches: Array<{ undo: () => void }> = [];`);
342
+ const updatesContent = extractUpdatesArray(ep.optimisticSource);
343
+ if (updatesContent) {
344
+ for (const update of updatesContent) {
345
+ const targetMatch = update.match(/target:\s*["'](\w+)["']/);
346
+ const conditionMatch = update.match(/condition:\s*((?:\([^)]*\)|\w+)\s*=>[^,}]*)/);
347
+ const argsMatch = update.match(/args:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,\s*(?:update|condition):)/);
348
+ const updateMatch = update.match(/update:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,?\s*}$)/);
349
+ if (targetMatch && argsMatch && updateMatch) {
350
+ const target = targetMatch[1];
351
+ const argsFn = argsMatch[1].trim();
352
+ const updateFn = updateMatch[1].trim();
353
+ if (conditionMatch) {
354
+ const conditionFn = conditionMatch[1].trim();
355
+ lines.push(`\t\t\t\tif ((${conditionFn})(params)) {`);
356
+ lines.push(`\t\t\t\t\tpatches.push(`);
357
+ lines.push(`\t\t\t\t\t\tdispatch(`);
358
+ lines.push(`\t\t\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
359
+ lines.push(`\t\t\t\t\t\t\t\t(${updateFn})(draft, params);`);
360
+ lines.push(`\t\t\t\t\t\t\t}),`);
361
+ lines.push(`\t\t\t\t\t\t),`);
362
+ lines.push(`\t\t\t\t\t);`);
363
+ lines.push(`\t\t\t\t}`);
364
+ }
365
+ else {
366
+ lines.push(`\t\t\t\tpatches.push(`);
367
+ lines.push(`\t\t\t\t\tdispatch(`);
368
+ lines.push(`\t\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
369
+ lines.push(`\t\t\t\t\t\t\t(${updateFn})(draft, params);`);
370
+ lines.push(`\t\t\t\t\t\t}),`);
371
+ lines.push(`\t\t\t\t\t),`);
372
+ lines.push(`\t\t\t\t);`);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ lines.push(`\t\t\t\ttry { await queryFulfilled; } catch { for (const p of patches) p.undo(); }`);
378
+ lines.push(`\t\t\t},`);
379
+ }
380
+ return lines.length > 0 ? lines : null;
381
+ }
382
+ function extractUpdatesArray(source) {
383
+ const match = source.match(/updates:\s*\[([\s\S]*)\]/);
384
+ if (!match)
385
+ return null;
386
+ const content = match[1];
387
+ const updates = [];
388
+ let depth = 0;
389
+ let current = "";
390
+ for (const char of content) {
391
+ if (char === "{") {
392
+ depth++;
393
+ current += char;
394
+ }
395
+ else if (char === "}") {
396
+ depth--;
397
+ current += char;
398
+ if (depth === 0) {
399
+ updates.push(current.trim());
400
+ current = "";
401
+ }
402
+ }
403
+ else if (depth > 0) {
404
+ current += char;
405
+ }
406
+ }
407
+ return updates.length > 0 ? updates : null;
408
+ }
409
+ function extractTagTypes(source, tags) {
410
+ if (!source)
411
+ return;
412
+ const matches = source.matchAll(/["'](\w+)["']/g);
413
+ for (const m of matches) {
414
+ if (m[1][0] === m[1][0].toUpperCase()) {
415
+ tags.add(m[1]);
416
+ }
417
+ }
418
+ }
419
+ function capitalize(s) {
420
+ return s.charAt(0).toUpperCase() + s.slice(1);
421
+ }
422
+ function generateStoreTs() {
423
+ return `// AUTO-GENERATED by ERTK codegen. Do not edit.
424
+ import { configureStore } from "@reduxjs/toolkit";
425
+ import { api } from "./api";
426
+
427
+ export const store = configureStore({
428
+ \treducer: {
429
+ \t\t[api.reducerPath]: api.reducer,
430
+ \t},
431
+ \tmiddleware: (getDefaultMiddleware) =>
432
+ \t\tgetDefaultMiddleware().concat(api.middleware),
433
+ });
434
+
435
+ export type RootState = ReturnType<typeof store.getState>;
436
+ export type AppDispatch = typeof store.dispatch;
437
+ `;
438
+ }
439
+ function generateInvalidationTs() {
440
+ return `// AUTO-GENERATED by ERTK codegen. Do not edit.
441
+ import { api } from "./api";
442
+
443
+ export function invalidateTags(
444
+ \t...args: Parameters<typeof api.util.invalidateTags>
445
+ ) {
446
+ \treturn api.util.invalidateTags(...args);
447
+ }
448
+
449
+ export const updateQueryData = api.util.updateQueryData;
450
+ `;
451
+ }
452
+ function generateRouteFile(group, config) {
453
+ const lines = [];
454
+ lines.push("// AUTO-GENERATED by ERTK codegen. Do not edit.");
455
+ const handlerModule = config.routes.handlerModule;
456
+ const importLines = [];
457
+ importLines.push(`import { createRouteHandler } from "${handlerModule}";`);
458
+ for (const [, ep] of group.methods) {
459
+ const varName = `${ep.name}Endpoint`;
460
+ importLines.push(`import ${varName} from "${ep.importPath}";`);
461
+ }
462
+ importLines.sort((a, b) => {
463
+ const pathA = a.match(/from "(.+)"/)?.[1] ?? "";
464
+ const pathB = b.match(/from "(.+)"/)?.[1] ?? "";
465
+ return pathA.localeCompare(pathB);
466
+ });
467
+ lines.push(...importLines);
468
+ lines.push("");
469
+ for (const [method, ep] of group.methods) {
470
+ const varName = `${ep.name}Endpoint`;
471
+ lines.push(`export const ${method} = createRouteHandler(${varName});`);
472
+ }
473
+ return lines.join("\n") + "\n";
474
+ }
475
+ // ─── Incremental Build Helpers ────────────────────────────────
476
+ function scanEndpointFiles(config) {
477
+ if (!fs.existsSync(config.endpointsDir))
478
+ return [];
479
+ const allFiles = fs.readdirSync(config.endpointsDir, {
480
+ recursive: true,
481
+ });
482
+ return allFiles
483
+ .filter((f) => f.endsWith(".ts"))
484
+ .map((f) => f.replace(/\\/g, "/"))
485
+ .sort();
486
+ }
487
+ function hashFile(filePath) {
488
+ return crypto
489
+ .createHash("md5")
490
+ .update(fs.readFileSync(filePath))
491
+ .digest("hex");
492
+ }
493
+ function loadManifest(config) {
494
+ try {
495
+ return JSON.parse(fs.readFileSync(config.manifestPath, "utf-8"));
496
+ }
497
+ catch {
498
+ return {};
499
+ }
500
+ }
501
+ function saveManifest(manifest, config) {
502
+ fs.writeFileSync(config.manifestPath, JSON.stringify(manifest, null, 2) + "\n");
503
+ }
504
+ function buildManifest(files, config) {
505
+ const manifest = {};
506
+ for (const file of files) {
507
+ manifest[file] = hashFile(path.join(config.endpointsDir, file));
508
+ }
509
+ return manifest;
510
+ }
511
+ function manifestsMatch(a, b) {
512
+ const keysA = Object.keys(a).sort();
513
+ const keysB = Object.keys(b).sort();
514
+ if (keysA.length !== keysB.length)
515
+ return false;
516
+ for (let i = 0; i < keysA.length; i++) {
517
+ if (keysA[i] !== keysB[i])
518
+ return false;
519
+ if (a[keysA[i]] !== b[keysB[i]])
520
+ return false;
521
+ }
522
+ return true;
523
+ }
524
+ function writeIfChanged(filePath, content) {
525
+ try {
526
+ const existing = fs.readFileSync(filePath, "utf-8");
527
+ if (existing === content)
528
+ return false;
529
+ }
530
+ catch {
531
+ // File doesn't exist yet
532
+ }
533
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
534
+ fs.writeFileSync(filePath, content);
535
+ return true;
536
+ }
537
+ function isIgnoredRoute(routeDir, config) {
538
+ if (!config.routes)
539
+ return true;
540
+ const relative = path.relative(config.routes.dir, routeDir);
541
+ const topLevel = relative.split(path.sep)[0];
542
+ return config.routes.ignoredRoutes.has(topLevel);
543
+ }
544
+ // ─── Core Generate Function ──────────────────────────────────
545
+ function parseAllEndpoints(project, config) {
546
+ const files = scanEndpointFiles(config);
547
+ const cache = new Map();
548
+ for (const file of files) {
549
+ const parsed = parseEndpointFile(project, file, config);
550
+ if (parsed) {
551
+ cache.set(file, parsed);
552
+ }
553
+ }
554
+ return cache;
555
+ }
556
+ function generate(endpoints, config) {
557
+ fs.mkdirSync(config.generatedDir, { recursive: true });
558
+ // 1. Generate api.ts
559
+ const apiContent = generateApiTs(endpoints, config);
560
+ writeIfChanged(path.join(config.generatedDir, "api.ts"), apiContent);
561
+ // 2. Generate store.ts
562
+ writeIfChanged(path.join(config.generatedDir, "store.ts"), generateStoreTs());
563
+ // 3. Generate invalidation.ts
564
+ writeIfChanged(path.join(config.generatedDir, "invalidation.ts"), generateInvalidationTs());
565
+ // 4. Generate route handlers (if routes config is present)
566
+ let routeCount = 0;
567
+ if (config.routes) {
568
+ const routeGroups = groupEndpointsByRoute(endpoints, config);
569
+ for (const [, group] of routeGroups) {
570
+ if (isIgnoredRoute(group.appRouteDir, config))
571
+ continue;
572
+ const routeContent = generateRouteFile(group, config);
573
+ writeIfChanged(path.join(group.appRouteDir, "route.ts"), routeContent);
574
+ routeCount++;
575
+ }
576
+ }
577
+ return routeCount;
578
+ }
579
+ // ─── Public API ───────────────────────────────────────────────
580
+ /**
581
+ * Run a one-shot generation. Skips if nothing changed (manifest comparison).
582
+ */
583
+ export function runGenerate(config) {
584
+ const tsProject = new Project({
585
+ tsConfigFilePath: path.join(config.root, "tsconfig.json"),
586
+ skipAddingFilesFromTsConfig: true,
587
+ });
588
+ const files = scanEndpointFiles(config);
589
+ if (files.length === 0) {
590
+ console.log("ERTK: No endpoint files found.");
591
+ return;
592
+ }
593
+ const oldManifest = loadManifest(config);
594
+ const newManifest = buildManifest(files, config);
595
+ if (manifestsMatch(oldManifest, newManifest)) {
596
+ console.log("ERTK: Nothing changed.");
597
+ return;
598
+ }
599
+ const cache = parseAllEndpoints(tsProject, config);
600
+ const routeCount = generate([...cache.values()], config);
601
+ saveManifest(newManifest, config);
602
+ const routeMsg = config.routes ? `, ${routeCount} routes` : "";
603
+ console.log(`ERTK: Generated ${cache.size} endpoints${routeMsg}.`);
604
+ }
605
+ /**
606
+ * Run generation in watch mode. Does an initial full build, then
607
+ * watches for changes and incrementally regenerates.
608
+ */
609
+ export function runWatch(config) {
610
+ const tsProject = new Project({
611
+ tsConfigFilePath: path.join(config.root, "tsconfig.json"),
612
+ skipAddingFilesFromTsConfig: true,
613
+ });
614
+ const cache = parseAllEndpoints(tsProject, config);
615
+ const routeCount = generate([...cache.values()], config);
616
+ const manifest = buildManifest(scanEndpointFiles(config), config);
617
+ saveManifest(manifest, config);
618
+ const routeMsg = config.routes ? `, ${routeCount} routes ready` : "";
619
+ console.log(`ERTK: Watching — ${cache.size} endpoints${routeMsg}.`);
620
+ let debounceTimer = null;
621
+ fs.watch(config.endpointsDir, { recursive: true }, (_event, filename) => {
622
+ if (!filename?.endsWith(".ts"))
623
+ return;
624
+ if (debounceTimer)
625
+ clearTimeout(debounceTimer);
626
+ debounceTimer = setTimeout(() => {
627
+ const relPath = filename.replace(/\\/g, "/");
628
+ const fullPath = path.join(config.endpointsDir, relPath);
629
+ if (fs.existsSync(fullPath)) {
630
+ const hash = hashFile(fullPath);
631
+ if (manifest[relPath] === hash)
632
+ return;
633
+ manifest[relPath] = hash;
634
+ const existing = tsProject.getSourceFile(fullPath);
635
+ if (existing)
636
+ existing.forget();
637
+ const parsed = parseEndpointFile(tsProject, relPath, config);
638
+ if (parsed) {
639
+ cache.set(relPath, parsed);
640
+ console.log(`ERTK: Updated ${parsed.name}`);
641
+ }
642
+ }
643
+ else {
644
+ delete manifest[relPath];
645
+ cache.delete(relPath);
646
+ console.log(`ERTK: Removed ${relPath}`);
647
+ }
648
+ generate([...cache.values()], config);
649
+ saveManifest(manifest, config);
650
+ }, 300);
651
+ });
652
+ }
653
+ //# sourceMappingURL=generate.js.map