Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add no-missing-translations rule #5

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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