Skip to content

Commit 6ab705b

Browse files
authored
Add relative-url-style rule (#1672)
1 parent b054d65 commit 6ab705b

File tree

12 files changed

+369
-2
lines changed

12 files changed

+369
-2
lines changed

configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ module.exports = {
9696
'unicorn/prefer-top-level-await': 'off',
9797
'unicorn/prefer-type-error': 'error',
9898
'unicorn/prevent-abbreviations': 'error',
99+
'unicorn/relative-url-style': 'error',
99100
'unicorn/require-array-join-separator': 'error',
100101
'unicorn/require-number-to-fixed-digits-argument': 'error',
101102
// Turned off because we can't distinguish `widow.postMessage` and `{Worker,MessagePort,Client,BroadcastChannel}#postMessage()`

docs/rules/relative-url-style.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Enforce consistent relative URL style
2+
3+
*This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*
4+
5+
🔧 *This rule is [auto-fixable](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems).*
6+
7+
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.
8+
9+
## Fail
10+
11+
```js
12+
const url = new URL('./foo', base);
13+
```
14+
15+
## Pass
16+
17+
```js
18+
const url = new URL('foo', base);
19+
```
20+
21+
## Options
22+
23+
Type: `string`\
24+
Default: `'never'`
25+
26+
- `'never'` (default)
27+
- Never use a `./` prefix.
28+
- `'always'`
29+
- Always add a `./` prefix to the relative URL when possible.
30+
31+
```js
32+
// eslint unicorn/relative-url-style: ["error", "always"]
33+
const url = new URL('foo', base); // Fail
34+
const url = new URL('./foo', base); // Pass
35+
```

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Configure it in `package.json`.
127127
"unicorn/prefer-top-level-await": "off",
128128
"unicorn/prefer-type-error": "error",
129129
"unicorn/prevent-abbreviations": "error",
130+
"unicorn/relative-url-style": "error",
130131
"unicorn/require-array-join-separator": "error",
131132
"unicorn/require-number-to-fixed-digits-argument": "error",
132133
"unicorn/require-post-message-target-origin": "off",
@@ -242,6 +243,7 @@ Each rule has emojis denoting:
242243
| [prefer-top-level-await](docs/rules/prefer-top-level-await.md) | Prefer top-level await over top-level promises and async function calls. | | | 💡 |
243244
| [prefer-type-error](docs/rules/prefer-type-error.md) | Enforce throwing `TypeError` in type checking conditions. || 🔧 | |
244245
| [prevent-abbreviations](docs/rules/prevent-abbreviations.md) | Prevent abbreviations. || 🔧 | |
246+
| [relative-url-style](docs/rules/relative-url-style.md) | Enforce consistent relative URL style. || 🔧 | |
245247
| [require-array-join-separator](docs/rules/require-array-join-separator.md) | Enforce using the separator argument with `Array#join()`. || 🔧 | |
246248
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
247249
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. | | | 💡 |

rules/fix/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ module.exports = {
1818
replaceNodeOrTokenAndSpacesBefore: require('./replace-node-or-token-and-spaces-before.js'),
1919
removeSpacesAfter: require('./remove-spaces-after.js'),
2020
fixSpaceAroundKeyword: require('./fix-space-around-keywords.js'),
21+
replaceStringLiteral: require('./replace-string-literal.js'),
2122
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use strict';
2+
3+
function replaceStringLiteral(fixer, node, text, relativeRangeStart, relativeRangeEnd) {
4+
const firstCharacterIndex = node.range[0] + 1;
5+
const start = Number.isInteger(relativeRangeEnd) ? relativeRangeStart + firstCharacterIndex : firstCharacterIndex;
6+
const end = Number.isInteger(relativeRangeEnd) ? relativeRangeEnd + firstCharacterIndex : node.range[1] - 1;
7+
8+
return fixer.replaceTextRange([start, end], text);
9+
}
10+
11+
module.exports = replaceStringLiteral;

rules/prefer-node-protocol.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22
const isBuiltinModule = require('is-builtin-module');
33
const {matches, STATIC_REQUIRE_SOURCE_SELECTOR} = require('./selectors/index.js');
4+
const {replaceStringLiteral} = require('./fix/index.js');
45

56
const MESSAGE_ID = 'prefer-node-protocol';
67
const messages = {
@@ -35,13 +36,12 @@ const create = context => {
3536
return;
3637
}
3738

38-
const firstCharacterIndex = node.range[0] + 1;
3939
return {
4040
node,
4141
messageId: MESSAGE_ID,
4242
data: {moduleName: value},
4343
/** @param {import('eslint').Rule.RuleFixer} fixer */
44-
fix: fixer => fixer.insertTextBeforeRange([firstCharacterIndex, firstCharacterIndex], 'node:'),
44+
fix: fixer => replaceStringLiteral(fixer, node, 'node:', 0, 0),
4545
};
4646
},
4747
};

rules/relative-url-style.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use strict';
2+
const {newExpressionSelector} = require('./selectors/index.js');
3+
const {replaceStringLiteral} = require('./fix/index.js');
4+
5+
const MESSAGE_ID_NEVER = 'never';
6+
const MESSAGE_ID_ALWAYS = 'always';
7+
const messages = {
8+
[MESSAGE_ID_NEVER]: 'Remove the `./` prefix from the relative URL.',
9+
[MESSAGE_ID_ALWAYS]: 'Add a `./` prefix to the relative URL.',
10+
};
11+
12+
const selector = [
13+
newExpressionSelector({name: 'URL', argumentsLength: 2}),
14+
' > .arguments:first-child',
15+
].join('');
16+
17+
const DOT_SLASH = './';
18+
const TEST_URL_BASE = 'https://example.com/';
19+
const isSafeToAddDotSlash = url => {
20+
try {
21+
return new URL(url, TEST_URL_BASE).href === new URL(`${DOT_SLASH}${url}`, TEST_URL_BASE).href;
22+
} catch {}
23+
24+
return false;
25+
};
26+
27+
function removeDotSlash(node) {
28+
if (
29+
node.type === 'TemplateLiteral'
30+
&& node.quasis[0].value.raw.startsWith(DOT_SLASH)
31+
) {
32+
const firstPart = node.quasis[0];
33+
return fixer => {
34+
const start = firstPart.range[0] + 1;
35+
return fixer.removeRange([start, start + 2]);
36+
};
37+
}
38+
39+
if (node.type !== 'Literal' || typeof node.value !== 'string') {
40+
return;
41+
}
42+
43+
if (!node.raw.slice(1, -1).startsWith(DOT_SLASH)) {
44+
return;
45+
}
46+
47+
return fixer => replaceStringLiteral(fixer, node, '', 0, 2);
48+
}
49+
50+
function addDotSlash(node) {
51+
if (node.type !== 'Literal' || typeof node.value !== 'string') {
52+
return;
53+
}
54+
55+
const url = node.value;
56+
57+
if (url.startsWith(DOT_SLASH)) {
58+
return;
59+
}
60+
61+
if (
62+
url.startsWith('.')
63+
|| url.startsWith('/')
64+
|| !isSafeToAddDotSlash(url)
65+
) {
66+
return;
67+
}
68+
69+
return fixer => replaceStringLiteral(fixer, node, DOT_SLASH, 0, 0);
70+
}
71+
72+
/** @param {import('eslint').Rule.RuleContext} context */
73+
const create = context => {
74+
const style = context.options[0] || 'never';
75+
return {[selector](node) {
76+
const fix = (style === 'never' ? removeDotSlash : addDotSlash)(node);
77+
78+
if (!fix) {
79+
return;
80+
}
81+
82+
return {
83+
node,
84+
messageId: style,
85+
fix,
86+
};
87+
}};
88+
};
89+
90+
const schema = [
91+
{
92+
enum: ['never', 'always'],
93+
default: 'never',
94+
},
95+
];
96+
97+
/** @type {import('eslint').Rule.RuleModule} */
98+
module.exports = {
99+
create,
100+
meta: {
101+
type: 'suggestion',
102+
docs: {
103+
description: 'Enforce consistent relative URL style.',
104+
},
105+
fixable: 'code',
106+
schema,
107+
messages,
108+
},
109+
};

scripts/template/documentation.md.jst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# <%= docTitle %>
2+
3+
✅ *This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*
4+
25
<% if (fixableType) { %>
36
🔧 *This rule is [auto-fixable](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems).*
47
<% } %>

test/relative-url-style.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* eslint-disable no-template-curly-in-string */
2+
import {getTester} from './utils/test.mjs';
3+
4+
const {test} = getTester(import.meta);
5+
6+
test.snapshot({
7+
valid: [
8+
'URL("./foo", base)',
9+
'new URL(...["./foo"], base)',
10+
'new URL(["./foo"], base)',
11+
'new URL("./foo")',
12+
'new URL("./foo", base, extra)',
13+
'new URL("./foo", ...[base])',
14+
'new NOT_URL("./foo", base)',
15+
'new URL',
16+
// Not checking this case
17+
'new globalThis.URL("./foo", base)',
18+
'const foo = "./foo"; new URL(foo, base)',
19+
'const foo = "/foo"; new URL(`.${foo}`, base)',
20+
'new URL(`.${foo}`, base)',
21+
'new URL(".", base)',
22+
'new URL(".././foo", base)',
23+
// We don't check cooked value
24+
'new URL(`\\u002E/${foo}`, base)',
25+
// We don't check escaped string
26+
'new URL("\\u002E/foo", base)',
27+
'new URL(\'\\u002E/foo\', base)',
28+
],
29+
invalid: [
30+
'new URL("./foo", base)',
31+
'new URL(\'./foo\', base)',
32+
'new URL("./", base)',
33+
'new URL("././a", base)',
34+
'new URL(`./${foo}`, base)',
35+
],
36+
});
37+
38+
const alwaysAddDotSlashOptions = ['always'];
39+
test.snapshot({
40+
valid: [
41+
'URL("foo", base)',
42+
'new URL(...["foo"], base)',
43+
'new URL(["foo"], base)',
44+
'new URL("foo")',
45+
'new URL("foo", base, extra)',
46+
'new URL("foo", ...[base])',
47+
'new NOT_URL("foo", base)',
48+
'/* 2 */ new URL',
49+
// Not checking this case
50+
'new globalThis.URL("foo", base)',
51+
'new URL(`${foo}`, base2)',
52+
'new URL(`.${foo}`, base2)',
53+
'new URL(".", base2)',
54+
'new URL("//example.org", "https://example.com")',
55+
'new URL("//example.org", "ftp://example.com")',
56+
'new URL("ftp://example.org", "https://example.com")',
57+
'new URL("https://example.org:65536", "https://example.com")',
58+
'new URL("/", base)',
59+
'new URL("/foo", base)',
60+
'new URL("../foo", base)',
61+
'new URL(".././foo", base)',
62+
'new URL("C:\\foo", base)',
63+
'new URL("\\u002E/foo", base)',
64+
'new URL("\\u002Ffoo", base)',
65+
].map(code => ({code, options: alwaysAddDotSlashOptions})),
66+
invalid: [
67+
'new URL("foo", base)',
68+
'new URL(\'foo\', base)',
69+
].map(code => ({code, options: alwaysAddDotSlashOptions})),
70+
});

test/run-rules-on-codebase/lint.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ const eslint = new ESLint({
5353
'unicorn/prefer-top-level-await': 'off',
5454
'unicorn/prefer-object-has-own': 'off',
5555
'unicorn/prefer-at': 'off',
56+
// TODO: Turn this on when `xo` updated `eslint-plugin-unicorn`
57+
'unicorn/relative-url-style': 'off',
5658
},
5759
overrides: [
5860
{

0 commit comments

Comments
 (0)