更新是否生成generated/index.js

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-22 11:07:26 +08:00
parent 57a2492408
commit 1fa9232c57
10 changed files with 339 additions and 67 deletions

View File

@ -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`

View File

@ -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,
})
}

View File

@ -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'))

View File

@ -26,6 +26,7 @@ export const runSyncCommand = async (args) => {
projectRoot: projectConfig.rootDir,
outputDir: projectConfig.outputDir,
externalIndexFile: projectConfig.externalIndexFile,
generateIndexFile: projectConfig.generateIndexFile,
syncOptions: projectConfig.sync,
})
}

View File

@ -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:

View File

@ -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(),

View File

@ -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'),

View File

@ -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 的注释快照。

View File

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

View File

@ -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()