Skip to content

Commit

Permalink
Add no-missing-translations rule (#5)
Browse files Browse the repository at this point in the history
Resolves: #4
  • Loading branch information
jonathan-waarneming-nl authored Oct 14, 2024
1 parent 2e69a03 commit 6228a59
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 0 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
```

<table>
<tr>
<td> ❌ Incorrect </td> <td> ✅ Correct </td>
</tr>
<tr>
<td>

```typescript
i18n.t('Missing translation key')
```
</td>
<td>

```typescript
i18n.t('Hello world')
```
</td>
</tr>
</table>

## 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
Expand Down
2 changes: 2 additions & 0 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions dist/rules/no-missing-translations.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 }
82 changes: 82 additions & 0 deletions src/rules/__tests__/no-missing-translations.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("fs")>("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"],
},
],
},
],
})
107 changes: 107 additions & 0 deletions src/rules/no-missing-translations.ts
Original file line number Diff line number Diff line change
@@ -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<RuleContext<MessageIds, unknown[]>>,
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<Options, MessageIds>({
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

0 comments on commit 6228a59

Please sign in to comment.