diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index 2ab16c9f..61d0d61b 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -45,6 +45,66 @@ type UpdateChannel = 'latest' | 'beta'; // Store interval ID for cleanup during shutdown let periodicCheckIntervalId: ReturnType | null = null; +/** + * Convert basic HTML (from GitHub release bodies) to markdown. + * Handles the common tags GitHub uses in release notes. + */ +function htmlToMarkdown(html: string): string { + let md = html; + + // Block-level replacements + md = md.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); + md = md.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); + md = md.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); + md = md.replace(/]*>(.*?)<\/h4>/gi, '#### $1\n\n'); + + // Lists: convert
    /
      with
    • items + // First handle
    • within
        (numbered) + md = md.replace(/]*>([\s\S]*?)<\/ol>/gi, (_match, content: string) => { + let i = 0; + return content.replace(/]*>([\s\S]*?)<\/li>/gi, (_m: string, text: string) => { + i++; + return `${i}. ${text.trim()}\n`; + }) + '\n'; + }); + // Then
      1. within
          (bulleted) + md = md.replace(/]*>([\s\S]*?)<\/ul>/gi, (_match, content: string) => { + return content.replace(/]*>([\s\S]*?)<\/li>/gi, (_m: string, text: string) => { + return `- ${text.trim()}\n`; + }) + '\n'; + }); + + // Inline replacements + md = md.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); + md = md.replace(/]*>(.*?)<\/b>/gi, '**$1**'); + md = md.replace(/]*>(.*?)<\/em>/gi, '*$1*'); + md = md.replace(/]*>(.*?)<\/i>/gi, '*$1*'); + md = md.replace(/]*>(.*?)<\/code>/gi, '`$1`'); + md = md.replace(/]*>(.*?)<\/tt>/gi, '`$1`'); + md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + + // Block elements + md = md.replace(/]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); + md = md.replace(//gi, '\n'); + md = md.replace(//gi, '---\n\n'); + + // Remove any remaining HTML tags + md = md.replace(/<[^>]+>/g, ''); + + // Decode common HTML entities + md = md.replace(/&/g, '&'); + md = md.replace(/</g, '<'); + md = md.replace(/>/g, '>'); + md = md.replace(/"/g, '"'); + md = md.replace(/'/g, "'"); + md = md.replace(/ /g, ' '); + + // Clean up excessive whitespace + md = md.replace(/\n{3,}/g, '\n\n'); + + return md.trim(); +} + /** * Convert releaseNotes from electron-updater to a markdown string. * releaseNotes can be: @@ -57,8 +117,12 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | return undefined; } - // If it's already a string, return as-is + // If it's a string, convert HTML to markdown if needed + // electron-updater returns GitHub release bodies as HTML if (typeof releaseNotes === 'string') { + if (releaseNotes.trimStart().startsWith('<')) { + return htmlToMarkdown(releaseNotes); + } return releaseNotes; } @@ -74,8 +138,12 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | .filter(item => item.note) // Filter out entries with null/undefined notes .map(item => { // Each item has version and note properties + // note can be HTML (GitHub provider) so convert if needed const versionHeader = item.version ? `## ${item.version}\n` : ''; - return `${versionHeader}${item.note}`; + const note = typeof item.note === 'string' && item.note.trimStart().startsWith('<') + ? htmlToMarkdown(item.note) + : item.note; + return `${versionHeader}${note}`; }) .join('\n\n');