CLI to find unused/missing translations #503
Replies: 6 comments 3 replies
-
Hey @simonlovesyou, thank you so much for starting this discussion! There's no CLI or ESLint plugin planned at this point, but I'm absolutely open to discussing this! Would you like to implement and provide this under your own GitHub user or in this repo? Both is totally fine IMO, we can link to it from the workflows & integrations page in the docs. I like your idea about having an ESLint plugin, mostly because many developers already use ESLint and therefore it's easy to integrate with their toolchain. In regard to other rules, a But starting with an ESLint plugin with a single rule could still be useful IMO. Do you have experience with building something like this? I'm wondering how this would impact editor performance or, more generally, ESLint runtime performance. When are messages files checked? Are there parts of the linting process that can be cached? A CLI might be less performance-sensitive since a user would explicitly call the CLI e.g. on CI (see also #398 (comment)). What's your opinion? /cc @kieranm |
Beta Was this translation helpful? Give feedback.
-
I tried to write an ESlint rule for this and failed miserably. ESLint seems to analyse files independently and so there doesn't appear to be a way of determining whether something is used or unused globally across the whole codebase. I could be missing something big though as my ESLint knowledge is very limited |
Beta Was this translation helpful? Give feedback.
-
I just noticed i18n Ally has a usage report, this could be helpful in this context: ![]() It currently reports a few false positives for me though, due to pending support for |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
Late to the party, but in case somebody still needs it, with the help of some LLM magic I've created this node script that works for my needs: const fs = require('fs');
const path = require('path');
const { Project, SyntaxKind } = require('ts-morph');
// === CLI ARGUMENTS ===
const locale = process.argv[2] || 'en';
const messagesDir = process.argv[3] || '../messages';
const RELATIVE_LOCALE_PATH = `${messagesDir}/${locale}.json`;
const LOCALE_FILE = path.join(__dirname, RELATIVE_LOCALE_PATH);
const SRC_DIR = path.join(__dirname, '../src'); // Adjust if needed
console.log(`🔍 Checking missing messages in path: ${RELATIVE_LOCALE_PATH}`);
// === STEP 1: LOAD EXISTING TRANSLATIONS ===
let existingMessages = {};
try {
existingMessages = JSON.parse(fs.readFileSync(LOCALE_FILE, 'utf8'));
} catch (err) {
console.error(`❌ Failed to load locale file: ${RELATIVE_LOCALE_PATH}`);
console.error(err.message);
process.exit(1);
}
const existingKeys = new Set();
function flatten(obj, prefix = '') {
for (const key in obj) {
const val = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof val === 'object' && val !== null) {
flatten(val, fullKey);
} else {
existingKeys.add(fullKey);
}
}
}
flatten(existingMessages);
// === STEP 2: PARSE FILES WITH ts-morph ===
const project = new Project({
tsConfigFilePath: path.join(__dirname, '../tsconfig.json'),
});
project.addSourceFilesAtPaths(`${SRC_DIR}/**/*.{ts,tsx,js,jsx}`);
const usedKeys = new Set();
project.getSourceFiles().forEach((file) => {
const namespaceMap = new Map(); // e.g. t -> "general"
// Track: const t = useTranslations('namespace')
file.forEachDescendant((node) => {
if (node.getKind() === SyntaxKind.VariableDeclaration) {
const init = node.getInitializer();
if (!init || init.getKind() !== SyntaxKind.CallExpression) return;
const call = init;
const expr = call.getExpression().getText();
if (expr === 'useTranslations') {
const args = call.getArguments();
if (args.length > 0 && args[0].getKind() === SyntaxKind.StringLiteral) {
const ns = args[0].getText().slice(1, -1); // remove quotes
const varName = node.getName();
namespaceMap.set(varName, ns);
}
}
}
});
// Match calls like t("title") where t is a known namespace-bound function
file.forEachDescendant((node) => {
if (node.getKind() === SyntaxKind.CallExpression) {
const call = node;
const expr = call.getExpression().getText();
const arg = call.getArguments()[0];
if (namespaceMap.has(expr) && arg && arg.getKind() === SyntaxKind.StringLiteral) {
const key = arg.getText().slice(1, -1); // remove quotes
const fullKey = `${namespaceMap.get(expr)}.${key}`;
usedKeys.add(fullKey);
}
}
});
});
// === STEP 3: COMPARE USED VS EXISTING ===
const missing = [...usedKeys].filter((key) => !existingKeys.has(key));
if (missing.length === 0) {
console.log('✅ No missing translation keys found!');
} else {
console.log(`🚨 Found ${missing.length} missing translation key(s):\n`);
missing.forEach((key) => console.log(`- ${key}`));
} Usage example: Required npm package:
|
Beta Was this translation helpful? Give feedback.
-
@amannn I have a created cli tool in go for exactly this purpose and it tests out pretty good. Would you be interested in checking it out? |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hey!
At my previous engagement we developed a CLI to find unused or missing translations with static code analysis. It was closed source unfortunately, but would there be any interest in integrating it to
next-intl
if I were to rebuild it, but open source? :) If so, any thoughts on what the features set should be? There are also some different choices we can make in terms of user configuration, preferred libraries to use etc.This is relevant to Linting unused strings · amannn/next-intl · Discussion #398
EDIT: On second thought it could even be integrated as an eslint rule instead of a separate CLI if we would prefer that.
Beta Was this translation helpful? Give feedback.
All reactions