From fa7747abe02a0bee6412b022b0e4f50872e4db9b Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:07:42 -0700 Subject: [PATCH] feat(changelog): extract and display published date for changelog entries --- src/main/services/ChangelogService.ts | 144 +++++++++++++++--- src/renderer/components/ChangelogModal.tsx | 24 +-- .../sidebar/ChangelogNotificationCard.tsx | 9 ++ src/renderer/lib/changelogDate.ts | 12 ++ src/test/main/ChangelogService.test.ts | 36 +++++ 5 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 src/renderer/lib/changelogDate.ts diff --git a/src/main/services/ChangelogService.ts b/src/main/services/ChangelogService.ts index 42545f862..287eb1ddb 100644 --- a/src/main/services/ChangelogService.ts +++ b/src/main/services/ChangelogService.ts @@ -32,6 +32,58 @@ function firstString(...values: Array): string | undefined { return undefined; } +const MONTH_NAME_PATTERN = + '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)'; +const HUMAN_DATE_REGEX = new RegExp(`\\b(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})\\b`, 'i'); +const ISO_DATE_REGEX = /\b(\d{4}-\d{2}-\d{2}(?:[tT][0-9:.+-Z]*)?)\b/; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractPublishedAtFromText(value: string): string | undefined { + const normalized = stripTags(value).replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + + const humanDate = normalized.match(HUMAN_DATE_REGEX)?.[1]; + if (humanDate) return humanDate; + + const isoDate = normalized.match(ISO_DATE_REGEX)?.[1]; + if (isoDate) return isoDate; + + return undefined; +} + +function extractPublishedAtForVersion(value: string, version?: string): string | undefined { + if (!version) return extractPublishedAtFromText(value); + + const normalized = stripTags(value).replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + + const escapedVersion = escapeRegex(version); + const leadingDate = normalized.match( + new RegExp(`(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})\\s+v?${escapedVersion}\\b`, 'i') + )?.[1]; + if (leadingDate) return leadingDate; + + const trailingDate = normalized.match( + new RegExp(`v?${escapedVersion}\\b\\s+(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})`, 'i') + )?.[1]; + if (trailingDate) return trailingDate; + + const leadingIsoDate = normalized.match( + new RegExp(`(\\d{4}-\\d{2}-\\d{2}(?:[tT][0-9:.+-Z]*)?)\\s+v?${escapedVersion}\\b`, 'i') + )?.[1]; + if (leadingIsoDate) return leadingIsoDate; + + const trailingIsoDate = normalized.match( + new RegExp(`v?${escapedVersion}\\b\\s+(\\d{4}-\\d{2}-\\d{2}(?:[tT][0-9:.+-Z]*)?)`, 'i') + )?.[1]; + if (trailingIsoDate) return trailingIsoDate; + + return undefined; +} + function decodeHtmlEntities(input: string): string { return input .replace(/ /gi, ' ') @@ -237,6 +289,15 @@ async function fetchJson(url: string): Promise { return response.json(); } +async function fetchHtml(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'text/html,application/xhtml+xml', 'Cache-Control': 'no-cache' }, + }); + + if (!response.ok) return null; + return response.text(); +} + function extractTime(block: string): string | undefined { const datetime = block.match(/]*datetime=(["'])(.*?)\1/i)?.[2]; if (datetime?.trim()) return datetime.trim(); @@ -258,6 +319,18 @@ function extractSummary(block: string): string | undefined { return summary || undefined; } +function withResolvedHtmlPublishedAt( + entry: ChangelogEntry | null, + html: string, + requestedVersion?: string +): ChangelogEntry | null { + if (!entry) return null; + if (entry.publishedAt) return entry; + + const publishedAt = extractPublishedAtForVersion(html, requestedVersion ?? entry.version); + return publishedAt ? { ...entry, publishedAt } : entry; +} + export function parseChangelogHtml(html: string, requestedVersion?: string): ChangelogEntry | null { const blocks = html.match(/<(article|section)\b[\s\S]*?<\/\1>/gi) ?? []; const candidates: ChangelogEntry[] = []; @@ -277,7 +350,7 @@ export function parseChangelogHtml(html: string, requestedVersion?: string): Cha title: extractTitle(block), summary: extractSummary(block), contentHtml: block, - publishedAt: extractTime(block), + publishedAt: extractTime(block) ?? extractPublishedAtForVersion(block, versionFromBlock), }, requestedVersion ); @@ -288,24 +361,46 @@ export function parseChangelogHtml(html: string, requestedVersion?: string): Cha } if (candidates.length > 0) { - return pickBestCandidate(candidates, requestedVersion); + return withResolvedHtmlPublishedAt( + pickBestCandidate(candidates, requestedVersion), + html, + requestedVersion + ); } - const fallback = normalizeEntry( - { - version: normalizeChangelogVersion(requestedVersion) ?? undefined, - title: extractTitle(html), - summary: extractSummary(html), - contentHtml: html, - publishedAt: extractTime(html), - }, + return withResolvedHtmlPublishedAt( + normalizeEntry( + { + version: normalizeChangelogVersion(requestedVersion) ?? undefined, + title: extractTitle(html), + summary: extractSummary(html), + contentHtml: html, + publishedAt: + extractTime(html) ?? + extractPublishedAtForVersion( + html, + normalizeChangelogVersion(requestedVersion) ?? undefined + ), + }, + requestedVersion + ), + html, requestedVersion ); - - return fallback; } class ChangelogService { + private async getHtmlEntry(requestedVersion?: string): Promise { + try { + const html = await fetchHtml(EMDASH_CHANGELOG_URL); + if (!html) return null; + return parseChangelogHtml(html, requestedVersion); + } catch (error) { + log.error('Failed to fetch changelog HTML', error); + return null; + } + } + async getLatestEntry(requestedVersion?: string): Promise { const version = normalizeChangelogVersion(requestedVersion); const apiUrls = [ @@ -326,23 +421,24 @@ class ChangelogService { .map((candidate) => normalizeEntry(candidate, version ?? undefined)) .filter((candidate): candidate is ChangelogEntry => candidate !== null); const match = pickBestCandidate(entries, version ?? undefined); - if (match) return match; + if (match) { + if (match.publishedAt) return match; + + const htmlEntry = await this.getHtmlEntry(match.version); + if (!htmlEntry) return match; + + return { + ...match, + publishedAt: htmlEntry.publishedAt ?? match.publishedAt, + url: match.url ?? htmlEntry.url, + }; + } } catch (error) { log.debug('Changelog JSON fetch failed', { url, error }); } } - try { - const response = await fetch(EMDASH_CHANGELOG_URL, { - headers: { Accept: 'text/html,application/xhtml+xml', 'Cache-Control': 'no-cache' }, - }); - if (!response.ok) return null; - const html = await response.text(); - return parseChangelogHtml(html, version ?? undefined); - } catch (error) { - log.error('Failed to fetch changelog HTML', error); - return null; - } + return this.getHtmlEntry(version ?? undefined); } } diff --git a/src/renderer/components/ChangelogModal.tsx b/src/renderer/components/ChangelogModal.tsx index 91a533065..292258f17 100644 --- a/src/renderer/components/ChangelogModal.tsx +++ b/src/renderer/components/ChangelogModal.tsx @@ -1,6 +1,8 @@ +import { Badge } from '@/components/ui/badge'; import { BaseModalProps } from '@/contexts/ModalProvider'; import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; import { DialogContent } from '@/components/ui/dialog'; +import { formatChangelogPublishedAt } from '@/lib/changelogDate'; import { EMDASH_CHANGELOG_URL, type ChangelogEntry } from '@shared/changelog'; import { ExternalLink } from 'lucide-react'; @@ -12,18 +14,6 @@ export function ChangelogModalOverlay({ entry }: BaseModalProps & Changelo return ; } -function formatPublishedAt(value?: string): string | null { - if (!value) return null; - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return value; - - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', - }).format(parsed); -} - function normalizeLeadLine(value: string): string { return value .replace(/^#+\s*/, '') @@ -66,7 +56,7 @@ function stripLeadingReleaseHeadings(content: string, entry: ChangelogEntry): st } function ChangelogModal({ entry }: ChangelogModalProps): JSX.Element { - const publishedAt = formatPublishedAt(entry.publishedAt); + const publishedAt = formatChangelogPublishedAt(entry.publishedAt); const content = stripLeadingReleaseHeadings(entry.content, entry); return ( @@ -83,9 +73,11 @@ function ChangelogModal({ entry }: ChangelogModalProps): JSX.Element {
{publishedAt && ( -

- {publishedAt} -

+
+ + {publishedAt} + +
)}

{entry.title} diff --git a/src/renderer/components/sidebar/ChangelogNotificationCard.tsx b/src/renderer/components/sidebar/ChangelogNotificationCard.tsx index 4a676544c..931772e9b 100644 --- a/src/renderer/components/sidebar/ChangelogNotificationCard.tsx +++ b/src/renderer/components/sidebar/ChangelogNotificationCard.tsx @@ -1,3 +1,5 @@ +import { Badge } from '@/components/ui/badge'; +import { formatChangelogPublishedAt } from '@/lib/changelogDate'; import { cn } from '@/lib/utils'; import { motion } from 'framer-motion'; import type { ChangelogEntry } from '@shared/changelog'; @@ -16,6 +18,8 @@ export function ChangelogNotificationCard({ onDismiss, className, }: ChangelogNotificationCardProps) { + const publishedAt = formatChangelogPublishedAt(entry.publishedAt); + return (
+ {publishedAt && ( + + {publishedAt} + + )}

{entry.title}

diff --git a/src/renderer/lib/changelogDate.ts b/src/renderer/lib/changelogDate.ts new file mode 100644 index 000000000..e98d2845c --- /dev/null +++ b/src/renderer/lib/changelogDate.ts @@ -0,0 +1,12 @@ +export function formatChangelogPublishedAt(value?: string): string | null { + if (!value) return null; + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(parsed); +} diff --git a/src/test/main/ChangelogService.test.ts b/src/test/main/ChangelogService.test.ts index a091c78c8..64e4929db 100644 --- a/src/test/main/ChangelogService.test.ts +++ b/src/test/main/ChangelogService.test.ts @@ -62,4 +62,40 @@ describe('parseChangelogHtml', () => { expect(entry?.content).toContain('## Sidebar card'); expect(entry?.content).toContain('- Dismiss per version'); }); + + it('infers the published date from rendered content when no time tag exists', () => { + const htmlWithoutTime = ` +
+
+

March 13, 2026 v0.4.32

+

Added a changelog card in the sidebar.

+
+
+ `; + + const entry = parseChangelogHtml(htmlWithoutTime, '0.4.32'); + + expect(entry?.version).toBe('0.4.32'); + expect(entry?.publishedAt).toBe('March 13, 2026'); + }); + + it('does not assign another release date when the requested version has no matching date text', () => { + const htmlWithOtherDate = ` +
+
+

March 12, 2026 v0.4.31

+

Previous release.

+
+
+

What's new in Emdash v0.4.32

+

Current release.

+
+
+ `; + + const entry = parseChangelogHtml(htmlWithOtherDate, '0.4.32'); + + expect(entry?.version).toBe('0.4.32'); + expect(entry?.publishedAt).toBeUndefined(); + }); });