UNPKG

12.8 kBJavaScriptView Raw
1/**
2 * @fileoverview Enforce curly braces or disallow unnecessary curly brace in JSX
3 * @author Jacky Ho
4 * @author Simon Lydell
5 */
6
7'use strict';
8
9const arrayIncludes = require('array-includes');
10
11const docsUrl = require('../util/docsUrl');
12const jsxUtil = require('../util/jsx');
13
14// ------------------------------------------------------------------------------
15// Constants
16// ------------------------------------------------------------------------------
17
18const OPTION_ALWAYS = 'always';
19const OPTION_NEVER = 'never';
20const OPTION_IGNORE = 'ignore';
21
22const OPTION_VALUES = [
23 OPTION_ALWAYS,
24 OPTION_NEVER,
25 OPTION_IGNORE
26];
27const DEFAULT_CONFIG = {props: OPTION_NEVER, children: OPTION_NEVER};
28
29// ------------------------------------------------------------------------------
30// Rule Definition
31// ------------------------------------------------------------------------------
32
33module.exports = {
34 meta: {
35 docs: {
36 description:
37 'Disallow unnecessary JSX expressions when literals alone are sufficient '
38 + 'or enfore JSX expressions on literals in JSX children or attributes',
39 category: 'Stylistic Issues',
40 recommended: false,
41 url: docsUrl('jsx-curly-brace-presence')
42 },
43 fixable: 'code',
44
45 schema: [
46 {
47 oneOf: [
48 {
49 type: 'object',
50 properties: {
51 props: {enum: OPTION_VALUES},
52 children: {enum: OPTION_VALUES}
53 },
54 additionalProperties: false
55 },
56 {
57 enum: OPTION_VALUES
58 }
59 ]
60 }
61 ]
62 },
63
64 create(context) {
65 const HTML_ENTITY_REGEX = () => /&[A-Za-z\d#]+;/g;
66 const ruleOptions = context.options[0];
67 const userConfig = typeof ruleOptions === 'string'
68 ? {props: ruleOptions, children: ruleOptions}
69 : Object.assign({}, DEFAULT_CONFIG, ruleOptions);
70
71 function containsLineTerminators(rawStringValue) {
72 return /[\n\r\u2028\u2029]/.test(rawStringValue);
73 }
74
75 function containsBackslash(rawStringValue) {
76 return arrayIncludes(rawStringValue, '\\');
77 }
78
79 function containsHTMLEntity(rawStringValue) {
80 return HTML_ENTITY_REGEX().test(rawStringValue);
81 }
82
83 function containsOnlyHtmlEntities(rawStringValue) {
84 return rawStringValue.replace(HTML_ENTITY_REGEX(), '').trim() === '';
85 }
86
87 function containsDisallowedJSXTextChars(rawStringValue) {
88 return /[{<>}]/.test(rawStringValue);
89 }
90
91 function containsQuoteCharacters(value) {
92 return /['"]/.test(value);
93 }
94
95 function containsMultilineComment(value) {
96 return /\/\*/.test(value);
97 }
98
99 function escapeDoubleQuotes(rawStringValue) {
100 return rawStringValue.replace(/\\"/g, '"').replace(/"/g, '\\"');
101 }
102
103 function escapeBackslashes(rawStringValue) {
104 return rawStringValue.replace(/\\/g, '\\\\');
105 }
106
107 function needToEscapeCharacterForJSX(raw, node) {
108 return (
109 containsBackslash(raw)
110 || containsHTMLEntity(raw)
111 || (node.parent.type !== 'JSXAttribute' && containsDisallowedJSXTextChars(raw))
112 );
113 }
114
115 function containsWhitespaceExpression(child) {
116 if (child.type === 'JSXExpressionContainer') {
117 const value = child.expression.value;
118 return value ? jsxUtil.isWhiteSpaces(value) : false;
119 }
120 return false;
121 }
122
123 function isLineBreak(text) {
124 return containsLineTerminators(text) && text.trim() === '';
125 }
126
127 function wrapNonHTMLEntities(text) {
128 const HTML_ENTITY = '<HTML_ENTITY>';
129 const withCurlyBraces = text.split(HTML_ENTITY_REGEX()).map((word) => (
130 word === '' ? '' : `{${JSON.stringify(word)}}`
131 )).join(HTML_ENTITY);
132
133 const htmlEntities = text.match(HTML_ENTITY_REGEX());
134 return htmlEntities.reduce((acc, htmlEntitiy) => (
135 acc.replace(HTML_ENTITY, htmlEntitiy)
136 ), withCurlyBraces);
137 }
138
139 function wrapWithCurlyBraces(rawText) {
140 if (!containsLineTerminators(rawText)) {
141 return `{${JSON.stringify(rawText)}}`;
142 }
143
144 return rawText.split('\n').map((line) => {
145 if (line.trim() === '') {
146 return line;
147 }
148 const firstCharIndex = line.search(/[^\s]/);
149 const leftWhitespace = line.slice(0, firstCharIndex);
150 const text = line.slice(firstCharIndex);
151
152 if (containsHTMLEntity(line)) {
153 return `${leftWhitespace}${wrapNonHTMLEntities(text)}`;
154 }
155 return `${leftWhitespace}{${JSON.stringify(text)}}`;
156 }).join('\n');
157 }
158
159 /**
160 * Report and fix an unnecessary curly brace violation on a node
161 * @param {ASTNode} JSXExpressionNode - The AST node with an unnecessary JSX expression
162 */
163 function reportUnnecessaryCurly(JSXExpressionNode) {
164 context.report({
165 node: JSXExpressionNode,
166 message: 'Curly braces are unnecessary here.',
167 fix(fixer) {
168 const expression = JSXExpressionNode.expression;
169 const expressionType = expression.type;
170 const parentType = JSXExpressionNode.parent.type;
171
172 let textToReplace;
173 if (parentType === 'JSXAttribute') {
174 textToReplace = `"${expressionType === 'TemplateLiteral'
175 ? expression.quasis[0].value.raw
176 : expression.raw.substring(1, expression.raw.length - 1)
177 }"`;
178 } else if (jsxUtil.isJSX(expression)) {
179 const sourceCode = context.getSourceCode();
180
181 textToReplace = sourceCode.getText(expression);
182 } else {
183 textToReplace = expressionType === 'TemplateLiteral'
184 ? expression.quasis[0].value.cooked : expression.value;
185 }
186
187 return fixer.replaceText(JSXExpressionNode, textToReplace);
188 }
189 });
190 }
191
192 function reportMissingCurly(literalNode) {
193 context.report({
194 node: literalNode,
195 message: 'Need to wrap this literal in a JSX expression.',
196 fix(fixer) {
197 // If a HTML entity name is found, bail out because it can be fixed
198 // by either using the real character or the unicode equivalent.
199 // If it contains any line terminator character, bail out as well.
200 if (
201 containsOnlyHtmlEntities(literalNode.raw)
202 || (literalNode.parent.type === 'JSXAttribute' && containsLineTerminators(literalNode.raw))
203 || isLineBreak(literalNode.raw)
204 ) {
205 return null;
206 }
207
208 const expression = literalNode.parent.type === 'JSXAttribute'
209 ? `{"${escapeDoubleQuotes(escapeBackslashes(
210 literalNode.raw.substring(1, literalNode.raw.length - 1)
211 ))}"}`
212 : wrapWithCurlyBraces(literalNode.raw);
213
214 return fixer.replaceText(literalNode, expression);
215 }
216 });
217 }
218
219 function isWhiteSpaceLiteral(node) {
220 return node.type && node.type === 'Literal' && node.value && jsxUtil.isWhiteSpaces(node.value);
221 }
222
223 function isStringWithTrailingWhiteSpaces(value) {
224 return /^\s|\s$/.test(value);
225 }
226
227 function isLiteralWithTrailingWhiteSpaces(node) {
228 return node.type && node.type === 'Literal' && node.value && isStringWithTrailingWhiteSpaces(node.value);
229 }
230
231 // Bail out if there is any character that needs to be escaped in JSX
232 // because escaping decreases readiblity and the original code may be more
233 // readible anyway or intentional for other specific reasons
234 function lintUnnecessaryCurly(JSXExpressionNode) {
235 const expression = JSXExpressionNode.expression;
236 const expressionType = expression.type;
237
238 if (
239 (expressionType === 'Literal' || expressionType === 'JSXText')
240 && typeof expression.value === 'string'
241 && (
242 (JSXExpressionNode.parent.type === 'JSXAttribute' && !isWhiteSpaceLiteral(expression))
243 || !isLiteralWithTrailingWhiteSpaces(expression)
244 )
245 && !containsMultilineComment(expression.value)
246 && !needToEscapeCharacterForJSX(expression.raw, JSXExpressionNode) && (
247 jsxUtil.isJSX(JSXExpressionNode.parent)
248 || !containsQuoteCharacters(expression.value)
249 )
250 ) {
251 reportUnnecessaryCurly(JSXExpressionNode);
252 } else if (
253 expressionType === 'TemplateLiteral'
254 && expression.expressions.length === 0
255 && expression.quasis[0].value.raw.indexOf('\n') === -1
256 && !isStringWithTrailingWhiteSpaces(expression.quasis[0].value.raw)
257 && !needToEscapeCharacterForJSX(expression.quasis[0].value.raw, JSXExpressionNode) && (
258 jsxUtil.isJSX(JSXExpressionNode.parent)
259 || !containsQuoteCharacters(expression.quasis[0].value.cooked)
260 )
261 ) {
262 reportUnnecessaryCurly(JSXExpressionNode);
263 } else if (jsxUtil.isJSX(expression)) {
264 reportUnnecessaryCurly(JSXExpressionNode);
265 }
266 }
267
268 function areRuleConditionsSatisfied(parent, config, ruleCondition) {
269 return (
270 parent.type === 'JSXAttribute'
271 && typeof config.props === 'string'
272 && config.props === ruleCondition
273 ) || (
274 jsxUtil.isJSX(parent)
275 && typeof config.children === 'string'
276 && config.children === ruleCondition
277 );
278 }
279
280 function getAdjacentSiblings(node, children) {
281 for (let i = 1; i < children.length - 1; i++) {
282 const child = children[i];
283 if (node === child) {
284 return [children[i - 1], children[i + 1]];
285 }
286 }
287 if (node === children[0] && children[1]) {
288 return [children[1]];
289 }
290 if (node === children[children.length - 1] && children[children.length - 2]) {
291 return [children[children.length - 2]];
292 }
293 return [];
294 }
295
296 function hasAdjacentJsxExpressionContainers(node, children) {
297 if (!children) {
298 return false;
299 }
300 const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
301 const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);
302
303 return adjSiblings.some((x) => x.type && x.type === 'JSXExpressionContainer');
304 }
305 function hasAdjacentJsx(node, children) {
306 if (!children) {
307 return false;
308 }
309 const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
310 const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);
311
312 return adjSiblings.some((x) => x.type && arrayIncludes(['JSXExpressionContainer', 'JSXElement'], x.type));
313 }
314 function shouldCheckForUnnecessaryCurly(parent, node, config) {
315 // Bail out if the parent is a JSXAttribute & its contents aren't
316 // StringLiteral or TemplateLiteral since e.g
317 // <App prop1={<CustomEl />} prop2={<CustomEl>...</CustomEl>} />
318
319 if (
320 parent.type && parent.type === 'JSXAttribute'
321 && (node.expression && node.expression.type
322 && node.expression.type !== 'Literal'
323 && node.expression.type !== 'StringLiteral'
324 && node.expression.type !== 'TemplateLiteral')
325 ) {
326 return false;
327 }
328
329 // If there are adjacent `JsxExpressionContainer` then there is no need,
330 // to check for unnecessary curly braces.
331 if (jsxUtil.isJSX(parent) && hasAdjacentJsxExpressionContainers(node, parent.children)) {
332 return false;
333 }
334 if (containsWhitespaceExpression(node) && hasAdjacentJsx(node, parent.children)) {
335 return false;
336 }
337 if (
338 parent.children
339 && parent.children.length === 1
340 && containsWhitespaceExpression(node)
341 ) {
342 return false;
343 }
344
345 return areRuleConditionsSatisfied(parent, config, OPTION_NEVER);
346 }
347
348 function shouldCheckForMissingCurly(node, config) {
349 if (
350 isLineBreak(node.raw)
351 || containsOnlyHtmlEntities(node.raw)
352 ) {
353 return false;
354 }
355 const parent = node.parent;
356 if (
357 parent.children
358 && parent.children.length === 1
359 && containsWhitespaceExpression(parent.children[0])
360 ) {
361 return false;
362 }
363
364 return areRuleConditionsSatisfied(parent, config, OPTION_ALWAYS);
365 }
366
367 // --------------------------------------------------------------------------
368 // Public
369 // --------------------------------------------------------------------------
370
371 return {
372 JSXExpressionContainer: (node) => {
373 if (shouldCheckForUnnecessaryCurly(node.parent, node, userConfig)) {
374 lintUnnecessaryCurly(node);
375 }
376 },
377
378 'Literal, JSXText': (node) => {
379 if (shouldCheckForMissingCurly(node, userConfig)) {
380 reportMissingCurly(node);
381 }
382 }
383 };
384 }
385};