File size: 4,450 Bytes
f76c374 | 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 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | /**
* Tiny user-facing changelog builder. Takes a list of raw commit summaries,
* parses the Conventional Commits 1.0 header (`type(scope)!: subject`),
* filters internal noise (chore/ci/docs/...), and groups the rest into
* friendly buckets for end users (What's new, Fixed, Faster, Improved).
*
* Inlined (rather than depending on `conventional-commits-parser`) because
* that package's index re-exports a Node `stream` helper which won't load
* in the sandboxed Electron renderer, and its actual parse logic for the
* header is a small regex.
*/
import { capitalize } from '@/lib/text'
export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other'
export interface CommitGroup {
id: CommitGroupId
label: string
items: string[]
}
export interface ParsedCommit {
type: null | string
scope: null | string
breaking: boolean
subject: string
}
export interface CommitChangelogInput {
summary?: string
}
interface BuildOptions {
maxGroups?: number
maxPerGroup?: number
maxTotal?: number
}
const GROUP_META: Record<CommitGroupId, { label: string; order: number }> = {
new: { label: "What's new", order: 0 },
fixed: { label: 'Fixed', order: 1 },
faster: { label: 'Faster', order: 2 },
improved: { label: 'Improved', order: 3 },
other: { label: 'Other improvements', order: 4 }
}
const TYPE_TO_GROUP: Record<string, CommitGroupId> = {
feat: 'new',
feature: 'new',
fix: 'fixed',
bugfix: 'fixed',
hotfix: 'fixed',
revert: 'fixed',
perf: 'faster',
performance: 'faster',
refactor: 'improved',
a11y: 'improved',
ui: 'improved',
ux: 'improved'
}
const HIDDEN_TYPES = new Set([
'build',
'chore',
'ci',
'dep',
'deps',
'doc',
'docs',
'lint',
'release',
'style',
'test',
'tests',
'wip'
])
const FALLBACK_GROUP: CommitGroup = { id: 'other', items: ['Improvements and fixes'], label: 'In this update' }
const CONVENTIONAL_HEADER = /^(?<type>[a-zA-Z][a-zA-Z0-9_-]*)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$/
/** Parse a single commit header line per Conventional Commits 1.0. */
export function parseCommitHeader(raw: string): ParsedCommit {
const header = (raw ?? '').split(/\r?\n/, 1)[0].trim()
if (!header) {
return { breaking: false, scope: null, subject: '', type: null }
}
const match = CONVENTIONAL_HEADER.exec(header)
if (!match?.groups) {
return { breaking: false, scope: null, subject: header, type: null }
}
return {
breaking: Boolean(match.groups.bang),
scope: match.groups.scope ?? null,
subject: match.groups.subject.trim(),
type: match.groups.type.toLowerCase()
}
}
function tidySubject(subject: string): string {
const cleaned = subject
.replace(/\s+/g, ' ')
.replace(/[.;,\s]+$/, '')
.trim()
if (!cleaned) {
return cleaned
}
return capitalize(cleaned)
}
/**
* Build a small grouped changelog from a list of raw commits.
* Always returns at least one group; falls back to a neutral placeholder
* when every commit was filtered or unparseable.
*/
export function buildCommitChangelog(
commits: readonly CommitChangelogInput[] | undefined,
options: BuildOptions = {}
): CommitGroup[] {
const { maxGroups = 3, maxPerGroup = 4, maxTotal = 6 } = options
const groups = new Map<CommitGroupId, string[]>()
const seen = new Set<string>()
let total = 0
for (const commit of commits ?? []) {
if (total >= maxTotal) {
break
}
const parsed = parseCommitHeader(commit.summary ?? '')
if (parsed.type && HIDDEN_TYPES.has(parsed.type)) {
continue
}
const groupId: CommitGroupId = parsed.type ? (TYPE_TO_GROUP[parsed.type] ?? 'other') : 'other'
const subject = tidySubject(parsed.subject)
if (!subject) {
continue
}
const dedupeKey = subject.toLowerCase()
if (seen.has(dedupeKey)) {
continue
}
const bucket = groups.get(groupId) ?? []
if (bucket.length >= maxPerGroup) {
continue
}
bucket.push(subject)
groups.set(groupId, bucket)
seen.add(dedupeKey)
total += 1
}
const result = Array.from(groups.entries())
.map(([id, items]) => ({ id, items, label: GROUP_META[id].label, order: GROUP_META[id].order }))
.sort((a, b) => a.order - b.order)
.slice(0, maxGroups)
.map(({ id, items, label }): CommitGroup => ({ id, items, label }))
if (result.length === 0) {
return [FALLBACK_GROUP]
}
return result
}
|