no-unknown-property.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. /**
  2. * @fileoverview Prevent usage of unknown DOM property
  3. * @author Yannick Croissant
  4. */
  5. 'use strict';
  6. const has = require('object.hasown/polyfill')();
  7. const docsUrl = require('../util/docsUrl');
  8. const testReactVersion = require('../util/version').testReactVersion;
  9. const report = require('../util/report');
  10. // ------------------------------------------------------------------------------
  11. // Constants
  12. // ------------------------------------------------------------------------------
  13. const DEFAULTS = {
  14. ignore: [],
  15. };
  16. const DOM_ATTRIBUTE_NAMES = {
  17. 'accept-charset': 'acceptCharset',
  18. class: 'className',
  19. for: 'htmlFor',
  20. 'http-equiv': 'httpEquiv',
  21. crossorigin: 'crossOrigin',
  22. };
  23. const ATTRIBUTE_TAGS_MAP = {
  24. // image is required for SVG support, all other tags are HTML.
  25. crossOrigin: ['script', 'img', 'video', 'audio', 'link', 'image'],
  26. };
  27. const SVGDOM_ATTRIBUTE_NAMES = {
  28. 'accent-height': 'accentHeight',
  29. 'alignment-baseline': 'alignmentBaseline',
  30. 'arabic-form': 'arabicForm',
  31. 'baseline-shift': 'baselineShift',
  32. 'cap-height': 'capHeight',
  33. 'clip-path': 'clipPath',
  34. 'clip-rule': 'clipRule',
  35. 'color-interpolation': 'colorInterpolation',
  36. 'color-interpolation-filters': 'colorInterpolationFilters',
  37. 'color-profile': 'colorProfile',
  38. 'color-rendering': 'colorRendering',
  39. 'dominant-baseline': 'dominantBaseline',
  40. 'enable-background': 'enableBackground',
  41. 'fill-opacity': 'fillOpacity',
  42. 'fill-rule': 'fillRule',
  43. 'flood-color': 'floodColor',
  44. 'flood-opacity': 'floodOpacity',
  45. 'font-family': 'fontFamily',
  46. 'font-size': 'fontSize',
  47. 'font-size-adjust': 'fontSizeAdjust',
  48. 'font-stretch': 'fontStretch',
  49. 'font-style': 'fontStyle',
  50. 'font-variant': 'fontVariant',
  51. 'font-weight': 'fontWeight',
  52. 'glyph-name': 'glyphName',
  53. 'glyph-orientation-horizontal': 'glyphOrientationHorizontal',
  54. 'glyph-orientation-vertical': 'glyphOrientationVertical',
  55. 'horiz-adv-x': 'horizAdvX',
  56. 'horiz-origin-x': 'horizOriginX',
  57. 'image-rendering': 'imageRendering',
  58. 'letter-spacing': 'letterSpacing',
  59. 'lighting-color': 'lightingColor',
  60. 'marker-end': 'markerEnd',
  61. 'marker-mid': 'markerMid',
  62. 'marker-start': 'markerStart',
  63. 'overline-position': 'overlinePosition',
  64. 'overline-thickness': 'overlineThickness',
  65. 'paint-order': 'paintOrder',
  66. 'panose-1': 'panose1',
  67. 'pointer-events': 'pointerEvents',
  68. 'rendering-intent': 'renderingIntent',
  69. 'shape-rendering': 'shapeRendering',
  70. 'stop-color': 'stopColor',
  71. 'stop-opacity': 'stopOpacity',
  72. 'strikethrough-position': 'strikethroughPosition',
  73. 'strikethrough-thickness': 'strikethroughThickness',
  74. 'stroke-dasharray': 'strokeDasharray',
  75. 'stroke-dashoffset': 'strokeDashoffset',
  76. 'stroke-linecap': 'strokeLinecap',
  77. 'stroke-linejoin': 'strokeLinejoin',
  78. 'stroke-miterlimit': 'strokeMiterlimit',
  79. 'stroke-opacity': 'strokeOpacity',
  80. 'stroke-width': 'strokeWidth',
  81. 'text-anchor': 'textAnchor',
  82. 'text-decoration': 'textDecoration',
  83. 'text-rendering': 'textRendering',
  84. 'underline-position': 'underlinePosition',
  85. 'underline-thickness': 'underlineThickness',
  86. 'unicode-bidi': 'unicodeBidi',
  87. 'unicode-range': 'unicodeRange',
  88. 'units-per-em': 'unitsPerEm',
  89. 'v-alphabetic': 'vAlphabetic',
  90. 'v-hanging': 'vHanging',
  91. 'v-ideographic': 'vIdeographic',
  92. 'v-mathematical': 'vMathematical',
  93. 'vector-effect': 'vectorEffect',
  94. 'vert-adv-y': 'vertAdvY',
  95. 'vert-origin-x': 'vertOriginX',
  96. 'vert-origin-y': 'vertOriginY',
  97. 'word-spacing': 'wordSpacing',
  98. 'writing-mode': 'writingMode',
  99. 'x-height': 'xHeight',
  100. 'xlink:actuate': 'xlinkActuate',
  101. 'xlink:arcrole': 'xlinkArcrole',
  102. 'xlink:href': 'xlinkHref',
  103. 'xlink:role': 'xlinkRole',
  104. 'xlink:show': 'xlinkShow',
  105. 'xlink:title': 'xlinkTitle',
  106. 'xlink:type': 'xlinkType',
  107. 'xml:base': 'xmlBase',
  108. 'xml:lang': 'xmlLang',
  109. 'xml:space': 'xmlSpace',
  110. };
  111. const DOM_PROPERTY_NAMES = [
  112. // Standard
  113. 'acceptCharset', 'accessKey', 'allowFullScreen', 'autoComplete', 'autoFocus', 'autoPlay',
  114. 'cellPadding', 'cellSpacing', 'classID', 'className', 'colSpan', 'contentEditable', 'contextMenu',
  115. 'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget',
  116. 'frameBorder', 'hrefLang', 'htmlFor', 'httpEquiv', 'inputMode', 'keyParams', 'keyType', 'marginHeight', 'marginWidth',
  117. 'maxLength', 'mediaGroup', 'minLength', 'noValidate', 'onAnimationEnd', 'onAnimationIteration', 'onAnimationStart',
  118. 'onBlur', 'onChange', 'onClick', 'onContextMenu', 'onCopy', 'onCompositionEnd', 'onCompositionStart',
  119. 'onCompositionUpdate', 'onCut', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', 'onDragLeave',
  120. 'onError', 'onFocus', 'onInput', 'onKeyDown', 'onKeyPress', 'onKeyUp', 'onLoad', 'onWheel', 'onDragOver',
  121. 'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter', 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver',
  122. 'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'rowSpan',
  123. 'spellCheck', 'srcDoc', 'srcLang', 'srcSet', 'tabIndex', 'useMap',
  124. // Non standard
  125. 'autoCapitalize', 'autoCorrect',
  126. 'autoSave',
  127. 'itemProp', 'itemScope', 'itemType', 'itemRef', 'itemID',
  128. ];
  129. function getDOMPropertyNames(context) {
  130. // this was removed in React v16.1+, see https://github.com/facebook/react/pull/10823
  131. if (!testReactVersion(context, '>= 16.1.0')) {
  132. return ['allowTransparency'].concat(DOM_PROPERTY_NAMES);
  133. }
  134. return DOM_PROPERTY_NAMES;
  135. }
  136. // ------------------------------------------------------------------------------
  137. // Helpers
  138. // ------------------------------------------------------------------------------
  139. /**
  140. * Checks if a node matches the JSX tag convention. This also checks if a node
  141. * is extended as a webcomponent using the attribute "is".
  142. * @param {Object} node - JSX element being tested.
  143. * @returns {boolean} Whether or not the node name match the JSX tag convention.
  144. */
  145. const tagConvention = /^[a-z][^-]*$/;
  146. function isTagName(node) {
  147. if (tagConvention.test(node.parent.name.name)) {
  148. // https://www.w3.org/TR/custom-elements/#type-extension-semantics
  149. return !node.parent.attributes.some((attrNode) => (
  150. attrNode.type === 'JSXAttribute'
  151. && attrNode.name.type === 'JSXIdentifier'
  152. && attrNode.name.name === 'is'
  153. ));
  154. }
  155. return false;
  156. }
  157. /**
  158. * Extracts the tag name for the JSXAttribute
  159. * @param {JSXAttribute} node - JSXAttribute being tested.
  160. * @returns {String|null} tag name
  161. */
  162. function getTagName(node) {
  163. if (node && node.parent && node.parent.name && node.parent.name) {
  164. return node.parent.name.name;
  165. }
  166. return null;
  167. }
  168. /**
  169. * Test wether the tag name for the JSXAttribute is
  170. * something like <Foo.bar />
  171. * @param {JSXAttribute} node - JSXAttribute being tested.
  172. * @returns {Boolean} result
  173. */
  174. function tagNameHasDot(node) {
  175. return !!(
  176. node.parent
  177. && node.parent.name
  178. && node.parent.name.type === 'JSXMemberExpression'
  179. );
  180. }
  181. /**
  182. * Get the standard name of the attribute.
  183. * @param {String} name - Name of the attribute.
  184. * @param {String} context - eslint context
  185. * @returns {String | undefined} The standard name of the attribute, or undefined if no standard name was found.
  186. */
  187. function getStandardName(name, context) {
  188. if (has(DOM_ATTRIBUTE_NAMES, name)) {
  189. return DOM_ATTRIBUTE_NAMES[name];
  190. }
  191. if (has(SVGDOM_ATTRIBUTE_NAMES, name)) {
  192. return SVGDOM_ATTRIBUTE_NAMES[name];
  193. }
  194. const names = getDOMPropertyNames(context);
  195. // Let's find a possible attribute match with a case-insensitive search.
  196. return names.find((element) => element.toLowerCase() === name.toLowerCase());
  197. }
  198. // ------------------------------------------------------------------------------
  199. // Rule Definition
  200. // ------------------------------------------------------------------------------
  201. const messages = {
  202. invalidPropOnTag: 'Invalid property \'{{name}}\' found on tag \'{{tagName}}\', but it is only allowed on: {{allowedTags}}',
  203. unknownProp: 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead',
  204. };
  205. module.exports = {
  206. meta: {
  207. docs: {
  208. description: 'Disallow usage of unknown DOM property',
  209. category: 'Possible Errors',
  210. recommended: true,
  211. url: docsUrl('no-unknown-property'),
  212. },
  213. fixable: 'code',
  214. messages,
  215. schema: [{
  216. type: 'object',
  217. properties: {
  218. ignore: {
  219. type: 'array',
  220. items: {
  221. type: 'string',
  222. },
  223. },
  224. },
  225. additionalProperties: false,
  226. }],
  227. },
  228. create(context) {
  229. function getIgnoreConfig() {
  230. return (context.options[0] && context.options[0].ignore) || DEFAULTS.ignore;
  231. }
  232. return {
  233. JSXAttribute(node) {
  234. const ignoreNames = getIgnoreConfig();
  235. const name = context.getSourceCode().getText(node.name);
  236. if (ignoreNames.indexOf(name) >= 0) {
  237. return;
  238. }
  239. // Ignore tags like <Foo.bar />
  240. if (tagNameHasDot(node)) {
  241. return;
  242. }
  243. const tagName = getTagName(node);
  244. // 1. Some attributes are allowed on some tags only.
  245. const allowedTags = has(ATTRIBUTE_TAGS_MAP, name) ? ATTRIBUTE_TAGS_MAP[name] : null;
  246. if (tagName && allowedTags && /[^A-Z]/.test(tagName.charAt(0)) && allowedTags.indexOf(tagName) === -1) {
  247. report(context, messages.invalidPropOnTag, 'invalidPropOnTag', {
  248. node,
  249. data: {
  250. name,
  251. tagName,
  252. allowedTags: allowedTags.join(', '),
  253. },
  254. });
  255. }
  256. // 2. Otherwise, we'll try to find if the attribute is a close version
  257. // of what we should normally have with React. If yes, we'll report an
  258. // error. We don't want to report if the input attribute name is the
  259. // standard name though!
  260. const standardName = getStandardName(name, context);
  261. if (!isTagName(node) || !standardName || standardName === name) {
  262. return;
  263. }
  264. report(context, messages.unknownProp, 'unknownProp', {
  265. node,
  266. data: {
  267. name,
  268. standardName,
  269. },
  270. fix(fixer) {
  271. return fixer.replaceText(node.name, standardName);
  272. },
  273. });
  274. },
  275. };
  276. },
  277. };