1/**
2 * Custom markdown renderer for changelogithub-parsed commits.
3 *
4 * Mirrors the output of changelogithub's built-in generator but groups
5 * conventional-commit scopes hierarchically by splitting on `/`. For
6 * example commits like `fix(aws/lambda): ...`, `fix(aws/s3): ...` and
7 * `fix(aws): ...` render as nested categories under a shared `**aws**`
8 * top-level scope rather than as three unrelated flat groups.
9 *
10 * Line-level formatting (authors, PR/issue links, `<samp>` hash tags,
11 * `&nbsp;` spacing) is kept byte-compatible with changelogithub so output
12 * for single-segment scopes is identical to the upstream renderer.
13 */
14import type { ChangelogOptions, Commit } from "changelogithub";
15
16export type RenderConfig = Required<
17 Pick<
18 ChangelogOptions,
19 | "titles"
20 | "types"
21 | "capitalize"
22 | "emoji"
23 | "baseUrl"
24 | "repo"
25 | "from"
26 | "to"
27 >
28> & {
29 scopeMap?: Record<string, string>;
30};
31
32const emojisRE =
33 /([\u2700-\u27BF\uE000-\uF8FF\u2011-\u26FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|\uD83E[\uDD10-\uDDFF])/g;
34
35export function renderMarkdown(
36 commits: Commit[],
37 config: RenderConfig,
38): string {
39 const lines: string[] = [];
40 const [breaking, rest] = partition(commits, (c) => c.isBreaking);
41
42 if (config.titles?.breakingChanges) {
43 lines.push(
44 ...renderSection(breaking, config.titles.breakingChanges, config),
45 );
46 }
47
48 const byType = groupBy(rest, (c) => c.type);
49 for (const type of Object.keys(config.types)) {
50 const items = byType[type] || [];
51 lines.push(...renderSection(items, config.types[type].title, config));
52 }
53
54 if (!lines.length) lines.push("*No significant changes*");
55
56 const url = `https://${config.baseUrl}/${config.repo}/compare/${config.from}...${config.to}`;
57 lines.push(
58 "",
59 `##### &nbsp;&nbsp;&nbsp;&nbsp;[View changes on GitHub](${url})`,
60 );
61
62 return lines.join("\n").trim();
63}
64
65interface Node {
66 commits: Commit[];
67 children: Record<string, Node>;
68}
69
70function makeNode(): Node {
71 return { commits: [], children: {} };
72}
73
74function renderSection(
75 commits: Commit[],
76 sectionName: string,
77 config: RenderConfig,
78): string[] {
79 if (!commits.length) return [];
80 const out: string[] = ["", formatTitle(sectionName, config), ""];
81
82 // Build a tree keyed by the `/`-separated scope segments. Commits attach
83 // to the deepest node that matches their scope exactly.
84 const root = makeNode();
85 for (const commit of commits) {
86 const scope = (commit.scope ?? "").trim();
87 const mapped = config.scopeMap?.[scope] ?? scope;
88 const segments = mapped
89 ? mapped
90 .split("/")
91 .map((s) => s.trim())
92 .filter(Boolean)
93 : [];
94 let node = root;
95 for (const seg of segments) {
96 node.children[seg] ??= makeNode();
97 node = node.children[seg];
98 }
99 node.commits.push(commit);
100 }
101
102 out.push(...renderNode(root, 0, config));
103 return out;
104}
105
106function renderNode(node: Node, depth: number, config: RenderConfig): string[] {
107 const lines: string[] = [];
108 const pad = " ".repeat(depth);
109
110 // Reverse to match changelogithub's ordering: commits come out of git log
111 // newest-first, and the upstream renderer flips them so the oldest entry
112 // in the range appears at the top of each bucket.
113 for (const commit of [...node.commits].reverse()) {
114 lines.push(`${pad}- ${formatLine(commit, config)}`);
115 }
116
117 const childNames = Object.keys(node.children).sort();
118
119 // Match changelogithub's `group: true` default: within a given level, if
120 // ANY sibling scope has more than a single inline-able commit (either
121 // multiple commits or further sub-scopes), force every sibling to render
122 // with a header. This keeps visual alignment consistent and is what the
123 // existing CHANGELOG.md entries already use, so it avoids regressing
124 // historical release-notes style.
125 const forceHeaders = childNames.some((name) => {
126 const child = node.children[name];
127 return countCommits(child) > 1 || Object.keys(child.children).length > 0;
128 });
129
130 for (const name of childNames) {
131 const child = node.children[name];
132 const label = `**${name}**`;
133
134 const childCommitCount = countCommits(child);
135 const hasGrandchildren = Object.keys(child.children).length > 0;
136
137 // Collapse inline only when NO sibling wants a header and this child is
138 // a single-commit leaf. Otherwise emit the header + nested bullets.
139 if (!forceHeaders && childCommitCount === 1 && !hasGrandchildren) {
140 const [commit] = child.commits;
141 lines.push(`${pad}- ${label}: ${formatLine(commit, config)}`);
142 continue;
143 }
144
145 lines.push(`${pad}- ${label}:`);
146 lines.push(...renderNode(child, depth + 1, config));
147 }
148
149 return lines;
150}
151
152function countCommits(node: Node): number {
153 let n = node.commits.length;
154 for (const child of Object.values(node.children)) n += countCommits(child);
155 return n;
156}
157
158function formatTitle(name: string, config: RenderConfig): string {
159 if (!config.emoji) name = name.replace(emojisRE, "");
160 return `### &nbsp;&nbsp;&nbsp;${name.trim()}`;
161}
162
163function formatLine(commit: Commit, config: RenderConfig): string {
164 const prRefs = formatReferences(commit.references, config, "issues");
165 const hashRefs = formatReferences(commit.references, config, "hash");
166 const authorNames = [
167 ...new Set(
168 (commit.resolvedAuthors ?? []).map((a) =>
169 a.login ? `@${a.login}` : `**${a.name}**`,
170 ),
171 ),
172 ];
173 const authors = joinWithAnd(authorNames).trim();
174 const authorStr = authors ? `by ${authors}` : "";
175 let refs = [authorStr, prRefs, hashRefs]
176 .filter((s) => s && s.trim())
177 .join(" ");
178 if (refs) refs = `&nbsp;-&nbsp; ${refs}`;
179 const description = config.capitalize
180 ? capitalize(commit.description)
181 : commit.description;
182 return [description, refs].filter((s) => s && s.trim()).join(" ");
183}
184
185function formatReferences(
186 references: Commit["references"],
187 config: RenderConfig,
188 kind: "issues" | "hash",
189): string {
190 const baseUrl = config.baseUrl;
191 const repo = config.repo;
192 const refs = references
193 .filter((r) =>
194 kind === "issues"
195 ? r.type === "issue" || r.type === "pull-request"
196 : r.type === "hash",
197 )
198 .map((ref) => {
199 if (!repo) return ref.value;
200 if (ref.type === "pull-request" || ref.type === "issue") {
201 return `https://${baseUrl}/${repo}/issues/${ref.value.slice(1)}`;
202 }
203 return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://${baseUrl}/${repo}/commit/${ref.value})`;
204 });
205 const joined = joinWithAnd(refs).trim();
206 if (kind === "issues") return joined ? `in ${joined}` : "";
207 return joined;
208}
209
210function capitalize(s: string): string {
211 return s.charAt(0).toUpperCase() + s.slice(1);
212}
213
214function joinWithAnd(
215 array: string[],
216 glue = ", ",
217 finalGlue = " and ",
218): string {
219 if (!array || array.length === 0) return "";
220 if (array.length === 1) return array[0];
221 if (array.length === 2) return array.join(finalGlue);
222 return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
223}
224
225function partition<T>(items: T[], pred: (item: T) => boolean): [T[], T[]] {
226 const yes: T[] = [];
227 const no: T[] = [];
228 for (const item of items) (pred(item) ? yes : no).push(item);
229 return [yes, no];
230}
231
232function groupBy<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
233 const out: Record<string, T[]> = {};
234 for (const item of items) {
235 const k = key(item);
236 (out[k] ??= []).push(item);
237 }
238 return out;
239}