@toktokhan-dev/cli-plugin-gen-api-react-query 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -74,10 +74,288 @@ const parseSwagger = (config) => generateApi({
74
74
  },
75
75
  });
76
76
 
77
+ /**
78
+ * data-contracts.ts의 렌더링된 내용을 타입 블록 단위로 파싱합니다.
79
+ * 각 export type/interface/enum/const를 독립된 블록으로 분리합니다.
80
+ *
81
+ * lookahead로 `\nexport\s`를 사용하여 export function/class 등
82
+ * 비표준 export도 블록 경계로 인식합니다.
83
+ */
84
+ const TYPE_BLOCK_REGEX = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=\nexport\s|$)/g;
85
+ function parseTypeDefinitions(content) {
86
+ const types = {};
87
+ const typeRegex = new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags);
88
+ let match;
89
+ while ((match = typeRegex.exec(content)) !== null) {
90
+ const typeName = match[2];
91
+ const typeContent = match[1].trim();
92
+ types[typeName] = typeContent;
93
+ }
94
+ return types;
95
+ }
96
+
97
+ /**
98
+ * 코드에서 주석과 문자열 리터럴을 제거합니다.
99
+ * false positive 의존성 감지를 방지합니다. (예: 주석 내 타입명 언급)
100
+ */
101
+ function stripNonCode(code) {
102
+ return code
103
+ .replace(/\/\*[\s\S]*?\*\//g, '')
104
+ .replace(/\/\/.*$/gm, '')
105
+ .replace(/'[^']*'/g, '""')
106
+ .replace(/"[^"]*"/g, '""')
107
+ .replace(/`[^`]*`/g, '""');
108
+ }
109
+ /**
110
+ * 렌더링된 타입 블록들 간의 의존성 그래프를 구축합니다.
111
+ *
112
+ * 각 타입 블록의 TypeScript 코드에서 다른 타입 이름이 참조되는지를
113
+ * 단어 경계 기반 정규식으로 감지합니다.
114
+ * 주석과 문자열 리터럴은 제거 후 매칭하여 false positive를 줄입니다.
115
+ *
116
+ * @param parsedTypes - parseTypeDefinitions의 결과 (typeName -> renderedBlock)
117
+ * @returns Map<typeName, Set<referencedTypeName>> - 직접 의존성 그래프
118
+ */
119
+ function buildDependencyGraph(parsedTypes) {
120
+ const knownTypes = Object.keys(parsedTypes);
121
+ const graph = new Map();
122
+ // Pre-compile regexes once (O(n) instead of O(n²) compilations)
123
+ const compiledPatterns = new Map();
124
+ for (const known of knownTypes) {
125
+ const escaped = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126
+ compiledPatterns.set(known, new RegExp(`\\b${escaped}\\b`));
127
+ }
128
+ for (const [typeName, typeBlock] of Object.entries(parsedTypes)) {
129
+ const deps = new Set();
130
+ const cleanBlock = stripNonCode(typeBlock);
131
+ for (const known of knownTypes) {
132
+ if (known !== typeName && compiledPatterns.get(known).test(cleanBlock)) {
133
+ deps.add(known);
134
+ }
135
+ }
136
+ graph.set(typeName, deps);
137
+ }
138
+ return graph;
139
+ }
140
+ /**
141
+ * 직접 의존성 그래프에서 전이적 의존성을 포함한 전체 의존성을 계산합니다.
142
+ * A -> B -> C면, A의 의존성은 {B, C}가 됩니다.
143
+ */
144
+ function getTransitiveDependencies(graph, typeName, visited = new Set()) {
145
+ if (visited.has(typeName))
146
+ return new Set();
147
+ visited.add(typeName);
148
+ const directDeps = graph.get(typeName) || new Set();
149
+ const allDeps = new Set(directDeps);
150
+ for (const dep of directDeps) {
151
+ const transitiveDeps = getTransitiveDependencies(graph, dep, visited);
152
+ for (const td of transitiveDeps) {
153
+ allDeps.add(td);
154
+ }
155
+ }
156
+ return allDeps;
157
+ }
158
+
159
+ /**
160
+ * route의 type 문자열에서 알려진 타입 이름들을 추출합니다.
161
+ * 제네릭, 유니온, 인터섹션, 배열 등에서 타입 이름을 추출합니다.
162
+ *
163
+ * 예: "PaginatedResponse<UserType>" -> ["PaginatedResponse", "UserType"]
164
+ */
165
+ function extractTypeNames(typeString, knownTypes) {
166
+ if (!typeString)
167
+ return [];
168
+ const identifiers = typeString.match(/\b[A-Z]\w+/g) || [];
169
+ return identifiers.filter((id) => knownTypes.has(id));
170
+ }
171
+ /**
172
+ * swagger-typescript-api의 routes.combined에서 타입-모듈 매핑을 구축합니다.
173
+ *
174
+ * 각 route의 response.type, response.errorType, request.payload.type,
175
+ * request.query.type에서 타입 이름을 추출하여, 어느 모듈에서 사용하는지 매핑합니다.
176
+ *
177
+ * @returns Map<typeName, Set<moduleName>> (moduleName은 PascalCase로 정규화)
178
+ */
179
+ function buildTypeToModuleMap(combined, knownTypeNames) {
180
+ const typeToModules = new Map();
181
+ if (!combined)
182
+ return typeToModules;
183
+ for (const moduleGroup of combined) {
184
+ // moduleName을 PascalCase로 정규화 (write-swagger.ts의 filename과 일치시키기 위해)
185
+ const normalizedModuleName = upperFirst(camelCase(moduleGroup.moduleName));
186
+ for (const route of moduleGroup.routes) {
187
+ // ParsedRoute의 .d.ts는 request: Request, response: Response (DOM 타입)으로 선언되어 있으나
188
+ // 런타임에는 swagger-typescript-api 내부 객체임. as any 캐스팅 필요.
189
+ const r = route;
190
+ const typeStrings = [
191
+ r.response?.type,
192
+ r.response?.errorType,
193
+ r.request?.payload?.type,
194
+ r.request?.query?.type,
195
+ ];
196
+ // request.parameters에서도 타입 추출 (path params 등)
197
+ if (r.request?.parameters) {
198
+ for (const param of Object.values(r.request.parameters)) {
199
+ if (param?.type) {
200
+ typeStrings.push(param.type);
201
+ }
202
+ }
203
+ }
204
+ for (const typeStr of typeStrings) {
205
+ const extracted = extractTypeNames(typeStr, knownTypeNames);
206
+ for (const typeName of extracted) {
207
+ if (!typeToModules.has(typeName)) {
208
+ typeToModules.set(typeName, new Set());
209
+ }
210
+ typeToModules.get(typeName).add(normalizedModuleName);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ return typeToModules;
216
+ }
217
+
218
+ /**
219
+ * data-contracts.ts의 타입들을 모듈별/공유로 분류합니다.
220
+ *
221
+ * 분류 알고리즘:
222
+ * 1. 직접 매핑: route에서 참조하는 타입 → 해당 모듈에 소속
223
+ * 2. 의존성 전파: 타입이 참조하는 다른 타입도 같은 모듈에 소속
224
+ * 3. 공유 판별: 2개 이상 모듈에 소속된 타입 → shared
225
+ * 4. Orphan 처리: 어디에도 소속되지 않은 타입 → shared
226
+ * 5. 전이적 공유: shared 타입이 참조하는 타입도 shared로 격상
227
+ * 6. enumMap 동반 이동: *Map const는 base type과 동일 파일에 배치
228
+ */
229
+ function classifyTypes(dataContractsContent, routesCombined) {
230
+ const parsedTypes = parseTypeDefinitions(dataContractsContent);
231
+ const allTypeNames = new Set(Object.keys(parsedTypes));
232
+ // Step 1: 타입-모듈 직접 매핑
233
+ const typeToModules = buildTypeToModuleMap(routesCombined, allTypeNames);
234
+ // Step 2: 의존성 그래프 구축 + 의존성 전파
235
+ const depGraph = buildDependencyGraph(parsedTypes);
236
+ // 의존성 전파: 각 타입의 전이적 의존성도 같은 모듈에 추가
237
+ for (const [typeName, modules] of typeToModules.entries()) {
238
+ const transitiveDeps = getTransitiveDependencies(depGraph, typeName);
239
+ for (const dep of transitiveDeps) {
240
+ if (!typeToModules.has(dep)) {
241
+ typeToModules.set(dep, new Set());
242
+ }
243
+ for (const mod of modules) {
244
+ typeToModules.get(dep).add(mod);
245
+ }
246
+ }
247
+ }
248
+ // Step 3: 분류 (module-exclusive vs shared vs orphan)
249
+ const moduleTypesMap = new Map();
250
+ const sharedTypesSet = new Set();
251
+ for (const typeName of allTypeNames) {
252
+ const modules = typeToModules.get(typeName);
253
+ if (!modules || modules.size === 0) {
254
+ // Orphan: 어디서도 참조되지 않음 → shared (안전 기본값)
255
+ sharedTypesSet.add(typeName);
256
+ }
257
+ else if (modules.size === 1) {
258
+ // Module-exclusive: 한 모듈에서만 사용
259
+ const moduleName = [...modules][0];
260
+ if (!moduleTypesMap.has(moduleName)) {
261
+ moduleTypesMap.set(moduleName, new Set());
262
+ }
263
+ moduleTypesMap.get(moduleName).add(typeName);
264
+ }
265
+ else {
266
+ // Shared: 2개 이상 모듈에서 사용
267
+ sharedTypesSet.add(typeName);
268
+ }
269
+ }
270
+ // Step 5: 전이적 공유 — shared 타입이 참조하는 타입도 shared로 격상
271
+ // 순회 중 Set에 추가하지 않고 별도 배열에 수집 후 일괄 적용
272
+ let changed = true;
273
+ while (changed) {
274
+ changed = false;
275
+ const toPromote = [];
276
+ for (const sharedType of sharedTypesSet) {
277
+ const deps = depGraph.get(sharedType) || new Set();
278
+ for (const dep of deps) {
279
+ if (!sharedTypesSet.has(dep)) {
280
+ toPromote.push(dep);
281
+ }
282
+ }
283
+ }
284
+ for (const dep of toPromote) {
285
+ sharedTypesSet.add(dep);
286
+ for (const [, moduleSet] of moduleTypesMap) {
287
+ moduleSet.delete(dep);
288
+ }
289
+ changed = true;
290
+ }
291
+ }
292
+ // Step 6: enumMap 동반 이동 — *Map const는 base type과 동일 위치에 배치
293
+ for (const typeName of allTypeNames) {
294
+ if (typeName.endsWith('Map')) {
295
+ const baseTypeName = typeName.slice(0, -3); // FooTypeMap -> FooType
296
+ if (!allTypeNames.has(baseTypeName))
297
+ continue;
298
+ // base type이 있는 위치를 찾아서 Map도 같은 곳으로 이동
299
+ if (sharedTypesSet.has(baseTypeName)) {
300
+ // base가 shared → Map도 shared
301
+ sharedTypesSet.add(typeName);
302
+ for (const [, moduleSet] of moduleTypesMap) {
303
+ moduleSet.delete(typeName);
304
+ }
305
+ }
306
+ else {
307
+ // base가 특정 모듈에 있음 → Map도 같은 모듈로
308
+ for (const [moduleName, moduleSet] of moduleTypesMap) {
309
+ if (moduleSet.has(baseTypeName)) {
310
+ moduleSet.add(typeName);
311
+ // 다른 모듈이나 shared에서 제거
312
+ sharedTypesSet.delete(typeName);
313
+ for (const [otherMod, otherSet] of moduleTypesMap) {
314
+ if (otherMod !== moduleName) {
315
+ otherSet.delete(typeName);
316
+ }
317
+ }
318
+ break;
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ // 빈 모듈 제거
325
+ for (const [moduleName, typeSet] of moduleTypesMap) {
326
+ if (typeSet.size === 0) {
327
+ moduleTypesMap.delete(moduleName);
328
+ }
329
+ }
330
+ // Set → Array 변환
331
+ const moduleTypes = new Map();
332
+ for (const [moduleName, typeSet] of moduleTypesMap) {
333
+ moduleTypes.set(moduleName, [...typeSet]);
334
+ }
335
+ return {
336
+ moduleTypes,
337
+ sharedTypes: [...sharedTypesSet],
338
+ parsedTypes,
339
+ };
340
+ }
341
+
77
342
  const { TYPE_FILE, UTIL_FILE, QUERY_HOOK_INDICATOR, USE_SUSPENSE_QUERY_HOOK_INDICATOR, } = GENERATE_SWAGGER_DATA;
78
- const writeSwaggerApiFile = (params) => {
343
+ const writeSwaggerApiFile = async (params) => {
79
344
  const { input, output, spinner, config } = params;
80
- input.files.forEach(async ({ fileName, fileContent: content, fileExtension }) => {
345
+ // === Pre-analysis (splitDataContracts 모드) ===
346
+ // for...of 전에 classification을 완료하여 import 후처리에 사용
347
+ let classificationResult = null;
348
+ if (config.splitDataContracts) {
349
+ const dataContractsFile = input.files.find((f) => f.fileName + f.fileExtension === 'data-contracts.ts');
350
+ if (dataContractsFile?.fileContent) {
351
+ // GenerateApiOutput은 configuration을 런타임에 포함하지만 타입 정의에서 미노출
352
+ const configuration = input.configuration;
353
+ const routesCombined = configuration?.routes?.combined;
354
+ classificationResult = classifyTypes(dataContractsFile.fileContent, routesCombined);
355
+ }
356
+ }
357
+ // === Pass 1: for...of 루프 (기존 로직 + splitDataContracts 분기) ===
358
+ for (const { fileName, fileContent: content, fileExtension } of input.files) {
81
359
  const name = fileName + fileExtension;
82
360
  try {
83
361
  const isTypeFile = TYPE_FILE.includes(name);
@@ -85,6 +363,13 @@ const writeSwaggerApiFile = (params) => {
85
363
  const isHttpClient = name === 'http-client.ts';
86
364
  const isApiFile = content?.includes(QUERY_HOOK_INDICATOR);
87
365
  const filename = name.replace('.ts', '');
366
+ // splitDataContracts: data-contracts.ts 쓰기 억제
367
+ if (isTypeFile &&
368
+ name === 'data-contracts.ts' &&
369
+ config.splitDataContracts) {
370
+ // 디스크에 쓰지 않음 — pre-analysis에서 이미 content를 처리함
371
+ continue;
372
+ }
88
373
  const getTargetFolder = () => {
89
374
  if (isUtilFile)
90
375
  return path.resolve(output, '@utils');
@@ -100,28 +385,120 @@ const writeSwaggerApiFile = (params) => {
100
385
  spinner.info(`generated: ${targetFolder}`);
101
386
  if (isHttpClient) {
102
387
  generate(path.resolve(targetFolder, 'index.ts'), content);
103
- return;
388
+ continue;
104
389
  }
105
390
  if (isApiFile) {
106
- const { apiContents, hookParts } = splitHookContents(filename, content);
107
- generatePretty(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
391
+ // splitDataContracts: import 후처리 (splitHookContents 호출 )
392
+ let processedContent = content;
393
+ if (config.splitDataContracts && classificationResult) {
394
+ processedContent = rewriteDataContractsImport(content, filename, classificationResult);
395
+ }
396
+ const { apiContents, hookParts } = splitHookContents(filename, processedContent);
397
+ await generatePretty(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
108
398
  if (config.includeReactQuery) {
109
- generatePretty(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
399
+ await generatePretty(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
110
400
  }
111
401
  if (config.includeReactSuspenseQuery) {
112
- generatePretty(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
402
+ await generatePretty(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
113
403
  }
114
- return;
404
+ continue;
115
405
  }
116
406
  generate(path.resolve(targetFolder, name), content);
117
407
  }
118
408
  catch (err) {
119
409
  console.error(err);
120
410
  }
121
- });
411
+ }
412
+ // === Post-step: 분할 contracts 파일 생성 (동기 작업이므로 async forEach 영향 없음) ===
413
+ if (config.splitDataContracts && classificationResult) {
414
+ const { moduleTypes, sharedTypes, parsedTypes } = classificationResult;
415
+ // 모듈별 contracts 파일 생성
416
+ for (const [moduleName, typeNames] of moduleTypes.entries()) {
417
+ const moduleFolder = path.resolve(output, moduleName);
418
+ fs.mkdirSync(moduleFolder, { recursive: true });
419
+ const moduleContent = buildContractsFileContent(typeNames, parsedTypes, sharedTypes);
420
+ generate(path.resolve(moduleFolder, `${moduleName}.contracts.ts`), moduleContent);
421
+ }
422
+ // common-contracts 파일 생성 (공유 타입이 있을 때만)
423
+ if (sharedTypes.length > 0) {
424
+ const commonFolder = path.resolve(output, '@types');
425
+ fs.mkdirSync(commonFolder, { recursive: true });
426
+ const commonContent = buildContractsFileContent(sharedTypes, parsedTypes);
427
+ generate(path.resolve(commonFolder, 'common-contracts.ts'), commonContent);
428
+ }
429
+ }
122
430
  };
431
+ /**
432
+ * 타입 이름 목록과 파싱된 타입 블록으로 contracts 파일 내용을 생성합니다.
433
+ * @internal — exported for testing
434
+ */
435
+ function buildContractsFileContent(typeNames, parsedTypes, sharedTypeNames) {
436
+ const header = `/* eslint-disable */
437
+ /* tslint:disable */
438
+ /**
439
+ * !DO NOT EDIT THIS FILE!
440
+ *
441
+ * This file was auto-generated by tok-cli.config.ts 에서 설정된 gen:api 명령어로 생성되었습니다.
442
+ */\n`;
443
+ const blocks = typeNames.map((name) => parsedTypes[name]).filter(Boolean);
444
+ const bodyContent = blocks.join('\n\n');
445
+ // module contracts가 shared type을 참조하면 import 추가
446
+ let importSection = '';
447
+ if (sharedTypeNames && sharedTypeNames.length > 0) {
448
+ const referencedShared = sharedTypeNames.filter((sharedType) => {
449
+ const escaped = sharedType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
450
+ return new RegExp(`\\b${escaped}\\b`).test(bodyContent);
451
+ });
452
+ if (referencedShared.length > 0) {
453
+ importSection =
454
+ `import { ${referencedShared.join(', ')} } from '../@types/common-contracts';\n\n`;
455
+ }
456
+ }
457
+ return header + '\n' + importSection + bodyContent + '\n';
458
+ }
459
+ /**
460
+ * api 파일의 data-contracts import를 모듈별 contracts + common-contracts로 교체합니다.
461
+ *
462
+ * import의 `[^}]+`는 negated character class이므로 멀티라인도 매칭됩니다.
463
+ * (prettier가 줄바꿈해도 안전)
464
+ * @internal — exported for testing
465
+ */
466
+ function rewriteDataContractsImport(content, filename, classification) {
467
+ const moduleTypeNames = classification.moduleTypes.get(filename) || [];
468
+ const sharedTypeNames = classification.sharedTypes;
469
+ // 현재 api 파일에서 실제로 사용하는 타입만 필터링
470
+ const allImportedTypes = extractImportedTypeNames(content);
471
+ const usedModuleTypes = moduleTypeNames.filter((t) => allImportedTypes.has(t));
472
+ const usedSharedTypes = sharedTypeNames.filter((t) => allImportedTypes.has(t));
473
+ // 기존 data-contracts import 라인을 찾아서 교체
474
+ const importRegex = /import\s*\{[^}]+\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/;
475
+ const newImports = [];
476
+ if (usedModuleTypes.length > 0) {
477
+ newImports.push(`import { ${usedModuleTypes.join(', ')} } from './${filename}.contracts';`);
478
+ }
479
+ if (usedSharedTypes.length > 0) {
480
+ newImports.push(`import { ${usedSharedTypes.join(', ')} } from '../@types/common-contracts';`);
481
+ }
482
+ // import가 하나도 없으면 빈 문자열로 교체 (사용하지 않는 타입만 있던 경우)
483
+ if (newImports.length === 0) {
484
+ return content.replace(importRegex, '');
485
+ }
486
+ return content.replace(importRegex, newImports.join('\n'));
487
+ }
488
+ /**
489
+ * api 파일 content에서 data-contracts import 구문의 타입 이름들을 추출합니다.
490
+ * @internal — exported for testing
491
+ */
492
+ function extractImportedTypeNames(content) {
493
+ const importMatch = content.match(/import\s*\{([^}]+)\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/);
494
+ if (!importMatch)
495
+ return new Set();
496
+ return new Set(importMatch[1]
497
+ .split(',')
498
+ .map((s) => s.trim())
499
+ .filter(Boolean));
500
+ }
123
501
  async function generatePretty(path, contents) {
124
- // prettier-plugin-organize-imports가 설치되어 있으면 사용, 없으면 스킵
125
502
  let organized = contents;
126
503
  try {
127
504
  organized = await prettierString(contents, {
@@ -130,7 +507,6 @@ async function generatePretty(path, contents) {
130
507
  });
131
508
  }
132
509
  catch (err) {
133
- // 플러그인이 없거나 에러 발생 시 원본 사용
134
510
  console.warn('⚠️ prettier-plugin-organize-imports not found, skipping import organization');
135
511
  }
136
512
  const formatted = await prettierString(organized, {
@@ -140,32 +516,42 @@ async function generatePretty(path, contents) {
140
516
  generate(path, formatted);
141
517
  }
142
518
  function generate(path, contents) {
143
- // 기존 파일이 있으면 읽어서 병합
144
519
  let existingContent = '';
145
520
  try {
146
521
  if (fs.existsSync(path)) {
147
522
  existingContent = fs.readFileSync(path, 'utf8');
148
- console.log('🔧 [SMART-MERGE] Found existing file, merging:', path);
149
523
  }
150
524
  }
151
525
  catch (err) {
152
- console.log('🔧 [SMART-MERGE] No existing file found:', path);
526
+ // no existing file
153
527
  }
154
- // 기존 내용이 있으면 병합
155
528
  if (existingContent) {
156
- // 스마트 병합: 중복 타입 제거
157
529
  const mergedContent = mergeTypeScriptContent(existingContent, contents);
158
530
  fs.writeFileSync(path, mergedContent);
159
- console.log('🔧 [SMART-MERGE] Smart merged content for:', path);
160
531
  }
161
532
  else {
162
533
  fs.writeFileSync(path, contents);
163
- console.log('🔧 [SMART-MERGE] Created new file:', path);
164
534
  }
165
535
  }
166
536
  function splitHookContents(filename, content) {
167
- const [_apiContent, _hookContent] = content.split(QUERY_HOOK_INDICATOR);
168
- const _hookParts = _hookContent.split(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
537
+ const indicatorIdx = content.indexOf(QUERY_HOOK_INDICATOR);
538
+ if (indicatorIdx === -1) {
539
+ throw new Error(`[splitHookContents] QUERY_HOOK_INDICATOR not found in ${filename}. ` +
540
+ `Ensure the template includes the indicator comment.`);
541
+ }
542
+ const _apiContent = content.slice(0, indicatorIdx);
543
+ const _hookContent = content.slice(indicatorIdx + QUERY_HOOK_INDICATOR.length);
544
+ const suspenseIdx = _hookContent.indexOf(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
545
+ let _hookParts;
546
+ if (suspenseIdx === -1) {
547
+ _hookParts = [_hookContent, ''];
548
+ }
549
+ else {
550
+ _hookParts = [
551
+ _hookContent.slice(0, suspenseIdx),
552
+ _hookContent.slice(suspenseIdx + USE_SUSPENSE_QUERY_HOOK_INDICATOR.length),
553
+ ];
554
+ }
169
555
  const lastImport = getLastImportLine(content);
170
556
  const lines = content.split('\n');
171
557
  const importArea = [
@@ -177,12 +563,16 @@ function splitHookContents(filename, content) {
177
563
  hookParts: _hookParts.map((d) => importArea + d),
178
564
  };
179
565
  }
566
+ /** @internal — exported for testing */
180
567
  function getLastImportLine(content) {
181
- return (Math.max(...content
568
+ const importLines = content
182
569
  .split('\n')
183
570
  .map((line, idx) => ({ idx, has: /from ('|").*('|");/.test(line) }))
184
571
  .filter(({ has }) => has)
185
- .map(({ idx }) => idx)) + 1);
572
+ .map(({ idx }) => idx);
573
+ if (importLines.length === 0)
574
+ return 0;
575
+ return Math.max(...importLines) + 1;
186
576
  }
187
577
 
188
578
  /**
@@ -206,6 +596,7 @@ const genApi = defineCommand({
206
596
  },
207
597
  ],
208
598
  ignoreTlsError: false,
599
+ splitDataContracts: false,
209
600
  },
210
601
  run: async (config) => {
211
602
  if (config.ignoreTlsError) {
@@ -225,7 +616,6 @@ const genApi = defineCommand({
225
616
  }
226
617
  return [];
227
618
  })();
228
- console.log('🔧 [MULTI-URL] Processing URLs:', urls);
229
619
  const coverPath = (config, url) => {
230
620
  const { httpClientType, output } = config;
231
621
  const { AXIOS_DEFAULT_INSTANCE_PATH, FETCH_DEFAULT_INSTANCE_PATH } = GENERATE_SWAGGER_DATA;
@@ -243,7 +633,6 @@ const genApi = defineCommand({
243
633
  // 각 URL별로 순차 처리
244
634
  for (let i = 0; i < urls.length; i++) {
245
635
  const url = urls[i];
246
- console.log(`🔧 [MULTI-URL] Processing URL ${i + 1}/${urls.length}: ${url}`);
247
636
  const covered = coverPath(config, url);
248
637
  const parsed = await withLoading(`Parse Swagger ${i + 1}/${urls.length}`, 'swaggerSchemaUrl' in covered ? covered.swaggerSchemaUrl : '', () => {
249
638
  return parseSwagger(omit(covered, 'swaggerSchemaUrls'));
@@ -252,9 +641,9 @@ const genApi = defineCommand({
252
641
  console.error(`Failed to generate api for URL ${i + 1}: swagger parse error.`);
253
642
  continue;
254
643
  }
255
- withLoading('Write Swagger API', //
644
+ await withLoading('Write Swagger API', //
256
645
  covered.output, (spinner) => {
257
- writeSwaggerApiFile({
646
+ return writeSwaggerApiFile({
258
647
  input: parsed,
259
648
  output: covered.output,
260
649
  spinner,
@@ -301,20 +690,6 @@ const genApi = defineCommand({
301
690
  /**
302
691
  * 스마트 타입 병합 함수들
303
692
  */
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
693
  // 스마트 타입 병합 함수
319
694
  function mergeTypeScriptContent(existing, newContent) {
320
695
  // 1) import 구문 보존 및 병합 (양쪽 모두에서 수집)
@@ -337,25 +712,11 @@ function mergeTypeScriptContent(existing, newContent) {
337
712
  existingImports.forEach((imp) => mergedImportSet.add(imp));
338
713
  const mergedImports = Array.from(mergedImportSet);
339
714
  // 2) 타입 선언 병합 (중복 제거)
340
- const typeBlockRegex = /(export\s+(?:type|interface|enum|const)\s+\w+[\s\S]*?)(?=export\s+(?:type|interface|enum|const)\s+\w+|$)/g;
341
715
  const existingTypes = parseTypeDefinitions(existingBody);
342
716
  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);
717
+ // 타입 기준으로 병합: 새 버전이 source of truth (swagger 스키마)
718
+ // 기존에만 있는 타입은 보존 (사용자가 수동 추가한 것)
719
+ const mergedTypes = { ...existingTypes, ...newTypes };
359
720
  const mergedTypesString = Object.values(mergedTypes).join('\n\n');
360
721
  // 3) 기타 코드(타입/임포트 외)는 "새로운 내용"을 기준으로 유지
361
722
  const removeHeaderComment = (content) => {
@@ -372,7 +733,7 @@ function mergeTypeScriptContent(existing, newContent) {
372
733
  return removeLineComment(removeBlockComment(content));
373
734
  };
374
735
  // 새 본문에서 타입 블록 제거 후 남은 코드
375
- const newBodyWithoutTypes = (newBody || '').replace(typeBlockRegex, '');
736
+ const newBodyWithoutTypes = (newBody || '').replace(new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags), '');
376
737
  let otherCodeFromNew = removeHeaderComment(newBodyWithoutTypes).trim();
377
738
  // 본문 내에 남아있는 "!DO NOT EDIT THIS FILE" 주석 블록들을 모두 제거
378
739
  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.0",
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",
@@ -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
  }