← 中文资源

简体中文版本

从提交信息生成变更日志

读取约定式提交、按类型分组、链接议题引用,并为指定版本写出 Markdown 变更日志小节。

阅读英文原文

项目目标

本项目把约定式提交转换为分组清晰、可读的变更日志小节。解析器处理约定式提交头,分类器把类型映射到小节并识别破坏性变更,渲染器产出可粘贴进发布说明的 Markdown;非约定式标题归入 Other 而非报错。

核心特性

  • 把约定式提交头解析为类型、作用域与描述
  • 按类型分组为功能、修复与其他小节
  • 链接议题引用并跳过合并与发布提交
  • 在专门小节列出破坏性变更

架构与分层

解析器把提交标题转为结构化变更;分类器按类型分组并标记破坏性;渲染器按小节产出 Markdown;CLI 从 git log 或文件读取。解析与渲染都是纯函数,因此整条管道可无仓库测试。

实施阶段

六个阶段为:提交标题解析、分类器、Markdown 渲染器、git 日志读取、CLI、测试与加固。破坏性变更测试对发布安全最为关键。

测试与验收

用 vitest 运行。解析测试覆盖普通 feat、带作用域 fix、以 ! 标记的破坏性变更、正文中的 BREAKING CHANGE 与非约定式标题;渲染测试断言小节顺序与议题链接;CLI 测试读取样例产出 Markdown 块。

延伸方向

按范围读取、按作用域过滤与输出其他格式都是纯解析器与渲染器外围的增量。配置文件可把自定义类型映射到小节而无需改代码。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

const HEADER = /^(?<type>\w+)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?:\s*(?<description>.+)$/;

export function parseCommit(subject, body = '') {
  const match = HEADER.exec(subject.trim());
  if (!match) return { type: 'other', scope: null, description: subject.trim(), breaking: false };
  const { type, scope, breaking, description } = match.groups;
  return {
    type: type.toLowerCase(),
    scope: scope ?? null,
    description: description.trim(),
    breaking: Boolean(breaking) || /BREAKING CHANGE:/.test(body),
  };
}
mkdir changelog && cd changelog
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "changelog",
  "type": "module",
  "scripts": {
    "changelog": "node src/cli.js",
    "test": "vitest run"
  }
}
const HEADER = /^(?<type>\w+)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?:\s*(?<description>.+)$/;

export function parseCommit(subject, body = '') {
  const match = HEADER.exec(subject.trim());
  if (!match) {
    return { type: 'other', scope: null, description: subject.trim(), breaking: false, refs: [] };
  }
  const { type, scope, breaking, description } = match.groups;
  return {
    type: type.toLowerCase(),
    scope: scope ?? null,
    description: description.trim(),
    breaking: Boolean(breaking) || /BREAKING CHANGE:/.test(body),
    refs: [...description.matchAll(/#(\d+)/g)].map((m) => Number(m[1])),
  };
}
const SECTIONS = {
  feat: 'Features',
  fix: 'Fixes',
  perf: 'Performance',
  docs: 'Documentation',
  refactor: 'Refactors',
  test: 'Tests',
  chore: 'Chores',
  other: 'Other',
};

export function classify(commits) {
  const grouped = new Map();
  const breaking = [];
  for (const commit of commits) {
    if (/^chore\(release\)|^Merge /.test(commit.subject ?? '')) continue;
    if (commit.breaking) breaking.push(commit);
    const section = SECTIONS[commit.type] ?? 'Other';
    if (!grouped.has(section)) grouped.set(section, []);
    grouped.get(section).push(commit);
  }
  return { grouped, breaking };
}

export const SECTION_ORDER = ['Features', 'Fixes', 'Performance', 'Refactors', 'Documentation', 'Tests', 'Chores', 'Other'];
import { SECTION_ORDER } from './classify.js';

export function renderSection(version, date, { grouped, breaking }) {
  const lines = ['## ' + version + ' - ' + date, ''];
  if (breaking.length) {
    lines.push('### Breaking changes', '');
    for (const b of breaking) lines.push('- ' + formatEntry(b));
    lines.push('');
  }
  for (const section of SECTION_ORDER) {
    const items = grouped.get(section);
    if (!items?.length) continue;
    lines.push('### ' + section, '');
    for (const item of items.sort((a, b) => a.description.localeCompare(b.description))) lines.push('- ' + formatEntry(item));
    lines.push('');
  }
  return lines.join('\n').trimEnd() + '\n';
}

function formatEntry(commit) {
  const scope = commit.scope ? '**' + commit.scope + ':** ' : '';
  return scope + commit.description;
}
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { readFile } from 'node:fs/promises';

const run = promisify(execFile);

export async function readCommits({ from, file, cwd = process.cwd() } = {}) {
  if (file) {
    const text = await readFile(file, 'utf8');
    return parseBlocks(text);
  }
  const args = ['log', '--pretty=format:__C__%s%x1f%b%x1e'];
  if (from) args.push(from + '..HEAD');
  const { stdout } = await run('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 });
  return parseBlocks(stdout);
}

function parseBlocks(text) {
  return text.split('__C__').filter(Boolean).map((block) => {
    const [subject, body = ''] = block.replace(/\x1e$/, '').split('\x1f');
    return { subject: subject.trim(), body };
  });
}
import { readCommits } from './git.js';
import { parseCommit } from './parse.js';
import { classify } from './classify.js';
import { renderSection } from './render.js';

const args = parse(process.argv.slice(2));
if (!args.version) { console.error('usage: changelog --version 1.2.0 [--from v1.1.0] [--file commits.txt]'); process.exit(1); }
const raw = await readCommits({ from: args.from, file: args.file });
const commits = raw.map((c) => ({ ...parseCommit(c.subject, c.body), subject: c.subject }));
const section = renderSection(args.version, args.date ?? new Date().toISOString().slice(0, 10), classify(commits));
console.log(section);
import { parseCommit } from '../src/parse.js';
import { classify } from '../src/classify.js';
import { renderSection } from '../src/render.js';

function build(subjects) {
  return classify(subjects.map((s) => ({ ...parseCommit(s), subject: s })));
}

test('features and fixes render in order with scopes', () => {
  const md = renderSection('1.0.0', '2026-10-22', build([
    'feat(api): add pagination',
    'fix(cli): handle missing arg',
  ]));
  expect(md.indexOf('### Features')).toBeLessThan(md.indexOf('### Fixes'));
  expect(md).toContain('**api:** add pagination');
});