-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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')` |
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')], | ||
}, | ||
], | ||
}); |
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did we need the white space after the first There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, to ensure we also match e.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; | ||
} | ||
} | ||
}, | ||
}; | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './default-value'; |
This file was deleted.
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, | ||
}, | ||
}; |
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; | ||
} | ||
} | ||
}; |
There was a problem hiding this comment.
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