Skip to content

Commit 5149927

Browse files
authored
feat: add no-bare-urls rule (#418)
* feat: add no-bare-urls rule * docs: add more examples * code review
1 parent 2435390 commit 5149927

File tree

4 files changed

+588
-0
lines changed

4 files changed

+588
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export default defineConfig([
8989
| :- | :- | :-: |
9090
| [`fenced-code-language`](./docs/rules/fenced-code-language.md) | Require languages for fenced code blocks | yes |
9191
| [`heading-increment`](./docs/rules/heading-increment.md) | Enforce heading levels increment by one | yes |
92+
| [`no-bare-urls`](./docs/rules/no-bare-urls.md) | Disallow bare URLs | no |
9293
| [`no-duplicate-definitions`](./docs/rules/no-duplicate-definitions.md) | Disallow duplicate definitions | yes |
9394
| [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document | no |
9495
| [`no-empty-definitions`](./docs/rules/no-empty-definitions.md) | Disallow empty definitions | yes |

docs/rules/no-bare-urls.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# no-bare-urls
2+
3+
Disallow bare URLs.
4+
5+
## Background
6+
7+
In Markdown, URLs are typically formatted in two ways:
8+
9+
1. Autolinks: URLs wrapped in angle brackets, such as `<https://example.com/>`
10+
2. Links: URLs with descriptive text, such as `[Visit our website](https://example.com/)`
11+
12+
A bare URL appears without angle brackets (`<>`), such as `https://example.com/`. While GitHub Flavored Markdown (GFM) allows bare URLs to be recognized as clickable links, this feature is not supported in all Markdown processors. To ensure compatibility across different processors, use autolinks or links instead.
13+
14+
## Rule Details
15+
16+
> [!IMPORTANT] <!-- eslint-disable-line -- This should be fixed in https://github.com/eslint/markdown/issues/294 -->
17+
>
18+
> This rule requires `language: "markdown/gfm"`.
19+
20+
This rule flags bare URLs that should be formatted as autolinks or links.
21+
22+
Examples of **incorrect** code for this rule:
23+
24+
```markdown
25+
<!-- eslint markdown/no-bare-urls: "error" -->
26+
27+
For more info, visit https://www.example.com/
28+
29+
Contact us at [email protected]
30+
31+
# https://www.example.com/
32+
33+
[Read the [docs]](https://www.example.com/)
34+
35+
[docs]: https://www.example.com/docs
36+
```
37+
38+
Examples of **correct** code for this rule:
39+
40+
```markdown
41+
<!-- eslint markdown/no-bare-urls: "error" -->
42+
43+
For more info, visit <https://www.example.com/>
44+
45+
For more info, visit [Example Website](https://www.example.com/)
46+
47+
Contact us at <[email protected]>
48+
49+
Contact us at [[email protected]](mailto:[email protected])
50+
51+
# <https://www.example.com/>
52+
53+
Not a clickable link: `https://www.example.com/`
54+
55+
[https://www.example.com/]
56+
57+
[Read the \[docs\]](https://www.example.com/)
58+
```
59+
60+
## When Not to Use It
61+
62+
If you're working in an environment where GFM autolink literals are fully supported and you prefer their simplicity, you can safely disable this rule.
63+
64+
## Prior Art
65+
66+
* [remark-lint-no-literal-urls](https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-no-literal-urls)
67+
* [MD034 - Bare URL used](https://github.com/DavidAnson/markdownlint/blob/main/doc/md034.md)

src/rules/no-bare-urls.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* @fileoverview Rule to prevent bare URLs in Markdown.
3+
* @author xbinaryx
4+
*/
5+
6+
//-----------------------------------------------------------------------------
7+
// Type Definitions
8+
//-----------------------------------------------------------------------------
9+
10+
/** @typedef {import("mdast").Node} Node */
11+
/** @typedef {import("mdast").Paragraph} ParagraphNode */
12+
/** @typedef {import("mdast").Heading} HeadingNode */
13+
/** @typedef {import("mdast").TableCell} TableCellNode */
14+
/** @typedef {import("mdast").Link} LinkNode */
15+
/**
16+
* @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
17+
* NoBareUrlsRuleDefinition
18+
*/
19+
20+
//-----------------------------------------------------------------------------
21+
// Helpers
22+
//-----------------------------------------------------------------------------
23+
24+
const htmlTagNamePattern = /^<([^!>][^/\s>]*)/u;
25+
26+
/**
27+
* Parses an HTML tag to extract its name and closing status
28+
* @param {string} tagText The HTML tag text to parse
29+
* @returns {{ name: string; isClosing: boolean; } | null} Object containing tag name and closing status, or null if not a valid tag
30+
*/
31+
function parseHtmlTag(tagText) {
32+
const match = tagText.match(htmlTagNamePattern);
33+
if (match) {
34+
const tagName = match[1].toLowerCase();
35+
const isClosing = tagName.startsWith("/");
36+
37+
return {
38+
name: isClosing ? tagName.slice(1) : tagName,
39+
isClosing,
40+
};
41+
}
42+
43+
return null;
44+
}
45+
46+
//-----------------------------------------------------------------------------
47+
// Rule Definition
48+
//-----------------------------------------------------------------------------
49+
50+
/** @type {NoBareUrlsRuleDefinition} */
51+
export default {
52+
meta: {
53+
type: "problem",
54+
55+
docs: {
56+
description: "Disallow bare URLs",
57+
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-bare-urls.md",
58+
},
59+
60+
fixable: "code",
61+
62+
messages: {
63+
bareUrl:
64+
"Unexpected bare URL. Use autolink (<URL>) or link ([text](URL)) instead.",
65+
},
66+
},
67+
68+
create(context) {
69+
const { sourceCode } = context;
70+
/** @type {Array<LinkNode>} */
71+
const bareUrls = [];
72+
73+
/**
74+
* Finds bare URLs in markdown nodes while handling HTML tags.
75+
* When an HTML tag is found, it looks for its closing tag and skips all nodes
76+
* between them to prevent checking for bare URLs inside HTML content.
77+
* @param {ParagraphNode|HeadingNode|TableCellNode} node The node to process
78+
* @returns {void}
79+
*/
80+
function findBareUrls(node) {
81+
/**
82+
* Recursively traverses the AST to find bare URLs, skipping over HTML blocks.
83+
* @param {Node} currentNode The current AST node being traversed.
84+
* @returns {void}
85+
*/
86+
function traverse(currentNode) {
87+
if (
88+
"children" in currentNode &&
89+
Array.isArray(currentNode.children)
90+
) {
91+
for (let i = 0; i < currentNode.children.length; i++) {
92+
const child = currentNode.children[i];
93+
94+
if (child.type === "html") {
95+
const tagInfo = parseHtmlTag(
96+
sourceCode.getText(child),
97+
);
98+
99+
if (tagInfo && !tagInfo.isClosing) {
100+
for (
101+
let j = i + 1;
102+
j < currentNode.children.length;
103+
j++
104+
) {
105+
const nextChild = currentNode.children[j];
106+
if (nextChild.type === "html") {
107+
const closingTagInfo = parseHtmlTag(
108+
sourceCode.getText(nextChild),
109+
);
110+
if (
111+
closingTagInfo?.name ===
112+
tagInfo.name &&
113+
closingTagInfo?.isClosing
114+
) {
115+
i = j;
116+
break;
117+
}
118+
}
119+
}
120+
continue;
121+
}
122+
}
123+
124+
if (child.type === "link") {
125+
const text = sourceCode.getText(child);
126+
const { url } = child;
127+
128+
if (
129+
text === url ||
130+
url === `http://${text}` ||
131+
url === `mailto:${text}`
132+
) {
133+
bareUrls.push(child);
134+
}
135+
}
136+
137+
traverse(child);
138+
}
139+
}
140+
}
141+
142+
traverse(node);
143+
}
144+
145+
return {
146+
"root:exit"() {
147+
for (const bareUrl of bareUrls) {
148+
context.report({
149+
node: bareUrl,
150+
messageId: "bareUrl",
151+
fix(fixer) {
152+
const text = sourceCode.getText(bareUrl);
153+
return fixer.replaceText(bareUrl, `<${text}>`);
154+
},
155+
});
156+
}
157+
},
158+
159+
paragraph(node) {
160+
findBareUrls(node);
161+
},
162+
163+
heading(node) {
164+
findBareUrls(node);
165+
},
166+
167+
tableCell(node) {
168+
findBareUrls(node);
169+
},
170+
};
171+
},
172+
};

0 commit comments

Comments
 (0)