Skip to content

Instantly share code, notes, and snippets.

@michalpulpan
Last active September 12, 2024 11:38
Show Gist options
  • Save michalpulpan/40923fc940725615b6218fdb91f397a7 to your computer and use it in GitHub Desktop.
Save michalpulpan/40923fc940725615b6218fdb91f397a7 to your computer and use it in GitHub Desktop.
Simple (and a bit naive) parser generating i18n JSON files for namespaces and translation keys found in `.ts` and `.tsx` files within the project utilizing `next-intl`.
import * as fs from "fs";
import * as path from "path";
import { glob } from "glob";
import * as parser from "@babel/parser";
import * as t from "@babel/types";
import traverse from "@babel/traverse";
// Types for translation extraction
type Translations = Record<string, string>;
type NamespaceTranslations = Record<string, string[]>;
// Languages to process (from your folder structure)
const languages = ["cs", "sk", "en", "pl", "de"];
// Get current timestamp for the backup folder
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
// Backup the `messages` folder into `messages_backup/{timestamp}/`
function backupMessagesFolder(): string {
const messagesDir = path.join(__dirname, "messages");
const backupDir = path.join(__dirname, "messages_backup", timestamp);
if (fs.existsSync(messagesDir)) {
fs.mkdirSync(backupDir, { recursive: true });
fs.cpSync(messagesDir, backupDir, { recursive: true });
console.log(`Backup created at ${backupDir}`);
}
return backupDir;
}
// Function to get all .ts and .tsx files in the project
function getTsFiles(): string[] {
return glob.sync("./src/**/*.@(ts|tsx)", { ignore: "node_modules/**" });
}
// Function to create the `messages_backup` folder and store the old translations
function createBackupAndClearMessagesFolder() {
const backupDir = backupMessagesFolder();
const messagesDir = path.join(__dirname, "messages");
// Clear the messages directory
if (fs.existsSync(messagesDir)) {
fs.rmSync(messagesDir, { recursive: true, force: true });
}
// Recreate an empty messages folder
fs.mkdirSync(messagesDir, { recursive: true });
return backupDir;
}
// Parse and traverse a TypeScript file
function extractTranslations(fileContent: string): NamespaceTranslations {
const ast = parser.parse(fileContent, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const translations: NamespaceTranslations = {};
const namespaceStack: string[][] = [];
// Helper function to push a namespace onto the stack and initialize it in translations
function enterNamespace(namespace: string[]) {
// now we can push the new namespace onto the stack
namespaceStack.push(namespace);
// and initialize it in the translations object
namespace.forEach((ns) => {
if (!translations[ns]) {
translations[ns] = [];
}
});
}
function namespaceRedeclaration() {
// if there's something on the stack, it means we're in a nested function or component and the namespace was inherited when entering the function
// however it seems that the function was redefined, so we need to push the namespace onto the stack again and remove the old one
if (namespaceStack.length) {
namespaceStack.pop();
}
}
// Helper function to pop a namespace off the stack
function exitNamespace() {
namespaceStack.pop();
}
// Helper function to get the current namespace (top of the stack)
function getCurrentNamespaceAndKey(key: string): Record<string, string> {
// if the stack is non empty, get the top of the stack, this can be multiple namespaces (if it's getTranslations) or a single namespace (if it's useTranslations)
// if it's a single namespace, return it, if it's multiple, check if the key starts with any of the namespaces, if it does, return that namespace
// otherwise the namespace is hidden in the key (first part of the key when split by ".")
if (namespaceStack.length) {
const currentNamespaces = namespaceStack[namespaceStack.length - 1];
if (key.includes(".")) {
// if key contains ".", it means it's a nested key, so we need to check if the first part of the key is one of the current namespaces
const [namespace, ...rest] = key.split(".");
if (currentNamespaces.includes(namespace)) {
return { namespace, key: rest.join(".") };
}
} else {
// however, even namespaces can contain ".", so we need to check if the key starts with any of the namespaces
const [namespace, ...rest] = (currentNamespaces[0] || "").split(".");
return { namespace, key: [...rest, key].join(".") };
}
}
// if the stack is empty, it means we're in the root of the file, so the namespace is hidden in the key (first part of the key when split by ".")
const [namespace, ...rest] = key.split(".");
return { namespace, key: rest.join(".") };
}
// Traverse the AST
traverse(ast, {
// When entering a function or component
FunctionDeclaration: {
enter(path) {
// If entering a function, and stack is not empty, put the namespace on the stack (again so that it's inherited by nested functions)
// on exit it will be popped off the stack
if (namespaceStack.length) {
const namespace = namespaceStack[namespaceStack.length - 1];
enterNamespace(namespace);
}
},
exit(path) {
// If exiting a function, pop the namespace off the stack
if (namespaceStack.length) {
exitNamespace();
}
},
},
FunctionExpression: {
// No need to reset namespace here, it will be inherited if in the same scope
enter(path) {
if (namespaceStack.length) {
const namespace = namespaceStack[namespaceStack.length - 1];
enterNamespace(namespace);
}
},
exit(path) {
if (namespaceStack.length) {
exitNamespace();
}
},
},
ArrowFunctionExpression: {
// No need to reset namespace here, it will be inherited if in the same scope
enter(path) {
if (namespaceStack.length) {
const namespace = namespaceStack[namespaceStack.length - 1];
enterNamespace(namespace);
}
},
exit(path) {
if (namespaceStack.length) {
exitNamespace();
}
},
},
CallExpression(path) {
const { callee } = path.node;
// Check for `useTranslations` and extract the translation context
if (callee.type === "Identifier" && callee.name === "useTranslations") {
if (path.node.arguments.length > 0 && path.node.arguments[0].type === "StringLiteral") {
const namespace = path.node.arguments[0].value;
namespaceRedeclaration();
enterNamespace([namespace]); // Push namespace onto stack as array - since it's a single value
} else {
console.log("No namespace found for useTranslations");
enterNamespace([]);
}
}
// Check for `getTranslations` and extract namespaces
if (
callee.type === "Identifier" &&
callee.name === "getTranslations" &&
path.node.arguments[0].type === "ObjectExpression"
) {
const namespacesArg = (path.node.arguments[0] as t.ObjectExpression).properties.find(
(prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === "namespaces"
) as t.ObjectProperty;
const namespaces = (namespacesArg.value as t.ArrayExpression).elements.map(
(el) => (el as t.StringLiteral).value
); // Extract all namespaces
enterNamespace(namespaces);
}
// General case: Check for `t()` or `t.*()` calls and extract keys
if (
(t.isIdentifier(callee) && callee.name === "t") ||
(t.isMemberExpression(callee) && t.isIdentifier(callee.object) && callee.object.name === "t")
) {
if (path.node.arguments.length > 0 && t.isStringLiteral(path.node.arguments[0])) {
let transKey = path.node.arguments[0].value; // Extract translation key
// replace : with . in the key
transKey = transKey.replace(/:/g, ".");
const { namespace, key } = getCurrentNamespaceAndKey(transKey); // Get current namespace from the stack
if (namespace) {
if (!translations[namespace]) {
translations[namespace] = [];
}
translations[namespace].push(key); // Add key to current namespace
} else {
console.log("No namespace found for key", transKey);
}
}
}
},
});
return translations;
}
// Function to read and parse a JSON file
function readJsonFile(filePath: string): Translations {
if (!fs.existsSync(filePath)) return {};
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Function to write to a JSON file
function writeJsonFile(filePath: string, data: Translations): void {
// if file exists, create a backup
if (fs.existsSync(filePath)) {
const backupPath = filePath.replace(".json", "-backup.json");
fs.copyFileSync(filePath, backupPath);
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
}
// Recursive function to flatten nested JSON keys
function flattenJson(json: Record<string, any>, prefix = ""): Translations {
let flat: Translations = {};
for (const key in json) {
const value = json[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && !Array.isArray(value)) {
Object.assign(flat, flattenJson(value, newKey));
} else {
flat[newKey] = value;
}
}
return flat;
}
// Recursive function to deflatten JSON keys
function deflattedJson(json: Translations): Record<string, any> {
let deflattened: Record<string, any> = {};
for (const key in json) {
const value = json[key];
const parts = key.split(".");
let current = deflattened;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
return deflattened;
}
// Main function to update the translations for all languages
function updateTranslations(): void {
const tsFiles = getTsFiles();
const extractedTranslations: NamespaceTranslations = {};
// Traverse all TypeScript files and extract translations
tsFiles.forEach((file) => {
const content = fs.readFileSync(file, "utf-8");
const translations = extractTranslations(content);
for (const namespace in translations) {
if (!extractedTranslations[namespace]) {
extractedTranslations[namespace] = [];
}
extractedTranslations[namespace].push(...translations[namespace]);
}
});
// remove duplicates from extractedTranslations
for (const namespace in extractedTranslations) {
extractedTranslations[namespace] = extractedTranslations[namespace].filter(
(value, index, array) => array.indexOf(value) === index
);
}
// remove empty namespaces
for (const namespace in extractedTranslations) {
if (extractedTranslations[namespace].length === 0) {
delete extractedTranslations[namespace];
}
}
// every namespace should have sorted keys for easier processing of the files
// beware that the keys might contain dots, so we need to sort them by the parts of the key
for (const namespace in extractedTranslations) {
extractedTranslations[namespace].sort((a, b) => {
const partsA = a.split(".");
const partsB = b.split(".");
const minLength = Math.min(partsA.length, partsB.length);
for (let i = 0; i < minLength; i++) {
if (partsA[i] < partsB[i]) {
return -1;
} else if (partsA[i] > partsB[i]) {
return 1;
}
}
return partsA.length - partsB.length;
});
}
console.log(extractedTranslations);
// Backup the old `messages` folder and create a fresh one
const backupDir = createBackupAndClearMessagesFolder();
// Process each namespace for each language
languages.forEach((language) => {
const languageDir = path.join(__dirname, "messages", language);
const languageBackupDir = path.join(backupDir, language);
// Ensure the language directory exists
fs.mkdirSync(languageDir, { recursive: true });
for (const namespace in extractedTranslations) {
const jsonPath = path.join(languageDir, `${namespace}.json`);
const backupJsonPath = path.join(languageBackupDir, `${namespace}.json`);
// Read current JSON (from backup) and update translations
const currentTranslations = readJsonFile(backupJsonPath);
const flatCurrent = flattenJson(currentTranslations);
const updatedTranslations: Translations = {};
const extractedKeys = new Set(extractedTranslations[namespace]);
// Iterate over the extracted keys and add them to the updated translations object with empty values or value from the current translations if it exists
extractedKeys.forEach((key) => {
updatedTranslations[key] = flatCurrent[key] || "";
});
// Add new keys that don't exist in the current translations
extractedTranslations[namespace].forEach((key) => {
if (!updatedTranslations[key]) {
updatedTranslations[key] = "";
}
});
// deflatten the updated translations
const deflattenedTranslations = deflattedJson(updatedTranslations);
// Write updated translations
writeJsonFile(jsonPath, deflattenedTranslations);
}
});
}
// Run the updateTranslations function
updateTranslations();
// Example of processing a file
const fileContent = `
function ComponentA() {
const t = useTranslations('common');
function nestedFunction() {
return t('hello');
}
return <div>{t('goodbye')}</div>;
}
async function ComponentB() {
const t = await getTranslations({
locale,
namespaces: ['home', 'dashboard'],
});
const nestedFunc = () => {
return t('dashboard.dashboard-key');
};
return <div>{t('home.home-key')}</div>;
}
function ComponentC() {
const t = useTranslations("ns2");
function nestedFunction() {
return t('ns.hello');
const func = () => {
const t = useTranslations("common");
return t.rich("ciao", {});
}
}
return <div>{t('something.something3.something2.goodbye')}</div>;
}
function ComponentD() {
const t = useTranslations('common');
return (
<>
<div>{t('hello')}</div>
<div>{t.rich('rich-text', { bold: '<b>Bold Text</b>' })}</div>
<div>{t.html('html-key', { name: 'User' })}</div>
</>
);
}
const Ordering = ({ option }: { option: SortOptions }) => {
const t = useTranslations("category.ordering.options");
return (
<div className={styles.ordering_holder}>
{Object.values(SortOptions).map((key) => {
// because the lcoale files are generated from static analysis, we can't put the key in the t function as variable
let title = "";
switch (key) {
case SortOptions.POSITION_DESC:
title = t("-position");
break;
case SortOptions.PRICE_ASC:
title = t("price");
break;
case SortOptions.PRICE_DESC:
title = t("-price");
break;
case SortOptions.RATING_DESC:
title = t("-rating");
break;
case SortOptions.NAME_ASC:
title = t("name");
break;
default:
title = "";
}
return <OrderButton key={key} option={key} title={t(key.toLocaleLowerCase())} />;
})}
</div>
);
};
`;
// const extractedTranslations = extractTranslations(fileContent);
// console.log(extractedTranslations);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment