From 801074b9d140025189070b621b773409552b698a Mon Sep 17 00:00:00 2001 From: Jonathan Neuteboom Date: Sun, 6 Oct 2024 18:11:57 +0200 Subject: [PATCH] Add new rule: no missing translations --- dist/index.js | 2 + dist/rules/no-missing-translations.js | 71 ++++++++++++++ src/index.ts | 2 + .../__tests__/no-missing-translations.test.ts | 81 ++++++++++++++++ src/rules/no-function-without-logging.ts | 3 +- src/rules/no-missing-translations.ts | 93 +++++++++++++++++++ tsconfig.json | 3 +- 7 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 dist/rules/no-missing-translations.js create mode 100644 src/rules/__tests__/no-missing-translations.test.ts create mode 100644 src/rules/no-missing-translations.ts diff --git a/dist/index.js b/dist/index.js index 1116488..1e8a02d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5,7 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.rules = void 0; const no_function_without_logging_1 = __importDefault(require("./rules/no-function-without-logging")); +const no_missing_translations_1 = __importDefault(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..90a6b27 --- /dev/null +++ b/dist/rules/no-missing-translations.js @@ -0,0 +1,71 @@ +"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', + }, + 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 f2d5279..53dc385 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..17774a3 --- /dev/null +++ b/src/rules/__tests__/no-missing-translations.test.ts @@ -0,0 +1,81 @@ +import { jest } from '@jest/globals' +import { RuleTester } from '@typescript-eslint/rule-tester' +import noMissingTranslations from '../no-missing-translations' + +const ruleTester = new RuleTester() + +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-function-without-logging.ts b/src/rules/no-function-without-logging.ts index 1106e27..ad34ead 100644 --- a/src/rules/no-function-without-logging.ts +++ b/src/rules/no-function-without-logging.ts @@ -1,7 +1,6 @@ import * as path from 'path' -import { TSESTree } from '@typescript-eslint/utils' -import { ESLintUtils } from '@typescript-eslint/utils' +import { TSESTree, ESLintUtils } from '@typescript-eslint/utils' import { ReportSuggestionArray, RuleContext, RuleFixer } from '@typescript-eslint/utils/ts-eslint' import { diff --git a/src/rules/no-missing-translations.ts b/src/rules/no-missing-translations.ts new file mode 100644 index 0000000..6882b8a --- /dev/null +++ b/src/rules/no-missing-translations.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'fs' + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils' +import { RuleContext } from '@typescript-eslint/utils/ts-eslint' +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', + }, + 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 diff --git a/tsconfig.json b/tsconfig.json index d911048..f026aa4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "strict": true, "target": "esnext", "moduleResolution": "NodeNext", - "skipLibCheck": true + "skipLibCheck": true, + "esModuleInterop": true }, "files": ["src/index.ts"] }