Skip to content

Commit

Permalink
Add relative-url-style rule (#1672)
Browse files Browse the repository at this point in the history
  • Loading branch information
fisker committed Dec 31, 2021
1 parent b054d65 commit 6ab705b
Show file tree
Hide file tree
Showing 12 changed files with 369 additions and 2 deletions.
1 change: 1 addition & 0 deletions configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ module.exports = {
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-type-error': 'error',
'unicorn/prevent-abbreviations': 'error',
'unicorn/relative-url-style': 'error',
'unicorn/require-array-join-separator': 'error',
'unicorn/require-number-to-fixed-digits-argument': 'error',
// Turned off because we can't distinguish `widow.postMessage` and `{Worker,MessagePort,Client,BroadcastChannel}#postMessage()`
Expand Down
35 changes: 35 additions & 0 deletions docs/rules/relative-url-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Enforce consistent relative URL style

*This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*

🔧 *This rule is [auto-fixable](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems).*

When using a relative URL in [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL), the URL should either never or always use the `./` prefix consistently.

## Fail

```js
const url = new URL('./foo', base);
```

## Pass

```js
const url = new URL('foo', base);
```

## Options

Type: `string`\
Default: `'never'`

- `'never'` (default)
- Never use a `./` prefix.
- `'always'`
- Always add a `./` prefix to the relative URL when possible.

```js
// eslint unicorn/relative-url-style: ["error", "always"]
const url = new URL('foo', base); // Fail
const url = new URL('./foo', base); // Pass
```
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ Configure it in `package.json`.
"unicorn/prefer-top-level-await": "off",
"unicorn/prefer-type-error": "error",
"unicorn/prevent-abbreviations": "error",
"unicorn/relative-url-style": "error",
"unicorn/require-array-join-separator": "error",
"unicorn/require-number-to-fixed-digits-argument": "error",
"unicorn/require-post-message-target-origin": "off",
Expand Down Expand Up @@ -242,6 +243,7 @@ Each rule has emojis denoting:
| [prefer-top-level-await](docs/rules/prefer-top-level-await.md) | Prefer top-level await over top-level promises and async function calls. | | | 💡 |
| [prefer-type-error](docs/rules/prefer-type-error.md) | Enforce throwing `TypeError` in type checking conditions. || 🔧 | |
| [prevent-abbreviations](docs/rules/prevent-abbreviations.md) | Prevent abbreviations. || 🔧 | |
| [relative-url-style](docs/rules/relative-url-style.md) | Enforce consistent relative URL style. || 🔧 | |
| [require-array-join-separator](docs/rules/require-array-join-separator.md) | Enforce using the separator argument with `Array#join()`. || 🔧 | |
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. | | | 💡 |
Expand Down
1 change: 1 addition & 0 deletions rules/fix/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ module.exports = {
replaceNodeOrTokenAndSpacesBefore: require('./replace-node-or-token-and-spaces-before.js'),
removeSpacesAfter: require('./remove-spaces-after.js'),
fixSpaceAroundKeyword: require('./fix-space-around-keywords.js'),
replaceStringLiteral: require('./replace-string-literal.js'),
};
11 changes: 11 additions & 0 deletions rules/fix/replace-string-literal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict';

function replaceStringLiteral(fixer, node, text, relativeRangeStart, relativeRangeEnd) {
const firstCharacterIndex = node.range[0] + 1;
const start = Number.isInteger(relativeRangeEnd) ? relativeRangeStart + firstCharacterIndex : firstCharacterIndex;
const end = Number.isInteger(relativeRangeEnd) ? relativeRangeEnd + firstCharacterIndex : node.range[1] - 1;

return fixer.replaceTextRange([start, end], text);
}

module.exports = replaceStringLiteral;
4 changes: 2 additions & 2 deletions rules/prefer-node-protocol.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';
const isBuiltinModule = require('is-builtin-module');
const {matches, STATIC_REQUIRE_SOURCE_SELECTOR} = require('./selectors/index.js');
const {replaceStringLiteral} = require('./fix/index.js');

const MESSAGE_ID = 'prefer-node-protocol';
const messages = {
Expand Down Expand Up @@ -35,13 +36,12 @@ const create = context => {
return;
}

const firstCharacterIndex = node.range[0] + 1;
return {
node,
messageId: MESSAGE_ID,
data: {moduleName: value},
/** @param {import('eslint').Rule.RuleFixer} fixer */
fix: fixer => fixer.insertTextBeforeRange([firstCharacterIndex, firstCharacterIndex], 'node:'),
fix: fixer => replaceStringLiteral(fixer, node, 'node:', 0, 0),
};
},
};
Expand Down
109 changes: 109 additions & 0 deletions rules/relative-url-style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use strict';
const {newExpressionSelector} = require('./selectors/index.js');
const {replaceStringLiteral} = require('./fix/index.js');

const MESSAGE_ID_NEVER = 'never';
const MESSAGE_ID_ALWAYS = 'always';
const messages = {
[MESSAGE_ID_NEVER]: 'Remove the `./` prefix from the relative URL.',
[MESSAGE_ID_ALWAYS]: 'Add a `./` prefix to the relative URL.',
};

const selector = [
newExpressionSelector({name: 'URL', argumentsLength: 2}),
' > .arguments:first-child',
].join('');

const DOT_SLASH = './';
const TEST_URL_BASE = 'https://example.com/';
const isSafeToAddDotSlash = url => {
try {
return new URL(url, TEST_URL_BASE).href === new URL(`${DOT_SLASH}${url}`, TEST_URL_BASE).href;
} catch {}

return false;
};

function removeDotSlash(node) {
if (
node.type === 'TemplateLiteral'
&& node.quasis[0].value.raw.startsWith(DOT_SLASH)
) {
const firstPart = node.quasis[0];
return fixer => {
const start = firstPart.range[0] + 1;
return fixer.removeRange([start, start + 2]);
};
}

if (node.type !== 'Literal' || typeof node.value !== 'string') {
return;
}

if (!node.raw.slice(1, -1).startsWith(DOT_SLASH)) {
return;
}

return fixer => replaceStringLiteral(fixer, node, '', 0, 2);
}

function addDotSlash(node) {
if (node.type !== 'Literal' || typeof node.value !== 'string') {
return;
}

const url = node.value;

if (url.startsWith(DOT_SLASH)) {
return;
}

if (
url.startsWith('.')
|| url.startsWith('/')
|| !isSafeToAddDotSlash(url)
) {
return;
}

return fixer => replaceStringLiteral(fixer, node, DOT_SLASH, 0, 0);
}

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const style = context.options[0] || 'never';
return {[selector](node) {
const fix = (style === 'never' ? removeDotSlash : addDotSlash)(node);

if (!fix) {
return;
}

return {
node,
messageId: style,
fix,
};
}};
};

