You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
414 lines
14 KiB
414 lines
14 KiB
/** |
|
* @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'); |
|
const report = require('../util/report'); |
|
|
|
// ------------------------------------------------------------------------------ |
|
// 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, propElementValues: OPTION_IGNORE }; |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Rule Definition |
|
// ------------------------------------------------------------------------------ |
|
|
|
const messages = { |
|
unnecessaryCurly: 'Curly braces are unnecessary here.', |
|
missingCurly: 'Need to wrap this literal in a JSX expression.', |
|
}; |
|
|
|
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', |
|
|
|
messages, |
|
|
|
schema: [ |
|
{ |
|
oneOf: [ |
|
{ |
|
type: 'object', |
|
properties: { |
|
props: { enum: OPTION_VALUES }, |
|
children: { enum: OPTION_VALUES }, |
|
propElementValues: { 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, propElementValues: OPTION_IGNORE } |
|
: 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 containsMultilineComment(value) { |
|
return /\/\*/.test(value); |
|
} |
|
|
|
function escapeDoubleQuotes(rawStringValue) { |
|
return rawStringValue.replace(/\\"/g, '"').replace(/"/g, '\\"'); |
|
} |
|
|
|
function escapeBackslashes(rawStringValue) { |
|
return rawStringValue.replace(/\\/g, '\\\\'); |
|
} |
|
|
|
function needToEscapeCharacterForJSX(raw, node) { |
|
return ( |
|
containsBackslash(raw) |
|
|| containsHTMLEntity(raw) |
|
|| (node.parent.type !== 'JSXAttribute' && 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) { |
|
report(context, messages.unnecessaryCurly, 'unnecessaryCurly', { |
|
node: JSXExpressionNode, |
|
fix(fixer) { |
|
const expression = JSXExpressionNode.expression; |
|
|
|
let textToReplace; |
|
if (jsxUtil.isJSX(expression)) { |
|
const sourceCode = context.getSourceCode(); |
|
textToReplace = sourceCode.getText(expression); |
|
} else { |
|
const expressionType = expression && expression.type; |
|
const parentType = JSXExpressionNode.parent.type; |
|
|
|
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) { |
|
report(context, messages.missingCurly, 'missingCurly', { |
|
node: literalNode, |
|
fix(fixer) { |
|
if (jsxUtil.isJSX(literalNode)) { |
|
return fixer.replaceText(literalNode, `{${context.getSourceCode().getText(literalNode)}}`); |
|
} |
|
|
|
// 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; |
|
|
|
const sourceCode = context.getSourceCode(); |
|
// Curly braces containing comments are necessary |
|
if (sourceCode.getCommentsInside && sourceCode.getCommentsInside(JSXExpressionNode).length > 0) { |
|
return; |
|
} |
|
|
|
if ( |
|
(expressionType === 'Literal' || expressionType === 'JSXText') |
|
&& typeof expression.value === 'string' |
|
&& ( |
|
(JSXExpressionNode.parent.type === 'JSXAttribute' && !isWhiteSpaceLiteral(expression)) |
|
|| !isLiteralWithTrailingWhiteSpaces(expression) |
|
) |
|
&& !containsMultilineComment(expression.value) |
|
&& !needToEscapeCharacterForJSX(expression.raw, JSXExpressionNode) && ( |
|
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, JSXExpressionNode) |
|
&& !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(node, config) { |
|
const parent = node.parent; |
|
// 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 (jsxUtil.isJSX(node)) { |
|
return config.propElementValues !== OPTION_IGNORE; |
|
} |
|
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 { |
|
'JSXAttribute > JSXExpressionContainer > JSXElement'(node) { |
|
if (userConfig.propElementValues === OPTION_NEVER) { |
|
reportUnnecessaryCurly(node.parent); |
|
} |
|
}, |
|
|
|
JSXExpressionContainer(node) { |
|
if (shouldCheckForUnnecessaryCurly(node, userConfig)) { |
|
lintUnnecessaryCurly(node); |
|
} |
|
}, |
|
|
|
'JSXAttribute > JSXElement, Literal, JSXText'(node) { |
|
if (shouldCheckForMissingCurly(node, userConfig)) { |
|
reportMissingCurly(node); |
|
} |
|
}, |
|
}; |
|
}, |
|
};
|
|
|