| /** |
| * @fileoverview Enforce curly braces or disallow unnecessary curly brace in JSX |
| * @author Jacky Ho |
| * @author Simon Lydell |
| */ |
| |
| 'use strict'; |
| |
| const arrayIncludes = require('array-includes'); |
| |
| const docsUrl = require('../util/docsUrl'); |
| const jsxUtil = require('../util/jsx'); |
| |
| // ------------------------------------------------------------------------------ |
| // Constants |
| // ------------------------------------------------------------------------------ |
| |
| const OPTION_ALWAYS = 'always'; |
| const OPTION_NEVER = 'never'; |
| const OPTION_IGNORE = 'ignore'; |
| |
| const OPTION_VALUES = [ |
| OPTION_ALWAYS, |
| OPTION_NEVER, |
| OPTION_IGNORE |
| ]; |
| const DEFAULT_CONFIG = {props: OPTION_NEVER, children: OPTION_NEVER}; |
| |
| // ------------------------------------------------------------------------------ |
| // Rule Definition |
| // ------------------------------------------------------------------------------ |
| |
| module.exports = { |
| meta: { |
| docs: { |
| description: |
| 'Disallow unnecessary JSX expressions when literals alone are sufficient ' + |
| 'or enfore JSX expressions on literals in JSX children or attributes', |
| category: 'Stylistic Issues', |
| recommended: false, |
| url: docsUrl('jsx-curly-brace-presence') |
| }, |
| fixable: 'code', |
| |
| schema: [ |
| { |
| oneOf: [ |
| { |
| type: 'object', |
| properties: { |
| props: {enum: OPTION_VALUES}, |
| children: {enum: OPTION_VALUES} |
| }, |
| additionalProperties: false |
| }, |
| { |
| enum: OPTION_VALUES |
| } |
| ] |
| } |
| ] |
| }, |
| |
| create(context) { |
| const HTML_ENTITY_REGEX = () => /&[A-Za-z\d#]+;/g; |
| const ruleOptions = context.options[0]; |
| const userConfig = typeof ruleOptions === 'string' ? |
| {props: ruleOptions, children: ruleOptions} : |
| Object.assign({}, DEFAULT_CONFIG, ruleOptions); |
| |
| function containsLineTerminators(rawStringValue) { |
| return /[\n\r\u2028\u2029]/.test(rawStringValue); |
| } |
| |
| function containsBackslash(rawStringValue) { |
| return arrayIncludes(rawStringValue, '\\'); |
| } |
| |
| function containsHTMLEntity(rawStringValue) { |
| return HTML_ENTITY_REGEX().test(rawStringValue); |
| } |
| |
| function containsOnlyHtmlEntities(rawStringValue) { |
| return rawStringValue.replace(HTML_ENTITY_REGEX(), '').trim() === ''; |
| } |
| |
| function containsDisallowedJSXTextChars(rawStringValue) { |
| return /[{<>}]/.test(rawStringValue); |
| } |
| |
| function containsQuoteCharacters(value) { |
| return /['"]/.test(value); |
| } |
| |
| function escapeDoubleQuotes(rawStringValue) { |
| return rawStringValue.replace(/\\"/g, '"').replace(/"/g, '\\"'); |
| } |
| |
| function escapeBackslashes(rawStringValue) { |
| return rawStringValue.replace(/\\/g, '\\\\'); |
| } |
| |
| function needToEscapeCharacterForJSX(raw) { |
| return ( |
| containsBackslash(raw) || |
| containsHTMLEntity(raw) || |
| containsDisallowedJSXTextChars(raw) |
| ); |
| } |
| |
| function containsWhitespaceExpression(child) { |
| if (child.type === 'JSXExpressionContainer') { |
| const value = child.expression.value; |
| return value ? jsxUtil.isWhiteSpaces(value) : false; |
| } |
| return false; |
| } |
| |
| function isLineBreak(text) { |
| return containsLineTerminators(text) && text.trim() === ''; |
| } |
| |
| function wrapNonHTMLEntities(text) { |
| const HTML_ENTITY = '<HTML_ENTITY>'; |
| const withCurlyBraces = text.split(HTML_ENTITY_REGEX()).map(word => ( |
| word === '' ? '' : `{${JSON.stringify(word)}}` |
| )).join(HTML_ENTITY); |
| |
| const htmlEntities = text.match(HTML_ENTITY_REGEX()); |
| return htmlEntities.reduce((acc, htmlEntitiy) => ( |
| acc.replace(HTML_ENTITY, htmlEntitiy) |
| ), withCurlyBraces); |
| } |
| |
| function wrapWithCurlyBraces(rawText) { |
| if (!containsLineTerminators(rawText)) { |
| return `{${JSON.stringify(rawText)}}`; |
| } |
| |
| return rawText.split('\n').map((line) => { |
| if (line.trim() === '') { |
| return line; |
| } |
| const firstCharIndex = line.search(/[^\s]/); |
| const leftWhitespace = line.slice(0, firstCharIndex); |
| const text = line.slice(firstCharIndex); |
| |
| if (containsHTMLEntity(line)) { |
| return `${leftWhitespace}${wrapNonHTMLEntities(text)}`; |
| } |
| return `${leftWhitespace}{${JSON.stringify(text)}}`; |
| }).join('\n'); |
| } |
| |
| /** |
| * Report and fix an unnecessary curly brace violation on a node |
| * @param {ASTNode} JSXExpressionNode - The AST node with an unnecessary JSX expression |
| */ |
| function reportUnnecessaryCurly(JSXExpressionNode) { |
| context.report({ |
| node: JSXExpressionNode, |
| message: 'Curly braces are unnecessary here.', |
| fix(fixer) { |
| const expression = JSXExpressionNode.expression; |
| const expressionType = expression.type; |
| const parentType = JSXExpressionNode.parent.type; |
| |
| let textToReplace; |
| if (parentType === 'JSXAttribute') { |
| textToReplace = `"${expressionType === 'TemplateLiteral' ? |
| expression.quasis[0].value.raw : |
| expression.raw.substring(1, expression.raw.length - 1) |
| }"`; |
| } else if (jsxUtil.isJSX(expression)) { |
| const sourceCode = context.getSourceCode(); |
| |
| textToReplace = sourceCode.getText(expression); |
| } else { |
| textToReplace = expressionType === 'TemplateLiteral' ? |
| expression.quasis[0].value.cooked : expression.value; |
| } |
| |
| return fixer.replaceText(JSXExpressionNode, textToReplace); |
| } |
| }); |
| } |
| |
| function reportMissingCurly(literalNode) { |
| context.report({ |
| node: literalNode, |
| message: 'Need to wrap this literal in a JSX expression.', |
| fix(fixer) { |
| // If a HTML entity name is found, bail out because it can be fixed |
| // by either using the real character or the unicode equivalent. |
| // If it contains any line terminator character, bail out as well. |
| if ( |
| containsOnlyHtmlEntities(literalNode.raw) || |
| (literalNode.parent.type === 'JSXAttribute' && containsLineTerminators(literalNode.raw)) || |
| isLineBreak(literalNode.raw) |
| ) { |
| return null; |
| } |
| |
| const expression = literalNode.parent.type === 'JSXAttribute' ? |
| `{"${escapeDoubleQuotes(escapeBackslashes( |
| literalNode.raw.substring(1, literalNode.raw.length - 1) |
| ))}"}` : |
| wrapWithCurlyBraces(literalNode.raw); |
| |
| return fixer.replaceText(literalNode, expression); |
| } |
| }); |
| } |
| |
| function isWhiteSpaceLiteral(node) { |
| return node.type && node.type === 'Literal' && node.value && jsxUtil.isWhiteSpaces(node.value); |
| } |
| |
| function isStringWithTrailingWhiteSpaces(value) { |
| return /^\s|\s$/.test(value); |
| } |
| |
| function isLiteralWithTrailingWhiteSpaces(node) { |
| return node.type && node.type === 'Literal' && node.value && isStringWithTrailingWhiteSpaces(node.value); |
| } |
| |
| // Bail out if there is any character that needs to be escaped in JSX |
| // because escaping decreases readiblity and the original code may be more |
| // readible anyway or intentional for other specific reasons |
| function lintUnnecessaryCurly(JSXExpressionNode) { |
| const expression = JSXExpressionNode.expression; |
| const expressionType = expression.type; |
| |
| if ( |
| (expressionType === 'Literal' || expressionType === 'JSXText') && |
| typeof expression.value === 'string' && |
| ( |
| (JSXExpressionNode.parent.type === 'JSXAttribute' && !isWhiteSpaceLiteral(expression)) || |
| !isLiteralWithTrailingWhiteSpaces(expression) |
| ) && |
| !needToEscapeCharacterForJSX(expression.raw) && ( |
| jsxUtil.isJSX(JSXExpressionNode.parent) || |
| !containsQuoteCharacters(expression.value) |
| ) |
| ) { |
| reportUnnecessaryCurly(JSXExpressionNode); |
| } else if ( |
| expressionType === 'TemplateLiteral' && |
| expression.expressions.length === 0 && |
| expression.quasis[0].value.raw.indexOf('\n') === -1 && |
| !isStringWithTrailingWhiteSpaces(expression.quasis[0].value.raw) && |
| !needToEscapeCharacterForJSX(expression.quasis[0].value.raw) && ( |
| jsxUtil.isJSX(JSXExpressionNode.parent) || |
| !containsQuoteCharacters(expression.quasis[0].value.cooked) |
| ) |
| ) { |
| reportUnnecessaryCurly(JSXExpressionNode); |
| } else if (jsxUtil.isJSX(expression)) { |
| reportUnnecessaryCurly(JSXExpressionNode); |
| } |
| } |
| |
| function areRuleConditionsSatisfied(parent, config, ruleCondition) { |
| return ( |
| parent.type === 'JSXAttribute' && |
| typeof config.props === 'string' && |
| config.props === ruleCondition |
| ) || ( |
| jsxUtil.isJSX(parent) && |
| typeof config.children === 'string' && |
| config.children === ruleCondition |
| ); |
| } |
| |
| function getAdjacentSiblings(node, children) { |
| for (let i = 1; i < children.length - 1; i++) { |
| const child = children[i]; |
| if (node === child) { |
| return [children[i - 1], children[i + 1]]; |
| } |
| } |
| if (node === children[0] && children[1]) { |
| return [children[1]]; |
| } |
| if (node === children[children.length - 1] && children[children.length - 2]) { |
| return [children[children.length - 2]]; |
| } |
| return []; |
| } |
| |
| function hasAdjacentJsxExpressionContainers(node, children) { |
| if (!children) { |
| return false; |
| } |
| const childrenExcludingWhitespaceLiteral = children.filter(child => !isWhiteSpaceLiteral(child)); |
| const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral); |
| |
| return adjSiblings.some(x => x.type && x.type === 'JSXExpressionContainer'); |
| } |
| function hasAdjacentJsx(node, children) { |
| if (!children) { |
| return false; |
| } |
| const childrenExcludingWhitespaceLiteral = children.filter(child => !isWhiteSpaceLiteral(child)); |
| const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral); |
| |
| return adjSiblings.some(x => x.type && arrayIncludes(['JSXExpressionContainer', 'JSXElement'], x.type)); |
| } |
| function shouldCheckForUnnecessaryCurly(parent, node, config) { |
| // Bail out if the parent is a JSXAttribute & its contents aren't |
| // StringLiteral or TemplateLiteral since e.g |
| // <App prop1={<CustomEl />} prop2={<CustomEl>...</CustomEl>} /> |
| |
| if ( |
| parent.type && parent.type === 'JSXAttribute' && |
| (node.expression && node.expression.type && |
| node.expression.type !== 'Literal' && |
| node.expression.type !== 'StringLiteral' && |
| node.expression.type !== 'TemplateLiteral') |
| ) { |
| return false; |
| } |
| |
| // If there are adjacent `JsxExpressionContainer` then there is no need, |
| // to check for unnecessary curly braces. |
| if (jsxUtil.isJSX(parent) && hasAdjacentJsxExpressionContainers(node, parent.children)) { |
| return false; |
| } |
| if (containsWhitespaceExpression(node) && hasAdjacentJsx(node, parent.children)) { |
| return false; |
| } |
| if ( |
| parent.children && |
| parent.children.length === 1 && |
| containsWhitespaceExpression(node) |
| ) { |
| return false; |
| } |
| |
| return areRuleConditionsSatisfied(parent, config, OPTION_NEVER); |
| } |
| |
| function shouldCheckForMissingCurly(node, config) { |
| if ( |
| isLineBreak(node.raw) || |
| containsOnlyHtmlEntities(node.raw) |
| ) { |
| return false; |
| } |
| const parent = node.parent; |
| if ( |
| parent.children && |
| parent.children.length === 1 && |
| containsWhitespaceExpression(parent.children[0]) |
| ) { |
| return false; |
| } |
| |
| return areRuleConditionsSatisfied(parent, config, OPTION_ALWAYS); |
| } |
| |
| // -------------------------------------------------------------------------- |
| // Public |
| // -------------------------------------------------------------------------- |
| |
| return { |
| JSXExpressionContainer: (node) => { |
| if (shouldCheckForUnnecessaryCurly(node.parent, node, userConfig)) { |
| lintUnnecessaryCurly(node); |
| } |
| }, |
| |
| 'Literal, JSXText': (node) => { |
| if (shouldCheckForMissingCurly(node, userConfig)) { |
| reportMissingCurly(node); |
| } |
| } |
| }; |
| } |
| }; |