Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[jsx-no-script-url]: use shared settings to specify link components #3673

Merged
merged 2 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,14 @@ You should also specify settings that will be shared across all the plugin rules
"formComponents": [
// Components used as alternatives to <form> for forms, eg. <Form endpoint={ url } />
"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 <a> for linking, eg. <Link to={ url } />
"Hyperlink",
{"name": "Link", "linkAttribute": "to"}
{"name": "MyLink", "linkAttribute": "to"},
{"name": "Link", "linkAttribute": ["to", "href"]}, // allows specifying multiple properties if necessary
]
}
}
Expand Down
44 changes: 42 additions & 2 deletions docs/rules/jsx-no-script-url.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ Examples of **correct** code for this rule:
<a href={"javascript:"}></a>
```

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": [
Expand All @@ -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.

Expand All @@ -60,3 +66,37 @@ Examples of **incorrect** code for this rule, when configured with the above opt
<Foo href="javascript:void(0)"></Foo>
<Foo to="javascript:void(0)"></Foo>
```

### 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 }]
}
```
115 changes: 80 additions & 35 deletions lib/rules/jsx-no-script-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

// ------------------------------------------------------------------------------
Expand All @@ -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 = {
Expand All @@ -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,
});
Expand Down
21 changes: 11 additions & 10 deletions lib/rules/jsx-no-target-blank.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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))
ljharb marked this conversation as resolved.
Show resolved Hide resolved
) {
const messageId = allowReferrer ? 'noTargetBlankWithoutNoopener' : 'noTargetBlankWithoutNoreferrer';
report(context, messages[messageId], messageId, {
Expand Down
8 changes: 4 additions & 4 deletions lib/util/linkComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
}));
}

Expand All @@ -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)];
}));
}

Expand Down