Skip to content

Commit

Permalink
Merge pull request #2 from lifeomic/CON-1959
Browse files Browse the repository at this point in the history
CON-1959: add default-value rule
  • Loading branch information
swain authored Oct 20, 2021
2 parents bc66c54 + fa6249d commit b99311d
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 6 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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')`
104 changes: 104 additions & 0 deletions src/default-value/default-value.test.ts
Original file line number Diff line number Diff line change
@@ -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')],
},
],
});
124 changes: 124 additions & 0 deletions src/default-value/default-value.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
},
};
},
};
1 change: 1 addition & 0 deletions src/default-value/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './default-value';
5 changes: 0 additions & 5 deletions src/index.test.ts

This file was deleted.

8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export const placeholder = 'placeholder';
import { defaultValueRule } from './default-value/default-value';

export default {
rules: {
'default-value': defaultValueRule,
},
};
30 changes: 30 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
};

0 comments on commit b99311d

Please sign in to comment.