Repository navigation
Expand file tree
/
Copy pathchangeset-entry-format.cjs
More file actions
162 lines (146 loc) · 4.65 KB
/
Copy pathchangeset-entry-format.cjs
File metadata and controls
162 lines (146 loc) · 4.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// Copyright (c) Meta Platforms, Inc. and affiliates.
/**
* Shared parser/formatter for XDS changeset bodies.
*
* XDS encodes two extra signals in the changeset *body* (not frontmatter,
* because @changesets/parse treats every frontmatter key as a package→bump
* mapping and rejects anything else):
*
* [category] one-line user-facing summary (#1234)
* @contributor1 @contributor2
* ...optional extra body (codemod notes, etc.)
*
* - category -> editorial grouping for the changelog, decoupled from semver.
* - @handle -> contributor(s), captured at authoring time (correct human,
* not the release bot).
*
* This module is the single source of truth for that convention. It is used
* by:
* - .changeset/changelog.cjs (release-line rendering)
* - scripts/changeset-new.mjs (the authoring wrapper)
* - scripts/check-changesets.mjs (CI lint)
* - scripts/format-changelogs.mjs (post-version formatter)
*
* NOTE: lives in scripts/ (not .changeset/) on purpose — @changesets/read
* treats every *subdirectory* of .changeset/ as a legacy v1 changeset, so a
* .changeset/lib/ dir breaks `changeset version`.
*/
// Canonical category set. Order here is the order sections render in the
// changelog. `label` is the changelog section heading.
const CATEGORIES = [
{key: 'breaking', label: 'Breaking Changes'},
{key: 'component', label: 'New Components'},
{key: 'feat', label: 'New Features'},
{key: 'fix', label: 'Fixes'},
{key: 'perf', label: 'Performance'},
{key: 'docs', label: 'Documentation'},
{key: 'chore', label: 'Other Changes'},
];
const CATEGORY_KEYS = CATEGORIES.map(c => c.key);
// Aliases people are likely to type.
const CATEGORY_ALIASES = {
bug: 'fix',
bugfix: 'fix',
fixes: 'fix',
feature: 'feat',
features: 'feat',
break: 'breaking',
major: 'breaking',
components: 'component',
new: 'component',
performance: 'perf',
doc: 'docs',
refactor: 'chore',
internal: 'chore',
};
function normalizeCategory(raw) {
if (!raw) return null;
const k = String(raw).trim().toLowerCase();
if (CATEGORY_KEYS.includes(k)) return k;
if (CATEGORY_ALIASES[k]) return CATEGORY_ALIASES[k];
return null;
}
function labelFor(key) {
const c = CATEGORIES.find(x => x.key === key);
return c ? c.label : 'Other Changes';
}
const CONTRIB_LINE = /^@[\w-]+(\s+@[\w-]+)*\s*$/;
const HANDLE = /@([\w-]+)/g;
/**
* Parse a changeset summary body into structured parts.
* Tolerant: missing category/contributors are reported, not thrown — the
* checker decides severity.
*/
function parseEntry(summary) {
const lines = String(summary || '')
.replace(/\r\n/g, '\n')
.split('\n');
let category = null;
let headline = '';
const contributors = [];
const extra = [];
let consumedHeadline = false;
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!consumedHeadline) {
if (line.trim() === '') continue; // skip leading blanks
// First non-empty line is the headline; may carry a [category] prefix.
const m = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
if (m) {
category = normalizeCategory(m[1]);
headline = m[2].trim();
} else {
headline = line.trim();
}
consumedHeadline = true;
continue;
}
// After the headline: a line that is only @handles is a contributor line.
if (CONTRIB_LINE.test(line.trim()) && line.trim().startsWith('@')) {
let mm;
const re = new RegExp(HANDLE.source, 'g');
while ((mm = re.exec(line)) !== null) {
if (!contributors.includes(mm[1])) contributors.push(mm[1]);
}
continue;
}
extra.push(rawLine);
}
// Trim leading/trailing blank lines from extra.
while (extra.length && extra[0].trim() === '') extra.shift();
while (extra.length && extra[extra.length - 1].trim() === '') extra.pop();
return {
category,
headline,
contributors,
extra: extra.join('\n'),
};
}
/**
* Render a single changelog bullet for a parsed entry. Used by getReleaseLine.
* Keeps the [category] tag inline so the post-version formatter can regroup,
* and appends contributor attribution.
*/
function renderReleaseLine(parsed) {
const cat = parsed.category || 'chore';
let line = `- [${cat}] ${parsed.headline}`;
if (parsed.contributors.length) {
line += ` — thanks ${parsed.contributors.map(c => `@${c}`).join(', ')}`;
}
if (parsed.extra) {
const indented = parsed.extra
.split('\n')
.map(l => (l.trim() ? ` ${l}` : ''))
.join('\n');
line += `\n${indented}`;
}
return line;
}
module.exports = {
CATEGORIES,
CATEGORY_KEYS,
normalizeCategory,
labelFor,
parseEntry,
renderReleaseLine,
};