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