no-invalid-html-attribute.js 16 KB

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