;
+}
+```
+
+### 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();