From 1fa9232c57af74e65b9ff2a9379886a171f75585 Mon Sep 17 00:00:00 2001 From: "DESKTOP-I3JPKHK\\wy" <1111> Date: Wed, 22 Apr 2026 11:07:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=98=AF=E5=90=A6=E7=94=9F?= =?UTF-8?q?=E6=88=90generated/index.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 ++++- src/commands/gen.js | 1 + src/commands/generate.js | 1 + src/commands/sync.js | 1 + src/core/config.js | 3 +- src/core/generate.js | 162 ++++++++++++++++----------- src/core/sync.js | 7 ++ templates/yx-generate-api.config.mjs | 5 + test/cli.integration.test.js | 94 ++++++++++++++++ test/generate.test.js | 104 +++++++++++++++++ 10 files changed, 339 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 5601ca6..4caf283 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ export default { // 相对路径会基于当前配置文件所在目录解析。 outputDir: 'src/api/aixue/generated', + // 是否生成 outputDir/index.js。 + // 关闭后只保留 shared.js 和各模块文件;sync / gen 也会默认不再同步。 + generateIndexFile: true, + // 由 `sync` / `gen` 维护的外部 API 入口文件。 externalIndexFile: 'src/api/aixue/index.js', @@ -92,6 +96,7 @@ export default { sync: { // 如果你只想生成文件、不想改 externalIndexFile,可以设为 false。 + // 当 generateIndexFile=false 时,这个开关默认也会变成 false。 enabled: true, // 是否在受管区块中附带 generated/index.js 的注释快照。 @@ -118,8 +123,8 @@ npx yx-generate-api gen 1. 拉取 `swaggerUrl` 2. 在 `outputDir` 下生成 API 文件 -3. 生成 `outputDir/index.js` -4. 把导出同步到 `externalIndexFile` +3. 如果 `generateIndexFile=true`,生成 `outputDir/index.js` +4. 如果 `sync.enabled=true`,把导出同步到 `externalIndexFile` ## 一个完整的日常流程 @@ -156,6 +161,8 @@ npx yx-generate-api sync 生成目录。默认是 `src/api/generated`。 - `externalIndexFile` 业务侧统一导出文件,例如 `src/api/index.js`。如果不填,默认不会执行同步。 +- `generateIndexFile` + 是否生成 `outputDir/index.js`。默认 `true`。设为 `false` 后,工具只生成 `shared.js` 和各模块文件;`sync.enabled` 的默认值也会随之变成 `false`。 - `requestImport` 生成模块里 `import request from '...'` 的路径。它应该相对于每个生成出来的模块文件。 - `paramStyle` @@ -168,7 +175,7 @@ npx yx-generate-api sync ### sync 配置 - `sync.enabled` - 是否启用同步。默认值等于 `Boolean(externalIndexFile)`。 + 是否启用同步。默认值等于 `Boolean(externalIndexFile && generateIndexFile)`。 - `sync.blockStart` 受管区块开始标记。 - `sync.blockEnd` @@ -348,6 +355,21 @@ import { curriculumApi } from '@/api/aixue/generated' import { getCurriculumListApi } from '@/api/aixue/generated' ``` +如果你不需要这个聚合入口,也可以这样关闭: + +```js +generateIndexFile: false +``` + +关闭后会有这些变化: + +- 不再生成 `generated/index.js` +- 旧的自动生成 `generated/index.js` 会在下次生成时被移除 +- 不再对“跨模块扁平导出重名”做 `generated/index.js` 级别的冲突校验 +- `sync.enabled` 默认会变成 `false` + +如果你又显式把 `sync.enabled` 设成 `true`,命令会直接报错,因为 `sync` 依赖 `generated/index.js` + 需要注意: - 如果不同模块里恰好生成了同名函数,例如都生成了 `getListApi` diff --git a/src/commands/gen.js b/src/commands/gen.js index 2443f5a..65670d8 100644 --- a/src/commands/gen.js +++ b/src/commands/gen.js @@ -21,6 +21,7 @@ export const runGenCommand = async (args) => { projectRoot: context.projectConfig.rootDir, outputDir: context.runtimeConfig.outputDir, externalIndexFile: context.projectConfig.externalIndexFile, + generateIndexFile: context.runtimeConfig.generateIndexFile, syncOptions: context.projectConfig.sync, }) } diff --git a/src/commands/generate.js b/src/commands/generate.js index d83c119..10437c6 100644 --- a/src/commands/generate.js +++ b/src/commands/generate.js @@ -53,6 +53,7 @@ export const resolveGenerateCommandContext = async (args) => { outputDir: getFlagValue(parsedArgs.flags, 'outDir') ? resolveCwdPath(process.cwd(), getFlagValue(parsedArgs.flags, 'outDir')) : projectConfig.outputDir, + generateIndexFile: projectConfig.generateIndexFile, requestImport: getFlagValue(parsedArgs.flags, 'requestImport') || projectConfig.requestImport, paramStyle: getFlagValue(parsedArgs.flags, 'paramStyle') ? normalizeParamStyle(getFlagValue(parsedArgs.flags, 'paramStyle')) diff --git a/src/commands/sync.js b/src/commands/sync.js index 72679e6..a7539fa 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -26,6 +26,7 @@ export const runSyncCommand = async (args) => { projectRoot: projectConfig.rootDir, outputDir: projectConfig.outputDir, externalIndexFile: projectConfig.externalIndexFile, + generateIndexFile: projectConfig.generateIndexFile, syncOptions: projectConfig.sync, }) } diff --git a/src/core/config.js b/src/core/config.js index 4a8ecad..d85f9ec 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -53,12 +53,13 @@ export const loadProjectConfig = async ({ configPath, cwd = process.cwd() } = {} outputDir: rawConfig.outputDir ? resolveProjectPath(rootDir, rawConfig.outputDir) : resolveProjectPath(rootDir, 'src/api/generated'), + generateIndexFile: rawConfig.generateIndexFile ?? true, externalIndexFile, requestImport: normalizeString(rawConfig.requestImport) || '../request', paramStyle: normalizeParamStyle(rawConfig.paramStyle || PARAM_STYLE.OBJECT), cleanOutput: rawConfig.cleanOutput ?? true, sync: { - enabled: syncConfig.enabled ?? Boolean(externalIndexFile), + enabled: syncConfig.enabled ?? Boolean(externalIndexFile && (rawConfig.generateIndexFile ?? true)), blockStart: normalizeString(syncConfig.blockStart) || DEFAULT_SYNC_OPTIONS.blockStart, blockEnd: normalizeString(syncConfig.blockEnd) || DEFAULT_SYNC_OPTIONS.blockEnd, includeGeneratedIndexSnapshot: diff --git a/src/core/generate.js b/src/core/generate.js index a6c34fb..15b8c10 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -61,6 +61,7 @@ export const generateApiFiles = async ({ swaggerUrl, swaggerTimeoutMs, outputDir, + generateIndexFile: shouldGenerateIndexFile = true, requestImport, paramStyle, modules, @@ -72,73 +73,80 @@ export const generateApiFiles = async ({ const selectedModules = resolveSelectedModules(moduleMap, modules) const selectedModuleFileNames = new Set(selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`)) const shouldRemoveStaleGeneratedFiles = cleanOutput && modules.length === 0 - const retainedModuleExports = await readRetainedModuleExports({ - outputDir, - skipFileNames: selectedModuleFileNames, - skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, - }) - const reservedNamespaceFunctionNames = new Set( - [...selectedModules.map((moduleInfo) => moduleInfo.fileName), ...retainedModuleExports.map((item) => item.fileName)].map( - (fileName) => toCamelCase(fileName), - ), - ) + const retainedModuleExports = shouldGenerateIndexFile + ? await readRetainedModuleExports({ + outputDir, + skipFileNames: selectedModuleFileNames, + skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, + }) + : [] + const reservedNamespaceFunctionNames = shouldGenerateIndexFile + ? new Set( + [ + ...selectedModules.map((moduleInfo) => moduleInfo.fileName), + ...retainedModuleExports.map((item) => item.fileName), + ].map((fileName) => toCamelCase(fileName)), + ) + : new Set() for (const moduleInfo of selectedModules) { assignFunctionNames(moduleInfo.operations, reservedNamespaceFunctionNames) } - const namespaceConflicts = findNamespaceExportConflicts([ - ...buildPlannedModuleExports(selectedModules), - ...retainedModuleExports, - ]) + if (shouldGenerateIndexFile) { + const namespaceConflicts = findNamespaceExportConflicts([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) - if (namespaceConflicts.length) { - throw createDuplicateModuleExportsError(namespaceConflicts, { - reason: 'namespace-conflict', - }) - } + if (namespaceConflicts.length) { + throw createDuplicateModuleExportsError(namespaceConflicts, { + reason: 'namespace-conflict', + }) + } - const duplicateConflicts = findDuplicateModuleExports([ - ...buildPlannedModuleExports(selectedModules), - ...retainedModuleExports, - ]) + const duplicateConflicts = findDuplicateModuleExports([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) - if (duplicateConflicts.length) { - const resolution = await resolveDuplicateExports?.({ - conflicts: duplicateConflicts, - }) + if (duplicateConflicts.length) { + const resolution = await resolveDuplicateExports?.({ + conflicts: duplicateConflicts, + }) - if (resolution?.action === 'rename') { - const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts) - const remainingNamespaceConflicts = findNamespaceExportConflicts([ - ...buildPlannedModuleExports(selectedModules), - ...retainedModuleExports, - ]) - const remainingConflicts = findDuplicateModuleExports([ - ...buildPlannedModuleExports(selectedModules), - ...retainedModuleExports, - ]) + if (resolution?.action === 'rename') { + const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts) + const remainingNamespaceConflicts = findNamespaceExportConflicts([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) + const remainingConflicts = findDuplicateModuleExports([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) - if (remainingNamespaceConflicts.length || remainingConflicts.length) { - throw createDuplicateModuleExportsError(remainingConflicts, { - reason: 'rename-failed', - supplementalConflicts: remainingNamespaceConflicts, + if (remainingNamespaceConflicts.length || remainingConflicts.length) { + throw createDuplicateModuleExportsError(remainingConflicts, { + reason: 'rename-failed', + supplementalConflicts: remainingNamespaceConflicts, + }) + } + + if (renameEntries.length) { + console.log('resolved duplicate exports by appending module names:') + + for (const renameEntry of renameEntries) { + console.log( + ` ${renameEntry.from} -> ${renameEntry.to} (${renameEntry.moduleName}${renameEntry.path ? ` | ${renameEntry.path}` : ''})`, + ) + } + } + } else { + throw createDuplicateModuleExportsError(duplicateConflicts, { + reason: resolution?.action === 'abort' ? 'user-aborted' : 'unresolved', }) } - - if (renameEntries.length) { - console.log('resolved duplicate exports by appending module names:') - - for (const renameEntry of renameEntries) { - console.log( - ` ${renameEntry.from} -> ${renameEntry.to} (${renameEntry.moduleName}${renameEntry.path ? ` | ${renameEntry.path}` : ''})`, - ) - } - } - } else { - throw createDuplicateModuleExportsError(duplicateConflicts, { - reason: resolution?.action === 'abort' ? 'user-aborted' : 'unresolved', - }) } } @@ -177,14 +185,23 @@ export const generateApiFiles = async ({ } const indexFilePath = path.join(outputDir, DEFAULT_INDEX_FILE_NAME) - await writeGeneratedFile( - indexFilePath, - await generateIndexFile({ - outDir: outputDir, - swaggerUrl, - }), - ) - console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`) + + if (shouldGenerateIndexFile) { + await writeGeneratedFile( + indexFilePath, + await generateIndexFile({ + outDir: outputDir, + swaggerUrl, + }), + ) + console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`) + } else { + await removeAutoGeneratedIndexFile({ + indexFilePath, + projectRoot, + }) + } + console.log(`done: ${selectedModules.length} module(s)`) } @@ -273,6 +290,25 @@ const cleanStaleGeneratedFiles = async ({ outputDir, nextModuleFileNames, projec } } +const removeAutoGeneratedIndexFile = async ({ indexFilePath, projectRoot }) => { + try { + const content = await fs.readFile(indexFilePath, 'utf8') + + if (!content.startsWith(AUTO_GENERATED_BANNER)) { + return + } + + await fs.rm(indexFilePath) + console.log(`removed: ${path.relative(projectRoot, indexFilePath)}`) + } catch (error) { + if (error?.code === 'ENOENT') { + return + } + + throw error + } +} + const readRetainedModuleExports = async ({ outputDir, skipFileNames = new Set(), diff --git a/src/core/sync.js b/src/core/sync.js index 33e0060..56abfb9 100644 --- a/src/core/sync.js +++ b/src/core/sync.js @@ -5,12 +5,19 @@ export const syncExternalIndex = async ({ projectRoot, outputDir, externalIndexFile, + generateIndexFile = true, syncOptions, }) => { if (!externalIndexFile) { throw new Error('externalIndexFile is required for sync') } + if (!generateIndexFile) { + throw new Error( + 'sync requires generated/index.js, but generateIndexFile=false. Set generateIndexFile=true or sync.enabled=false.', + ) + } + const generatedIndexPath = path.join(outputDir, 'index.js') const [generatedContent, targetContent] = await Promise.all([ fs.readFile(generatedIndexPath, 'utf8'), diff --git a/templates/yx-generate-api.config.mjs b/templates/yx-generate-api.config.mjs index a963613..202123b 100644 --- a/templates/yx-generate-api.config.mjs +++ b/templates/yx-generate-api.config.mjs @@ -10,6 +10,10 @@ export default { // 相对路径会基于当前配置文件所在目录解析。 outputDir: 'src/api/generated', + // 是否生成 outputDir/index.js。 + // 关闭后只保留 shared.js 和各模块文件;sync / gen 也会默认不再同步。 + generateIndexFile: true, + // 由 `sync` / `gen` 维护的外部 API 入口文件。 externalIndexFile: 'src/api/index.js', @@ -26,6 +30,7 @@ export default { sync: { // 如果你只想生成文件、不想改 externalIndexFile,可以设为 false。 + // 当 generateIndexFile=false 时,这个开关默认也会变成 false。 enabled: true, // 是否在受管区块中附带 generated/index.js 的注释快照。 diff --git a/test/cli.integration.test.js b/test/cli.integration.test.js index e682d36..6dfeb02 100644 --- a/test/cli.integration.test.js +++ b/test/cli.integration.test.js @@ -148,3 +148,97 @@ test('cli gen reports duplicate export conflicts with module and url details in await fs.rm(tempDir, { recursive: true, force: true }) } }) + +test('cli gen skips sync by default when generateIndexFile is false', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const configPath = path.join(tempDir, 'yx-generate-api.config.mjs') + const outputDir = path.join(tempDir, 'generated') + const externalIndexFile = path.join(tempDir, 'api-index.js') + const cliPath = path.resolve('bin/yx-generate-api.js') + + await fs.writeFile( + swaggerPath, + JSON.stringify( + { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }, + null, + 2, + ), + 'utf8', + ) + + await fs.writeFile( + configPath, + `export default { + swaggerUrl: './swagger.json', + swaggerTimeoutMs: 1000, + outputDir: './generated', + generateIndexFile: false, + externalIndexFile: './api-index.js', + requestImport: '../request', + cleanOutput: true, +} +`, + 'utf8', + ) + + await execFile(process.execPath, [cliPath, 'gen', '--config', configPath], { + cwd: tempDir, + }) + + await assert.doesNotReject(fs.access(path.join(outputDir, 'alpha.js'))) + await assert.rejects(fs.access(path.join(outputDir, 'index.js'))) + await assert.rejects(fs.access(externalIndexFile)) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('cli sync fails clearly when sync.enabled=true but generateIndexFile is false', async () => { + const tempDir = await createTempDir() + + try { + const configPath = path.join(tempDir, 'yx-generate-api.config.mjs') + const cliPath = path.resolve('bin/yx-generate-api.js') + + await fs.writeFile( + configPath, + `export default { + outputDir: './generated', + generateIndexFile: false, + externalIndexFile: './api-index.js', + sync: { + enabled: true, + }, +} +`, + 'utf8', + ) + + await assert.rejects( + execFile(process.execPath, [cliPath, 'sync', '--config', configPath], { + cwd: tempDir, + }), + (error) => { + assert.match(error.stderr, /generateIndexFile=false/) + return true + }, + ) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/test/generate.test.js b/test/generate.test.js index 66c66ae..e5ce72f 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -218,6 +218,110 @@ test('generated index includes namespace exports and flattened re-exports', asyn } }) +test('generation can skip generated index.js when generateIndexFile is false', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await fs.mkdir(outputDir, { recursive: true }) + await fs.writeFile( + path.join(outputDir, 'index.js'), + `// Auto-generated. Do not edit manually. +// Swagger: old +export * from './old' +`, + 'utf8', + ) + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + generateIndexFile: false, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }) + + await assert.doesNotReject(fs.access(path.join(outputDir, 'alpha.js'))) + await assert.rejects(fs.access(path.join(outputDir, 'index.js'))) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generation allows duplicate module export names when generateIndexFile is false', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await assert.doesNotReject( + generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + generateIndexFile: false, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }), + ) + + const alphaContent = await readFile(path.join(outputDir, 'alpha.js')) + const betaContent = await readFile(path.join(outputDir, 'beta.js')) + + assert.match(alphaContent, /const getListApi =/) + assert.match(betaContent, /const getListApi =/) + await assert.rejects(fs.access(path.join(outputDir, 'index.js'))) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + test('generation can rename duplicate exports by appending module names', async () => { const tempDir = await createTempDir()