UNPKG

@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
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 };