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