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.
This commit is contained in:
Maurice
2026-10-09 10:12:17 +02:00
parent 67f20829f7
commit 81decb843d
6 changed files with 475 additions and 241 deletions
+44
View File
@@ -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(`<a href="x">Tom & Jerry's</a>`)).toBe(
"&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry's&lt;/a&gt;"
);
});
});
+48
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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;
}