| 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 | */ |
| 14 | import type { ChangelogOptions, Commit } from "changelogithub"; |
| 15 | |
| 16 | export 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 | |
| 32 | const emojisRE = |
| 33 | /([\u2700-\u27BF\uE000-\uF8FF\u2011-\u26FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|\uD83E[\uDD10-\uDDFF])/g; |
| 34 | |
| 35 | export 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 | |
| 65 | interface Node { |
| 66 | commits: Commit[]; |
| 67 | children: Record<string, Node>; |
| 68 | } |
| 69 | |
| 70 | function makeNode(): Node { |
| 71 | return { commits: [], children: {} }; |
| 72 | } |
| 73 | |
| 74 | function 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 | |
| 106 | function 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 | |
| 152 | function 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 | |
| 158 | function formatTitle(name: string, config: RenderConfig): string { |
| 159 | if (!config.emoji) name = name.replace(emojisRE, ""); |
| 160 | return `### &nbsp;&nbsp;&nbsp;${name.trim()}`; |
| 161 | } |
| 162 | |
| 163 | function 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 | |
| 185 | function 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 | |
| 210 | function capitalize(s: string): string { |
| 211 | return s.charAt(0).toUpperCase() + s.slice(1); |
| 212 | } |
| 213 | |
| 214 | function 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 | |
| 225 | function 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 | |
| 232 | function 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 | } |