From ef45b726adc44bdadbe83188817fbb290ec63659 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:36:57 +0300 Subject: [PATCH] fix(changelog): resolve embedded notes by semver core (#373) Whats New and the post-update modal required an exact ## [version] header, so prerelease package versions (rc, dev) often had no matching section. Match on major.minor.patch first; if several sections share the core, prefer a plain X.Y.Z heading when present. --- src/components/ChangelogModal.tsx | 11 +---- src/pages/WhatsNew.tsx | 13 ++---- src/utils/changelogReleaseMatch.ts | 68 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 src/utils/changelogReleaseMatch.ts diff --git a/src/components/ChangelogModal.tsx b/src/components/ChangelogModal.tsx index 892bb404..b53a3d76 100644 --- a/src/components/ChangelogModal.tsx +++ b/src/components/ChangelogModal.tsx @@ -5,6 +5,7 @@ import { Sparkles } from 'lucide-react'; import { version } from '../../package.json'; import changelogRaw from '../../CHANGELOG.md?raw'; import { useAuthStore } from '../store/authStore'; +import { findChangelogReleaseEntry } from '../utils/changelogReleaseMatch'; function renderInline(text: string): React.ReactNode[] { const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); @@ -29,15 +30,7 @@ export default function ChangelogModal({ onClose }: Props) { const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate); const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion); - const currentVersionData = useMemo(() => { - const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## [')); - const block = blocks.find((b: string) => b.startsWith(`## [${version}]`)); - if (!block) return null; - const lines = block.split('\n'); - const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/); - const body = lines.slice(1).join('\n').trim(); - return { version: match?.[1] ?? version, date: match?.[2] ?? '', body }; - }, []); + const currentVersionData = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []); const handleClose = () => { if (dontShow) setShowChangelogOnUpdate(false); diff --git a/src/pages/WhatsNew.tsx b/src/pages/WhatsNew.tsx index 33204e2d..9bdda429 100644 --- a/src/pages/WhatsNew.tsx +++ b/src/pages/WhatsNew.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { version } from '../../package.json'; import changelogRaw from '../../CHANGELOG.md?raw'; +import { findChangelogReleaseEntry } from '../utils/changelogReleaseMatch'; import { renderChangelogBody } from '../utils/changelogMarkdown'; export default function WhatsNew() { @@ -15,15 +16,7 @@ export default function WhatsNew() { else navigate('/'); }; - const entry = useMemo(() => { - const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## [')); - const block = blocks.find((b: string) => b.startsWith(`## [${version}]`)); - if (!block) return null; - const lines = block.split('\n'); - const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/); - const body = lines.slice(1).join('\n').trim(); - return { version: match?.[1] ?? version, date: match?.[2] ?? '', body }; - }, []); + const entry = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []); return (
@@ -33,7 +26,7 @@ export default function WhatsNew() {

{t('whatsNew.title')}

- v{entry?.version ?? version} + v{version} {entry?.date && ยท {entry.date}}
diff --git a/src/utils/changelogReleaseMatch.ts b/src/utils/changelogReleaseMatch.ts new file mode 100644 index 00000000..91863498 --- /dev/null +++ b/src/utils/changelogReleaseMatch.ts @@ -0,0 +1,68 @@ +/** + * Match CHANGELOG ## [version] sections to the app version from package.json. + * Exact match first; otherwise same semver major.minor.patch (ignores -rc, -dev, etc.). + */ + +const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i; + +export function changelogVersionCore(version: string): string | null { + const m = version.trim().match(SEMVER_CORE); + return m ? m[1] : null; +} + +function isPlainSemverTriple(header: string): boolean { + return /^\d+\.\d+\.\d+$/.test(header.trim()); +} + +export function splitChangelogBlocks(changelogRaw: string): string[] { + return changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## [')); +} + +export interface ChangelogReleaseEntry { + headerVersion: string; + date: string; + body: string; +} + +function parseBlock(block: string): ChangelogReleaseEntry | null { + const lines = block.split('\n'); + const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/); + if (!m) return null; + return { + headerVersion: m[1], + date: (m[2] ?? '').trim(), + body: lines.slice(1).join('\n').trim(), + }; +} + +function headerVersionFromBlock(block: string): string | null { + const m = block.match(/^## \[([^\]]+)\]/); + return m ? m[1] : null; +} + +export function findChangelogReleaseEntry( + changelogRaw: string, + appVersion: string, +): ChangelogReleaseEntry | null { + const blocks = splitChangelogBlocks(changelogRaw); + + const exact = blocks.find((b: string) => b.startsWith(`## [${appVersion}]`)); + if (exact) return parseBlock(exact); + + const appCore = changelogVersionCore(appVersion); + if (!appCore) return null; + + const candidates = blocks.filter((b: string) => { + const hv = headerVersionFromBlock(b); + if (!hv) return false; + return changelogVersionCore(hv) === appCore; + }); + if (candidates.length === 0) return null; + + const plain = candidates.find((b: string) => { + const hv = headerVersionFromBlock(b); + return hv !== null && isPlainSemverTriple(hv); + }); + const chosen = plain ?? candidates[0]; + return parseBlock(chosen); +}