Skip to content

Commit ae547ab

Browse files
authored
feat: add no-reversed-media-syntax rule (#398)
* feat: add no-reversed-media-syntax rule * cleanup tests * code review * code review * simplify error message * code review * code review
1 parent 5149927 commit ae547ab

File tree

4 files changed

+622
-0
lines changed

4 files changed

+622
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export default defineConfig([
101101
| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references | yes |
102102
| [`no-missing-link-fragments`](./docs/rules/no-missing-link-fragments.md) | Disallow link fragments that do not reference valid headings | yes |
103103
| [`no-multiple-h1`](./docs/rules/no-multiple-h1.md) | Disallow multiple H1 headings in the same document | yes |
104+
| [`no-reversed-media-syntax`](./docs/rules/no-reversed-media-syntax.md) | Disallow reversed link and image syntax | yes |
104105
| [`require-alt-text`](./docs/rules/require-alt-text.md) | Require alternative text for images | yes |
105106
| [`table-column-count`](./docs/rules/table-column-count.md) | Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row | yes |
106107
<!-- Rule Table End -->
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# no-reversed-media-syntax
2+
3+
Disallow reversed link and image syntax in Markdown.
4+
5+
## Background
6+
7+
Markdown syntax for links requires the text to be in square brackets `[]` followed by the URL in parentheses `()`. Similarly, images use `![alt](url)`. It's easy to accidentally reverse these brackets, which results in invalid syntax that won't render correctly.
8+
9+
## Rule Details
10+
11+
This rule is triggered when text that appears to be a link or image is encountered, but the syntax seems to have been reversed (the `[]` and `()` are in the wrong order).
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```markdown
16+
<!-- eslint markdown/no-reversed-media-syntax: "error" -->
17+
18+
(ESLint)[https://eslint.org/]
19+
20+
!(A beautiful sunset)[sunset.png]
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```markdown
26+
<!-- eslint markdown/no-reversed-media-syntax: "error" -->
27+
28+
[ESLint](https://eslint.org/)
29+
30+
![A beautiful sunset](sunset.png)
31+
```
32+
33+
## When Not To Use It
34+
35+
If you don't need to enforce correct link and image syntax, you can safely disable this rule.
36+
37+
## Prior Art
38+
39+
* [MD011 - no-reversed-links](https://github.com/DavidAnson/markdownlint/blob/main/doc/md011.md)
40+
* [remark-lint-correct-media-syntax](https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-correct-media-syntax)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @fileoverview Rule to prevent reversed link and image syntax in Markdown.
3+
* @author xbinaryx
4+
*/
5+
6+
//-----------------------------------------------------------------------------
7+
// Imports
8+
//-----------------------------------------------------------------------------
9+
10+
import { findOffsets } from "../util.js";
11+
12+
//-----------------------------------------------------------------------------
13+
// Type Definitions
14+
//-----------------------------------------------------------------------------
15+
16+
/** @typedef {import("mdast").Node} Node */
17+
/** @typedef {import("mdast").Paragraph} ParagraphNode */
18+
/**
19+
* @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
20+
* NoReversedMediaSyntaxRuleDefinition
21+
*/
22+
23+
//-----------------------------------------------------------------------------
24+
// Helpers
25+
//-----------------------------------------------------------------------------
26+
27+
/** Matches reversed link/image syntax like (text)[url], ignoring escaped characters like \(text\)[url]. */
28+
const reversedPattern =
29+
/(?<!\\)\(((?:\\.|[^()\\]|\([\s\S]*\))*)\)\[((?:\\.|[^\]\\\n])*)\](?!\()/gu;
30+
31+
/**
32+
* Checks if a match is within any of the code spans
33+
* @param {number} matchIndex The index of the match
34+
* @param {Array<{startOffset: number, endOffset: number}>} codeSpans Array of code span positions
35+
* @returns {boolean} True if the match is within a code span
36+
*/
37+
function isInCodeSpan(matchIndex, codeSpans) {
38+
return codeSpans.some(
39+
span => matchIndex >= span.startOffset && matchIndex < span.endOffset,
40+
);
41+
}
42+
43+
/**
44+
* Finds all code spans in the paragraph node by traversing its children
45+
* @param {ParagraphNode} node The paragraph node to search
46+
* @returns {Array<{startOffset: number, endOffset: number}>} Array of code span positions
47+
*/
48+
function findCodeSpans(node) {
49+
const codeSpans = [];
50+
51+
/**
52+
* Recursively traverses the AST to find inline code nodes
53+
* @param {Node} currentNode The current node being traversed
54+
* @returns {void}
55+
*/
56+
function traverse(currentNode) {
57+
if (currentNode.type === "inlineCode") {
58+
codeSpans.push({
59+
startOffset: currentNode.position.start.offset,
60+
endOffset: currentNode.position.end.offset,
61+
});
62+
return;
63+
}
64+
65+
if ("children" in currentNode && Array.isArray(currentNode.children)) {
66+
currentNode.children.forEach(traverse);
67+
}
68+
}
69+
70+
traverse(node);
71+
return codeSpans;
72+
}
73+
74+
//-----------------------------------------------------------------------------
75+
// Rule Definition
76+
//-----------------------------------------------------------------------------
77+
78+
/** @type {NoReversedMediaSyntaxRuleDefinition} */
79+
export default {
80+
meta: {
81+
type: "problem",
82+
83+
docs: {
84+
recommended: true,
85+
description: "Disallow reversed link and image syntax",
86+
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reversed-media-syntax.md",
87+
},
88+
89+
fixable: "code",
90+
91+
messages: {
92+
reversedSyntax:
93+
"Unexpected reversed syntax found. Use [label](URL) syntax instead.",
94+
},
95+
},
96+
97+
create(context) {
98+
return {
99+
paragraph(node) {
100+
const text = context.sourceCode.getText(node);
101+
const codeSpans = findCodeSpans(node);
102+
let match;
103+
104+
while ((match = reversedPattern.exec(text)) !== null) {
105+
const [reversedSyntax, label, url] = match;
106+
const matchIndex = match.index;
107+
const matchLength = reversedSyntax.length;
108+
109+
if (isInCodeSpan(matchIndex, codeSpans)) {
110+
continue;
111+
}
112+
113+
const {
114+
lineOffset: startLineOffset,
115+
columnOffset: startColumnOffset,
116+
} = findOffsets(text, matchIndex);
117+
const {
118+
lineOffset: endLineOffset,
119+
columnOffset: endColumnOffset,
120+
} = findOffsets(text, matchIndex + matchLength);
121+
122+
const baseColumn = 1;
123+
const nodeStartLine = node.position.start.line;
124+
const nodeStartColumn = node.position.start.column;
125+
const startLine = nodeStartLine + startLineOffset;
126+
const endLine = nodeStartLine + endLineOffset;
127+
const startColumn =
128+
(startLine === nodeStartLine
129+
? nodeStartColumn
130+
: baseColumn) + startColumnOffset;
131+
const endColumn =
132+
(endLine === nodeStartLine
133+
? nodeStartColumn
134+
: baseColumn) + endColumnOffset;
135+
136+
context.report({
137+
loc: {
138+
start: {
139+
line: startLine,
140+
column: startColumn,
141+
},
142+
end: {
143+
line: endLine,
144+
column: endColumn,
145+
},
146+
},
147+
messageId: "reversedSyntax",
148+
fix(fixer) {
149+
const startOffset =
150+
node.position.start.offset + matchIndex;
151+
const endOffset = startOffset + matchLength;
152+
153+
return fixer.replaceTextRange(
154+
[startOffset, endOffset],
155+
`[${label}](${url})`,
156+
);
157+
},
158+
});
159+
}
160+
},
161+
};
162+
},
163+
};

0 commit comments

Comments
 (0)