@toktokhan-dev/cli-plugin-gen-api-react-query
Version:
A CLI plugin for generating API hooks with React Query built by TOKTOKHAN.DEV
791 lines (779 loc) • 34.6 kB
JavaScript
import { defineCommand } from '@toktokhan-dev/cli';
import { createPackageRoot, withLoading, cwd } from '@toktokhan-dev/node';
import omit from 'lodash/omit.js';
import path from 'path';
import { generateApi } from 'swagger-typescript-api';
import fs from 'fs';
import camelCase from 'lodash/camelCase.js';
import upperFirst from 'lodash/upperFirst.js';
// -- Shims --
import cjsUrl from 'node:url';
import cjsPath from 'node:path';
import cjsModule from 'node:module';
const __filename = cjsUrl.fileURLToPath(import.meta.url);
const __dirname = cjsPath.dirname(__filename);
const require = cjsModule.createRequire(import.meta.url);
const packageRoot = createPackageRoot(__dirname);
const GENERATE_SWAGGER_DATA = {
CUSTOM_AXIOS_TEMPLATE_FOLDER: packageRoot('templates/custom-axios'),
CUSTOM_FETCH_TEMPLATE_FOLDER: packageRoot('templates/custom-fetch'),
EXTRA_TEMPLATE_FOLTER: packageRoot('templates/my'),
TYPE_FILE: ['react-query-type.ts', 'data-contracts.ts', 'util-types.ts'],
UTIL_FILE: ['param-serializer-by.ts'],
QUERY_HOOK_INDICATOR: '@indicator-for-query-hook',
USE_SUSPENSE_QUERY_HOOK_INDICATOR: '@indicator-for-use-suspense-query-hook',
AXIOS_DEFAULT_INSTANCE_PATH: '@/configs/axios/instance',
FETCH_DEFAULT_INSTANCE_PATH: '@/configs/fetch/fetch-extend',
};
const { EXTRA_TEMPLATE_FOLTER, CUSTOM_AXIOS_TEMPLATE_FOLDER, CUSTOM_FETCH_TEMPLATE_FOLDER, QUERY_HOOK_INDICATOR: QUERY_HOOK_INDICATOR$1, USE_SUSPENSE_QUERY_HOOK_INDICATOR: USE_SUSPENSE_QUERY_HOOK_INDICATOR$1, } = GENERATE_SWAGGER_DATA;
const parseSwagger = (config) => generateApi({
templates: config.httpClientType === 'axios' ?
CUSTOM_AXIOS_TEMPLATE_FOLDER
: CUSTOM_FETCH_TEMPLATE_FOLDER,
modular: true,
moduleNameFirstTag: true,
extractEnums: true,
addReadonly: true,
unwrapResponseData: true,
url: config.swaggerSchemaUrl,
input: config.swaggerSchemaUrl,
httpClientType: config.httpClientType, // "axios" or "fetch"
typeSuffix: 'Type',
sortTypes: true,
sortRoutes: true,
prettier: {
printWidth: 120,
},
extraTemplates: [
{
name: 'react-query-type.ts', //
path: path.resolve(EXTRA_TEMPLATE_FOLTER, 'react-query-type.eta'),
},
{
name: 'util-types.ts', //
path: path.resolve(EXTRA_TEMPLATE_FOLTER, 'util-types.eta'),
},
{
name: 'param-serializer-by.ts', //
path: path.resolve(EXTRA_TEMPLATE_FOLTER, 'param-serializer-by.eta'),
},
],
hooks: {
onPrepareConfig: (defaultConfig) => {
return {
...defaultConfig,
myConfig: {
QUERY_HOOK_INDICATOR: QUERY_HOOK_INDICATOR$1,
USE_SUSPENSE_QUERY_HOOK_INDICATOR: USE_SUSPENSE_QUERY_HOOK_INDICATOR$1,
...config,
},
};
},
},
});
/**
* data-contracts.ts의 렌더링된 내용을 타입 블록 단위로 파싱합니다.
* 각 export type/interface/enum/const를 독립된 블록으로 분리합니다.
*
* lookahead로 `\nexport\s`를 사용하여 export function/class 등
* 비표준 export도 블록 경계로 인식합니다.
*/
const TYPE_BLOCK_REGEX = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=\nexport\s|$)/g;
function parseTypeDefinitions(content) {
const types = {};
const typeRegex = new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags);
let match;
while ((match = typeRegex.exec(content)) !== null) {
const typeName = match[2];
const typeContent = match[1].trim();
types[typeName] = typeContent;
}
return types;
}
/**
* 코드에서 주석과 문자열 리터럴을 제거합니다.
* false positive 의존성 감지를 방지합니다. (예: 주석 내 타입명 언급)
*/
function stripNonCode(code) {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.replace(/'[^']*'/g, '""')
.replace(/"[^"]*"/g, '""')
.replace(/`[^`]*`/g, '""');
}
/**
* 렌더링된 타입 블록들 간의 의존성 그래프를 구축합니다.
*
* 각 타입 블록의 TypeScript 코드에서 다른 타입 이름이 참조되는지를
* 단어 경계 기반 정규식으로 감지합니다.
* 주석과 문자열 리터럴은 제거 후 매칭하여 false positive를 줄입니다.
*
* @param parsedTypes - parseTypeDefinitions의 결과 (typeName -> renderedBlock)
* @returns Map<typeName, Set<referencedTypeName>> - 직접 의존성 그래프
*/
function buildDependencyGraph(parsedTypes) {
const knownTypes = Object.keys(parsedTypes);
const graph = new Map();
// Pre-compile regexes once (O(n) instead of O(n²) compilations)
const compiledPatterns = new Map();
for (const known of knownTypes) {
const escaped = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
compiledPatterns.set(known, new RegExp(`\\b${escaped}\\b`));
}
for (const [typeName, typeBlock] of Object.entries(parsedTypes)) {
const deps = new Set();
const cleanBlock = stripNonCode(typeBlock);
for (const known of knownTypes) {
if (known !== typeName && compiledPatterns.get(known).test(cleanBlock)) {
deps.add(known);
}
}
graph.set(typeName, deps);
}
return graph;
}
/**
* 직접 의존성 그래프에서 전이적 의존성을 포함한 전체 의존성을 계산합니다.
* A -> B -> C면, A의 의존성은 {B, C}가 됩니다.
*/
function getTransitiveDependencies(graph, typeName, visited = new Set()) {
if (visited.has(typeName))
return new Set();
visited.add(typeName);
const directDeps = graph.get(typeName) || new Set();
const allDeps = new Set(directDeps);
for (const dep of directDeps) {
const transitiveDeps = getTransitiveDependencies(graph, dep, visited);
for (const td of transitiveDeps) {
allDeps.add(td);
}
}
return allDeps;
}
/**
* route의 type 문자열에서 알려진 타입 이름들을 추출합니다.
* 제네릭, 유니온, 인터섹션, 배열 등에서 타입 이름을 추출합니다.
*
* 예: "PaginatedResponse<UserType>" -> ["PaginatedResponse", "UserType"]
*/
function extractTypeNames(typeString, knownTypes) {
if (!typeString)
return [];
const identifiers = typeString.match(/\b[A-Z]\w+/g) || [];
return identifiers.filter((id) => knownTypes.has(id));
}
/**
* swagger-typescript-api의 routes.combined에서 타입-모듈 매핑을 구축합니다.
*
* 각 route의 response.type, response.errorType, request.payload.type,
* request.query.type에서 타입 이름을 추출하여, 어느 모듈에서 사용하는지 매핑합니다.
*
* @returns Map<typeName, Set<moduleName>> (moduleName은 PascalCase로 정규화)
*/
function buildTypeToModuleMap(combined, knownTypeNames) {
const typeToModules = new Map();
if (!combined)
return typeToModules;
for (const moduleGroup of combined) {
// moduleName을 PascalCase로 정규화 (write-swagger.ts의 filename과 일치시키기 위해)
const normalizedModuleName = upperFirst(camelCase(moduleGroup.moduleName));
for (const route of moduleGroup.routes) {
// ParsedRoute의 .d.ts는 request: Request, response: Response (DOM 타입)으로 선언되어 있으나
// 런타임에는 swagger-typescript-api 내부 객체임. as any 캐스팅 필요.
const r = route;
const typeStrings = [
r.response?.type,
r.response?.errorType,
r.request?.payload?.type,
r.request?.query?.type,
];
// request.parameters에서도 타입 추출 (path params 등)
if (r.request?.parameters) {
for (const param of Object.values(r.request.parameters)) {
if (param?.type) {
typeStrings.push(param.type);
}
}
}
for (const typeStr of typeStrings) {
const extracted = extractTypeNames(typeStr, knownTypeNames);
for (const typeName of extracted) {
if (!typeToModules.has(typeName)) {
typeToModules.set(typeName, new Set());
}
typeToModules.get(typeName).add(normalizedModuleName);
}
}
}
}
return typeToModules;
}
/**
* data-contracts.ts의 타입들을 모듈별/공유로 분류합니다.
*
* 분류 알고리즘:
* 1. 직접 매핑: route에서 참조하는 타입 → 해당 모듈에 소속
* 2. 의존성 전파: 타입이 참조하는 다른 타입도 같은 모듈에 소속
* 3. 공유 판별: 2개 이상 모듈에 소속된 타입 → shared
* 4. Orphan 처리: 어디에도 소속되지 않은 타입 → shared
* 5. 전이적 공유: shared 타입이 참조하는 타입도 shared로 격상
* 6. enumMap 동반 이동: *Map const는 base type과 동일 파일에 배치
*/
function classifyTypes(dataContractsContent, routesCombined) {
const parsedTypes = parseTypeDefinitions(dataContractsContent);
const allTypeNames = new Set(Object.keys(parsedTypes));
// Step 1: 타입-모듈 직접 매핑
const typeToModules = buildTypeToModuleMap(routesCombined, allTypeNames);
// Step 2: 의존성 그래프 구축 + 의존성 전파
const depGraph = buildDependencyGraph(parsedTypes);
// 의존성 전파: 각 타입의 전이적 의존성도 같은 모듈에 추가
for (const [typeName, modules] of typeToModules.entries()) {
const transitiveDeps = getTransitiveDependencies(depGraph, typeName);
for (const dep of transitiveDeps) {
if (!typeToModules.has(dep)) {
typeToModules.set(dep, new Set());
}
for (const mod of modules) {
typeToModules.get(dep).add(mod);
}
}
}
// Step 3: 분류 (module-exclusive vs shared vs orphan)
const moduleTypesMap = new Map();
const sharedTypesSet = new Set();
for (const typeName of allTypeNames) {
const modules = typeToModules.get(typeName);
if (!modules || modules.size === 0) {
// Orphan: 어디서도 참조되지 않음 → shared (안전 기본값)
sharedTypesSet.add(typeName);
}
else if (modules.size === 1) {
// Module-exclusive: 한 모듈에서만 사용
const moduleName = [...modules][0];
if (!moduleTypesMap.has(moduleName)) {
moduleTypesMap.set(moduleName, new Set());
}
moduleTypesMap.get(moduleName).add(typeName);
}
else {
// Shared: 2개 이상 모듈에서 사용
sharedTypesSet.add(typeName);
}
}
// Step 5: 전이적 공유 — shared 타입이 참조하는 타입도 shared로 격상
// 순회 중 Set에 추가하지 않고 별도 배열에 수집 후 일괄 적용
let changed = true;
while (changed) {
changed = false;
const toPromote = [];
for (const sharedType of sharedTypesSet) {
const deps = depGraph.get(sharedType) || new Set();
for (const dep of deps) {
if (!sharedTypesSet.has(dep)) {
toPromote.push(dep);
}
}
}
for (const dep of toPromote) {
sharedTypesSet.add(dep);
for (const [, moduleSet] of moduleTypesMap) {
moduleSet.delete(dep);
}
changed = true;
}
}
// Step 6: enumMap 동반 이동 — *Map const는 base type과 동일 위치에 배치
for (const typeName of allTypeNames) {
if (typeName.endsWith('Map')) {
const baseTypeName = typeName.slice(0, -3); // FooTypeMap -> FooType
if (!allTypeNames.has(baseTypeName))
continue;
// base type이 있는 위치를 찾아서 Map도 같은 곳으로 이동
if (sharedTypesSet.has(baseTypeName)) {
// base가 shared → Map도 shared
sharedTypesSet.add(typeName);
for (const [, moduleSet] of moduleTypesMap) {
moduleSet.delete(typeName);
}
}
else {
// base가 특정 모듈에 있음 → Map도 같은 모듈로
for (const [moduleName, moduleSet] of moduleTypesMap) {
if (moduleSet.has(baseTypeName)) {
moduleSet.add(typeName);
// 다른 모듈이나 shared에서 제거
sharedTypesSet.delete(typeName);
for (const [otherMod, otherSet] of moduleTypesMap) {
if (otherMod !== moduleName) {
otherSet.delete(typeName);
}
}
break;
}
}
}
}
}
// 빈 모듈 제거
for (const [moduleName, typeSet] of moduleTypesMap) {
if (typeSet.size === 0) {
moduleTypesMap.delete(moduleName);
}
}
// Set → Array 변환
const moduleTypes = new Map();
for (const [moduleName, typeSet] of moduleTypesMap) {
moduleTypes.set(moduleName, [...typeSet]);
}
return {
moduleTypes,
sharedTypes: [...sharedTypesSet],
parsedTypes,
};
}
const { TYPE_FILE, UTIL_FILE, QUERY_HOOK_INDICATOR, USE_SUSPENSE_QUERY_HOOK_INDICATOR, } = GENERATE_SWAGGER_DATA;
const writeSwaggerApiFile = async (params) => {
const { input, output, spinner, config } = params;
// === Pre-analysis (splitDataContracts 모드) ===
// for...of 전에 classification을 완료하여 import 후처리에 사용
let classificationResult = null;
if (config.splitDataContracts) {
const dataContractsFile = input.files.find((f) => f.fileName + f.fileExtension === 'data-contracts.ts');
if (dataContractsFile?.fileContent) {
// GenerateApiOutput은 configuration을 런타임에 포함하지만 타입 정의에서 미노출
const configuration = input.configuration;
const routesCombined = configuration?.routes?.combined;
classificationResult = classifyTypes(dataContractsFile.fileContent, routesCombined);
}
}
// === Pass 1: for...of 루프 (기존 로직 + splitDataContracts 분기) ===
for (const { fileName, fileContent: content, fileExtension } of input.files) {
const name = fileName + fileExtension;
try {
const isTypeFile = TYPE_FILE.includes(name);
const isUtilFile = UTIL_FILE.includes(name);
const isHttpClient = name === 'http-client.ts';
const isApiFile = content?.includes(QUERY_HOOK_INDICATOR);
const filename = name.replace('.ts', '');
// splitDataContracts: data-contracts.ts 쓰기 억제
if (isTypeFile &&
name === 'data-contracts.ts' &&
config.splitDataContracts) {
// 디스크에 쓰지 않음 — pre-analysis에서 이미 content를 처리함
continue;
}
const getTargetFolder = () => {
if (isUtilFile)
return path.resolve(output, '@utils');
if (isTypeFile)
return path.resolve(output, '@types');
if (isHttpClient)
return path.resolve(output, `@${filename}`);
return path.resolve(output, filename);
};
const targetFolder = getTargetFolder();
fs.mkdirSync(targetFolder, { recursive: true });
if (spinner)
spinner.info(`generated: ${targetFolder}`);
if (isHttpClient) {
generate(path.resolve(targetFolder, 'index.ts'), content);
continue;
}
if (isApiFile) {
// splitDataContracts: import 후처리 (splitHookContents 호출 전)
let processedContent = content;
if (config.splitDataContracts && classificationResult) {
processedContent = rewriteDataContractsImport(content, filename, classificationResult);
}
const { apiContents, hookParts } = splitHookContents(filename, processedContent);
generate(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
if (config.includeReactQuery) {
generate(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
}
if (config.includeReactSuspenseQuery) {
generate(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
}
continue;
}
generate(path.resolve(targetFolder, name), content);
}
catch (err) {
console.error(err);
}
}
// === Post-step: 분할 contracts 파일 생성 (동기 작업이므로 async forEach 영향 없음) ===
if (config.splitDataContracts && classificationResult) {
const { moduleTypes, sharedTypes, parsedTypes } = classificationResult;
// 모듈별 contracts 파일 생성
for (const [moduleName, typeNames] of moduleTypes.entries()) {
const moduleFolder = path.resolve(output, moduleName);
fs.mkdirSync(moduleFolder, { recursive: true });
const moduleContent = buildContractsFileContent(typeNames, parsedTypes, sharedTypes);
generate(path.resolve(moduleFolder, `${moduleName}.contracts.ts`), moduleContent);
}
// common-contracts 파일 생성 (공유 타입이 있을 때만)
if (sharedTypes.length > 0) {
const commonFolder = path.resolve(output, '@types');
fs.mkdirSync(commonFolder, { recursive: true });
const commonContent = buildContractsFileContent(sharedTypes, parsedTypes);
generate(path.resolve(commonFolder, 'common-contracts.ts'), commonContent);
}
}
};
/**
* 타입 이름 목록과 파싱된 타입 블록으로 contracts 파일 내용을 생성합니다.
* @internal — exported for testing
*/
function buildContractsFileContent(typeNames, parsedTypes, sharedTypeNames) {
const header = `/* eslint-disable */
/* tslint:disable */
/**
* !DO NOT EDIT THIS FILE!
*
* This file was auto-generated by tok-cli.config.ts 에서 설정된 gen:api 명령어로 생성되었습니다.
*/\n`;
const blocks = typeNames.map((name) => parsedTypes[name]).filter(Boolean);
const bodyContent = blocks.join('\n\n');
// module contracts가 shared type을 참조하면 import 추가
let importSection = '';
if (sharedTypeNames && sharedTypeNames.length > 0) {
const referencedShared = sharedTypeNames.filter((sharedType) => {
const escaped = sharedType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\b`).test(bodyContent);
});
if (referencedShared.length > 0) {
importSection =
`import { ${referencedShared.join(', ')} } from '../@types/common-contracts';\n\n`;
}
}
return header + '\n' + importSection + bodyContent + '\n';
}
/**
* api 파일의 data-contracts import를 모듈별 contracts + common-contracts로 교체합니다.
*
* import의 `[^}]+`는 negated character class이므로 멀티라인도 매칭됩니다.
* (prettier가 줄바꿈해도 안전)
* @internal — exported for testing
*/
function rewriteDataContractsImport(content, filename, classification) {
const moduleTypeNames = classification.moduleTypes.get(filename) || [];
const sharedTypeNames = classification.sharedTypes;
// 현재 api 파일에서 실제로 사용하는 타입만 필터링
const allImportedTypes = extractImportedTypeNames(content);
const usedModuleTypes = moduleTypeNames.filter((t) => allImportedTypes.has(t));
const usedSharedTypes = sharedTypeNames.filter((t) => allImportedTypes.has(t));
// 기존 data-contracts import 라인을 찾아서 교체
const importRegex = /import\s*\{[^}]+\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/;
const newImports = [];
if (usedModuleTypes.length > 0) {
newImports.push(`import { ${usedModuleTypes.join(', ')} } from './${filename}.contracts';`);
}
if (usedSharedTypes.length > 0) {
newImports.push(`import { ${usedSharedTypes.join(', ')} } from '../@types/common-contracts';`);
}
// import가 하나도 없으면 빈 문자열로 교체 (사용하지 않는 타입만 있던 경우)
if (newImports.length === 0) {
return content.replace(importRegex, '');
}
return content.replace(importRegex, newImports.join('\n'));
}
/**
* api 파일 content에서 data-contracts import 구문의 타입 이름들을 추출합니다.
* @internal — exported for testing
*/
function extractImportedTypeNames(content) {
const importMatch = content.match(/import\s*\{([^}]+)\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/);
if (!importMatch)
return new Set();
return new Set(importMatch[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean));
}
function generate(path, contents) {
let existingContent = '';
try {
if (fs.existsSync(path)) {
existingContent = fs.readFileSync(path, 'utf8');
}
}
catch (err) {
// no existing file
}
if (existingContent) {
const mergedContent = mergeTypeScriptContent(existingContent, contents);
fs.writeFileSync(path, mergedContent);
}
else {
fs.writeFileSync(path, contents);
}
}
function splitHookContents(filename, content) {
const indicatorIdx = content.indexOf(QUERY_HOOK_INDICATOR);
if (indicatorIdx === -1) {
throw new Error(`[splitHookContents] QUERY_HOOK_INDICATOR not found in ${filename}. ` +
`Ensure the template includes the indicator comment.`);
}
const _apiContent = content.slice(0, indicatorIdx);
const _hookContent = content.slice(indicatorIdx + QUERY_HOOK_INDICATOR.length);
const suspenseIdx = _hookContent.indexOf(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
let _hookParts;
if (suspenseIdx === -1) {
_hookParts = [_hookContent, ''];
}
else {
_hookParts = [
_hookContent.slice(0, suspenseIdx),
_hookContent.slice(suspenseIdx + USE_SUSPENSE_QUERY_HOOK_INDICATOR.length),
];
}
const lastImport = getLastImportLine(content);
const lines = content.split('\n');
const importArea = [
`import { ${upperFirst(camelCase(filename))}Api } from './${filename}.api';`,
...lines.slice(0, lastImport),
].join('\n');
return {
apiContents: _apiContent,
hookParts: _hookParts.map((d) => importArea + d),
};
}
/** @internal — exported for testing */
function getLastImportLine(content) {
const importLines = content
.split('\n')
.map((line, idx) => ({ idx, has: /from ('|").*('|");/.test(line) }))
.filter(({ has }) => has)
.map(({ idx }) => idx);
if (importLines.length === 0)
return 0;
return Math.max(...importLines) + 1;
}
/**
* @category Commands
*/
const genApi = defineCommand({
name: 'gen:api',
description: 'swagger schema 를 기반으로 api 를 생성합니다.',
cliOptions: [],
default: {
swaggerSchemaUrl: '',
output: 'src/generated/apis',
includeReactQuery: true,
includeReactSuspenseQuery: false,
httpClientType: 'axios',
instancePath: GENERATE_SWAGGER_DATA.AXIOS_DEFAULT_INSTANCE_PATH,
paginationSets: [
{
keywords: ['cursor'],
nextKey: 'cursor',
},
],
ignoreTlsError: false,
splitDataContracts: false,
},
run: async (config) => {
if (config.ignoreTlsError) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
if (config.swaggerSchemaUrls && config.swaggerSchemaUrls.length === 0) {
throw new Error('No URLs provided');
}
const isWebUrl = (string) => string.startsWith('http://') || string.startsWith('https://');
// 다중 URL 처리 로직 추가
const urls = (() => {
if ('swaggerSchemaUrls' in config) {
return config.swaggerSchemaUrls;
}
if ('swaggerSchemaUrl' in config) {
return [config.swaggerSchemaUrl];
}
return [];
})();
const coverPath = (config, url) => {
const { httpClientType, output } = config;
const { AXIOS_DEFAULT_INSTANCE_PATH, FETCH_DEFAULT_INSTANCE_PATH } = GENERATE_SWAGGER_DATA;
const instancePath = config.instancePath ||
(httpClientType === 'axios' ?
AXIOS_DEFAULT_INSTANCE_PATH
: FETCH_DEFAULT_INSTANCE_PATH);
return {
...config,
instancePath,
swaggerSchemaUrl: isWebUrl(url) ? url : cwd(url),
output: cwd(output),
};
};
// 각 URL별로 순차 처리
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const covered = coverPath(config, url);
const parsed = await withLoading(`Parse Swagger ${i + 1}/${urls.length}`, 'swaggerSchemaUrl' in covered ? covered.swaggerSchemaUrl : '', () => {
return parseSwagger(omit(covered, 'swaggerSchemaUrls'));
});
if (!parsed) {
console.error(`Failed to generate api for URL ${i + 1}: swagger parse error.`);
continue;
}
await withLoading('Write Swagger API', //
covered.output, (spinner) => {
return writeSwaggerApiFile({
input: parsed,
output: covered.output,
spinner,
config,
});
});
// 최종 단계 포매팅: 디렉터리 전체를 한 번에 포맷
await withLoading('Prettier format', covered.output, async () => {
const fs = await import('fs');
const pathMod = await import('path');
// `import.meta.url` 대신 __filename 을 앵커로 쓴다 — 이 패키지의 다른 파일
// (package-root.ts)과 동일한 패턴으로, Rollup ESM 빌드에서는 자동 shim이 주입되고
// ts-jest(CJS 트랜스파일) 환경에서는 네이티브 전역으로 그대로 동작한다.
const { createRequire } = await import('module');
const require = createRequire(__filename);
const { prettierString, findFileToTop } = await import('@toktokhan-dev/node');
// output 디렉토리 기준으로 prettier config를 탐색 (cwd 의존 제거)
const configPath = findFileToTop(covered.output, '.prettierrc.js') || 'auto';
const listTsFiles = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = pathMod.resolve(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listTsFiles(full));
}
else if (entry.isFile() && full.endsWith('.ts')) {
files.push(full);
}
}
return files;
};
const files = listTsFiles(covered.output);
for (const file of files) {
try {
const raw = fs.readFileSync(file, 'utf8');
let organized = raw;
try {
organized = await prettierString(raw, {
parser: 'babel-ts',
plugins: [require.resolve('prettier-plugin-organize-imports')],
});
}
catch {
// prettier-plugin-organize-imports not installed, skip
}
const formatted = await prettierString(organized, {
parser: 'typescript',
configPath,
});
fs.writeFileSync(file, formatted);
}
catch {
console.warn('Prettier final pass failed for', file);
}
}
});
}
},
});
/**
* 스마트 타입 병합 함수들
*/
// 스마트 타입 병합 함수
function mergeTypeScriptContent(existing, newContent) {
// 1) import 구문 보존 및 병합 (양쪽 모두에서 수집)
// 멀티라인 import도 매칭: import { \n A, \n B \n } from '...';
const importRegex = /^\s*import\s+[\s\S]*?from\s*['"][^'"]*['"];?/gm;
const sideEffectImportRegex = /^\s*import\s*['"][^'"]+['"];\s*$/gm;
// quote(', ")·세미콜론·공백 차이만 있는 import 는 같은 import 로 취급한다.
// (swagger-typescript-api 의 내부 포맷과 소비 프로젝트의 .prettierrc.js 가 서로 다른
// quote/세미콜론 스타일을 쓰는 경우가 흔해서, 문자열 그대로 비교하면 예: `import axios
// from "axios";` 와 `import axios from 'axios'` 가 별개로 취급되어 매 실행마다 누적되고,
// 결국 "Identifier has already been declared" 문법 에러로 파일이 영구히 손상된다.)
const normalizeImportKey = (line) => line
.replace(/\s+/g, ' ')
.trim()
.replace(/;$/, '')
.replace(/"/g, "'");
const collectImports = (content) => {
const imports = new Map();
const matchedA = content.match(importRegex) ?? [];
const matchedB = content.match(sideEffectImportRegex) ?? [];
[...matchedA, ...matchedB].forEach((line) => {
const trimmed = line.trim();
imports.set(normalizeImportKey(trimmed), trimmed);
});
const contentWithoutImports = content
.replace(importRegex, '')
.replace(sideEffectImportRegex, '');
return { imports, body: contentWithoutImports };
};
const { imports: existingImports, body: existingBody } = collectImports(existing);
const { imports: newImports, body: newBody } = collectImports(newContent);
// import 병합: 동일 import(정규화 키 기준)는 새 파일 내용이 우선하고,
// 기존에만 있던 import 는 그대로 보존한다.
const mergedImportMap = new Map(existingImports);
newImports.forEach((line, key) => mergedImportMap.set(key, line));
const mergedImports = Array.from(mergedImportMap.values()).sort();
// 2) 타입 선언 병합 (중복 제거)
const existingTypes = parseTypeDefinitions(existingBody);
const newTypes = parseTypeDefinitions(newBody);
// 새 타입 기준으로 병합: 새 버전이 source of truth (swagger 스키마)
// 기존에만 있는 타입은 보존 (사용자가 수동 추가한 것)
const mergedTypes = { ...existingTypes, ...newTypes };
const mergedTypesString = Object.entries(mergedTypes)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, v]) => v)
.join('\n\n');
// 3) 기타 코드(타입/임포트 외)는 "새로운 내용"을 기준으로 유지
const removeHeaderComment = (content) => {
// 파일 상단의 모든 블록 코멘트와 라인 코멘트를 제거 (재귀적 처리)
const removeBlockComment = (str) => {
const result = str.replace(/^\s*\/\*[\s\S]*?\*\/\s*/, '');
return result === str ? str : removeBlockComment(result);
};
const removeLineComment = (str) => {
const result = str.replace(/^(?:\s*\/\/.*\n)+/, '');
return result === str ? str : removeLineComment(result);
};
// 블록 주석 제거 후 라인 주석 제거
return removeLineComment(removeBlockComment(content));
};
// 새 본문에서 타입 블록 제거 후 남은 코드
const newBodyWithoutTypes = (newBody || '').replace(new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags), '');
let otherCodeFromNew = removeHeaderComment(newBodyWithoutTypes).trim();
// 본문 내에 남아있는 "!DO NOT EDIT THIS FILE" 주석 블록들을 모두 제거
otherCodeFromNew = otherCodeFromNew.replace(/\/\*\*?\s*\*\s*!DO NOT EDIT THIS FILE[\s\S]*?\*\//g, '');
// "tok-cli.config.ts 에서 설정된..." 주석도 제거
otherCodeFromNew = otherCodeFromNew.replace(/\/\*\*?\s*\*\s*tok-cli\.config\.ts[\s\S]*?\*\//g, '');
// 연속된 빈 줄 정리
otherCodeFromNew = otherCodeFromNew.replace(/\n\s*\n\s*\n+/g, '\n\n').trim();
// 4) 헤더 주석은 새 컨텐츠 상단의 헤더가 있으면 우선 사용, 없으면 기존의 것을 사용
const pickHeader = (content) => {
const block = content.match(/^\s*(\/\*[\s\S]*?\*\/)\s*/);
if (block)
return block[1];
const lines = content.match(/^(?:\s*\/\/.*\n)+/);
if (lines)
return lines[0].trimEnd();
return '';
};
const headerFromNew = pickHeader(newContent);
const headerFromExisting = pickHeader(existing);
const header = headerFromNew || headerFromExisting;
// 5) 최종 조립
const parts = [];
if (header)
parts.push(header);
if (mergedImports.length > 0)
parts.push(mergedImports.join('\n'));
if (mergedTypesString.trim().length > 0)
parts.push(mergedTypesString);
if (otherCodeFromNew.length > 0)
parts.push(otherCodeFromNew);
return parts.join('\n\n') + '\n';
}
export { genApi, mergeTypeScriptContent };