sicua
Version:
A tool for analyzing project structure and dependencies
445 lines (444 loc) • 17.7 kB
JavaScript
;
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;
}