jsx-no-literals.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. /**
  2. * @fileoverview Prevent using string literals in React component definition
  3. * @author Caleb Morris
  4. * @author David Buchan-Swanson
  5. */
  6. 'use strict';
  7. const iterFrom = require('es-iterator-helpers/Iterator.from');
  8. const map = require('es-iterator-helpers/Iterator.prototype.map');
  9. const some = require('es-iterator-helpers/Iterator.prototype.some');
  10. const flatMap = require('es-iterator-helpers/Iterator.prototype.flatMap');
  11. const fromEntries = require('object.fromentries');
  12. const entries = require('object.entries');
  13. const docsUrl = require('../util/docsUrl');
  14. const report = require('../util/report');
  15. const getText = require('../util/eslint').getText;
  16. /** @typedef {import('eslint').Rule.RuleModule} RuleModule */
  17. /** @typedef {import('../../types/rules/jsx-no-literals').Config} Config */
  18. /** @typedef {import('../../types/rules/jsx-no-literals').RawConfig} RawConfig */
  19. /** @typedef {import('../../types/rules/jsx-no-literals').ResolvedConfig} ResolvedConfig */
  20. /** @typedef {import('../../types/rules/jsx-no-literals').OverrideConfig} OverrideConfig */
  21. /** @typedef {import('../../types/rules/jsx-no-literals').ElementConfig} ElementConfig */
  22. // ------------------------------------------------------------------------------
  23. // Rule Definition
  24. // ------------------------------------------------------------------------------
  25. /**
  26. * @param {unknown} value
  27. * @returns {string | unknown}
  28. */
  29. function trimIfString(value) {
  30. return typeof value === 'string' ? value.trim() : value;
  31. }
  32. const reOverridableElement = /^[A-Z][\w.]*$/;
  33. const reIsWhiteSpace = /^[\s]+$/;
  34. const jsxElementTypes = new Set(['JSXElement', 'JSXFragment']);
  35. const standardJSXNodeParentTypes = new Set(['JSXAttribute', 'JSXElement', 'JSXExpressionContainer', 'JSXFragment']);
  36. const messages = {
  37. invalidPropValue: 'Invalid prop value: "{{text}}"',
  38. invalidPropValueInElement: 'Invalid prop value: "{{text}}" in {{element}}',
  39. noStringsInAttributes: 'Strings not allowed in attributes: "{{text}}"',
  40. noStringsInAttributesInElement: 'Strings not allowed in attributes: "{{text}}" in {{element}}',
  41. noStringsInJSX: 'Strings not allowed in JSX files: "{{text}}"',
  42. noStringsInJSXInElement: 'Strings not allowed in JSX files: "{{text}}" in {{element}}',
  43. literalNotInJSXExpression: 'Missing JSX expression container around literal string: "{{text}}"',
  44. literalNotInJSXExpressionInElement: 'Missing JSX expression container around literal string: "{{text}}" in {{element}}',
  45. };
  46. /** @type {Exclude<RuleModule['meta']['schema'], unknown[] | false>['properties']} */
  47. const commonPropertiesSchema = {
  48. noStrings: {
  49. type: 'boolean',
  50. },
  51. allowedStrings: {
  52. type: 'array',
  53. uniqueItems: true,
  54. items: {
  55. type: 'string',
  56. },
  57. },
  58. ignoreProps: {
  59. type: 'boolean',
  60. },
  61. noAttributeStrings: {
  62. type: 'boolean',
  63. },
  64. };
  65. // eslint-disable-next-line valid-jsdoc
  66. /**
  67. * Normalizes the element portion of the config
  68. * @param {RawConfig} config
  69. * @returns {ElementConfig}
  70. */
  71. function normalizeElementConfig(config) {
  72. return {
  73. type: 'element',
  74. noStrings: !!config.noStrings,
  75. allowedStrings: config.allowedStrings
  76. ? new Set(map(iterFrom(config.allowedStrings), trimIfString))
  77. : new Set(),
  78. ignoreProps: !!config.ignoreProps,
  79. noAttributeStrings: !!config.noAttributeStrings,
  80. };
  81. }
  82. // eslint-disable-next-line valid-jsdoc
  83. /**
  84. * Normalizes the config and applies default values to all config options
  85. * @param {RawConfig} config
  86. * @returns {Config}
  87. */
  88. function normalizeConfig(config) {
  89. /** @type {Config} */
  90. const normalizedConfig = Object.assign(normalizeElementConfig(config), {
  91. elementOverrides: {},
  92. });
  93. if (config.elementOverrides) {
  94. normalizedConfig.elementOverrides = fromEntries(
  95. flatMap(
  96. iterFrom(entries(config.elementOverrides)),
  97. (entry) => {
  98. const elementName = entry[0];
  99. const rawElementConfig = entry[1];
  100. if (!reOverridableElement.test(elementName)) {
  101. return [];
  102. }
  103. return [[
  104. elementName,
  105. Object.assign(normalizeElementConfig(rawElementConfig), {
  106. type: 'override',
  107. name: elementName,
  108. allowElement: !!rawElementConfig.allowElement,
  109. applyToNestedElements: typeof rawElementConfig.applyToNestedElements === 'undefined' || !!rawElementConfig.applyToNestedElements,
  110. }),
  111. ]];
  112. }
  113. )
  114. );
  115. }
  116. return normalizedConfig;
  117. }
  118. const elementOverrides = {
  119. type: 'object',
  120. patternProperties: {
  121. [reOverridableElement.source]: {
  122. type: 'object',
  123. properties: Object.assign(
  124. { applyToNestedElements: { type: 'boolean' } },
  125. commonPropertiesSchema
  126. ),
  127. },
  128. },
  129. };
  130. /** @type {RuleModule} */
  131. module.exports = {
  132. meta: /** @type {RuleModule['meta']} */ ({
  133. docs: {
  134. description: 'Disallow usage of string literals in JSX',
  135. category: 'Stylistic Issues',
  136. recommended: false,
  137. url: docsUrl('jsx-no-literals'),
  138. },
  139. messages,
  140. schema: [{
  141. type: 'object',
  142. properties: Object.assign(
  143. { elementOverrides },
  144. commonPropertiesSchema
  145. ),
  146. additionalProperties: false,
  147. }],
  148. }),
  149. create(context) {
  150. /** @type {RawConfig} */
  151. const rawConfig = (context.options.length && context.options[0]) || {};
  152. const config = normalizeConfig(rawConfig);
  153. const hasElementOverrides = Object.keys(config.elementOverrides).length > 0;
  154. /** @type {Map<string, string>} */
  155. const renamedImportMap = new Map();
  156. /**
  157. * Determines if the given expression is a require statement. Supports
  158. * nested MemberExpresions. ie `require('foo').nested.property`
  159. * @param {ASTNode} node
  160. * @returns {boolean}
  161. */
  162. function isRequireStatement(node) {
  163. if (node.type === 'CallExpression') {
  164. if (node.callee.type === 'Identifier') {
  165. return node.callee.name === 'require';
  166. }
  167. }
  168. if (node.type === 'MemberExpression') {
  169. return isRequireStatement(node.object);
  170. }
  171. return false;
  172. }
  173. /** @typedef {{ name: string, compoundName?: string }} ElementNameFragment */
  174. /**
  175. * Gets the name of the given JSX element. Supports nested
  176. * JSXMemeberExpressions. ie `<Namesapce.Component.SubComponent />`
  177. * @param {ASTNode} node
  178. * @returns {ElementNameFragment | undefined}
  179. */
  180. function getJSXElementName(node) {
  181. if (node.openingElement.name.type === 'JSXIdentifier') {
  182. const name = node.openingElement.name.name;
  183. return {
  184. name: renamedImportMap.get(name) || name,
  185. compoundName: undefined,
  186. };
  187. }
  188. /** @type {string[]} */
  189. const nameFragments = [];
  190. if (node.openingElement.name.type === 'JSXMemberExpression') {
  191. /** @type {ASTNode} */
  192. let current = node.openingElement.name;
  193. while (current.type === 'JSXMemberExpression') {
  194. if (current.property.type === 'JSXIdentifier') {
  195. nameFragments.unshift(current.property.name);
  196. }
  197. current = current.object;
  198. }
  199. if (current.type === 'JSXIdentifier') {
  200. nameFragments.unshift(current.name);
  201. const rootFragment = nameFragments[0];
  202. if (rootFragment) {
  203. const rootFragmentRenamed = renamedImportMap.get(rootFragment);
  204. if (rootFragmentRenamed) {
  205. nameFragments[0] = rootFragmentRenamed;
  206. }
  207. }
  208. const nameFragment = nameFragments[nameFragments.length - 1];
  209. if (nameFragment) {
  210. return {
  211. name: nameFragment,
  212. compoundName: nameFragments.join('.'),
  213. };
  214. }
  215. }
  216. }
  217. }
  218. /**
  219. * Gets all JSXElement ancestor nodes for the given node
  220. * @param {ASTNode} node
  221. * @returns {ASTNode[]}
  222. */
  223. function getJSXElementAncestors(node) {
  224. /** @type {ASTNode[]} */
  225. const ancestors = [];
  226. let current = node;
  227. while (current) {
  228. if (current.type === 'JSXElement') {
  229. ancestors.push(current);
  230. }
  231. current = current.parent;
  232. }
  233. return ancestors;
  234. }
  235. /**
  236. * @param {ASTNode} node
  237. * @returns {ASTNode}
  238. */
  239. function getParentIgnoringBinaryExpressions(node) {
  240. let current = node;
  241. while (current.parent.type === 'BinaryExpression') {
  242. current = current.parent;
  243. }
  244. return current.parent;
  245. }
  246. /**
  247. * @param {ASTNode} node
  248. * @returns {{ parent: ASTNode, grandParent: ASTNode }}
  249. */
  250. function getParentAndGrandParent(node) {
  251. const parent = getParentIgnoringBinaryExpressions(node);
  252. return {
  253. parent,
  254. grandParent: parent.parent,
  255. };
  256. }
  257. /**
  258. * @param {ASTNode} node
  259. * @returns {boolean}
  260. */
  261. function hasJSXElementParentOrGrandParent(node) {
  262. const ancestors = getParentAndGrandParent(node);
  263. return some(iterFrom([ancestors.parent, ancestors.grandParent]), (parent) => jsxElementTypes.has(parent.type));
  264. }
  265. // eslint-disable-next-line valid-jsdoc
  266. /**
  267. * Determines whether a given node's value and its immediate parent are
  268. * viable text nodes that can/should be reported on
  269. * @param {ASTNode} node
  270. * @param {ResolvedConfig} resolvedConfig
  271. * @returns {boolean}
  272. */
  273. function isViableTextNode(node, resolvedConfig) {
  274. const textValues = iterFrom([trimIfString(node.raw), trimIfString(node.value)]);
  275. if (some(textValues, (value) => resolvedConfig.allowedStrings.has(value))) {
  276. return false;
  277. }
  278. const parent = getParentIgnoringBinaryExpressions(node);
  279. let isStandardJSXNode = false;
  280. if (typeof node.value === 'string' && !reIsWhiteSpace.test(node.value) && standardJSXNodeParentTypes.has(parent.type)) {
  281. if (resolvedConfig.noAttributeStrings) {
  282. isStandardJSXNode = parent.type === 'JSXAttribute' || parent.type === 'JSXElement';
  283. } else {
  284. isStandardJSXNode = parent.type !== 'JSXAttribute';
  285. }
  286. }
  287. if (resolvedConfig.noStrings) {
  288. return isStandardJSXNode;
  289. }
  290. return isStandardJSXNode && parent.type !== 'JSXExpressionContainer';
  291. }
  292. // eslint-disable-next-line valid-jsdoc
  293. /**
  294. * Gets an override config for a given node. For any given node, we also
  295. * need to traverse the ancestor tree to determine if an ancestor's config
  296. * will also apply to the current node.
  297. * @param {ASTNode} node
  298. * @returns {OverrideConfig | undefined}
  299. */
  300. function getOverrideConfig(node) {
  301. if (!hasElementOverrides) {
  302. return;
  303. }
  304. const allAncestorElements = getJSXElementAncestors(node);
  305. if (!allAncestorElements.length) {
  306. return;
  307. }
  308. for (const ancestorElement of allAncestorElements) {
  309. const isClosestJSXAncestor = ancestorElement === allAncestorElements[0];
  310. const ancestor = getJSXElementName(ancestorElement);
  311. if (ancestor) {
  312. if (ancestor.name) {
  313. const ancestorElements = config.elementOverrides[ancestor.name];
  314. const ancestorConfig = ancestor.compoundName
  315. ? config.elementOverrides[ancestor.compoundName] || ancestorElements
  316. : ancestorElements;
  317. if (ancestorConfig) {
  318. if (isClosestJSXAncestor || ancestorConfig.applyToNestedElements) {
  319. return ancestorConfig;
  320. }
  321. }
  322. }
  323. }
  324. }
  325. }
  326. // eslint-disable-next-line valid-jsdoc
  327. /**
  328. * @param {ResolvedConfig} resolvedConfig
  329. * @returns {boolean}
  330. */
  331. function shouldAllowElement(resolvedConfig) {
  332. return resolvedConfig.type === 'override' && 'allowElement' in resolvedConfig && !!resolvedConfig.allowElement;
  333. }
  334. // eslint-disable-next-line valid-jsdoc
  335. /**
  336. * @param {boolean} ancestorIsJSXElement
  337. * @param {ResolvedConfig} resolvedConfig
  338. * @returns {string}
  339. */
  340. function defaultMessageId(ancestorIsJSXElement, resolvedConfig) {
  341. if (resolvedConfig.noAttributeStrings && !ancestorIsJSXElement) {
  342. return resolvedConfig.type === 'override' ? 'noStringsInAttributesInElement' : 'noStringsInAttributes';
  343. }
  344. if (resolvedConfig.noStrings) {
  345. return resolvedConfig.type === 'override' ? 'noStringsInJSXInElement' : 'noStringsInJSX';
  346. }
  347. return resolvedConfig.type === 'override' ? 'literalNotInJSXExpressionInElement' : 'literalNotInJSXExpression';
  348. }
  349. // eslint-disable-next-line valid-jsdoc
  350. /**
  351. * @param {ASTNode} node
  352. * @param {string} messageId
  353. * @param {ResolvedConfig} resolvedConfig
  354. */
  355. function reportLiteralNode(node, messageId, resolvedConfig) {
  356. report(context, messages[messageId], messageId, {
  357. node,
  358. data: {
  359. text: getText(context, node).trim(),
  360. element: resolvedConfig.type === 'override' && 'name' in resolvedConfig ? resolvedConfig.name : undefined,
  361. },
  362. });
  363. }
  364. // --------------------------------------------------------------------------
  365. // Public
  366. // --------------------------------------------------------------------------
  367. return Object.assign(hasElementOverrides ? {
  368. // Get renamed import local names mapped to their imported name
  369. ImportDeclaration(node) {
  370. node.specifiers
  371. .filter((s) => s.type === 'ImportSpecifier')
  372. .forEach((specifier) => {
  373. renamedImportMap.set(
  374. (specifier.local || specifier.imported).name,
  375. specifier.imported.name
  376. );
  377. });
  378. },
  379. // Get renamed destructured local names mapped to their imported name
  380. VariableDeclaration(node) {
  381. node.declarations
  382. .filter((d) => (
  383. d.type === 'VariableDeclarator'
  384. && isRequireStatement(d.init)
  385. && d.id.type === 'ObjectPattern'
  386. ))
  387. .forEach((declaration) => {
  388. declaration.id.properties
  389. .filter((property) => (
  390. property.type === 'Property'
  391. && property.key.type === 'Identifier'
  392. && property.value.type === 'Identifier'
  393. ))
  394. .forEach((property) => {
  395. renamedImportMap.set(property.value.name, property.key.name);
  396. });
  397. });
  398. },
  399. } : false, {
  400. Literal(node) {
  401. const resolvedConfig = getOverrideConfig(node) || config;
  402. const hasJSXParentOrGrandParent = hasJSXElementParentOrGrandParent(node);
  403. if (hasJSXParentOrGrandParent && shouldAllowElement(resolvedConfig)) {
  404. return;
  405. }
  406. if (isViableTextNode(node, resolvedConfig)) {
  407. if (hasJSXParentOrGrandParent || !config.ignoreProps) {
  408. reportLiteralNode(node, defaultMessageId(hasJSXParentOrGrandParent, resolvedConfig), resolvedConfig);
  409. }
  410. }
  411. },
  412. JSXAttribute(node) {
  413. const isLiteralString = node.value && node.value.type === 'Literal'
  414. && typeof node.value.value === 'string';
  415. const isStringLiteral = node.value && node.value.type === 'StringLiteral';
  416. if (isLiteralString || isStringLiteral) {
  417. const resolvedConfig = getOverrideConfig(node) || config;
  418. if (
  419. resolvedConfig.noStrings
  420. && !resolvedConfig.ignoreProps
  421. && !resolvedConfig.allowedStrings.has(node.value.value)
  422. ) {
  423. const messageId = resolvedConfig.type === 'override' ? 'invalidPropValueInElement' : 'invalidPropValue';
  424. reportLiteralNode(node, messageId, resolvedConfig);
  425. }
  426. }
  427. },
  428. JSXText(node) {
  429. const resolvedConfig = getOverrideConfig(node) || config;
  430. if (shouldAllowElement(resolvedConfig)) {
  431. return;
  432. }
  433. if (isViableTextNode(node, resolvedConfig)) {
  434. const hasJSXParendOrGrantParent = hasJSXElementParentOrGrandParent(node);
  435. reportLiteralNode(node, defaultMessageId(hasJSXParendOrGrantParent, resolvedConfig), resolvedConfig);
  436. }
  437. },
  438. TemplateLiteral(node) {
  439. const ancestors = getParentAndGrandParent(node);
  440. const isParentJSXExpressionCont = ancestors.parent.type === 'JSXExpressionContainer';
  441. const isParentJSXElement = ancestors.grandParent.type === 'JSXElement';
  442. if (isParentJSXExpressionCont) {
  443. const resolvedConfig = getOverrideConfig(node) || config;
  444. if (
  445. resolvedConfig.noStrings
  446. && (isParentJSXElement || !resolvedConfig.ignoreProps)
  447. ) {
  448. reportLiteralNode(node, defaultMessageId(isParentJSXElement, resolvedConfig), resolvedConfig);
  449. }
  450. }
  451. },
  452. });
  453. },
  454. };