@toktokhan-dev/cli-plugin-gen-api-react-query 0.1.10 → 0.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/index.d.ts CHANGED
@@ -63,11 +63,21 @@ type GenerateSwaggerApiConfig = SwaggerSchemaOption & {
63
63
  * @default false
64
64
  */
65
65
  ignoreTlsError?: boolean;
66
+ /**
67
+ * data-contracts.ts를 모듈별로 분할할지 여부입니다.
68
+ * true일 때 각 모듈 폴더에 <module>.contracts.ts가 생성되고,
69
+ * 공유 타입은 @types/common-contracts.ts에 생성됩니다.
70
+ * @default false
71
+ */
72
+ splitDataContracts?: boolean;
66
73
  };
67
74
  /**
68
75
  * @category Commands
69
76
  */
70
77
  declare const genApi: _toktokhan_dev_cli.MyCommand<GenerateSwaggerApiConfig, "gen:api">;
78
+ /**
79
+ * 스마트 타입 병합 함수들
80
+ */
71
81
  declare function mergeTypeScriptContent(existing: string, newContent: string): string;
72
82
 
73
83
  export { genApi, mergeTypeScriptContent };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCommand } from '@toktokhan-dev/cli';
2
- import { createPackageRoot, prettierString, withLoading, cwd } from '@toktokhan-dev/node';
2
+ import { createPackageRoot, withLoading, cwd } from '@toktokhan-dev/node';
3
3
  import omit from 'lodash/omit.js';
4
4
  import path from 'path';
5
5
  import { generateApi } from 'swagger-typescript-api';
