sicua
Version:
A tool for analyzing project structure and dependencies
400 lines (399 loc) • 15.6 kB
JavaScript
"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;