diff --git a/lib/rules/no-invalid-html-attribute.js b/lib/rules/no-invalid-html-attribute.js index 4c1712f952..42096fee07 100644 --- a/lib/rules/no-invalid-html-attribute.js +++ b/lib/rules/no-invalid-html-attribute.js @@ -185,6 +185,103 @@ function checkAttribute(context, node) { } } +function isValidCreateElement(node) { + return node.callee + && node.callee.type === 'MemberExpression' + && node.callee.object.name === 'React' + && node.callee.property.name === 'createElement' + && node.arguments.length > 0; +} + +function checkPropValidValue(context, node, value, attribute) { + const validTags = VALID_VALUES.get(attribute); + + if (value.type !== 'Literal') { + return; // cannot check non-literals + } + + const validTagSet = validTags.get(value.value); + if (!validTagSet) { + return context.report({ + node: value, + message: `${value.raw} is never a valid "${attribute}" attribute value.` + }) + } + + if (!validTagSet.has(node.arguments[0].value)) { + return context.report({ + node: value, + message: `${value.raw} is not a valid value of "${attribute}" for a ${node.arguments[0].raw} element` + }) + } +} + +/** + * + * @param {*} context + * @param {*} node + * @param {string} attribute + */ +function checkCreateProps(context, node, attribute) { + if (node.arguments[0].type !== 'Literal') { + return; // can only check literals + } + + const propsArg = node.arguments[1]; + + if (!propsArg || propsArg.type !== 'ObjectExpression' + ) { + return; // can't check variables, computed, or shorthands + } + + for (const prop of propsArg.properties) { + if (prop.key.type !== 'Identifier') { + continue; // cannot check computed keys + } + + if (prop.key.name !== attribute) { + continue; // ignore not this attribute + } + + if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) { + const tagNames = Array.from( + COMPONENT_ATTRIBUTE_MAP.get(attribute).values(), + (tagName) => `"<${tagName}>"` + ).join(', '); + + context.report({ + node, + message: `The "${attribute}" attribute only has meaning on the tags: ${tagNames}` + }); + + continue; + } + + if (prop.method) { + context.report({ + node: prop, + message: `The "${attribute}" attribute cannot be a method.` + }); + + continue; + } + + if (prop.shorthand || prop.computed) { + continue; // cannot check these + } + + if (prop.value.type === 'ArrayExpression') { + for (const value of prop.value.elements) { + checkPropValidValue(context, node, value, attribute); + } + + continue; + } + + checkPropValidValue(context, node, prop.value, attribute); + } +} + module.exports = { meta: { fixable: 'code', @@ -213,6 +310,18 @@ module.exports = { } checkAttribute(context, node); + }, + + CallExpression(node) { + if (!isValidCreateElement(node)) { + return; + } + + const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES); + + for (const attribute of attributes) { + checkCreateProps(context, node, attribute); + } } }; } diff --git a/tests/lib/rules/no-invalid-html-attribute.js b/tests/lib/rules/no-invalid-html-attribute.js index a808d13ac6..1ee5b68417 100644 --- a/tests/lib/rules/no-invalid-html-attribute.js +++ b/tests/lib/rules/no-invalid-html-attribute.js @@ -29,62 +29,176 @@ const ruleTester = new RuleTester({parserOptions}); ruleTester.run('no-invalid-html-attribute', rule, { valid: [ {code: ''}, + {code: 'React.createElement("a", { rel: "alternate" })'}, + {code: 'React.createElement("a", { rel: ["alternate"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "author" })'}, + {code: 'React.createElement("a", { rel: ["author"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "bookmark" })'}, + {code: 'React.createElement("a", { rel: ["bookmark"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "external" })'}, + {code: 'React.createElement("a", { rel: ["external"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "help" })'}, + {code: 'React.createElement("a", { rel: ["help"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "license" })'}, + {code: 'React.createElement("a", { rel: ["license"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "next" })'}, + {code: 'React.createElement("a", { rel: ["next"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "nofollow" })'}, + {code: 'React.createElement("a", { rel: ["nofollow"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "noopener" })'}, + {code: 'React.createElement("a", { rel: ["noopener"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "noreferrer" })'}, + {code: 'React.createElement("a", { rel: ["noreferrer"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "opener" })'}, + {code: 'React.createElement("a", { rel: ["opener"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "prev" })'}, + {code: 'React.createElement("a", { rel: ["prev"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "search" })'}, + {code: 'React.createElement("a", { rel: ["search"] })'}, {code: ''}, + {code: 'React.createElement("a", { rel: "tag" })'}, + {code: 'React.createElement("a", { rel: ["tag"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "alternate" })'}, + {code: 'React.createElement("area", { rel: ["alternate"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "author" })'}, + {code: 'React.createElement("area", { rel: ["author"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "bookmark" })'}, + {code: 'React.createElement("area", { rel: ["bookmark"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "external" })'}, + {code: 'React.createElement("area", { rel: ["external"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "help" })'}, + {code: 'React.createElement("area", { rel: ["help"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "license" })'}, + {code: 'React.createElement("area", { rel: ["license"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "next" })'}, + {code: 'React.createElement("area", { rel: ["next"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "nofollow" })'}, + {code: 'React.createElement("area", { rel: ["nofollow"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "noopener" })'}, + {code: 'React.createElement("area", { rel: ["noopener"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "noreferrer" })'}, + {code: 'React.createElement("area", { rel: ["noreferrer"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "opener" })'}, + {code: 'React.createElement("area", { rel: ["opener"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "prev" })'}, + {code: 'React.createElement("area", { rel: ["prev"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "search" })'}, + {code: 'React.createElement("area", { rel: ["search"] })'}, {code: ''}, + {code: 'React.createElement("area", { rel: "tag" })'}, + {code: 'React.createElement("area", { rel: ["tag"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "alternate" })'}, + {code: 'React.createElement("link", { rel: ["alternate"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "author" })'}, + {code: 'React.createElement("link", { rel: ["author"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "canonical" })'}, + {code: 'React.createElement("link", { rel: ["canonical"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "dns-prefetch" })'}, + {code: 'React.createElement("link", { rel: ["dns-prefetch"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "help" })'}, + {code: 'React.createElement("link", { rel: ["help"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "icon" })'}, + {code: 'React.createElement("link", { rel: ["icon"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "license" })'}, + {code: 'React.createElement("link", { rel: ["license"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "manifest" })'}, + {code: 'React.createElement("link", { rel: ["manifest"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "modulepreload" })'}, + {code: 'React.createElement("link", { rel: ["modulepreload"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "next" })'}, + {code: 'React.createElement("link", { rel: ["next"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "pingback" })'}, + {code: 'React.createElement("link", { rel: ["pingback"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "preconnect" })'}, + {code: 'React.createElement("link", { rel: ["preconnect"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "prefetch" })'}, + {code: 'React.createElement("link", { rel: ["prefetch"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "preload" })'}, + {code: 'React.createElement("link", { rel: ["preload"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "prerender" })'}, + {code: 'React.createElement("link", { rel: ["prerender"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "prev" })'}, + {code: 'React.createElement("link", { rel: ["prev"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "search" })'}, + {code: 'React.createElement("link", { rel: ["search"] })'}, {code: ''}, + {code: 'React.createElement("link", { rel: "stylesheet" })'}, + {code: 'React.createElement("link", { rel: ["stylesheet"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "external" })'}, + {code: 'React.createElement("form", { rel: ["external"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "help" })'}, + {code: 'React.createElement("form", { rel: ["help"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "license" })'}, + {code: 'React.createElement("form", { rel: ["license"] })'}, {code: ''}, + {code: 'React.createElement("form", { rel: "next" })'}, + {code: 'React.createElement("form", { rel: ["next"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "nofollow" })'}, + {code: 'React.createElement("form", { rel: ["nofollow"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "noopener" })'}, + {code: 'React.createElement("form", { rel: ["noopener"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "noreferrer" })'}, + {code: 'React.createElement("form", { rel: ["noreferrer"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: "opener" })'}, + {code: 'React.createElement("form", { rel: ["opener"] })'}, {code: ''}, + {code: 'React.createElement("form", { rel: "prev" })'}, + {code: 'React.createElement("form", { rel: ["prev"] })'}, {code: ''}, + {code: 'React.createElement("form", { rel: "search" })'}, + {code: 'React.createElement("form", { rel: ["search"] })'}, {code: '
'}, + {code: 'React.createElement("form", { rel: callFoo() })'}, + {code: 'React.createElement("form", { rel: [callFoo()] })'}, {code: ''}, {code: ''} ], @@ -96,6 +210,12 @@ ruleTester.run('no-invalid-html-attribute', rule, { message: 'The "rel" attribute only has meaning on the tags: "", "", "", "
"' }] }, + { + code: 'React.createElement("html", { rel: 1 })', + errors: [{ + message: 'The "rel" attribute only has meaning on the tags: "", "", "", ""' + }] + }, { code: '', output: '', @@ -103,6 +223,12 @@ ruleTester.run('no-invalid-html-attribute', rule, { message: 'The "rel" attribute only has meaning on the tags: "", "", "", ""' }] }, + { + code: 'React.createElement("Foo", { rel: true })', + errors: [{ + message: 'The "rel" attribute only has meaning on the tags: "", "", "", ""' + }] + }, { code: '', output: '', @@ -110,6 +236,18 @@ ruleTester.run('no-invalid-html-attribute', rule, { message: 'An empty "rel" attribute is meaningless.' }] }, + { + code: 'React.createElement("a", { rel: 1 })', + errors: [{ + message: '1 is never a valid "rel" attribute value.' + }] + }, + { + code: 'React.createElement("a", { rel() { return 1; } })', + errors: [{ + message: 'The "rel" attribute cannot be a method.' + }] + }, { code: '', output: '', @@ -173,6 +311,12 @@ ruleTester.run('no-invalid-html-attribute', rule, { message: '"foobar" is never a valid "rel" attribute value.' }] }, + { + code: 'React.createElement("a", { rel: ["noreferrer", "noopener", "foobar" ] })', + errors: [{ + message: '"foobar" is never a valid "rel" attribute value.' + }] + }, { code: '', output: '',