fix(i18n,notice): make locale formatting idempotent and guard early notice translations

This commit is contained in:
Slinetrac
2025-11-06 19:09:07 +08:00
Unverified
parent 9e3078e017
commit 30750df724
2 changed files with 77 additions and 39 deletions

View File

@@ -945,10 +945,15 @@ function loadLocales() {
function ensureBackup(localePath) { function ensureBackup(localePath) {
const backupPath = `${localePath}.bak`; const backupPath = `${localePath}.bak`;
if (fs.existsSync(backupPath)) { if (fs.existsSync(backupPath)) {
throw new Error( try {
`Backup file already exists for ${path.basename(localePath)}; ` + fs.rmSync(backupPath);
"either remove it manually or rerun with --no-backup", } catch (error) {
); throw new Error(
`Failed to recycle existing backup for ${path.basename(
localePath,
)}: ${error.message}`,
);
}
} }
fs.copyFileSync(localePath, backupPath); fs.copyFileSync(localePath, backupPath);
return backupPath; return backupPath;
@@ -959,10 +964,27 @@ function backupIfNeeded(filePath, backups, options) {
if (!fs.existsSync(filePath)) return; if (!fs.existsSync(filePath)) return;
if (backups.has(filePath)) return; if (backups.has(filePath)) return;
const backupPath = ensureBackup(filePath); const backupPath = ensureBackup(filePath);
backups.add(filePath); backups.set(filePath, backupPath);
return 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) { function toModuleIdentifier(namespace, seen) {
const RESERVED = new Set([ const RESERVED = new Set([
"default", "default",
@@ -1016,46 +1038,55 @@ export default resources;
} }
function writeLocale(locale, data, options) { function writeLocale(locale, data, options) {
const backups = new Set(); const backups = new Map();
let success = false;
if (locale.format === "single-file") { try {
const target = locale.files[0].path; if (locale.format === "single-file") {
backupIfNeeded(target, backups, options); const target = locale.files[0].path;
const serialized = JSON.stringify(data, null, 2); backupIfNeeded(target, backups, options);
fs.writeFileSync(target, `${serialized}\n`, "utf8"); const serialized = JSON.stringify(data, null, 2);
return; fs.writeFileSync(target, `${serialized}\n`, "utf8");
} success = true;
return;
}
const entries = Object.entries(data); const entries = Object.entries(data);
const orderedNamespaces = entries.map(([namespace]) => namespace); const orderedNamespaces = entries.map(([namespace]) => namespace);
const existingFiles = new Map( const existingFiles = new Map(
locale.files.map((file) => [file.namespace, file.path]), locale.files.map((file) => [file.namespace, file.path]),
); );
const visited = new Set(); const visited = new Set();
for (const [namespace, value] of entries) { for (const [namespace, value] of entries) {
const target = const target =
existingFiles.get(namespace) ?? existingFiles.get(namespace) ??
path.join(locale.dir, `${namespace}.json`); path.join(locale.dir, `${namespace}.json`);
backupIfNeeded(target, backups, options); backupIfNeeded(target, backups, options);
const serialized = JSON.stringify(value ?? {}, null, 2); const serialized = JSON.stringify(value ?? {}, null, 2);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${serialized}\n`, "utf8"); fs.writeFileSync(target, `${serialized}\n`, "utf8");
visited.add(namespace); visited.add(namespace);
} }
for (const [namespace, filePath] of existingFiles.entries()) { for (const [namespace, filePath] of existingFiles.entries()) {
if (!visited.has(namespace) && fs.existsSync(filePath)) { if (!visited.has(namespace) && fs.existsSync(filePath)) {
backupIfNeeded(filePath, backups, options); backupIfNeeded(filePath, backups, options);
fs.rmSync(filePath); 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( function processLocale(

View File

@@ -44,6 +44,8 @@ const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
error: 8000, error: 8000,
}; };
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/;
let nextId = 0; let nextId = 0;
let notices: NoticeItem[] = []; let notices: NoticeItem[] = [];
const subscribers: Set<NoticeSubscriber> = new Set(); const subscribers: Set<NoticeSubscriber> = new Set();
@@ -157,11 +159,16 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor {
}; };
} }
function isLikelyTranslationKey(key: string) {
return TRANSLATION_KEY_PATTERN.test(key);
}
function shouldUseTranslationKey( function shouldUseTranslationKey(
key: string, key: string,
params?: Record<string, unknown>, params?: Record<string, unknown>,
) { ) {
if (params && Object.keys(params).length > 0) return true; if (params && Object.keys(params).length > 0) return true;
if (isLikelyTranslationKey(key)) return true;
if (i18n.isInitialized) { if (i18n.isInitialized) {
return i18n.exists(key); return i18n.exists(key);
} }