1import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts";
2
3import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
4
5interface ElectronReleaseNoteInfo {
6 readonly version: string;
7 readonly note: string | null | undefined;
8}
9
10function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo {
11 if (typeof value !== "object" || value === null) return false;
12 const candidate = value as { readonly version?: unknown; readonly note?: unknown };
13 return (
14 typeof candidate.version === "string" &&
15 (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined)
16 );
17}
18
19const MAX_RELEASE_NOTE_GROUPS = 6;
20const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8;
21const MAX_RELEASE_NOTE_ITEM_LENGTH = 220;
22
23const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
24 amp: "&",
25 apos: "'",
26 gt: ">",
27 lt: "<",
28 nbsp: " ",
29 quot: '"',
30};
31
32function decodeCodePoint(codePoint: number, entity: string): string {
33 // String.fromCodePoint throws RangeError outside the valid Unicode range, and
34 // Number.isFinite alone lets oversized values (e.g. &#9999999999;) through.
35 if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
36 return `&${entity};`;
37 }
38 return String.fromCodePoint(codePoint);
39}
40
41function decodeHtmlEntity(entity: string): string {
42 const named = HTML_ENTITY_REPLACEMENTS[entity];
43 if (named) return named;
44 if (entity.startsWith("#x")) {
45 return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity);
46 }
47 if (entity.startsWith("#")) {
48 return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity);
49 }
50 return `&${entity};`;
51}
52
53function decodeHtmlEntities(input: string): string {
54 return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) =>
55 decodeHtmlEntity(entity),
56 );
57}
58
59function stripMarkup(input: string): string {
60 return decodeHtmlEntities(
61 input
62 .replace(/<br\s*\/?>/gi, "\n")
63 .replace(/<li\b[^>]*>/gi, "\n- ")
64 .replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `)
65 .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n")
66 .replace(/<[^>]*>/g, "")
67 .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
68 .replace(/\*\*([^*]+)\*\*/g, "$1"),
69 );
70}
71
72function truncateReleaseNoteItem(item: string): string {
73 if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item;
74 return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`;
75}
76
77function normalizeReleaseNoteLine(line: string): string {
78 return line
79 .toLowerCase()
80 .replace(/[*_`#]/g, "")
81 .trim();
82}
83
84function isIgnoredReleaseNoteLine(line: string): boolean {
85 const normalized = normalizeReleaseNoteLine(line);
86 return (
87 normalized === "" ||
88 normalized === "what's changed" ||
89 normalized === "whats changed" ||
90 normalized.startsWith("compare: ") ||
91 normalized.includes("/compare/")
92 );
93}
94
95interface ExtractedReleaseNoteItems {
96 readonly items: ReadonlyArray<string>;
97 readonly totalItems: number;
98}
99
100function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems {
101 if (!note) return { items: [], totalItems: 0 };
102
103 const items: string[] = [];
104 let totalItems = 0;
105 for (const rawLine of stripMarkup(note).split("\n")) {
106 const item = rawLine
107 .trim()
108 .replace(/^[-*]\s+/, "")
109 .replace(/^\d+[.)]\s+/, "")
110 .replace(/\s+/g, " ");
111 const normalized = normalizeReleaseNoteLine(item);
112 if (normalized === "new contributors" || normalized === "full changelog") break;
113 if (/^#{1,6}\s+/.test(item)) continue;
114 if (isIgnoredReleaseNoteLine(item)) continue;
115 totalItems += 1;
116 items.push(truncateReleaseNoteItem(item));
117 if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift();
118 }
119 return { items: items.toReversed(), totalItems };
120}
121
122interface NormalizedDesktopUpdateReleaseNotes {
123 readonly releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>;
124 readonly omittedReleaseCount: number;
125}
126
127/**
128 * Turns electron-updater's release notes into the groups the popover shows.
129 * With `fullChangelog` on (nightly), electron-updater collects every GitHub
130 * release whose version is semver-greater than the running one, whatever
131 * train it belongs to; a maintainers' `-preview.` cut sorts above every
132 * `-nightly.` of the same base version and would lead the list. Only
133 * releases on the channel being followed are kept, the same test the
134 * updater applies to the offered version itself.
135 */
136export function normalizeDesktopUpdateReleaseNotes(
137 releaseNotes: unknown,
138 fallbackVersion: string,
139 channel: DesktopUpdateChannel,
140): NormalizedDesktopUpdateReleaseNotes {
141 const rawNotes = (
142 typeof releaseNotes === "string"
143 ? [{ version: fallbackVersion, note: releaseNotes }]
144 : Array.isArray(releaseNotes)
145 ? releaseNotes.filter(isElectronReleaseNoteInfo)
146 : []
147 ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel);
148
149 const normalizedNotes = rawNotes.flatMap((entry) => {
150 const { items, totalItems } = extractReleaseNoteItems(entry.note);
151 if (totalItems === 0) return [];
152 return [
153 {
154 version: entry.version,
155 items,
156 totalItems,
157 },
158 ];
159 });
160
161 return {
162 releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS),
163 omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS),
164 };
165}