From 30750df724d26d747dbdc6eeaa4b553bec4a2303 Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Thu, 6 Nov 2025 19:09:07 +0800 Subject: [PATCH] fix(i18n,notice): make locale formatting idempotent and guard early notice translations --- scripts/cleanup-unused-i18n.mjs | 109 ++++++++++++++++++++------------ src/services/noticeService.ts | 7 ++ 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 07395508..ce8af9c3 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -945,10 +945,15 @@ function loadLocales() { function ensureBackup(localePath) { const backupPath = `${localePath}.bak`; if (fs.existsSync(backupPath)) { - throw new Error( - `Backup file already exists for ${path.basename(localePath)}; ` + - "either remove it manually or rerun with --no-backup", - ); + try { + fs.rmSync(backupPath); + } catch (error) { + throw new Error( + `Failed to recycle existing backup for ${path.basename( + localePath, + )}: ${error.message}`, + ); + } } fs.copyFileSync(localePath, backupPath); return backupPath; @@ -959,10 +964,27 @@ function backupIfNeeded(filePath, backups, options) { if (!fs.existsSync(filePath)) return; if (backups.has(filePath)) return; const backupPath = ensureBackup(filePath); - backups.add(filePath); + backups.set(filePath, backupPath); return backupPath; } +function cleanupBackups(backups) { + for (const backupPath of backups.values()) { + try { + if (fs.existsSync(backupPath)) { + fs.rmSync(backupPath); + } + } catch (error) { + console.warn( + `Warning: failed to remove backup ${path.basename( + backupPath, + )}: ${error.message}`, + ); + } + } + backups.clear(); +} + function toModuleIdentifier(namespace, seen) { const RESERVED = new Set([ "default", @@ -1016,46 +1038,55 @@ export default resources; } function writeLocale(locale, data, options) { - const backups = new Set(); + const backups = new Map(); + let success = false; - if (locale.format === "single-file") { - const target = locale.files[0].path; - backupIfNeeded(target, backups, options); - const serialized = JSON.stringify(data, null, 2); - fs.writeFileSync(target, `${serialized}\n`, "utf8"); - return; - } + try { + if (locale.format === "single-file") { + const target = locale.files[0].path; + backupIfNeeded(target, backups, options); + const serialized = JSON.stringify(data, null, 2); + fs.writeFileSync(target, `${serialized}\n`, "utf8"); + success = true; + return; + } - const entries = Object.entries(data); - const orderedNamespaces = entries.map(([namespace]) => namespace); - const existingFiles = new Map( - locale.files.map((file) => [file.namespace, file.path]), - ); - const visited = new Set(); + const entries = Object.entries(data); + const orderedNamespaces = entries.map(([namespace]) => namespace); + const existingFiles = new Map( + locale.files.map((file) => [file.namespace, file.path]), + ); + const visited = new Set(); - for (const [namespace, value] of entries) { - const target = - existingFiles.get(namespace) ?? - path.join(locale.dir, `${namespace}.json`); - backupIfNeeded(target, backups, options); - const serialized = JSON.stringify(value ?? {}, null, 2); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, `${serialized}\n`, "utf8"); - visited.add(namespace); - } + for (const [namespace, value] of entries) { + const target = + existingFiles.get(namespace) ?? + path.join(locale.dir, `${namespace}.json`); + backupIfNeeded(target, backups, options); + const serialized = JSON.stringify(value ?? {}, null, 2); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${serialized}\n`, "utf8"); + visited.add(namespace); + } - for (const [namespace, filePath] of existingFiles.entries()) { - if (!visited.has(namespace) && fs.existsSync(filePath)) { - backupIfNeeded(filePath, backups, options); - fs.rmSync(filePath); + for (const [namespace, filePath] of existingFiles.entries()) { + if (!visited.has(namespace) && fs.existsSync(filePath)) { + backupIfNeeded(filePath, backups, options); + fs.rmSync(filePath); + } + } + + regenerateLocaleIndex(locale.dir, orderedNamespaces); + locale.files = orderedNamespaces.map((namespace) => ({ + namespace, + path: path.join(locale.dir, `${namespace}.json`), + })); + success = true; + } finally { + if (success) { + cleanupBackups(backups); } } - - regenerateLocaleIndex(locale.dir, orderedNamespaces); - locale.files = orderedNamespaces.map((namespace) => ({ - namespace, - path: path.join(locale.dir, `${namespace}.json`), - })); } function processLocale( diff --git a/src/services/noticeService.ts b/src/services/noticeService.ts index 250c519d..f0e316c7 100644 --- a/src/services/noticeService.ts +++ b/src/services/noticeService.ts @@ -44,6 +44,8 @@ const DEFAULT_DURATIONS: Readonly> = { error: 8000, }; +const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/; + let nextId = 0; let notices: NoticeItem[] = []; const subscribers: Set = new Set(); @@ -157,11 +159,16 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor { }; } +function isLikelyTranslationKey(key: string) { + return TRANSLATION_KEY_PATTERN.test(key); +} + function shouldUseTranslationKey( key: string, params?: Record, ) { if (params && Object.keys(params).length > 0) return true; + if (isLikelyTranslationKey(key)) return true; if (i18n.isInitialized) { return i18n.exists(key); }