Last active
September 12, 2024 11:38
-
-
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`.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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