Skip to content

Instantly share code, notes, and snippets.

@codebutler
Created October 27, 2023 19:12
Show Gist options
  • Save codebutler/51d43de2e9449017a23160f5b60c4f0f to your computer and use it in GitHub Desktop.
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
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