no-invalid-html-attribute.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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. COMPONENT_ATTRIBUTE_MAP.set('rel', new Set(['link', 'a', 'area', 'form']));
  215. const messages = {
  216. emptyIsMeaningless: 'An empty “{{attributeName}}” attribute is meaningless.',
  217. neverValid: '“{{reportingValue}}” is never a valid “{{attributeName}}” attribute value.',
  218. noEmpty: 'An empty “{{attributeName}}” attribute is meaningless.',
  219. noMethod: 'The ”{{attributeName}}“ attribute cannot be a method.',
  220. notAlone: '“{{reportingValue}}” must be directly followed by “{{missingValue}}”.',
  221. notPaired: '“{{reportingValue}}” can not be directly followed by “{{secondValue}}” without “{{missingValue}}”.',
  222. notValidFor: '“{{reportingValue}}” is not a valid “{{attributeName}}” attribute value for <{{elementName}}>.',
  223. onlyMeaningfulFor: 'The ”{{attributeName}}“ attribute only has meaning on the tags: {{tagNames}}',
  224. onlyStrings: '“{{attributeName}}” attribute only supports strings.',
  225. spaceDelimited: '”{{attributeName}}“ attribute values should be space delimited.',
  226. suggestRemoveDefault: '"remove {{attributeName}}"',
  227. suggestRemoveEmpty: '"remove empty attribute {{attributeName}}"',
  228. suggestRemoveInvalid: '“remove invalid attribute {{reportingValue}}”',
  229. suggestRemoveWhitespaces: 'remove whitespaces in “{{reportingValue}}”',
  230. suggestRemoveNonString: 'remove non-string value in “{{reportingValue}}”',
  231. };
  232. function splitIntoRangedParts(node, regex) {
  233. const valueRangeStart = node.range[0] + 1; // the plus one is for the initial quote
  234. return Array.from(matchAll(node.value, regex), (match) => {
  235. const start = match.index + valueRangeStart;
  236. const end = start + match[0].length;
  237. return {
  238. reportingValue: `${match[1]}`,
  239. value: match[1],
  240. range: [start, end],
  241. };
  242. });
  243. }
  244. function checkLiteralValueNode(context, attributeName, node, parentNode, parentNodeName) {
  245. if (typeof node.value !== 'string') {
  246. report(context, messages.onlyStrings, 'onlyStrings', {
  247. node,
  248. data: { attributeName },
  249. suggest: [
  250. Object.assign(
  251. getMessageData('suggestRemoveNonString', messages.suggestRemoveNonString),
  252. { fix(fixer) { return fixer.remove(parentNode); } }
  253. ),
  254. ],
  255. });
  256. return;
  257. }
  258. if (!node.value.trim()) {
  259. report(context, messages.noEmpty, 'noEmpty', {
  260. node,
  261. data: { attributeName },
  262. suggest: [
  263. Object.assign(
  264. getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
  265. { fix(fixer) { return fixer.remove(node.parent); } }
  266. ),
  267. ],
  268. });
  269. return;
  270. }
  271. const singleAttributeParts = splitIntoRangedParts(node, /(\S+)/g);
  272. for (const singlePart of singleAttributeParts) {
  273. const allowedTags = VALID_VALUES.get(attributeName).get(singlePart.value);
  274. const reportingValue = singlePart.reportingValue;
  275. const suggest = [
  276. Object.assign(
  277. getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
  278. { fix(fixer) { return fixer.removeRange(singlePart.range); } }
  279. ),
  280. ];
  281. if (!allowedTags) {
  282. const data = {
  283. attributeName,
  284. reportingValue,
  285. };
  286. report(context, messages.neverValid, 'neverValid', {
  287. node,
  288. data,
  289. suggest,
  290. });
  291. } else if (!allowedTags.has(parentNodeName)) {
  292. report(context, messages.notValidFor, 'notValidFor', {
  293. node,
  294. data: {
  295. attributeName,
  296. reportingValue,
  297. elementName: parentNodeName,
  298. },
  299. suggest,
  300. });
  301. }
  302. }
  303. const allowedPairsForAttribute = VALID_PAIR_VALUES.get(attributeName);
  304. if (allowedPairsForAttribute) {
  305. const pairAttributeParts = splitIntoRangedParts(node, /(?=(\b\S+\s*\S+))/g);
  306. for (const pairPart of pairAttributeParts) {
  307. for (const allowedPair of allowedPairsForAttribute) {
  308. const pairing = allowedPair[0];
  309. const siblings = allowedPair[1];
  310. const attributes = pairPart.reportingValue.split('\u0020');
  311. const firstValue = attributes[0];
  312. const secondValue = attributes[1];
  313. if (firstValue === pairing) {
  314. const lastValue = attributes[attributes.length - 1]; // in case of multiple white spaces
  315. if (!siblings.has(lastValue)) {
  316. const message = secondValue ? messages.notPaired : messages.notAlone;
  317. const messageId = secondValue ? 'notPaired' : 'notAlone';
  318. report(context, message, messageId, {
  319. node,
  320. data: {
  321. reportingValue: firstValue,
  322. secondValue,
  323. missingValue: Array.from(siblings).join(', '),
  324. },
  325. suggest: false,
  326. });
  327. }
  328. }
  329. }
  330. }
  331. }
  332. const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
  333. for (const whitespacePart of whitespaceParts) {
  334. if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
  335. report(context, messages.spaceDelimited, 'spaceDelimited', {
  336. node,
  337. data: { attributeName },
  338. suggest: [
  339. Object.assign(
  340. getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
  341. { fix(fixer) { return fixer.removeRange(whitespacePart.range); } }
  342. ),
  343. ],
  344. });
  345. } else if (whitespacePart.value !== '\u0020') {
  346. report(context, messages.spaceDelimited, 'spaceDelimited', {
  347. node,
  348. data: { attributeName },
  349. suggest: [
  350. Object.assign(
  351. getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
  352. { fix(fixer) { return fixer.replaceTextRange(whitespacePart.range, '\u0020'); } }
  353. ),
  354. ],
  355. });
  356. }
  357. }
  358. }
  359. const DEFAULT_ATTRIBUTES = ['rel'];
  360. function checkAttribute(context, node) {
  361. const attribute = node.name.name;
  362. const parentNodeName = node.parent.name.name;
  363. if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
  364. const tagNames = Array.from(
  365. COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
  366. (tagName) => `"<${tagName}>"`
  367. ).join(', ');
  368. report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
  369. node,
  370. data: {
  371. attributeName: attribute,
  372. tagNames,
  373. },
  374. suggest: [
  375. Object.assign(
  376. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  377. { fix(fixer) { return fixer.remove(node); } }
  378. ),
  379. ],
  380. });
  381. return;
  382. }
  383. function fix(fixer) { return fixer.remove(node); }
  384. if (!node.value) {
  385. report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
  386. node,
  387. data: { attributeName: attribute },
  388. suggest: [
  389. Object.assign(
  390. getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
  391. { fix }
  392. ),
  393. ],
  394. });
  395. return;
  396. }
  397. if (node.value.type === 'Literal') {
  398. return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
  399. }
  400. if (node.value.expression.type === 'Literal') {
  401. return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
  402. }
  403. if (node.value.type !== 'JSXExpressionContainer') {
  404. return;
  405. }
  406. if (node.value.expression.type === 'ObjectExpression') {
  407. report(context, messages.onlyStrings, 'onlyStrings', {
  408. node,
  409. data: { attributeName: attribute },
  410. suggest: [
  411. Object.assign(
  412. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  413. { fix }
  414. ),
  415. ],
  416. });
  417. } else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
  418. report(context, messages.onlyStrings, 'onlyStrings', {
  419. node,
  420. data: { attributeName: attribute },
  421. suggest: [
  422. Object.assign(
  423. getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
  424. { fix }
  425. ),
  426. ],
  427. });
  428. }
  429. }
  430. function isValidCreateElement(node) {
  431. return node.callee
  432. && node.callee.type === 'MemberExpression'
  433. && node.callee.object.name === 'React'
  434. && node.callee.property.name === 'createElement'
  435. && node.arguments.length > 0;
  436. }
  437. function checkPropValidValue(context, node, value, attribute) {
  438. const validTags = VALID_VALUES.get(attribute);
  439. if (value.type !== 'Literal') {
  440. return; // cannot check non-literals
  441. }
  442. const validTagSet = validTags.get(value.value);
  443. if (!validTagSet) {
  444. report(context, messages.neverValid, 'neverValid', {
  445. node: value,
  446. data: {
  447. attributeName: attribute,
  448. reportingValue: value.value,
  449. },
  450. suggest: [
  451. Object.assign(
  452. getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
  453. { fix(fixer) { return fixer.replaceText(value, value.raw.replace(value.value, '')); } }
  454. ),
  455. ],
  456. });
  457. } else if (!validTagSet.has(node.arguments[0].value)) {
  458. report(context, messages.notValidFor, 'notValidFor', {
  459. node: value,
  460. data: {
  461. attributeName: attribute,
  462. reportingValue: value.raw,
  463. elementName: node.arguments[0].value,
  464. },
  465. suggest: false,
  466. });
  467. }
  468. }
  469. /**
  470. *
  471. * @param {*} context
  472. * @param {*} node
  473. * @param {string} attribute
  474. */
  475. function checkCreateProps(context, node, attribute) {
  476. const propsArg = node.arguments[1];
  477. if (!propsArg || propsArg.type !== 'ObjectExpression') {
  478. return; // can't check variables, computed, or shorthands
  479. }
  480. for (const prop of propsArg.properties) {
  481. if (!prop.key || prop.key.type !== 'Identifier') {
  482. // eslint-disable-next-line no-continue
  483. continue; // cannot check computed keys
  484. }
  485. if (prop.key.name !== attribute) {
  486. // eslint-disable-next-line no-continue
  487. continue; // ignore not this attribute
  488. }
  489. if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) {
  490. const tagNames = Array.from(
  491. COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
  492. (tagName) => `"<${tagName}>"`
  493. ).join(', ');
  494. report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
  495. node,
  496. data: {
  497. attributeName: attribute,
  498. tagNames,
  499. },
  500. suggest: false,
  501. });
  502. // eslint-disable-next-line no-continue
  503. continue;
  504. }
  505. if (prop.method) {
  506. report(context, messages.noMethod, 'noMethod', {
  507. node: prop,
  508. data: {
  509. attributeName: attribute,
  510. },
  511. suggest: false,
  512. });
  513. // eslint-disable-next-line no-continue
  514. continue;
  515. }
  516. if (prop.shorthand || prop.computed) {
  517. // eslint-disable-next-line no-continue
  518. continue; // cannot check these
  519. }
  520. if (prop.value.type === 'ArrayExpression') {
  521. for (const value of prop.value.elements) {
  522. checkPropValidValue(context, node, value, attribute);
  523. }
  524. // eslint-disable-next-line no-continue
  525. continue;
  526. }
  527. checkPropValidValue(context, node, prop.value, attribute);
  528. }
  529. }
  530. module.exports = {
  531. meta: {
  532. docs: {
  533. description: 'Disallow usage of invalid attributes',
  534. category: 'Possible Errors',
  535. url: docsUrl('no-invalid-html-attribute'),
  536. },
  537. messages,
  538. schema: [{
  539. type: 'array',
  540. uniqueItems: true,
  541. items: {
  542. enum: ['rel'],
  543. },
  544. }],
  545. type: 'suggestion',
  546. hasSuggestions: true, // eslint-disable-line eslint-plugin/require-meta-has-suggestions
  547. },
  548. create(context) {
  549. return {
  550. JSXAttribute(node) {
  551. const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
  552. // ignore attributes that aren't configured to be checked
  553. if (!attributes.has(node.name.name)) {
  554. return;
  555. }
  556. // ignore non-HTML elements
  557. if (!HTML_ELEMENTS.has(node.parent.name.name)) {
  558. return;
  559. }
  560. checkAttribute(context, node);
  561. },
  562. CallExpression(node) {
  563. if (!isValidCreateElement(node)) {
  564. return;
  565. }
  566. const elemNameArg = node.arguments[0];
  567. if (!elemNameArg || elemNameArg.type !== 'Literal') {
  568. return; // can only check literals
  569. }
  570. // ignore non-HTML elements
  571. if (!HTML_ELEMENTS.has(elemNameArg.value)) {
  572. return;
  573. }
  574. const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
  575. for (const attribute of attributes) {
  576. checkCreateProps(context, node, attribute);
  577. }
  578. },
  579. };
  580. },
  581. };