Created
October 27, 2023 19:12
-
-
Save codebutler/51d43de2e9449017a23160f5b60c4f0f to your computer and use it in GitHub Desktop.
Script that converts i18next/i18next-react translation keys from snake_case to camelCase
This file contains hidden or 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 { camelCase } from "lodash"; | |
import * as path from "path"; | |
import { Node, Project, SyntaxKind } from "ts-morph"; | |
/* | |
* This script converts all i18next/i18next-react translation keys from | |
* snake_case to camelCase. It updates the JSON translation files and the | |
* source code. | |
* | |
* The reason to not use snake_case is that i18next uses underscores as a | |
* delimiter for plurals and contexts. Using underscores for any other purpose | |
* causes issues with type checking. | |
* | |
* Supported syntax: | |
* - i18n key string: t("KEY") | |
* - i18n key with interpolation: t(`KEY.${arg}`) | |
* - i18n key in JSX/TSX: <Trans i18nKey="KEY" /> | |
* - i18n key with interpolation in JSX/TSX expression: <Trans i18nKey={`KEY.${arg}`} /> | |
* | |
* Use 'bun' to run the script: | |
* bun run morphs/camel-case-i18n-keys.ts | |
*/ | |
const IGNORED_JSON_FILES = ["enums.json"]; | |
const PLURAL_SUFFIXES = ["_one", "_other"]; | |
const project = new Project({ | |
tsConfigFilePath: "tsconfig.json", | |
}); | |
const translationsDir = path.join(__dirname, "..", "src", "locales", "en"); | |
const files = fs | |
.readdirSync(translationsDir) | |
.filter( | |
(file) => file.endsWith(".json") && !IGNORED_JSON_FILES.includes(file), | |
); | |
const processObjectKeys = (obj: any) => { | |
const newObj: Record<string, unknown> = {}; | |
for (const key in obj) { | |
const newKey = key | |
.split(".") | |
.map((key, index, arr) => { | |
const isLast = index === arr.length - 1; | |
const isPlural = PLURAL_SUFFIXES.some((suffix) => key.endsWith(suffix)); | |
if (isLast && isPlural) { | |
const suffixDelimiterIndex = key.lastIndexOf("_"); | |
const prefix = key.slice(0, suffixDelimiterIndex); | |
const suffix = key.slice(suffixDelimiterIndex + 1); | |
return [camelCase(prefix), suffix].join("_"); | |
} | |
return camelCase(key); | |
}) | |
.join("."); | |
const value = obj[key]; | |
if (typeof value === "object") { | |
newObj[newKey] = processObjectKeys(value); | |
} else { | |
newObj[newKey] = value; | |
} | |
} | |
return newObj; | |
}; | |
for (const file of files) { | |
const filePath = path.join(translationsDir, file); | |
console.log("Update translation file:", filePath); | |
const data = fs.readFileSync(filePath, "utf8"); | |
const json = JSON.parse(data); | |
const newJson = processObjectKeys(json); | |
const newData = JSON.stringify(newJson, null, 2); | |
fs.writeFileSync(filePath, newData, "utf8"); | |
} | |
const translateText = (text: string) => { | |
const nsDelimiterIndex = text.indexOf(":"); | |
if (nsDelimiterIndex >= 0) { | |
const ns = text.slice(0, nsDelimiterIndex); | |
if (ns === "enums") { | |
return text; | |
} | |
const oldKey = text.slice(nsDelimiterIndex + 1); | |
const newKey = oldKey.split(".").map(camelCase).join("."); | |
return `${ns}:${newKey}`; | |
} else { | |
const newKey = text.split(".").map(camelCase).join("."); | |
return `${newKey}`; | |
} | |
}; | |
const translateValueNode = (firstArg: Node) => { | |
if (firstArg.getKind() === SyntaxKind.StringLiteral) { | |
const text = firstArg.getText().slice(1, -1); // remove quotes | |
firstArg | |
.asKindOrThrow(SyntaxKind.StringLiteral) | |
.replaceWithText(`"${translateText(text)}"`); | |
} else if (firstArg.getKind() === SyntaxKind.TemplateExpression) { | |
const tmplExpr = firstArg.asKindOrThrow(SyntaxKind.TemplateExpression); | |
const head = tmplExpr.getHead(); | |
head.replaceWithText(`\`${translateText(head.getLiteralText())}$\{`); | |
} else { | |
console.warn("Unexpected first arg:", firstArg.getKindName()); | |
} | |
}; | |
project.getSourceFiles().forEach((file) => { | |
console.log("Update source file:", file.getFilePath()); | |
file.forEachDescendant((node) => { | |
if (node.getKind() === SyntaxKind.CallExpression) { | |
const callExpr = node.asKindOrThrow(SyntaxKind.CallExpression); | |
const expression = callExpr.getExpression(); | |
if ( | |
expression && | |
(expression.getText() === "t" || expression.getText() === "i18n.t") | |
) { | |
const args = callExpr.getArguments(); | |
const firstArg = args[0]; | |
translateValueNode(firstArg); | |
} | |
} else if (node.getKind() === SyntaxKind.JsxOpeningElement) { | |
const jsxOpeningElement = node.asKindOrThrow( | |
SyntaxKind.JsxOpeningElement, | |
); | |
if (jsxOpeningElement.getTagNameNode().getText() === "Trans") { | |
const attrs = jsxOpeningElement.getAttributes(); | |
const i18nKeyAttr = attrs | |
.find((attr) => { | |
const name = attr | |
.asKindOrThrow(SyntaxKind.JsxAttribute) | |
.getNameNode(); | |
return name.getText() === "i18nKey"; | |
}) | |
?.asKindOrThrow(SyntaxKind.JsxAttribute); | |
if (!i18nKeyAttr) { | |
return; | |
} | |
const attrInit = i18nKeyAttr.getInitializerOrThrow(); | |
translateValueNode(attrInit); | |
} | |
} | |
}); | |
}); | |
project.saveSync(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment