Browse Source

feat(i18n): enforce translation synchronization across all languages

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
b2ccf997b5
  1. 1
      package.json
  2. 135
      scripts/check-translations.js
  3. 150
      src/components/layout/ServerStatusDialog.tsx
  4. 5
      src/features/dashboard/DashboardHome.tsx
  5. 12
      src/locales/__tests__/locales.test.ts
  6. 3
      src/locales/ar.json
  7. 3
      src/locales/en.json
  8. 3
      src/locales/fa.json

1
package.json

@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"check:i18n": "node scripts/check-translations.js",
"test": "vitest run", "test": "vitest run",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"

135
scripts/check-translations.js

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Translation Synchronization Checker for aqila_panel.
* Validates that fa.json, en.json, and ar.json:
* 1. Exist and have valid JSON syntax.
* 2. Contain identical keys (nested dot-notation paths).
* 3. Have no null, undefined, or empty/whitespace values.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const LOCALES_DIR = path.resolve(__dirname, '../src/locales')
const TARGET_LANGS = ['fa', 'en', 'ar']
function loadJson(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(raw)
} catch (err) {
console.error(`❌ Failed to read or parse JSON file: ${filePath}`)
console.error(err.message)
process.exit(1)
}
}
/**
* Recursively flattens an object to dot-notation paths and validates values.
* Returns { keys: Map<string, string>, emptyKeys: string[] }
*/
function extractKeysAndValidate(obj, prefix = '') {
const keys = new Map()
const emptyKeys = []
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key
if (value === null || value === undefined) {
emptyKeys.push(fullPath)
keys.set(fullPath, '')
} else if (typeof value === 'object' && !Array.isArray(value)) {
const child = extractKeysAndValidate(value, fullPath)
for (const [childKey, childVal] of child.keys.entries()) {
keys.set(childKey, childVal)
}
emptyKeys.push(...child.emptyKeys)
} else if (typeof value === 'string') {
if (value.trim() === '') {
emptyKeys.push(fullPath)
}
keys.set(fullPath, value)
} else {
keys.set(fullPath, String(value))
}
}
return { keys, emptyKeys }
}
export function validateTranslations() {
console.log('🔍 Checking translation synchronization for aqila_panel locales...')
console.log(`📁 Directory: ${LOCALES_DIR}\n`)
const data = {}
const extracted = {}
for (const lang of TARGET_LANGS) {
const filePath = path.join(LOCALES_DIR, `${lang}.json`)
if (!fs.existsSync(filePath)) {
console.error(`❌ Missing locale file: ${filePath}`)
return { success: false, errors: [`Missing locale file: ${filePath}`] }
}
data[lang] = loadJson(filePath)
extracted[lang] = extractKeysAndValidate(data[lang])
}
const allKeyPaths = new Set()
for (const lang of TARGET_LANGS) {
for (const key of extracted[lang].keys.keys()) {
allKeyPaths.add(key)
}
}
const errors = []
// Check 1: Empty values
for (const lang of TARGET_LANGS) {
if (extracted[lang].emptyKeys.length > 0) {
errors.push(
`[${lang}.json] Contains ${extracted[lang].emptyKeys.length} empty value(s):\n - ` +
extracted[lang].emptyKeys.join('\n - ')
)
}
}
// Check 2: Missing keys across languages
for (const key of allKeyPaths) {
const missingIn = TARGET_LANGS.filter((lang) => !extracted[lang].keys.has(key))
if (missingIn.length > 0) {
errors.push(
`Key "${key}" is missing in: [${missingIn.map((l) => `${l}.json`).join(', ')}]`
)
}
}
// Check 3: Key counts
const counts = TARGET_LANGS.map((lang) => `${lang}.json: ${extracted[lang].keys.size} keys`).join(' | ')
if (errors.length > 0) {
console.error('❌ Translation synchronization check FAILED!\n')
console.error(`📊 Key counts: ${counts}\n`)
console.error('🚨 Details:')
errors.forEach((err, idx) => console.error(`${idx + 1}. ${err}`))
console.error('\n⚠️ Please ensure all keys exist and have non-empty values in fa.json, en.json, and ar.json.\n')
return { success: false, errors }
}
console.log('✅ Translation synchronization check PASSED!')
console.log(`📊 Total keys verified: ${allKeyPaths.size} keys`)
console.log(` ${counts}\n`)
return { success: true, count: allKeyPaths.size }
}
// Run if called directly from CLI
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const result = validateTranslations()
if (!result.success) {
process.exit(1)
}
}

150
src/components/layout/ServerStatusDialog.tsx

