diff --git a/CLAUDE.md b/CLAUDE.md index c7e341ce6f7533b35ce679fe160e845b447e8573..da1e1239a5eec906480851a411dec08fbfdd92c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,3 +220,210 @@ apps/blog/ - Commented-out code (delete it, git has history) - Console.logs in production code (use logger) - Ignoring TypeScript errors with `@ts-ignore` + +--- + +## ESLint Rules for Code Quality + +The project enforces code quality and consistency via ESLint. Run `pnpm lint` after changes to check. All rules are set to **warn** to allow gradual adoption. + +### TypeScript Strict Typing + +| Rule | Purpose | +|------|---------| +| `@typescript-eslint/no-explicit-any` | Disallows `any` type - use `unknown` with type guards | +| `@typescript-eslint/consistent-type-assertions` | Discourages `as Type` - prefer proper typing | +| `@typescript-eslint/no-non-null-assertion` | Discourages `!` operator - prefer proper null checks | +| `@typescript-eslint/no-unused-vars` | Detects unused variables (prefix with `_` to ignore) | +| `@typescript-eslint/ban-ts-comment` | Requires description for `@ts-ignore` (min 10 chars) | + +### Naming Conventions + +| Rule | Convention | +|------|------------| +| Variables | `camelCase`, `UPPER_CASE` (constants), `PascalCase` (React components) | +| Functions | `camelCase`, `PascalCase` (React components) | +| Parameters | `camelCase` (prefix `_` for intentionally unused) | +| Types/Interfaces | `PascalCase` | +| Enum members | `PascalCase` or `UPPER_CASE` | + +**Example violations:** +```typescript +// Bad - snake_case variable +const user_profile = fetchProfile(); + +// Good - camelCase +const userProfile = fetchProfile(); + +// Bad - unused parameter +function handleClick(event) { doSomething(); } + +// Good - prefix with underscore +function handleClick(_event) { doSomething(); } +``` + +### Code Quality + +| Rule | Purpose | +|------|---------| +| `eqeqeq` | Enforce `===` instead of `==` (type-safe comparisons) | +| `prefer-const` | Prefer `const` over `let` when never reassigned | +| `no-console` | Discourage `console.log` - use logger instead | + +### How to Fix Common Issues + +**Instead of `any`:** +```typescript +// Bad +function handleError(error: any) { ... } + +// Good - use unknown with type guard +function handleError(error: unknown) { + if (error instanceof Error) { + logger.error(error, 'Operation failed'); + } +} + +// Good - use specific type +function handleError(error: Error) { ... } +``` + +**Instead of `as Type`:** +```typescript +// Bad +const config = JSON.parse(data) as AppConfig; + +// Good - use type guard +function isAppConfig(obj: unknown): obj is AppConfig { + return typeof obj === 'object' && obj !== null && 'apiUrl' in obj; +} +const parsed = JSON.parse(data); +if (isAppConfig(parsed)) { + const config = parsed; // typed as AppConfig +} + +// Good - use zod schema validation +const configSchema = z.object({ apiUrl: z.string() }); +const config = configSchema.parse(JSON.parse(data)); +``` + +**Instead of `@ts-ignore`:** +```typescript +// Bad - no explanation +// @ts-ignore +someCode(); + +// Good - with explanation +// @ts-ignore - Third-party library types are incorrect for this overload +someCode(); + +// Better - use @ts-expect-error (fails if error disappears) +// @ts-expect-error - Third-party library types are incorrect +someCode(); +``` + +**Instead of non-null assertion:** +```typescript +// Bad +const name = user!.name; + +// Good - explicit check +if (user) { + const name = user.name; +} + +// Good - optional chaining with fallback +const name = user?.name ?? 'Anonymous'; +``` + +### Running Lint +```bash +# Lint specific package +pnpm --filter @hive/blog lint + +# Lint all packages +pnpm lint + +# Auto-fix fixable issues +pnpm --filter @hive/blog lint -- --fix +``` + +### Translation Rules + +**IMPORTANT: Never use inline strings for user-facing text!** + +All user-visible text must use translation keys via the `t()` function. This ensures: +- Proper internationalization (i18n) support +- Consistent text management +- Easy translation to other languages + +**Bad - inline strings:** +```tsx +// DON'T do this + +

Loading...

+No results found +{error &&
Something went wrong
} +``` + +**Good - translation keys:** +```tsx +// DO this + +

{t('global.loading')}

+{t('search_page.no_results')} +{error &&
{t('global.something_went_wrong')}
} +``` + +**Usage pattern:** +```tsx +import { useTranslation } from '@/blog/i18n/client'; + +function MyComponent() { + const { t } = useTranslation('common_blog'); + + return
{t('namespace.key_name')}
; +} +``` + +### Translation Keys Validation + +#### 1. Cross-locale key sync +Validates that all locales have the same keys as English reference. + +```bash +pnpm --filter @hive/blog lint:translations +``` + +**What it checks:** +- Missing keys (present in English but missing in other locales) +- Extra keys (present in other locales but not in English reference) +- Missing translation files + +#### 2. Translation usage validation +Validates that all `t('key')` and `` calls in code reference existing translation keys. + +```bash +# Check for missing keys +pnpm --filter @hive/blog lint:translations:usage + +# Also show potentially unused keys (-- passes flag to the script) +pnpm --filter @hive/blog lint:translations:usage -- --unused +``` + +**What it checks:** +- All `t('key')` function calls reference existing keys +- All `` components reference existing keys +- Reports file and line number for invalid keys +- Optionally reports unused translation keys + +**Reference locale:** English (`en`) is the source of truth. + +**Fixing issues:** +1. Missing keys in code: Add the key to `apps/blog/locales/en/common_blog.json` +2. Missing keys in locales: Copy from English and translate +3. Extra keys: Remove outdated keys or add to English if valid + +**Scripts location:** `scripts/check-blog-translations.js`, `scripts/check-blog-translation-usage.js` + +**Translations location:** `apps/blog/locales/[lang]/common_blog.json` diff --git a/apps/blog/features/search/ai-result.tsx b/apps/blog/features/search/ai-result.tsx index 73b63d6125fded151dc70c0e96f9eb7c1f74f7f9..71e08589ef2570aac90b9f95d9c74a473eaada6c 100644 --- a/apps/blog/features/search/ai-result.tsx +++ b/apps/blog/features/search/ai-result.tsx @@ -146,7 +146,7 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: } if (!searchResults || searchResults.length === 0) { - return
{t('search.no_results')}
; + return
{t('search_page.no_results')}
; } return ( diff --git a/apps/blog/package.json b/apps/blog/package.json index 5019b5c174da18f5c6245f53d6c183dff208dfd9..b1a4cbd25a4b4323e9bb1c2c7c0f2b99ec768d95 100644 --- a/apps/blog/package.json +++ b/apps/blog/package.json @@ -15,6 +15,8 @@ "postbuild": "pnpm run copy:worker && pnpm run copy:assets", "start": "react-env -- next start -p 3000", "lint": "eslint .", + "lint:translations": "node ../../scripts/check-blog-translations.js", + "lint:translations:usage": "node ../../scripts/check-blog-translation-usage.js", "pw:test:local": "playwright test --config=playwright.local3000.config.ts --update-snapshots", "pw:test:local:chromium": "playwright test --project=chromium --config=playwright.local3000.config.ts --headed --update-snapshots", "pw:test:local:firefox": "playwright test --project=firefox --config=playwright.local3000.config.ts --headed --update-snapshots", diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 1cd25321e2c1b04043cce4d2af579a31623cf8c7..3dbe84781432e1ec1f0286794eec279929083cd8 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -27,8 +27,117 @@ module.exports = { } ] } - ] + ], + // Enforce === instead of == for type-safe comparisons + eqeqeq: ['warn', 'always', { null: 'ignore' }], + // Prefer const over let when variable is never reassigned + 'prefer-const': 'warn', + // Discourage console.log in production code - use logger instead + 'no-console': ['warn', { allow: ['warn', 'error'] }] }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: { + // TypeScript strict typing rules - require proper types instead of "any" + '@typescript-eslint/no-explicit-any': 'warn', + // Discourage type assertions (as Type) - prefer proper typing + // Allows 'as const' and assertions in object literals passed as parameters + '@typescript-eslint/consistent-type-assertions': [ + 'warn', + { + assertionStyle: 'as', + objectLiteralTypeAssertions: 'allow-as-parameter' + } + ], + // Discourage @ts-ignore - prefer proper typing or @ts-expect-error with explanation + '@typescript-eslint/ban-ts-comment': [ + 'warn', + { + 'ts-ignore': 'allow-with-description', + 'ts-nocheck': 'allow-with-description', + 'ts-expect-error': 'allow-with-description', + minimumDescriptionLength: 10 + } + ], + // Discourage non-null assertion operator (!) - prefer proper null checks + '@typescript-eslint/no-non-null-assertion': 'warn', + // Detect unused variables (TypeScript version) + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ], + // Enforce consistent naming conventions (camelCase for variables/functions) + '@typescript-eslint/naming-convention': [ + 'warn', + // Default: camelCase for everything + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow' + }, + // Imports: allow PascalCase (React components) and camelCase + { + selector: 'import', + format: ['camelCase', 'PascalCase'] + }, + // Variables: camelCase or UPPER_CASE (for constants) or PascalCase (React components) + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow' + }, + // Functions: camelCase or PascalCase (React components) + { + selector: 'function', + format: ['camelCase', 'PascalCase'] + }, + // Parameters: camelCase + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow' + }, + // Properties: camelCase or snake_case (for API responses) or UPPER_CASE + { + selector: 'property', + format: ['camelCase', 'snake_case', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow' + }, + // Allow any format for properties that require quotes (HTTP headers, data-*, CSS classes with dashes) + { + selector: 'property', + modifiers: ['requiresQuotes'], + format: null + }, + // Object literal properties: allow more formats for flexibility with external APIs + { + selector: 'objectLiteralProperty', + format: null + }, + // Type properties: allow more formats for external API types (CSP reports, etc.) + { + selector: 'typeProperty', + format: null + }, + // Types/Interfaces: PascalCase + { + selector: 'typeLike', + format: ['PascalCase'] + }, + // Enum members: PascalCase or UPPER_CASE + { + selector: 'enumMember', + format: ['PascalCase', 'UPPER_CASE'] + } + ] + } + } + ], env: { browser: true, es2021: true, diff --git a/scripts/check-blog-translation-usage.js b/scripts/check-blog-translation-usage.js new file mode 100755 index 0000000000000000000000000000000000000000..36332337ad6fcb2edbc8d6cc34326bbd4505a604 --- /dev/null +++ b/scripts/check-blog-translation-usage.js @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +/** + * Translation Usage Validator + * + * Scans source files for translation key usage and validates: + * - All used translation keys exist in the reference locale (English) + * - Reports unused translation keys (optional, with --unused flag) + * + * Usage: + * node scripts/check-blog-translation-usage.js # Check for missing keys + * node scripts/check-blog-translation-usage.js --unused # Also report unused keys + */ + +const fs = require('fs'); +const path = require('path'); + +const BLOG_DIR = path.join(__dirname, '../apps/blog'); +const LOCALES_DIR = path.join(BLOG_DIR, 'locales'); +const REFERENCE_LOCALE = 'en'; +const SOURCE_DIRS = ['app', 'components', 'features', 'lib', 'pages']; +const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js']; + +// Patterns to match translation function calls +// Matches: t('key'), t("key"), t(`key`), +const TRANSLATION_PATTERNS = [ + /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*(?:,|\))/g, + /i18nKey\s*=\s*['"`]([^'"`\n]+?)['"`]/g +]; + +/** + * Recursively get all keys from a nested object + * @param {object} obj + * @param {string} prefix + * @returns {Set} + */ +function getAllKeys(obj, prefix = '') { + const keys = new Set(); + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const nestedKeys = getAllKeys(value, fullKey); + nestedKeys.forEach((k) => keys.add(k)); + } else { + keys.add(fullKey); + } + } + + return keys; +} + +/** + * Load translation keys from reference locale + * @returns {Set} + */ +function loadTranslationKeys() { + const keys = new Set(); + const localeDir = path.join(LOCALES_DIR, REFERENCE_LOCALE); + + if (!fs.existsSync(localeDir)) { + console.error(`Reference locale directory not found: ${localeDir}`); + process.exit(1); + } + + const files = fs.readdirSync(localeDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + const filePath = path.join(localeDir, file); + try { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const fileKeys = getAllKeys(content); + fileKeys.forEach((k) => keys.add(k)); + } catch (error) { + console.error(`Error loading ${filePath}: ${error.message}`); + } + } + + return keys; +} + +/** + * Recursively find all source files + * @param {string} dir + * @returns {string[]} + */ +function findSourceFiles(dir) { + const files = []; + + if (!fs.existsSync(dir)) return files; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip node_modules, .next, etc. + if (!entry.name.startsWith('.') && entry.name !== 'node_modules') { + files.push(...findSourceFiles(fullPath)); + } + } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Extract translation keys from a source file + * @param {string} filePath + * @returns {{ keys: Map, file: string }} + */ +function extractKeysFromFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + const keys = new Map(); // key -> [line numbers] + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]; + + for (const pattern of TRANSLATION_PATTERNS) { + // Reset regex state + pattern.lastIndex = 0; + let match; + + while ((match = pattern.exec(line)) !== null) { + const key = match[1]; + + // Skip dynamic keys (containing variables) + if (key.includes('${') || key.includes('{') || key.includes('+')) { + continue; + } + + if (!keys.has(key)) { + keys.set(key, []); + } + keys.get(key).push(lineNum + 1); + } + } + } + + return { keys, file: filePath }; +} + +/** + * Main validation function + */ +function validateTranslationUsage() { + const showUnused = process.argv.includes('--unused'); + + console.log('\nšŸ“‹ Translation Usage Validator (Blog)'); + console.log(` Reference locale: ${REFERENCE_LOCALE}`); + console.log(` Scanning: ${SOURCE_DIRS.join(', ')}\n`); + + // Load all valid translation keys + const validKeys = loadTranslationKeys(); + console.log(` Found ${validKeys.size} translation keys in ${REFERENCE_LOCALE}\n`); + + // Find all source files + const sourceFiles = []; + for (const dir of SOURCE_DIRS) { + sourceFiles.push(...findSourceFiles(path.join(BLOG_DIR, dir))); + } + console.log(` Scanning ${sourceFiles.length} source files...\n`); + + // Extract and validate keys + const usedKeys = new Set(); + const missingKeys = new Map(); // key -> [{file, lines}] + let totalUsages = 0; + + for (const filePath of sourceFiles) { + const { keys } = extractKeysFromFile(filePath); + const relativePath = path.relative(BLOG_DIR, filePath); + + for (const [key, lineNumbers] of keys) { + totalUsages += lineNumbers.length; + usedKeys.add(key); + + if (!validKeys.has(key)) { + if (!missingKeys.has(key)) { + missingKeys.set(key, []); + } + missingKeys.get(key).push({ file: relativePath, lines: lineNumbers }); + } + } + } + + // Report missing keys + let hasErrors = false; + + if (missingKeys.size > 0) { + hasErrors = true; + console.log(`āŒ Missing translation keys (${missingKeys.size} keys not found in ${REFERENCE_LOCALE}):\n`); + + const sortedKeys = [...missingKeys.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [key, locations] of sortedKeys) { + console.log(` "${key}"`); + for (const { file, lines } of locations) { + console.log(` └─ ${file}:${lines.join(', ')}`); + } + } + console.log(''); + } else { + console.log(`āœ… All ${totalUsages} translation usages reference valid keys\n`); + } + + // Report unused keys (optional) + if (showUnused) { + const unusedKeys = [...validKeys].filter((k) => !usedKeys.has(k)); + + if (unusedKeys.length > 0) { + console.log(`āš ļø Potentially unused translation keys (${unusedKeys.length} keys):\n`); + console.log(' Note: Some keys may be used dynamically and not detected.\n'); + + // Group by top-level namespace + const grouped = {}; + for (const key of unusedKeys) { + const namespace = key.split('.')[0]; + if (!grouped[namespace]) { + grouped[namespace] = []; + } + grouped[namespace].push(key); + } + + for (const [namespace, keys] of Object.entries(grouped)) { + console.log(` ${namespace}/ (${keys.length} keys)`); + keys.slice(0, 5).forEach((k) => console.log(` - ${k}`)); + if (keys.length > 5) { + console.log(` ... and ${keys.length - 5} more`); + } + } + console.log(''); + } else { + console.log(`āœ… All translation keys appear to be used\n`); + } + } + + // Summary + console.log('─'.repeat(50)); + console.log(`\n Total translation keys: ${validKeys.size}`); + console.log(` Keys used in code: ${usedKeys.size}`); + console.log(` Total usages found: ${totalUsages}`); + + if (hasErrors) { + console.log(`\nāŒ Validation failed! Add missing keys to apps/blog/locales/${REFERENCE_LOCALE}/common_blog.json\n`); + process.exit(1); + } else { + console.log(`\nāœ… Translation usage validation passed!\n`); + process.exit(0); + } +} + +validateTranslationUsage(); diff --git a/scripts/check-blog-translations.js b/scripts/check-blog-translations.js new file mode 100755 index 0000000000000000000000000000000000000000..55a0857f344cd113ddb8be8443d327c06dc7282a --- /dev/null +++ b/scripts/check-blog-translations.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +/** + * Translation Keys Validator + * + * Compares translation JSON files across all locales to find: + * - Missing keys (present in reference locale but missing in others) + * - Extra keys (present in other locales but not in reference) + * + * Usage: node scripts/check-blog-translations.js + * + * Reference locale: English (en) + */ + +const fs = require('fs'); +const path = require('path'); + +const LOCALES_DIR = path.join(__dirname, '../apps/blog/locales'); +const REFERENCE_LOCALE = 'en'; + +/** + * Recursively extract all keys from a nested object + * @param {object} obj - The object to extract keys from + * @param {string} prefix - Current key path prefix + * @returns {Set} Set of dot-notation key paths + */ +function getAllKeys(obj, prefix = '') { + const keys = new Set(); + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const nestedKeys = getAllKeys(value, fullKey); + nestedKeys.forEach((k) => keys.add(k)); + } else { + keys.add(fullKey); + } + } + + return keys; +} + +/** + * Load and parse a JSON file + * @param {string} filePath - Path to the JSON file + * @returns {object|null} Parsed JSON or null if error + */ +function loadJson(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + console.error(`Error loading ${filePath}: ${error.message}`); + return null; + } +} + +/** + * Get all locale directories + * @returns {string[]} Array of locale codes + */ +function getLocales() { + return fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); +} + +/** + * Get all JSON files in a locale directory + * @param {string} locale - Locale code + * @returns {string[]} Array of JSON filenames + */ +function getJsonFiles(locale) { + const localeDir = path.join(LOCALES_DIR, locale); + return fs.readdirSync(localeDir).filter((file) => file.endsWith('.json')); +} + +/** + * Compare keys between reference and target locale + * @param {Set} referenceKeys - Keys from reference locale + * @param {Set} targetKeys - Keys from target locale + * @returns {{ missing: string[], extra: string[] }} + */ +function compareKeys(referenceKeys, targetKeys) { + const missing = []; + const extra = []; + + referenceKeys.forEach((key) => { + if (!targetKeys.has(key)) { + missing.push(key); + } + }); + + targetKeys.forEach((key) => { + if (!referenceKeys.has(key)) { + extra.push(key); + } + }); + + return { missing, extra }; +} + +/** + * Main validation function + */ +function validateTranslations() { + const locales = getLocales(); + + if (!locales.includes(REFERENCE_LOCALE)) { + console.error(`Reference locale '${REFERENCE_LOCALE}' not found!`); + process.exit(1); + } + + const referenceFiles = getJsonFiles(REFERENCE_LOCALE); + let hasErrors = false; + let totalMissing = 0; + let totalExtra = 0; + + console.log(`\nšŸ“‹ Translation Keys Validator (Blog)`); + console.log(` Reference locale: ${REFERENCE_LOCALE}`); + console.log(` Checking locales: ${locales.filter((l) => l !== REFERENCE_LOCALE).join(', ')}\n`); + + for (const jsonFile of referenceFiles) { + const referenceFilePath = path.join(LOCALES_DIR, REFERENCE_LOCALE, jsonFile); + const referenceData = loadJson(referenceFilePath); + + if (!referenceData) { + hasErrors = true; + continue; + } + + const referenceKeys = getAllKeys(referenceData); + console.log(`šŸ“„ ${jsonFile} (${referenceKeys.size} keys in ${REFERENCE_LOCALE})`); + + for (const locale of locales) { + if (locale === REFERENCE_LOCALE) continue; + + const targetFilePath = path.join(LOCALES_DIR, locale, jsonFile); + + if (!fs.existsSync(targetFilePath)) { + console.log(` āŒ ${locale}: File missing!`); + hasErrors = true; + continue; + } + + const targetData = loadJson(targetFilePath); + if (!targetData) { + hasErrors = true; + continue; + } + + const targetKeys = getAllKeys(targetData); + const { missing, extra } = compareKeys(referenceKeys, targetKeys); + + if (missing.length === 0 && extra.length === 0) { + console.log(` āœ… ${locale}: OK (${targetKeys.size} keys)`); + } else { + hasErrors = true; + totalMissing += missing.length; + totalExtra += extra.length; + + console.log(` āš ļø ${locale}: ${missing.length} missing, ${extra.length} extra`); + + if (missing.length > 0) { + console.log(` Missing keys:`); + missing.slice(0, 10).forEach((key) => console.log(` - ${key}`)); + if (missing.length > 10) { + console.log(` ... and ${missing.length - 10} more`); + } + } + + if (extra.length > 0) { + console.log(` Extra keys (not in ${REFERENCE_LOCALE}):`); + extra.slice(0, 5).forEach((key) => console.log(` + ${key}`)); + if (extra.length > 5) { + console.log(` ... and ${extra.length - 5} more`); + } + } + } + } + console.log(''); + } + + // Check for files that exist in other locales but not in reference + for (const locale of locales) { + if (locale === REFERENCE_LOCALE) continue; + + const localeFiles = getJsonFiles(locale); + const extraFiles = localeFiles.filter((f) => !referenceFiles.includes(f)); + + if (extraFiles.length > 0) { + console.log(`āš ļø ${locale} has extra files not in ${REFERENCE_LOCALE}: ${extraFiles.join(', ')}`); + hasErrors = true; + } + } + + // Summary + console.log('\n' + '─'.repeat(50)); + if (hasErrors) { + console.log(`\nāŒ Validation failed!`); + console.log(` Total missing keys: ${totalMissing}`); + console.log(` Total extra keys: ${totalExtra}`); + console.log(`\n Fix missing keys by adding them to the respective locale files.`); + console.log(` Use English (${REFERENCE_LOCALE}) as the reference.\n`); + process.exit(1); + } else { + console.log(`\nāœ… All translations are in sync!\n`); + process.exit(0); + } +} + +validateTranslations();