Skip to content

Commit

Permalink
feat: add default-value rule
Browse files Browse the repository at this point in the history
swain committed Oct 19, 2021

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent e2ef35d commit fa6249d
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 fa6249d

Please sign in to comment.