feat: display multilingual release notes in version management

- Support Simplified Chinese, Traditional Chinese, English, and Japanese
- Follow the UI language and show a notice when notes are unavailable
- Avoid language and content fallbacks
- Generate Release bodies and update manifests from per-version Markdown
- Add collapsible notes, publication dates, and links to full releases
- Test publishing, legacy manifest compatibility, and localized rendering
This commit is contained in:
lzt404 committed 2026-09-19 20:42:30 +08:00
1 parent c9c0c4cf1c
commit bac67c1524
25 files changed
+888 -9

No files matched your search

+8
View File
@@ -3,6 +3,7 @@ import { readFile, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { validateAppVersion } from './version.mjs';
import { validateReleaseNotes } from './release-notes.mjs';
export function portableUpdateManifestName(platform) {
const normalizedPlatform = String(platform).trim().toLowerCase();
@@ -31,6 +32,7 @@ export async function generatePortableUpdateManifest({
gitcodeRepository,
tag: rawTag,
publishedAt = new Date().toISOString(),
releaseNotes,
}) {
const resolvedDirectory = resolve(directory ?? 'artifacts');
const platformSpecs = {
@@ -62,6 +64,7 @@ export async function generatePortableUpdateManifest({
if (Number.isNaN(Date.parse(publishedAt))) {
throw new Error(`Invalid publishedAt: ${publishedAt}`);
}
if (releaseNotes !== undefined) validateReleaseNotes(releaseNotes);
const assets = {};
for (const arch of ['amd64', 'aarch64']) {
@@ -91,6 +94,7 @@ export async function generatePortableUpdateManifest({
version,
publishedAt,
releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`,
...(releaseNotes === undefined ? {} : { releaseNotes }),
assets,
};
@@ -116,6 +120,9 @@ async function main() {
? [resolve(requestedOutput)]
: portableUpdateManifestNames(platform).map((name) => join(directory, name));
const publishedAt = new Date().toISOString();
const releaseNotes = args.has('--release-notes')
? JSON.parse(await readFile(resolve(args.get('--release-notes')), 'utf8'))
: undefined;
let manifest;
for (const output of outputs) {
manifest = await generatePortableUpdateManifest({
@@ -126,6 +133,7 @@ async function main() {
gitcodeRepository: args.get('--gitcode-repository'),
tag: args.get('--tag'),
publishedAt,
releaseNotes,
});
}
console.log(
+63
View File
@@ -0,0 +1,63 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { validateAppVersion } from './version.mjs';
export const releaseNotesLanguages = [
{ locale: 'zh-CN', label: '简体中文', missing: '暂未提供该版本的简体中文更新说明。' },
{ locale: 'zh-TW', label: '繁體中文', missing: '暫未提供此版本的繁體中文更新說明。' },
{ locale: 'en', label: 'English', missing: 'Release notes for this version are not available in English.' },
{ locale: 'ja', label: '日本語', missing: 'このバージョンの日本語の更新内容はありません。' },
];
// Only explicitly authored translations are published. Missing languages stay missing.
export async function resolveReleaseNotes({ root = process.cwd(), tag }) {
const version = validateAppVersion(String(tag ?? '').trim().replace(/^v/, ''), 'release tag');
const directory = join(root, 'docs', 'release-notes', `v${version}`);
const notes = {};
for (const { locale } of releaseNotesLanguages) {
try {
const text = (await readFile(join(directory, `${locale}.md`), 'utf8')).trim();
if (text) notes[locale] = `${text}\n`;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
return notes;
}
export function validateReleaseNotes(notes) {
if (!notes || typeof notes !== 'object' || Array.isArray(notes)) {
throw new Error('Release notes must be an object keyed by locale');
}
for (const [locale, text] of Object.entries(notes)) {
if (!releaseNotesLanguages.some((language) => language.locale === locale)
|| typeof text !== 'string' || !text.trim()) {
throw new Error(`Invalid release notes for locale: ${locale}`);
}
}
}
export function releaseNotesBody(notes) {
validateReleaseNotes(notes);
return `${releaseNotesLanguages.map(({ locale, label, missing }) => (
`# ${label}\n\n${notes[locale]?.trim() || missing}`
)).join('\n\n---\n\n')}\n`;
}
async function main() {
const args = new Map();
for (let index = 2; index < process.argv.length; index += 2) {
args.set(process.argv[index], process.argv[index + 1]);
}
if (!args.get('--output') || !args.get('--body-output')) {
throw new Error('--output and --body-output are required');
}
const notes = await resolveReleaseNotes({ tag: args.get('--tag') });
await writeFile(resolve(args.get('--output')), `${JSON.stringify(notes, null, 2)}\n`, 'utf8');
await writeFile(resolve(args.get('--body-output')), releaseNotesBody(notes), 'utf8');
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await main();
}