Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions webui/src/components/layout/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,15 @@ describe('Layout onboarding entry', () => {
latest_version: '2026.04.28',
current_version: '2026.04.28',
release_notes: [
'## 中文',
'English update 1',
'',
'<details>',
'<summary>中文</summary>',
'',
'中文更新 1',
'中文更新 2',
'',
'## English',
'English update 1',
'</details>',
].join('\n'),
release_url: 'https://example.com/release',
error: null,
Expand Down
63 changes: 46 additions & 17 deletions webui/src/utils/releaseNotes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,68 @@ 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',
'English update',
].join('\n');

expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe('中文更新 1\n中文更新 2');
expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe(notes);
expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe(notes);
});

it('extracts the English section for English locales', () => {
it('extracts Chinese release notes from a GitHub details block', () => {
const notes = [
'## 简体中文',
'中文更新',
'### What\'s Changed',
'',
'## English',
'### Authentication',
'English update 1',
'English update 2',
'- English update 1',
'- English update 2',
'',
'<details>',
'<summary>中文</summary>',
'',
'### 更新内容',
'',
'- 中文更新 1',
'- 中文更新 2',
'',
'</details>',
].join('\n');

expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe('### Authentication\nEnglish update 1\nEnglish update 2');
expect(getLocalizedReleaseNotes(notes, 'zh-CN')).toBe('### 更新内容\n\n- 中文更新 1\n- 中文更新 2');
});

it('falls back to full release notes when the target section is missing', () => {
it('removes Chinese details blocks for English release notes', () => {
const notes = [
'## 中文',
'中文更新',
'### What\'s Changed',
'',
'- English update 1',
'',
'<details>',
'<summary>简体中文</summary>',
'',
'- 中文更新',
'',
'</details>',
'',
'### Fixes',
'',
'- English fix 1',
].join('\n');

expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe(notes);
expect(getLocalizedReleaseNotes(notes, 'en-US')).toBe(
[
'### What\'s Changed',
'',
'- English update 1',
'',
'### Fixes',
'',
'- English fix 1',
].join('\n'),
);
});
});
61 changes: 32 additions & 29 deletions webui/src/utils/releaseNotes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
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;
interface DetailsSection {
language: 'zh' | 'en' | null;
body: string;
fullBlock: string;
}

const normalizeSectionTitle = (title: string) => (
title
.trim()
.replace(/^#+\s*/, '')
.replace(/\s*#+$/, '')
.replace(/<[^>]+>/g, '')
.toLowerCase()
);

Expand All @@ -21,38 +23,34 @@ 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 parseDetailsSections = (notes: string): DetailsSection[] => {
const sections: DetailsSection[] = [];
const detailsPattern = /<details\b[^>]*>([\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(/<summary\b[^>]*>([\s\S]*?)<\/summary>/i);
if (!summary) continue;

const flush = () => {
if (currentTitle === null) return;
sections.push({
title: currentTitle,
body: currentBody.join('\n').trim(),
language: getSectionLanguage(summary[1]),
body: inner.replace(summary[0], '').trim(),
fullBlock,
});
};

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 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,
Expand All @@ -61,8 +59,13 @@ export const getLocalizedReleaseNotes = (
if (!fallback) return '';

const targetLanguage = (language ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
const sections = parseReleaseNoteSections(fallback);
const matched = sections.find((section) => getSectionLanguage(section.title) === targetLanguage);
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);
}

return matched?.body || fallback;
return fallback;
};
Loading