generate-changelog.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * generate-changelog.js — 从 git log 生成 changelog,按中版本分文件存储
  3. *
  4. * 文件结构:
  5. * public/CHANGELOG.md — 索引页,显示最新版本内容 + 链接到所有版本文件
  6. * public/CHANGELOG-v{MAJOR}.{MINOR}.md — 每个中版本一个文件
  7. *
  8. * 用法: node scripts/generate-changelog.js <version>
  9. */
  10. const fs = require('fs');
  11. const path = require('path');
  12. const { execSync } = require('child_process');
  13. const rootDir = path.resolve(__dirname, '..');
  14. const PUBLIC_DIR = path.join(rootDir, 'public');
  15. const version = process.argv[2];
  16. if (!version) {
  17. console.error('[generate-changelog] 用法: node scripts/generate-changelog.js <version>');
  18. process.exit(1);
  19. }
  20. const lastBuildFile = path.join(rootDir, '.last_build_commit');
  21. const changelogFile = path.join(PUBLIC_DIR, 'CHANGELOG.md');
  22. const versionParts = version.split('.');
  23. const minorVersion = `${versionParts[0]}.${versionParts[1]}`;
  24. const versionFileName = `CHANGELOG-v${minorVersion}.md`;
  25. const versionFilePath = path.join(PUBLIC_DIR, versionFileName);
  26. // ==================== 1. 获取 commit 日志 ====================
  27. let fromHash = '';
  28. try {
  29. fromHash = fs.readFileSync(lastBuildFile, 'utf-8').trim();
  30. } catch {
  31. try {
  32. fromHash = execSync('git rev-list --max-parents=0 HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim();
  33. } catch {
  34. fromHash = '';
  35. }
  36. }
  37. const headHash = execSync('git rev-parse HEAD', { cwd: rootDir, encoding: 'utf-8' }).trim();
  38. let log = '';
  39. try {
  40. const range = fromHash ? `${fromHash}..HEAD` : 'HEAD';
  41. log = execSync(
  42. `git log --no-merges --pretty=format:"%s|||%b|||%an|||%ai" ${range}`,
  43. { cwd: rootDir, encoding: 'utf-8' }
  44. ).trim();
  45. } catch {
  46. log = '';
  47. }
  48. const commits = log ? log.split('\n').filter(Boolean).map(line => {
  49. const [subject, body, author, date] = line.split('|||');
  50. return { subject: subject || '', body: body || '', author: author || '', date: date || '' };
  51. }) : [];
  52. if (commits.length === 0) {
  53. console.log('[generate-changelog] 没有新 commit,跳过 changelog 生成');
  54. fs.writeFileSync(lastBuildFile, headHash, 'utf-8');
  55. return;
  56. }
  57. // ==================== 2. 分类 commit ====================
  58. function categorize(subject) {
  59. const s = subject.toLowerCase();
  60. if (s.startsWith('feat') || s.startsWith('feature')) return '新功能';
  61. if (s.startsWith('fix') || s.startsWith('bug')) return 'Bug 修复';
  62. if (s.startsWith('refactor')) return '重构';
  63. if (s.startsWith('docs') || s.startsWith('doc')) return '文档';
  64. if (s.startsWith('chore') || s.startsWith('build') || s.startsWith('ci')) return '工程化';
  65. if (s.startsWith('perf') || s.startsWith('optimize')) return '性能优化';
  66. if (s.startsWith('style')) return '样式';
  67. if (s.startsWith('test')) return '测试';
  68. return '其他';
  69. }
  70. const hasMeaningful = commits.some(c => categorize(c.subject) !== '工程化');
  71. if (!hasMeaningful) {
  72. console.log('[generate-changelog] 仅有工程化变更,跳过 changelog 生成');
  73. fs.writeFileSync(lastBuildFile, headHash, 'utf-8');
  74. return;
  75. }
  76. const grouped = {};
  77. commits.forEach(c => {
  78. const cat = categorize(c.subject);
  79. if (!grouped[cat]) grouped[cat] = [];
  80. let desc = c.subject.replace(/^(feat|feature|fix|refactor|docs?|chore|build|ci|perf|style|test)(\([^)]*\))?[:\s]*/i, '');
  81. if (!desc) desc = c.subject;
  82. grouped[cat].push(`- ${desc}`);
  83. });
  84. // ==================== 3. 构建版本内容块 ====================
  85. const now = new Date().toISOString().slice(0, 10);
  86. let block = `\n## v${version} (${now})\n\n`;
  87. for (const [cat, items] of Object.entries(grouped)) {
  88. if (cat === '工程化') continue;
  89. block += `### ${cat}\n${items.join('\n')}\n\n`;
  90. }
  91. // ==================== 4. 写入版本文件 ====================
  92. let versionContent = '';
  93. try {
  94. versionContent = fs.readFileSync(versionFilePath, 'utf-8');
  95. } catch {
  96. versionContent = `# v${minorVersion} 版本历史\n\n[« 返回最新版本](CHANGELOG.md)\n\n---\n`;
  97. }
  98. const versionHeaderEnd = versionContent.indexOf('\n## ');
  99. if (versionHeaderEnd === -1) {
  100. versionContent = versionContent.trimEnd() + block;
  101. } else {
  102. versionContent = versionContent.slice(0, versionHeaderEnd) + block + versionContent.slice(versionHeaderEnd);
  103. }
  104. fs.writeFileSync(versionFilePath, versionContent, 'utf-8');
  105. // ==================== 5. 迁移旧版 CHANGELOG.md 内容(首次运行) ====================
  106. // 检查是否已有版本文件(除当前文件外),若没有则从旧版 CHANGELOG.md 迁移
  107. const allFiles = fs.readdirSync(PUBLIC_DIR);
  108. const existingVersionFiles = allFiles.filter(f => /^CHANGELOG-v\d+\.\d+\.md$/.test(f) && f !== versionFileName);
  109. if (existingVersionFiles.length === 0) {
  110. try {
  111. const legacyContent = fs.readFileSync(changelogFile, 'utf-8');
  112. // 判断是否是旧版格式(包含 ## v 条目但没有版本导航链接)
  113. const hasLegacyEntries = /^## v\d+\.\d+\.\d+/m.test(legacyContent);
  114. const hasVersionNav = legacyContent.includes('版本历史](');
  115. if (hasLegacyEntries && !hasVersionNav) {
  116. // 提取所有版本条目,按中版本分组
  117. const versionRegex = /(## v(\d+\.\d+)\.\d+ .*?)(?=\n## v\d+\.\d+\.\d+ |\n*$)/gs;
  118. const entries = {};
  119. let match;
  120. while ((match = versionRegex.exec(legacyContent)) !== null) {
  121. const entry = match[1].trim();
  122. const ver = match[2];
  123. if (!entries[ver]) entries[ver] = [];
  124. entries[ver].push(entry);
  125. }
  126. for (const [ver, entryList] of Object.entries(entries)) {
  127. if (ver === minorVersion) continue; // 当前版本已写入
  128. const vf = path.join(PUBLIC_DIR, `CHANGELOG-v${ver}.md`);
  129. let vc = '';
  130. try {
  131. vc = fs.readFileSync(vf, 'utf-8');
  132. } catch {
  133. vc = `# v${ver} 版本历史\n\n[« 返回最新版本](CHANGELOG.md)\n\n---\n`;
  134. }
  135. const vhEnd = vc.indexOf('\n## ');
  136. const migratedBlock = '\n' + entryList.join('\n') + '\n';
  137. if (vhEnd === -1) {
  138. vc = vc.trimEnd() + migratedBlock;
  139. } else {
  140. vc = vc.slice(0, vhEnd) + migratedBlock + vc.slice(vhEnd);
  141. }
  142. fs.writeFileSync(vf, vc, 'utf-8');
  143. }
  144. console.log(`[generate-changelog] 已迁移 ${Object.keys(entries).length} 个中版本的历史记录`);
  145. }
  146. } catch {
  147. // 没有旧版 CHANGELOG.md,忽略
  148. }
  149. }
  150. // ==================== 6. 重建主 CHANGELOG.md ====================
  151. const allVersionFiles = fs.readdirSync(PUBLIC_DIR)
  152. .filter(f => /^CHANGELOG-v(\d+)\.(\d+)\.md$/.test(f))
  153. .sort((a, b) => {
  154. // 降序排列:1.1 > 1.0
  155. const ma = a.match(/^CHANGELOG-v(\d+)\.(\d+)\.md$/);
  156. const mb = b.match(/^CHANGELOG-v(\d+)\.(\d+)\.md$/);
  157. if (!ma || !mb) return 0;
  158. const va = parseInt(ma[1]) * 1000 + parseInt(ma[2]);
  159. const vb = parseInt(mb[1]) * 1000 + parseInt(mb[2]);
  160. return vb - va;
  161. });
  162. const links = allVersionFiles.map(f => {
  163. const v = f.replace('CHANGELOG-v', '').replace('.md', '');
  164. return `- [v${v} 版本历史](${f})`;
  165. }).join('\n');
  166. // 读取最新版本文件内容作为默认显示
  167. let latestContent = '';
  168. if (allVersionFiles.length > 0) {
  169. const latestFile = allVersionFiles[0];
  170. latestContent = fs.readFileSync(path.join(PUBLIC_DIR, latestFile), 'utf-8');
  171. // 去掉文件头(标题 + 返回链接 + 分隔线)
  172. latestContent = latestContent.replace(/^# .+?\n\n\[.+?\]\(.+?\)\n\n---\n\n/s, '');
  173. }
  174. const mainContent = `# 更新日志
  175. > 当前版本: v${version}
  176. ## 历史版本
  177. ${links}
  178. ---
  179. ${latestContent}
  180. `;
  181. fs.writeFileSync(changelogFile, mainContent, 'utf-8');
  182. // ==================== 7. 更新 LAST_BUILD ====================
  183. fs.writeFileSync(lastBuildFile, headHash, 'utf-8');
  184. console.log(`[generate-changelog] CHANGELOG.md + ${versionFileName} 已更新,新增 ${commits.length} 条记录`);