diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 9482b3c2..d62e7a8d 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -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 diff --git a/.github/workflows/reusable-channel-publish.yml b/.github/workflows/reusable-channel-publish.yml index 21b37e23..21c340f6 100644 --- a/.github/workflows/reusable-channel-publish.yml +++ b/.github/workflows/reusable-channel-publish.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 25277893..5e6814f1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3452cbd2..e9c7e2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index c4f81c7d..54c5d111 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -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) diff --git a/WHATS_NEW.md b/WHATS_NEW.md new file mode 100644 index 00000000..10a7cf5d --- /dev/null +++ b/WHATS_NEW.md @@ -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. diff --git a/package.json b/package.json index 00918e40..a54ba3da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/extract-release-section.mjs b/scripts/extract-release-section.mjs new file mode 100644 index 00000000..5917756d --- /dev/null +++ b/scripts/extract-release-section.mjs @@ -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 [--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 [--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(); diff --git a/scripts/extract-release-section.test.mjs b/scripts/extract-release-section.test.mjs new file mode 100644 index 00000000..2e467872 --- /dev/null +++ b/scripts/extract-release-section.test.mjs @@ -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'); + }); +}); diff --git a/scripts/generate-release-notes-bundle.mjs b/scripts/generate-release-notes-bundle.mjs new file mode 100644 index 00000000..3819ad0b --- /dev/null +++ b/scripts/generate-release-notes-bundle.mjs @@ -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})`); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index daccb294..9acfc9f1 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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); diff --git a/src/components/ChangelogModal.tsx b/src/components/ChangelogModal.tsx deleted file mode 100644 index 96ad8296..00000000 --- a/src/components/ChangelogModal.tsx +++ /dev/null @@ -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 {part.slice(2, -2)}; - if (part.startsWith('*') && part.endsWith('*')) - return {part.slice(1, -1)}; - if (part.startsWith('`') && part.endsWith('`')) - return {part.slice(1, -1)}; - 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( - <> -
-
-
- - - {t('changelog.modalTitle')} — v{version} - - {currentVersionData.date && ( - - {currentVersionData.date} - - )} -
- -
- {currentVersionData.body.split('\n').map((line, i) => { - if (line.startsWith('### ')) - return
{renderInline(line.slice(4))}
; - if (line.startsWith('#### ')) - return
{renderInline(line.slice(5))}
; - if (line.startsWith('- ')) - return
{renderInline(line.slice(2))}
; - if (line.trim() === '') return null; - return
{renderInline(line)}
; - })} -
- -
- - -
-
- , - document.body - ); -} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index cd8994e7..d89becd8 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/usePrefetchReleaseNotes.ts b/src/hooks/usePrefetchReleaseNotes.ts new file mode 100644 index 00000000..fbd1e56a --- /dev/null +++ b/src/hooks/usePrefetchReleaseNotes.ts @@ -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]); +} diff --git a/src/hooks/useReleaseNotes.ts b/src/hooks/useReleaseNotes.ts new file mode 100644 index 00000000..63363aa4 --- /dev/null +++ b/src/hooks/useReleaseNotes.ts @@ -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(null); + const [changelogEntry, setChangelogEntry] = useState(null); + const [whatsNewSource, setWhatsNewSource] = useState('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 }; +} diff --git a/src/locales/de/whatsNew.ts b/src/locales/de/whatsNew.ts index 1d2a6b36..fe434504 100644 --- a/src/locales/de/whatsNew.ts +++ b/src/locales/de/whatsNew.ts @@ -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}}', diff --git a/src/locales/en/whatsNew.ts b/src/locales/en/whatsNew.ts index 3c498b17..64b9e25f 100644 --- a/src/locales/en/whatsNew.ts +++ b/src/locales/en/whatsNew.ts @@ -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}}", diff --git a/src/locales/es/whatsNew.ts b/src/locales/es/whatsNew.ts index ecefb313..cb926fb6 100644 --- a/src/locales/es/whatsNew.ts +++ b/src/locales/es/whatsNew.ts @@ -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}}', diff --git a/src/locales/fr/whatsNew.ts b/src/locales/fr/whatsNew.ts index 7279b028..971ba3f7 100644 --- a/src/locales/fr/whatsNew.ts +++ b/src/locales/fr/whatsNew.ts @@ -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}}', diff --git a/src/locales/nb/whatsNew.ts b/src/locales/nb/whatsNew.ts index 78191a73..5908ac2b 100644 --- a/src/locales/nb/whatsNew.ts +++ b/src/locales/nb/whatsNew.ts @@ -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}}', diff --git a/src/locales/nl/whatsNew.ts b/src/locales/nl/whatsNew.ts index fe371772..7b567bc4 100644 --- a/src/locales/nl/whatsNew.ts +++ b/src/locales/nl/whatsNew.ts @@ -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}}', diff --git a/src/locales/ro/whatsNew.ts b/src/locales/ro/whatsNew.ts index 376cb71c..5e246bba 100644 --- a/src/locales/ro/whatsNew.ts +++ b/src/locales/ro/whatsNew.ts @@ -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}}", diff --git a/src/locales/ru/whatsNew.ts b/src/locales/ru/whatsNew.ts index d7e67472..b615f25b 100644 --- a/src/locales/ru/whatsNew.ts +++ b/src/locales/ru/whatsNew.ts @@ -1,5 +1,9 @@ export const whatsNew = { title: 'Что нового', + changelogTitle: 'Журнал изменений', + viewTabsLabel: 'Вид заметок о выпуске', + viewHighlights: 'Главное', + viewChangelog: 'Changelog', empty: 'Для этой версии пока нет записи в журнале изменений.', bannerTitle: 'Журнал изменений', bannerCollapsed: 'Что нового в v{{version}}', diff --git a/src/locales/zh/whatsNew.ts b/src/locales/zh/whatsNew.ts index cf15f0f9..f8dc0972 100644 --- a/src/locales/zh/whatsNew.ts +++ b/src/locales/zh/whatsNew.ts @@ -1,5 +1,9 @@ export const whatsNew = { title: '新功能', + changelogTitle: '更新日志', + viewTabsLabel: '版本说明视图', + viewHighlights: '亮点', + viewChangelog: '更新日志', empty: '此版本暂无更新日志。', bannerTitle: '更新日志', bannerCollapsed: 'v{{version}} 新功能', diff --git a/src/pages/WhatsNew.tsx b/src/pages/WhatsNew.tsx index da215119..9a04fca2 100644 --- a/src/pages/WhatsNew.tsx +++ b/src/pages/WhatsNew.tsx @@ -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('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 (
@@ -24,10 +27,12 @@ export default function WhatsNew() {
-

{t('whatsNew.title')}

+

+ {view === 'changelog' ? t('whatsNew.changelogTitle') : t('whatsNew.title')} +

v{version} - {entry?.date && · {entry.date}} + {activeEntry?.date && · {activeEntry.date}}
+ + {changelogEntry && ( +
+ + +
+ )} -
- {entry ? ( - renderChangelogBody(entry.body) +
+ {loading ? ( +

{t('common.loading')}

+ ) : activeEntry ? ( + renderChangelogBody(activeEntry.body) ) : (

{t('whatsNew.empty')}

)} diff --git a/src/styles/components/page.css b/src/styles/components/page.css index b95976e2..1a83abfc 100644 --- a/src/styles/components/page.css +++ b/src/styles/components/page.css @@ -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; diff --git a/src/utils/changelog/changelogMarkdown.test.tsx b/src/utils/changelog/changelogMarkdown.test.tsx new file mode 100644 index 00000000..7899936f --- /dev/null +++ b/src/utils/changelog/changelogMarkdown.test.tsx @@ -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(
{renderChangelogBody('## Highlights\n\n### Theme Store\n- Item')}
); + expect(screen.getByRole('heading', { level: 2, name: 'Highlights' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Theme Store' })).toBeInTheDocument(); + }); +}); diff --git a/src/utils/changelog/changelogMarkdown.tsx b/src/utils/changelog/changelogMarkdown.tsx index c043c483..02328450 100644 --- a/src/utils/changelog/changelogMarkdown.tsx +++ b/src/utils/changelog/changelogMarkdown.tsx @@ -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(

{renderInlineMarkdown(line.slice(3), `h2-${i}`)}

); + continue; + } if (line.startsWith('### ')) { flushBullets(); flushQuote(); out.push(

{renderInlineMarkdown(line.slice(4), `h3-${i}`)}

); diff --git a/src/utils/changelog/changelogReleaseMatch.ts b/src/utils/changelog/changelogReleaseMatch.ts index 91863498..bc8a686e 100644 --- a/src/utils/changelog/changelogReleaseMatch.ts +++ b/src/utils/changelog/changelogReleaseMatch.ts @@ -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'; diff --git a/src/utils/releaseNotes/releaseNotesCache.ts b/src/utils/releaseNotes/releaseNotesCache.ts new file mode 100644 index 00000000..c1dfd233 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesCache.ts @@ -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 { + 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 { + 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 + } +} diff --git a/src/utils/releaseNotes/releaseNotesChannel.test.ts b/src/utils/releaseNotes/releaseNotesChannel.test.ts new file mode 100644 index 00000000..50bc10e8 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesChannel.test.ts @@ -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); + }); +}); diff --git a/src/utils/releaseNotes/releaseNotesChannel.ts b/src/utils/releaseNotes/releaseNotesChannel.ts new file mode 100644 index 00000000..0a2620d1 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesChannel.ts @@ -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); +} diff --git a/src/utils/releaseNotes/releaseNotesFetch.test.ts b/src/utils/releaseNotes/releaseNotesFetch.test.ts new file mode 100644 index 00000000..1375b763 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesFetch.test.ts @@ -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(); + }); +}); diff --git a/src/utils/releaseNotes/releaseNotesFetch.ts b/src/utils/releaseNotes/releaseNotesFetch.ts new file mode 100644 index 00000000..6e9f6b48 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesFetch.ts @@ -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 { + const [bytes] = await invoke<[number[], string]>('fetch_url_bytes', { url }); + return new Uint8Array(bytes); +} + +export async function fetchWhatsNewAsset(version: string): Promise { + 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; +} diff --git a/src/utils/releaseNotes/releaseNotesMatch.test.ts b/src/utils/releaseNotes/releaseNotesMatch.test.ts new file mode 100644 index 00000000..f5b38bcd --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesMatch.test.ts @@ -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(); + }); +}); diff --git a/src/utils/releaseNotes/releaseNotesMatch.ts b/src/utils/releaseNotes/releaseNotesMatch.ts new file mode 100644 index 00000000..cb47480e --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesMatch.ts @@ -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; diff --git a/src/utils/releaseNotes/releaseNotesResolve.test.ts b/src/utils/releaseNotes/releaseNotesResolve.test.ts new file mode 100644 index 00000000..2bc0e4b7 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesResolve.test.ts @@ -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); + }); +}); diff --git a/src/utils/releaseNotes/releaseNotesResolve.ts b/src/utils/releaseNotes/releaseNotesResolve.ts new file mode 100644 index 00000000..cd9be2c8 --- /dev/null +++ b/src/utils/releaseNotes/releaseNotesResolve.ts @@ -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 { + 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 { + 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 { + const { WHATS_NEW_RAW } = await import('../../generated/releaseNotesBundle'); + return WHATS_NEW_RAW; +} + +async function loadEmbeddedChangelogRaw(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 }; +}