diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 729ef9c..456286b 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -8,19 +8,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@v4 + + - name: Spellcheck (typos) + uses: crate-ci/typos@v1 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v4 with: - node-version: 26 + node-version: 22 cache: 'npm' - name: Install Dependencies run: npm install --legacy-peer-deps + + - name: Validate i18n Translations + run: npm run check:i18n + + - name: Typecheck + run: npm run typecheck + - name: Build Project - # TODO: Fix all lints and replace with "build" - run: npm run buildWithoutLints + run: npm run build - name: Deploy Static Files to VPS uses: appleboy/scp-action@v1.0.0 with: diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..4e525bd --- /dev/null +++ b/_typos.toml @@ -0,0 +1,26 @@ +[files] +extend-exclude = [ + "src/locales/de.json", + "src/locales/es.json", + "src/locales/fr.json", + "src/locales/it.json", + "src/locales/ja.json", + "src/locales/ko.json", + "src/locales/nl.json", + "src/locales/pl.json", + "src/locales/pt.json", + "src/locales/ru.json", + "src/locales/tr.json", + "src/locales/zh.json", + "dist/**", + "package-lock.json" +] + +[default.extend-words] +wasm = "wasm" +preorder = "preorder" +adwall = "adwall" +PumpkinMC = "PumpkinMC" +pumpkinmc = "pumpkinmc" +Mis = "Mis" +mis = "mis" diff --git a/package.json b/package.json index 8eb52e2..eb7670a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "buildWithoutLints": "vite build", - "lint": "eslint .", + "lint": "eslint . && npm run check:i18n", + "typecheck": "tsc -b", + "check:i18n": "node scripts/validate-i18n.mjs", + "test": "npm run check:i18n && npm run typecheck", "preview": "vite preview" }, "dependencies": { diff --git a/scripts/validate-i18n.mjs b/scripts/validate-i18n.mjs new file mode 100644 index 0000000..f2a5a0e --- /dev/null +++ b/scripts/validate-i18n.mjs @@ -0,0 +1,158 @@ +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 rootDir = path.resolve(__dirname, '..'); +const localesDir = path.join(rootDir, 'src', 'locales'); +const srcDir = path.join(rootDir, 'src'); + +let hasErrors = false; + +function logError(msg) { + console.error(`\x1b[31m✖ ${msg}\x1b[0m`); + hasErrors = true; +} + +function logSuccess(msg) { + console.log(`\x1b[32m✔ ${msg}\x1b[0m`); +} + +function logInfo(msg) { + console.log(`\x1b[36mℹ ${msg}\x1b[0m`); +} + +// 1. Load and parse all locale files +logInfo('Validating locale JSON files...'); +const localeFiles = fs.readdirSync(localesDir).filter(f => f.endsWith('.json')); +const locales = {}; + +for (const file of localeFiles) { + const lang = path.basename(file, '.json'); + const filePath = path.join(localesDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + locales[lang] = JSON.parse(content); + } catch (err) { + logError(`Invalid JSON in ${file}: ${err.message}`); + } +} + +if (!locales.en) { + logError('Reference locale en.json not found!'); + process.exit(1); +} + +// Helper to flatten nested object keys +function flattenKeys(obj, prefix = '') { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + result[fullKey] = { type: 'object', value }; + Object.assign(result, flattenKeys(value, fullKey)); + } else { + result[fullKey] = { type: typeof value, value }; + } + } + return result; +} + +// Helper to get nested value by dot path +function getNestedValue(obj, keyPath) { + const parts = keyPath.split('.'); + let current = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return undefined; + } + } + return current; +} + +const enFlat = flattenKeys(locales.en); +const enLeafKeys = Object.keys(enFlat).filter(k => enFlat[k].type === 'string'); + +logSuccess(`Reference locale en.json has ${enLeafKeys.length} string keys.`); + +// 2. Check parity of all other locales against en.json +logInfo('Checking translation parity across all locales...'); +for (const [lang, data] of Object.entries(locales)) { + if (lang === 'en') continue; + const langFlat = flattenKeys(data); + + for (const key of enLeafKeys) { + if (!langFlat[key]) { + logError(`[${lang}.json] Missing translation key: "${key}"`); + } else if (langFlat[key].type !== 'string') { + logError(`[${lang}.json] Key "${key}" should be string, found ${langFlat[key].type}`); + } else if (typeof langFlat[key].value === 'string' && langFlat[key].value.trim() === '') { + logError(`[${lang}.json] Key "${key}" is empty string`); + } + } + + for (const key of Object.keys(langFlat)) { + if (langFlat[key].type === 'string' && !enFlat[key]) { + logError(`[${lang}.json] Extraneous translation key not in en.json: "${key}"`); + } + } +} + +// 3. Scan source code for t('...') usages +logInfo('Scanning source code for t(...) translation calls...'); +const tRegex = /\bt\(\s*['"`]([^'"`]+)['"`]/g; + +function scanDir(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'locales' || entry.name === 'node_modules' || entry.name === 'dist') { + continue; + } + scanDir(fullPath); + } else if (/\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + const lines = content.split('\n'); + const relPath = path.relative(rootDir, fullPath); + + lines.forEach((line, lineIndex) => { + // Strip single line comments + const cleanLine = line.replace(/\/\/.*$/, ''); + let match; + tRegex.lastIndex = 0; + while ((match = tRegex.exec(cleanLine)) !== null) { + const key = match[1]; + const lineNum = lineIndex + 1; + + // Check if key exists in en.json + const value = getNestedValue(locales.en, key); + + if (value === undefined) { + logError(`${relPath}:${lineNum} - Translation key "${key}" does not exist in en.json`); + } else if (typeof value === 'object' && value !== null) { + logError( + `${relPath}:${lineNum} - Translation key "${key}" returned an object instead of string! Did you mean a nested key like "${key}.${Object.keys(value)[0]}"?` + ); + } else if (typeof value !== 'string') { + logError(`${relPath}:${lineNum} - Translation key "${key}" is of type ${typeof value}, expected string`); + } + } + }); + } + } +} + +scanDir(srcDir); + +if (hasErrors) { + console.error('\n\x1b[31m❌ i18n validation failed with errors above.\x1b[0m\n'); + process.exit(1); +} else { + console.log('\n\x1b[32m✔ All translation keys and locale files validated successfully!\x1b[0m\n'); + process.exit(0); +} diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts new file mode 100644 index 0000000..f5e5941 --- /dev/null +++ b/src/@types/i18next.d.ts @@ -0,0 +1,11 @@ +import 'i18next'; +import type enTranslation from '../locales/en.json'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: { + translation: typeof enTranslation; + }; + } +} diff --git a/src/App.tsx b/src/App.tsx index 6757e0e..12e9745 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -307,9 +307,8 @@ const Navbar = ({ user }: any) => {
{t('common.loading')}
++ {error || 'The requested plugin could not be found.'} +
+ + {t('common.back')} + +