@@ -43,6 +43,8 @@ const parseSwagger = (config) => generateApi({
43
43
  input: config.swaggerSchemaUrl,
44
44
  httpClientType: config.httpClientType, // "axios" or "fetch"
45
45
  typeSuffix: 'Type',
46
+ sortTypes: true,
47
+ sortRoutes: true,
46
48
  prettier: {
47
49
  printWidth: 120,
48
50
  },
@@ -74,10 +76,288 @@ const parseSwagger = (config) => generateApi({
74
76
  },
75
77
  });
76
78
 
79
+ /**
80
+ * data-contracts.ts의 렌더링된 내용을 타입 블록 단위로 파싱합니다.
81
+ * 각 export type/interface/enum/const를 독립된 블록으로 분리합니다.
82
+ *
83
+ * lookahead로 `\nexport\s`를 사용하여 export function/class 등
84
+ * 비표준 export도 블록 경계로 인식합니다.
85
+ */
86
+ const TYPE_BLOCK_REGEX = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=\nexport\s|$)/g;
87
+ function parseTypeDefinitions(content) {
88
+ const types = {};
89
+ const typeRegex = new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags);
90
+ let match;
91
+ while ((match = typeRegex.exec(content)) !== null) {
92
+ const typeName = match[2];
93
+ const typeContent = match[1].trim();
94
+ types[typeName] = typeContent;
95
+ }
96
+ return types;
97
+ }
98
+
99
+ /**
100
+ * 코드에서 주석과 문자열 리터럴을 제거합니다.
101
+ * false positive 의존성 감지를 방지합니다. (예: 주석 내 타입명 언급)
102
+ */
103
+ function stripNonCode(code) {
104
+ return code
105
+ .replace(/\/\*[\s\S]*?\*\//g, '')
106
+ .replace(/\/\/.*$/gm, '')
107
+ .replace(/'[^']*'/g, '""')
108
+ .replace(/"[^"]*"/g, '""')
109
+ .replace(/`[^`]*`/g, '""');
110
+ }
111
+ /**
112
+ * 렌더링된 타입 블록들 간의 의존성 그래프를 구축합니다.
113
+ *
114
+ * 각 타입 블록의 TypeScript 코드에서 다른 타입 이름이 참조되는지를
115
+ * 단어 경계 기반 정규식으로 감지합니다.
116
+ * 주석과 문자열 리터럴은 제거 후 매칭하여 false positive를 줄입니다.
117
+ *
118
+ * @param parsedTypes - parseTypeDefinitions의 결과 (typeName -> renderedBlock)
119
+ * @returns Map<typeName, Set<referencedTypeName>> - 직접 의존성 그래프
120
+ */
121
+ function buildDependencyGraph(parsedTypes) {
122
+ const knownTypes = Object.keys(parsedTypes);
123
+ const graph = new Map();
124
+ // Pre-compile regexes once (O(n) instead of O(n²) compilations)
125
+ const compiledPatterns = new Map();
126
+ for (const known of knownTypes) {
127
+ const escaped = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128
+ compiledPatterns.set(known, new RegExp(`\\b${escaped}\\b`));
129
+ }
130
+ for (const [typeName, typeBlock] of Object.entries(parsedTypes)) {
131
+ const deps = new Set();
132
+ const cleanBlock = stripNonCode(typeBlock);
133
+ for (const known of knownTypes) {
134
+ if (known !== typeName && compiledPatterns.get(known).test(cleanBlock)) {
135
+ deps.add(known);
136
+ }
137
+ }
138
+ graph.set(typeName, deps);
139
+ }
140
+ return graph;
141
+ }
142
+ /**
143
+ * 직접 의존성 그래프에서 전이적 의존성을 포함한 전체 의존성을 계산합니다.
144
+ * A -> B -> C면, A의 의존성은 {B, C}가 됩니다.
145
+ */
146
+ function getTransitiveDependencies(graph, typeName, visited = new Set()) {
147
+ if (visited.has(typeName))
148
+ return new Set();
149
+ visited.add(typeName);
150
+ const directDeps = graph.get(typeName) || new Set();
151
+ const allDeps = new Set(directDeps);
152
+ for (const dep of directDeps) {
153
+ const transitiveDeps = getTransitiveDependencies(graph, dep, visited);
154
+ for (const td of transitiveDeps) {
155
+ allDeps.add(td);
156
+ }
157
+ }
158
+ return allDeps;
159
+ }
160
+
161
+ /**
162
+ * route의 type 문자열에서 알려진 타입 이름들을 추출합니다.
163
+ * 제네릭, 유니온, 인터섹션, 배열 등에서 타입 이름을 추출합니다.
164
+ *
165
+ * 예: "PaginatedResponse<UserType>" -> ["PaginatedResponse", "UserType"]
166
+ */
167
+ function extractTypeNames(typeString, knownTypes) {
168
+ if (!typeString)
169
+ return [];
170
+ const identifiers = typeString.match(/\b[A-Z]\w+/g) || [];
171
+ return identifiers.filter((id) => knownTypes.has(id));
172
+ }
173
+ /**
174
+ * swagger-typescript-api의 routes.combined에서 타입-모듈 매핑을 구축합니다.
175
+ *
176
+ * 각 route의 response.type, response.errorType, request.payload.type,
177
+ * request.query.type에서 타입 이름을 추출하여, 어느 모듈에서 사용하는지 매핑합니다.
178
+ *
179
+ * @returns Map<typeName, Set<moduleName>> (moduleName은 PascalCase로 정규화)
180
+ */
181
+ function buildTypeToModuleMap(combined, knownTypeNames) {
182
+ const typeToModules = new Map();
183
+ if (!combined)
184
+ return typeToModules;
185
+ for (const moduleGroup of combined) {
186
+ // moduleName을 PascalCase로 정규화 (write-swagger.ts의 filename과 일치시키기 위해)
187
+ const normalizedModuleName = upperFirst(camelCase(moduleGroup.moduleName));
188
+ for (const route of moduleGroup.routes) {
189
+ // ParsedRoute의 .d.ts는 request: Request, response: Response (DOM 타입)으로 선언되어 있으나
190
+ // 런타임에는 swagger-typescript-api 내부 객체임. as any 캐스팅 필요.
191
+ const r = route;
192
+ const typeStrings = [
193
+ r.response?.type,
194
+ r.response?.errorType,
195
+ r.request?.payload?.type,
196
+ r.request?.query?.type,
197
+ ];
198
+ // request.parameters에서도 타입 추출 (path params 등)
199
+ if (r.request?.parameters) {
200
+ for (const param of Object.values(r.request.parameters)) {
201
+ if (param?.type) {
202
+ typeStrings.push(param.type);
203
+ }
204
+ }
205
+ }
206
+ for (const typeStr of typeStrings) {
207
+ const extracted = extractTypeNames(typeStr, knownTypeNames);
208
+ for (const typeName of extracted) {
209
+ if (!typeToModules.has(typeName)) {
210
+ typeToModules.set(typeName, new Set());
211
+ }
212
+ typeToModules.get(typeName).add(normalizedModuleName);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ return typeToModules;
218
+ }
219
+
220
+ /**
221
+ * data-contracts.ts의 타입들을 모듈별/공유로 분류합니다.
222
+ *
223
+ * 분류 알고리즘:
224
+ * 1. 직접 매핑: route에서 참조하는 타입 → 해당 모듈에 소속
225
+ * 2. 의존성 전파: 타입이 참조하는 다른 타입도 같은 모듈에 소속
226
+ * 3. 공유 판별: 2개 이상 모듈에 소속된 타입 → shared
227
+ * 4. Orphan 처리: 어디에도 소속되지 않은 타입 → shared
228
+ * 5. 전이적 공유: shared 타입이 참조하는 타입도 shared로 격상
229
+ * 6. enumMap 동반 이동: *Map const는 base type과 동일 파일에 배치
230
+ */
231
+ function classifyTypes(dataContractsContent, routesCombined) {
232
+ const parsedTypes = parseTypeDefinitions(dataContractsContent);
233
+ const allTypeNames = new Set(Object.keys(parsedTypes));
234
+ // Step 1: 타입-모듈 직접 매핑
235
+ const typeToModules = buildTypeToModuleMap(routesCombined, allTypeNames);
236
+ // Step 2: 의존성 그래프 구축 + 의존성 전파
237
+ const depGraph = buildDependencyGraph(parsedTypes);
238
+ // 의존성 전파: 각 타입의 전이적 의존성도 같은 모듈에 추가
239
+ for (const [typeName, modules] of typeToModules.entries()) {
240
+ const transitiveDeps = getTransitiveDependencies(depGraph, typeName);
241
+ for (const dep of transitiveDeps) {
242
+ if (!typeToModules.has(dep)) {
243
+ typeToModules.set(dep, new Set());
244
+ }
245
+ for (const mod of modules) {
246
+ typeToModules.get(dep).add(mod);
247
+ }
248
+ }
249
+ }
250
+ // Step 3: 분류 (module-exclusive vs shared vs orphan)
251
+ const moduleTypesMap = new Map();
252
+ const sharedTypesSet = new Set();
253
+ for (const typeName of allTypeNames) {
254
+ const modules = typeToModules.get(typeName);
255
+ if (!modules || modules.size === 0) {
256
+ // Orphan: 어디서도 참조되지 않음 → shared (안전 기본값)
257
+ sharedTypesSet.add(typeName);
258
+ }
259
+ else if (modules.size === 1) {
260
+ // Module-exclusive: 한 모듈에서만 사용
261
+ const moduleName = [...modules][0];
262
+ if (!moduleTypesMap.has(moduleName)) {
263
+ moduleTypesMap.set(moduleName, new Set());
264
+ }
265
+ moduleTypesMap.get(moduleName).add(typeName);
266
+ }
267
+ else {
268
+ // Shared: 2개 이상 모듈에서 사용
269
+ sharedTypesSet.add(typeName);
270
+ }
271
+ }
272
+ // Step 5: 전이적 공유 — shared 타입이 참조하는 타입도 shared로 격상
273
+ // 순회 중 Set에 추가하지 않고 별도 배열에 수집 후 일괄 적용
274
+ let changed = true;
275
+ while (changed) {
276
+ changed = false;
277
+ const toPromote = [];
278
+ for (const sharedType of sharedTypesSet) {
279
+ const deps = depGraph.get(sharedType) || new Set();
280
+ for (const dep of deps) {
281
+ if (!sharedTypesSet.has(dep)) {
282
+ toPromote.push(dep);
283
+ }
284
+ }
285
+ }
286
+ for (const dep of toPromote) {
287
+ sharedTypesSet.add(dep);
288
+ for (const [, moduleSet] of moduleTypesMap) {
289
+ moduleSet.delete(dep);
290
+ }
291
+ changed = true;
292
+ }
293
+ }
294
+ // Step 6: enumMap 동반 이동 — *Map const는 base type과 동일 위치에 배치
295
+ for (const typeName of allTypeNames) {
296
+ if (typeName.endsWith('Map')) {
297
+ const baseTypeName = typeName.slice(0, -3); // FooTypeMap -> FooType
298
+ if (!allTypeNames.has(baseTypeName))
299
+ continue;
300
+ // base type이 있는 위치를 찾아서 Map도 같은 곳으로 이동
301
+ if (sharedTypesSet.has(baseTypeName)) {
302
+ // base가 shared → Map도 shared
303
+ sharedTypesSet.add(typeName);
304
+ for (const [, moduleSet] of moduleTypesMap) {
305
+ moduleSet.delete(typeName);
306
+ }
307
+ }
308
+ else {
309
+ // base가 특정 모듈에 있음 → Map도 같은 모듈로
310
+ for (const [moduleName, moduleSet] of moduleTypesMap) {
311
+ if (moduleSet.has(baseTypeName)) {
312
+ moduleSet.add(typeName);
313
+ // 다른 모듈이나 shared에서 제거
314
+ sharedTypesSet.delete(typeName);
315
+ for (const [otherMod, otherSet] of moduleTypesMap) {
316
+ if (otherMod !== moduleName) {
317
+ otherSet.delete(typeName);
318
+ }
319
+ }
320
+ break;
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ // 빈 모듈 제거
327
+ for (const [moduleName, typeSet] of moduleTypesMap) {
328
+ if (typeSet.size === 0) {
329
+ moduleTypesMap.delete(moduleName);
330
+ }
331
+ }
332
+ // Set → Array 변환
333
+ const moduleTypes = new Map();
334
+ for (const [moduleName, typeSet] of moduleTypesMap) {
335
+ moduleTypes.set(moduleName, [...typeSet]);
336
+ }
337
+ return {
338
+ moduleTypes,
339
+ sharedTypes: [...sharedTypesSet],
340
+ parsedTypes,
341
+ };
342
+ }
343
+
77
344
  const { TYPE_FILE, UTIL_FILE, QUERY_HOOK_INDICATOR, USE_SUSPENSE_QUERY_HOOK_INDICATOR, } = GENERATE_SWAGGER_DATA;
78
- const writeSwaggerApiFile = (params) => {
345
+ const writeSwaggerApiFile = async (params) => {
79
346
  const { input, output, spinner, config } = params;
80
- input.files.forEach(async ({ fileName, fileContent: content, fileExtension }) => {
347
+ // === Pre-analysis (splitDataContracts 모드) ===
348
+ // for...of 전에 classification을 완료하여 import 후처리에 사용
349
+ let classificationResult = null;
350
+ if (config.splitDataContracts) {
351
+ const dataContractsFile = input.files.find((f) => f.fileName + f.fileExtension === 'data-contracts.ts');
352
+ if (dataContractsFile?.fileContent) {
353
+ // GenerateApiOutput은 configuration을 런타임에 포함하지만 타입 정의에서 미노출
354
+ const configuration = input.configuration;
355
+ const routesCombined = configuration?.routes?.combined;
356
+ classificationResult = classifyTypes(dataContractsFile.fileContent, routesCombined);
357
+ }
358
+ }
359
+ // === Pass 1: for...of 루프 (기존 로직 + splitDataContracts 분기) ===
360
+ for (const { fileName, fileContent: content, fileExtension } of input.files) {
81
361
  const name = fileName + fileExtension;
82
362
  try {
83
363
  const isTypeFile = TYPE_FILE.includes(name);
@@ -85,6 +365,13 @@ const writeSwaggerApiFile = (params) => {
85
365
  const isHttpClient = name === 'http-client.ts';
86
366
  const isApiFile = content?.includes(QUERY_HOOK_INDICATOR);
87
367
  const filename = name.replace('.ts', '');
368
+ // splitDataContracts: data-contracts.ts 쓰기 억제
369
+ if (isTypeFile &&
370
+ name === 'data-contracts.ts' &&
371
+ config.splitDataContracts) {
372
+ // 디스크에 쓰지 않음 — pre-analysis에서 이미 content를 처리함
373
+ continue;
374
+ }
88
375
  const getTargetFolder = () => {
89
376
  if (isUtilFile)
90
377
  return path.resolve(output, '@utils');
@@ -100,72 +387,156 @@ const writeSwaggerApiFile = (params) => {
100
387
  spinner.info(`generated: ${targetFolder}`);
101
388
  if (isHttpClient) {
102
389
  generate(path.resolve(targetFolder, 'index.ts'), content);
103
- return;
390
+ continue;
104
391
  }
105
392
  if (isApiFile) {
106
- const { apiContents, hookParts } = splitHookContents(filename, content);
107
- generatePretty(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
393
+ // splitDataContracts: import 후처리 (splitHookContents 호출 )
394
+ let processedContent = content;
395
+ if (config.splitDataContracts && classificationResult) {
396
+ processedContent = rewriteDataContractsImport(content, filename, classificationResult);
397
+ }
398
+ const { apiContents, hookParts } = splitHookContents(filename, processedContent);
399
+ generate(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
108
400
  if (config.includeReactQuery) {
109
- generatePretty(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
401
+ generate(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
110
402
  }
111
403
  if (config.includeReactSuspenseQuery) {
112
- generatePretty(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
404
+ generate(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
113
405
  }
114
- return;
406
+ continue;
115
407
  }
116
408
  generate(path.resolve(targetFolder, name), content);
117
409
  }
118
410
  catch (err) {
119
411
  console.error(err);
120
412
  }
121
- });
413
+ }
414
+ // === Post-step: 분할 contracts 파일 생성 (동기 작업이므로 async forEach 영향 없음) ===
415
+ if (config.splitDataContracts && classificationResult) {
416
+ const { moduleTypes, sharedTypes, parsedTypes } = classificationResult;
417
+ // 모듈별 contracts 파일 생성
418
+ for (const [moduleName, typeNames] of moduleTypes.entries()) {
419
+ const moduleFolder = path.resolve(output, moduleName);
420
+ fs.mkdirSync(moduleFolder, { recursive: true });
421
+ const moduleContent = buildContractsFileContent(typeNames, parsedTypes, sharedTypes);
422
+ generate(path.resolve(moduleFolder, `${moduleName}.contracts.ts`), moduleContent);
423
+ }
424
+ // common-contracts 파일 생성 (공유 타입이 있을 때만)
425
+ if (sharedTypes.length > 0) {
426
+ const commonFolder = path.resolve(output, '@types');
427
+ fs.mkdirSync(commonFolder, { recursive: true });
428
+ const commonContent = buildContractsFileContent(sharedTypes, parsedTypes);
429
+ generate(path.resolve(commonFolder, 'common-contracts.ts'), commonContent);
430
+ }
431
+ }
122
432
  };
123
- async function generatePretty(path, contents) {
124
- // prettier-plugin-organize-imports가 설치되어 있으면 사용, 없으면 스킵
125
- let organized = contents;
126
- try {
127
- organized = await prettierString(contents, {
128
- parser: 'babel-ts',
129
- plugins: ['prettier-plugin-organize-imports'],
433
+ /**
434
+ * 타입 이름 목록과 파싱된 타입 블록으로 contracts 파일 내용을 생성합니다.
435
+ * @internal — exported for testing
436
+ */
437
+ function buildContractsFileContent(typeNames, parsedTypes, sharedTypeNames) {
438
+ const header = `/* eslint-disable */
439
+ /* tslint:disable */
440
+ /**
441
+ * !DO NOT EDIT THIS FILE!
442
+ *
443
+ * This file was auto-generated by tok-cli.config.ts 에서 설정된 gen:api 명령어로 생성되었습니다.
444
+ */\n`;
445
+ const blocks = typeNames.map((name) => parsedTypes[name]).filter(Boolean);
446
+ const bodyContent = blocks.join('\n\n');
447
+ // module contracts가 shared type을 참조하면 import 추가
448
+ let importSection = '';
449
+ if (sharedTypeNames && sharedTypeNames.length > 0) {
450
+ const referencedShared = sharedTypeNames.filter((sharedType) => {
451
+ const escaped = sharedType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
+ return new RegExp(`\\b${escaped}\\b`).test(bodyContent);
130
453
  });
454
+ if (referencedShared.length > 0) {
455
+ importSection =
456
+ `import { ${referencedShared.join(', ')} } from '../@types/common-contracts';\n\n`;
457
+ }
131
458
  }
132
- catch (err) {
133
- // 플러그인이 없거나 에러 발생 시 원본 사용
134
- console.warn('⚠️ prettier-plugin-organize-imports not found, skipping import organization');
459
+ return header + '\n' + importSection + bodyContent + '\n';
460
+ }
461
+ /**
462
+ * api 파일의 data-contracts import를 모듈별 contracts + common-contracts로 교체합니다.
463
+ *
464
+ * import의 `[^}]+`는 negated character class이므로 멀티라인도 매칭됩니다.
465
+ * (prettier가 줄바꿈해도 안전)
466
+ * @internal — exported for testing
467
+ */
468
+ function rewriteDataContractsImport(content, filename, classification) {
469
+ const moduleTypeNames = classification.moduleTypes.get(filename) || [];
470
+ const sharedTypeNames = classification.sharedTypes;
471
+ // 현재 api 파일에서 실제로 사용하는 타입만 필터링
472
+ const allImportedTypes = extractImportedTypeNames(content);
473
+ const usedModuleTypes = moduleTypeNames.filter((t) => allImportedTypes.has(t));
474
+ const usedSharedTypes = sharedTypeNames.filter((t) => allImportedTypes.has(t));
475
+ // 기존 data-contracts import 라인을 찾아서 교체
476
+ const importRegex = /import\s*\{[^}]+\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/;
477
+ const newImports = [];
478
+ if (usedModuleTypes.length > 0) {
479
+ newImports.push(`import { ${usedModuleTypes.join(', ')} } from './${filename}.contracts';`);
480
+ }
481
+ if (usedSharedTypes.length > 0) {
482
+ newImports.push(`import { ${usedSharedTypes.join(', ')} } from '../@types/common-contracts';`);
135
483
  }
136
- const formatted = await prettierString(organized, {
137
- parser: 'typescript',
138
- configPath: 'auto',
139
- });
140
- generate(path, formatted);
484
+ // import가 하나도 없으면 빈 문자열로 교체 (사용하지 않는 타입만 있던 경우)
485
+ if (newImports.length === 0) {
486
+ return content.replace(importRegex, '');
487
+ }
488
+ return content.replace(importRegex, newImports.join('\n'));
489
+ }
490
+ /**
491
+ * api 파일 content에서 data-contracts import 구문의 타입 이름들을 추출합니다.
492
+ * @internal — exported for testing
493
+ */
494
+ function extractImportedTypeNames(content) {
495
+ const importMatch = content.match(/import\s*\{([^}]+)\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/);
496
+ if (!importMatch)
497
+ return new Set();
498
+ return new Set(importMatch[1]
499
+ .split(',')
500
+ .map((s) => s.trim())
501
+ .filter(Boolean));
141
502
  }
142
503
  function generate(path, contents) {
143
- // 기존 파일이 있으면 읽어서 병합
144
504
  let existingContent = '';
145
505
  try {
146
506
  if (fs.existsSync(path)) {
147
507
  existingContent = fs.readFileSync(path, 'utf8');
148
- console.log('🔧 [SMART-MERGE] Found existing file, merging:', path);
149
508
  }
150
509
  }
151
510
  catch (err) {
152
- console.log('🔧 [SMART-MERGE] No existing file found:', path);
511
+ // no existing file
153
512
  }
154
- // 기존 내용이 있으면 병합
155
513
  if (existingContent) {
156
- // 스마트 병합: 중복 타입 제거
157
514
  const mergedContent = mergeTypeScriptContent(existingContent, contents);
158
515
  fs.writeFileSync(path, mergedContent);
159
- console.log('🔧 [SMART-MERGE] Smart merged content for:', path);
160
516
  }
161
517
  else {
162
518
  fs.writeFileSync(path, contents);
163
- console.log('🔧 [SMART-MERGE] Created new file:', path);
164
519
  }
165
520
  }
166
521
  function splitHookContents(filename, content) {
167
- const [_apiContent, _hookContent] = content.split(QUERY_HOOK_INDICATOR);
168
- const _hookParts = _hookContent.split(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
522
+ const indicatorIdx = content.indexOf(QUERY_HOOK_INDICATOR);
523
+ if (indicatorIdx === -1) {
524
+ throw new Error(`[splitHookContents] QUERY_HOOK_INDICATOR not found in ${filename}. ` +
525
+ `Ensure the template includes the indicator comment.`);
526
+ }
527
+ const _apiContent = content.slice(0, indicatorIdx);
528
+ const _hookContent = content.slice(indicatorIdx + QUERY_HOOK_INDICATOR.length);
529
+ const suspenseIdx = _hookContent.indexOf(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
530
+ let _hookParts;
531
+ if (suspenseIdx === -1) {
532
+ _hookParts = [_hookContent, ''];
533
+ }
534
+ else {
535
+ _hookParts = [
536
+ _hookContent.slice(0, suspenseIdx),
537
+ _hookContent.slice(suspenseIdx + USE_SUSPENSE_QUERY_HOOK_INDICATOR.length),
538
+ ];
539
+ }
169
540
  const lastImport = getLastImportLine(content);
170
541
  const lines = content.split('\n');
171
542
  const importArea = [
@@ -177,12 +548,16 @@ function splitHookContents(filename, content) {
177
548
  hookParts: _hookParts.map((d) => importArea + d),
178
549
  };
179
550
  }
551
+ /** @internal — exported for testing */
180
552
  function getLastImportLine(content) {
181
- return (Math.max(...content
553
+ const importLines = content
182
554
  .split('\n')
183
555
  .map((line, idx) => ({ idx, has: /from ('|").*('|");/.test(line) }))
184
556
  .filter(({ has }) => has)
185
- .map(({ idx }) => idx)) + 1);
557
+ .map(({ idx }) => idx);
558
+ if (importLines.length === 0)
559
+ return 0;
560
+ return Math.max(...importLines) + 1;
186
561
  }
187
562
 
188
563
  /**
@@ -206,6 +581,7 @@ const genApi = defineCommand({
206
581
  },
207
582
  ],
208
583
  ignoreTlsError: false,
584
+ splitDataContracts: false,
209
585
  },
210
586
  run: async (config) => {
211
587
  if (config.ignoreTlsError) {
@@ -225,7 +601,6 @@ const genApi = defineCommand({
225
601
  }
226
602
  return [];
227
603
  })();
228
- console.log('🔧 [MULTI-URL] Processing URLs:', urls);
229
604
  const coverPath = (config, url) => {
230
605
  const { httpClientType, output } = config;
231
606
  const { AXIOS_DEFAULT_INSTANCE_PATH, FETCH_DEFAULT_INSTANCE_PATH } = GENERATE_SWAGGER_DATA;
@@ -243,7 +618,6 @@ const genApi = defineCommand({
243
618
  // 각 URL별로 순차 처리
244
619
  for (let i = 0; i < urls.length; i++) {
245
620
  const url = urls[i];
246
- console.log(`🔧 [MULTI-URL] Processing URL ${i + 1}/${urls.length}: ${url}`);
247
621
  const covered = coverPath(config, url);
248
622
  const parsed = await withLoading(`Parse Swagger ${i + 1}/${urls.length}`, 'swaggerSchemaUrl' in covered ? covered.swaggerSchemaUrl : '', () => {
249
623
  return parseSwagger(omit(covered, 'swaggerSchemaUrls'));
@@ -252,9 +626,9 @@ const genApi = defineCommand({
252
626
  console.error(`Failed to generate api for URL ${i + 1}: swagger parse error.`);
253
627
  continue;
254
628
  }
255
- withLoading('Write Swagger API', //
629
+ await withLoading('Write Swagger API', //
256
630
  covered.output, (spinner) => {
257
- writeSwaggerApiFile({
631
+ return writeSwaggerApiFile({
258
632
  input: parsed,
259
633
  output: covered.output,
260
634
  spinner,
@@ -265,7 +639,9 @@ const genApi = defineCommand({
265
639
  await withLoading('Prettier format', covered.output, async () => {
266
640
  const fs = await import('fs');
267
641
  const pathMod = await import('path');
268
- const { prettierString } = await import('@toktokhan-dev/node');
642
+ const { prettierString, findFileToTop } = await import('@toktokhan-dev/node');
643
+ // output 디렉토리 기준으로 prettier config를 탐색 (cwd 의존 제거)
644
+ const configPath = findFileToTop(covered.output, '.prettierrc.js') || 'auto';
269
645
  const listTsFiles = (dir) => {
270
646
  const entries = fs.readdirSync(dir, { withFileTypes: true });
271
647
  const files = [];
@@ -284,9 +660,19 @@ const genApi = defineCommand({
284
660
  for (const file of files) {
285
661
  try {
286
662
  const raw = fs.readFileSync(file, 'utf8');
287
- const formatted = await prettierString(raw, {
663
+ let organized = raw;
664
+ try {
665
+ organized = await prettierString(raw, {
666
+ parser: 'babel-ts',
667
+ plugins: ['prettier-plugin-organize-imports'],
668
+ });
669
+ }
670
+ catch {
671
+ // prettier-plugin-organize-imports not installed, skip
672
+ }
673
+ const formatted = await prettierString(organized, {
288
674
  parser: 'typescript',
289
- configPath: 'auto',
675
+ configPath,
290
676
  });
291
677
  fs.writeFileSync(file, formatted);
292
678
  }
@@ -301,25 +687,12 @@ const genApi = defineCommand({
301
687
  /**
302
688
  * 스마트 타입 병합 함수들
303
689
  */
304
- // 타입 정의 파싱 함수
305
- function parseTypeDefinitions(content) {
306
- const types = {};
307
- // export type, interface, enum, const 패턴 매칭
308
- const typeRegex = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=export\s+(?:type|interface|enum|const)\s+\w+|$)/g;
309
- let match;
310
- while ((match = typeRegex.exec(content)) !== null) {
311
- const typeName = match[2];
312
- const typeContent = match[1].trim();
313
- types[typeName] = typeContent;
314
- }
315
- return types;
316
- }
317
- // (deprecated) 타입 문자열 변환 로직은 병합 로직 내에서 직접 조립합니다.
318
690
  // 스마트 타입 병합 함수
319
691
  function mergeTypeScriptContent(existing, newContent) {
320
692
  // 1) import 구문 보존 및 병합 (양쪽 모두에서 수집)
321
- const importRegex = /^\s*import\s+[^;]*;\s*$/gm;
322
- const sideEffectImportRegex = /^\s*import\s+['"][^'"]+['"];\s*$/gm;
693
+ // 멀티라인 import도 매칭: import { \n A, \n B \n } from '...';
694
+ const importRegex = /^\s*import\s+[\s\S]*?from\s*['"][^'"]*['"];?/gm;
695
+ const sideEffectImportRegex = /^\s*import\s*['"][^'"]+['"];\s*$/gm;
323
696
  const collectImports = (content) => {
324
697
  const imports = new Set();
325
698
  const matchedA = content.match(importRegex) ?? [];
@@ -335,28 +708,17 @@ function mergeTypeScriptContent(existing, newContent) {
335
708
  // import 병합: 새 파일 기준 우선 순서 + 기존에만 있는 import 추가
336
709
  const mergedImportSet = new Set(newImports);
337
710
  existingImports.forEach((imp) => mergedImportSet.add(imp));
338
- const mergedImports = Array.from(mergedImportSet);
711
+ const mergedImports = Array.from(mergedImportSet).sort();
339
712
  // 2) 타입 선언 병합 (중복 제거)
340
- const typeBlockRegex = /(export\s+(?:type|interface|enum|const)\s+\w+[\s\S]*?)(?=export\s+(?:type|interface|enum|const)\s+\w+|$)/g;
341
713
  const existingTypes = parseTypeDefinitions(existingBody);
342
714
  const newTypes = parseTypeDefinitions(newBody);
343
- console.log('🔧 [SMART-MERGE] Existing types count:', Object.keys(existingTypes).length);
344
- console.log('🔧 [SMART-MERGE] New types count:', Object.keys(newTypes).length);
345
- const mergedTypes = { ...existingTypes };
346
- let addedCount = 0;
347
- let skippedCount = 0;
348
- for (const [typeName, typeContent] of Object.entries(newTypes)) {
349
- if (mergedTypes[typeName]) {
350
- console.log('🔧 [SMART-MERGE] Skipping duplicate type:', typeName);
351
- skippedCount++;
352
- }
353
- else {
354
- mergedTypes[typeName] = typeContent;
355
- addedCount++;
356
- }
357
- }
358
- console.log('🔧 [SMART-MERGE] Added types:', addedCount, 'Skipped duplicates:', skippedCount);
359
- const mergedTypesString = Object.values(mergedTypes).join('\n\n');
715
+ // 타입 기준으로 병합: 새 버전이 source of truth (swagger 스키마)
716
+ // 기존에만 있는 타입은 보존 (사용자가 수동 추가한 것)
717
+ const mergedTypes = { ...existingTypes, ...newTypes };
718
+ const mergedTypesString = Object.entries(mergedTypes)
719
+ .sort(([a], [b]) => a.localeCompare(b))
720
+ .map(([, v]) => v)
721
+ .join('\n\n');
360
722
  // 3) 기타 코드(타입/임포트 외)는 "새로운 내용"을 기준으로 유지
361
723
  const removeHeaderComment = (content) => {
362
724
  // 파일 상단의 모든 블록 코멘트와 라인 코멘트를 제거 (재귀적 처리)
@@ -372,7 +734,7 @@ function mergeTypeScriptContent(existing, newContent) {
372
734
  return removeLineComment(removeBlockComment(content));
373
735
  };
374
736
  // 새 본문에서 타입 블록 제거 후 남은 코드
375
- const newBodyWithoutTypes = (newBody || '').replace(typeBlockRegex, '');
737
+ const newBodyWithoutTypes = (newBody || '').replace(new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags), '');
376
738
  let otherCodeFromNew = removeHeaderComment(newBodyWithoutTypes).trim();
377
739
  // 본문 내에 남아있는 "!DO NOT EDIT THIS FILE" 주석 블록들을 모두 제거
378
740
  otherCodeFromNew = otherCodeFromNew.replace(/\/\*\*?\s*\*\s*!DO NOT EDIT THIS FILE[\s\S]*?\*\//g, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toktokhan-dev/cli-plugin-gen-api-react-query",
3
- "version": "0.1.10",
3
+ "version": "0.2.1",
4
4
  "description": "A CLI plugin for generating API hooks with React Query built by TOKTOKHAN.DEV",
5
5
  "author": "TOKTOKHAN.DEV <fe-system@toktokhan.dev>",
6
6
  "license": "ISC",
@@ -38,8 +38,8 @@
38
38
  "lodash": "^4.17.21",
39
39
  "prettier-plugin-organize-imports": "^3.2.4",
40
40
  "swagger-typescript-api": "13.0.22",
41
- "@toktokhan-dev/node": "0.0.10",
42
- "@toktokhan-dev/cli": "0.0.11"
41
+ "@toktokhan-dev/cli": "0.0.11",
42
+ "@toktokhan-dev/node": "0.0.10"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/lodash": "^4.17.0",
@@ -50,6 +50,6 @@
50
50
  "build:watch": "rollup -c --watch",
51
51
  "api-extractor": "api-extractor run --local --verbose",
52
52
  "play": "pnpm build:watch & node --watch play/playground.js",
53
- "test": "echo \"Error: no test specified\" && exit 1"
53
+ "test": "jest --passWithNoTests"
54
54
  }
55
55
  }
@@ -36,7 +36,7 @@
36
36
  return `${keyName} : "${enumNames?.[idx]}"`;
37
37
  }).join(", ");
38
38
 
39
- return `type ${contract.name} = keyof typeof ${mapName}; export const ${mapName} = {\r\n${map} \r\n } as const`;
39
+ return `type ${contract.name} = keyof typeof ${mapName}; export const ${mapName} = {\n${map} \n } as const`;
40
40
 
41
41
  },
42
42
  interface: (contract) => {
@@ -49,7 +49,7 @@
49
49
  content = content.replace(field, `${field} | null`)
50
50
  })
51
51
 
52
- return `interface ${contract.name} {\r\n${content}}`;
52
+ return `interface ${contract.name} {\n${content}}`;
53
53
  },
54
54
  type: (contract) => {
55
55
  return `type ${contract.name} = ${contract.content}`;
@@ -83,8 +83,7 @@
83
83
  <% if (description.length) { %>
84
84
  /**
85
85
  <%~ description.map(part => `* ${part}`).join("\n") %>
86
-
87
- */
86
+ */
88
87
  <% } %>
89
88
  export <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %>
90
89
 
@@ -36,7 +36,7 @@
36
36
  return `${keyName} : "${enumNames?.[idx]}"`;
37
37
  }).join(", ");
38
38
 
39
- return `type ${contract.name} = keyof typeof ${mapName}; export const ${mapName} = {\r\n${map} \r\n } as const`;
39
+ return `type ${contract.name} = keyof typeof ${mapName}; export const ${mapName} = {\n${map} \n } as const`;
40
40
 
41
41
  },
42
42
  interface: (contract) => {
@@ -49,7 +49,7 @@
49
49
  content = content.replace(field, `${field} | null`)
50
50
  })
51
51
 
52
- return `interface ${contract.name} {\r\n${content}}`;
52
+ return `interface ${contract.name} {\n${content}}`;
53
53
  },
54
54
  type: (contract) => {
55
55
  return `type ${contract.name} = ${contract.content}`;
@@ -85,8 +85,7 @@
85
85
  <% if (description.length) { %>
86
86
  /**
87
87
  <%~ description.map(part => `* ${part}`).join("\n") %>
88
-
89
- */
88
+ */
90
89
  <% } %>
91
90
  export <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %>
92
91
 
@@ -60,7 +60,7 @@ export enum ContentType {
60
60
  }
61
61
 
62
62
  export class HttpClient<SecurityDataType = unknown> {
63
- public baseUrl: string = "<%~ apiConfig.baseUrl %>";
63
+ public baseUrl: string = "<%~ apiConfig.baseUrl.replace(/\/$/, '') %>";
64
64
  private securityData: SecurityDataType | null = null;
65
65
  private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
66
66
  private abortControllers = new Map<CancelToken, AbortController>();
@@ -44,7 +44,7 @@ export type InfiniteQueryHookParams<
44
44
  > = {
45
45
  options?: Partial<
46
46
  Omit<
47
- UseInfiniteQueryOptions<OriginData, Error, TData, OriginData, any, TPageParam>,
47
+ UseInfiniteQueryOptions<OriginData, Error, TData, any, TPageParam>,
48
48
  'queryKey' | 'queryFn'
49
49
  >
50
50
  >;
@@ -85,7 +85,7 @@ export type SuspenseInfiniteQueryHookParams<
85
85
  > = {
86
86
  options?: Partial<
87
87
  Omit<
88
- UseSuspenseInfiniteQueryOptions<OriginData, Error, TData, OriginData, any, TPageParam>,
88
+ UseSuspenseInfiniteQueryOptions<OriginData, Error, TData, any, TPageParam>,
89
89
  'queryKey' | 'queryFn'
90
90
  >
91
91
  >;