294 lines
9.3 KiB
JavaScript
294 lines
9.3 KiB
JavaScript
|
/**
|
||
|
* @fileoverview Prevent usage of unknown DOM property
|
||
|
* @author Yannick Croissant
|
||
|
*/
|
||
|
|
||
|
'use strict';
|
||
|
|
||
|
const docsUrl = require('../util/docsUrl');
|
||
|
const versionUtil = require('../util/version');
|
||
|
|
||
|
// ------------------------------------------------------------------------------
|
||
|
// Constants
|
||
|
// ------------------------------------------------------------------------------
|
||
|
|
||
|
const DEFAULTS = {
|
||
|
ignore: []
|
||
|
};
|
||
|
|
||
|
const UNKNOWN_MESSAGE = 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead';
|
||
|
const WRONG_TAG_MESSAGE = 'Invalid property \'{{name}}\' found on tag \'{{tagName}}\', but it is only allowed on: {{allowedTags}}';
|
||
|
|
||
|
const DOM_ATTRIBUTE_NAMES = {
|
||
|
'accept-charset': 'acceptCharset',
|
||
|
class: 'className',
|
||
|
for: 'htmlFor',
|
||
|
'http-equiv': 'httpEquiv',
|
||
|
crossorigin: 'crossOrigin'
|
||
|
};
|
||
|
|
||
|
const ATTRIBUTE_TAGS_MAP = {
|
||
|
crossOrigin: ['script', 'img', 'video', 'audio', 'link']
|
||
|
};
|
||
|
|
||
|
const SVGDOM_ATTRIBUTE_NAMES = {
|
||
|
'accent-height': 'accentHeight',
|
||
|
'alignment-baseline': 'alignmentBaseline',
|
||
|
'arabic-form': 'arabicForm',
|
||
|
'baseline-shift': 'baselineShift',
|
||
|
'cap-height': 'capHeight',
|
||
|
'clip-path': 'clipPath',
|
||
|
'clip-rule': 'clipRule',
|
||
|
'color-interpolation': 'colorInterpolation',
|
||
|
'color-interpolation-filters': 'colorInterpolationFilters',
|
||
|
'color-profile': 'colorProfile',
|
||
|
'color-rendering': 'colorRendering',
|
||
|
'dominant-baseline': 'dominantBaseline',
|
||
|
'enable-background': 'enableBackground',
|
||
|
'fill-opacity': 'fillOpacity',
|
||
|
'fill-rule': 'fillRule',
|
||
|
'flood-color': 'floodColor',
|
||
|
'flood-opacity': 'floodOpacity',
|
||
|
'font-family': 'fontFamily',
|
||
|
'font-size': 'fontSize',
|
||
|
'font-size-adjust': 'fontSizeAdjust',
|
||
|
'font-stretch': 'fontStretch',
|
||
|
'font-style': 'fontStyle',
|
||
|
'font-variant': 'fontVariant',
|
||
|
'font-weight': 'fontWeight',
|
||
|
'glyph-name': 'glyphName',
|
||
|
'glyph-orientation-horizontal': 'glyphOrientationHorizontal',
|
||
|
'glyph-orientation-vertical': 'glyphOrientationVertical',
|
||
|
'horiz-adv-x': 'horizAdvX',
|
||
|
'horiz-origin-x': 'horizOriginX',
|
||
|
'image-rendering': 'imageRendering',
|
||
|
'letter-spacing': 'letterSpacing',
|
||
|
'lighting-color': 'lightingColor',
|
||
|
'marker-end': 'markerEnd',
|
||
|
'marker-mid': 'markerMid',
|
||
|
'marker-start': 'markerStart',
|
||
|
'overline-position': 'overlinePosition',
|
||
|
'overline-thickness': 'overlineThickness',
|
||
|
'paint-order': 'paintOrder',
|
||
|
'panose-1': 'panose1',
|
||
|
'pointer-events': 'pointerEvents',
|
||
|
'rendering-intent': 'renderingIntent',
|
||
|
'shape-rendering': 'shapeRendering',
|
||
|
'stop-color': 'stopColor',
|
||
|
'stop-opacity': 'stopOpacity',
|
||
|
'strikethrough-position': 'strikethroughPosition',
|
||
|
'strikethrough-thickness': 'strikethroughThickness',
|
||
|
'stroke-dasharray': 'strokeDasharray',
|
||
|
'stroke-dashoffset': 'strokeDashoffset',
|
||
|
'stroke-linecap': 'strokeLinecap',
|
||
|
'stroke-linejoin': 'strokeLinejoin',
|
||
|
'stroke-miterlimit': 'strokeMiterlimit',
|
||
|
'stroke-opacity': 'strokeOpacity',
|
||
|
'stroke-width': 'strokeWidth',
|
||
|
'text-anchor': 'textAnchor',
|
||
|
'text-decoration': 'textDecoration',
|
||
|
'text-rendering': 'textRendering',
|
||
|
'underline-position': 'underlinePosition',
|
||
|
'underline-thickness': 'underlineThickness',
|
||
|
'unicode-bidi': 'unicodeBidi',
|
||
|
'unicode-range': 'unicodeRange',
|
||
|
'units-per-em': 'unitsPerEm',
|
||
|
'v-alphabetic': 'vAlphabetic',
|
||
|
'v-hanging': 'vHanging',
|
||
|
'v-ideographic': 'vIdeographic',
|
||
|
'v-mathematical': 'vMathematical',
|
||
|
'vector-effect': 'vectorEffect',
|
||
|
'vert-adv-y': 'vertAdvY',
|
||
|
'vert-origin-x': 'vertOriginX',
|
||
|
'vert-origin-y': 'vertOriginY',
|
||
|
'word-spacing': 'wordSpacing',
|
||
|
'writing-mode': 'writingMode',
|
||
|
'x-height': 'xHeight',
|
||
|
'xlink:actuate': 'xlinkActuate',
|
||
|
'xlink:arcrole': 'xlinkArcrole',
|
||
|
'xlink:href': 'xlinkHref',
|
||
|
'xlink:role': 'xlinkRole',
|
||
|
'xlink:show': 'xlinkShow',
|
||
|
'xlink:title': 'xlinkTitle',
|
||
|
'xlink:type': 'xlinkType',
|
||
|
'xml:base': 'xmlBase',
|
||
|
'xml:lang': 'xmlLang',
|
||
|
'xml:space': 'xmlSpace'
|
||
|
};
|
||
|
|
||
|
const DOM_PROPERTY_NAMES = [
|
||
|
// Standard
|
||
|
'acceptCharset', 'accessKey', 'allowFullScreen', 'autoComplete', 'autoFocus', 'autoPlay',
|
||
|
'cellPadding', 'cellSpacing', 'classID', 'className', 'colSpan', 'contentEditable', 'contextMenu',
|
||
|
'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget',
|
||
|
'frameBorder', 'hrefLang', 'htmlFor', 'httpEquiv', 'inputMode', 'keyParams', 'keyType', 'marginHeight', 'marginWidth',
|
||
|
'maxLength', 'mediaGroup', 'minLength', 'noValidate', 'onAnimationEnd', 'onAnimationIteration', 'onAnimationStart',
|
||
|
'onBlur', 'onChange', 'onClick', 'onContextMenu', 'onCopy', 'onCompositionEnd', 'onCompositionStart',
|
||
|
'onCompositionUpdate', 'onCut', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', 'onDragLeave',
|
||
|
'onError', 'onFocus', 'onInput', 'onKeyDown', 'onKeyPress', 'onKeyUp', 'onLoad', 'onWheel', 'onDragOver',
|
||
|
'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter', 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver',
|
||
|
'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'rowSpan',
|
||
|
'spellCheck', 'srcDoc', 'srcLang', 'srcSet', 'tabIndex', 'useMap',
|
||
|
// Non standard
|
||
|
'autoCapitalize', 'autoCorrect',
|
||
|
'autoSave',
|
||
|
'itemProp', 'itemScope', 'itemType', 'itemRef', 'itemID'
|
||
|
];
|
||
|
function getDOMPropertyNames(context) {
|
||
|
// this was removed in React v16.1+, see https://github.com/facebook/react/pull/10823
|
||
|
if (!versionUtil.testReactVersion(context, '16.1.0')) {
|
||
|
return ['allowTransparency'].concat(DOM_PROPERTY_NAMES);
|
||
|
}
|
||
|
return DOM_PROPERTY_NAMES;
|
||
|
}
|
||
|
|
||
|
// ------------------------------------------------------------------------------
|
||
|
// Helpers
|
||
|
// ------------------------------------------------------------------------------
|
||
|
|
||
|
/**
|
||
|
* Checks if a node matches the JSX tag convention.
|
||
|
* @param {Object} node - JSX element being tested.
|
||
|
* @returns {boolean} Whether or not the node name match the JSX tag convention.
|
||
|
*/
|
||
|
const tagConvention = /^[a-z][^-]*$/;
|
||
|
function isTagName(node) {
|
||
|
if (tagConvention.test(node.parent.name.name)) {
|
||
|
// http://www.w3.org/TR/custom-elements/#type-extension-semantics
|
||
|
return !node.parent.attributes.some(attrNode => (
|
||
|
attrNode.type === 'JSXAttribute' &&
|
||
|
attrNode.name.type === 'JSXIdentifier' &&
|
||
|
attrNode.name.name === 'is'
|
||
|
));
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Extracts the tag name for the JSXAttribute
|
||
|
* @param {JSXAttribute} node - JSXAttribute being tested.
|
||
|
* @returns {String|null} tag name
|
||
|
*/
|
||
|
function getTagName(node) {
|
||
|
if (node && node.parent && node.parent.name && node.parent.name) {
|
||
|
return node.parent.name.name;
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Test wether the tag name for the JSXAttribute is
|
||
|
* something like <Foo.bar />
|
||
|
* @param {JSXAttribute} node - JSXAttribute being tested.
|
||
|
* @returns {Boolean} result
|
||
|
*/
|
||
|
function tagNameHasDot(node) {
|
||
|
return !!(
|
||
|
node.parent &&
|
||
|
node.parent.name &&
|
||
|
node.parent.name.type === 'JSXMemberExpression'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the standard name of the attribute.
|
||
|
* @param {String} name - Name of the attribute.
|
||
|
* @param {String} context - eslint context
|
||
|
* @returns {String} The standard name of the attribute.
|
||
|
*/
|
||
|
function getStandardName(name, context) {
|
||
|
if (DOM_ATTRIBUTE_NAMES[name]) {
|
||
|
return DOM_ATTRIBUTE_NAMES[name];
|
||
|
}
|
||
|
if (SVGDOM_ATTRIBUTE_NAMES[name]) {
|
||
|
return SVGDOM_ATTRIBUTE_NAMES[name];
|
||
|
}
|
||
|
let i = -1;
|
||
|
const names = getDOMPropertyNames(context);
|
||
|
const found = names.some((element, index) => {
|
||
|
i = index;
|
||
|
return element.toLowerCase() === name;
|
||
|
});
|
||
|
return found ? names[i] : null;
|
||
|
}
|
||
|
|
||
|
// ------------------------------------------------------------------------------
|
||
|
// Rule Definition
|
||
|
// ------------------------------------------------------------------------------
|
||
|
|
||
|
module.exports = {
|
||
|
meta: {
|
||
|
docs: {
|
||
|
description: 'Prevent usage of unknown DOM property',
|
||
|
category: 'Possible Errors',
|
||
|
recommended: true,
|
||
|
url: docsUrl('no-unknown-property')
|
||
|
},
|
||
|
fixable: 'code',
|
||
|
|
||
|
schema: [{
|
||
|
type: 'object',
|
||
|
properties: {
|
||
|
ignore: {
|
||
|
type: 'array',
|
||
|
items: {
|
||
|
type: 'string'
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
additionalProperties: false
|
||
|
}]
|
||
|
},
|
||
|
|
||
|
create(context) {
|
||
|
function getIgnoreConfig() {
|
||
|
return context.options[0] && context.options[0].ignore || DEFAULTS.ignore;
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
JSXAttribute(node) {
|
||
|
const ignoreNames = getIgnoreConfig();
|
||
|
const name = context.getSourceCode().getText(node.name);
|
||
|
if (ignoreNames.indexOf(name) >= 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Ignore tags like <Foo.bar />
|
||
|
if (tagNameHasDot(node)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const tagName = getTagName(node);
|
||
|
const allowedTags = ATTRIBUTE_TAGS_MAP[name];
|
||
|
if (tagName && allowedTags && /[^A-Z]/.test(tagName.charAt(0)) && allowedTags.indexOf(tagName) === -1) {
|
||
|
context.report({
|
||
|
node,
|
||
|
message: WRONG_TAG_MESSAGE,
|
||
|
data: {
|
||
|
name,
|
||
|
tagName,
|
||
|
allowedTags: allowedTags.join(', ')
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const standardName = getStandardName(name, context);
|
||
|
if (!isTagName(node) || !standardName) {
|
||
|
return;
|
||
|
}
|
||
|
context.report({
|
||
|
node,
|
||
|
message: UNKNOWN_MESSAGE,
|
||
|
data: {
|
||
|
name,
|
||
|
standardName
|
||
|
},
|
||
|
fix(fixer) {
|
||
|
return fixer.replaceText(node.name, standardName);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
};
|