1import { cn } from "~/lib/utils";
2
3export const ISSUE_PREFIX = /^\[[A-Z][A-Z0-9]*-\d+(?:,\s*[A-Z][A-Z0-9]*-\d+)*\]\s*/;
4export const CONVENTIONAL_TYPE = /^([A-Za-z]+)(\([^()]*\))?!?:/;
5
6export type CommitTitleTone = "add" | "fix" | "other" | "wip";
7
8/** The same tone table as Mergebot's commit-title.ts, so both UIs color a title alike. */
9export function commitTypeTone(type: string): CommitTitleTone {
10 switch (type.toLowerCase()) {
11 case "wip":
12 return "wip";
13 case "feat":
14 case "feature":
15 case "aura":
16 case "infra":
17 return "add";
18 case "fix":
19 case "hotfix":
20 case "hotpatch":
21 return "fix";
22 default:
23 return "other";
24 }
25}
26
27export interface ParsedCommitTitle {
28 readonly issues: readonly string[];
29 /** Offsets into the title; `prefix` covers `type(scope)!:`. */
30 readonly prefix: {
31 readonly from: number;
32 readonly to: number;
33 readonly tone: CommitTitleTone;
34 } | null;
35 readonly scope: { readonly from: number; readonly to: number } | null;
36 readonly restFrom: number;
37}
38
39export function parseCommitTitle(title: string): ParsedCommitTitle {
40 const issueMatch = ISSUE_PREFIX.exec(title);
41 const typeStart = issueMatch ? issueMatch[0].length : 0;
42 const issues = issueMatch ? (issueMatch[0].match(/[A-Z][A-Z0-9]*-\d+/g) ?? []) : [];
43 const conventional = CONVENTIONAL_TYPE.exec(title.slice(typeStart));
44 if (!conventional) return { issues, prefix: null, scope: null, restFrom: typeStart };
45 const [prefix, type = "", scope] = conventional;
46 const tone = commitTypeTone(type);
47 return {
48 issues,
49 prefix: { from: typeStart, to: typeStart + prefix.length, tone },
50 scope:
51 scope && tone !== "wip"
52 ? { from: typeStart + type.length, to: typeStart + type.length + scope.length }
53 : null,
54 restFrom: typeStart + prefix.length,
55 };
56}
57
58const TONE_CLASS: Record<CommitTitleTone, string> = {
59 add: "text-success-foreground",
60 fix: "text-error-foreground",
61 other: "text-info-foreground",
62 wip: "rounded-[3px] bg-warning px-0.5 text-background",
63};
64
65/** A commit title with its issue keys dimmed and its conventional prefix colored. */
66export function CommitTitle({ title, className }: { title: string; className?: string }) {
67 const parsed = parseCommitTitle(title);
68 const { prefix, scope } = parsed;
69 return (
70 <span className={cn("truncate", className)}>
71 {parsed.issues.length > 0 ? (
72 <span className="me-1 text-muted-foreground">{parsed.issues.join(", ")}</span>
73 ) : null}
74 {prefix ? (
75 <span className={TONE_CLASS[prefix.tone]}>
76 {scope ? (
77 <>
78 {title.slice(prefix.from, scope.from)}
79 <span className="opacity-65">{title.slice(scope.from, scope.to)}</span>
80 {title.slice(scope.to, prefix.to)}
81 </>
82 ) : (
83 title.slice(prefix.from, prefix.to)
84 )}
85 </span>
86 ) : null}
87 {title.slice(parsed.restFrom)}
88 </span>
89 );
90}