import fs, { existsSync, mkdirSync, writeFileSync } from "node:fs" import path from "node:path" import ts from "typescript" import type { ResolveFriendlyOptions } from "#Source/file-system/index.ts" import { findClosestFilePath, resolveAbsolutePath, resolveStartDirectory, } from "#Source/file-system/index.ts" import { getGlobalLogger } from "#Source/log/index.ts" export interface GenerateApiListOptions extends ResolveFriendlyOptions { /** * @description Path to Api folder. */ apiFolderPath?: string | undefined /** * @description Path to output file. */ outputFilePath?: string | undefined } const defaultApiFolderPath = "./src/api" const defaultOutputFilePath = "./src/api/api-list.ts" export const generateApiList = (options: GenerateApiListOptions): void => { const logger = getGlobalLogger() const startDirectory = resolveStartDirectory({ startPath: options.startPath }) logger.log("startDirectory:", startDirectory) const apiFolderPath = resolveAbsolutePath({ path: options.apiFolderPath ?? defaultApiFolderPath, basePath: startDirectory, }) logger.log("apiFolderPath:", apiFolderPath) const outputFilePath = resolveAbsolutePath({ path: options.outputFilePath ?? defaultOutputFilePath, basePath: startDirectory, }) logger.log("outputFilePath:", outputFilePath) const tsconfigFilePath = findClosestFilePath({ startPath: startDirectory, targetBase: "tsconfig.json", }) if (tsconfigFilePath === undefined) { throw new Error("未找到 tsconfig.json") } // const _projectRootPath = path.dirname(tsconfigFilePath) const tsconfigJson = ts.parseConfigFileTextToJson( tsconfigFilePath, fs.readFileSync(tsconfigFilePath, "utf8"), ) if (tsconfigJson.error) { throw new Error("无法解析 tsconfig.json") } const parsedTsconfig = ts.parseJsonConfigFileContent( tsconfigJson.config, ts.sys, path.resolve(tsconfigFilePath, ".."), ) logger.log("成功解析 tsconfig 文件...") const projectHost = ts.createCompilerHost(parsedTsconfig.options) const project = ts.createProgram(parsedTsconfig.fileNames, parsedTsconfig.options, projectHost) logger.log("成功读取项目...") // 不可以删除, 否则会变得不幸 // Gets a type checker that can be used to semantically analyze source files in the program. // const _check = project.getTypeChecker() const outputFolderPath = path.dirname(outputFilePath) /** * @description 统一路径分隔符,避免 Windows 下出现 `\`,后续字符串比较都使用 `/`。 * For example: * * - TargetPath: C:\Users\xxx\Desktop\Codespace\mobius\packages\example-service\src\api\hello\api.ts * - NormalizedPath: * C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/api.ts */ const normalizeSlashes = (targetPath: string): string => { return targetPath.replaceAll("\\", "/") } /** * @description 获取 fromPath 到 toPath 的相对路径,并统一成 `/`。 * For example: * * - FromPath: C:\Users\xxx\Desktop\Codespace\mobius\packages\example-service\src\api * - ToPath: C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/api.ts * - RelativePath: hello/api.ts */ const toRelativePath = (fromPath: string, toPath: string): string => { return normalizeSlashes(path.relative(fromPath, toPath)) } /** * @description 生成写入 api-list.ts 时使用的 import 路径,确保同级文件也带 `./` 前缀。 * For example: * * - FromPath: C:\Users\xxx\Desktop\Codespace\mobius\packages\example-service\src\api * - ToPath: C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/api.ts * - ImportPath: ./hello/api.ts */ const toImportPath = (fromPath: string, toPath: string): string => { const relativePath = toRelativePath(fromPath, toPath) return relativePath.startsWith(".") ? relativePath : `./${relativePath}` } /** * @description 判断相对路径是否仍在目标目录内部,避免把 `../other/api.ts` 之类的路径算进来。 * For example: * - relativePath: hello/api.ts -> true * - relativePath: ../other/api.ts -> false * - relativePath: .. -> false */ const isChildPath = (relativePath: string): boolean => { return ( relativePath !== ".." && relativePath.startsWith("../") === false && path.isAbsolute(relativePath) === false ) } /** * @description 我们约定接口实现文件必须命名为 api.ts,或者以 .api.ts 结尾,且必须位于 apiFolderPath 内部。 For example: - filePath: * C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/api.ts -> true - * filePath: * C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/hello.api.ts -> * true - filePath: * C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/api/hello/index.ts -> false * - filePath: * C:/Users/xxx/Desktop/Codespace/mobius/packages/example-service/src/service/hello/api.ts -> * false */ const isApiImplementationFile = (filePath: string): boolean => { const relativePath = toRelativePath(apiFolderPath, filePath) const fileName = relativePath.split("/").at(-1) ?? "" return isChildPath(relativePath) && (fileName === "api.ts" || fileName.endsWith(".api.ts")) } /** * @description 根据 api.ts 的相对路径生成命名空间导入名。 * For example: * - relativePath: hello/api.ts -> Hello * - relativePath: hello.api.ts -> Hello * - relativePath: user/register.api.ts -> UserRegister * - relativePath: hello/hello-world/api.ts -> HelloHelloWorld * - relativePath: api.ts -> Api */ const getNamespaceImportName = (filePath: string): string => { const relativePath = toRelativePath(apiFolderPath, filePath) const relativePathWithoutApiImplementationSuffix = relativePath.endsWith(".api.ts") ? relativePath.slice(0, -".api.ts".length) : relativePath.endsWith("/api.ts") ? relativePath.slice(0, -"/api.ts".length) : relativePath.replaceAll(".ts", "") const rawNamespaceImportName = relativePathWithoutApiImplementationSuffix .split("/") .filter((part) => part !== "" && part !== "." && part !== "api") .flatMap((part) => part.split(/[^A-Za-z0-9_]+/).filter(Boolean)) .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) .join("") if (rawNamespaceImportName === "") { return "Api" } return /^[A-Za-z_]/.test(rawNamespaceImportName) ? rawNamespaceImportName : `Api${rawNamespaceImportName}` } const allSourceFiles = project.getSourceFiles() const apiSourceFiles = allSourceFiles .filter((sourceFile) => { return isApiImplementationFile(sourceFile.fileName) }) .toSorted((left, right) => { const leftRelativePath = toRelativePath(apiFolderPath, left.fileName) const rightRelativePath = toRelativePath(apiFolderPath, right.fileName) return leftRelativePath.localeCompare(rightRelativePath) }) logger.log(`找到 ${apiSourceFiles.length} 个接口`) type ApiSourceFileInfo = { filenameRelativeToApiFolder: string importPath: string namespaceImportName: string } const usedNamespaceImportNameSet = new Set() const hasExportModifier = (node: ts.TypeAliasDeclaration | ts.VariableStatement): boolean => { return ( node.modifiers?.some((modifier) => { return modifier.kind === ts.SyntaxKind.ExportKeyword }) === true ) } const assertApiSourceFileIsValid = (apiSourceFile: ts.SourceFile): void => { const filenameRelativeToApiFolder = toRelativePath(apiFolderPath, apiSourceFile.fileName) const exportedTypeNameSet = new Set() const exportedValueNameSet = new Set() for (const node of apiSourceFile.statements) { if (ts.isTypeAliasDeclaration(node)) { if (hasExportModifier(node)) { exportedTypeNameSet.add(node.name.text) } } if (ts.isVariableStatement(node)) { if (hasExportModifier(node)) { node.declarationList.declarations.forEach((declaration) => { if (ts.isIdentifier(declaration.name)) { exportedValueNameSet.add(declaration.name.text) } }) } } } const requiredTypeNameList = ["ApiSchema", "Api"] const requiredValueNameList = ["apiSchema", "api"] const missingExportNameList = [ ...requiredTypeNameList.filter((typeName) => exportedTypeNameSet.has(typeName) === false), ...requiredValueNameList.filter((valueName) => exportedValueNameSet.has(valueName) === false), ] if (missingExportNameList.length !== 0) { throw new Error( `${filenameRelativeToApiFolder}:必须导出 ${missingExportNameList.join("、")}`, ) } } /** * @description 把单个 SourceFile 收敛成生成 api-list.ts 所需的元数据,并在这里完成导出校验。 */ const createApiSourceFileInfo = ( apiSourceFile: ts.SourceFile, index: number, ): ApiSourceFileInfo => { const filenameRelativeToApiFolder = toRelativePath(apiFolderPath, apiSourceFile.fileName) const importPath = toImportPath(outputFolderPath, apiSourceFile.fileName) const namespaceImportName = getNamespaceImportName(apiSourceFile.fileName) if (usedNamespaceImportNameSet.has(namespaceImportName)) { throw new Error(`${filenameRelativeToApiFolder}:命名空间导入名重复:${namespaceImportName}`) } usedNamespaceImportNameSet.add(namespaceImportName) logger.info(`处理(${index + 1} / ${apiSourceFiles.length}):${filenameRelativeToApiFolder}`) assertApiSourceFileIsValid(apiSourceFile) return { filenameRelativeToApiFolder, importPath, namespaceImportName, } } const buildTupleArea = ( openingLine: string, items: string[], options?: { trailingCommaOnLast?: boolean }, ): string[] => { const trailingCommaOnLast = options?.trailingCommaOnLast ?? false return [ openingLine, ...items.map((item, index) => { return index === items.length - 1 ? trailingCommaOnLast === true ? ` ${String(item)},` : ` ${String(item)}` : ` ${String(item)},` }), "]", ] } const buildTupleTypeDeclaration = ( typeName: string, items: string[], options?: { trailingCommaOnLast?: boolean }, ): string[] => { return buildTupleArea(`export type ${typeName} = [`, items, options) } const buildTypedConstDeclaration = ( constName: string, typeName: string, items: string[], ): string[] => { return buildTupleArea(`export const ${constName}: ${typeName} = [`, items) } const buildApiListFileContent = (apiSourceFileInfoList: ApiSourceFileInfo[]): string => { const importArea = apiSourceFileInfoList.map((apiSourceFileInfo) => { return `import * as ${apiSourceFileInfo.namespaceImportName} from "${apiSourceFileInfo.importPath}"` }) const apiSchemaTypeArea = apiSourceFileInfoList.map((apiSourceFileInfo) => { return `${apiSourceFileInfo.namespaceImportName}.ApiSchema` }) const apiSchemaValueArea = apiSourceFileInfoList.map((apiSourceFileInfo) => { return `${apiSourceFileInfo.namespaceImportName}.apiSchema` }) const apiTypeArea = apiSourceFileInfoList.map((apiSourceFileInfo) => { return `${apiSourceFileInfo.namespaceImportName}.Api` }) const apiValueArea = apiSourceFileInfoList.map((apiSourceFileInfo) => { return `${apiSourceFileInfo.namespaceImportName}.api` }) return [ ...importArea, "", ...buildTupleTypeDeclaration("ApiSchemaList", apiSchemaTypeArea, { trailingCommaOnLast: true, }), ...buildTypedConstDeclaration("apiSchemaList", "ApiSchemaList", apiSchemaValueArea), ...buildTupleTypeDeclaration("ApiList", apiTypeArea), ...buildTypedConstDeclaration("apiList", "ApiList", apiValueArea), "", ].join("\n") } const apiSourceFileInfoList = apiSourceFiles.map((apiSourceFile, index) => { return createApiSourceFileInfo(apiSourceFile, index) }) const finalApiListFile = buildApiListFileContent(apiSourceFileInfoList) const outDir = path.dirname(outputFilePath) if (existsSync(outDir) === false) { mkdirSync(outDir, { recursive: true }) } writeFileSync(outputFilePath, finalApiListFile) }