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", 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..bb470f950 --- /dev/null +++ b/scripts/check-i18n.js @@ -0,0 +1,99 @@ +/** + * 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([ + '简体中文', + '日本語', +]); + +// 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'); + 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('//')) 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)}`); + } + + // Also catch raw JSX text nodes (e.g. 保存) + const jsxTextRegex = />\s*([^<]*[\u4e00-\u9fff][^<]*)\s* 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(); +}