UNPKG

sicua

Version:

A tool for analyzing project structure and dependencies

445 lines (444 loc) 17.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.findReactI18nextTranslationFiles = findReactI18nextTranslationFiles; exports.isReactI18nextLanguageDirectory = isReactI18nextLanguageDirectory; exports.isLikelyReactI18nextTranslationFile = isLikelyReactI18nextTranslationFile; exports.hasNestedStringValues = hasNestedStringValues; exports.countReactI18nextTranslationEntries = countReactI18nextTranslationEntries; exports.determineMainReactI18nextTranslationFile = determineMainReactI18nextTranslationFile; exports.extractNamespaceFromReactI18nextFile = extractNamespaceFromReactI18nextFile; exports.translationExistsInReactI18nextObject = translationExistsInReactI18nextObject; exports.extractKeysFromReactI18nextTranslations = extractKeysFromReactI18nextTranslations; exports.flattenReactI18nextTranslations = flattenReactI18nextTranslations; exports.isSignificantReactI18nextText = isSignificantReactI18nextText; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const pathUtils_1 = require("../../../utils/common/pathUtils"); /** * Finds all react-i18next translation files in the project * @param context Translation file context * @returns Promise that resolves to an array of translation files */ async function findReactI18nextTranslationFiles(context) { const translationFiles = []; const { projectPath, log } = context; // React-i18next common locations const possibleLocations = [ "locales", // Standard react-i18next location "public/locales", // Common Next.js with react-i18next location "src/locales", // Alternative src location "assets/locales", // Create React App style "translations", // Generic translations folder "i18n", // Alternative i18n folder "src/i18n", // i18n in src ]; for (const location of possibleLocations) { const dirPath = path_1.default.join(projectPath, location); log(`Checking for react-i18next translation directory at: ${dirPath}`); if (!fs_1.default.existsSync(dirPath)) { continue; } if (!fs_1.default.statSync(dirPath).isDirectory()) { continue; } // Search for translation files const foundFiles = await findFilesInReactI18nextDirectory(dirPath, context); translationFiles.push(...foundFiles); // If we found files in one location, don't check others if (foundFiles.length > 0) { log(`Found ${foundFiles.length} react-i18next translation files in ${dirPath}`); break; } } return translationFiles; } /** * Finds translation files in a react-i18next directory structure * @param dirPath Directory path * @param context Translation file context * @returns Promise that resolves to an array of translation files */ async function findFilesInReactI18nextDirectory(dirPath, context) { const { log } = context; const translationFiles = []; try { const items = fs_1.default.readdirSync(dirPath); log(`Found ${items.length} items in directory: ${dirPath}`); for (const item of items) { const itemPath = path_1.default.join(dirPath, item); const stat = fs_1.default.statSync(itemPath); if (stat.isDirectory()) { // Check if this is a language directory (e.g., 'en', 'fr', 'de') if (isReactI18nextLanguageDirectory(item)) { log(`Processing language directory: ${itemPath}`); const languageFiles = await findLanguageFiles(itemPath, item, context); translationFiles.push(...languageFiles); } else { // Recursively check subdirectories const nestedFiles = await findFilesInReactI18nextDirectory(itemPath, context); translationFiles.push(...nestedFiles); } } else if (item.endsWith(".json")) { // Direct JSON files in the locales directory try { log(`Reading react-i18next translation file: ${itemPath}`); const content = await (0, pathUtils_1.readJsonFile)(itemPath); if (isLikelyReactI18nextTranslationFile(content)) { const size = countReactI18nextTranslationEntries(content); log(`Found ${size} translation entries in ${itemPath}`); translationFiles.push({ path: itemPath, content, size, }); } } catch (error) { log(`Error reading react-i18next translation file ${itemPath}: ${error}`); } } } } catch (error) { log(`Error reading directory ${dirPath}: ${error}`); } return translationFiles; } /** * Finds translation files within a language directory * @param languagePath Path to the language directory * @param languageCode Language code (e.g., 'en', 'fr') * @param context Translation file context * @returns Promise that resolves to an array of translation files */ async function findLanguageFiles(languagePath, languageCode, context) { const { log } = context; const translationFiles = []; try { const files = fs_1.default.readdirSync(languagePath); log(`Found ${files.length} files in language directory: ${languagePath}`); for (const file of files) { if (file.endsWith(".json")) { const filePath = path_1.default.join(languagePath, file); try { log(`Reading language file: ${filePath}`); const content = await (0, pathUtils_1.readJsonFile)(filePath); if (isLikelyReactI18nextTranslationFile(content)) { const size = countReactI18nextTranslationEntries(content); log(`Found ${size} translation entries in ${filePath}`); translationFiles.push({ path: filePath, content, size, }); } } catch (error) { log(`Error reading language file ${filePath}: ${error}`); } } } } catch (error) { log(`Error reading language directory ${languagePath}: ${error}`); } return translationFiles; } /** * Determines if a directory name represents a react-i18next language code * @param dirName Directory name * @returns Boolean indicating if it's a language directory */ function isReactI18nextLanguageDirectory(dirName) { // Common language code patterns for react-i18next const languagePatterns = [ /^[a-z]{2}$/, // Two letter codes: en, fr, de /^[a-z]{2}-[A-Z]{2}$/, // Locale codes: en-US, fr-FR /^[a-z]{2}_[A-Z]{2}$/, // Alternative locale: en_US, fr_FR /^[a-z]{3}$/, // Three letter codes: eng, fra ]; return languagePatterns.some((pattern) => pattern.test(dirName)); } /** * Determines if a JSON object is likely a react-i18next translation file * @param content JSON content * @returns Boolean indicating if it looks like a react-i18next translation file */ function isLikelyReactI18nextTranslationFile(content) { if (Object.keys(content).length === 0) { return false; } // Check if it has nested string values (basic translation file check) if (!hasNestedStringValues(content)) { return false; } // Additional react-i18next specific checks if (hasReactI18nextFeatures(content)) { return true; } // Fallback to general translation file check return hasTranslationStructure(content); } /** * Checks if content has react-i18next specific features * @param content JSON content * @returns Boolean indicating if it has react-i18next features */ function hasReactI18nextFeatures(content) { return hasPluralizationKeys(content) || hasInterpolationPatterns(content); } /** * Checks for react-i18next pluralization keys * @param content JSON content * @returns Boolean indicating if pluralization keys are present */ function hasPluralizationKeys(content) { const checkObject = (obj) => { for (const [key, value] of Object.entries(obj)) { // Check for react-i18next plural keys if (key.endsWith("_zero") || key.endsWith("_one") || key.endsWith("_two") || key.endsWith("_few") || key.endsWith("_many") || key.endsWith("_other")) { return true; } if (typeof value === "object" && value !== null) { if (checkObject(value)) return true; } } return false; }; return checkObject(content); } /** * Checks for react-i18next interpolation patterns * @param content JSON content * @returns Boolean indicating if interpolation patterns are present */ function hasInterpolationPatterns(content) { const checkValue = (value) => { if (typeof value === "string") { // Check for react-i18next interpolation patterns return /\{\{[^}]+\}\}/.test(value) || /\$t\([^)]+\)/.test(value); } if (typeof value === "object" && value !== null) { return Object.values(value).some(checkValue); } return false; }; return checkValue(content); } /** * Checks if content has nested string values * @param node JSON node * @returns Boolean indicating if it has string values */ function hasNestedStringValues(node) { if (typeof node === "string") { return true; } if (typeof node === "object" && node !== null) { return Object.values(node).some(hasNestedStringValues); } return false; } /** * Checks if content has a translation-like structure * @param content JSON content * @returns Boolean indicating if it has translation structure */ function hasTranslationStructure(content) { const keys = Object.keys(content); // Should have reasonable number of keys if (keys.length === 0 || keys.length > 1000) { return false; } // Check if most values are strings or nested objects with strings let stringValues = 0; let totalValues = 0; const countValues = (obj) => { for (const value of Object.values(obj)) { totalValues++; if (typeof value === "string") { stringValues++; } else if (typeof value === "object" && value !== null) { countValues(value); } } }; countValues(content); // At least 70% should be string values return totalValues > 0 && stringValues / totalValues >= 0.7; } /** * Counts the number of translation entries in a react-i18next file (recursive) * @param obj Translation object * @param prefix Optional prefix for recursive calls * @returns Number of translations */ function countReactI18nextTranslationEntries(obj, prefix = "") { let count = 0; for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === "object" && value !== null) { count += countReactI18nextTranslationEntries(value, fullKey); } else if (typeof value === "string") { count++; } } return count; } /** * Determines the main react-i18next translation file (largest one in translation namespace) * @param translationFiles Array of translation files * @returns The main translation file or null if none found */ function determineMainReactI18nextTranslationFile(translationFiles) { if (translationFiles.length === 0) return null; // Look for files that are likely the main namespace (translation, common, etc.) const mainNamespaceFiles = translationFiles.filter((file) => { const fileName = path_1.default.basename(file.path, ".json"); const namespace = extractNamespaceFromReactI18nextFile(file); return (["translation", "common", "main", "app"].includes(namespace.toLowerCase()) || ["translation", "common", "main", "app"].includes(fileName.toLowerCase())); }); if (mainNamespaceFiles.length > 0) { // Return the largest file in the main namespace return mainNamespaceFiles.reduce((largest, current) => (current.size > largest.size ? current : largest), mainNamespaceFiles[0]); } // Fallback to largest file overall return translationFiles.reduce((largest, current) => (current.size > largest.size ? current : largest), translationFiles[0]); } /** * Extracts namespace from react-i18next file path * @param file Translation file * @returns Namespace string */ function extractNamespaceFromReactI18nextFile(file) { const fileName = path_1.default.basename(file.path, ".json"); // If filename is a language code, check parent directory structure if (isReactI18nextLanguageDirectory(fileName)) { // This might be a file named after language code, look at parent const parentDir = path_1.default.basename(path_1.default.dirname(file.path)); if (!isReactI18nextLanguageDirectory(parentDir)) { return parentDir; } return "translation"; // Default namespace } // Check if filename contains language code prefix (e.g., en.json, fr.json) const languageMatch = fileName.match(/^([a-z]{2}(-[A-Z]{2})?)$/); if (languageMatch) { return "translation"; // Default namespace for language files } // Check for namespace files (e.g., common.json, forms.json) const namespaceMatch = fileName.match(/^([a-zA-Z][a-zA-Z0-9_-]*)/); if (namespaceMatch) { const potentialNamespace = namespaceMatch[1]; // Skip language codes as namespaces if (!isReactI18nextLanguageDirectory(potentialNamespace)) { return potentialNamespace; } } return "translation"; // Default namespace } /** * Checks if a translation key exists in the react-i18next translation object * @param fullKey The full dot-notation key * @param translations Translation object * @returns Boolean indicating if the key exists */ function translationExistsInReactI18nextObject(fullKey, translations) { const parts = fullKey.split("."); let current = translations; for (const part of parts) { if (!current || typeof current !== "object" || !(part in current)) { return false; } current = current[part]; } return typeof current === "string"; } /** * Extracts all translation keys from a react-i18next translation object * @param obj Translation object * @param prefix Optional prefix for recursive calls * @param result Array to collect results */ function extractKeysFromReactI18nextTranslations(obj, prefix, result) { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === "object" && value !== null) { extractKeysFromReactI18nextTranslations(value, fullKey, result); } else if (typeof value === "string") { result.push(fullKey); } } } /** * Flattens a nested react-i18next translation object for comparison * @param obj Translation object * @param prefix Optional prefix for recursive calls * @param namespace Namespace of the translation file * @param filePath File path of the translation file * @param valueToKeysMap Map to collect values and their keys */ function flattenReactI18nextTranslations(obj, prefix, namespace, filePath, valueToKeysMap) { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === "object" && value !== null) { flattenReactI18nextTranslations(value, fullKey, namespace, filePath, valueToKeysMap); } else if (typeof value === "string") { const existing = valueToKeysMap.get(value) || []; existing.push({ fullKey, filePath, namespace }); valueToKeysMap.set(value, existing); } } } /** * Checks if text is significant enough to warn about duplication in react-i18next * @param value The text value to check * @returns Boolean indicating if the text is significant */ function isSignificantReactI18nextText(value) { // Ignore very short strings or non-significant content if (value.length < 5) return false; // Ignore strings that are just "yes", "no", "true", "false", etc. const lowered = value.toLowerCase(); if ([ "yes", "no", "true", "false", "ok", "cancel", "save", "edit", "delete", "submit", ].includes(lowered)) return false; // Ignore strings that are just numbers if (/^\d+$/.test(value)) return false; // Ignore react-i18next interpolation-only strings if (/^\{\{[^}]+\}\}$/.test(value)) return false; // Ignore $t() function calls if (/^\$t\([^)]+\)$/.test(value)) return false; return true; }