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..09bf3b1 --- /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 value", + "Key that only exists in en.json": "Value", + }) + } + if (file === "nl.json") { + return JSON.stringify({ + "Existing key": "Existing value", + }) + } + }), + } + 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