eslint-plugin-i18next
Version:
ESLint plugin for i18n
479 lines (415 loc) • 14.5 kB
JavaScript
/**
* @fileoverview disallow literal string
* @author edvardchen
*/
'use strict';
const {
isUpperCase,
getNearestAncestor,
isAllowedDOMAttr,
shouldSkip,
} = require('../helper');
function getSourceCode(context) {
return context.sourceCode || context.getSourceCode();
}
function getAncestors(context, node) {
if (context.sourceCode && context.sourceCode.getAncestors) {
return context.sourceCode.getAncestors(node);
}
return context.getAncestors(node);
}
function withDottedPrefix(patterns = []) {
return patterns.map(item =>
typeof item === 'string' ? `(?:.*\\.)?${item}` : item
);
}
function isValidFunctionCall(context, options, { callee }) {
if (callee.type === 'Import') return true;
const sourceText = getSourceCode(context).getText(callee);
const { include = [], exclude = [] } = options.callees || {};
return shouldSkip(
{
include: withDottedPrefix(include),
exclude: withDottedPrefix(exclude),
},
sourceText
);
}
function isValidLiteral(options, { value }) {
if (typeof value !== 'string') {
return true;
}
const trimed = value.trim();
if (!trimed) return true;
if (shouldSkip(options.words, trimed)) return true;
}
/**
* @param {VDirective | VAttribute} node
* @returns {string | null}
*/
function getAttributeName(node) {
if (!node.directive) {
return node.key.rawName;
}
if (
(node.key.name.name === 'bind' || node.key.name.name === 'model') &&
node.key.argument &&
node.key.argument.type === 'VIdentifier'
) {
return node.key.argument.rawName;
}
return null;
}
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
meta: {
docs: {
description: 'disallow literal string',
category: 'Best Practices',
recommended: true,
},
schema: [require('../options/schema.json')],
},
create(context) {
// variables should be defined here
const parserServices =
context.parserServices || context.sourceCode.parserServices;
const options = {
...require('../options/defaults'),
...context.options[0],
};
const {
mode,
'should-validate-template': validateTemplate,
message,
framework,
} = options;
const onlyValidateJSX = ['jsx-only', 'jsx-text-only'].includes(mode);
const onlyValidateVueTemplate =
framework === 'vue' && mode === 'vue-template-only';
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
const indicatorStack = [];
function endIndicator() {
indicatorStack.pop();
}
/**
* detect if current "scope" is valid
*/
function isValidScope() {
return indicatorStack.some(item => item);
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
function report(node) {
context.report({
node,
message: `${message}: ${getSourceCode(context).getText(node.parent)}`,
});
}
const { esTreeNodeToTSNodeMap, program } = parserServices;
let typeChecker;
if (program && esTreeNodeToTSNodeMap)
typeChecker = program.getTypeChecker();
function validateBeforeReport(node) {
if (isValidScope()) return;
if (isValidLiteral(options, node)) return;
// ─── Typescript ──────────────────────────────────────
if (typeChecker) {
const tsNode = esTreeNodeToTSNodeMap.get(node);
const typeObj = typeChecker.getContextualType(tsNode);
if (typeObj) {
// var a: 'abc' = 'abc'
if (typeObj.isStringLiteral()) {
return;
}
// var a: 'abc' | 'name' = 'abc'
if (typeObj.isUnion()) {
const found = typeObj.types.some(item => {
if (item.isStringLiteral() && item.value === node.value) {
return true;
}
});
if (found) return;
}
}
}
// ─────────────────────────────────────────────────────
report(node);
}
function filterOutJSX(node) {
if (onlyValidateJSX) {
const isInsideJSX = getAncestors(context, node).some(item =>
['JSXElement', 'JSXFragment'].includes(item.type)
);
if (!isInsideJSX) return true;
if (
mode === 'jsx-text-only' &&
!['JSXElement', 'JSXFragment'].includes(node.parent.type)
) {
// Under mode jsx-text-only, if the direct parent isn't JSXElement or JSXFragment then skip
return true;
}
}
return false;
}
const scriptVisitor = {
//
// ─── EXPORT AND IMPORT ───────────────────────────────────────────
//
ImportExpression(node) {
// allow (import('abc'))
indicatorStack.push(true);
},
'ImportExpression:exit': endIndicator,
ImportDeclaration(node) {
// allow (import abc form 'abc')
indicatorStack.push(true);
},
'ImportDeclaration:exit': endIndicator,
ExportAllDeclaration(node) {
// allow export * from 'mod'
indicatorStack.push(true);
},
'ExportAllDeclaration:exit': endIndicator,
'ExportNamedDeclaration[source]'(node) {
// allow export { named } from 'mod'
indicatorStack.push(true);
},
'ExportNamedDeclaration[source]:exit': endIndicator,
// ─────────────────────────────────────────────────────────────────
//
// ─── JSX ─────────────────────────────────────────────────────────
//
JSXElement(node) {
const fullComponentName = getSourceCode(context).getText(
node.openingElement.name
);
const componentOptions = options['jsx-components'] || {};
const { include = [], exclude = [] } = componentOptions;
indicatorStack.push(
shouldSkip(
{
include: withDottedPrefix(include),
exclude: withDottedPrefix(exclude),
},
fullComponentName
)
);
},
'JSXElement:exit': endIndicator,
JSXAttribute(node) {
const attrName = node.name.name;
// allow <MyComponent className="active" />
if (shouldSkip(options['jsx-attributes'], attrName)) {
indicatorStack.push(true);
return;
}
const jsxElement = getNearestAncestor(node, 'JSXOpeningElement');
const tagName = jsxElement.name.name;
if (isAllowedDOMAttr(tagName, attrName)) {
indicatorStack.push(true);
return;
}
indicatorStack.push(false);
},
'JSXAttribute:exit': endIndicator,
// @typescript-eslint/parser would parse string literal as JSXText node
JSXText(node) {
validateBeforeReport(node);
},
// ─────────────────────────────────────────────────────────────────
//
// ─── Vue ──────────────────────────────────────────────────
//
VElement(node) {
const componentOptions = options['jsx-components'] || {};
const { include = [], exclude = [] } = componentOptions;
indicatorStack.push(
shouldSkip(
{
include: withDottedPrefix(include),
exclude: withDottedPrefix(exclude),
},
node.rawName
)
);
},
'VElement:exit': endIndicator,
VAttribute(node) {
const attrName = getAttributeName(node);
indicatorStack.push(shouldSkip(options['jsx-attributes'], attrName));
},
'VAttribute:exit': endIndicator,
// ─────────────────────────────────────────────────────────────────
//
// ─── TYPESCRIPT ──────────────────────────────────────────────────
//
TSModuleDeclaration() {
indicatorStack.push(true);
},
'TSModuleDeclaration:exit': endIndicator,
TSLiteralType(node) {
// allow var a: Type['member'];
indicatorStack.push(true);
},
'TSLiteralType:exit': endIndicator,
TSEnumMember(node) {
// allow enum E { "a b" = 1 }
indicatorStack.push(true);
},
'TSEnumMember:exit': endIndicator,
// ─────────────────────────────────────────────────────────────────
/**
* @param {import('estree').PropertyDefinition} node
*/
PropertyDefinition(node) {
indicatorStack.push(
!!(node.key && shouldSkip(options['class-properties'], node.key.name))
);
},
'PropertyDefinition:exit': endIndicator,
ClassProperty(node) {
indicatorStack.push(
!!(node.key && shouldSkip(options['class-properties'], node.key.name))
);
},
'ClassProperty:exit': endIndicator,
VariableDeclarator(node) {
// allow statements like const A_B = "test"
indicatorStack.push(isUpperCase(node.id.name));
},
'VariableDeclarator:exit': endIndicator,
Property(node) {
// pick up key.name if key is Identifier or key.value if key is Literal
// dont care whether if this is computed or not
const result = shouldSkip(
options['object-properties'],
node.key.name || node.key.value
);
indicatorStack.push(result);
},
'Property:exit': endIndicator,
BinaryExpression(node) {
const { operator } = node;
// allow name === 'Android'
indicatorStack.push(operator !== '+');
},
'BinaryExpression:exit': endIndicator,
AssignmentPattern(node) {
// allow function bar(input = 'foo') {}
indicatorStack.push(true);
},
'AssignmentPattern:exit': endIndicator,
NewExpression(node) {
indicatorStack.push(isValidFunctionCall(context, options, node));
},
'NewExpression:exit': endIndicator,
CallExpression(node) {
indicatorStack.push(isValidFunctionCall(context, options, node));
},
'CallExpression:exit': endIndicator,
TaggedTemplateExpression(node) {
indicatorStack.push(
isValidFunctionCall(context, options, { callee: node.tag })
);
},
'TaggedTemplateExpression:exit': endIndicator,
'AssignmentExpression[left.type="MemberExpression"]'(node) {
// allow Enum['value']
indicatorStack.push(
shouldSkip(options['object-properties'], node.left.property.name)
);
},
'AssignmentExpression[left.type="MemberExpression"]:exit'(node) {
endIndicator();
},
TemplateLiteral(node) {
if (!validateTemplate) {
return;
}
if (framework === 'react' && filterOutJSX(node)) {
return;
}
if (isValidScope()) return;
const { quasis = [] } = node;
quasis.some(({ value: { raw } }) => {
if (isValidLiteral(options, { value: raw })) return;
report(node);
return true; // break
});
},
Literal(node) {
// allow Enum['value'] and literal that follows the 'case' keyword in a switch statement.
if (['MemberExpression', 'SwitchCase'].includes(node?.parent?.type)) {
return;
}
if (framework === 'react' && filterOutJSX(node)) {
return;
}
if (onlyValidateVueTemplate) {
const parents = getAncestors(context, node);
if (
parents.length &&
parents.every(
item =>
![
'VElement',
'VAttribute',
'VText',
'VExpressionContainer',
].includes(item.type)
)
) {
return true;
}
}
// ignore `var a = { "foo": 123 }`
if (node.parent.key === node) {
return;
}
validateBeforeReport(node);
},
};
return (
(parserServices.defineTemplateBodyVisitor &&
parserServices.defineTemplateBodyVisitor(
{
VText(node) {
scriptVisitor['JSXText'](node);
},
VLiteral(node) {
scriptVisitor['JSXText'](node);
},
VElement(node) {
scriptVisitor['VElement'](node);
},
'VElement:exit'(node) {
scriptVisitor['VElement:exit'](node);
},
VAttribute(node) {
scriptVisitor['VAttribute'](node);
},
'VAttribute:exit'(node) {
scriptVisitor['VAttribute:exit'](node);
},
'VExpressionContainer CallExpression'(node) {
scriptVisitor['CallExpression'](node);
},
'VExpressionContainer CallExpression:exit'(node) {
scriptVisitor['CallExpression:exit'](node);
},
'VExpressionContainer Literal'(node) {
scriptVisitor['Literal'](node);
},
},
scriptVisitor
)) ||
scriptVisitor
);
},
};