Skip to content

Instantly share code, notes, and snippets.

@kelleyvanevert
Last active May 24, 2023 12:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kelleyvanevert/33da23f9a80049cca717420e82e2c75d to your computer and use it in GitHub Desktop.
Save kelleyvanevert/33da23f9a80049cca717420e82e2c75d to your computer and use it in GitHub Desktop.
A little helper script that uses Babel to scan your React source code for untranslated content

A little helper script that uses Babel to scan your React source code for untranslated content

This script will analyze the translation coverage in your source code. It improves upon i18next-scanner here and there, and is custom-tailored to a use-case I have.

The key insight here is that parsing and scanning code with Babel is remarkably simple, and you can just build a small tool with it when the need arises. And there's a good chance this is an easier path than trying to get existing tooling to work for you!

It assumes you wrap all your content strings like such:

function MyComponent() {
  return (
    <div>
      {t("A simple string")}
      <Trans>
        A good translation can <strong>cost you</strong> more
        than <Currency amount={10} noDecimals />.
      </Trans>
    </div>
  );
}

const something = t("Some constant string");

Any covered content strings (either wrapped in t() or <Trans>) are automatically added to all language files (like en.json, de.json, etc in a certain directory), but of course won't overwrite existing entries. Additionally, it saves a summary of its findings in a file _todo.json in that same directory. The summary consists of these categories:

  • Untranslated content in source code — Content strings found verbatim in the source code, not wrapped in t() or <Trans>
  • Possibly orphaned translations (per language) — Translated strings in a language file, that was not found in the code (any more)
  • Yet to be translated dummy translation content (per language) — Translation entries that are identical, i.e. "Annuleren": "Annuleren"

It also prints these results to stardard out while scanning, which lookes something like this:

