parent
25bd99abc5
commit
47cf6171c5
14 changed files with 291 additions and 62 deletions
|
@ -17,9 +17,7 @@
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "STARSET Mirror",
|
"title": "STARSET Mirror",
|
||||||
"description": "STARSET Mirror, connecting STARSET and you."
|
"description": "STARSET Mirror, connecting STARSET and you."
|
||||||
},
|
}
|
||||||
"latestUpdates": "Latest Updates",
|
|
||||||
"featuredProjects": "Featured Projects"
|
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"meta": {
|
"meta": {
|
||||||
|
@ -49,7 +47,6 @@
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"back_to_list": "Back to Updates",
|
"back_to_list": "Back to Updates",
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "All Updates",
|
|
||||||
"title": "Filter by Tags",
|
"title": "Filter by Tags",
|
||||||
"search_placeholder": "Search tags...",
|
"search_placeholder": "Search tags...",
|
||||||
"no_results": "No matching tags found",
|
"no_results": "No matching tags found",
|
||||||
|
@ -76,7 +73,6 @@
|
||||||
"title": "Contributors - STARSET Mirror",
|
"title": "Contributors - STARSET Mirror",
|
||||||
"description": "Meet the amazing people behind STARSET Mirror. Our contributors work tirelessly to bring STARSET closer to you."
|
"description": "Meet the amazing people behind STARSET Mirror. Our contributors work tirelessly to bring STARSET closer to you."
|
||||||
},
|
},
|
||||||
"title": "Contributors",
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"contributors": "Contributors",
|
"contributors": "Contributors",
|
||||||
"sponsors": "Sponsors"
|
"sponsors": "Sponsors"
|
||||||
|
@ -99,16 +95,17 @@
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"mainContent": "Main content",
|
"mainContent": "Main content",
|
||||||
"breadcrumb": "Page navigation",
|
|
||||||
"navigation": "Site navigation",
|
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
|
"navigation": "Navigation",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"darkMode": "Dark mode",
|
"language": "Change language",
|
||||||
"language": "Language selection",
|
"darkMode": "Toggle dark mode",
|
||||||
"loading": "Loading",
|
"openMenu": "Open menu",
|
||||||
"error": "Error",
|
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"openMenu": "Open menu"
|
"error": "Error",
|
||||||
|
"loading": "Loading",
|
||||||
|
"comments": "Comments section",
|
||||||
|
"breadcrumb": "Breadcrumb"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Connecting STARSET and You",
|
"title": "Connecting STARSET and You",
|
||||||
|
@ -174,9 +171,7 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "An error occurred",
|
"error": "An error occurred"
|
||||||
"retry": "Retry",
|
|
||||||
"close": "Close"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -17,22 +17,21 @@
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "STARSET Mirror",
|
"title": "STARSET Mirror",
|
||||||
"description": "STARSET Mirror,连接星落与你"
|
"description": "STARSET Mirror,连接星落与你"
|
||||||
},
|
}
|
||||||
"latestUpdates": "最新动态",
|
|
||||||
"featuredProjects": "精选项目"
|
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"mainContent": "主要内容",
|
"mainContent": "主要内容",
|
||||||
"breadcrumb": "页面导航",
|
|
||||||
"navigation": "网站导航",
|
|
||||||
"menu": "菜单",
|
"menu": "菜单",
|
||||||
|
"navigation": "导航",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"darkMode": "深色模式",
|
"language": "切换语言",
|
||||||
"language": "语言选择",
|
"darkMode": "切换深色模式",
|
||||||
"loading": "加载中",
|
"openMenu": "打开菜单",
|
||||||
"error": "错误",
|
|
||||||
"closeMenu": "关闭菜单",
|
"closeMenu": "关闭菜单",
|
||||||
"openMenu": "打开菜单"
|
"error": "错误",
|
||||||
|
"loading": "加载中",
|
||||||
|
"comments": "评论区",
|
||||||
|
"breadcrumb": "面包屑导航"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "连接星落与你",
|
"title": "连接星落与你",
|
||||||
|
@ -70,7 +69,6 @@
|
||||||
"loading": "正在加载...",
|
"loading": "正在加载...",
|
||||||
"back_to_list": "返回动态列表",
|
"back_to_list": "返回动态列表",
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "全部动态",
|
|
||||||
"title": "按标签筛选",
|
"title": "按标签筛选",
|
||||||
"search_placeholder": "搜索标签...",
|
"search_placeholder": "搜索标签...",
|
||||||
"no_results": "未找到匹配的标签",
|
"no_results": "未找到匹配的标签",
|
||||||
|
@ -97,7 +95,6 @@
|
||||||
"title": "贡献者 - STARSET Mirror",
|
"title": "贡献者 - STARSET Mirror",
|
||||||
"description": "认识 STARSET Mirror 背后的优秀贡献者们。他们不懈努力,让 STARSET 与你更近。"
|
"description": "认识 STARSET Mirror 背后的优秀贡献者们。他们不懈努力,让 STARSET 与你更近。"
|
||||||
},
|
},
|
||||||
"title": "贡献者",
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"contributors": "贡献者",
|
"contributors": "贡献者",
|
||||||
"sponsors": "赞助人"
|
"sponsors": "赞助人"
|
||||||
|
@ -168,9 +165,7 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"error": "发生错误",
|
"error": "发生错误"
|
||||||
"retry": "重试",
|
|
||||||
"close": "关闭"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -17,22 +17,21 @@
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "STARSET Mirror",
|
"title": "STARSET Mirror",
|
||||||
"description": "STARSET Mirror,連結星落與你。"
|
"description": "STARSET Mirror,連結星落與你。"
|
||||||
},
|
}
|
||||||
"latestUpdates": "最新動態",
|
|
||||||
"featuredProjects": "查看專案"
|
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"mainContent": "主要內容",
|
"mainContent": "主要內容",
|
||||||
"breadcrumb": "頁面導航",
|
|
||||||
"navigation": "網站導航",
|
|
||||||
"menu": "選單",
|
"menu": "選單",
|
||||||
|
"navigation": "導航",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"darkMode": "深色模式",
|
"language": "切換語言",
|
||||||
"language": "語言選擇",
|
"darkMode": "切換深色模式",
|
||||||
"loading": "載入中",
|
"openMenu": "打開選單",
|
||||||
"error": "錯誤",
|
|
||||||
"closeMenu": "關閉選單",
|
"closeMenu": "關閉選單",
|
||||||
"openMenu": "開啟選單"
|
"error": "錯誤",
|
||||||
|
"loading": "載入中",
|
||||||
|
"comments": "評論區",
|
||||||
|
"breadcrumb": "麵包屑導航"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "連接星落與你",
|
"title": "連接星落與你",
|
||||||
|
@ -43,6 +42,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
"meta": {
|
||||||
|
"title": "專案 - STARSET Mirror",
|
||||||
|
"description": "探索我們為 STARSET 和社群打造的專案。從翻譯到社群服務,了解我們如何為社群貢獻力量。"
|
||||||
|
},
|
||||||
"title": "專案",
|
"title": "專案",
|
||||||
"tags": {
|
"tags": {
|
||||||
"translation": "翻譯",
|
"translation": "翻譯",
|
||||||
|
@ -58,11 +61,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
|
"meta": {
|
||||||
|
"title": "動態 - STARSET Mirror",
|
||||||
|
"description": "了解 STARSET Mirror 的最新動態、更新和活動。跟隨我們支持 STARSET 社群的脚步。"
|
||||||
|
},
|
||||||
"title": "專案動態",
|
"title": "專案動態",
|
||||||
"loading": "正在載入...",
|
"loading": "正在載入...",
|
||||||
"back_to_list": "返回動態列表",
|
"back_to_list": "返回動態列表",
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "全部動態",
|
|
||||||
"title": "按標籤篩選",
|
"title": "按標籤篩選",
|
||||||
"search_placeholder": "搜尋標籤...",
|
"search_placeholder": "搜尋標籤...",
|
||||||
"no_results": "未找到匹配的標籤",
|
"no_results": "未找到匹配的標籤",
|
||||||
|
@ -85,7 +91,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contributors": {
|
"contributors": {
|
||||||
"title": "貢獻者",
|
"meta": {
|
||||||
|
"title": "貢獻者 - STARSET Mirror",
|
||||||
|
"description": "了解 STARSET Mirror 的核心貢獻者和贊助人。"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"contributors": "貢獻者",
|
"contributors": "貢獻者",
|
||||||
"sponsors": "贊助人"
|
"sponsors": "贊助人"
|
||||||
|
@ -94,6 +103,10 @@
|
||||||
"regular_members": "專案成員 & 社群貢獻者"
|
"regular_members": "專案成員 & 社群貢獻者"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
"meta": {
|
||||||
|
"title": "關於 - STARSET Mirror",
|
||||||
|
"description": "了解 STARSET Mirror 的使命、價值觀,以及我们致力于连接 STARSET 与全球粉丝的愿景。"
|
||||||
|
},
|
||||||
"title": "關於我們",
|
"title": "關於我們",
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "聯絡方式"
|
"title": "聯絡方式"
|
||||||
|
@ -158,9 +171,7 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
"error": "發生錯誤",
|
"error": "發生錯誤"
|
||||||
"retry": "重試",
|
|
||||||
"close": "關閉"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,9 +5,10 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && tsx scripts/generate-rss.ts && tsx scripts/generate-sitemap.ts",
|
"build": "tsx scripts/check-translations.ts && vite build && tsx scripts/generate-rss.ts && tsx scripts/generate-sitemap.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"check-translations": "tsx scripts/check-translations.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
|
@ -36,6 +37,7 @@
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"chalk": "^5.4.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.18",
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
|
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
@ -81,6 +81,9 @@ importers:
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.20(postcss@8.5.1)
|
version: 10.4.20(postcss@8.5.1)
|
||||||
|
chalk:
|
||||||
|
specifier: ^5.4.1
|
||||||
|
version: 5.4.1
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.19.0
|
specifier: ^9.19.0
|
||||||
version: 9.19.0(jiti@1.21.7)
|
version: 9.19.0(jiti@1.21.7)
|
||||||
|
@ -989,6 +992,10 @@ packages:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
chalk@5.4.1:
|
||||||
|
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
|
||||||
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
@ -2942,6 +2949,8 @@ snapshots:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
chalk@5.4.1: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
|
|
183
scripts/check-translations.ts
Normal file
183
scripts/check-translations.ts
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import * as globModule from 'glob';
|
||||||
|
const glob = globModule.glob;
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
interface TranslationMap {
|
||||||
|
[key: string]: string | TranslationMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranslationResult {
|
||||||
|
missingKeys: string[];
|
||||||
|
unusedKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归获取所有翻译键
|
||||||
|
function getAllTranslationKeys(obj: TranslationMap, prefix = ''): string[] {
|
||||||
|
return Object.entries(obj).reduce<string[]>((acc, [key, value]) => {
|
||||||
|
const currentKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return [...acc, ...getAllTranslationKeys(value, currentKey)];
|
||||||
|
}
|
||||||
|
// 如果键以 translation. 开头,移除这个前缀
|
||||||
|
const finalKey = currentKey.startsWith('translation.')
|
||||||
|
? currentKey.substring('translation.'.length)
|
||||||
|
: currentKey;
|
||||||
|
if (!shouldIgnoreKey(finalKey)) {
|
||||||
|
return [...acc, finalKey];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从源代码中提取翻译键的正则表达式
|
||||||
|
const translationKeyRegexes = [
|
||||||
|
/(?:t|i18n\.t)\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*,\s*{[^}]*})?\)/gi, // t('key') or t('key', {})
|
||||||
|
/(?:t|i18n\.t)\(\s*(?:[a-zA-Z][a-zA-Z0-9._]+\s*\?\s*)?['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*:\s*['"]([a-zA-Z][a-zA-Z0-9._-]+)['"])?(?:\s*,\s*{[^}]*})?\)/gi, // t(isOpen ? 'key1' : 'key2') or t('key')
|
||||||
|
/useTranslation\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"]\)/gi, // useTranslation('key')
|
||||||
|
/<Trans[^>]*i18nKey=["']([a-zA-Z][a-zA-Z0-9._-]+)["']/gi, // <Trans i18nKey="key" />
|
||||||
|
];
|
||||||
|
|
||||||
|
// 从文件内容中提取翻译键
|
||||||
|
function extractTranslationKeys(content: string): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const regex of translationKeyRegexes) {
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
// 如果有多个捕获组(三元运算符的情况),添加所有捕获的键
|
||||||
|
for (let i = 1; i < match.length; i++) {
|
||||||
|
const key = match[i];
|
||||||
|
if (key && !shouldIgnoreKey(key)) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略的翻译键
|
||||||
|
const ignoredKeys = new Set([
|
||||||
|
'content-type',
|
||||||
|
'page',
|
||||||
|
'tags'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 忽略的翻译键前缀
|
||||||
|
const ignoredPrefixes = [
|
||||||
|
'projects.status.',
|
||||||
|
'projects.tags.',
|
||||||
|
'social.links',
|
||||||
|
'updates.tags',
|
||||||
|
'data.'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 检查键是否应该被忽略
|
||||||
|
function shouldIgnoreKey(key: string): boolean {
|
||||||
|
if (ignoredKeys.has(key)) return true;
|
||||||
|
return ignoredPrefixes.some(prefix => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 比较翻译键
|
||||||
|
function compareTranslations(usedKeys: Set<string>, availableKeys: Set<string>): TranslationResult {
|
||||||
|
const missingKeys = Array.from(usedKeys)
|
||||||
|
.filter(key => !availableKeys.has(key))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const unusedKeys = Array.from(availableKeys)
|
||||||
|
.filter(key => !usedKeys.has(key))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return { missingKeys, unusedKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(chalk.blue('Checking translations...'));
|
||||||
|
// 获取所有支持的语言
|
||||||
|
const dataDir = path.resolve('data');
|
||||||
|
const langs = fs.readdirSync(dataDir)
|
||||||
|
.filter(file => fs.statSync(path.join(dataDir, file)).isDirectory())
|
||||||
|
.filter(dir => /^[a-z]{2}-[A-Z]{2}$/.test(dir));
|
||||||
|
|
||||||
|
// 扫描源代码文件
|
||||||
|
const srcFiles = await glob('src/**/*.{ts,tsx,js,jsx}', { absolute: true });
|
||||||
|
const usedKeys = new Set<string>();
|
||||||
|
|
||||||
|
// 从所有源代码文件中提取翻译键
|
||||||
|
for (const file of srcFiles) {
|
||||||
|
const content = await fs.readFile(file, 'utf-8');
|
||||||
|
const keys = extractTranslationKeys(content);
|
||||||
|
keys.forEach(key => usedKeys.add(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个语言的翻译
|
||||||
|
let hasWarnings = false;
|
||||||
|
for (const lang of langs) {
|
||||||
|
const indexPath = path.join(dataDir, lang, 'index.json');
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
const content = await fs.readJson(indexPath);
|
||||||
|
// 确保我们从 translations 对象开始
|
||||||
|
const translationsObj = content.translations || content;
|
||||||
|
const availableKeys = new Set(getAllTranslationKeys(translationsObj));
|
||||||
|
|
||||||
|
const { missingKeys, unusedKeys } = compareTranslations(usedKeys, availableKeys);
|
||||||
|
|
||||||
|
if (missingKeys.length > 0 || unusedKeys.length > 0) {
|
||||||
|
hasWarnings = true;
|
||||||
|
console.log(chalk.yellow(`\n[${lang}]`));
|
||||||
|
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
console.log(chalk.yellow(` Missing translations: (${missingKeys.length} keys)`));
|
||||||
|
// 按命名空间分组输出
|
||||||
|
const groupedKeys = missingKeys.reduce((acc, key) => {
|
||||||
|
const namespace = key.split('.')[0];
|
||||||
|
if (!acc[namespace]) acc[namespace] = [];
|
||||||
|
acc[namespace].push(key);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string[]>);
|
||||||
|
|
||||||
|
Object.entries(groupedKeys)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.forEach(([namespace, keys]) => {
|
||||||
|
console.log(chalk.cyan(`\n ${namespace}:`));
|
||||||
|
keys.forEach(key => {
|
||||||
|
console.log(chalk.red(` ${key}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unusedKeys.length > 0) {
|
||||||
|
console.log(chalk.yellow(`\n Unused translations: (${unusedKeys.length} keys)`));
|
||||||
|
// 按命名空间分组输出
|
||||||
|
const groupedKeys = unusedKeys.reduce((acc, key) => {
|
||||||
|
const namespace = key.split('.')[0];
|
||||||
|
if (!acc[namespace]) acc[namespace] = [];
|
||||||
|
acc[namespace].push(key);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string[]>);
|
||||||
|
|
||||||
|
Object.entries(groupedKeys)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.forEach(([namespace, keys]) => {
|
||||||
|
console.log(chalk.cyan(`\n ${namespace}:`));
|
||||||
|
keys.forEach(key => {
|
||||||
|
console.log(chalk.yellow(` ${key}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWarnings) {
|
||||||
|
console.log(chalk.green('\n✓ All translations are complete and clean!'));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.yellow('\n⚠️ Please review the translation issues listed above.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(chalk.red('Error:'), error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
|
@ -59,10 +59,10 @@ const Comments = ({ title }: CommentsProps) => {
|
||||||
}, [pathname, title])
|
}, [pathname, title])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<section
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="mt-12 p-4 bg-white dark:bg-gray-800 rounded-lg shadow"
|
className="mt-12 p-4 bg-white dark:bg-gray-800 rounded-lg shadow"
|
||||||
aria-label="Comments section"
|
aria-label={t('aria.comments')}
|
||||||
role="complementary"
|
role="complementary"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
const LanguageSwitcher = () => {
|
const LanguageSwitcher = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
@ -32,9 +32,12 @@ const LanguageSwitcher = () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
type="button"
|
||||||
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||||
aria-label="Change language"
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={t('aria.language')}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<Globe className="w-5 h-5" />
|
<Globe className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -5,7 +5,11 @@ const LoadingSpinner: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[200px]" role="status">
|
<div
|
||||||
|
className="flex items-center justify-center min-h-[200px]"
|
||||||
|
role="status"
|
||||||
|
aria-label={t('aria.loading')}
|
||||||
|
>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary dark:border-primary border-t-transparent dark:border-t-transparent" />
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary dark:border-primary border-t-transparent dark:border-t-transparent" />
|
||||||
<span className="sr-only">{t('common.loading')}</span>
|
<span className="sr-only">{t('common.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -34,7 +34,11 @@ const Navbar = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="hidden md:flex items-center">
|
<nav
|
||||||
|
className="hidden md:flex items-center"
|
||||||
|
role="navigation"
|
||||||
|
aria-label={t('aria.navigation')}
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
|
@ -54,7 +58,7 @@ const Navbar = () => {
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Navigation Button */}
|
{/* Mobile Navigation Button */}
|
||||||
<div className="md:hidden flex items-center space-x-1.5">
|
<div className="md:hidden flex items-center space-x-1.5">
|
||||||
|
@ -71,7 +75,11 @@ const Navbar = () => {
|
||||||
|
|
||||||
{/* Mobile Navigation Menu */}
|
{/* Mobile Navigation Menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="md:hidden">
|
<div
|
||||||
|
className="md:hidden"
|
||||||
|
role="menu"
|
||||||
|
aria-label={t('aria.menu')}
|
||||||
|
>
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -23,7 +23,16 @@ const ThemeSwitcher = () => {
|
||||||
items={themes}
|
items={themes}
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={setTheme}
|
onChange={setTheme}
|
||||||
/>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||||
|
aria-label={t('aria.darkMode')}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{currentIcon}
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -112,8 +112,12 @@ const Timeline = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20">
|
<div
|
||||||
<p className="text-red-500">{t('updates.error')}</p>
|
className="text-center py-8"
|
||||||
|
role="alert"
|
||||||
|
aria-label={t('aria.error')}
|
||||||
|
>
|
||||||
|
<p className="text-red-500">{t('common.error')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -211,7 +215,7 @@ const Timeline = () => {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p>{t('updates.no_results')}</p>
|
<p>{t('updates.notFound.title')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
@ -16,6 +17,7 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
@ -31,9 +33,12 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||||
aria-label="Toggle dropdown"
|
aria-label={t(isOpen ? 'aria.closeMenu' : 'aria.openMenu')}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -104,10 +104,11 @@ const TagFilter: React.FC<TagFilterProps> = ({
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
className="pl-10 pr-4 py-2 w-full bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('updates.filter.search_placeholder')}
|
placeholder={t('updates.filter.search_placeholder')}
|
||||||
className="w-full px-4 py-2.5 pl-10 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
aria-label={t('aria.search')}
|
||||||
/>
|
/>
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue