Skip to content

Commit

Permalink
Add new rule: no missing translations
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathan-waarneming-nl committed Oct 6, 2024
1 parent 39a0293 commit c047ee1
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 3 deletions.
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.

71 changes: 71 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 }
81 changes: 81 additions & 0 deletions src/rules/__tests__/no-missing-translations.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('fs')>('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'],
},
],
},
],
})
3 changes: 1 addition & 2 deletions src/rules/no-function-without-logging.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions src/rules/no-missing-translations.ts
Original file line number Diff line number Diff line change
@@ -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<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',
},
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
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"strict": true,
"target": "esnext",
"moduleResolution": "NodeNext",
"skipLibCheck": true
"skipLibCheck": true,
"esModuleInterop": true
},
"files": ["src/index.ts"]
}

0 comments on commit c047ee1

Please sign in to comment.