| 1 | import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; |
| 2 | |
| 3 | import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; |
| 4 | |
| 5 | interface ElectronReleaseNoteInfo { |
| 6 | readonly version: string; |
| 7 | readonly note: string | null | undefined; |
| 8 | } |
| 9 | |
| 10 | function 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 | |
| 19 | const MAX_RELEASE_NOTE_GROUPS = 6; |
| 20 | const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; |
| 21 | const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; |
| 22 | |
| 23 | const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = { |
| 24 | amp: "&", |
| 25 | apos: "'", |
| 26 | gt: ">", |
| 27 | lt: "<", |
| 28 | nbsp: " ", |
| 29 | quot: '"', |
| 30 | }; |
| 31 | |
| 32 | function 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 | |
| 41 | function 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 | |
| 53 | function 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 | |
| 59 | function 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 | |
| 72 | function 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 | |
| 77 | function normalizeReleaseNoteLine(line: string): string { |
| 78 | return line |
| 79 | .toLowerCase() |
| 80 | .replace(/[*_`#]/g, "") |
| 81 | .trim(); |
| 82 | } |
| 83 | |
| 84 | function 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 | |
| 95 | interface ExtractedReleaseNoteItems { |
| 96 | readonly items: ReadonlyArray<string>; |
| 97 | readonly totalItems: number; |
| 98 | } |
| 99 | |
| 100 | function 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 | |
| 122 | interface 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 | */ |
| 136 | export 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 | } |