Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 120 additions & 24 deletions src/main/services/ChangelogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ function firstString(...values: Array<unknown>): 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/;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unintentional character-class range in ISO_DATE_REGEX

The character class [0-9:.+-Z] contains an unintended ASCII range. In a regex character class, a bare - between two characters creates a range. Here + is ASCII 43 and Z is ASCII 90, so +-Z matches all characters from ASCII 43–90 — including ,, /, ;, <, =, >, ?, @, and all uppercase letters A–Y. The intent is clearly to match ISO 8601 time-segment characters (digits, colon, period, plus, hyphen, and the literal Z). The hyphen must either be escaped or placed at the start/end of the class to be treated as a literal.

Suggested change
const ISO_DATE_REGEX = /\b(\d{4}-\d{2}-\d{2}(?:[tT][0-9:.+-Z]*)?)\b/;
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(/&nbsp;/gi, ' ')
Expand Down Expand Up @@ -237,6 +289,15 @@ async function fetchJson(url: string): Promise<unknown | null> {
return response.json();
}

async function fetchHtml(url: string): Promise<string | null> {
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(/<time\b[^>]*datetime=(["'])(.*?)\1/i)?.[2];
if (datetime?.trim()) return datetime.trim();
Expand All @@ -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[] = [];
Expand All @@ -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
);
Expand All @@ -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<ChangelogEntry | null> {
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<ChangelogEntry | null> {
const version = normalizeChangelogVersion(requestedVersion);
const apiUrls = [
Expand All @@ -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);
}
}

Expand Down
24 changes: 8 additions & 16 deletions src/renderer/components/ChangelogModal.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,18 +14,6 @@ export function ChangelogModalOverlay({ entry }: BaseModalProps<void> & Changelo
return <ChangelogModal entry={entry} />;
}

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*/, '')
Expand Down Expand Up @@ -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 (
Expand All @@ -83,9 +73,11 @@ function ChangelogModal({ entry }: ChangelogModalProps): JSX.Element {

<div className="max-h-[min(75vh,44rem)] overflow-y-auto px-6 py-5">
{publishedAt && (
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{publishedAt}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Badge variant="outline" className="h-5 px-2 text-[11px] font-medium">
{publishedAt}
</Badge>
</div>
)}
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
{entry.title}
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/components/sidebar/ChangelogNotificationCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +18,8 @@ export function ChangelogNotificationCard({
onDismiss,
className,
}: ChangelogNotificationCardProps) {
const publishedAt = formatChangelogPublishedAt(entry.publishedAt);

return (
<motion.div
whileTap={{ scale: 0.97 }}
Expand All @@ -30,6 +34,11 @@ export function ChangelogNotificationCard({
className="flex w-full flex-col gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/30"
>
<div className="pr-8">
{publishedAt && (
<Badge variant="outline" className="mb-2 h-5 px-2 text-[11px] font-medium">
{publishedAt}
</Badge>
)}
<h3 className="line-clamp-2 text-sm font-semibold leading-5 text-foreground">
{entry.title}
</h3>
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/lib/changelogDate.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +4 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISO date-only strings parsed as UTC midnight, causing off-by-one day for users in negative-offset timezones

When value is an ISO date-only string such as "2026-03-13" (without a time component), the ECMAScript specification mandates that new Date("2026-03-13") is parsed as UTC midnight. Intl.DateTimeFormat then formats this in the user's local timezone. For any user whose timezone is behind UTC (UTC-1 through UTC-12, covering all of the Americas), the displayed date will be one day earlier — March 12 instead of March 13. This is a user-visible regression that will affect the badge in both the changelog modal and the sidebar notification card.

This PR introduces new paths that can write ISO date-only strings into publishedAt via ISO_DATE_REGEX (e.g. an HTML heading like "2026-03-13 v0.4.32" extracts "2026-03-13"). The same bug pre-existed for <time datetime="2026-03-13">, but this PR widens the surface.

To prevent the timezone shift, parse date-only strings explicitly as local midnight instead of UTC:

Suggested change
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T00:00:00`)
: new Date(value);

Adding a local-time suffix (T00:00:00, no Z) causes the engine to parse the date in local time, so the rendered day always matches what was authored.


return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
}).format(parsed);
}
36 changes: 36 additions & 0 deletions src/test/main/ChangelogService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<main>
<article data-version="0.4.32">
<h2>March 13, 2026 v0.4.32</h2>
<p>Added a changelog card in the sidebar.</p>
</article>
</main>
`;

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 = `
<main>
<article data-version="0.4.31">
<h2>March 12, 2026 v0.4.31</h2>
<p>Previous release.</p>
</article>
<article data-version="0.4.32">
<h2>What&apos;s new in Emdash v0.4.32</h2>
<p>Current release.</p>
</article>
</main>
`;

const entry = parseChangelogHtml(htmlWithOtherDate, '0.4.32');

expect(entry?.version).toBe('0.4.32');
expect(entry?.publishedAt).toBeUndefined();
});
});
Loading