Skip to content

Commit 65251ec

Browse files
authored
feat: add no-reference-like-urls rule (#433)
* feat: add no-reference-like-url rule * address review feedback * refactor types * normalize urls and add more tests * fix CI * use regex-based parsing * refactor and add more tests * optimize with targeted node processing * rename rule * refactor tests * simplify with ESQuery selector
1 parent c532194 commit 65251ec

File tree

4 files changed

+1450
-0
lines changed

4 files changed

+1450
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default defineConfig([
102102
| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references | yes |
103103
| [`no-missing-link-fragments`](./docs/rules/no-missing-link-fragments.md) | Disallow link fragments that do not reference valid headings | yes |
104104
| [`no-multiple-h1`](./docs/rules/no-multiple-h1.md) | Disallow multiple H1 headings in the same document | yes |
105+
| [`no-reference-like-urls`](./docs/rules/no-reference-like-urls.md) | Disallow URLs that match defined reference identifiers | yes |
105106
| [`no-reversed-media-syntax`](./docs/rules/no-reversed-media-syntax.md) | Disallow reversed link and image syntax | yes |
106107
| [`no-space-in-emphasis`](./docs/rules/no-space-in-emphasis.md) | Disallow spaces around emphasis markers | yes |
107108
| [`no-unused-definitions`](./docs/rules/no-unused-definitions.md) | Disallow unused definitions | yes |
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# no-reference-like-urls
2+
3+
Disallow URLs that match defined reference identifiers.
4+
5+
## Background
6+
7+
In Markdown, you can create links using either inline syntax `[text](url)` or reference syntax `[text][id]` with a separate definition `[id]: url`. This rule encourages the use of reference syntax when a link's URL matches an existing reference identifier.
8+
9+
For example, if you have a definition like `[mercury]: https://example.com/mercury/`, then using `[text](mercury)` should be written as `[text][mercury]` instead.
10+
11+
Please note that autofix is not performed for links or images that include a title. For example:
12+
13+
```markdown
14+
[Mercury](mercury "The planet Mercury")
15+
![Venus](venus "The planet Venus")
16+
```
17+
18+
## Rule Details
19+
20+
This rule flags URLs that match defined reference identifiers.
21+
22+
Examples of **incorrect** code for this rule:
23+
24+
```markdown
25+
<!-- eslint markdown/no-reference-like-urls: "error" -->
26+
27+
[**Mercury**](mercury) is the first planet from the sun.
28+
![**Venus** is a planet](venus).
29+
30+
[mercury]: https://example.com/mercury/
31+
[venus]: https://example.com/venus.jpg
32+
```
33+
34+
Examples of **correct** code for this rule:
35+
36+
```markdown
37+
<!-- eslint markdown/no-reference-like-urls: "error" -->
38+
39+
[**Mercury**][mercury] is the first planet from the sun.
40+
![**Venus** is a planet][venus].
41+
42+
[mercury]: https://example.com/mercury/
43+
[venus]: https://example.com/venus.jpg
44+
```
45+
46+
## When Not to Use It
47+
48+
If you prefer inline link syntax even when reference definitions are available, or if you're working in an environment where reference syntax is not preferred, you can safely disable this rule.
49+
50+
## Prior Art
51+
52+
* [remark-lint-no-reference-like-url](https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-no-reference-like-url)
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* @fileoverview Rule to enforce reference-style links when URL matches a defined identifier.
3+
* @author TKDev7
4+
*/
5+
6+
//-----------------------------------------------------------------------------
7+
// Imports
8+
//-----------------------------------------------------------------------------
9+
10+
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
11+
import { findOffsets } from "../util.js";
12+
13+
//-----------------------------------------------------------------------------
14+
// Type Definitions
15+
//-----------------------------------------------------------------------------
16+
17+
/**
18+
* @import { SourceRange } from "@eslint/core"
19+
* @import { Heading, Node, Paragraph, TableCell } from "mdast";
20+
* @import { MarkdownRuleDefinition } from "../types.js";
21+
* @typedef {"referenceLikeUrl"} NoReferenceLikeUrlMessageIds
22+
* @typedef {[]} NoReferenceLikeUrlOptions
23+
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReferenceLikeUrlOptions, MessageIds: NoReferenceLikeUrlMessageIds }>} NoReferenceLikeUrlRuleDefinition
24+
*/
25+
26+
//-----------------------------------------------------------------------------
27+
// Helpers
28+
//-----------------------------------------------------------------------------
29+
30+
/** Pattern to match both inline links: `[text](url)` and images: `![alt](url)`, with optional title */
31+
const linkOrImagePattern =
32+
/(?<!(?<!\\)\\)(?<imageBang>!)?\[(?<label>(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\((?<destination>[ \t]*(?:\r\n?|\n)?(?<![ \t])[ \t]*(?:<[^>]*>|[^ \t()]+))(?:[ \t]*(?:\r\n?|\n)?(?<![ \t])[ \t]*(?<title>"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*(?:\r\n?|\n)?(?<![ \t])[ \t]*\)(?!\()/gu;
33+
34+
/**
35+
* Checks if a given index is within any skip range.
36+
* @param {number} index The index to check
37+
* @param {Array<SourceRange>} skipRanges The skip ranges
38+
* @returns {boolean} True if index is in a skip range
39+
*/
40+
function isInSkipRange(index, skipRanges) {
41+
return skipRanges.some(range => range[0] <= index && index < range[1]);
42+
}
43+
44+
//-----------------------------------------------------------------------------
45+
// Rule Definition
46+
//-----------------------------------------------------------------------------
47+
48+
/** @type {NoReferenceLikeUrlRuleDefinition} */
49+
export default {
50+
meta: {
51+
type: "problem",
52+
53+
docs: {
54+
recommended: true,
55+
description:
56+
"Disallow URLs that match defined reference identifiers",
57+
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reference-like-urls.md",
58+
},
59+
60+
fixable: "code",
61+
62+
messages: {
63+
referenceLikeUrl:
64+
"Unexpected resource {{type}} ('{{prefix}}[text](url)') with URL that matches a definition identifier. Use '[text][id]' syntax instead.",
65+
},
66+
},
67+
68+
create(context) {
69+
const { sourceCode } = context;
70+
/** @type {Array<SourceRange>} */
71+
const skipRanges = [];
72+
/** @type {Set<string>} */
73+
const definitionIdentifiers = new Set();
74+
/** @type {Array<Heading | Paragraph | TableCell>} */
75+
const relevantNodes = [];
76+
77+
return {
78+
definition(node) {
79+
definitionIdentifiers.add(node.identifier);
80+
},
81+
82+
heading(node) {
83+
relevantNodes.push(node);
84+
},
85+
86+
"heading :matches(html, inlineCode)"(node) {
87+
skipRanges.push(sourceCode.getRange(node));
88+
},
89+
90+
paragraph(node) {
91+
relevantNodes.push(node);
92+
},
93+
94+
"paragraph :matches(html, inlineCode)"(node) {
95+
skipRanges.push(sourceCode.getRange(node));
96+
},
97+
98+
tableCell(node) {
99+
relevantNodes.push(node);
100+
},
101+
102+
"tableCell :matches(html, inlineCode)"(node) {
103+
skipRanges.push(sourceCode.getRange(node));
104+
},
105+
106+
"root:exit"() {
107+
for (const node of relevantNodes) {
108+
const text = sourceCode.getText(node);
109+
110+
let match;
111+
while ((match = linkOrImagePattern.exec(text)) !== null) {
112+
const {
113+
imageBang,
114+
label,
115+
destination,
116+
title: titleRaw,
117+
} = match.groups;
118+
const title = titleRaw?.slice(1, -1);
119+
const matchIndex = match.index;
120+
const matchLength = match[0].length;
121+
122+
if (
123+
isInSkipRange(
124+
matchIndex + node.position.start.offset,
125+
skipRanges,
126+
)
127+
) {
128+
continue;
129+
}
130+
131+
const isImage = !!imageBang;
132+
const type = isImage ? "image" : "link";
133+
const prefix = isImage ? "!" : "";
134+
const url =
135+
normalizeIdentifier(destination).toLowerCase();
136+
137+
if (definitionIdentifiers.has(url)) {
138+
const {
139+
lineOffset: startLineOffset,
140+
columnOffset: startColumnOffset,
141+
} = findOffsets(text, matchIndex);
142+
const {
143+
lineOffset: endLineOffset,
144+
columnOffset: endColumnOffset,
145+
} = findOffsets(text, matchIndex + matchLength);
146+
147+
const baseColumn = 1;
148+
const nodeStartLine = node.position.start.line;
149+
const nodeStartColumn = node.position.start.column;
150+
const startLine = nodeStartLine + startLineOffset;
151+
const endLine = nodeStartLine + endLineOffset;
152+
const startColumn =
153+
(startLine === nodeStartLine
154+
? nodeStartColumn
155+
: baseColumn) + startColumnOffset;
156+
const endColumn =
157+
(endLine === nodeStartLine
158+
? nodeStartColumn
159+
: baseColumn) + endColumnOffset;
160+
161+
context.report({
162+
loc: {
163+
start: {
164+
line: startLine,
165+
column: startColumn,
166+
},
167+
end: { line: endLine, column: endColumn },
168+
},
169+
messageId: "referenceLikeUrl",
170+
data: {
171+
type,
172+
prefix,
173+
},
174+
fix(fixer) {
175+
// The AST treats both missing and empty titles as null, so it's safe to auto-fix in both cases.
176+
if (title) {
177+
return null;
178+
}
179+
180+
const startOffset =
181+
node.position.start.offset + matchIndex;
182+
const endOffset = startOffset + matchLength;
183+
184+
return fixer.replaceTextRange(
185+
[startOffset, endOffset],
186+
`${prefix}[${label}][${destination}]`,
187+
);
188+
},
189+
});
190+
}
191+
}
192+
}
193+
},
194+
};
195+
},
196+
};

0 commit comments

Comments
 (0)