UNPKG

sicua

Version:

A tool for analyzing project structure and dependencies

400 lines (399 loc) 15.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ReactI18nextFileFinder = void 0; const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const pathUtils_1 = require("../../../utils/common/pathUtils"); const translationFileUtils_1 = require("../utils/translationFileUtils"); /** * Finds and analyzes react-i18next translation files in a project */ class ReactI18nextFileFinder { /** * Creates a new react-i18next translation file finder * @param context Translation file context */ constructor(context) { this.translationFiles = []; this.mainTranslationFile = null; this.namespaceFiles = new Map(); this.context = context; } /** * Finds all react-i18next translation files in the project * @returns Promise resolving when files are found */ async findAllTranslationFiles() { this.translationFiles = await this.findReactI18nextFiles(); if (this.translationFiles.length > 0) { this.mainTranslationFile = this.determineMainTranslationFile(); this.organizeFilesByNamespace(); } } /** * Gets the discovered translation files * @returns Array of translation files */ getTranslationFiles() { return this.translationFiles; } /** * Gets the main translation file (largest one or main namespace) * @returns Main translation file or null if none found */ getMainTranslationFile() { return this.mainTranslationFile; } /** * Gets translation files organized by namespace * @returns Map of namespace to translation files */ getNamespaceFiles() { return this.namespaceFiles; } /** * Gets files for a specific namespace * @param namespace Namespace to get files for * @returns Array of translation files for the namespace */ getFilesForNamespace(namespace) { return this.namespaceFiles.get(namespace) || []; } /** * Finds react-i18next translation files in common locations */ async findReactI18nextFiles() { const translationFiles = []; const { projectPath } = this.context; // React-i18next common locations const possibleLocations = [ "locales", // Standard react-i18next location "public/locales", // Common Next.js location "src/locales", // Alternative src location "assets/locales", // Create React App style "translations", // Generic translations folder "i18n", // Alternative i18n folder ]; for (const location of possibleLocations) { const dirPath = path_1.default.join(projectPath, location); if (!fs_1.default.existsSync(dirPath) || !fs_1.default.statSync(dirPath).isDirectory()) { continue; } // Search for translation files in this location const foundFiles = await this.findFilesInDirectory(dirPath); translationFiles.push(...foundFiles); // If we found files in one location, don't check others if (foundFiles.length > 0) { break; } } return translationFiles; } /** * Finds translation files in a directory (react-i18next structure) * @param dirPath Directory path * @returns Promise that resolves to an array of translation files */ async findFilesInDirectory(dirPath) { const translationFiles = []; try { const items = fs_1.default.readdirSync(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 (this.isLanguageDirectory(item)) { const languageFiles = await this.findLanguageFiles(itemPath, item); translationFiles.push(...languageFiles); } else { // Recursively check subdirectories const nestedFiles = await this.findFilesInDirectory(itemPath); translationFiles.push(...nestedFiles); } } else if (item.endsWith(".json")) { // Direct JSON files in the locales directory try { const content = await (0, pathUtils_1.readJsonFile)(itemPath); if ((0, translationFileUtils_1.isLikelyTranslationFile)(content)) { const size = this.countTranslationEntries(content); translationFiles.push({ path: itemPath, content, size, }); } } catch (error) { // Skip files that can't be read } } } } catch (error) { // Skip directories that can't be read } return translationFiles; } /** * Finds translation files within a language directory * @param languagePath Path to the language directory * @param languageCode Language code (e.g., 'en', 'fr') * @returns Promise that resolves to an array of translation files */ async findLanguageFiles(languagePath, languageCode) { const translationFiles = []; try { const files = fs_1.default.readdirSync(languagePath); for (const file of files) { if (file.endsWith(".json")) { const filePath = path_1.default.join(languagePath, file); try { const content = await (0, pathUtils_1.readJsonFile)(filePath); if ((0, translationFileUtils_1.isLikelyTranslationFile)(content)) { const size = this.countTranslationEntries(content); translationFiles.push({ path: filePath, content, size, }); } } catch (error) { // Skip files that can't be read } } } } catch (error) { // Skip directories that can't be read } return translationFiles; } /** * Determines if a directory name represents a language code * @param dirName Directory name * @returns Boolean indicating if it's a language directory */ isLanguageDirectory(dirName) { // Common language code patterns 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 ]; return languagePatterns.some((pattern) => pattern.test(dirName)); } /** * Organizes translation files by namespace */ organizeFilesByNamespace() { this.namespaceFiles.clear(); for (const file of this.translationFiles) { const namespace = this.extractNamespaceFromFile(file); const existing = this.namespaceFiles.get(namespace) || []; existing.push(file); this.namespaceFiles.set(namespace, existing); } } /** * Extracts namespace from file path * @param file Translation file * @returns Namespace string */ extractNamespaceFromFile(file) { const fileName = path_1.default.basename(file.path, ".json"); // If filename is a language code, check parent directory structure if (this.isLanguageDirectory(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 (!this.isLanguageDirectory(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 (!this.isLanguageDirectory(potentialNamespace)) { return potentialNamespace; } } return "translation"; // Default namespace } /** * Counts the number of translation entries in a file (recursive) * @param obj Translation object * @param prefix Optional prefix for recursive calls * @returns Number of translations */ countTranslationEntries(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 += this.countTranslationEntries(value, fullKey); } else if (typeof value === "string") { count++; } } return count; } /** * Determines the main translation file * @returns The main translation file or null if none found */ determineMainTranslationFile() { if (this.translationFiles.length === 0) return null; // Priority: 'translation' namespace > largest file const translationNamespaceFiles = this.namespaceFiles.get("translation") || []; if (translationNamespaceFiles.length > 0) { // Return the largest file in the translation namespace return translationNamespaceFiles.reduce((largest, current) => (current.size > largest.size ? current : largest), translationNamespaceFiles[0]); } // Fallback to largest file overall return this.translationFiles.reduce((largest, current) => (current.size > largest.size ? current : largest), this.translationFiles[0]); } /** * Analyzes the translation file structure for react-i18next * @param file Translation file to analyze * @returns Analysis information about the file */ analyzeReactI18nextFile(file) { const namespace = this.extractNamespaceFromFile(file); // Try to extract language code from path let languageCode = null; const pathParts = file.path.split(path_1.default.sep); for (const part of pathParts) { if (this.isLanguageDirectory(part)) { languageCode = part; break; } } // Calculate max nesting depth const calculateNestingDepth = (obj, currentDepth = 0) => { let maxDepth = currentDepth; for (const [key, value] of Object.entries(obj)) { if (typeof value === "object" && value !== null) { const depth = calculateNestingDepth(value, currentDepth + 1); maxDepth = Math.max(maxDepth, depth); } } return maxDepth; }; const nestedLevels = calculateNestingDepth(file.content); const format = nestedLevels > 0 ? "nested" : "flat"; // Check for react-i18next specific features const hasPlurals = this.checkForPlurals(file.content); const hasInterpolation = this.checkForInterpolation(file.content); return { keysCount: file.size, nestedLevels, languageCode, namespace, format, hasPlurals, hasInterpolation, }; } /** * Checks if the translation file contains pluralization keys * @param content Translation content * @returns Boolean indicating if plurals are present */ checkForPlurals(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 if the translation file contains interpolation * @param content Translation content * @returns Boolean indicating if interpolation is present */ checkForInterpolation(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); } /** * Gets information about the react-i18next setup * @returns Information about react-i18next configuration */ detectReactI18nextSetup() { const languages = new Set(); const namespaces = new Set(); let hasPlurals = false; let hasInterpolation = false; for (const file of this.translationFiles) { const analysis = this.analyzeReactI18nextFile(file); if (analysis.languageCode) { languages.add(analysis.languageCode); } namespaces.add(analysis.namespace); if (analysis.hasPlurals) { hasPlurals = true; } if (analysis.hasInterpolation) { hasInterpolation = true; } } // Determine structure type let structure; if (namespaces.size > 1 && languages.size > 1) { structure = "mixed"; } else if (namespaces.size > 1) { structure = "namespace-based"; } else { structure = "language-based"; } return { structure, languages: Array.from(languages), namespaces: Array.from(namespaces), hasPlurals, hasInterpolation, }; } } exports.ReactI18nextFileFinder = ReactI18nextFileFinder;