const schema = [
{
enum: ['never', 'always'],
default: 'never',
},
];

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce consistent relative URL style.',
},
fixable: 'code',
schema,
messages,
},
};
3 changes: 3 additions & 0 deletions scripts/template/documentation.md.jst
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# <%= docTitle %>

✅ *This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*

<% if (fixableType) { %>
🔧 *This rule is [auto-fixable](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems).*
<% } %>
Expand Down
70 changes: 70 additions & 0 deletions test/relative-url-style.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/* eslint-disable no-template-curly-in-string */
import {getTester} from './utils/test.mjs';

const {test} = getTester(import.meta);

test.snapshot({
valid: [
'URL("./foo", base)',
'new URL(...["./foo"], base)',
'new URL(["./foo"], base)',
'new URL("./foo")',
'new URL("./foo", base, extra)',
'new URL("./foo", ...[base])',
'new NOT_URL("./foo", base)',
'new URL',
// Not checking this case
'new globalThis.URL("./foo", base)',
'const foo = "./foo"; new URL(foo, base)',
'const foo = "/foo"; new URL(`.${foo}`, base)',
'new URL(`.${foo}`, base)',
'new URL(".", base)',
'new URL(".././foo", base)',
// We don't check cooked value
'new URL(`\\u002E/${foo}`, base)',
// We don't check escaped string
'new URL("\\u002E/foo", base)',
'new URL(\'\\u002E/foo\', base)',
],
invalid: [
'new URL("./foo", base)',
'new URL(\'./foo\', base)',
'new URL("./", base)',
'new URL("././a", base)',
'new URL(`./${foo}`, base)',
],
});

const alwaysAddDotSlashOptions = ['always'];
test.snapshot({
valid: [
'URL("foo", base)',
'new URL(...["foo"], base)',
'new URL(["foo"], base)',
'new URL("foo")',
'new URL("foo", base, extra)',
'new URL("foo", ...[base])',
'new NOT_URL("foo", base)',
'/* 2 */ new URL',
// Not checking this case
'new globalThis.URL("foo", base)',
'new URL(`${foo}`, base2)',
'new URL(`.${foo}`, base2)',
'new URL(".", base2)',
'new URL("//example.org", "https://example.com")',
'new URL("//example.org", "ftp://example.com")',
'new URL("ftp://example.org", "https://example.com")',
'new URL("https://example.org:65536", "https://example.com")',
'new URL("/", base)',
'new URL("/foo", base)',
'new URL("../foo", base)',
'new URL(".././foo", base)',
'new URL("C:\\foo", base)',
'new URL("\\u002E/foo", base)',
'new URL("\\u002Ffoo", base)',
].map(code => ({code, options: alwaysAddDotSlashOptions})),
invalid: [
'new URL("foo", base)',
'new URL(\'foo\', base)',
].map(code => ({code, options: alwaysAddDotSlashOptions})),
});
2 changes: 2 additions & 0 deletions test/run-rules-on-codebase/lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const eslint = new ESLint({
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-object-has-own': 'off',
'unicorn/prefer-at': 'off',
// TODO: Turn this on when `xo` updated `eslint-plugin-unicorn`
'unicorn/relative-url-style': 'off',
},
overrides: [
{
Expand Down

0 comments on commit 6ab705b

Please sign in to comment.