const _ = require("lodash");
const fs = require("fs");
const path = require("path");
const chalk = require("chalk");
const { parse, traverse } = require("@babel/core");
const t = require("@babel/types");
const glob = require("glob");
const root = path.resolve(__dirname + "/../");
const translations_dir = `${root}/src/translations`;
let translations;
let seen = {};
let warn = {};
console.log(chalk.bold`Loading current translations...`);
glob(`${translations_dir}/+([a-z0-9]).json`, (err, matches) => {
translations = _.fromPairs(
matches.map(filename => {
return [
filename.match(/([a-z0-9]+)\.json/)[1],
JSON.parse(fs.readFileSync(filename, "utf8"))
];
})
);
console.log(translations);
console.log("");
console.log(chalk.bold`Scanning source files...`);
glob(
`${root}/src/**/*/!(*.spec|*.test|*.stories|*.dev).{js,jsx,ts,tsx}`,
(err, matches) => {
matches.forEach(scanFile);
console.log("");
console.log(chalk.bold`Writing back new translations:`);
console.log(translations);
Object.entries(translations).forEach(([lang, data]) => {
fs.writeFileSync(
`${translations_dir}/${lang}.json`,
JSON.stringify(data, null, 2),
"utf8"
);
});
const TODO = {};
console.log("");
console.log(chalk.bold`Yet to be translated dummy translation content:`);
const dummy = (TODO[
"Yet to be translated dummy translation content"
] = {});
Object.entries(translations).forEach(([lang, data]) => {
dummy[lang] = {};
Object.keys(data).forEach(str => {
if (str === data[str]) {
dummy[lang][str] = true;
console.log(" -", chalk.bold.yellow(str));
}
});
});
console.log("");
console.log(chalk.bold`Possibly orphaned translations:`);
const orphaned = (TODO["Possibly orphaned translations"] = {});
Object.entries(translations).forEach(([lang, data]) => {
orphaned[lang] = {};
Object.keys(data).forEach(str => {
if (!seen[str]) {
orphaned[lang][str] = true;
console.log(" -", chalk.bold.magenta(str));
}
});
});
console.log("");
console.log(chalk.bold`Untranslated content in source code:`);
TODO["Untranslated content in source code"] = _.mapValues(
warn,
o => true
);
Object.entries(warn).forEach(([text, [filename, startLoc]]) => {
console.log(" -", chalk.bold.cyan(text), "@", filename, {
...startLoc
});
});
fs.writeFileSync(
`${translations_dir}/_todo.json`,
JSON.stringify(TODO, null, 2),
"utf8"
);
console.log("");
}
);
});
const colorings = {
t: chalk.bold.green,
Trans: chalk.bold.blue,
i18nKey: chalk.bold.white
};
function foundStr(type, str) {
if (!seen[str]) {
seen[str] = true;
console.log(" - found", type, colorings[type](str));
Object.keys(translations).forEach(lang => {
if (typeof translations[lang][str] === "undefined") {
translations[lang][str] = str; // default to default language
console.log(" - added to", chalk.inverse(` ${lang} `));
}
});
}
}
function scanFile(filename) {
const f = filename.replace(root + "/", "");
console.log(chalk.dim(f));
const code = fs.readFileSync(filename, "utf8");
const ast = parse(code, {
presets: [
["@babel/preset-typescript", { isTSX: true, allExtensions: true }]
],
plugins: [
["@babel/plugin-proposal-decorators", { legacy: true }],
"@babel/plugin-syntax-dynamic-import"
],
filename
});
traverse(ast, {
CallExpression(path) {
if (t.isIdentifier(path.node.callee) && path.node.callee.name === "t") {
if (
(path.node.arguments.length === 1 ||
path.node.arguments.length === 2) &&
t.isStringLiteral(path.node.arguments[0])
) {
const str = path.node.arguments[0].value;
foundStr("t", str);
} else {
console.log(
" -",
chalk.red`t not called with 1 or 2 string literals`,
"@",
f,
{
...path.node.loc.start
}
);
return;
}
}
},
JSXText(path) {
const text = path.node.value.trim();
if (text && !warn[text] && text.match(/[a-zA-Z]/) && text.length > 1) {
console.log(" - not translated?", chalk.bold.red(text));
warn[text] = [f, path.node.loc.start];
}
},
JSXElement(path) {
if (
t.isJSXIdentifier(path.node.openingElement.name) &&
path.node.openingElement.name.name === "Trans"
) {
const i18nKey = path.node.openingElement.attributes.find(
attr => attr.name.name === "i18nKey"
);
if (i18nKey) {
foundStr("i18nKey", i18nKey.value.value);
} else {
foundStr("Trans", nodesToString(path.node.children));
}
path.skip();
}
}
});
}
// See [https://github.com/i18next/i18next-scanner/blob/master/src/nodes-to-string.js]
function nodesToString(nodes) {
let memo = "";
let nodeIndex = 0;
nodes.forEach((node, i) => {
if (t.isJSXText(node) || t.isStringLiteral(node)) {
const value = node.value
.replace(/^[\r\n]+\s*/g, "") // remove leading spaces containing a leading newline character
.replace(/[\r\n]+\s*$/g, "") // remove trailing spaces containing a leading newline character
.replace(/[\r\n]+\s*/g, " "); // replace spaces containing a leading newline character with a single space character
if (!value) {
return null;
}
memo += value;
} else if (t.isJSXExpressionContainer(node)) {
const { expression = {} } = node;
if (t.isNumericLiteral(expression)) {
// Numeric literal is ignored in react-i18next
memo += "";
}
if (t.isStringLiteral(expression)) {
memo += expression.value;
} else if (
t.isObjectExpression(expression) &&
_.get(expression, "properties[0].type") === "ObjectProperty"
) {
// memo += `<${nodeIndex}>{{${
// expression.properties[0].key.name
// }}}</${nodeIndex}>`;
memo += `{{${expression.properties[0].key.name}}}`;
} else {
console.error(
`Unsupported JSX expression. Only static values or {{interpolation}} blocks are supported. Got ${expression.type}:`
);
return null;
}
} else if (node.children) {
memo += `<${nodeIndex}>${nodesToString(node.children)}</${nodeIndex}>`;
}
++nodeIndex;
});
return memo;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment