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;
};