no-invalid-html-attribute.js 14 KB

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