From 81decb843d6586738ea3f18c46b7f9a64e53ca7b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:12:17 +0200 Subject: [PATCH] refactor(admin): share the GitHub release history between desktop and phone useGithubReleases holds the paged fetch, the prerelease filter, the expanded rows and the date format, and parseReleaseNotes splits a release body into the blocks both panels draw. The phone keeps its page walk past hidden prereleases through the fillPages option, its repository link and its own inline link scan; the desktop keeps one page per load. --- client/src/components/Admin/GitHubPanel.tsx | 148 ++++----------- .../Admin/useGithubReleases.test.ts | 176 ++++++++++++++++++ .../src/components/Admin/useGithubReleases.ts | 130 +++++++++++++ .../screens/admin/MAdminGitHubPanel.tsx | 170 ++++------------- client/src/utils/releaseNotes.test.ts | 44 +++++ client/src/utils/releaseNotes.ts | 48 +++++ 6 files changed, 475 insertions(+), 241 deletions(-) create mode 100644 client/src/components/Admin/useGithubReleases.test.ts create mode 100644 client/src/components/Admin/useGithubReleases.ts create mode 100644 client/src/utils/releaseNotes.test.ts create mode 100644 client/src/utils/releaseNotes.ts diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 05ab3d37a..9d64967ed 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -13,26 +13,14 @@ import { Loader2, Tag, } from 'lucide-react'; -import { useEffect, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react'; -import apiClient from '../../api/client'; -import { getLocaleForLanguage, useTranslation } from '../../i18n'; +import { type ComponentType, type CSSProperties, type ReactNode } from 'react'; +import { useTranslation } from '../../i18n'; +import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../utils/releaseNotes'; import { fs } from '../shared/DialogShell'; import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit'; +import { useGithubReleases } from './useGithubReleases'; const REPO = 'liketrek/TREK'; -const PER_PAGE = 10; - -interface GithubRelease { - id: number; - prerelease: boolean; - tag_name: string; - name: string | null; - body: string | null; - published_at: string | null; - created_at: string; - author: { login: string } | null; - [key: string]: unknown; -} /** The Discord mark, sized and coloured like the lucide glyphs beside it. */ function DiscordIcon({ size = 18 }: { size?: number }) { @@ -79,73 +67,15 @@ function SupportCard({ link }: { link: SupportLink }) { } export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { - const { t, language } = useTranslation(); - const [releases, setReleases] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [expanded, setExpanded] = useState>({}); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - - const fetchReleases = async (pageNum = 1, append = false) => { - try { - const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }); - const data = Array.isArray(res.data) ? res.data : []; - setReleases((prev) => (append ? [...prev, ...data] : data)); - setHasMore(data.length === PER_PAGE); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } - }; - - useEffect(() => { - setLoading(true); - void fetchReleases(1).finally(() => setLoading(false)); - }, []); - - const handleLoadMore = async () => { - const next = page + 1; - setLoadingMore(true); - await fetchReleases(next, true); - setPage(next); - setLoadingMore(false); - }; - - const toggleExpand = (id: number) => { - setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); - }; - - const formatDate = (dateStr: string) => { - const d = new Date(dateStr); - return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' }); - }; + const { t } = useTranslation(); + const { + shownReleases: shown, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate, + } = useGithubReleases({ isPrerelease }); // Simple markdown-to-html for release notes (handles headers, bold, lists, links) const renderBody = (body: string) => { if (!body) return null; - const lines = body.split('\n'); - const elements: ReactNode[] = []; - let listItems: string[] = []; - const flushList = () => { - if (listItems.length > 0) { - elements.push( -
    - {listItems.map((item, i) => ( -
  • - - -
  • - ))} -
