import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; interface ElectronReleaseNoteInfo { readonly version: string; readonly note: string | null | undefined; } function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { if (typeof value !== "object" || value === null) return false; const candidate = value as { readonly version?: unknown; readonly note?: unknown }; return ( typeof candidate.version === "string" && (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) ); } const MAX_RELEASE_NOTE_GROUPS = 6; const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; const HTML_ENTITY_REPLACEMENTS: Readonly> = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"', }; function decodeCodePoint(codePoint: number, entity: string): string { // String.fromCodePoint throws RangeError outside the valid Unicode range, and // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } return String.fromCodePoint(codePoint); } function decodeHtmlEntity(entity: string): string { const named = HTML_ENTITY_REPLACEMENTS[entity]; if (named) return named; if (entity.startsWith("#x")) { return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); } if (entity.startsWith("#")) { return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); } return `&${entity};`; } function decodeHtmlEntities(input: string): string { return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => decodeHtmlEntity(entity), ); } function stripMarkup(input: string): string { return decodeHtmlEntities( input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") .replace(/<[^>]*>/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1"), ); } function truncateReleaseNoteItem(item: string): string { if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; } function isIgnoredReleaseNoteLine(line: string): boolean { const normalized = line .toLowerCase() .replace(/[*_`#]/g, "") .trim(); return ( normalized === "" || normalized === "what's changed" || normalized === "whats changed" || normalized === "full changelog" || normalized === "new contributors" || normalized.startsWith("compare: ") || normalized.includes("/compare/") ); } function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { if (!note) return []; const items: string[] = []; for (const rawLine of stripMarkup(note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") .replace(/^\d+[.)]\s+/, "") .replace(/\s+/g, " "); if (isIgnoredReleaseNoteLine(item)) continue; items.push(truncateReleaseNoteItem(item)); if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; } return items; } export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, ): ReadonlyArray { const rawNotes = typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : []; return rawNotes .map((entry) => ({ version: entry.version, items: extractReleaseNoteItems(entry.note), })) .filter((entry) => entry.items.length > 0) .slice(0, MAX_RELEASE_NOTE_GROUPS); }