/** * generate-changelog.js — 从 git log 生成 changelog,按中版本分文件存储 * * 文件结构: * public/CHANGELOG.md — 索引页,显示最新版本内容 + 链接到所有版本文件 * public/CHANGELOG-v{MAJOR}.{MINOR}.md — 每个中版本一个文件 * * 用法: node scripts/generate-changelog.js */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const rootDir = path.resolve(__dirname, '..'); const PUBLIC_DIR = path.join(rootDir, 'public'); const version = process.argv[2]; if (!version) { console.error('[generate-changelog] 用法: node scripts/generate-changelog.js '); process.exit(1); } const lastBuildFile = path.join(rootDir, '.last_build_commit'); const changelogFile = path.join(PUBLIC_DIR, 'CHANGELOG.md'); const versionParts = version.split('.'); const minorVersion = `${versionParts[0]}.${versionParts[1]}`; const versionFileName = `CHANGELOG-v${minorVersion}.md`; const versionFilePath = path.join(PUBLIC_DIR, versionFileName); // ==================== 1. 获取 commit 日志 ==================== let fromHash = ''; try { fromHash = fs.readFileSync(lastBuildFile, 'utf-8').trim(); } catch { try { fromHash = execSync('git rev-list --max-parents=0 HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim(); } catch { fromHash = ''; } } const headHash = execSync('git rev-parse HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim(); let log = ''; try { const range = fromHash ? `${fromHash}..HEAD` : 'HEAD'; log = execSync( `git log --no-merges --pretty=format:"%s|||%b|||%an|||%ai" ${range}`, { cwd: rootDir, encoding: 'utf-8' } ).trim(); } catch { log = ''; } const commits = log ? log.split('\n').filter(Boolean).map(line => { const [subject, body, author, date] = line.split('|||'); return { subject: subject || '', body: body || '', author: author || '', date: date || '' }; }) : []; if (commits.length === 0) { console.log('[generate-changelog] 没有新 commit,跳过 changelog 生成'); fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); return; } // ==================== 2. 分类 commit ==================== function categorize(subject) { const s = subject.toLowerCase(); if (s.startsWith('feat') || s.startsWith('feature')) return '新功能'; if (s.startsWith('fix') || s.startsWith('bug')) return 'Bug 修复'; if (s.startsWith('refactor')) return '重构'; if (s.startsWith('docs') || s.startsWith('doc')) return '文档'; if (s.startsWith('chore') || s.startsWith('build') || s.startsWith('ci')) return '工程化'; if (s.startsWith('perf') || s.startsWith('optimize')) return '性能优化'; if (s.startsWith('style')) return '样式'; if (s.startsWith('test')) return '测试'; return '其他'; } const hasMeaningful = commits.some(c => categorize(c.subject) !== '工程化'); if (!hasMeaningful) { console.log('[generate-changelog] 仅有工程化变更,跳过 changelog 生成'); fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); return; } const grouped = {}; commits.forEach(c => { const cat = categorize(c.subject); if (!grouped[cat]) grouped[cat] = []; let desc = c.subject.replace(/^(feat|feature|fix|refactor|docs?|chore|build|ci|perf|style|test)(\([^)]*\))?[:\s]*/i, ''); if (!desc) desc = c.subject; grouped[cat].push(`- ${desc}`); }); // ==================== 3. 构建版本内容块 ==================== const now = new Date().toISOString().slice(0, 10); let block = `\n## v${version} (${now})\n\n`; for (const [cat, items] of Object.entries(grouped)) { if (cat === '工程化') continue; block += `### ${cat}\n${items.join('\n')}\n\n`; } // ==================== 4. 写入版本文件 ==================== let versionContent = ''; try { versionContent = fs.readFileSync(versionFilePath, 'utf-8'); } catch { versionContent = `# v${minorVersion} 版本历史\n\n[« 返回最新版本](CHANGELOG.md)\n\n---\n`; } const versionHeaderEnd = versionContent.indexOf('\n## '); if (versionHeaderEnd === -1) { versionContent = versionContent.trimEnd() + block; } else { versionContent = versionContent.slice(0, versionHeaderEnd) + block + versionContent.slice(versionHeaderEnd); } fs.writeFileSync(versionFilePath, versionContent, 'utf-8'); // ==================== 5. 迁移旧版 CHANGELOG.md 内容(首次运行) ==================== // 检查是否已有版本文件(除当前文件外),若没有则从旧版 CHANGELOG.md 迁移 const allFiles = fs.readdirSync(PUBLIC_DIR); const existingVersionFiles = allFiles.filter(f => /^CHANGELOG-v\d+\.\d+\.md$/.test(f) && f !== versionFileName); if (existingVersionFiles.length === 0) { try { const legacyContent = fs.readFileSync(changelogFile, 'utf-8'); // 判断是否是旧版格式(包含 ## v 条目但没有版本导航链接) const hasLegacyEntries = /^## v\d+\.\d+\.\d+/m.test(legacyContent); const hasVersionNav = legacyContent.includes('版本历史]('); if (hasLegacyEntries && !hasVersionNav) { // 提取所有版本条目,按中版本分组 const versionRegex = /(## v(\d+\.\d+)\.\d+ .*?)(?=\n## v\d+\.\d+\.\d+ |\n*$)/gs; const entries = {}; let match; while ((match = versionRegex.exec(legacyContent)) !== null) { const entry = match[1].trim(); const ver = match[2]; if (!entries[ver]) entries[ver] = []; entries[ver].push(entry); } for (const [ver, entryList] of Object.entries(entries)) { if (ver === minorVersion) continue; // 当前版本已写入 const vf = path.join(PUBLIC_DIR, `CHANGELOG-v${ver}.md`); let vc = ''; try { vc = fs.readFileSync(vf, 'utf-8'); } catch { vc = `# v${ver} 版本历史\n\n[« 返回最新版本](CHANGELOG.md)\n\n---\n`; } const vhEnd = vc.indexOf('\n## '); const migratedBlock = '\n' + entryList.join('\n') + '\n'; if (vhEnd === -1) { vc = vc.trimEnd() + migratedBlock; } else { vc = vc.slice(0, vhEnd) + migratedBlock + vc.slice(vhEnd); } fs.writeFileSync(vf, vc, 'utf-8'); } console.log(`[generate-changelog] 已迁移 ${Object.keys(entries).length} 个中版本的历史记录`); } } catch { // 没有旧版 CHANGELOG.md,忽略 } } // ==================== 6. 重建主 CHANGELOG.md ==================== const allVersionFiles = fs.readdirSync(PUBLIC_DIR) .filter(f => /^CHANGELOG-v(\d+)\.(\d+)\.md$/.test(f)) .sort((a, b) => { // 降序排列:1.1 > 1.0 const ma = a.match(/^CHANGELOG-v(\d+)\.(\d+)\.md$/); const mb = b.match(/^CHANGELOG-v(\d+)\.(\d+)\.md$/); if (!ma || !mb) return 0; const va = parseInt(ma[1]) * 1000 + parseInt(ma[2]); const vb = parseInt(mb[1]) * 1000 + parseInt(mb[2]); return vb - va; }); const links = allVersionFiles.map(f => { const v = f.replace('CHANGELOG-v', '').replace('.md', ''); return `- [v${v} 版本历史](${f})`; }).join('\n'); // 读取最新版本文件内容作为默认显示 let latestContent = ''; if (allVersionFiles.length > 0) { const latestFile = allVersionFiles[0]; latestContent = fs.readFileSync(path.join(PUBLIC_DIR, latestFile), 'utf-8'); // 去掉文件头(标题 + 返回链接 + 分隔线) latestContent = latestContent.replace(/^# .+?\n\n\[.+?\]\(.+?\)\n\n---\n\n/s, ''); } const mainContent = `# 更新日志 > 当前版本: v${version} ## 历史版本 ${links} --- ${latestContent} `; fs.writeFileSync(changelogFile, mainContent, 'utf-8'); // ==================== 7. 更新 LAST_BUILD ==================== fs.writeFileSync(lastBuildFile, headHash, 'utf-8'); console.log(`[generate-changelog] CHANGELOG.md + ${versionFileName} 已更新,新增 ${commits.length} 条记录`);