- ); - listItems = []; - } - }; - - const escapeHtml = (str: string) => - str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const inlineFormat = (text: string) => { return escapeHtml(text) .replace(/\*\*(.+?)\*\*/g, '$1') @@ -159,43 +89,42 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b }); }; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - flushList(); - continue; + return parseReleaseNotes(body).map((block, idx) => { + if (block.kind === 'ul') { + return ( +
    + {block.items.map((item, i) => ( +
  • + + +
  • + ))} +
+ ); } - - if (trimmed.startsWith('### ')) { - flushList(); - elements.push( -

- {trimmed.slice(4)} + if (block.kind === 'h4') { + return ( +

+ {block.text}

); - } else if (trimmed.startsWith('## ')) { - flushList(); - elements.push( -

- {trimmed.slice(3)} + } + if (block.kind === 'h3') { + return ( +

+ {block.text}

); - } else if (/^[-*] /.test(trimmed)) { - listItems.push(trimmed.slice(2)); - } else { - flushList(); - elements.push( -

- ); } - } - flushList(); - return elements; + return ( +

+ ); + }); }; const supportLinks: SupportLink[] = [ @@ -257,7 +186,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b } else if (error) { releasesCard = ; } else { - const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease); releasesCard = ( ({ + useTranslation: () => ({ language: 'de' }), + getLocaleForLanguage: (language: string) => (language === 'de' ? 'de-DE' : 'en-US'), +})); + +function release(id: number, prerelease = false): GithubRelease { + return { + id, + prerelease, + tag_name: `v${id}`, + name: null, + body: null, + published_at: null, + created_at: '2026-01-05T10:00:00Z', + author: null, + }; +} + +/** A full page of ten, numbered from `from`. */ +function fullPage(from: number, prerelease = false) { + return Array.from({ length: 10 }, (_, i) => release(from + i, prerelease)); +} + +/** Answers each page number from `pages`; a page holding an Error rejects with it. */ +function serve(pages: Record) { + return vi + .spyOn(apiClient, 'get') + .mockImplementation(async (_url: string, config?: { params?: { page?: number } }) => { + const answer = pages[config?.params?.page ?? 1] ?? []; + if (answer instanceof Error) throw answer; + return { data: answer }; + }); +} + +function requestedPages(spy: ReturnType) { + return spy.mock.calls.map((call) => (call[1] as { params: { page: number } }).params.page); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('useGithubReleases, one page per load', () => { + it('FE-COMP-GHREL-HOOK-001: loads the first page and hides prereleases from a stable install', async () => { + const spy = serve({ 1: [release(1, true), release(2)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(spy).toHaveBeenCalledWith('/admin/github-releases', { params: { per_page: 10, page: 1 } }); + expect(result.current.releases.map((r) => r.id)).toEqual([1, 2]); + expect(result.current.shownReleases.map((r) => r.id)).toEqual([2]); + expect(result.current.hasMore).toBe(false); + }); + + it('FE-COMP-GHREL-HOOK-002: a prerelease install shows every release', async () => { + serve({ 1: [release(1, true), release(2)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: true })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.shownReleases.map((r) => r.id)).toEqual([1, 2]); + }); + + it('FE-COMP-GHREL-HOOK-003: load more appends the next page and keeps going while pages are full', async () => { + const spy = serve({ 1: fullPage(1), 2: [release(11)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + await act(async () => { + await result.current.handleLoadMore(); + }); + expect(requestedPages(spy)).toEqual([1, 2]); + expect(result.current.releases).toHaveLength(11); + expect(result.current.hasMore).toBe(false); + expect(result.current.loadingMore).toBe(false); + }); + + it('FE-COMP-GHREL-HOOK-004: a failed load more sets the error and still moves the page on', async () => { + const spy = serve({ 1: fullPage(1), 2: new Error('rate limited'), 3: [release(21)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.handleLoadMore(); + }); + expect(result.current.error).toBe('rate limited'); + await act(async () => { + await result.current.handleLoadMore(); + }); + expect(requestedPages(spy)).toEqual([1, 2, 3]); + expect(result.current.error).toBe('rate limited'); + }); + + it('FE-COMP-GHREL-HOOK-005: a first page of only prereleases is all a stable install gets', async () => { + const spy = serve({ 1: fullPage(1, true), 2: [release(11)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(requestedPages(spy)).toEqual([1]); + expect(result.current.shownReleases).toEqual([]); + expect(result.current.hasMore).toBe(true); + }); +}); + +describe('useGithubReleases, filling pages', () => { + it('FE-COMP-GHREL-HOOK-006: walks past pages of hidden prereleases until one release shows', async () => { + const spy = serve({ + 1: fullPage(1, true), + 2: fullPage(11, true), + 3: [release(21), ...fullPage(22, true).slice(1)], + }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(requestedPages(spy)).toEqual([1, 2, 3]); + expect(result.current.releases).toHaveLength(30); + expect(result.current.shownReleases.map((r) => r.id)).toEqual([21]); + expect(result.current.hasMore).toBe(true); + }); + + it('FE-COMP-GHREL-HOOK-007: stops after five pages in one load', async () => { + const pages: Record = {}; + for (let p = 1; p <= 6; p++) pages[p] = fullPage(p * 10, true); + const spy = serve(pages); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(requestedPages(spy)).toEqual([1, 2, 3, 4, 5]); + expect(result.current.releases).toHaveLength(50); + expect(result.current.hasMore).toBe(true); + }); + + it('FE-COMP-GHREL-HOOK-008: a failed load more keeps the list, holds the page, and the retry clears the error', async () => { + let failPage2 = true; + const spy = vi + .spyOn(apiClient, 'get') + .mockImplementation(async (_url: string, config?: { params?: { page?: number } }) => { + const page = config?.params?.page; + if (page === 1) return { data: fullPage(1) }; + if (failPage2) throw new Error('offline'); + return { data: [release(11)] }; + }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true })); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.handleLoadMore(); + }); + expect(result.current.error).toBe('offline'); + expect(result.current.releases).toHaveLength(10); + failPage2 = false; + await act(async () => { + await result.current.handleLoadMore(); + }); + expect(requestedPages(spy)).toEqual([1, 2, 2]); + expect(result.current.error).toBeNull(); + expect(result.current.releases).toHaveLength(11); + }); +}); + +describe('useGithubReleases, display helpers', () => { + it('FE-COMP-GHREL-HOOK-009: toggleExpand flips one release at a time', async () => { + serve({ 1: [release(1)] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.toggleExpand(1)); + expect(result.current.expanded).toEqual({ 1: true }); + act(() => result.current.toggleExpand(1)); + expect(result.current.expanded).toEqual({ 1: false }); + }); + + it('FE-COMP-GHREL-HOOK-010: formatDate writes the date in the locale of the UI language', async () => { + serve({ 1: [] }); + const { result } = renderHook(() => useGithubReleases({ isPrerelease: false })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.formatDate('2026-01-05T10:00:00Z')).toBe( + new Date('2026-01-05T10:00:00Z').toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }) + ); + }); +}); diff --git a/client/src/components/Admin/useGithubReleases.ts b/client/src/components/Admin/useGithubReleases.ts new file mode 100644 index 000000000..a0ace2869 --- /dev/null +++ b/client/src/components/Admin/useGithubReleases.ts @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; + +import apiClient from '../../api/client'; +import { getLocaleForLanguage, useTranslation } from '../../i18n'; + +const PER_PAGE = 10; +const MAX_PAGES_PER_LOAD = 5; + +export interface GithubRelease { + id: number; + prerelease: boolean; + tag_name: string; + name: string | null; + body: string | null; + published_at: string | null; + created_at: string; + author: { login: string } | null; + [key: string]: unknown; +} + +interface UseGithubReleasesOptions { + /** Whether this install runs a prerelease: prereleases only show to those. */ + isPrerelease: boolean; + /** + * Keep pulling pages until at least one release survives the prerelease filter, up to + * MAX_PAGES_PER_LOAD per load. A failed page then keeps what is already shown and + * leaves the page counter where it was, and the next good load clears the error. + * Without it each load reads exactly one page and the page counter moves on regardless. + */ + fillPages?: boolean; +} + +/** + * The GitHub release history behind both admin shells (the desktop panel and the phone + * panel render their own timeline over it): the paged fetch from the server's proxy, + * which releases show, which ones are expanded, and their dates in the user's locale. + */ +export function useGithubReleases({ isPrerelease, fillPages = false }: UseGithubReleasesOptions) { + const { language } = useTranslation(); + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>({}); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + + const isShown = (release: GithubRelease) => isPrerelease || !release.prerelease; + + const fetchPage = async (pageNum: number) => { + try { + const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }); + return Array.isArray(res.data) ? (res.data as GithubRelease[]) : []; + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Unknown error'); + return null; + } + }; + + const fetchReleases = async (pageNum = 1, append = false) => { + const data = await fetchPage(pageNum); + if (!data) return; + setReleases((prev) => (append ? [...prev, ...data] : data)); + setHasMore(data.length === PER_PAGE); + }; + + // Keep pulling pages until at least one release survives the prerelease filter, + // otherwise a page of nothing but prereleases leaves an empty timeline behind a + // "Load more" button. MAX_PAGES_PER_LOAD bounds the walk. + const loadFrom = async (startPage: number, append: boolean) => { + const collected: GithubRelease[] = []; + let pageNum = startPage; + let more = true; + + for (let i = 0; i < MAX_PAGES_PER_LOAD; i++) { + const data = await fetchPage(pageNum); + if (!data) return; + collected.push(...data); + more = data.length === PER_PAGE; + if (!more || collected.some(isShown)) break; + pageNum += 1; + } + + setReleases((prev) => (append ? [...prev, ...collected] : collected)); + setHasMore(more); + setPage(pageNum); + setError(null); + }; + + useEffect(() => { + setLoading(true); + void (fillPages ? loadFrom(1, false) : fetchReleases(1)).finally(() => setLoading(false)); + }, []); + + const handleLoadMore = async () => { + if (fillPages) { + setLoadingMore(true); + await loadFrom(page + 1, true); + setLoadingMore(false); + return; + } + const next = page + 1; + setLoadingMore(true); + await fetchReleases(next, true); + setPage(next); + setLoadingMore(false); + }; + + const toggleExpand = (id: number) => { + setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); + }; + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr); + return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' }); + }; + + return { + releases, + shownReleases: releases.filter(isShown), + loading, + error, + expanded, + toggleExpand, + hasMore, + loadingMore, + handleLoadMore, + formatDate, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx index 2910eba5f..ba520f156 100644 --- a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx @@ -1,24 +1,11 @@ -import { useState, useEffect, type ReactNode } from 'react' +import { type ReactNode } from 'react' import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react' -import { getLocaleForLanguage, useTranslation } from '../../../i18n' -import apiClient from '../../../api/client' +import { useTranslation } from '../../../i18n' +import { useGithubReleases } from '../../../components/Admin/useGithubReleases' +import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../../utils/releaseNotes' import { MAdminButton, MAdminCard } from './MAdminUi' const REPO = 'mauriceboe/TREK' -const PER_PAGE = 10 -const MAX_PAGES_PER_LOAD = 5 - -interface GithubRelease { - id: number - prerelease: boolean - tag_name: string - name: string | null - body: string | null - published_at: string | null - created_at: string - author: { login: string } | null - [key: string]: unknown -} // Support / community link cards (design: brand-tinted icon tile + title/sub). // Brand hex stays as content identity; every surface/border uses --m-* tokens. @@ -31,94 +18,15 @@ interface LinkCard { } export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { - const { t, language } = useTranslation() - const [releases, setReleases] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [expanded, setExpanded] = useState>({}) - const [page, setPage] = useState(1) - const [hasMore, setHasMore] = useState(true) - const [loadingMore, setLoadingMore] = useState(false) - - const isShown = (release: GithubRelease) => isPrerelease || !release.prerelease - - const fetchPage = async (pageNum: number) => { - try { - const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }) - return Array.isArray(res.data) ? res.data as GithubRelease[] : [] - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Unknown error') - return null - } - } - - // Keep pulling pages until at least one release survives the prerelease filter, - // otherwise a page of nothing but prereleases leaves an empty timeline behind a - // "Load more" button. MAX_PAGES_PER_LOAD bounds the walk. - const loadFrom = async (startPage: number, append: boolean) => { - const collected: GithubRelease[] = [] - let pageNum = startPage - let more = true - - for (let i = 0; i < MAX_PAGES_PER_LOAD; i++) { - const data = await fetchPage(pageNum) - if (!data) return - collected.push(...data) - more = data.length === PER_PAGE - if (!more || collected.some(isShown)) break - pageNum += 1 - } - - setReleases(prev => append ? [...prev, ...collected] : collected) - setHasMore(more) - setPage(pageNum) - setError(null) - } - - useEffect(() => { - setLoading(true) - void loadFrom(1, false).finally(() => setLoading(false)) - }, []) - - const handleLoadMore = async () => { - setLoadingMore(true) - await loadFrom(page + 1, true) - setLoadingMore(false) - } - - const toggleExpand = (id: number) => { - setExpanded(prev => ({ ...prev, [id]: !prev[id] })) - } - - const formatDate = (dateStr: string) => { - const d = new Date(dateStr) - return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' }) - } + const { t } = useTranslation() + const { + releases, shownReleases, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate, + } = useGithubReleases({ isPrerelease, fillPages: true }) // Simple markdown-to-html for release notes (handles headers, bold, lists, links) const renderBody = (body: string | null) => { if (!body) return null - const lines = body.split('\n') - const elements: ReactNode[] = [] - let listItems: string[] = [] - const flushList = () => { - if (listItems.length > 0) { - elements.push( -

    - {listItems.map((item, i) => ( -
  • - - -
  • - ))} -
- ) - listItems = [] - } - } - - const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') // `[label](url)` → anchor, exactly as /\[([^\]]+)\]\(([^)]+)\)/g did. Written as a // scan because that pattern backtracks from both ends: `[^\]]` matches `[` and // `[^)]` matches `(`, so a run of either made the engine retry the whole match from @@ -162,37 +70,39 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea ) } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { flushList(); continue } - - if (trimmed.startsWith('### ')) { - flushList() - elements.push( -

- {trimmed.slice(4)} -

- ) - } else if (trimmed.startsWith('## ')) { - flushList() - elements.push( -

- {trimmed.slice(3)} -

- ) - } else if (/^[-*] /.test(trimmed)) { - listItems.push(trimmed.slice(2)) - } else { - flushList() - elements.push( -

+ return parseReleaseNotes(body).map((block, idx) => { + if (block.kind === 'ul') { + return ( +

    + {block.items.map((item, i) => ( +
  • + + +
  • + ))} +
) } - } - flushList() - return elements + if (block.kind === 'h4') { + return ( +

+ {block.text} +

+ ) + } + if (block.kind === 'h3') { + return ( +

+ {block.text} +

+ ) + } + return ( +

+ ) + }) } const discordIcon = ( @@ -208,8 +118,6 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea { href: 'https://github.com/mauriceboe/TREK/wiki', color: '#6366f1', icon: , title: 'Wiki', sub: t('settings.about.wikiHint') }, ] - const shownReleases = releases.filter(isShown) - return (

{/* Support / community cards */} diff --git a/client/src/utils/releaseNotes.test.ts b/client/src/utils/releaseNotes.test.ts new file mode 100644 index 000000000..4a5d99c9a --- /dev/null +++ b/client/src/utils/releaseNotes.test.ts @@ -0,0 +1,44 @@ +// FE-UTIL-RELNOTES-001 to -005: the release body parser both admin shells render from. +import { escapeReleaseHtml, parseReleaseNotes } from './releaseNotes'; + +describe('parseReleaseNotes', () => { + it('FE-UTIL-RELNOTES-001: reads headings, paragraphs and list items', () => { + expect(parseReleaseNotes('## Release\n### Fixed\nSome text\n- one\n* two')).toEqual([ + { kind: 'h3', text: 'Release' }, + { kind: 'h4', text: 'Fixed' }, + { kind: 'p', text: 'Some text' }, + { kind: 'ul', items: ['one', 'two'] }, + ]); + }); + + it('FE-UTIL-RELNOTES-002: a blank line or any other block ends a list', () => { + expect(parseReleaseNotes('- a\n\n- b\nplain\n- c\n### H\n- d')).toEqual([ + { kind: 'ul', items: ['a'] }, + { kind: 'ul', items: ['b'] }, + { kind: 'p', text: 'plain' }, + { kind: 'ul', items: ['c'] }, + { kind: 'h4', text: 'H' }, + { kind: 'ul', items: ['d'] }, + ]); + }); + + it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => { + expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([ + { kind: 'h3', text: 'Spaced' }, + { kind: 'p', text: '-no space' }, + { kind: 'p', text: '#### deep' }, + ]); + }); + + it('FE-UTIL-RELNOTES-004: an empty body has no blocks', () => { + expect(parseReleaseNotes('')).toEqual([]); + }); +}); + +describe('escapeReleaseHtml', () => { + it('FE-UTIL-RELNOTES-005: escapes ampersands, angle brackets and double quotes only', () => { + expect(escapeReleaseHtml(`Tom & Jerry's`)).toBe( + "<a href="x">Tom & Jerry's</a>" + ); + }); +}); diff --git a/client/src/utils/releaseNotes.ts b/client/src/utils/releaseNotes.ts new file mode 100644 index 000000000..c9f5de0a8 --- /dev/null +++ b/client/src/utils/releaseNotes.ts @@ -0,0 +1,48 @@ +/** One block of a release body: a heading, a paragraph, or a run of list items. */ +export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] }; + +/** Escapes the characters that would let release text break out of the HTML it is placed in. */ +export function escapeReleaseHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * Splits a GitHub release body into the blocks both admin shells render: `### ` and `## ` + * headings, `- ` or `* ` list items gathered into one list until anything else breaks the + * run, and every other non-empty line as a paragraph. Blank lines only end a list. The + * text stays raw; each shell formats it inline itself. + */ +export function parseReleaseNotes(body: string): ReleaseNoteBlock[] { + const blocks: ReleaseNoteBlock[] = []; + let listItems: string[] = []; + + const flushList = () => { + if (listItems.length > 0) { + blocks.push({ kind: 'ul', items: listItems }); + listItems = []; + } + }; + + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + flushList(); + continue; + } + + if (trimmed.startsWith('### ')) { + flushList(); + blocks.push({ kind: 'h4', text: trimmed.slice(4) }); + } else if (trimmed.startsWith('## ')) { + flushList(); + blocks.push({ kind: 'h3', text: trimmed.slice(3) }); + } else if (/^[-*] /.test(trimmed)) { + listItems.push(trimmed.slice(2)); + } else { + flushList(); + blocks.push({ kind: 'p', text: trimmed }); + } + } + flushList(); + return blocks; +}