diff --git a/README.md b/README.md index dc5d496..233dcd0 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,58 @@ class ClassName { } ``` +## No Missing Translations + +This ESLint rule ensures that every call to `i18n.t(...)` in the codebase has a corresponding key in all translation files. A translation file is defined as an input file for the npm package `i18n-js` (https://www.npmjs.com/package/i18n-js). + +## Examples + +Translation file (`nl.json`): +``` +{ + 'Hello world!': "Hallo wereld!" +} +``` + + + + + + + + + +
❌ Incorrect ✅ Correct
+ +```typescript +i18n.t('Missing translation key') +``` + + +```typescript +i18n.t('Hello world') +``` +
+ +## Configuration +To configure the ESLint rule, specify the relative paths of the translation files in an array within the ESLint configuration: +``` +... +rules: { + 'observation/no-missing-translations': [ + 'error', + { + translationFiles: [ + 'src/app/translations/locales/en.json', + 'src/app/translations/locales/nl.json' + ], + } + ] +} +... + +``` + # Build & publish 1. run `tsc` in the working folder, this creates the javascript files that will be run by ESLint diff --git a/dist/index.js b/dist/index.js index 276f418..098de93 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2,7 +2,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.rules = void 0; const no_function_without_logging_1 = require("./rules/no-function-without-logging"); +const no_missing_translations_1 = require("./rules/no-missing-translations"); const rules = { "no-function-without-logging": no_function_without_logging_1.default, + 'no-missing-translations': no_missing_translations_1.default, }; exports.rules = rules; diff --git a/dist/rules/no-missing-translations.js b/dist/rules/no-missing-translations.js new file mode 100644 index 0000000..325c255 --- /dev/null +++ b/dist/rules/no-missing-translations.js @@ -0,0 +1,76 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs_1 = require("fs"); +const utils_1 = require("@typescript-eslint/utils"); +const utils_2 = require("../utils"); +const createRule = utils_1.ESLintUtils.RuleCreator(() => "https://github.com/observation/eslint-rules"); +const checkTranslationFileForKey = (translationFile, translationKey) => { + const fileContent = (0, fs_1.readFileSync)(translationFile, "utf8"); + const jsonData = JSON.parse(fileContent); + return !(translationKey in jsonData); +}; +const checkCallExpression = (context, node, translationFiles) => { + if ((0, utils_2.isMemberExpression)(node.callee)) { + const { object, property } = node.callee; + if ((0, utils_2.isIdentifier)(object) && (0, utils_2.isIdentifier)(property)) { + const [argument] = node.arguments; + if (object.name === "i18n" && + property.name === "t" && + (0, utils_2.isLiteral)(argument)) { + const translationKey = argument.value; + if (typeof translationKey === "string") { + const invalidTranslationFiles = translationFiles.filter((translationFile) => checkTranslationFileForKey(translationFile, translationKey)); + if (invalidTranslationFiles.length > 0) { + context.report({ + node, + messageId: "missingTranslationKey", + data: { + translationKey, + invalidFiles: invalidTranslationFiles + .map((file) => `'${file}'`) + .join(", "), + }, + }); + } + } + } + } + } +}; +const noMissingTranslations = createRule({ + create(context, options) { + const [{ translationFiles }] = options; + return { + CallExpression: (node) => checkCallExpression(context, node, translationFiles), + }; + }, + name: "no-missing-translations", + meta: { + docs: { + description: "All translation keys used in the codebase should have a corresponding translation in the translation files", + recommended: "error", + }, + messages: { + missingTranslationKey: "Translation key '{{ translationKey }}' is missing in: {{ invalidFiles }}", + }, + type: "problem", + schema: [ + { + type: "object", + properties: { + translationFiles: { + type: "array", + items: { type: "string" }, + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [ + { + translationFiles: [], + }, + ], +}); +exports.default = noMissingTranslations; diff --git a/src/index.ts b/src/index.ts index 146b36d..f0fdede 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ import noFunctionWithoutLogging from "./rules/no-function-without-logging" +import noMissingTranslations from "./rules/no-missing-translations" const rules = { "no-function-without-logging": noFunctionWithoutLogging, + 'no-missing-translations': noMissingTranslations, } export { rules } diff --git a/src/rules/__tests__/no-missing-translations.test.ts b/src/rules/__tests__/no-missing-translations.test.ts new file mode 100644 index 0000000..da7ad95 --- /dev/null +++ b/src/rules/__tests__/no-missing-translations.test.ts @@ -0,0 +1,82 @@ +import { ESLintUtils } from "@typescript-eslint/utils" +import noMissingTranslations from "../no-missing-translations" +import { jest } from "@jest/globals" + +const ruleTester = new ESLintUtils.RuleTester({ + parser: "@typescript-eslint/parser", +}) +jest.mock("fs", () => { + const actualFs = jest.requireActual("fs") + const newFs = { + ...actualFs, + readFileSync: jest.fn((file: string) => { + if (file === "en.json") { + return JSON.stringify({ + "Existing key": "Existing key", + "Key that only exists in en.json": "Key that only exists in en.json", + }) + } + if (file === "nl.json") { + return JSON.stringify({ + "Existing key": "Bestaande sleutel", + }) + } + }), + } + return { + __esModule: true, + ...newFs, + } +}) + +ruleTester.run("no-missing-translations", noMissingTranslations, { + valid: [ + { + name: "Function declaration", + code: "i18n.t('Existing key')", + options: [ + { + translationFiles: ["en.json", "nl.json"], + }, + ], + }, + ], + invalid: [ + { + name: "Missing translation key in multiple files", + code: 'i18n.t("Missing key")', + errors: [ + { + messageId: "missingTranslationKey", + data: { + translationKey: "Missing key", + invalidFiles: "'en.json', 'nl.json'", + }, + }, + ], + options: [ + { + translationFiles: ["en.json", "nl.json"], + }, + ], + }, + { + name: "Missing translation key in one file", + code: 'i18n.t("Key that only exists in en.json")', + errors: [ + { + messageId: "missingTranslationKey", + data: { + translationKey: "Key that only exists in en.json", + invalidFiles: "'nl.json'", + }, + }, + ], + options: [ + { + translationFiles: ["en.json", "nl.json"], + }, + ], + }, + ], +}) diff --git a/src/rules/no-missing-translations.ts b/src/rules/no-missing-translations.ts new file mode 100644 index 0000000..56f779c --- /dev/null +++ b/src/rules/no-missing-translations.ts @@ -0,0 +1,107 @@ +import { readFileSync } from "fs" +import { RuleContext } from "@typescript-eslint/utils/dist/ts-eslint" +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils" +import { isIdentifier, isLiteral, isMemberExpression } from "../utils" + +const createRule = ESLintUtils.RuleCreator( + () => "https://github.com/observation/eslint-rules" +) + +type MessageIds = "missingTranslationKey"; +type Options = [ + { + translationFiles: string[]; + } +]; + +const checkTranslationFileForKey = ( + translationFile: string, + translationKey: string +) => { + const fileContent = readFileSync(translationFile, "utf8") + const jsonData = JSON.parse(fileContent) + return !(translationKey in jsonData) +} + +const checkCallExpression = ( + context: Readonly>, + node: TSESTree.CallExpression, + translationFiles: string[] +) => { + if (isMemberExpression(node.callee)) { + const { object, property } = node.callee + + if (isIdentifier(object) && isIdentifier(property)) { + const [argument] = node.arguments + if ( + object.name === "i18n" && + property.name === "t" && + isLiteral(argument) + ) { + const translationKey = argument.value + + if (typeof translationKey === "string") { + const invalidTranslationFiles = translationFiles.filter( + (translationFile) => + checkTranslationFileForKey(translationFile, translationKey) + ) + + if (invalidTranslationFiles.length > 0) { + context.report({ + node, + messageId: "missingTranslationKey", + data: { + translationKey, + invalidFiles: invalidTranslationFiles + .map((file) => `'${file}'`) + .join(", "), + }, + }) + } + } + } + } + } +} + +const noMissingTranslations = createRule({ + create(context, options) { + const [{ translationFiles }] = options + return { + CallExpression: (node) => + checkCallExpression(context, node, translationFiles), + } + }, + name: "no-missing-translations", + meta: { + docs: { + description: + "All translation keys used in the codebase should have a corresponding translation in the translation files", + recommended: "error", + }, + messages: { + missingTranslationKey: + "Translation key '{{ translationKey }}' is missing in: {{ invalidFiles }}", + }, + type: "problem", + schema: [ + { + type: "object", + properties: { + translationFiles: { + type: "array", + items: { type: "string" }, + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [ + { + translationFiles: [], + }, + ], +}) + +export default noMissingTranslations