@ -5,6 +5,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
DialogClose, DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -12,6 +13,7 @@ import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { useServerHealthStore } from '@/stores/server-health' import { useServerHealthStore } from '@/stores/server-health'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { cn } from '@/lib/utils'
interface ServerStatusDialogProps { interface ServerStatusDialogProps {
open: boolean open: boolean
@ -33,53 +35,25 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[540px] p-6 space-y-5"> <DialogContent className="max-w-[540px] p-6 space-y-5">
<DialogHeader className="pb-3 border-b border-border-soft"> <DialogHeader className="pb-3 border-b border-border-soft">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className={`flex size-9 items-center justify-center rounded-xl ${
health.status === 'connected'
? 'bg-emerald-500/15 text-emerald-400'
: health.status === 'checking'
? 'bg-amber-500/15 text-amber-400'
: 'bg-rose-500/15 text-rose-400'
}`}
>
<Ic name="server" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-bold text-foreground">
وضعیت سیستم و سرور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب
</DialogDescription>
</div>
<div className="flex items-center gap-3">
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${
health.status === 'connected'
? 'bg-emerald-500/15 text-emerald-400'
: health.status === 'checking'
? 'bg-amber-500/15 text-amber-400'
: 'bg-rose-500/15 text-rose-400'
}`}
>
<Ic name="server" className="size-5" />
</div> </div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
isLoading={isRefreshing}
loadingText="در حال تست..."
className="h-8 gap-1.5 text-xs"
>
<Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
</Button>
<DialogClose asChild>
<button
type="button"
className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
title="بستن"
aria-label="بستن پنجره"
>
<Ic name="x" className="size-4" />
</button>
</DialogClose>
<div>
<DialogTitle className="text-base font-bold text-foreground">
وضعیت سیستم و سرور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب
</DialogDescription>
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
@ -100,22 +74,35 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
/> />
وضعیت سرور جنگو (Backend) وضعیت سرور جنگو (Backend)
</span> </span>
<Badge
variant="outline"
className={
health.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
health.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
: health.status === 'checking'
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-rose-500/30 bg-rose-500/10 text-rose-400'
}
>
{health.status === 'connected'
? 'آنلاین و متصل'
: health.status === 'checking' : health.status === 'checking'
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-rose-500/30 bg-rose-500/10 text-rose-400'
}
>
{health.status === 'connected'
? 'آنلاین و متصل'
: health.status === 'checking'
? 'در حال بررسی...'
: 'قطع ارتباط'}
</Badge>
? 'در حال بررسی...'
: 'قطع ارتباط'}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={isRefreshing}
className="size-7 rounded-lg text-grey-3 hover:text-foreground hover:bg-muted/80 cursor-pointer"
title="بررسی مجدد ارتباط"
>
<Ic name="refresh" className={cn('size-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div> </div>
<div className="flex items-center justify-between text-xs text-grey-2 pt-1 border-t border-border-soft"> <div className="flex items-center justify-between text-xs text-grey-2 pt-1 border-t border-border-soft">
<span className="text-grey-3">{health.message}</span> <span className="text-grey-3">{health.message}</span>
@ -186,6 +173,47 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
</div> </div>
</div> </div>
</div> </div>
<DialogFooter className="flex-row items-center justify-between pt-4 border-t border-border-soft">
<div className="text-xs text-grey-3 flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${
health.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_6px_#10b981]'
: health.status === 'checking'
? 'bg-amber-400'
: 'bg-rose-500 shadow-[0_0_6px_#f43f5e]'
}`}
/>
<span>
{health.status === 'connected'
? 'ارتباط با سرور پایدار است'
: health.status === 'checking'
? 'در حال آزمایش اتصال...'
: 'سرور در دسترس نیست'}
</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl h-9 px-4 text-xs cursor-pointer">
بستن
</Button>
</DialogClose>
<Button
type="button"
variant="default"
size="sm"
onClick={handleRefresh}
isLoading={isRefreshing}
loadingText="در حال بررسی..."
className="rounded-xl h-9 px-4 text-xs gap-1.5 cursor-pointer"
>
<Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
</Button>
</div>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

5
src/features/dashboard/DashboardHome.tsx

@ -1,9 +1,11 @@
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useTranslation } from '@/i18n'
export default function DashboardHome() { export default function DashboardHome() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const { t } = useTranslation()
return ( return (
<div className="space-y-6 pt-2"> <div className="space-y-6 pt-2">
@ -21,6 +23,9 @@ export default function DashboardHome() {
<p className="text-xs text-grey-3"> <p className="text-xs text-grey-3">
پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست. پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.
</p> </p>
<p className="text-xs text-primary font-medium mt-1">
{t('common.testSyncNotice')}
</p>
</div> </div>
</div> </div>

12
src/locales/__tests__/locales.test.ts

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { validateTranslations } from '../../../scripts/check-translations.js'
describe('Locale Translations Synchronization', () => {
it('should ensure fa.json, en.json, and ar.json have identical keys and no empty values', () => {
const result = validateTranslations()
expect(result.success).toBe(true)
if (!result.success) {
expect(result.errors).toEqual([])
}
})
})

3
src/locales/ar.json

@ -64,7 +64,8 @@
"next": "التالي", "next": "التالي",
"showing": "عرض", "showing": "عرض",
"to": "إلى", "to": "إلى",
"of": "من"
"of": "من",
"testSyncNotice": "هذه رسالة تجريبية لاختبار مزامنة اللغات."
}, },
"billing": { "billing": {
"title": "إدارة الفواتير والمدفوعات", "title": "إدارة الفواتير والمدفوعات",

3
src/locales/en.json

@ -64,7 +64,8 @@
"next": "Next", "next": "Next",
"showing": "Showing", "showing": "Showing",
"to": "to", "to": "to",
"of": "of"
"of": "of",
"testSyncNotice": "This is a test message for language synchronization verification."
}, },
"billing": { "billing": {
"title": "Invoices & Billing Management", "title": "Invoices & Billing Management",

3
src/locales/fa.json

@ -64,7 +64,8 @@
"next": "بعدی", "next": "بعدی",
"showing": "نمایش", "showing": "نمایش",
"to": "تا", "to": "تا",
"of": "از"
"of": "از",
"testSyncNotice": "این یک پیام آزمایشی برای تست هماهنگی زبان‌هاست"
}, },
"billing": { "billing": {
"title": "مدیریت صورت‌حساب‌ها و فاکتورها", "title": "مدیریت صورت‌حساب‌ها و فاکتورها",

Loading…
Cancel
Save