no-invalid-html-attribute.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. /**
  2. * @fileoverview Check if tag attributes to have non-valid value
  3. * @author Sebastian Malton
  4. */
  5. 'use strict';
  6. const matchAll = require('string.prototype.matchall');
  7. const docsUrl = require('../util/docsUrl');
  8. const report = require('../util/report');
  9. const getMessageData = require('../util/message');
  10. // ------------------------------------------------------------------------------
  11. // Rule Definition
  12. // ------------------------------------------------------------------------------
  13. const rel = new Map([
  14. ['alternate', new Set(['link', 'area', 'a'])],
  15. ['apple-touch-icon', new Set(['link'])],
  16. ['author', new Set(['link', 'area', 'a'])],
  17. ['bookmark', new Set(['area', 'a'])],
  18. ['canonical', new Set(['link'])],
  19. ['dns-prefetch', new Set(['link'])],
  20. ['external', new Set(['area', 'a', 'form'])],
  21. ['help', new Set(['link', 'area', 'a', 'form'])],
  22. ['icon', new Set(['link'])],
  23. ['license', new Set(['link', 'area', 'a', 'form'])],
  24. ['manifest', new Set(['link'])],
  25. ['mask-icon', new Set(['link'])],
  26. ['modulepreload', new Set(['link'])],
  27. ['next', new Set(['link', 'area', 'a', 'form'])],
  28. ['nofollow', new Set(['area', 'a', 'form'])],
  29. ['noopener', new Set(['area', 'a', 'form'])],
  30. ['noreferrer', new Set(['area', 'a', 'form'])],
  31. ['opener', new Set(['area', 'a', 'form'])],
  32. ['pingback', new Set(['link'])],
  33. ['preconnect', new Set(['link'])],
  34. ['prefetch', new Set(['link'])],
  35. ['preload', new Set(['link'])],
  36. ['prerender', new Set(['link'])],
  37. ['prev', new Set(['link', 'area', 'a', 'form'])],
  38. ['search', new Set(['link', 'area', 'a', 'form'])],
  39. ['shortcut', new Set(['link'])], // generally allowed but needs pair with "icon"
  40. ['shortcut\u0020icon', new Set(['link'])],
  41. ['stylesheet', new Set(['link'])],
  42. ['tag', new Set(['area', 'a'])],
  43. ]);
  44. const pairs = new Map([
  45. ['shortcut', new Set(['icon'])],
  46. ]);
  47. /**
  48. * Map between attributes and a mapping between valid values and a set of tags they are valid on
  49. * @type {Map<string, Map<string, Set<string>>>}
  50. */
  51. const VALID_VALUES = new Map([
  52. ['rel', rel],
  53. ]);
  54. /**
  55. * Map between attributes and a mapping between pair-values and a set of values they are valid with
  56. * @type {Map<string, Map<string, Set<string>>>}
  57. */
  58. const VALID_PAIR_VALUES = new Map([
  59. ['rel', pairs],
  60. ]);
  61. /**
  62. * The set of all possible HTML elements. Used for skipping custom types
  63. * @type {Set<string>}
  64. */
  65. const HTML_ELEMENTS = new Set([
  66. 'a',
  67. 'abbr',
  68. 'acronym',
  69. 'address',
  70. 'applet',
  71. 'area',
  72. 'article',
  73. 'aside',
  74. 'audio',
  75. 'b',
  76. 'base',
  77. 'basefont',
  78. 'bdi',
  79. 'bdo',
  80. 'bgsound',
  81. 'big',
  82. 'blink',
  83. 'blockquote',
  84. 'body',
  85. 'br',
  86. 'button',
  87. 'canvas',
  88. 'caption',
  89. 'center',
  90. 'cite',
  91. 'code',
  92. 'col',
  93. 'colgroup',
  94. 'content',
  95. 'data',
  96. 'datalist',
  97. 'dd',
  98. 'del',
  99. 'details',
  100. 'dfn',
  101. 'dialog',
  102. 'dir',
  103. 'div',
  104. 'dl',
  105. 'dt',
  106. 'em',
  107. 'embed',
  108. 'fieldset',
  109. 'figcaption',
  110. 'figure',
  111. 'font',
  112. 'footer',
  113. 'form',
  114. 'frame',
  115. 'frameset',
  116. 'h1',
  117. 'h2',
  118. 'h3',
  119. 'h4',
  120. 'h5',
  121. 'h6',
  122. 'head',
  123. 'header',
  124. 'hgroup',
  125. 'hr',
  126. 'html',
  127. 'i',
  128. 'iframe',
  129. 'image',
  130. 'img',
  131. 'input',
  132. 'ins',
  133. 'kbd',
  134. 'keygen',
  135. 'label',
  136. 'legend',
  137. 'li',
  138. 'link',
  139. 'main',
  140. 'map',
  141. 'mark',
  142. 'marquee',
  143. 'math',
  144. 'menu',
  145. 'menuitem',
  146. 'meta',
  147. 'meter',
  148. 'nav',
  149. 'nobr',
  150. 'noembed',
  151. 'noframes',
  152. 'noscript',
  153. 'object',
  154. 'ol',
  155. 'optgroup',
  156. 'option',
  157. 'output',
  158. 'p',
  159. 'param',
  160. 'picture',
  161. 'plaintext',
  162. 'portal',
  163. 'pre',
  164. 'progress',
  165. 'q',
  166. 'rb',
  167. 'rp',
  168. 'rt',
  169. 'rtc',
  170. 'ruby',
  171. 's',
  172. 'samp',
  173. 'script',
  174. 'section',
  175. 'select',
  176. 'shadow',
  177. 'slot',
  178. 'small',
  179. 'source',
  180. 'spacer',
  181. 'span',
  182. 'strike',
  183. 'strong',
  184. 'style',
  185. 'sub',
  186. 'summary',
  187. 'sup',
  188. 'svg',
  189. 'table',
  190. 'tbody',
  191. 'td',
  192. 'template',
  193. 'textarea',
  194. 'tfoot',
  195. 'th',
  196. 'thead',
  197. 'time',
  198. 'title',
  199. 'tr',
  200. 'track',
  201. 'tt',
  202. 'u',
  203. 'ul',
  204. 'var',
  205. 'video',
  206. 'wbr',
  207. 'xmp',
  208. ]);
  209. /**
  210. * Map between attributes and set of tags that the attribute is valid on
  211. * @type {Map<string, Set<string>>}
  212. */
  213. const COMPONENT_ATTRIBUTE_MAP = new Map([
  214. ['rel', new Set(['link', 'a', 'area', 'form'])],
  215. ]);
  216. const messages = {
  217. emptyIsMeaningless: 'An empty “{{attributeName}}” attribute is meaningless.',
  218. neverValid: '“{{reportingValue}}” is never a valid “{{attributeName}}” attribute value.',
  219. noEmpty: 'An empty “{{attributeName}}” attribute is meaningless.',
  220. noMethod: 'The ”{{attributeName}}“ attribute cannot be a method.',
  221. notAlone: '“{{reportingValue}}” must be directly followed by “{{missingValue}}”.',
  222. notPaired: '“{{reportingValue}}” can not be directly followed by “{{secondValue}}” without “{{missingValue}}”.',
  223. notValidFor: '“{{reportingValue}}” is not a valid “{{attributeName}}” attribute value for <{{elementName}}>.',
  224. onlyMeaningfulFor: 'The ”{{attributeName}}“ attribute only has meaning on the tags: {{tagNames}}',
  225. onlyStrings: '“{{attributeName}}” attribute only supports strings.',
  226. spaceDelimited: '”{{attributeName}}“ attribute values should be space delimited.',
  227. suggestRemoveDefault: '"remove {{attributeName}}"',
  228. suggestRemoveEmpty: '"remove empty attribute {{attributeName}}"',
  229. suggestRemoveInvalid: '“remove invalid attribute {{reportingValue}}”',
  230. suggestRemoveWhitespaces: 'remove whitespaces in “{{reportingValue}}”',
  231. suggestRemoveNonString: 'remove non-string value in “{{reportingValue}}”',
  232. };
  233. function splitIntoRangedParts(node, regex) {
  234. const valueRangeStart = node.range[0] + 1; // the plus one is for the initial quote
  235. return Array.from(matchAll(node.value, regex), (match) => {
  236. const start = match.index + valueRangeStart;
  237. const end = start + match[0].length;
  238. return {
  239. reportingValue: `${match[1]}`,
  240. value: match[1],
  241. range: [start, end],
  242. };
  243. });
  244. }
  245. function checkLiteralValueNode(context, attributeName, node, parentNode, parentNodeName) {
  246. if (typeof node.value !== 'string') {
  247. report(context, messages.onlyStrings, 'onlyStrings', {
  248. node,
  249. data: { attributeName },
  250. suggest: [
  251. Object.assign(
  252. getMessageData('suggestRemoveNonString', messages.suggestRemoveNonString),
  253. { fix(fixer) { return fixer.remove(parentNode); } }
  254. ),
  255. ],
  256. });
  257. return;
  258. }
  259. if (!node.value.trim()) {
  260. report(context, messages.noEmpty, 'noEmpty', {
  261. node,
  262. data: { attributeName },
  263. suggest: [
  264. Object.assign(
  265. getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
  266. { fix(fixer) { return fixer.remove(node.parent); } }
  267. ),
  268. ],
  269. });
  270. return;
  271. }
  272. const singleAttributeParts = splitIntoRangedParts(node, /(\S+)/g);
  273. for (const singlePart of singleAttributeParts) {
  274. const allowedTags = VALID_VALUES.get(attributeName).get(singlePart.value);
  275. const reportingValue = singlePart.reportingValue;
  276. const suggest = [
  277. Object.assign(
  278. getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
  279. { fix(fixer) { return fixer.removeRange(singlePart.range); } }
  280. ),
  281. ];
  282. if (!allowedTags) {
  283. const data = {
  284. attributeName,
  285. reportingValue,
  286. };
  287. report(context, messages.neverValid, 'neverValid', {
  288. node,
  289. data,
  290. suggest,
  291. });
  292. } else if (!allowedTags.has(parentNodeName)) {
  293. report(context, messages.notValidFor, 'notValidFor', {
  294. node,
  295. data: {
  296. attributeName,
  297. reportingValue,
  298. elementName: parentNodeName,
  299. },
  300. suggest,
  301. });
  302. }
  303. }
  304. const allowedPairsForAttribute = VALID_PAIR_VALUES.get(attributeName);
  305. if (allowedPairsForAttribute) {
  306. const pairAttributeParts = splitIntoRangedParts(node, /(?=(\b\S+\s*\S+))/g);
  307. for (const pairPart of pairAttributeParts) {
  308. for (const allowedPair of allowedPairsForAttribute) {
  309. const pairing = allowedPair[0];
  310. const siblings = allowedPair[1];
  311. const attributes = pairPart.reportingValue.split('\u0020');
  312. const firstValue = attributes[0];
  313. const secondValue = attributes[1];
  314. if (firstValue === pairing) {
  315. const lastValue = attributes[attributes.length - 1]; // in case of multiple white spaces
  316. if (!siblings.has(lastValue)) {
  317. const message = secondValue ? messages.notPaired : messages.notAlone;
  318. const messageId = secondValue ? 'notPaired' : 'notAlone';
  319. report(context, message, messageId, {
  320. node,
  321. data: {
  322. reportingValue: firstValue,
  323. secondValue,
  324. missingValue: Array.from(siblings).join(', '),
  325. },
  326. suggest: false,
  327. });
  328. }
  329. }
  330. }
  331. }
  332. }
  333. const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
  334. for (const whitespacePart of whitespaceParts) {
  335. if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
  336. report(context, messages.spaceDelimited, 'spaceDelimited', {
  337. node,
  338. data: { attributeName },
  339. suggest: [
  340. Object.assign(
  341. getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
  342. { fix(fixer) { return fixer.removeRange(whitespacePart.range); } }
  343. ),
  344. ],
  345. });
  346. } else if (whitespacePart.value !== '\u0020') {
  347. report(context, messages.spaceDelimited, 'spaceDelimited', {
  348. node,
  349. data: { attributeName },
  350. suggest: [
  351. Object.assign(
  352. getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
  353. { fix(fixer) { return fixer.replaceTextRange(whitespacePart.range, '\u0020'); } }
  354. ),
  355. ],
  356. });
  357. }
  358. }
  359. }
  360. const DEFAULT_ATTRIBUTES = ['rel'];
  361. function checkAttribute(context, node) {
  362. const attribute = node.name.name;
  363. const parentNodeName = node.parent.name.name;
  364. if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
  365. const tagNames = Array.from(
  366. COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
  367. (tagName) => `"<${tagName}>"`
  368. ).join(', ');
  369. report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
  370. node: node.name,
  371. data: {
  372. attributeName: attribute,
  373. tagNames,
  374. },
  375. suggest: [
  376. Object.assign(
  377. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  378. { fix(fixer) { return fixer.remove(node); } }
  379. ),
  380. ],
  381. });
  382. return;
  383. }
  384. function fix(fixer) { return fixer.remove(node); }
  385. if (!node.value) {
  386. report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
  387. node: node.name,
  388. data: { attributeName: attribute },
  389. suggest: [
  390. Object.assign(
  391. getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
  392. { fix }
  393. ),
  394. ],
  395. });
  396. return;
  397. }
  398. if (node.value.type === 'Literal') {
  399. return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
  400. }
  401. if (node.value.expression.type === 'Literal') {
  402. return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
  403. }
  404. if (node.value.type !== 'JSXExpressionContainer') {
  405. return;
  406. }
  407. if (node.value.expression.type === 'ObjectExpression') {
  408. report(context, messages.onlyStrings, 'onlyStrings', {
  409. node: node.value,
  410. data: { attributeName: attribute },
  411. suggest: [
  412. Object.assign(
  413. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  414. { fix }
  415. ),
  416. ],
  417. });
  418. } else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
  419. report(context, messages.onlyStrings, 'onlyStrings', {
  420. node: node.value,
  421. data: { attributeName: attribute },
  422. suggest: [
  423. Object.assign(
  424. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  425. { fix }
  426. ),
  427. ],
  428. });
  429. }
  430. }
  431. function isValidCreateElement(node) {
  432. return node.callee
  433. && node.callee.type === 'MemberExpression'
  434. && node.callee.object.name === 'React'
  435. && node.callee.property.name === 'createElement'
  436. && node.arguments.length > 0;
  437. }
  438. function checkPropValidValue(context, node, value, attribute) {
  439. const validTags = VALID_VALUES.get(attribute);
  440. if (value.type !== 'Literal') {
  441. return; // cannot check non-literals
  442. }
  443. const validTagSet = validTags.get(value.value);
  444. if (!validTagSet) {
  445. report(context, messages.neverValid, 'neverValid', {
  446. node: value,
  447. data: {
  448. attributeName: attribute,
  449. reportingValue: value.value,
  450. },
  451. suggest: [
  452. Object.assign(
  453. getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
  454. { fix(fixer) { return fixer.replaceText(value, value.raw.replace(value.value, '')); } }
  455. ),
  456. ],
  457. });
  458. } else if (!validTagSet.has(node.arguments[0].value)) {
  459. report(context, messages.notValidFor, 'notValidFor', {
  460. node: value,
  461. data: {
  462. attributeName: attribute,
  463. reportingValue: value.raw,
  464. elementName: node.arguments[0].value,
  465. },
  466. suggest: false,
  467. });
  468. }
  469. }
  470. /**
  471. *
  472. * @param {*} context
  473. * @param {*} node
  474. * @param {string} attribute
  475. */
  476. function checkCreateProps(context, node, attribute) {
  477. const propsArg = node.arguments[1];
  478. if (!propsArg || propsArg.type !== 'ObjectExpression') {
  479. return; // can't check variables, computed, or shorthands
  480. }
  481. for (const prop of propsArg.properties) {
  482. if (!prop.key || prop.key.type !== 'Identifier') {
  483. // eslint-disable-next-line no-continue
  484. continue; // cannot check computed keys
  485. }
  486. if (prop.key.name !== attribute) {
  487. // eslint-disable-next-line no-continue
  488. continue; // ignore not this attribute
  489. }
  490. if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) {
  491. const tagNames = Array.from(
  492. COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
  493. (tagName) => `"<${tagName}>"`
  494. ).join(', ');
  495. report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
  496. node: prop.key,
  497. data: {
  498. attributeName: attribute,
  499. tagNames,
  500. },
  501. suggest: false,
  502. });
  503. // eslint-disable-next-line no-continue
  504. continue;
  505. }
  506. if (prop.method) {
  507. report(context, messages.noMethod, 'noMethod', {
  508. node: prop,
  509. data: {
  510. attributeName: attribute,
  511. },
  512. suggest: false,
  513. });
  514. // eslint-disable-next-line no-continue
  515. continue;
  516. }
  517. if (prop.shorthand || prop.computed) {
  518. // eslint-disable-next-line no-continue
  519. continue; // cannot check these
  520. }
  521. if (prop.value.type === 'ArrayExpression') {
  522. for (const value of prop.value.elements) {
  523. checkPropValidValue(context, node, value, attribute);
  524. }
  525. // eslint-disable-next-line no-continue
  526. continue;
  527. }
  528. checkPropValidValue(context, node, prop.value, attribute);
  529. }
  530. }
  531. module.exports = {
  532. meta: {
  533. docs: {
  534. description: 'Disallow usage of invalid attributes',
  535. category: 'Possible Errors',
  536. url: docsUrl('no-invalid-html-attribute'),
  537. },
  538. messages,
  539. schema: [{
  540. type: 'array',
  541. uniqueItems: true,
  542. items: {
  543. enum: ['rel'],
  544. },
  545. }],
  546. type: 'suggestion',
  547. hasSuggestions: true, // eslint-disable-line eslint-plugin/require-meta-has-suggestions
  548. },
  549. create(context) {
  550. return {
  551. JSXAttribute(node) {
  552. const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
  553. // ignore attributes that aren't configured to be checked
  554. if (!attributes.has(node.name.name)) {
  555. return;
  556. }
  557. // ignore non-HTML elements
  558. if (!HTML_ELEMENTS.has(node.parent.name.name)) {
  559. return;
  560. }
  561. checkAttribute(context, node);
  562. },
  563. CallExpression(node) {
  564. if (!isValidCreateElement(node)) {
  565. return;
  566. }
  567. const elemNameArg = node.arguments[0];
  568. if (!elemNameArg || elemNameArg.type !== 'Literal') {
  569. return; // can only check literals
  570. }
  571. // ignore non-HTML elements
  572. if (!HTML_ELEMENTS.has(elemNameArg.value)) {
  573. return;
  574. }
  575. const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
  576. for (const attribute of attributes) {
  577. checkCreateProps(context, node, attribute);
  578. }
  579. },
  580. };
  581. },
  582. };