项目目标
本项目把约定式提交转换为分组清晰、可读的变更日志小节。解析器处理约定式提交头,分类器把类型映射到小节并识别破坏性变更,渲染器产出可粘贴进发布说明的 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');
});