diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd7d07507..0aa9588e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange * [`jsx-wrap-multilines`]: add `never` option to prohibit wrapping parens on multiline JSX ([#3668][] @reedws) * [`jsx-filename-extension`]: add `ignoreFilesWithoutCode` option to allow empty files ([#3674][] @burtek) * [`jsx-boolean-value`]: add `assumeUndefinedIsFalse` option ([#3675][] @developer-bandi) +* `linkAttribute` setting, [`jsx-no-target-blank`]: support multiple properties ([#3673][] @burtek) +* [`jsx-no-script-url`]: add `includeFromSettings` option to support `linkAttributes` setting ([#3673][] @burtek) ### Fixed * [`jsx-no-leaked-render`]: preserve RHS parens for multiline jsx elements while fixing ([#3623][] @akulsr0) @@ -32,6 +34,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange [#3675]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3675 [#3674]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3674 +[#3673]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3673 [#3668]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3668 [#3666]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3666 [#3662]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3662 diff --git a/README.md b/README.md index 0cda1109c3..9c67ff12b5 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,14 @@ You should also specify settings that will be shared across all the plugin rules "formComponents": [ // Components used as alternatives to
for forms, eg. "CustomForm", - {"name": "Form", "formAttribute": "endpoint"} + {"name": "SimpleForm", "formAttribute": "endpoint"}, + {"name": "Form", "formAttribute": ["registerEndpoint", "loginEndpoint"]}, // allows specifying multiple properties if necessary ], "linkComponents": [ // Components used as alternatives to for linking, eg. "Hyperlink", - {"name": "Link", "linkAttribute": "to"} + {"name": "MyLink", "linkAttribute": "to"}, + {"name": "Link", "linkAttribute": ["to", "href"]}, // allows specifying multiple properties if necessary ] } } diff --git a/docs/rules/jsx-no-script-url.md b/docs/rules/jsx-no-script-url.md index 8d4f7c8cd4..11fffdad16 100644 --- a/docs/rules/jsx-no-script-url.md +++ b/docs/rules/jsx-no-script-url.md @@ -23,8 +23,14 @@ Examples of **correct** code for this rule: ``` +This rule takes the `linkComponents` setting into account. + ## Rule Options +This rule accepts array option (optional) and object option (optional). + +### Array option (default `[]`) + ```json { "react/jsx-no-script-url": [ @@ -45,11 +51,11 @@ Examples of **correct** code for this rule: Allows you to indicate a specific list of properties used by a custom component to be checked. -### name +#### name Component name. -### props +#### props List of properties that should be validated. @@ -60,3 +66,37 @@ Examples of **incorrect** code for this rule, when configured with the above opt ``` + +### Object option + +#### includeFromSettings (default `false`) + +Indicates if the `linkComponents` config in [global shared settings](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/README.md#configuration) should also be taken into account. If enabled, components and properties defined in settings will be added to the list provided in first option (if provided): + +```json +{ + "react/jsx-no-script-url": [ + "error", + [ + { + "name": "Link", + "props": ["to"] + }, + { + "name": "Foo", + "props": ["href", "to"] + } + ], + { "includeFromSettings": true } + ] +} +``` + +If only global settings should be used for this rule, the array option can be omitted: + +```jsonc +{ + // same as ["error", [], { "includeFromSettings": true }] + "react/jsx-no-script-url": ["error", { "includeFromSettings": true }] +} +``` diff --git a/lib/rules/jsx-no-script-url.js b/lib/rules/jsx-no-script-url.js index 425741cf1d..bcf9468a24 100644 --- a/lib/rules/jsx-no-script-url.js +++ b/lib/rules/jsx-no-script-url.js @@ -5,7 +5,9 @@ 'use strict'; +const includes = require('array-includes'); const docsUrl = require('../util/docsUrl'); +const linkComponentsUtil = require('../util/linkComponents'); const report = require('../util/report'); // ------------------------------------------------------------------------------ @@ -21,26 +23,20 @@ function hasJavaScriptProtocol(attr) { && isJavaScriptProtocol.test(attr.value.value); } -function shouldVerifyElement(node, config) { - const name = node.name && node.name.name; - return name === 'a' || config.find((i) => i.name === name); -} - function shouldVerifyProp(node, config) { const name = node.name && node.name.name; const parentName = node.parent.name && node.parent.name.name; - if (parentName === 'a' && name === 'href') { - return true; - } + if (!name || !parentName || !config.has(parentName)) return false; - const el = config.find((i) => i.name === parentName); - if (!el) { - return false; - } + const attributes = config.get(parentName); + return includes(attributes, name); +} - const props = el.props || []; - return node.name && props.indexOf(name) !== -1; +function parseLegacyOption(config, option) { + option.forEach((opt) => { + config.set(opt.name, opt.props); + }); } const messages = { @@ -58,35 +54,84 @@ module.exports = { messages, - schema: [{ - type: 'array', - uniqueItems: true, - items: { - type: 'object', - properties: { - name: { - type: 'string', - }, - props: { - type: 'array', - items: { - type: 'string', + schema: { + anyOf: [ + { + type: 'array', + items: [ + { + type: 'array', uniqueItems: true, + items: { + type: 'object', + properties: { + name: { + type: 'string', + }, + props: { + type: 'array', + items: { + type: 'string', + uniqueItems: true, + }, + }, + }, + required: ['name', 'props'], + additionalProperties: false, + }, + }, + { + type: 'object', + properties: { + includeFromSettings: { + type: 'boolean', + }, + }, + additionalItems: false, }, - }, + ], + additionalItems: false, }, - required: ['name', 'props'], - additionalProperties: false, - }, - }], + { + type: 'array', + items: [ + { + type: 'object', + properties: { + includeFromSettings: { + type: 'boolean', + }, + }, + additionalItems: false, + }, + ], + additionalItems: false, + }, + ], + }, }, create(context) { - const config = context.options[0] || []; + const options = context.options; + const hasLegacyOption = Array.isArray(options[0]); + const legacyOptions = hasLegacyOption ? options[0] : []; + // eslint-disable-next-line no-nested-ternary + const objectOption = (hasLegacyOption && options.length > 1) + ? options[1] + : (options.length > 0 + ? options[0] + : { + includeFromSettings: false, + } + ); + const includeFromSettings = objectOption.includeFromSettings; + + const linkComponents = linkComponentsUtil.getLinkComponents(includeFromSettings ? context : {}); + parseLegacyOption(linkComponents, legacyOptions); + return { JSXAttribute(node) { - const parent = node.parent; - if (shouldVerifyElement(parent, config) && shouldVerifyProp(node, config) && hasJavaScriptProtocol(node)) { + if (shouldVerifyProp(node, linkComponents) && hasJavaScriptProtocol(node)) { report(context, messages.noScriptURL, 'noScriptURL', { node, }); diff --git a/lib/rules/jsx-no-target-blank.js b/lib/rules/jsx-no-target-blank.js index 9b77e37f4c..795de8a70f 100644 --- a/lib/rules/jsx-no-target-blank.js +++ b/lib/rules/jsx-no-target-blank.js @@ -5,6 +5,7 @@ 'use strict'; +const includes = require('array-includes'); const docsUrl = require('../util/docsUrl'); const linkComponentsUtil = require('../util/linkComponents'); const report = require('../util/report'); @@ -48,16 +49,16 @@ function attributeValuePossiblyBlank(attribute) { return false; } -function hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) { - const linkIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === linkAttribute); +function hasExternalLink(node, linkAttributes, warnOnSpreadAttributes, spreadAttributeIndex) { + const linkIndex = findLastIndex(node.attributes, (attr) => attr.name && includes(linkAttributes, attr.name.name)); const foundExternalLink = linkIndex !== -1 && ((attr) => attr.value && attr.value.type === 'Literal' && /^(?:\w+:|\/\/)/.test(attr.value.value))( node.attributes[linkIndex]); return foundExternalLink || (warnOnSpreadAttributes && linkIndex < spreadAttributeIndex); } -function hasDynamicLink(node, linkAttribute) { +function hasDynamicLink(node, linkAttributes) { const dynamicLinkIndex = findLastIndex(node.attributes, (attr) => attr.name - && attr.name.name === linkAttribute + && includes(linkAttributes, attr.name.name) && attr.value && attr.value.type === 'JSXExpressionContainer'); if (dynamicLinkIndex !== -1) { @@ -194,9 +195,9 @@ module.exports = { } } - const linkAttribute = linkComponents.get(node.name.name); - const hasDangerousLink = hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) - || (enforceDynamicLinks === 'always' && hasDynamicLink(node, linkAttribute)); + const linkAttributes = linkComponents.get(node.name.name); + const hasDangerousLink = hasExternalLink(node, linkAttributes, warnOnSpreadAttributes, spreadAttributeIndex) + || (enforceDynamicLinks === 'always' && hasDynamicLink(node, linkAttributes)); if (hasDangerousLink && !hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex)) { const messageId = allowReferrer ? 'noTargetBlankWithoutNoopener' : 'noTargetBlankWithoutNoreferrer'; const relValue = allowReferrer ? 'noopener' : 'noreferrer'; @@ -265,11 +266,11 @@ module.exports = { return; } - const formAttribute = formComponents.get(node.name.name); + const formAttributes = formComponents.get(node.name.name); if ( - hasExternalLink(node, formAttribute) - || (enforceDynamicLinks === 'always' && hasDynamicLink(node, formAttribute)) + hasExternalLink(node, formAttributes) + || (enforceDynamicLinks === 'always' && hasDynamicLink(node, formAttributes)) ) { const messageId = allowReferrer ? 'noTargetBlankWithoutNoopener' : 'noTargetBlankWithoutNoreferrer'; report(context, messages[messageId], messageId, { diff --git a/lib/util/linkComponents.js b/lib/util/linkComponents.js index 46d9e0e27c..181ed377b8 100644 --- a/lib/util/linkComponents.js +++ b/lib/util/linkComponents.js @@ -24,9 +24,9 @@ function getFormComponents(context) { ); return new Map(map(iterFrom(formComponents), (value) => { if (typeof value === 'string') { - return [value, DEFAULT_FORM_ATTRIBUTE]; + return [value, [DEFAULT_FORM_ATTRIBUTE]]; } - return [value.name, value.formAttribute]; + return [value.name, [].concat(value.formAttribute)]; })); } @@ -37,9 +37,9 @@ function getLinkComponents(context) { ); return new Map(map(iterFrom(linkComponents), (value) => { if (typeof value === 'string') { - return [value, DEFAULT_LINK_ATTRIBUTE]; + return [value, [DEFAULT_LINK_ATTRIBUTE]]; } - return [value.name, value.linkAttribute]; + return [value.name, [].concat(value.linkAttribute)]; })); } diff --git a/tests/lib/rules/jsx-no-script-url.js b/tests/lib/rules/jsx-no-script-url.js index 24cff790a2..4d0374e08e 100644 --- a/tests/lib/rules/jsx-no-script-url.js +++ b/tests/lib/rules/jsx-no-script-url.js @@ -38,8 +38,22 @@ ruleTester.run('jsx-no-script-url', rule, { { code: '' }, { code: '' }, { code: '' }, + { + code: '', + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: ['to', 'href'] }], + }, + }, + { + code: '', + options: [[], { includeFromSettings: false }], + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: ['to', 'href'] }], + }, + }, ]), invalid: parsers.all([ + // defaults { code: '', errors: [{ messageId: 'noScriptURL' }], @@ -52,6 +66,8 @@ ruleTester.run('jsx-no-script-url', rule, { code: '', errors: [{ messageId: 'noScriptURL' }], }, + + // with component passed by options { code: '', errors: [{ messageId: 'noScriptURL' }], @@ -66,6 +82,34 @@ ruleTester.run('jsx-no-script-url', rule, { [{ name: 'Foo', props: ['to', 'href'] }], ], }, + { // make sure it still uses defaults when passed options + code: '', + errors: [{ messageId: 'noScriptURL' }], + options: [ + [{ name: 'Foo', props: ['to', 'href'] }], + ], + }, + + // with components passed by settings + { + code: '', + errors: [{ messageId: 'noScriptURL' }], + options: [ + [{ name: 'Bar', props: ['to', 'href'] }], + { includeFromSettings: true }, + ], + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: 'to' }], + }, + }, + { + code: '', + errors: [{ messageId: 'noScriptURL' }], + options: [{ includeFromSettings: true }], + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: ['to', 'href'] }], + }, + }, { code: `
@@ -78,11 +122,29 @@ ruleTester.run('jsx-no-script-url', rule, { { messageId: 'noScriptURL' }, ], options: [ - [ - { name: 'Foo', props: ['to', 'href'] }, - { name: 'Bar', props: ['link'] }, - ], + [{ name: 'Bar', props: ['link'] }], + { includeFromSettings: true }, + ], + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: ['to', 'href'] }], + }, + }, + { + code: ` +
+ + +
+ `, + errors: [ + { messageId: 'noScriptURL' }, + ], + options: [ + [{ name: 'Bar', props: ['link'] }], ], + settings: { + linkComponents: [{ name: 'Foo', linkAttribute: ['to', 'href'] }], + }, }, ]), }); diff --git a/tests/lib/rules/jsx-no-target-blank.js b/tests/lib/rules/jsx-no-target-blank.js index f5ab6b8793..591a1a42ee 100644 --- a/tests/lib/rules/jsx-no-target-blank.js +++ b/tests/lib/rules/jsx-no-target-blank.js @@ -102,6 +102,11 @@ ruleTester.run('jsx-no-target-blank', rule, { options: [{ enforceDynamicLinks: 'never' }], settings: { linkComponents: { name: 'Link', linkAttribute: 'to' } }, }, + { + code: '', + options: [{ enforceDynamicLinks: 'never' }], + settings: { linkComponents: { name: 'Link', linkAttribute: ['to'] } }, + }, { code: '', options: [{ allowReferrer: true }], @@ -167,6 +172,14 @@ ruleTester.run('jsx-no-target-blank', rule, { { code: '', }, + { + code: '', + options: [{ forms: true }], + }, + { + code: '', + options: [{ forms: true }], + }, ]), invalid: parsers.all([ { @@ -407,5 +420,20 @@ ruleTester.run('jsx-no-target-blank', rule, { options: [{ allowReferrer: true }], errors: allowReferrerErrors, }, + { + code: '', + options: [{ allowReferrer: true, forms: true }], + errors: allowReferrerErrors, + }, + { + code: '', + options: [{ forms: true }], + errors: defaultErrors, + }, + { + code: '', + options: [{ forms: true, warnOnSpreadAttributes: true }], + errors: defaultErrors, + }, ]), }); diff --git a/tests/util/linkComponents.js b/tests/util/linkComponents.js index 09394def48..741df2610e 100644 --- a/tests/util/linkComponents.js +++ b/tests/util/linkComponents.js @@ -8,7 +8,7 @@ describe('linkComponentsFunctions', () => { it('returns a default map of components', () => { const context = {}; assert.deepStrictEqual(linkComponentsUtil.getLinkComponents(context), new Map([ - ['a', 'href'], + ['a', ['href']], ])); }); @@ -19,6 +19,10 @@ describe('linkComponentsFunctions', () => { name: 'Link', linkAttribute: 'to', }, + { + name: 'Link2', + linkAttribute: ['to1', 'to2'], + }, ]; const context = { settings: { @@ -26,9 +30,44 @@ describe('linkComponentsFunctions', () => { }, }; assert.deepStrictEqual(linkComponentsUtil.getLinkComponents(context), new Map([ - ['a', 'href'], - ['Hyperlink', 'href'], - ['Link', 'to'], + ['a', ['href']], + ['Hyperlink', ['href']], + ['Link', ['to']], + ['Link2', ['to1', 'to2']], + ])); + }); + }); + + describe('getFormComponents', () => { + it('returns a default map of components', () => { + const context = {}; + assert.deepStrictEqual(linkComponentsUtil.getFormComponents(context), new Map([ + ['form', ['action']], + ])); + }); + + it('returns a map of components', () => { + const formComponents = [ + 'Form', + { + name: 'MyForm', + formAttribute: 'endpoint', + }, + { + name: 'MyForm2', + formAttribute: ['endpoint1', 'endpoint2'], + }, + ]; + const context = { + settings: { + formComponents, + }, + }; + assert.deepStrictEqual(linkComponentsUtil.getFormComponents(context), new Map([ + ['form', ['action']], + ['Form', ['action']], + ['MyForm', ['endpoint']], + ['MyForm2', ['endpoint1', 'endpoint2']], ])); }); });