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
JavaScript
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;