diff --git a/README.md b/README.md index 5561e65..60aca37 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ This repository contains an ESLint plugin for validating usage of [`18next`](https://github.com/i18next/i18next). + +## Installation + +``` +yarn add -D @lifeomic/eslint-plugin-18next +``` + +## Usage + +```javascript +// .eslintrc.js +module.exports = { + plugins: ['@lifeomic/i18next'], + rules: { + 'i18next/default-value': [ + 'error', + { + /* optional options object */ + }, + ], + }, +}; +``` + +## Rule Options + +#### `default-value` + +- `translateFunctionNames`: an array of translation function names to validate. Default is `['t']` +- `allowKeyOnly`: whether to allow e.g. `t('just-the-key')` diff --git a/src/default-value/default-value.test.ts b/src/default-value/default-value.test.ts new file mode 100644 index 0000000..c8fde28 --- /dev/null +++ b/src/default-value/default-value.test.ts @@ -0,0 +1,104 @@ +import { RuleTester } from 'eslint'; +import { defaultValueRule, Errors } from './default-value'; + +const tester = new RuleTester(); + +tester.run('default-value', defaultValueRule, { + valid: [ + { + code: ` + t('key', { defaultValue: 'some long value' }) + `, + }, + { + code: ` + i18n.t('key', { defaultValue: 'some long value' }) + `, + }, + { + code: ` + translate('key', { defaultValue: 'some long value' }) + `, + options: [{ translateFunctionNames: ['translate'] }], + }, + { + code: ` + translate('key', { defaultValue: 'some {{long}} value' }) + `, + options: [{ translateFunctionNames: ['notTranslate'] }], + }, + { + code: ` + t('key', { + defaultValue: 'some {{long}} value', + long: 'something' + }) + `, + }, + { + code: ` + t('key') + `, + options: [{ allowKeyOnly: true }], + }, + { + code: ` + someOtherFunction() + `, + }, + ], + invalid: [ + { + code: ` + t('key') + `, + errors: [Errors.twoArguments], + }, + { + code: ` + t('key', 'bogus') + `, + errors: [Errors.optionsObject], + }, + { + code: ` + t('key', { notDefaultValue: 'something' }) + `, + errors: [Errors.defaultValuePresence], + }, + { + code: ` + t('key', { defaultValue: true }) + `, + errors: [Errors.defaultValuePresence], + }, + { + code: ` + t('key', { defaultValue: 'some {{long}} value' }) + `, + errors: [Errors.missingVariable('long')], + }, + { + code: ` + translate('key', { + defaultValue: 'some {{long }} value' + }) + `, + options: [{ translateFunctionNames: ['translate'] }], + errors: [Errors.missingVariable('long')], + }, + { + code: ` + translate('key', { + defaultValue: 'some {{long}} value' + }); + + t('key', { + defaultValue: 'some {{short}} value' + }) + `, + options: [{ translateFunctionNames: ['translate', 't'] }], + errors: [Errors.missingVariable('long'), Errors.missingVariable('short')], + }, + ], +}); diff --git a/src/default-value/default-value.ts b/src/default-value/default-value.ts new file mode 100644 index 0000000..e42f981 --- /dev/null +++ b/src/default-value/default-value.ts @@ -0,0 +1,124 @@ +import { Rule } from 'eslint'; +import { ObjectExpression } from 'estree'; +import { findPropertyOnNode, isFunctionCallExpression } from '../util'; + +export const Errors = { + twoArguments: 'Translate function should have two arguments', + optionsObject: 'Translate function options should be an object', + defaultValuePresence: + 'Translate function defaultValue must a string property on the second argument', + missingVariable: (named: string) => + `Missing "${named}" in translate variables`, +}; + +const findDefaultValueOnObject = (node: ObjectExpression) => { + const prop = findPropertyOnNode(node, { named: 'defaultValue' }); + + if (prop?.value.type === 'Literal' && typeof prop.value.value === 'string') { + const variableNames = prop.value.value + .match(/{{[ a-zA-Z0-9]+}}/g) + ?.map((value) => value.replace('{{', '').replace('}}', '').trim()); + + return { + value: prop.value.value, + variableNames: variableNames ?? [], + }; + } +}; + +export type DefaultValueOptions = { + translateFunctionNames: string[]; + allowKeyOnly: boolean; +}; + +const parseOptions = (context: Rule.RuleContext): DefaultValueOptions => { + const DEFAULT: DefaultValueOptions = { + translateFunctionNames: ['t'], + allowKeyOnly: false, + }; + + if (!context.options.length) { + return DEFAULT; + } + + return { + ...DEFAULT, + ...context.options[0], + }; +}; + +export const defaultValueRule: Rule.RuleModule = { + meta: { + schema: [ + { + type: 'object', + additionalProperties: true, + properties: { + translateFunctionNames: { + type: 'array', + items: { type: 'string' }, + }, + allowKeyOnly: { + type: 'boolean', + }, + }, + }, + ], + }, + create: (context) => { + const options = parseOptions(context); + + return { + CallExpression: (node) => { + if ( + !isFunctionCallExpression(node, { + named: options.translateFunctionNames, + }) + ) { + return; + } + + if (node.arguments.length < 2) { + if (options.allowKeyOnly && node.arguments.length === 1) { + return; + } + context.report({ + node, + message: Errors.twoArguments, + }); + return; + } + + const secondArg = node.arguments[1]; + + if (secondArg.type !== 'ObjectExpression') { + context.report({ + node, + message: Errors.optionsObject, + }); + return; + } + + const defaultValue = findDefaultValueOnObject(secondArg); + if (!defaultValue) { + context.report({ + node, + message: Errors.defaultValuePresence, + }); + return; + } + + for (const variable of defaultValue.variableNames) { + const prop = findPropertyOnNode(secondArg, { named: variable }); + if (!prop) { + context.report({ + node, + message: Errors.missingVariable(variable), + }); + return; + } + } + }, + }; + }, +}; diff --git a/src/default-value/index.ts b/src/default-value/index.ts new file mode 100644 index 0000000..32ca480 --- /dev/null +++ b/src/default-value/index.ts @@ -0,0 +1 @@ +export * from './default-value'; diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 51b6e4c..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { placeholder } from './'; - -test('placeholder', () => { - expect(placeholder).toBe('placeholder'); -}); diff --git a/src/index.ts b/src/index.ts index dd98f81..f43537e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,7 @@ -export const placeholder = 'placeholder'; +import { defaultValueRule } from './default-value/default-value'; + +export default { + rules: { + 'default-value': defaultValueRule, + }, +}; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..ed029f4 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,30 @@ +import { CallExpression, ObjectExpression } from 'estree'; + +export const isFunctionCallExpression = ( + node: CallExpression, + options: { named: string[] }, +) => { + return ( + (node.callee.type === 'MemberExpression' && + options.named.includes((node.callee.property as any).name)) || + (node.callee.type === 'Identifier' && + options.named.includes(node.callee.name)) + ); +}; + +export const findPropertyOnNode = ( + { properties }: ObjectExpression, + options: { + named: string; + }, +) => { + for (const prop of properties) { + if ( + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === options.named + ) { + return prop; + } + } +};