Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1757,9 +1757,9 @@
"organizeAs": "Organize as",
"template": "Template",
"setting": "Settings",
"confirm": "确认",
"cancel": "取消",
"removeThinking": "移除思考过程",
"confirm": "Confirm",
"cancel": "Cancel",
"removeThinking": "Remove thinking process",
"stop": "Stop"
}
},
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions scripts/audit-locales.js
Original file line number Diff line number Diff line change
@@ -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);
99 changes: 99 additions & 0 deletions scripts/check-i18n.js
Original file line number Diff line number Diff line change
@@ -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. <span>保存</span>)
const jsxTextRegex = />\s*([^<]*[\u4e00-\u9fff][^<]*)\s*</g;
let jsxMatch;
while ((jsxMatch = jsxTextRegex.exec(line)) !== null) {
const text = jsxMatch[1].trim();
if (ALLOWLIST.has(text)) continue;
violations.push(`${filePath}:${idx + 1}: ${text.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);
}
65 changes: 65 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, any>>();

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<Record<string, any>> {
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();
}