From 8ca4a52c96cd074a683fae3acebeac2ddc91b76c Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Thu, 30 Apr 2026 16:19:24 +0800 Subject: [PATCH 1/2] feat: support collapsed Chinese release notes Extract Chinese release notes from GitHub details blocks while keeping English release notes readable outside the collapsed section. Made-with: Cursor --- webui/src/utils/releaseNotes.test.ts | 52 ++++++++++++++++++++++++++++ webui/src/utils/releaseNotes.ts | 43 +++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/webui/src/utils/releaseNotes.test.ts b/webui/src/utils/releaseNotes.test.ts index b601fd3f..111df631 100644 --- a/webui/src/utils/releaseNotes.test.ts +++ b/webui/src/utils/releaseNotes.test.ts @@ -37,4 +37,56 @@ describe('getLocalizedReleaseNotes', () => { expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe(notes); }); + + it('extracts Chinese release notes from a GitHub details block', () => { + const notes = [ + '### What\'s Changed', + '', + '- English update 1', + '- English update 2', + '', + '
', + '中文', + '', + '### 更新内容', + '', + '- 中文更新 1', + '- 中文更新 2', + '', + '
', + ].join('\n'); + + expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe('### 更新内容\n\n- 中文更新 1\n- 中文更新 2'); + }); + + it('removes Chinese details blocks for English release notes', () => { + const notes = [ + '### What\'s Changed', + '', + '- English update 1', + '', + '
', + '简体中文', + '', + '- 中文更新', + '', + '
', + '', + '### Fixes', + '', + '- English fix 1', + ].join('\n'); + + expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe( + [ + '### What\'s Changed', + '', + '- English update 1', + '', + '### Fixes', + '', + '- English fix 1', + ].join('\n'), + ); + }); }); diff --git a/webui/src/utils/releaseNotes.ts b/webui/src/utils/releaseNotes.ts index 203c7442..0cfc0edc 100644 --- a/webui/src/utils/releaseNotes.ts +++ b/webui/src/utils/releaseNotes.ts @@ -6,11 +6,18 @@ interface ReleaseNoteSection { body: string; } +interface DetailsSection { + language: 'zh' | 'en' | null; + body: string; + fullBlock: string; +} + const normalizeSectionTitle = (title: string) => ( title .trim() .replace(/^#+\s*/, '') .replace(/\s*#+$/, '') + .replace(/<[^>]+>/g, '') .toLowerCase() ); @@ -53,6 +60,34 @@ const parseReleaseNoteSections = (notes: string): ReleaseNoteSection[] => { return sections; }; +const parseDetailsSections = (notes: string): DetailsSection[] => { + const sections: DetailsSection[] = []; + const detailsPattern = /]*>([\s\S]*?)<\/details>/gi; + let match: RegExpExecArray | null; + + while ((match = detailsPattern.exec(notes)) !== null) { + const fullBlock = match[0]; + const inner = match[1]; + const summary = inner.match(/]*>([\s\S]*?)<\/summary>/i); + if (!summary) continue; + + sections.push({ + language: getSectionLanguage(summary[1]), + body: inner.replace(summary[0], '').trim(), + fullBlock, + }); + } + + return sections; +}; + +const removeDetailsBlocks = (notes: string, sections: DetailsSection[]) => ( + sections + .reduce((value, section) => value.replace(section.fullBlock, ''), notes) + .replace(/\n{3,}/g, '\n\n') + .trim() +); + export const getLocalizedReleaseNotes = ( notes: string | null | undefined, language: string | null | undefined, @@ -61,6 +96,14 @@ export const getLocalizedReleaseNotes = ( if (!fallback) return ''; const targetLanguage = (language ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'; + const detailsSections = parseDetailsSections(fallback); + const matchedDetails = detailsSections.find((section) => section.language === targetLanguage); + if (matchedDetails?.body) return matchedDetails.body; + + if (targetLanguage === 'en' && detailsSections.some((section) => section.language === 'zh')) { + return removeDetailsBlocks(fallback, detailsSections); + } + const sections = parseReleaseNoteSections(fallback); const matched = sections.find((section) => getSectionLanguage(section.title) === targetLanguage); From 02f5782487a94205d1cd9f40c97547d6d2b186cc Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Thu, 30 Apr 2026 16:22:09 +0800 Subject: [PATCH 2/2] fix: require details blocks for localized release notes Remove legacy language-heading parsing so localized release notes rely on the collapsed Chinese details format. Made-with: Cursor --- webui/src/components/layout/Layout.test.tsx | 9 +++-- webui/src/utils/releaseNotes.test.ts | 29 ++------------ webui/src/utils/releaseNotes.ts | 42 +-------------------- 3 files changed, 10 insertions(+), 70 deletions(-) diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index 35d304c1..f38c7398 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -320,12 +320,15 @@ describe('Layout onboarding entry', () => { latest_version: '2026.04.28', current_version: '2026.04.28', release_notes: [ - '## 中文', + 'English update 1', + '', + '
', + '中文', + '', '中文更新 1', '中文更新 2', '', - '## English', - 'English update 1', + '
', ].join('\n'), release_url: 'https://example.com/release', error: null, diff --git a/webui/src/utils/releaseNotes.test.ts b/webui/src/utils/releaseNotes.test.ts index 111df631..2773e09f 100644 --- a/webui/src/utils/releaseNotes.test.ts +++ b/webui/src/utils/releaseNotes.test.ts @@ -2,40 +2,17 @@ import { describe, expect, it } from 'vitest'; import { getLocalizedReleaseNotes } from './releaseNotes'; describe('getLocalizedReleaseNotes', () => { - it('extracts the Chinese section for Chinese locales', () => { + it('falls back to full release notes when no details section is present', () => { const notes = [ '## 中文', - '中文更新 1', - '中文更新 2', - '', - '## English', - 'English update 1', - ].join('\n'); - - expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe('中文更新 1\n中文更新 2'); - }); - - it('extracts the English section for English locales', () => { - const notes = [ - '## 简体中文', '中文更新', '', '## English', - '### Authentication', - 'English update 1', - 'English update 2', - ].join('\n'); - - expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe('### Authentication\nEnglish update 1\nEnglish update 2'); - }); - - it('falls back to full release notes when the target section is missing', () => { - const notes = [ - '## 中文', - '中文更新', + 'English update', ].join('\n'); expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe(notes); + expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe(notes); }); it('extracts Chinese release notes from a GitHub details block', () => { diff --git a/webui/src/utils/releaseNotes.ts b/webui/src/utils/releaseNotes.ts index 0cfc0edc..6b554d55 100644 --- a/webui/src/utils/releaseNotes.ts +++ b/webui/src/utils/releaseNotes.ts @@ -1,11 +1,6 @@ const CHINESE_SECTION_ALIASES = new Set(['中文', '简体中文', 'zh-cn', 'zh_cn', 'chinese']); const ENGLISH_SECTION_ALIASES = new Set(['english', 'en-us', 'en_us']); -interface ReleaseNoteSection { - title: string; - body: string; -} - interface DetailsSection { language: 'zh' | 'en' | null; body: string; @@ -28,38 +23,6 @@ const getSectionLanguage = (title: string): 'zh' | 'en' | null => { return null; }; -const parseReleaseNoteSections = (notes: string): ReleaseNoteSection[] => { - const lines = notes.split(/\r?\n/); - const sections: ReleaseNoteSection[] = []; - let currentTitle: string | null = null; - let currentBody: string[] = []; - - const flush = () => { - if (currentTitle === null) return; - sections.push({ - title: currentTitle, - body: currentBody.join('\n').trim(), - }); - }; - - for (const line of lines) { - const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); - if (heading && getSectionLanguage(heading[1]) !== null) { - flush(); - currentTitle = heading[1]; - currentBody = []; - continue; - } - - if (currentTitle !== null) { - currentBody.push(line); - } - } - - flush(); - return sections; -}; - const parseDetailsSections = (notes: string): DetailsSection[] => { const sections: DetailsSection[] = []; const detailsPattern = /]*>([\s\S]*?)<\/details>/gi; @@ -104,8 +67,5 @@ export const getLocalizedReleaseNotes = ( return removeDetailsBlocks(fallback, detailsSections); } - const sections = parseReleaseNoteSections(fallback); - const matched = sections.find((section) => getSectionLanguage(section.title) === targetLanguage); - - return matched?.body || fallback; + return fallback; };