UNPKG

auto-localization

Version:

A powerful tool to automatically extract text strings from React Native (or React) components and generate localization files. Supports generating JSON or JavaScript/TypeScript constants files for easy integration with your app's localization system.

346 lines (293 loc) 11.6 kB
const fs = require("fs-extra"); const path = require("path"); const parser = require("@babel/parser"); const chalk = require("chalk"); // Utility function to recursively scan directories const scanDirectory = (dirPath) => { const files = []; const items = fs.readdirSync(dirPath); items.forEach((item) => { const itemPath = path.join(dirPath, item); const stat = fs.statSync(itemPath); if (stat.isDirectory()) { files.push(...scanDirectory(itemPath)); // Recursive scan } else if ( stat.isFile() && (itemPath.endsWith(".js") || itemPath.endsWith(".jsx") || itemPath.endsWith(".ts") || itemPath.endsWith(".tsx")) ) { files.push(itemPath); } }); return files; }; // Function to convert a string to UPPER_SNAKE_CASE const toUpperSnakeCase = (str) => { let result = str .toUpperCase() // Convert to uppercase .replace(/[^A-Z0-9]+/g, "_") // Replace non-alphanumeric characters with '_' .replace(/^_+|_+$/g, ""); // Remove leading and trailing underscores // Ensure the key doesn't start with a number or special symbol if (/^\d/.test(result)) { result = "KEY_" + result; // Add a prefix to ensure the key starts with a letter } return result; }; // Function to convert a string to snake_case const toSnakeCase = (str) => { let result = str .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); // Remove leading and trailing underscores // Ensure the key doesn't start with a number or special symbol if (/^\d/.test(result)) { result = "key_" + result; // Add a prefix to ensure the key starts with a letter } return result; }; // Extract strings from the file using Babel Parser const extractStringsFromFile = (filePath) => { const code = fs.readFileSync(filePath, "utf-8"); const ast = parser.parse(code, { sourceType: "module", plugins: ["typescript", "jsx"], }); const strings = []; const traverseNode = (node) => { // Check for string literals inside variable declarations if ( node.type === "VariableDeclarator" && node.init && node.init.type === "StringLiteral" ) { strings.push(node.init.value); } // Check for standalone string literals if (node.type === "Literal" && typeof node.value === "string") { strings.push(node.value); } // Check for JSXText (inline text in JSX elements like <Text>...</Text>) if (node.type === "JSXText" && node.value.trim() !== "") { strings.push(node.value); } // Traverse child nodes for (const key in node) { if (node[key] && typeof node[key] === "object") { traverseNode(node[key]); } } }; traverseNode(ast); return strings; }; // Backup existing localization file const backupFile = (filePath) => { const dirPath = path.dirname(filePath); // Ensure the directory exists if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.-]/g, "_"); const backupPath = `${filePath}.${timestamp}.bak`; // If the file exists, create a backup if (fs.existsSync(filePath)) { fs.copySync(filePath, backupPath); console.log(chalk.green(`Backup created at ${backupPath}`)); } else { console.log(chalk.yellow(`No file found at ${filePath}, skipping backup.`)); } }; // Read existing JS localization file (JS or TS) const readExistingJsLocalization = (localizedFilePath) => { const fileContent = fs.readFileSync(localizedFilePath, "utf-8"); const localizationMatch = fileContent.match( /export\s+const\s+localization\s*=\s*({[^}]*})/ ); if (localizationMatch) { // Extract and return the localization object content try { return JSON.parse(localizationMatch[1]); } catch (error) { console.log( chalk.red("Error parsing localization content from JS file:", error) ); return {}; } } // If no match, return an empty object return {}; }; // Extract strings from a file or directory const extractStrings = (inputPath, outputFilePath) => { const allStrings = new Set(); // If it's a directory, scan for all files inside it if (fs.statSync(inputPath).isDirectory()) { const files = scanDirectory(inputPath); files.forEach((filePath) => { const strings = extractStringsFromFile(filePath); strings.forEach((str) => allStrings.add(str)); }); } else { // If it's a single file, extract strings from it const strings = extractStringsFromFile(inputPath); strings.forEach((str) => allStrings.add(str)); } // Ensure output path is absolute let localizedFilePath = path.resolve(outputFilePath); const fileExtension = path.extname(localizedFilePath).toLowerCase(); let existingData = {}; // If the output is JSON, handle it normally if (fileExtension === ".json") { if (fs.existsSync(localizedFilePath)) { // Backup the existing localization file before overwriting it backupFile(localizedFilePath); const existingContent = fs.readFileSync(localizedFilePath, "utf-8"); try { existingData = JSON.parse(existingContent); } catch (err) { console.log( "Error reading or parsing the existing localization file, initializing as empty object:", err ); existingData = {}; // Initialize as an empty object if there is an issue } } // Merge new strings with existing strings allStrings.forEach((str) => { const camelCaseKey = toSnakeCase(str); // Convert string to snake_case for JSON keys existingData[camelCaseKey] = str; // Add or update the string in the object }); // Write the updated JSON data to the file const formattedStrings = JSON.stringify(existingData, null, 2); fs.writeFileSync(localizedFilePath, formattedStrings); console.log( chalk.green( `Strings have been extracted and saved to ${localizedFilePath}` ) ); } // If the output is JS or TS, generate constants and auto-import them else if (fileExtension === ".js" || fileExtension === ".ts") { let localizationObject = "export const localization = {\n"; // If the file exists, read and merge the existing data if (fs.existsSync(localizedFilePath)) { existingData = readExistingJsLocalization(localizedFilePath); // Read existing localization data } // Merge new strings with existing strings, but only add new strings that don't already exist allStrings.forEach((str) => { const constantName = toUpperSnakeCase(str); // Convert string to UPPER_SNAKE_CASE for constants if (!existingData[constantName]) { existingData[constantName] = str; // Only add if the constant does not already exist } }); // Generate the updated localization object Object.keys(existingData).forEach((key) => { localizationObject += ` ${key}: "${existingData[key]}",\n`; }); localizationObject += "};\n"; // Backup the existing localization file before overwriting it backupFile(localizedFilePath); fs.writeFileSync(localizedFilePath, localizationObject); console.log( chalk.green( `Localization constants have been extracted and saved to ${localizedFilePath}` ) ); // Now modify the source code to import the localization object and replace string literals let importPath = path .relative(path.dirname(inputPath), localizedFilePath) .replace(/\\/g, "/"); // Check if the input file and the output file are in the same directory if (path.dirname(inputPath) === path.dirname(localizedFilePath)) { importPath = `./${path.basename(localizedFilePath)}`; } // Ensure we use relative path, but with './' if they are in the same directory const importStatement = `import { localization } from './${importPath}';\n`; const code = fs.readFileSync(inputPath, "utf-8"); // Modify the code by ensuring only one import statement is present let modifiedCode = code.replace( /^import\s+\{\s*localization\s*\}\s+from\s+'.+?';/gm, "" ); // Remove any existing localization import statements modifiedCode = importStatement + modifiedCode; // Add the import at the top of the code // Replace static strings in JSX elements with localization keys // 1. Handle variable declarations and replace their values with localization modifiedCode = modifiedCode.replace( /(const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g, (match, declaration, variableName, stringValue) => { const constantName = toUpperSnakeCase(stringValue); if (existingData[constantName]) { return `${declaration} ${variableName} = localization.${constantName}`; } return match; } ); // 2. Handle static text content inside JSX // Replace static text content inside JSX with localization keys modifiedCode = modifiedCode.replace( /(<[A-Za-z0-9-]+[^>]*>)([^<{]+)(<\/[A-Za-z0-9-]+>)/g, (match, openTag, content, closeTag) => { if (!content.includes("{")) { const constantName = toUpperSnakeCase(content.trim()); // Only replace if the constant exists in the localization object if (existingData[constantName]) { return `${openTag}{localization.${constantName}}${closeTag}`; } else { // If no match, leave it as is (no `{localization.}`) return match; } } return match; } ); // modifiedCode = modifiedCode.replace( // /(<[A-Za-z0-9-]+[^>]*>)([^<{]+)(<\/[A-Za-z0-9-]+>)/g, // (match, openTag, content, closeTag) => { // // if (!content.includes('{') && /^[a-zA-Z0-9]+$/.test(content.trim())) { // if (!content.includes("{")) { // const constantName = toUpperSnakeCase(content.trim()); // return `${openTag}{localization.${constantName}}${closeTag}`; // } // return match; // } // ); // Handle mixed content like "Rating: {item.rating}" or "Time: {item.time} mins" modifiedCode = modifiedCode.replace( /(<[A-Za-z0-9-]+[^>]*>)([^<{]*)\s*{([^}]+)}([^<{]*)(<\/[A-Za-z0-9-]+>)/g, (match, openTag, prefix, dynamicExpr, suffix, closeTag) => { const localizedPrefix = toUpperSnakeCase(prefix.trim()); // Localize static part (e.g., "Rating") const localizedSuffix = suffix.trim() ? toUpperSnakeCase(suffix.trim()) : ""; // Localize static suffix (e.g., "mins") // Prevent empty localization keys like `{localization.}` const prefixPart = localizedPrefix ? `{localization.${localizedPrefix}}` : ""; const suffixPart = localizedSuffix ? `{localization.${localizedSuffix}}` : ""; if (localizedPrefix || localizedSuffix) { return `${openTag}${prefixPart} {${dynamicExpr}} ${suffixPart}${closeTag}`; } else { return match; // If there's no static content to localize, just return the match } } ); // Write the modified code back to the input file or a new file fs.writeFileSync(inputPath, modifiedCode); console.log( chalk.green( `Source code has been updated with localization constants in ${inputPath}` ) ); } else { console.log( chalk.red( "Unsupported output file format. Only .json, .js, or .ts are supported." ) ); } }; module.exports = extractStrings;