From 614e3acdd4461c183abf4fff85d5aec45f2dcc9e Mon Sep 17 00:00:00 2001 From: "DESKTOP-I3JPKHK\\wy" <1111> Date: Tue, 21 Apr 2026 14:37:16 +0800 Subject: [PATCH] feat: initialize yx-generate-api CLI --- .gitignore | 2 + README.md | 161 +++++ bin/yx-generate-api.js | 8 + package-lock.json | 36 ++ package.json | 24 + src/cli.js | 55 ++ src/commands/gen.js | 43 ++ src/commands/generate.js | 84 +++ src/commands/init.js | 74 +++ src/commands/sync.js | 43 ++ src/core/args.js | 68 ++ src/core/config.js | 124 ++++ src/core/generate.js | 890 +++++++++++++++++++++++++++ src/core/sync.js | 152 +++++ templates/run-yx-generate-api.bat | 22 + templates/yx-generate-api.config.mjs | 13 + 16 files changed, 1799 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/yx-generate-api.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.js create mode 100644 src/commands/gen.js create mode 100644 src/commands/generate.js create mode 100644 src/commands/init.js create mode 100644 src/commands/sync.js create mode 100644 src/core/args.js create mode 100644 src/core/config.js create mode 100644 src/core/generate.js create mode 100644 src/core/sync.js create mode 100644 templates/run-yx-generate-api.bat create mode 100644 templates/yx-generate-api.config.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bb6228 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# yx-generate-api + +`yx-generate-api` 是一个独立的 Node CLI,用来把 Swagger/OpenAPI 接口生成为前端 API 文件,并把 `generated/index.js` 的导出同步到外部入口文件中。 + +## 能力 + +- 根据 Swagger/OpenAPI JSON 生成模块化 API 文件 +- 自动生成 `generated/index.js` +- 把生成目录的导出同步到外部 `index.js` +- 支持把内部 `generated/index.js` 原文写入注释区块 +- 支持 `generate`、`sync`、`gen`、`init` +- 通过配置文件复用到不同项目 + +## 安装 + +可以直接从 Git 安装到业务项目: + +```bash +npm install -D git+https://gitea.23544.com/wangyang/yx_generate_api_js.git +``` + +也可以先在本仓库开发,再通过 `npm pack` 或私有 npm 发布给其他项目。 + +## 快速开始 + +在你的业务项目根目录执行: + +```bash +npx yx-generate-api init +``` + +这个命令会生成: + +- `yx-generate-api.config.mjs` +- `run-yx-generate-api.bat` + +然后根据你的项目结构修改配置文件。 + +## 配置示例 + +```js +export default { + swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json', + outputDir: 'src/api/aixue/generated', + externalIndexFile: 'src/api/aixue/index.js', + requestImport: '../request', + paramStyle: 'object', + sync: { + enabled: true, + includeGeneratedIndexSnapshot: true, + }, +} +``` + +## 命令 + +### 1. 初始化模板 + +```bash +npx yx-generate-api init +``` + +可选参数: + +```bash +npx yx-generate-api init --force +``` + +### 2. 生成 API + +```bash +npx yx-generate-api generate +``` + +只生成单个模块: + +```bash +npx yx-generate-api generate Curriculum +``` + +生成多个模块: + +```bash +npx yx-generate-api generate class-assignment Ranking +``` + +带参数: + +```bash +npx yx-generate-api generate --modules=Curriculum,class-assignment +npx yx-generate-api generate --url=http://xxx/swagger/v1/swagger.json +npx yx-generate-api generate --paramStyle=positional +``` + +### 3. 只同步外部导出 + +```bash +npx yx-generate-api sync +``` + +### 4. 先生成再同步 + +```bash +npx yx-generate-api gen +``` + +## Windows 双击使用 + +`init` 默认会创建 `run-yx-generate-api.bat`,你可以直接双击它。 + +它内部会执行: + +```bat +npx yx-generate-api gen +``` + +也可以在命令行里带参数: + +```bat +run-yx-generate-api.bat Curriculum +run-yx-generate-api.bat --modules=Curriculum,class-assignment +``` + +## 配置说明 + +### 顶层配置 + +- `swaggerUrl`: Swagger/OpenAPI JSON 地址 +- `outputDir`: 生成目录,相对配置文件所在目录 +- `externalIndexFile`: 外部入口文件路径,相对配置文件所在目录 +- `requestImport`: 生成文件中的 `request` 导入路径 +- `paramStyle`: `object` 或 `positional` +- `sync`: 同步外部入口文件的规则 + +### sync 配置 + +- `enabled`: 是否启用同步 +- `blockStart`: 受管注释块开始标记 +- `blockEnd`: 受管注释块结束标记 +- `includeGeneratedIndexSnapshot`: 是否把 `generated/index.js` 内容写入注释 +- `snapshotTitle`: 快照注释标题 +- `exportFrom`: 手动指定外部入口中的 `export * from '...'` + +## 当前约定 + +默认会: + +1. 生成 `outputDir/index.js` +2. 在 `externalIndexFile` 里写入受管区块 +3. 受管区块包含: + - 同步来源注释 + - `generated/index.js` 内容快照注释 + - `export * from '...'` + +## 本地开发 + +在工具仓库执行: + +```bash +node ./bin/yx-generate-api.js --help +``` diff --git a/bin/yx-generate-api.js b/bin/yx-generate-api.js new file mode 100644 index 0000000..8705db5 --- /dev/null +++ b/bin/yx-generate-api.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { runCli } from '../src/cli.js' + +runCli(process.argv.slice(2)).catch((error) => { + console.error(error.message) + process.exit(1) +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..119925c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "yx-generate-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yx-generate-api", + "version": "0.1.0", + "dependencies": { + "prettier": "3.6.2" + }, + "bin": { + "yx-generate-api": "bin/yx-generate-api.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b13677 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "yx-generate-api", + "version": "0.1.0", + "description": "Config-driven Swagger API generator and export sync tool for frontend projects.", + "type": "module", + "bin": { + "yx-generate-api": "./bin/yx-generate-api.js" + }, + "files": [ + "bin", + "src", + "templates", + "README.md" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "start": "node ./bin/yx-generate-api.js --help" + }, + "dependencies": { + "prettier": "3.6.2" + } +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..c377a58 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,55 @@ +import { runGenerateCommand } from './commands/generate.js' +import { runGenCommand } from './commands/gen.js' +import { runInitCommand } from './commands/init.js' +import { runSyncCommand } from './commands/sync.js' + +const COMMANDS = new Map([ + ['init', runInitCommand], + ['generate', runGenerateCommand], + ['sync', runSyncCommand], + ['gen', runGenCommand], +]) + +export const runCli = async (argv) => { + const [inputCommand = 'help', ...restArgs] = argv + + if (inputCommand === '--help' || inputCommand === '-h' || inputCommand === 'help') { + printHelp() + return + } + + const command = COMMANDS.get(inputCommand) + + if (!command) { + throw new Error(`Unknown command: ${inputCommand}\n\n${buildHelpText()}`) + } + + await command(restArgs) +} + +const printHelp = () => { + console.log(buildHelpText()) +} + +const buildHelpText = () => { + return `yx-generate-api + +Usage: + yx-generate-api [options] + +Commands: + init Create config and Windows launcher templates in the current project + generate Generate API files from Swagger/OpenAPI + sync Sync generated/index.js exports into the external index file + gen Run generate first, then sync + help Show this help message + +Examples: + yx-generate-api init + yx-generate-api generate + yx-generate-api generate Curriculum + yx-generate-api generate --modules=Curriculum,class-assignment + yx-generate-api sync + yx-generate-api gen +` +} diff --git a/src/commands/gen.js b/src/commands/gen.js new file mode 100644 index 0000000..560301a --- /dev/null +++ b/src/commands/gen.js @@ -0,0 +1,43 @@ +import { generateApiFiles } from '../core/generate.js' +import { syncExternalIndex } from '../core/sync.js' +import { resolveGenerateCommandContext } from './generate.js' + +export const runGenCommand = async (args) => { + const context = await resolveGenerateCommandContext(args) + + if (context.help) { + printHelp() + return + } + + await generateApiFiles(context.runtimeConfig) + + if (!context.projectConfig.sync.enabled) { + console.log('sync skipped: sync.enabled=false') + return + } + + await syncExternalIndex({ + projectRoot: context.projectConfig.rootDir, + outputDir: context.runtimeConfig.outputDir, + externalIndexFile: context.projectConfig.externalIndexFile, + syncOptions: context.projectConfig.sync, + }) +} + +const printHelp = () => { + console.log(`yx-generate-api gen + +Usage: + yx-generate-api gen [moduleName...] [options] + +This command runs: + 1. generate + 2. sync + +Examples: + yx-generate-api gen + yx-generate-api gen Curriculum + yx-generate-api gen --modules=Curriculum,class-assignment +`) +} diff --git a/src/commands/generate.js b/src/commands/generate.js new file mode 100644 index 0000000..b4e4700 --- /dev/null +++ b/src/commands/generate.js @@ -0,0 +1,84 @@ +import process from 'node:process' + +import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js' +import { loadProjectConfig, normalizeParamStyle, resolveCwdPath } from '../core/config.js' +import { generateApiFiles } from '../core/generate.js' + +export const runGenerateCommand = async (args) => { + const context = await resolveGenerateCommandContext(args) + + if (context.help) { + printHelp() + return + } + + await generateApiFiles(context.runtimeConfig) +} + +export const resolveGenerateCommandContext = async (args) => { + const parsedArgs = parseCliArgs(args) + const help = hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h') + + if (help) { + return { + help: true, + } + } + + const configPath = getFlagValue(parsedArgs.flags, 'config') + const projectConfig = await loadProjectConfig({ + configPath, + cwd: process.cwd(), + }) + const moduleArgs = [ + ...parsedArgs.positionals, + ...getFlagValues(parsedArgs.flags, 'modules').flatMap(splitModuleArgs), + ] + + return { + help: false, + projectConfig, + runtimeConfig: { + projectRoot: projectConfig.rootDir, + swaggerUrl: getFlagValue(parsedArgs.flags, 'url') || projectConfig.swaggerUrl, + outputDir: getFlagValue(parsedArgs.flags, 'outDir') + ? resolveCwdPath(process.cwd(), getFlagValue(parsedArgs.flags, 'outDir')) + : projectConfig.outputDir, + requestImport: getFlagValue(parsedArgs.flags, 'requestImport') || projectConfig.requestImport, + paramStyle: getFlagValue(parsedArgs.flags, 'paramStyle') + ? normalizeParamStyle(getFlagValue(parsedArgs.flags, 'paramStyle')) + : projectConfig.paramStyle, + modules: moduleArgs, + }, + } +} + +const printHelp = () => { + console.log(`yx-generate-api generate + +Usage: + yx-generate-api generate [moduleName...] [options] + +Options: + --config=... Config file path + --url=... Swagger/OpenAPI JSON URL + --outDir=... Output directory + --requestImport=... request import path inside generated files + --modules=... Comma-separated module list + --paramStyle=... object / positional + --help Show help + +Examples: + yx-generate-api generate + yx-generate-api generate Curriculum + yx-generate-api generate class-assignment Ranking + yx-generate-api generate --modules=Curriculum,class-assignment +`) +} + +const splitModuleArgs = (value) => { + return String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/src/commands/init.js b/src/commands/init.js new file mode 100644 index 0000000..584c67f --- /dev/null +++ b/src/commands/init.js @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +import { parseCliArgs, hasFlag } from '../core/args.js' + +const CONFIG_TEMPLATE_PATH = new URL('../../templates/yx-generate-api.config.mjs', import.meta.url) +const BAT_TEMPLATE_PATH = new URL('../../templates/run-yx-generate-api.bat', import.meta.url) + +export const runInitCommand = async (args) => { + const parsedArgs = parseCliArgs(args) + + if (hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h')) { + printHelp() + return + } + + const force = hasFlag(parsedArgs.flags, 'force') + const cwd = process.cwd() + const configPath = path.join(cwd, 'yx-generate-api.config.mjs') + const batPath = path.join(cwd, 'run-yx-generate-api.bat') + const filesToWrite = [ + { + label: 'config', + filePath: configPath, + templateUrl: CONFIG_TEMPLATE_PATH, + }, + { + label: 'bat', + filePath: batPath, + templateUrl: BAT_TEMPLATE_PATH, + }, + ] + const existingFiles = [] + + for (const item of filesToWrite) { + if (await fileExists(item.filePath)) { + existingFiles.push(item.filePath) + } + } + + if (existingFiles.length && !force) { + throw new Error( + `Init aborted because files already exist:\n${existingFiles.join('\n')}\n\nUse --force to overwrite.`, + ) + } + + for (const item of filesToWrite) { + const content = await fs.readFile(item.templateUrl, 'utf8') + await fs.writeFile(item.filePath, content, 'utf8') + console.log(`created: ${path.relative(cwd, item.filePath)}`) + } +} + +const fileExists = async (targetPath) => { + try { + await fs.access(targetPath) + return true + } catch { + return false + } +} + +const printHelp = () => { + console.log(`yx-generate-api init + +Usage: + yx-generate-api init [options] + +Options: + --force Overwrite existing template files + --help Show help +`) +} diff --git a/src/commands/sync.js b/src/commands/sync.js new file mode 100644 index 0000000..72679e6 --- /dev/null +++ b/src/commands/sync.js @@ -0,0 +1,43 @@ +import process from 'node:process' + +import { parseCliArgs, getFlagValue, hasFlag } from '../core/args.js' +import { loadProjectConfig } from '../core/config.js' +import { syncExternalIndex } from '../core/sync.js' + +export const runSyncCommand = async (args) => { + const parsedArgs = parseCliArgs(args) + + if (hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h')) { + printHelp() + return + } + + const projectConfig = await loadProjectConfig({ + configPath: getFlagValue(parsedArgs.flags, 'config'), + cwd: process.cwd(), + }) + + if (!projectConfig.sync.enabled) { + console.log('sync skipped: sync.enabled=false') + return + } + + await syncExternalIndex({ + projectRoot: projectConfig.rootDir, + outputDir: projectConfig.outputDir, + externalIndexFile: projectConfig.externalIndexFile, + syncOptions: projectConfig.sync, + }) +} + +const printHelp = () => { + console.log(`yx-generate-api sync + +Usage: + yx-generate-api sync [options] + +Options: + --config=... Config file path + --help Show help +`) +} diff --git a/src/core/args.js b/src/core/args.js new file mode 100644 index 0000000..2ffe410 --- /dev/null +++ b/src/core/args.js @@ -0,0 +1,68 @@ +export const parseCliArgs = (args) => { + const flags = new Map() + const positionals = [] + + for (const arg of args) { + if (!arg.startsWith('--')) { + positionals.push(arg) + continue + } + + const normalizedArg = arg.slice(2) + const separatorIndex = normalizedArg.indexOf('=') + + if (separatorIndex === -1) { + pushFlagValue(flags, normalizedArg, true) + continue + } + + const key = normalizedArg.slice(0, separatorIndex) + const value = normalizedArg.slice(separatorIndex + 1) + pushFlagValue(flags, key, value) + } + + return { + flags, + positionals, + } +} + +export const getFlagValue = (flags, key) => { + const value = flags.get(key) + + if (Array.isArray(value)) { + return value.at(-1) + } + + return value +} + +export const getFlagValues = (flags, key) => { + const value = flags.get(key) + + if (value === undefined) { + return [] + } + + return Array.isArray(value) ? value : [value] +} + +export const hasFlag = (flags, key) => { + return flags.has(key) +} + +const pushFlagValue = (flags, key, value) => { + if (!flags.has(key)) { + flags.set(key, value) + return + } + + const currentValue = flags.get(key) + + if (Array.isArray(currentValue)) { + currentValue.push(value) + return + } + + flags.set(key, [currentValue, value]) +} diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..6136e46 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,124 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +export const CONFIG_FILE_NAMES = [ + 'yx-generate-api.config.mjs', + 'yx-generate-api.config.js', + 'yx-generate-api.config.cjs', +] + +export const PARAM_STYLE = { + OBJECT: 'object', + POSITIONAL: 'positional', +} + +export const SUPPORTED_PARAM_STYLES = Object.values(PARAM_STYLE) + +export const DEFAULT_SYNC_OPTIONS = { + blockStart: '// AUTO-GENERATED API EXPORTS START', + blockEnd: '// AUTO-GENERATED API EXPORTS END', + includeGeneratedIndexSnapshot: true, + snapshotTitle: '// generated/index.js content:', +} + +export const loadProjectConfig = async ({ configPath, cwd = process.cwd() } = {}) => { + const resolvedConfigPath = await resolveConfigPath({ configPath, cwd }) + const importedConfig = await import( + `${pathToFileURL(resolvedConfigPath).href}?cacheBust=${Date.now()}` + ) + const rawConfig = importedConfig.default ?? importedConfig + + if (!rawConfig || typeof rawConfig !== 'object') { + throw new Error(`Config file must export an object: ${resolvedConfigPath}`) + } + + const rootDir = path.dirname(resolvedConfigPath) + const syncConfig = rawConfig.sync || {} + const externalIndexFile = rawConfig.externalIndexFile + ? resolveProjectPath(rootDir, rawConfig.externalIndexFile) + : null + + return { + configPath: resolvedConfigPath, + rootDir, + swaggerUrl: normalizeString(rawConfig.swaggerUrl), + outputDir: rawConfig.outputDir + ? resolveProjectPath(rootDir, rawConfig.outputDir) + : resolveProjectPath(rootDir, 'src/api/generated'), + externalIndexFile, + requestImport: normalizeString(rawConfig.requestImport) || '../request', + paramStyle: normalizeParamStyle(rawConfig.paramStyle || PARAM_STYLE.OBJECT), + sync: { + enabled: syncConfig.enabled ?? Boolean(externalIndexFile), + blockStart: normalizeString(syncConfig.blockStart) || DEFAULT_SYNC_OPTIONS.blockStart, + blockEnd: normalizeString(syncConfig.blockEnd) || DEFAULT_SYNC_OPTIONS.blockEnd, + includeGeneratedIndexSnapshot: + syncConfig.includeGeneratedIndexSnapshot ?? + DEFAULT_SYNC_OPTIONS.includeGeneratedIndexSnapshot, + snapshotTitle: + normalizeString(syncConfig.snapshotTitle) || DEFAULT_SYNC_OPTIONS.snapshotTitle, + exportFrom: normalizeString(syncConfig.exportFrom) || null, + }, + } +} + +export const resolveConfigPath = async ({ configPath, cwd = process.cwd() } = {}) => { + if (configPath) { + const explicitPath = path.resolve(cwd, configPath) + await assertFileExists(explicitPath, `Config file not found: ${explicitPath}`) + return explicitPath + } + + for (const fileName of CONFIG_FILE_NAMES) { + const candidatePath = path.resolve(cwd, fileName) + + if (await fileExists(candidatePath)) { + return candidatePath + } + } + + throw new Error( + `Config file not found in ${cwd}. Expected one of: ${CONFIG_FILE_NAMES.join(', ')}`, + ) +} + +export const resolveCwdPath = (cwd, targetPath) => { + return path.resolve(cwd, targetPath) +} + +export const resolveProjectPath = (projectRoot, targetPath) => { + return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRoot, targetPath) +} + +export const normalizeParamStyle = (value) => { + const normalizedValue = normalizeString(value).toLowerCase() + + if (SUPPORTED_PARAM_STYLES.includes(normalizedValue)) { + return normalizedValue + } + + throw new Error( + `Unsupported paramStyle: ${value}. Expected one of: ${SUPPORTED_PARAM_STYLES.join(' / ')}`, + ) +} + +const normalizeString = (value) => { + return String(value || '').trim() +} + +const fileExists = async (targetPath) => { + try { + await fs.access(targetPath) + return true + } catch { + return false + } +} + +const assertFileExists = async (targetPath, errorMessage) => { + if (!(await fileExists(targetPath))) { + throw new Error(errorMessage) + } +} diff --git a/src/core/generate.js b/src/core/generate.js new file mode 100644 index 0000000..b362fe9 --- /dev/null +++ b/src/core/generate.js @@ -0,0 +1,890 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import prettier from 'prettier' + +const DEFAULT_SHARED_IMPORT = './shared' +const DEFAULT_SHARED_FILE_NAME = 'shared.js' +const DEFAULT_INDEX_FILE_NAME = 'index.js' +const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch'] +const JS_RESERVED_WORDS = new Set([ + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'enum', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'implements', + 'import', + 'in', + 'instanceof', + 'interface', + 'let', + 'new', + 'null', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'super', + 'switch', + 'static', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', +]) + +export const generateApiFiles = async ({ + projectRoot, + swaggerUrl, + outputDir, + requestImport, + paramStyle, + modules, +}) => { + const swagger = await fetchSwaggerJson(swaggerUrl) + const moduleMap = buildModuleMap(swagger) + const selectedModules = resolveSelectedModules(moduleMap, modules) + + await fs.mkdir(outputDir, { recursive: true }) + await writeGeneratedFile( + path.join(outputDir, DEFAULT_SHARED_FILE_NAME), + generateSharedFile({ + swaggerUrl, + }), + ) + + for (const moduleInfo of selectedModules) { + const filePath = path.join(outputDir, `${moduleInfo.fileName}.js`) + await writeGeneratedFile( + filePath, + generateModuleFile({ + moduleInfo, + paramStyle, + schemas: swagger.components?.schemas || {}, + requestImport, + swaggerUrl, + }), + ) + console.log(`generated: ${path.relative(projectRoot, filePath)}`) + } + + const indexFilePath = path.join(outputDir, DEFAULT_INDEX_FILE_NAME) + await writeGeneratedFile( + indexFilePath, + await generateIndexFile({ + outDir: outputDir, + swaggerUrl, + }), + ) + console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`) + console.log(`done: ${selectedModules.length} module(s)`) +} + +const fetchSwaggerJson = async (url) => { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Swagger fetch failed: ${response.status} ${response.statusText}`) + } + + return response.json() +} + +const buildModuleMap = (swagger) => { + const moduleMap = new Map() + + for (const [apiPath, pathItem] of Object.entries(swagger.paths || {})) { + for (const method of HTTP_METHOD_ORDER) { + const operation = pathItem?.[method] + + if (!operation) { + continue + } + + const moduleName = extractModuleName(apiPath, operation) + const moduleKey = normalizeLookupKey(moduleName) + const aliases = new Set([moduleName, toKebabCase(moduleName), ...(operation.tags || [])]) + + if (!moduleMap.has(moduleKey)) { + moduleMap.set(moduleKey, { + aliases, + fileName: toKebabCase(moduleName), + moduleName, + operations: [], + }) + } + + const moduleInfo = moduleMap.get(moduleKey) + + for (const alias of aliases) { + moduleInfo.aliases.add(alias) + } + + moduleInfo.operations.push({ + method, + operation, + path: apiPath, + }) + } + } + + for (const moduleInfo of moduleMap.values()) { + moduleInfo.operations.sort(compareOperations) + assignFunctionNames(moduleInfo.operations) + } + + return moduleMap +} + +const resolveSelectedModules = (moduleMap, requestedModules) => { + if (!requestedModules.length) { + return [...moduleMap.values()] + } + + const selectedModules = [] + const selectedKeys = new Set() + + for (const requestedModule of requestedModules) { + const lookupKey = normalizeLookupKey(requestedModule) + let matchedEntry = null + + for (const [moduleKey, moduleInfo] of moduleMap.entries()) { + if (moduleKey === lookupKey) { + matchedEntry = moduleInfo + break + } + + if ([...moduleInfo.aliases].some((alias) => normalizeLookupKey(alias) === lookupKey)) { + matchedEntry = moduleInfo + break + } + } + + if (!matchedEntry) { + const availableModules = [...moduleMap.values()] + .map((item) => item.moduleName) + .sort((left, right) => left.localeCompare(right)) + .join(', ') + + throw new Error(`Module "${requestedModule}" not found. Available modules: ${availableModules}`) + } + + const selectedKey = normalizeLookupKey(matchedEntry.moduleName) + + if (!selectedKeys.has(selectedKey)) { + selectedModules.push(matchedEntry) + selectedKeys.add(selectedKey) + } + } + + return selectedModules +} + +const writeGeneratedFile = async (filePath, content) => { + const prettierOptions = (await prettier.resolveConfig(filePath)) || {} + const formattedContent = await prettier.format(content, { + ...prettierOptions, + filepath: filePath, + }) + + await fs.writeFile(filePath, formattedContent, 'utf8') +} + +const generateSharedFile = ({ swaggerUrl }) => { + return `// Auto-generated. Do not edit manually. +// Swagger: ${swaggerUrl} +// Shared helpers: URL builder + +import qs from 'qs' + +export const stringifyParams = params => { + return qs.stringify(params, { + arrayFormat: 'repeat', + }) +} + +export const buildUrl = (url, params = {}) => { + const pathParamNames = [] + + const resolvedUrl = url.replace(/\\{([^}]+)\\}/g, (_, key) => { + pathParamNames.push(key) + const value = params[key] + + if (value === undefined || value === null) { + throw new Error(\`Missing path param: \${key}\`) + } + + return encodeURIComponent(value) + }) + + const query = stringifyParams( + Object.fromEntries( + Object.entries(params).filter( + ([key, value]) => !pathParamNames.includes(key) && value !== undefined && value !== null, + ), + ), + ) + + return query ? \`\${resolvedUrl}?\${query}\` : resolvedUrl +} +` +} + +const generateIndexFile = async ({ outDir, swaggerUrl }) => { + const dirEntries = await fs.readdir(outDir, { withFileTypes: true }) + const moduleFileNames = dirEntries + .filter((entry) => { + return ( + entry.isFile() && + entry.name.endsWith('.js') && + entry.name !== DEFAULT_INDEX_FILE_NAME && + entry.name !== DEFAULT_SHARED_FILE_NAME + ) + }) + .map((entry) => entry.name.replace(/\.js$/i, '')) + .sort((left, right) => left.localeCompare(right)) + const lines = ['// Auto-generated. Do not edit manually.', `// Swagger: ${swaggerUrl}`, '// Module aggregation exports', ''] + + for (const fileName of moduleFileNames) { + lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`) + } + + lines.push('') + + return `${lines.join('\n')}` +} + +const generateModuleFile = ({ moduleInfo, paramStyle, schemas, requestImport, swaggerUrl }) => { + const needsBuildUrl = moduleInfo.operations.some((item) => { + return getOperationParameters(item.operation).length > 0 + }) + const exportNames = moduleInfo.operations.map((item) => item.functionName) + const lines = [ + '// Auto-generated. Do not edit manually.', + `// Swagger: ${swaggerUrl}`, + `// Module: ${moduleInfo.moduleName}`, + `// Param style: ${paramStyle}`, + '', + `import request from '${requestImport}'`, + ] + + if (needsBuildUrl) { + lines.push(`import { buildUrl } from '${DEFAULT_SHARED_IMPORT}'`) + } + + lines.push('') + + const operationBlocks = moduleInfo.operations.flatMap((item) => { + return generateOperationBlock(item, schemas, paramStyle) + }) + const exportBlock = buildGroupedExportBlock(exportNames) + + return `${[...lines, ...operationBlocks, ...exportBlock].join('\n')}\n` +} + +const generateOperationBlock = ( + { functionName, method, operation, path: apiPath }, + schemas, + paramStyle, +) => { + const queryParameters = getParameters(operation, 'query') + const pathParameters = getParameters(operation, 'path') + const allParams = [...pathParameters, ...queryParameters] + const bodySchemaInfo = getRequestBodySchemaInfo(operation.requestBody, schemas) + const hasParams = allParams.length > 0 + const hasBody = Boolean(bodySchemaInfo) + const summary = operation.summary || `${method.toUpperCase()} ${apiPath}` + const jsDocLines = ['/**', ` * ${escapeComment(summary)}`] + const signatureInfo = buildSignatureInfo({ + hasBody, + parameters: allParams, + paramStyle, + }) + + if (hasParams) { + if (paramStyle === 'positional') { + for (const parameter of signatureInfo.parameterBindings) { + jsDocLines.push(` * ${buildPositionalParameterDocLine(parameter, schemas)}`) + } + } else { + jsDocLines.push( + ` * @param {Object} [params={}] ${pathParameters.length ? 'Path and query params' : 'Query params'}`, + ) + + for (const parameter of allParams) { + jsDocLines.push(` * ${buildParameterDocLine('params', parameter, schemas)}`) + } + } + } + + if (hasBody) { + const bodyType = getJsDocType(bodySchemaInfo.schema, schemas) + jsDocLines.push(` * @param {${bodyType}} ${signatureInfo.bodyArgName} Request body`) + + for (const propertyLine of buildSchemaPropertyDocLines( + signatureInfo.bodyArgName, + bodySchemaInfo.schema, + schemas, + )) { + jsDocLines.push(` * ${propertyLine}`) + } + } + + const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas) + const returnType = `Promise<${getExpandedResponseJsDocType(responseSchemaInfo?.schema, schemas)}>` + const returnDescription = responseSchemaInfo?.displayName + ? ` Returns ${responseSchemaInfo.displayName}` + : '' + + jsDocLines.push(` * @returns {${returnType}}${returnDescription}`) + + for (const propertyLine of buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)) { + jsDocLines.push(` * ${propertyLine}`) + } + + jsDocLines.push(' */') + + const signature = signatureInfo.signature + const urlExpression = buildUrlExpression(apiPath, signatureInfo) + const requestExpression = buildRequestExpression( + method, + urlExpression, + hasBody, + signatureInfo.bodyArgName, + ) + + return [...jsDocLines, `const ${functionName}Api = ${signature}${requestExpression}`, ''] +} + +const buildFunctionSignature = (hasParams, hasBody) => { + if (hasParams && hasBody) { + return '(params = {}, data) => ' + } + + if (hasParams) { + return '(params = {}) => ' + } + + if (hasBody) { + return '(data) => ' + } + + return '() => ' +} + +const buildSignatureInfo = ({ hasBody, parameters, paramStyle }) => { + const bodyArgName = 'data' + + if (paramStyle !== 'positional') { + return { + bodyArgName, + parameterBindings: [], + signature: buildFunctionSignature(parameters.length > 0, hasBody), + urlParamsExpression: parameters.length > 0 ? 'params' : null, + } + } + + const parameterBindings = buildParameterBindings(parameters, hasBody ? [bodyArgName] : []) + const signatureArgs = [...parameterBindings.map((item) => item.variableName)] + + if (hasBody) { + signatureArgs.push(bodyArgName) + } + + return { + bodyArgName, + parameterBindings, + signature: signatureArgs.length ? `(${signatureArgs.join(', ')}) => ` : '() => ', + urlParamsExpression: parameterBindings.length + ? `{ ${parameterBindings.map(renderParamBindingEntry).join(', ')} }` + : null, + } +} + +const buildRequestExpression = (method, urlExpression, hasBody, bodyArgName = 'data') => { + switch (method) { + case 'get': + return `request.get(${urlExpression})` + case 'post': + return hasBody + ? `request.post(${urlExpression}, ${bodyArgName})` + : `request.post(${urlExpression})` + case 'put': + return hasBody + ? `request.put(${urlExpression}, ${bodyArgName})` + : `request.put(${urlExpression})` + case 'patch': + return hasBody + ? `request.patch(${urlExpression}, ${bodyArgName})` + : `request.patch(${urlExpression})` + case 'delete': + return hasBody + ? `request.delete(${urlExpression}, { data: ${bodyArgName} })` + : `request.delete(${urlExpression})` + default: + return hasBody + ? `request.${method}(${urlExpression}, ${bodyArgName})` + : `request.${method}(${urlExpression})` + } +} + +const buildUrlExpression = (apiPath, signatureInfo) => { + return signatureInfo.urlParamsExpression + ? `buildUrl(\`${apiPath}\`, ${signatureInfo.urlParamsExpression})` + : `\`${apiPath}\`` +} + +const getOperationParameters = (operation) => { + return [...getParameters(operation, 'path'), ...getParameters(operation, 'query')] +} + +const getParameters = (operation, location) => { + return (operation?.parameters || []).filter((parameter) => parameter.in === location) +} + +const getRequestBodySchemaInfo = (requestBody, schemas) => { + if (!requestBody?.content) { + return null + } + + const contentEntries = Object.entries(requestBody.content) + const preferredContent = + contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0] + + if (!preferredContent) { + return null + } + + const [, contentValue] = preferredContent + + return { + schema: contentValue.schema, + resolvedSchema: resolveSchema(contentValue.schema, schemas), + } +} + +const getResponseSchemaInfo = (responses, schemas) => { + if (!responses) { + return null + } + + const preferredResponse = + responses['200'] || + responses['201'] || + responses.default || + Object.values(responses).find((response) => response?.content) + + if (!preferredResponse?.content) { + return null + } + + const contentEntries = Object.entries(preferredResponse.content) + const preferredContent = + contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0] + + if (!preferredContent?.[1]?.schema) { + return null + } + + const schema = preferredContent[1].schema + + return { + displayName: getSchemaDisplayName(schema, schemas), + resolvedSchema: resolveSchema(schema, schemas), + schema, + } +} + +const buildResponsePropertyDocLines = (schema, schemas) => { + if (!schema) { + return [] + } + + const resolvedSchema = resolveSchema(schema, schemas) + + if (resolvedSchema?.type === 'object' && resolvedSchema.properties) { + return buildResponseObjectPropertyDocLines(schema, schemas, 'Response fields:') + } + + if (resolvedSchema?.type === 'array') { + const itemSchema = resolvedSchema.items + const resolvedItemSchema = resolveSchema(itemSchema, schemas) + + if (resolvedItemSchema?.type === 'object' && resolvedItemSchema.properties) { + return buildResponseObjectPropertyDocLines(itemSchema, schemas, 'Response item fields:') + } + } + + return [] +} + +const buildResponseObjectPropertyDocLines = (schema, schemas, title) => { + const resolvedSchema = resolveSchema(schema, schemas) + const requiredKeys = new Set(resolvedSchema?.required || []) + const lines = [title] + + for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) { + const type = getJsDocType(propertySchema, schemas) + const description = escapeComment(propertySchema.description || '') + const suffix = requiredKeys.has(propertyName) ? '' : '?' + + lines.push(`- ${propertyName}${suffix}: {${type}}${description ? ` ${description}` : ''}`) + } + + return lines +} + +const buildParameterDocLine = (rootName, parameter, schemas) => { + const type = getJsDocType(parameter.schema, schemas) + const accessor = parameter.required + ? `${rootName}.${parameter.name}` + : `[${rootName}.${parameter.name}]` + const description = escapeComment(parameter.description || '') + + return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}` +} + +const buildPositionalParameterDocLine = (parameter, schemas) => { + const type = getJsDocType(parameter.schema, schemas) + const accessor = parameter.required ? parameter.variableName : `[${parameter.variableName}]` + const description = escapeComment(parameter.description || '') + + return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}` +} + +const buildSchemaPropertyDocLines = (rootName, schema, schemas) => { + const resolvedSchema = resolveSchema(schema, schemas) + const requiredKeys = new Set(resolvedSchema?.required || []) + const propertyLines = [] + + if (!resolvedSchema?.properties) { + return propertyLines + } + + for (const [propertyName, propertySchema] of Object.entries(resolvedSchema.properties)) { + const type = getJsDocType(propertySchema, schemas) + const accessor = requiredKeys.has(propertyName) + ? `${rootName}.${propertyName}` + : `[${rootName}.${propertyName}]` + const description = escapeComment(propertySchema.description || '') + + propertyLines.push(`@param {${type}} ${accessor}${description ? ` ${description}` : ''}`) + } + + return propertyLines +} + +const getSchemaDisplayName = (schema, schemas) => { + if (!schema) { + return '' + } + + if (schema.$ref) { + return schema.$ref.split('/').pop() + } + + if (schema.type === 'array') { + const itemDisplayName = getSchemaDisplayName(schema.items, schemas) + + if (itemDisplayName) { + return `${itemDisplayName}[]` + } + + return `${getJsDocType(schema.items, schemas)}[]` + } + + return '' +} + +const getExpandedResponseJsDocType = (schema, schemas) => { + const resolvedSchema = resolveSchema(schema, schemas) + + if (!resolvedSchema) { + return 'any' + } + + if (resolvedSchema.type === 'array') { + return appendNullable( + `Array<${getExpandedResponseObjectJsDocType(resolvedSchema.items, schemas)}>`, + schema?.nullable || resolvedSchema.nullable, + ) + } + + if (resolvedSchema.type === 'object') { + return getExpandedResponseObjectJsDocType(schema, schemas) + } + + return getJsDocType(schema, schemas) +} + +const getExpandedResponseObjectJsDocType = (schema, schemas) => { + const resolvedSchema = resolveSchema(schema, schemas) + + if (!resolvedSchema) { + return 'any' + } + + if (resolvedSchema.type !== 'object' || !resolvedSchema.properties) { + return getJsDocType(schema, schemas) + } + + const requiredKeys = new Set(resolvedSchema.required || []) + const propertyEntries = Object.entries(resolvedSchema.properties).map( + ([propertyName, propertySchema]) => { + const renderedName = renderJsDocObjectPropertyName(propertyName) + const optionalToken = requiredKeys.has(propertyName) ? '' : '?' + + return `${renderedName}${optionalToken}: ${getJsDocType(propertySchema, schemas)}` + }, + ) + + return appendNullable( + `{ ${propertyEntries.join(', ')} }`, + schema?.nullable || resolvedSchema.nullable, + ) +} + +const getJsDocType = (schema, schemas) => { + const resolvedSchema = resolveSchema(schema, schemas) + + if (!resolvedSchema) { + return 'any' + } + + if (schema?.$ref) { + const refType = + resolvedSchema.type === 'array' + ? `Array<${getJsDocType(resolvedSchema.items, schemas)}>` + : resolvedSchema.type === 'object' + ? 'Object' + : getJsDocType(resolvedSchema, schemas) + + return appendNullable(refType, schema.nullable || resolvedSchema.nullable) + } + + switch (resolvedSchema.type) { + case 'integer': + case 'number': + return appendNullable('number', resolvedSchema.nullable) + case 'boolean': + return appendNullable('boolean', resolvedSchema.nullable) + case 'string': + return appendNullable('string', resolvedSchema.nullable) + case 'array': + return appendNullable( + `Array<${getJsDocType(resolvedSchema.items, schemas)}>`, + resolvedSchema.nullable, + ) + case 'object': + return appendNullable('Object', resolvedSchema.nullable) + default: + return appendNullable('any', resolvedSchema.nullable) + } +} + +const renderJsDocObjectPropertyName = (propertyName) => { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName) + ? propertyName + : JSON.stringify(propertyName) +} + +const appendNullable = (type, nullable) => { + return nullable ? `${type} | null` : type +} + +const resolveSchema = (schema, schemas) => { + if (!schema) { + return null + } + + if (schema.$ref) { + const schemaName = schema.$ref.split('/').pop() + return schemas[schemaName] || null + } + + return schema +} + +const compareOperations = (left, right) => { + if (left.path !== right.path) { + return left.path.localeCompare(right.path) + } + + return HTTP_METHOD_ORDER.indexOf(left.method) - HTTP_METHOD_ORDER.indexOf(right.method) +} + +const assignFunctionNames = (operations) => { + const usedNames = new Set() + + for (const item of operations) { + const baseName = ensureIdentifier(toCamelCase(getEndpointName(item.path))) + let functionName = baseName + + if (usedNames.has(functionName)) { + functionName = ensureIdentifier(`${item.method}${capitalize(baseName)}`) + } + + let sequence = 2 + + while (usedNames.has(functionName)) { + functionName = `${baseName}${sequence}` + sequence += 1 + } + + item.functionName = functionName + usedNames.add(functionName) + } +} + +const extractModuleName = (apiPath, operation) => { + const segments = apiPath.split('/').filter(Boolean) + + if (segments[0] === 'api' && /^v\d+$/i.test(segments[1] || '')) { + return segments[2] || operation.tags?.[0] || 'default' + } + + if (segments[0] === 'api') { + return segments[1] || operation.tags?.[0] || 'default' + } + + return operation.tags?.[0] || segments[0] || 'default' +} + +const getEndpointName = (apiPath) => { + const segments = apiPath.split('/').filter(Boolean) + + for (let index = segments.length - 1; index >= 0; index -= 1) { + if (!segments[index].startsWith('{')) { + return segments[index] + } + } + + return 'api' +} + +const normalizeLookupKey = (value) => { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, '') +} + +const ensureIdentifier = (value) => { + const sanitized = value.replace(/[^a-zA-Z0-9_$]/g, '') + + if (!sanitized) { + return 'api' + } + + if (/^[0-9]/.test(sanitized)) { + return `api${capitalize(sanitized)}` + } + + return sanitized +} + +const toSafeVariableName = (value) => { + const identifier = ensureIdentifier(toCamelCase(value) || 'param') + + if (JS_RESERVED_WORDS.has(identifier)) { + return `param${capitalize(identifier)}` + } + + return identifier +} + +const buildParameterBindings = (parameters, reservedNames = []) => { + const usedNames = new Set(reservedNames) + + return parameters.map((parameter) => { + const baseName = toSafeVariableName(parameter.name) + let variableName = baseName + let sequence = 2 + + while (usedNames.has(variableName)) { + variableName = `${baseName}${sequence}` + sequence += 1 + } + + usedNames.add(variableName) + + return { + ...parameter, + variableName, + } + }) +} + +const renderParamBindingEntry = (parameter) => { + return `${JSON.stringify(parameter.name)}: ${parameter.variableName}` +} + +const buildGroupedExportBlock = (exportNames) => { + if (!exportNames.length) { + return [] + } + + return ['export {', ...exportNames.map((exportName) => ` ${exportName}Api,`), '}', ''] +} + +const buildModuleNamespaceExportName = (fileName) => { + return `${toCamelCase(fileName)}Api` +} + +const splitWords = (value) => { + return String(value || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^a-zA-Z0-9]+|\s+/) + .map((item) => item.trim()) + .filter(Boolean) +} + +const toCamelCase = (value) => { + const [firstWord = 'api', ...restWords] = splitWords(value) + + return [ + firstWord.charAt(0).toLowerCase() + firstWord.slice(1), + ...restWords.map((word) => capitalize(word)), + ].join('') +} + +const toKebabCase = (value) => { + return splitWords(value) + .map((word) => word.toLowerCase()) + .join('-') +} + +const capitalize = (value) => { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value +} + +const escapeComment = (value) => { + return String(value || '') + .replace(/\*\//g, '* /') + .replace(/\s*[\r\n]+\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() +} diff --git a/src/core/sync.js b/src/core/sync.js new file mode 100644 index 0000000..33e0060 --- /dev/null +++ b/src/core/sync.js @@ -0,0 +1,152 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export const syncExternalIndex = async ({ + projectRoot, + outputDir, + externalIndexFile, + syncOptions, +}) => { + if (!externalIndexFile) { + throw new Error('externalIndexFile is required for sync') + } + + const generatedIndexPath = path.join(outputDir, 'index.js') + const [generatedContent, targetContent] = await Promise.all([ + fs.readFile(generatedIndexPath, 'utf8'), + readFileIfExists(externalIndexFile), + ]) + const nextContent = buildTargetFileContent({ + targetContent, + generatedContent, + generatedIndexPath, + externalIndexFile, + exportFrom: syncOptions.exportFrom, + includeGeneratedIndexSnapshot: syncOptions.includeGeneratedIndexSnapshot, + snapshotTitle: syncOptions.snapshotTitle, + blockStart: syncOptions.blockStart, + blockEnd: syncOptions.blockEnd, + projectRoot, + }) + + if (normalizeLineEndings(nextContent) === normalizeLineEndings(targetContent)) { + console.log(`no changes: ${toProjectRelativePath(projectRoot, externalIndexFile)}`) + return + } + + await fs.mkdir(path.dirname(externalIndexFile), { recursive: true }) + await fs.writeFile(externalIndexFile, nextContent, 'utf8') + console.log(`synced: ${toProjectRelativePath(projectRoot, externalIndexFile)}`) +} + +const buildTargetFileContent = ({ + targetContent, + generatedContent, + generatedIndexPath, + externalIndexFile, + exportFrom, + includeGeneratedIndexSnapshot, + snapshotTitle, + blockStart, + blockEnd, + projectRoot, +}) => { + const lineEnding = targetContent.includes('\r\n') ? '\r\n' : '\n' + const normalizedTargetContent = normalizeLineEndings(targetContent) + const managedBlock = buildManagedBlock({ + generatedContent, + generatedIndexPath, + externalIndexFile, + exportFrom, + includeGeneratedIndexSnapshot, + snapshotTitle, + blockStart, + blockEnd, + projectRoot, + }) + const blockPattern = new RegExp( + `${escapeRegExp(blockStart)}[\\s\\S]*?${escapeRegExp(blockEnd)}`, + 'm', + ) + const trimmedTargetContent = normalizedTargetContent.trimEnd() + let nextContent = managedBlock + + if (trimmedTargetContent) { + nextContent = blockPattern.test(normalizedTargetContent) + ? trimmedTargetContent.replace(blockPattern, managedBlock) + : `${trimmedTargetContent}\n\n${managedBlock}` + } + + return `${nextContent}\n`.replace(/\n/g, lineEnding) +} + +const buildManagedBlock = ({ + generatedContent, + generatedIndexPath, + externalIndexFile, + exportFrom, + includeGeneratedIndexSnapshot, + snapshotTitle, + blockStart, + blockEnd, + projectRoot, +}) => { + const lines = [ + blockStart, + `// Synced from '${toProjectRelativePath(projectRoot, generatedIndexPath)}'. Do not edit manually.`, + ] + + if (includeGeneratedIndexSnapshot) { + lines.push(snapshotTitle, ...buildCommentLines(generatedContent)) + } + + lines.push( + `export * from '${exportFrom || buildExportFrom(externalIndexFile, path.dirname(generatedIndexPath))}'`, + ) + lines.push(blockEnd) + + return lines.join('\n') +} + +const buildExportFrom = (externalIndexFile, generatedDir) => { + let relativeImportPath = path + .relative(path.dirname(externalIndexFile), generatedDir) + .replace(/\\/g, '/') + + if (!relativeImportPath.startsWith('.')) { + relativeImportPath = `./${relativeImportPath}` + } + + return relativeImportPath +} + +const buildCommentLines = (content) => { + return normalizeLineEndings(content) + .trimEnd() + .split('\n') + .map((line) => (line ? `// ${line}` : '//')) +} + +const readFileIfExists = async (targetPath) => { + try { + return await fs.readFile(targetPath, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') { + return '' + } + + throw error + } +} + +const normalizeLineEndings = (content) => { + return content.replace(/\r\n/g, '\n') +} + +const escapeRegExp = (value) => { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const toProjectRelativePath = (projectRoot, targetPath) => { + return path.relative(projectRoot, targetPath).replace(/\\/g, '/') +} diff --git a/templates/run-yx-generate-api.bat b/templates/run-yx-generate-api.bat new file mode 100644 index 0000000..f1b8a12 --- /dev/null +++ b/templates/run-yx-generate-api.bat @@ -0,0 +1,22 @@ +@echo off +setlocal + +cd /d "%~dp0" +if errorlevel 1 goto :cd_error + +call npx yx-generate-api gen %* +if errorlevel 1 goto :run_error + +exit /b 0 + +:cd_error +echo. +echo Failed to enter project root. +pause +exit /b 1 + +:run_error +echo. +echo yx-generate-api gen failed. +pause +exit /b 1 diff --git a/templates/yx-generate-api.config.mjs b/templates/yx-generate-api.config.mjs new file mode 100644 index 0000000..50c112b --- /dev/null +++ b/templates/yx-generate-api.config.mjs @@ -0,0 +1,13 @@ +export default { + swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json', + outputDir: 'src/api/generated', + externalIndexFile: 'src/api/index.js', + requestImport: '../request', + paramStyle: 'object', + sync: { + enabled: true, + includeGeneratedIndexSnapshot: true, + blockStart: '// AUTO-GENERATED API EXPORTS START', + blockEnd: '// AUTO-GENERATED API EXPORTS END', + }, +}