UNPKG

eslint-plugin-react-google-translate

Version:

ESLint plugin to highlight code patterns in React applications which can lead to browser exceptions while the Google Translate browser extension is in use.

345 lines (326 loc) 11 kB
/** * @fileoverview Conditionally rendered text nodes should be wrapped in an element (for example, a <span>), otherwise Google Translate will cause a browser error. * @author alistair-coup */ 'use strict'; const { ESLintUtils } = require('@typescript-eslint/utils'); /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', docs: { description: 'Conditionally rendered text nodes should be wrapped in an element (for example, a `<span>`), otherwise Google Translate can cause a browser error.', url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate', }, schema: [], messages: { 'conditional-text-node': 'Conditionally rendered text nodes with siblings (elements or nodes), when rendered as a direct child of a JSX element, should be wrapped in an element (for example, a `<span>`) to prevent Google Translate causing a browser error while manipulating the DOM. This also applies to return values from functions, so `getString()` should become `<span>{getString()}</span>`.', 'text-node-preceded-by-conditional': 'Text nodes which are preceded by a conditional expression, when rendered as a direct child of a JSX element, should be wrapped in an element (for example, a `<span>`) to prevent Google Translate causing a browser error while manipulating the DOM. This also applies to return values from functions, so `getString()` should become `<span>{getString()}</span>`.', }, }, create(context) { // when type checking is unavailable, `parserServices` will be `null` let parserServices = null; let checker = null; try { parserServices = ESLintUtils.getParserServices(context); checker = parserServices.program.getTypeChecker(); } catch { // type checking unavailable } function getTypeForNode(node) { try { const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); const type = checker.getTypeAtLocation(tsNode); return checker.typeToString(type); } catch { return null; } } function returnsStringifiableValue(node) { const type = getTypeForNode(node); if (!type) return false; return type === 'string' || type === 'number'; } function isConditionallyRendered(node) { return ( node.parent && (node.parent.type === 'ConditionalExpression' || node.parent.type === 'LogicalExpression') ); } function isChildOfJSXExpressionContainer(node) { return node.parent && node.parent.type === 'JSXExpressionContainer'; } function isChildOfJSXElement(node) { return node.parent && node.parent.type === 'JSXElement'; } function hasSiblings(node) { return ( node.parent && node.parent.children && node.parent.children.length > 1 && node.parent.children.some( (child) => !Object.is(child, node) && !isWhitespace(child) ) ); } function isWhitespace(node) { return ( (node.type === 'Literal' && typeof node.value === 'string' && node.value !== '' && node.value.trim() === '') || (node.type === 'JSXText' && typeof node.value === 'string' && node.value !== '' && node.value.trim() === '') ); } function conditionalSiblingsPrecedeNode(node) { return ( node.parent && node.parent.children && node.parent.children .filter( (child) => (child.range ? child.range[0] : child.start) < (node.range ? node.range[0] : node.start) && !isWhitespace(child) ) .some( (child) => child.type === 'JSXExpressionContainer' && child.expression && (child.expression.type === 'ConditionalExpression' || child.expression.type === 'LogicalExpression') ) ); } // walk up through nested conditionals to allow reporting problematic // nested values function getOutermostConditional(node) { let current = node; while ( current.parent && (current.parent.type === 'ConditionalExpression' || current.parent.type === 'LogicalExpression') ) { current = current.parent; } return current; } function isProblematicConditional(node) { if (!isConditionallyRendered(node)) return false; const outermost = getOutermostConditional(node); return ( isChildOfJSXExpressionContainer(outermost) && isChildOfJSXElement(outermost.parent) && hasSiblings(outermost.parent) ); } function functionWasCalled(node) { let parent = node.parent; while (parent) { if (parent.type === 'CallExpression') { return true; } else if (parent.type === 'ReturnStatement') { return false; } parent = parent.parent; } return false; } // check if node is used as a condition (e.g. A && B && C where A and B are // conditions, or the test of a ternary) function isCondition(node) { let current = node; while (current.parent && current.parent.type === 'LogicalExpression') { if (Object.is(current.parent.left, current)) { return true; } current = current.parent; } if (current.parent && current.parent.type === 'ConditionalExpression') { return Object.is(current.parent.test, current); } return false; } function isBinaryExpression(node) { if (node.parent && node.parent.type === 'BinaryExpression') { return isCondition(node.parent); } return false; } function getCallExpression(node) { let parent = node.parent; while (parent) { if (parent.type === 'CallExpression') { return parent; } parent = parent.parent; } return null; } return { // conditionally rendered text nodes (string literals, or other literals // that are coerced to strings) Literal(node) { if ( node.value !== null && typeof node.value !== 'boolean' && !isWhitespace(node) && isProblematicConditional(node) ) { context.report({ node, messageId: 'conditional-text-node', }); } }, // conditionally rendered text nodes derived from template literal // expressions TemplateLiteral(node) { if (!isWhitespace(node) && isProblematicConditional(node)) { context.report({ node, messageId: 'conditional-text-node', }); } }, // static JSX text nodes preceded by a conditional expression JSXText(node) { if ( !isWhitespace(node) && hasSiblings(node) && conditionalSiblingsPrecedeNode(node) ) { context.report({ node, messageId: 'text-node-preceded-by-conditional', }); } }, // conditionally rendered text nodes generated from a function call CallExpression(node) { if (isCondition(node)) return; if (parserServices) { if ( isProblematicConditional(node) && returnsStringifiableValue(node) ) { context.report({ node, messageId: 'conditional-text-node', }); } if ( isChildOfJSXElement(node.parent) && hasSiblings(node.parent) && conditionalSiblingsPrecedeNode(node.parent) && returnsStringifiableValue(node) ) { context.report({ node, messageId: 'text-node-preceded-by-conditional', }); } return; } if ( node.callee.type === 'Identifier' && (node.callee.name === 'formatMessage' || node.callee.name === 't') && node.arguments.length > 0 && isProblematicConditional(node) ) { context.report({ node, messageId: 'conditional-text-node', }); } if ( node.callee.type === 'Identifier' && (node.callee.name === 'formatMessage' || node.callee.name === 't') && node.arguments.length > 0 && isChildOfJSXElement(node.parent) && hasSiblings(node.parent) && conditionalSiblingsPrecedeNode(node.parent) ) { context.report({ node, messageId: 'text-node-preceded-by-conditional', }); } }, // text nodes which are derived from object properties MemberExpression(node) { if (isCondition(node) || isBinaryExpression(node)) { return; } if (isProblematicConditional(node)) { context.report({ node, messageId: 'conditional-text-node', }); } }, // text nodes derived from optional chaining (e.g. some?.cool?.beans) ChainExpression(node) { if (isCondition(node) || isBinaryExpression(node)) { return; } if (isProblematicConditional(node)) { context.report({ node, messageId: 'conditional-text-node', }); } }, // conditionally rendered variables typed as string or number (e.g. // children) Identifier(node) { if (parserServices) { if (isCondition(node)) return; if ( isProblematicConditional(node) && returnsStringifiableValue(node) ) { context.report({ node, messageId: 'conditional-text-node', }); } if ( isChildOfJSXElement(node.parent) && hasSiblings(node.parent) && conditionalSiblingsPrecedeNode(node.parent) && returnsStringifiableValue(node) ) { context.report({ node, messageId: 'text-node-preceded-by-conditional', }); } // return early as `toString` / `toLocaleString` calls are reported by // `CallExpression` when type checking is available return; } if ( (node.name === 'toLocaleString' || node.name === 'toString') && functionWasCalled(node) ) { const callExpression = getCallExpression(node); if (callExpression && isProblematicConditional(callExpression)) { context.report({ node: callExpression, messageId: 'conditional-text-node', }); } } }, }; }, };