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

CON-1959: add default-value rule #2

Merged
merged 1 commit into from
Oct 20, 2021
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
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo nit: must a string

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we need the white space after the first [?

Copy link
Contributor Author

@swain swain Oct 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, to ensure we also match e.g. {{ long }}, not just {{long}}

?.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;
}
}
};