From 2045cc785054833aa0588633d60c4d90750206dd Mon Sep 17 00:00:00 2001 From: panoskava Date: Fri, 29 May 2026 20:22:43 +0300 Subject: [PATCH 1/3] feat(i18n): add i18n helper for stores, regression guard, and locale audit scripts - src/lib/i18n.ts: lightweight t() helper for non-React modules - scripts/check-i18n.js: scans src/ for hardcoded Chinese strings - scripts/audit-locales.js: verifies JSON key parity across all locales - package.json: registers lint:i18n and audit:locales scripts --- package.json | 2 + scripts/audit-locales.js | 58 ++++++++++++++++++++++++++ scripts/check-i18n.js | 88 ++++++++++++++++++++++++++++++++++++++++ src/lib/i18n.ts | 65 +++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 scripts/audit-locales.js create mode 100644 scripts/check-i18n.js create mode 100644 src/lib/i18n.ts diff --git a/package.json b/package.json index 92a233ae3..402f63083 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build --turbopack", "start": "next start --turbopack -p 3456", "lint": "next lint", + "lint:i18n": "node scripts/check-i18n.js", + "audit:locales": "node scripts/audit-locales.js", "tauri": "tauri", "docs:build": "npm --prefix ./docs run build", "sync-version": "./scripts/sync-version.sh", diff --git a/scripts/audit-locales.js b/scripts/audit-locales.js new file mode 100644 index 000000000..d519b0ead --- /dev/null +++ b/scripts/audit-locales.js @@ -0,0 +1,58 @@ +/** + * Verifies that every key in messages/zh.json exists in all other locale files. + * Exits with code 1 if any locale is missing keys. + */ + +const fs = require('fs'); +const path = require('path'); + +const MESSAGES_DIR = path.join(__dirname, '..', 'messages'); +const LOCALES = ['en.json', 'ja.json', 'pt-BR.json', 'zh-TW.json']; + +function getAllKeys(obj, prefix = '') { + const keys = new Set(); + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null && !Array.isArray(v)) { + for (const child of getAllKeys(v, full)) { + keys.add(child); + } + } else { + keys.add(full); + } + } + return keys; +} + +const zh = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, 'zh.json'), 'utf-8')); +const zhKeys = getAllKeys(zh); + +let failed = false; +for (const file of LOCALES) { + const fullPath = path.join(MESSAGES_DIR, file); + if (!fs.existsSync(fullPath)) { + console.error(`❌ Missing locale file: ${file}`); + failed = true; + continue; + } + const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + const keys = getAllKeys(data); + const missing = []; + for (const k of zhKeys) { + if (!keys.has(k)) { + missing.push(k); + } + } + if (missing.length > 0) { + console.error(`❌ ${file} missing ${missing.length} key(s):`); + missing.slice(0, 20).forEach((k) => console.error(' ' + k)); + if (missing.length > 20) { + console.error(` ... and ${missing.length - 20} more`); + } + failed = true; + } else { + console.log(`✅ ${file} has all ${zhKeys.size} keys`); + } +} + +process.exit(failed ? 1 : 0); diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js new file mode 100644 index 000000000..36ab3d1e6 --- /dev/null +++ b/scripts/check-i18n.js @@ -0,0 +1,88 @@ +/** + * Regression guard: scans src/ for Chinese characters in UI-facing code. + * Exits with code 1 if new hardcoded Chinese strings are found. + */ + +const fs = require('fs'); +const path = require('path'); + +const SRC_DIR = path.join(__dirname, '..', 'src'); + +// Allow-list: strings that are intentionally Chinese (model names, comments, etc.) +const ALLOWLIST = new Set([ + '简体中文', + '日本語', + 'Français', + '한국어', + 'Português', + 'বাংলা', + 'Italiano', + 'فارسی', + 'Русский', + 'Čeština', + 'Qwen/Qwen3-8B', + 'BAAI/bge-m3', + 'OpenGVLab/InternVL2-8B', +]); + +// Extensions to scan +const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']); + +function hasChinese(str) { + return /[\u4e00-\u9fff]/.test(str); +} + +function extractStringLiterals(line) { + const strings = []; + const singleQuote = /'((?:[^'\\]|\\.)*)'/g; + const doubleQuote = /"((?:[^"\\]|\\.)*)"/g; + const backtick = /`((?:[^`\\]|\\.)*)`/g; + let m; + while ((m = singleQuote.exec(line)) !== null) strings.push(m[1]); + while ((m = doubleQuote.exec(line)) !== null) strings.push(m[1]); + while ((m = backtick.exec(line)) !== null) strings.push(m[1]); + return strings; +} + +const violations = []; + +function scanFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + lines.forEach((line, idx) => { + // Skip import lines that load messages JSON (those contain locale codes, not UI) + if (line.includes('messages/') && line.includes('import')) return; + // Skip comment-only lines + if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) return; + + const strings = extractStringLiterals(line); + for (const str of strings) { + if (!hasChinese(str)) continue; + if (ALLOWLIST.has(str)) continue; + violations.push(`${filePath}:${idx + 1}: ${str.slice(0, 60)}`); + } + }); +} + +function walk(dir) { + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + const stat = fs.statSync(full); + if (stat.isDirectory()) { + walk(full); + } else if (EXTENSIONS.has(path.extname(full))) { + scanFile(full); + } + } +} + +walk(SRC_DIR); + +if (violations.length > 0) { + console.error(`❌ Found ${violations.length} hardcoded Chinese string(s):`); + violations.forEach((v) => console.error(' ' + v)); + process.exit(1); +} else { + console.log('✅ No hardcoded Chinese strings found in src/'); + process.exit(0); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 000000000..0b7baa1f2 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,65 @@ +/** + * Lightweight i18n helper for non-React modules (Zustand stores, sync utilities). + * Reads the active locale from localStorage (same key used by NextIntlProvider) + * and returns a synchronous t() function backed by the loaded messages. + * + * Messages are cached per locale to avoid repeated disk reads. + */ + +const CACHE = new Map>(); + +function getLocale(): string { + if (typeof localStorage !== 'undefined') { + return localStorage.getItem('app-language') || 'zh'; + } + return 'zh'; +} + +function resolvePath(obj: any, path: string): string | undefined { + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + return typeof current === 'string' ? current : undefined; +} + +async function loadMessages(locale: string): Promise> { + if (CACHE.has(locale)) { + return CACHE.get(locale)!; + } + try { + const mod = await import(`../../messages/${locale}.json`); + const messages = mod.default || mod; + CACHE.set(locale, messages); + return messages; + } catch { + const fallback = await import(`../../messages/zh.json`); + const messages = fallback.default || fallback; + CACHE.set(locale, messages); + return messages; + } +} + +export async function getI18n(): Promise<{ t: (key: string) => string }> { + const locale = getLocale(); + const messages = await loadMessages(locale); + return { + t(key: string): string { + const value = resolvePath(messages, key); + if (typeof value === 'string') { + return value; + } + // fallback to zh if missing in target locale + const fallback = resolvePath(CACHE.get('zh') || messages, key); + return fallback ?? key; + }, + }; +} + +export function clearI18nCache(): void { + CACHE.clear(); +} From ecac47d1dd37af0b35c939a42c08fe2e202bfaf5 Mon Sep 17 00:00:00 2001 From: panoskava Date: Fri, 29 May 2026 20:31:23 +0300 Subject: [PATCH 2/3] refactor(i18n): tighten check-i18n allow-list and improve detection --- scripts/check-i18n.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js index 36ab3d1e6..bb470f950 100644 --- a/scripts/check-i18n.js +++ b/scripts/check-i18n.js @@ -12,17 +12,6 @@ const SRC_DIR = path.join(__dirname, '..', 'src'); const ALLOWLIST = new Set([ '简体中文', '日本語', - 'Français', - '한국어', - 'Português', - 'বাংলা', - 'Italiano', - 'فارسی', - 'Русский', - 'Čeština', - 'Qwen/Qwen3-8B', - 'BAAI/bge-m3', - 'OpenGVLab/InternVL2-8B', ]); // Extensions to scan @@ -49,11 +38,24 @@ const violations = []; function scanFile(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); + let inBlockComment = false; lines.forEach((line, idx) => { + // Track block comment state + if (inBlockComment) { + if (line.includes('*/')) { + inBlockComment = false; + } + return; + } + if (line.includes('/*')) { + inBlockComment = !line.includes('*/'); + if (inBlockComment) return; + } + // Skip import lines that load messages JSON (those contain locale codes, not UI) if (line.includes('messages/') && line.includes('import')) return; // Skip comment-only lines - if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) return; + if (line.trim().startsWith('//')) return; const strings = extractStringLiterals(line); for (const str of strings) { @@ -61,6 +63,15 @@ function scanFile(filePath) { if (ALLOWLIST.has(str)) continue; violations.push(`${filePath}:${idx + 1}: ${str.slice(0, 60)}`); } + + // Also catch raw JSX text nodes (e.g. 保存) + const jsxTextRegex = />\s*([^<]*[\u4e00-\u9fff][^<]*)\s* Date: Fri, 29 May 2026 20:57:31 +0300 Subject: [PATCH 3/3] feat(i18n): fix remaining Chinese strings in en.json - Translate record.mark.note.confirm/cancel/removeThinking to English - Translate record.chat.empty.quickPrompts to English - Achieves zero Chinese characters in en.json --- messages/en.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/messages/en.json b/messages/en.json index 1ec6b4191..64f9a461e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1757,9 +1757,9 @@ "organizeAs": "Organize as", "template": "Template", "setting": "Settings", - "confirm": "确认", - "cancel": "取消", - "removeThinking": "移除思考过程", + "confirm": "Confirm", + "cancel": "Cancel", + "removeThinking": "Remove thinking process", "stop": "Stop" } }, @@ -1800,11 +1800,11 @@ "noMatchingConversations": "No matching conversations found", "noConversationHistory": "No conversation history yet", "quickPrompts": { - "title": "快速开始", - "writeNote": "帮我写一篇笔记", - "summarize": "帮我总结这段内容", - "brainstorm": "帮我头脑风暴一些想法", - "explain": "帮我解释这个概念" + "title": "Quick Start", + "writeNote": "Help me write a note", + "summarize": "Help me summarize this content", + "brainstorm": "Help me brainstorm some ideas", + "explain": "Help me explain this concept" } }, "newChat": "New Chat with New Tag",