feat(whats-new): remote release notes with dev workspace mode (#1058)

* feat(whats-new): remote release notes with dev workspace mode

Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache
with embedded fallbacks. Dev and -dev builds read the full file from the
repo for debugging; RC/stable download the release asset on first use.

* fix(whats-new): render ## headings and add changelog tab

Parse h2 sections in release-notes markdown; load changelog alongside
highlights and let users switch views on the What's New page.

* fix(whats-new): prefetch on startup and fix CI typecheck prebuild

Prefetch whats-new asset when the shell loads on RC/stable builds.
Run prebuild:release-notes before tsc and coverage jobs so the
gitignored generated bundle exists in CI.

* docs: CHANGELOG and credits for What's New remote notes (PR #1058)

* fix(whats-new): always slice embedded release notes to current line

Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live
markdown from the repo. Ignore all of src/generated/ in git.

* fix(whats-new): fetch release asset via Rust to bypass CORS

Route whats-new.md download through fetch_url_bytes; rename the
technical tab label; add fetch unit tests (PR #1058 review).
This commit is contained in:
cucadmuh
2026-06-10 23:35:23 +03:00
committed by GitHub
parent fb5a257735
commit c7d71ea57c
38 changed files with 956 additions and 186 deletions
+2
View File
@@ -56,6 +56,7 @@ jobs:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- run: npm run prebuild:release-notes
- name: tsc
run: npx tsc --noEmit
@@ -71,6 +72,7 @@ jobs:
- name: install jq
run: sudo apt-get update && sudo apt-get install -y jq
- run: npm ci
- run: npm run prebuild:release-notes
- name: vitest run --coverage
run: npx vitest run --coverage
- name: hot-path file coverage gate
+28 -6
View File
@@ -176,18 +176,32 @@ jobs:
- name: extract changelog
id: changelog
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
fi
BODY=""
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
BODY="$(cat /tmp/changelog-body.md)"
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: extract what's new for release asset
id: whats-new
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
CHANNEL="${{ inputs.channel }}"
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
if [ "$CHANNEL" = "release" ]; then
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
exit 1
fi
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v9
@@ -237,6 +251,14 @@ jobs:
prerelease,
});
return data.id;
- name: upload whats-new.md release asset
if: steps.whats-new.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.tag.outputs.value }}"
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
build-macos-windows:
if: ${{ inputs.build_platform_artifacts }}
+3
View File
@@ -37,6 +37,9 @@ src-tauri/lcov.info
# Frontend test coverage
coverage/
# Generated at build/test time (scripts/generate-release-notes-bundle.mjs)
src/generated/
# Documentation
CLAUDE.md
+12
View File
@@ -99,6 +99,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### What's New — remote release notes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1058](https://github.com/Psychotoxical/psysonic/pull/1058)**
* The **What's New** page shows user-friendly highlights from `WHATS_NEW.md` instead of embedding the full technical changelog in the app bundle.
* RC and stable builds prefetch `whats-new.md` from the GitHub release on startup and cache it locally; offline users see a thin embedded fallback.
* **`tauri:dev`** reads markdown straight from the repo for easy editing; shipped bundles embed only the current release-line slice. A **Full changelog** tab on the page shows the technical list for the same version.
* CI uploads `whats-new.md` on each `next` / `release` tag alongside platform artifacts.
* Remote download uses the existing Rust `fetch_url_bytes` proxy so GitHub release assets work without browser CORS.
## Changed
### Settings → Servers — compact server cards
+1
View File
@@ -49,6 +49,7 @@ Rules:
### Step B: Promote to RC (`next`)
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
+51
View File
@@ -0,0 +1,51 @@
# What's New
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
current line before promoting to `next` / `release`. Technical details and PR credits stay in
`CHANGELOG.md`.
## [1.48.0]
## Highlights
### Theme Store
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, and full-size previews.
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
### Offline listening
- Starred tracks and pinned albums stay on disk under one **media** folder — browse them in **Offline Library**.
- Favorites can sync automatically; pinned albums and playlists refresh when the library index updates.
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors.
### Fullscreen player
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock.
### Queue — Timeline mode
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
### Sidebar
- Optional **Settings → Sidebar** toggle pins **Now Playing** to the top of the sidebar.
### Startup
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
### Settings → Servers
- Server cards are easier to scan: software version on the card, compact actions, and a green **AudioMuse-AI** badge on Navidrome 0.62+ when the plugin is detected.
## Improved
- Audio decoding runs on **Symphonia 0.6** for current and future codec fixes.
- **Settings → About** shows a clearer open-source licenses panel.
## Fixed
- Smoother playback and library behaviour across offline mode, theme switching, and server reconnect — see `CHANGELOG.md` for the full list.
+5 -4
View File
@@ -4,15 +4,16 @@
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
"dev": "vite",
"build": "tsc && vite build",
"build": "npm run prebuild:release-notes && tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"test": "vitest run && npm run check:css-imports",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage && npm run check:css-imports"
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
*
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
function versionCore(version) {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainTriple(header) {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
function splitBlocks(raw) {
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
}
function headerVersion(block) {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
function parseBlock(block) {
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(),
};
}
export function findReleaseSection(raw, appVersion) {
const blocks = splitBlocks(raw);
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = versionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b) => {
const hv = headerVersion(b);
return hv !== null && versionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b) => {
const hv = headerVersion(b);
return hv !== null && isPlainTriple(hv);
});
return parseBlock(plain ?? candidates[0]);
}
function main() {
const args = process.argv.slice(2);
const allowEmpty = args.includes('--allow-empty');
const positional = args.filter((a) => a !== '--allow-empty');
const [file, version] = positional;
if (!file || !version) {
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
process.exit(2);
}
const raw = readFileSync(file, 'utf8');
const entry = findReleaseSection(raw, version);
const body = entry?.body?.trim() ?? '';
if (!body) {
if (allowEmpty) process.exit(0);
console.error(`No release section found in ${file} for version ${version}`);
process.exit(1);
}
process.stdout.write(`${body}\n`);
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findReleaseSection } from './extract-release-section.mjs';
const FIXTURE = `
## [1.48.0] - 2026-06-10
## Highlights
- One
## [1.47.0]
- Old
`;
describe('findReleaseSection', () => {
it('matches base line for -rc versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
assert.equal(entry.headerVersion, '1.48.0');
assert.match(entry.body, /Highlights/);
});
it('matches base line for -dev versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
assert.equal(entry.headerVersion, '1.48.0');
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Build src/generated/releaseNotesBundle.ts for production bundles.
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findReleaseSection } from './extract-release-section.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const whatsNewPath = join(root, 'WHATS_NEW.md');
const changelogPath = join(root, 'CHANGELOG.md');
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
const changelogFull = readFileSync(changelogPath, 'utf8');
function sliceForVersion(full, fileLabel) {
const entry = findReleaseSection(full, version);
if (!entry?.body) {
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
return '';
}
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
}
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
const outDir = join(root, 'src/generated');
mkdirSync(outDir, { recursive: true });
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
`;
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
+2
View File
@@ -44,6 +44,7 @@ import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { usePrefetchReleaseNotes } from '../hooks/usePrefetchReleaseNotes';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
import { useServerCapabilitiesProbe } from '../hooks/useServerCapabilitiesProbe';
import { useQueueResizer } from '../hooks/useQueueResizer';
@@ -164,6 +165,7 @@ export function AppShell() {
// Post-update changelog is now surfaced via a dismissible banner in the
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
// modal takeover on startup.
usePrefetchReleaseNotes();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(readInitialSidebarCollapsed);
const isMainScrolling = useMainScrollingIndicator(location.pathname);
-98
View File
@@ -1,98 +0,0 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
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/changelog/changelogReleaseMatch';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
+1
View File
@@ -160,6 +160,7 @@ const CONTRIBUTOR_ENTRIES = [
'Library DB: named slow-write op labels for macOS playback-stall diagnosis (PR #1043)',
'Settings → Servers: compact two-line cards, capability header badges, unified use/active action, delete in edit form, click-pinned version tooltip (PR #1054)',
'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)',
'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)',
],
},
{
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { version as appVersion } from '../../package.json';
import { isWorkspaceReleaseNotesMode } from '../utils/releaseNotes/releaseNotesChannel';
import { resolveReleaseNotes } from '../utils/releaseNotes/releaseNotesResolve';
/**
* Warm the release-notes cache after an update (RC/stable only).
* Runs once per app session; resolveReleaseNotes skips network when cached.
*/
export function usePrefetchReleaseNotes(version: string = appVersion): void {
useEffect(() => {
if (isWorkspaceReleaseNotesMode(version)) return;
void resolveReleaseNotes(version).catch(() => {
/* offline or missing asset — embedded fallback loads on /whats-new */
});
}, [version]);
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { version as appVersion } from '../../package.json';
import {
resolveChangelogEntry,
resolveReleaseNotes,
type ReleaseNotesSource,
} from '../utils/releaseNotes/releaseNotesResolve';
import type { ReleaseNotesEntry } from '../utils/releaseNotes/releaseNotesMatch';
export interface UseReleaseNotesResult {
loading: boolean;
whatsNewEntry: ReleaseNotesEntry | null;
changelogEntry: ReleaseNotesEntry | null;
whatsNewSource: ReleaseNotesSource;
}
export function useReleaseNotes(version: string = appVersion): UseReleaseNotesResult {
const [loading, setLoading] = useState(true);
const [whatsNewEntry, setWhatsNewEntry] = useState<ReleaseNotesEntry | null>(null);
const [changelogEntry, setChangelogEntry] = useState<ReleaseNotesEntry | null>(null);
const [whatsNewSource, setWhatsNewSource] = useState<ReleaseNotesSource>('empty');
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([resolveReleaseNotes(version), resolveChangelogEntry(version)])
.then(([whatsNew, changelog]) => {
if (cancelled) return;
setWhatsNewEntry(whatsNew.entry);
setWhatsNewSource(whatsNew.source);
setChangelogEntry(changelog.entry);
})
.catch(() => {
if (cancelled) return;
setWhatsNewEntry(null);
setChangelogEntry(null);
setWhatsNewSource('empty');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [version]);
return { loading, whatsNewEntry, changelogEntry, whatsNewSource };
}
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Was ist neu',
changelogTitle: 'Changelog',
viewTabsLabel: 'Ansicht der Versionshinweise',
viewHighlights: 'Highlights',
viewChangelog: 'Changelog',
empty: 'Für diese Version liegt noch kein Changelog-Eintrag vor.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Neu in v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: "What's New",
changelogTitle: 'Changelog',
viewTabsLabel: 'Release notes view',
viewHighlights: 'Highlights',
viewChangelog: 'Changelog',
empty: 'No changelog entry for this version yet.',
bannerTitle: 'Changelog',
bannerCollapsed: "What's new in v{{version}}",
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Novedades',
changelogTitle: 'Changelog',
viewTabsLabel: 'Vista de notas de la versión',
viewHighlights: 'Destacados',
viewChangelog: 'Changelog',
empty: 'Aún no hay entrada de changelog para esta versión.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Novedades en v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Quoi de neuf',
changelogTitle: 'Changelog',
viewTabsLabel: 'Vue des notes de version',
viewHighlights: 'À la une',
viewChangelog: 'Changelog',
empty: 'Aucune entrée de changelog pour cette version.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nouveau dans v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Nyheter',
changelogTitle: 'Endringslogg',
viewTabsLabel: 'Visning av versjonsnotater',
viewHighlights: 'Høydepunkter',
viewChangelog: 'Endringslogg',
empty: 'Ingen endringslogg for denne versjonen ennå.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nyheter i v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Wat is nieuw',
changelogTitle: 'Changelog',
viewTabsLabel: 'Weergave release notes',
viewHighlights: 'Highlights',
viewChangelog: 'Changelog',
empty: 'Nog geen changelog-item voor deze versie.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nieuw in v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: "Ce este nou",
changelogTitle: 'Lista de schimbări',
viewTabsLabel: 'Vizualizare note de versiune',
viewHighlights: 'Repere',
viewChangelog: 'Changelog',
empty: 'Nicio intrare în lista de schimbări pentru această versiune încă.',
bannerTitle: 'Lista de schimbări',
bannerCollapsed: "Ce este nou în v{{version}}",
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: 'Что нового',
changelogTitle: 'Журнал изменений',
viewTabsLabel: 'Вид заметок о выпуске',
viewHighlights: 'Главное',
viewChangelog: 'Changelog',
empty: 'Для этой версии пока нет записи в журнале изменений.',
bannerTitle: 'Журнал изменений',
bannerCollapsed: 'Что нового в v{{version}}',
+4
View File
@@ -1,5 +1,9 @@
export const whatsNew = {
title: '新功能',
changelogTitle: '更新日志',
viewTabsLabel: '版本说明视图',
viewHighlights: '亮点',
viewChangelog: '更新日志',
empty: '此版本暂无更新日志。',
bannerTitle: '更新日志',
bannerCollapsed: 'v{{version}} 新功能',
+39 -9
View File
@@ -1,22 +1,25 @@
import React, { useMemo } from 'react';
import React, { useState } from 'react';
import { Sparkles, X } from 'lucide-react';
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/changelog/changelogReleaseMatch';
import { useReleaseNotes } from '../hooks/useReleaseNotes';
import { renderChangelogBody } from '../utils/changelog/changelogMarkdown';
type WhatsNewView = 'highlights' | 'changelog';
export default function WhatsNew() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loading, whatsNewEntry, changelogEntry } = useReleaseNotes(version);
const [view, setView] = useState<WhatsNewView>('highlights');
const close = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
const entry = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []);
const activeEntry = view === 'highlights' ? whatsNewEntry : changelogEntry;
return (
<div className="whats-new">
@@ -24,10 +27,12 @@ export default function WhatsNew() {
<div className="whats-new__title-row">
<Sparkles size={20} className="whats-new__icon" />
<div>
<h1 className="whats-new__title">{t('whatsNew.title')}</h1>
<h1 className="whats-new__title">
{view === 'changelog' ? t('whatsNew.changelogTitle') : t('whatsNew.title')}
</h1>
<div className="whats-new__subtitle">
v{version}
{entry?.date && <span className="whats-new__date"> · {entry.date}</span>}
{activeEntry?.date && <span className="whats-new__date"> · {activeEntry.date}</span>}
</div>
</div>
<button
@@ -41,11 +46,36 @@ export default function WhatsNew() {
<X size={18} />
</button>
</div>
{changelogEntry && (
<div className="whats-new__view-tabs" role="tablist" aria-label={t('whatsNew.viewTabsLabel')}>
<button
type="button"
role="tab"
aria-selected={view === 'highlights'}
className={`whats-new__view-tab${view === 'highlights' ? ' whats-new__view-tab--active' : ''}`}
onClick={() => setView('highlights')}
>
{t('whatsNew.viewHighlights')}
</button>
<button
type="button"
role="tab"
aria-selected={view === 'changelog'}
className={`whats-new__view-tab${view === 'changelog' ? ' whats-new__view-tab--active' : ''}`}
onClick={() => setView('changelog')}
>
{t('whatsNew.viewChangelog')}
</button>
</div>
)}
</header>
<div className="whats-new__body">
{entry ? (
renderChangelogBody(entry.body)
<div className="whats-new__body" role="tabpanel">
{loading ? (
<p className="whats-new__empty">{t('common.loading')}</p>
) : activeEntry ? (
renderChangelogBody(activeEntry.body)
) : (
<p className="whats-new__empty">{t('whatsNew.empty')}</p>
)}
+44
View File
@@ -61,6 +61,38 @@
font-variant-numeric: tabular-nums;
}
.whats-new__view-tabs {
display: inline-flex;
gap: 4px;
margin-top: 14px;
padding: 3px;
border-radius: 8px;
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
}
.whats-new__view-tab {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.whats-new__view-tab:hover {
color: var(--text-primary);
}
.whats-new__view-tab--active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.whats-new__body {
font-size: 14px;
line-height: 1.65;
@@ -70,6 +102,18 @@
font-style: italic;
}
.whats-new-h2 {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 24px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-subtle);
}
.whats-new-h2:first-child {
margin-top: 0;
}
.whats-new-h3 {
font-size: 16px;
font-weight: 700;
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderChangelogBody } from './changelogMarkdown';
describe('renderChangelogBody', () => {
it('renders ## section headings', () => {
render(<div>{renderChangelogBody('## Highlights\n\n### Theme Store\n- Item')}</div>);
expect(screen.getByRole('heading', { level: 2, name: 'Highlights' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: 'Theme Store' })).toBeInTheDocument();
});
});
+6 -1
View File
@@ -51,7 +51,7 @@ export function renderInlineMarkdown(text: string, keyPrefix = 'i'): React.React
/**
* Render a subset of GitHub-flavored Markdown used by our CHANGELOG: headings
* (### / ####), bullets (- / *), blockquotes, horizontal rules, and inline
* (## / ### / ####), bullets (- / *), blockquotes, horizontal rules, and inline
* formatting (bold/italic/code/links).
*/
export function renderChangelogBody(body: string): React.ReactNode[] {
@@ -87,6 +87,11 @@ export function renderChangelogBody(body: string): React.ReactNode[] {
continue;
}
if (line.startsWith('## ') && !line.startsWith('### ')) {
flushBullets(); flushQuote();
out.push(<h2 key={`h2-${out.length}`} className="whats-new-h2">{renderInlineMarkdown(line.slice(3), `h2-${i}`)}</h2>);
continue;
}
if (line.startsWith('### ')) {
flushBullets(); flushQuote();
out.push(<h3 key={`h3-${out.length}`} className="whats-new-h3">{renderInlineMarkdown(line.slice(4), `h3-${i}`)}</h3>);
+11 -68
View File
@@ -1,68 +1,11 @@
/**
* 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);
}
/** Re-exports — implementation lives in releaseNotes/releaseNotesMatch.ts */
export {
changelogVersionCore,
findChangelogReleaseEntry,
findReleaseNotesEntry,
releaseNotesVersionCore,
splitChangelogBlocks,
splitReleaseNotesBlocks,
type ChangelogReleaseEntry,
type ReleaseNotesEntry,
} from '../releaseNotes/releaseNotesMatch';
@@ -0,0 +1,32 @@
import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { isTauri } from '@tauri-apps/api/core';
const CACHE_DIR = 'release-notes';
function cacheRelPath(version: string): string {
const safe = version.replace(/[^a-zA-Z0-9._-]+/g, '_');
return `${CACHE_DIR}/${safe}.md`;
}
export async function readReleaseNotesCache(version: string): Promise<string | null> {
if (!isTauri()) return null;
const path = cacheRelPath(version);
try {
if (!(await exists(path, { baseDir: BaseDirectory.AppData }))) return null;
const text = (await readTextFile(path, { baseDir: BaseDirectory.AppData })).trim();
return text || null;
} catch {
return null;
}
}
export async function writeReleaseNotesCache(version: string, body: string): Promise<void> {
if (!isTauri()) return;
const path = cacheRelPath(version);
try {
await mkdir(CACHE_DIR, { baseDir: BaseDirectory.AppData, recursive: true });
await writeTextFile(path, body, { baseDir: BaseDirectory.AppData });
} catch {
// cache is best-effort
}
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { isDevChannelVersion, isWorkspaceReleaseNotesMode } from './releaseNotesChannel';
describe('releaseNotesChannel', () => {
it('detects -dev channel versions', () => {
expect(isDevChannelVersion('1.48.0-dev')).toBe(true);
expect(isDevChannelVersion('1.48.0')).toBe(false);
expect(isDevChannelVersion('1.48.0-rc.1')).toBe(false);
});
it('enables workspace mode in vitest DEV builds', () => {
expect(isWorkspaceReleaseNotesMode('1.48.0')).toBe(import.meta.env.DEV);
expect(isWorkspaceReleaseNotesMode('1.48.0-dev')).toBe(true);
});
});
@@ -0,0 +1,7 @@
export function isDevChannelVersion(version: string): boolean {
return /-dev(?:\b|$)/i.test(version.trim());
}
export function isWorkspaceReleaseNotesMode(version: string): boolean {
return import.meta.env.DEV || isDevChannelVersion(version);
}
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invoke = vi.fn();
const isTauri = vi.fn(() => true);
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invoke(...args),
isTauri: () => isTauri(),
}));
const { fetchWhatsNewAsset, whatsNewDownloadUrl } = await import('./releaseNotesFetch');
describe('releaseNotesFetch', () => {
beforeEach(() => {
invoke.mockReset();
isTauri.mockReturnValue(true);
});
it('builds the release download URL from version', () => {
expect(whatsNewDownloadUrl('1.48.0-rc.1')).toBe(
'https://github.com/Psychotoxical/psysonic/releases/download/app-v1.48.0-rc.1/whats-new.md',
);
});
it('fetches via fetch_url_bytes Rust proxy', async () => {
const body = '# Highlights\n- Item';
invoke.mockResolvedValueOnce([[...new TextEncoder().encode(body)], 'text/plain']);
const text = await fetchWhatsNewAsset('1.48.0');
expect(invoke).toHaveBeenCalledWith('fetch_url_bytes', {
url: 'https://github.com/Psychotoxical/psysonic/releases/download/app-v1.48.0/whats-new.md',
});
expect(text).toBe(body);
});
it('returns null outside Tauri', async () => {
isTauri.mockReturnValue(false);
expect(await fetchWhatsNewAsset('1.48.0')).toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,43 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
export const RELEASE_NOTES_REPO = 'Psychotoxical/psysonic';
const MAX_BODY_BYTES = 64 * 1024;
/** Asset name for remote what's new (i18n: add locale suffix later). */
export function whatsNewAssetName(_locale?: string): string {
return 'whats-new.md';
}
export function whatsNewDownloadUrl(version: string): string {
const asset = whatsNewAssetName();
return `https://github.com/${RELEASE_NOTES_REPO}/releases/download/app-v${version}/${asset}`;
}
/**
* GitHub release download URLs are served without CORS — use the Rust proxy
* (same as radio favicons and other non-CORS endpoints).
*/
async function fetchBytesViaRust(url: string): Promise<Uint8Array> {
const [bytes] = await invoke<[number[], string]>('fetch_url_bytes', { url });
return new Uint8Array(bytes);
}
export async function fetchWhatsNewAsset(version: string): Promise<string | null> {
if (!isTauri()) return null;
const url = whatsNewDownloadUrl(version);
for (let attempt = 0; attempt < 2; attempt++) {
try {
const buf = await fetchBytesViaRust(url);
if (buf.byteLength > MAX_BODY_BYTES) return null;
const text = new TextDecoder().decode(buf).trim();
return text || null;
} catch {
// Retry once on network/timeout only (404/4xx throw from Rust — same on retry).
if (attempt === 1) return null;
}
}
return null;
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { findReleaseNotesEntry } from './releaseNotesMatch';
const FIXTURE = `
# Notes
## [1.48.0] - 2026-06-10
## Highlights
### Theme Store
- Install themes
## [1.47.0]
Old line
`;
describe('findReleaseNotesEntry', () => {
it('matches exact version header', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0');
expect(e?.headerVersion).toBe('1.48.0');
expect(e?.date).toBe('2026-06-10');
expect(e?.body).toContain('Theme Store');
});
it('resolves -rc to base release line', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0-rc.2');
expect(e?.headerVersion).toBe('1.48.0');
});
it('resolves -dev to base release line', () => {
const e = findReleaseNotesEntry(FIXTURE, '1.48.0-dev');
expect(e?.headerVersion).toBe('1.48.0');
});
it('prefers plain X.Y.Z header when multiple share core and no exact header', () => {
const raw = `
## [1.48.0-rc.1]
RC only
## [1.48.0]
Stable line
`;
const e = findReleaseNotesEntry(raw, '1.48.0-rc.2');
expect(e?.headerVersion).toBe('1.48.0');
expect(e?.body).toContain('Stable line');
});
it('returns null when no section matches', () => {
expect(findReleaseNotesEntry(FIXTURE, '9.9.9')).toBeNull();
});
});
@@ -0,0 +1,80 @@
/**
* Match ## [version] sections in release-notes markdown to the app version.
* Exact match first; otherwise same semver major.minor.patch (ignores -rc, -dev, etc.).
*/
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
export function releaseNotesVersionCore(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 splitReleaseNotesBlocks(markdownRaw: string): string[] {
return markdownRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
}
export interface ReleaseNotesEntry {
headerVersion: string;
date: string;
body: string;
}
function parseBlock(block: string): ReleaseNotesEntry | 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 findReleaseNotesEntry(
markdownRaw: string,
appVersion: string,
): ReleaseNotesEntry | null {
const blocks = splitReleaseNotesBlocks(markdownRaw);
const exact = blocks.find((b: string) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = releaseNotesVersionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b: string) => {
const hv = headerVersionFromBlock(b);
if (!hv) return false;
return releaseNotesVersionCore(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);
}
/** @deprecated Use findReleaseNotesEntry */
export const findChangelogReleaseEntry = findReleaseNotesEntry;
/** @deprecated Use ReleaseNotesEntry */
export type ChangelogReleaseEntry = ReleaseNotesEntry;
/** @deprecated Use releaseNotesVersionCore */
export const changelogVersionCore = releaseNotesVersionCore;
/** @deprecated Use splitReleaseNotesBlocks */
export const splitChangelogBlocks = splitReleaseNotesBlocks;
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWhatsNewAsset = vi.fn();
const readReleaseNotesCache = vi.fn();
const writeReleaseNotesCache = vi.fn();
vi.mock('./releaseNotesChannel', () => ({
isDevChannelVersion: (version: string) => /-dev(?:\b|$)/i.test(version.trim()),
isWorkspaceReleaseNotesMode: (version: string) => /-dev(?:\b|$)/i.test(version.trim()),
}));
vi.mock('./releaseNotesFetch', () => ({
fetchWhatsNewAsset: (...args: unknown[]) => fetchWhatsNewAsset(...args),
}));
vi.mock('./releaseNotesCache', () => ({
readReleaseNotesCache: (...args: unknown[]) => readReleaseNotesCache(...args),
writeReleaseNotesCache: (...args: unknown[]) => writeReleaseNotesCache(...args),
}));
vi.mock('../../generated/releaseNotesBundle', () => ({
WHATS_NEW_RAW: `## [1.48.0] - 2026-06-10
## Highlights
- Embedded what's new`,
CHANGELOG_RAW: `## [1.48.0]
## Added
- Embedded changelog fallback`,
}));
const { resolveReleaseNotes } = await import('./releaseNotesResolve');
describe('resolveReleaseNotes (shipped channel)', () => {
beforeEach(() => {
fetchWhatsNewAsset.mockReset();
readReleaseNotesCache.mockReset();
writeReleaseNotesCache.mockReset();
readReleaseNotesCache.mockResolvedValue(null);
fetchWhatsNewAsset.mockResolvedValue(null);
});
it('uses cache before network', async () => {
readReleaseNotesCache.mockResolvedValue('Cached body line');
const r = await resolveReleaseNotes('1.48.0');
expect(r.source).toBe('cache');
expect(r.entry?.body).toContain('Cached body line');
expect(fetchWhatsNewAsset).not.toHaveBeenCalled();
});
it('fetches remote and writes cache when no cache hit', async () => {
fetchWhatsNewAsset.mockResolvedValue('Fresh remote body');
const r = await resolveReleaseNotes('1.48.0-rc.1');
expect(r.source).toBe('remote');
expect(r.entry?.body).toBe('Fresh remote body');
expect(writeReleaseNotesCache).toHaveBeenCalledWith('1.48.0-rc.1', 'Fresh remote body');
});
it('falls back to embedded whats-new when offline', async () => {
const r = await resolveReleaseNotes('1.48.0');
expect(r.source).toBe('embedded-whats-new');
expect(r.entry?.body).toContain("Embedded what's new");
expect(r.entry?.date).toBe('2026-06-10');
});
});
describe('resolveReleaseNotes (workspace)', () => {
beforeEach(() => {
fetchWhatsNewAsset.mockReset();
readReleaseNotesCache.mockReset();
});
it('skips fetch for -dev versions', async () => {
const r = await resolveReleaseNotes('1.48.0-dev');
expect(['workspace', 'workspace-changelog']).toContain(r.source);
expect(fetchWhatsNewAsset).not.toHaveBeenCalled();
expect(r.entry?.body.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,123 @@
import { isWorkspaceReleaseNotesMode } from './releaseNotesChannel';
import { readReleaseNotesCache, writeReleaseNotesCache } from './releaseNotesCache';
import { fetchWhatsNewAsset } from './releaseNotesFetch';
import { findReleaseNotesEntry, type ReleaseNotesEntry } from './releaseNotesMatch';
export type ReleaseNotesSource =
| 'workspace'
| 'workspace-changelog'
| 'cache'
| 'remote'
| 'embedded-whats-new'
| 'embedded-changelog'
| 'empty';
export interface ResolvedReleaseNotes {
source: ReleaseNotesSource;
entry: ReleaseNotesEntry | null;
}
async function loadWorkspaceWhatsNewRaw(): Promise<string> {
if (import.meta.env.DEV) {
return (await import('../../../WHATS_NEW.md?raw')).default;
}
const { WHATS_NEW_RAW } = await import('../../generated/releaseNotesBundle');
return WHATS_NEW_RAW;
}
async function loadWorkspaceChangelogRaw(): Promise<string> {
if (import.meta.env.DEV) {
return (await import('../../../CHANGELOG.md?raw')).default;
}
const { CHANGELOG_RAW } = await import('../../generated/releaseNotesBundle');
return CHANGELOG_RAW;
}
async function loadEmbeddedWhatsNewRaw(): Promise<string> {
const { WHATS_NEW_RAW } = await import('../../generated/releaseNotesBundle');
return WHATS_NEW_RAW;
}
async function loadEmbeddedChangelogRaw(): Promise<string> {
const { CHANGELOG_RAW } = await import('../../generated/releaseNotesBundle');
return CHANGELOG_RAW;
}
function mergeBodyWithMeta(body: string, meta: ReleaseNotesEntry | null, version: string): ReleaseNotesEntry {
return {
body,
date: meta?.date ?? '',
headerVersion: meta?.headerVersion ?? version,
};
}
async function resolveWorkspace(version: string): Promise<ResolvedReleaseNotes> {
const whatsNewRaw = await loadWorkspaceWhatsNewRaw();
const fromWhatsNew = findReleaseNotesEntry(whatsNewRaw, version);
if (fromWhatsNew?.body) {
return { source: 'workspace', entry: fromWhatsNew };
}
const changelogRaw = await loadWorkspaceChangelogRaw();
const fromChangelog = findReleaseNotesEntry(changelogRaw, version);
if (fromChangelog?.body) {
return { source: 'workspace-changelog', entry: fromChangelog };
}
return { source: 'empty', entry: null };
}
async function resolveShipped(version: string): Promise<ResolvedReleaseNotes> {
const embeddedMeta = findReleaseNotesEntry(await loadEmbeddedWhatsNewRaw(), version);
const cached = await readReleaseNotesCache(version);
if (cached) {
return {
source: 'cache',
entry: mergeBodyWithMeta(cached, embeddedMeta, version),
};
}
const remote = await fetchWhatsNewAsset(version);
if (remote) {
await writeReleaseNotesCache(version, remote);
return {
source: 'remote',
entry: mergeBodyWithMeta(remote, embeddedMeta, version),
};
}
const fromEmbeddedWhatsNew = findReleaseNotesEntry(await loadEmbeddedWhatsNewRaw(), version);
if (fromEmbeddedWhatsNew?.body) {
return { source: 'embedded-whats-new', entry: fromEmbeddedWhatsNew };
}
const fromEmbeddedChangelog = findReleaseNotesEntry(await loadEmbeddedChangelogRaw(), version);
if (fromEmbeddedChangelog?.body) {
return { source: 'embedded-changelog', entry: fromEmbeddedChangelog };
}
return { source: 'empty', entry: null };
}
export async function resolveReleaseNotes(version: string): Promise<ResolvedReleaseNotes> {
if (isWorkspaceReleaseNotesMode(version)) {
return resolveWorkspace(version);
}
return resolveShipped(version);
}
/** Technical changelog for the same version (embedded or workspace; never remote). */
export async function resolveChangelogEntry(version: string): Promise<ResolvedReleaseNotes> {
const changelogRaw = isWorkspaceReleaseNotesMode(version)
? await loadWorkspaceChangelogRaw()
: await loadEmbeddedChangelogRaw();
const entry = findReleaseNotesEntry(changelogRaw, version);
if (entry?.body) {
return {
source: isWorkspaceReleaseNotesMode(version) ? 'workspace-changelog' : 'embedded-changelog',
entry,
};
}
return { source: 'empty', entry: null };
}