Skip to content

Commit e4b8de4

Browse files
authored
feat: add no-space-in-emphasis rule (#403)
* feat: add no-space-in-emphasis rule * refactor * address comments * revert no-unused-definitions.md * add includeStrikethrough option * refactor * add more examples
1 parent 99beea6 commit e4b8de4

File tree

4 files changed

+1809
-0
lines changed

4 files changed

+1809
-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-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 |
104104
| [`no-reversed-media-syntax`](./docs/rules/no-reversed-media-syntax.md) | Disallow reversed link and image syntax | yes |
105+
| [`no-space-in-emphasis`](./docs/rules/no-space-in-emphasis.md) | Disallow spaces around emphasis markers | yes |
105106
| [`no-unused-definitions`](./docs/rules/no-unused-definitions.md) | Disallow unused definitions | yes |
106107
| [`require-alt-text`](./docs/rules/require-alt-text.md) | Require alternative text for images | yes |
107108
| [`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 |

docs/rules/no-space-in-emphasis.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# no-space-in-emphasis
2+
3+
Disallow spaces around emphasis markers.
4+
5+
## Background
6+
7+
In Markdown, emphasis (bold and italic) is created using asterisks (`*`) or underscores (`_`), and a strikethrough is created using tildes (`~`). The emphasis markers must be directly adjacent to the text they're emphasizing, with no spaces between the markers and the text. When spaces are present, the emphasis is not rendered correctly.
8+
9+
Please note that this rule does not check for spaces inside emphasis markers when the content is itself an emphasis (i.e., nested emphasis). For example, `**_ bold _**` and `_** italic **_` are not flagged, even though there are spaces inside the inner emphasis markers.
10+
11+
## Rule Details
12+
13+
This rule warns when it finds emphasis markers that have spaces between the markers and the text they're emphasizing.
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```markdown
18+
<!-- eslint markdown/no-space-in-emphasis: "error" -->
19+
20+
Here is some ** bold ** text.
21+
Here is some * italic * text.
22+
Here is some __ bold __ text.
23+
Here is some _ italic _ text.
24+
Here is some *** bold italic *** text.
25+
Here is some ___ bold italic ___ text.
26+
```
27+
28+
Examples of **correct** code for this rule:
29+
30+
```markdown
31+
<!-- eslint markdown/no-space-in-emphasis: "error" -->
32+
33+
Here is some **bold** text.
34+
Here is some *italic* text.
35+
Here is some __bold__ text.
36+
Here is some _italic_ text.
37+
Here is some ***bold italic*** text.
38+
Here is some ___bold italic___ text.
39+
Here is some **_ bold _** text.
40+
Here is some _** italic **_ text.
41+
```
42+
43+
## Options
44+
45+
The following options are available on this rule:
46+
47+
* `checkStrikethrough: boolean` - when `true`, also check for spaces around strikethrough markers (`~` and `~~`). (default: `false`)
48+
49+
> [!IMPORTANT] <!-- eslint-disable-line -- This should be fixed in https://github.com/eslint/markdown/issues/294 -->
50+
>
51+
> Use `checkStrikethrough` with `language: "markdown/gfm"`; in CommonMark, `~`/`~~` aren’t strikethrough (they’ll still be linted if enabled).
52+
53+
Examples of **incorrect** code when configured as `"no-space-in-emphasis": ["error", { checkStrikethrough: true }]`:
54+
55+
```markdown
56+
<!-- eslint markdown/no-space-in-emphasis: ["error", { checkStrikethrough: true }] -->
57+
58+
Here is some ~ strikethrough ~ text.
59+
Here is some ~~ strikethrough ~~ text.
60+
```
61+
62+
Examples of **correct** code when configured as `"no-space-in-emphasis": ["error", { checkStrikethrough: true }]`:
63+
64+
```markdown
65+
<!-- eslint markdown/no-space-in-emphasis: ["error", { checkStrikethrough: true }] -->
66+
67+
Here is some ~strikethrough~ text.
68+
Here is some ~~strikethrough~~ text.
69+
```
70+
71+
## When Not to Use It
72+
73+
If you aren't concerned with proper emphasis rendering in your Markdown documents, you can safely disable this rule.
74+
75+
## Prior Art
76+
77+
* [MD037 - Spaces inside emphasis markers](https://github.com/DavidAnson/markdownlint/blob/main/doc/md037.md)

src/rules/no-space-in-emphasis.js

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* @fileoverview Rule to prevent spaces around emphasis markers in Markdown.
3+
* @author Pixel998
4+
*/
5+
6+
//-----------------------------------------------------------------------------
7+
// Imports
8+
//-----------------------------------------------------------------------------
9+
10+
import { findOffsets } from "../util.js";
11+
12+
//-----------------------------------------------------------------------------
13+
// Type Definitions
14+
//-----------------------------------------------------------------------------
15+
16+
/**
17+
* @import { Heading, Paragraph, TableCell, Text } from "mdast";
18+
* @import { MarkdownRuleDefinition } from "../types.js";
19+
* @typedef {"spaceInEmphasis"} NoSpaceInEmphasisMessageIds
20+
* @typedef {[{ checkStrikethrough?: boolean }]} NoSpaceInEmphasisOptions
21+
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoSpaceInEmphasisOptions, MessageIds: NoSpaceInEmphasisMessageIds }>} NoSpaceInEmphasisRuleDefinition
22+
*/
23+
24+
//-----------------------------------------------------------------------------
25+
// Helpers
26+
//-----------------------------------------------------------------------------
27+
28+
const whitespacePattern = /[ \t]/u;
29+
30+
/**
31+
* Creates a marker pattern based on whether strikethrough should be included.
32+
* @param {boolean} checkStrikethrough Whether to include strikethrough markers.
33+
* @returns {RegExp} The marker pattern.
34+
*/
35+
function createMarkerPattern(checkStrikethrough) {
36+
return checkStrikethrough
37+
? /(?<=(?<!\\)(?:\\{2})*)(?<marker>\*\*\*|\*\*|\*|___|__|_|~~|~)/gu
38+
: /(?<=(?<!\\)(?:\\{2})*)(?<marker>\*\*\*|\*\*|\*|___|__|_)/gu;
39+
}
40+
41+
/**
42+
* Finds all emphasis markers in the text.
43+
* @param {string} text The text to search.
44+
* @param {RegExp} pattern The marker pattern to use.
45+
* @typedef {{marker: string, startIndex: number, endIndex: number}} EmphasisMarker
46+
* @returns {Array<EmphasisMarker>} Array of emphasis markers.
47+
*/
48+
function findEmphasisMarkers(text, pattern) {
49+
/** @type {Array<EmphasisMarker>} */
50+
const markers = [];
51+
/** @type {RegExpExecArray | null} */
52+
let match;
53+
54+
while ((match = pattern.exec(text)) !== null) {
55+
markers.push({
56+
marker: match.groups.marker,
57+
startIndex: match.index,
58+
endIndex: match.index + match.groups.marker.length,
59+
});
60+
}
61+
62+
return markers;
63+
}
64+
65+
//-----------------------------------------------------------------------------
66+
// Rule Definition
67+
//-----------------------------------------------------------------------------
68+
69+
/** @type {NoSpaceInEmphasisRuleDefinition} */
70+
export default {
71+
meta: {
72+
type: "problem",
73+
74+
docs: {
75+
recommended: true,
76+
description: "Disallow spaces around emphasis markers",
77+
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-space-in-emphasis.md",
78+
},
79+
80+
fixable: "whitespace",
81+
82+
messages: {
83+
spaceInEmphasis: "Unexpected space around emphasis marker.",
84+
},
85+
86+
schema: [
87+
{
88+
type: "object",
89+
properties: {
90+
checkStrikethrough: {
91+
type: "boolean",
92+
},
93+
},
94+
additionalProperties: false,
95+
},
96+
],
97+
98+
defaultOptions: [
99+
{
100+
checkStrikethrough: false,
101+
},
102+
],
103+
},
104+
105+
create(context) {
106+
const { sourceCode } = context;
107+
const [{ checkStrikethrough }] = context.options;
108+
const markerPattern = createMarkerPattern(checkStrikethrough);
109+
110+
/**
111+
* Reports a surrounding-space violation if present.
112+
* @param {Object} params Options for the report arguments.
113+
* @param {string} params.originalText The original text of the node.
114+
* @param {number} params.checkIndex Character index to test for whitespace.
115+
* @param {number} params.highlightStartIndex Start index for highlighting.
116+
* @param {number} params.highlightEndIndex End index for highlighting.
117+
* @param {number} params.removeIndex Absolute index of the space to remove.
118+
* @param {number} params.nodeStartLine The starting line number for the node.
119+
* @param {number} params.nodeStartColumn The starting column number for the node.
120+
* @returns {void}
121+
*/
122+
function reportWhitespace({
123+
originalText,
124+
checkIndex,
125+
highlightStartIndex,
126+
highlightEndIndex,
127+
removeIndex,
128+
nodeStartLine,
129+
nodeStartColumn,
130+
}) {
131+
if (whitespacePattern.test(originalText[checkIndex])) {
132+
const {
133+
lineOffset: startLineOffset,
134+
columnOffset: startColumnOffset,
135+
} = findOffsets(originalText, highlightStartIndex);
136+
const {
137+
lineOffset: endLineOffset,
138+
columnOffset: endColumnOffset,
139+
} = findOffsets(originalText, highlightEndIndex);
140+
141+
context.report({
142+
loc: {
143+
start: {
144+
line: nodeStartLine + startLineOffset,
145+
column: nodeStartColumn + startColumnOffset,
146+
},
147+
end: {
148+
line: nodeStartLine + endLineOffset,
149+
column: nodeStartColumn + endColumnOffset,
150+
},
151+
},
152+
messageId: "spaceInEmphasis",
153+
fix(fixer) {
154+
return fixer.removeRange([
155+
removeIndex,
156+
removeIndex + 1,
157+
]);
158+
},
159+
});
160+
}
161+
}
162+
163+
/**
164+
* Checks a given node for emphasis markers with surrounding spaces.
165+
* @param {Heading|Paragraph|TableCell} node The node to check.
166+
* @param {string} maskedText The masked text preserving only direct text content.
167+
* @returns {void}
168+
*/
169+
function checkEmphasis(node, maskedText) {
170+
const originalText = sourceCode.getText(node);
171+
const markers = findEmphasisMarkers(maskedText, markerPattern);
172+
const nodeStartLine = node.position.start.line;
173+
const nodeStartColumn = node.position.start.column;
174+
const nodeStartOffset = node.position.start.offset;
175+
176+
const markerGroups = new Map();
177+
for (const marker of markers) {
178+
if (!markerGroups.has(marker.marker)) {
179+
markerGroups.set(marker.marker, []);
180+
}
181+
markerGroups.get(marker.marker).push(marker);
182+
}
183+
184+
for (const group of markerGroups.values()) {
185+
for (let i = 0; i < group.length - 1; i += 2) {
186+
const startMarker = group[i];
187+
reportWhitespace({
188+
originalText,
189+
checkIndex: startMarker.endIndex,
190+
highlightStartIndex: startMarker.startIndex,
191+
highlightEndIndex: startMarker.endIndex + 2,
192+
removeIndex: nodeStartOffset + startMarker.endIndex,
193+
nodeStartLine,
194+
nodeStartColumn,
195+
});
196+
197+
const endMarker = group[i + 1];
198+
reportWhitespace({
199+
originalText,
200+
checkIndex: endMarker.startIndex - 1,
201+
highlightStartIndex: endMarker.startIndex - 2,
202+
highlightEndIndex: endMarker.endIndex,
203+
removeIndex: nodeStartOffset + endMarker.startIndex - 1,
204+
nodeStartLine,
205+
nodeStartColumn,
206+
});
207+
}
208+
}
209+
}
210+
211+
/**
212+
* Manager for building a masked character array for a node's direct text content.
213+
* @typedef {{ buffer: string[], startOffset: number }} BufferState
214+
*/
215+
const bufferManager = {
216+
/** @type {BufferState | null} */
217+
state: null,
218+
219+
/**
220+
* Initialize state with a whitespace-masked character buffer for the node.
221+
* @param {Heading|Paragraph|TableCell} node Heading, Paragraph, or TableCell node to enter.
222+
* @returns {void}
223+
*/
224+
enter(node) {
225+
const [startOffset, endOffset] = sourceCode.getRange(node);
226+
this.state = {
227+
buffer: new Array(endOffset - startOffset).fill(" "),
228+
startOffset,
229+
};
230+
},
231+
232+
/**
233+
* Add the content of a Text node into the current buffer at the correct offsets.
234+
* @param {Text} node Text node whose characters will be copied into the buffer.
235+
* @returns {void}
236+
*/
237+
addText(node) {
238+
const start =
239+
node.position.start.offset - this.state.startOffset;
240+
const text = sourceCode.getText(node);
241+
for (let i = 0; i < text.length; i++) {
242+
this.state.buffer[start + i] = text[i];
243+
}
244+
},
245+
246+
/**
247+
* Join the character buffer into a masked string, run checks, then clear state.
248+
* @param {Heading|Paragraph|TableCell} node Heading, Paragraph, or TableCell node to exit.
249+
* @returns {void}
250+
*/
251+
exit(node) {
252+
checkEmphasis(node, this.state.buffer.join(""));
253+
this.state = null;
254+
},
255+
};
256+
257+
return {
258+
heading(node) {
259+
bufferManager.enter(node);
260+
},
261+
"heading > text"(node) {
262+
bufferManager.addText(node);
263+
},
264+
"heading:exit"(node) {
265+
bufferManager.exit(node);
266+
},
267+
268+
paragraph(node) {
269+
bufferManager.enter(node);
270+
},
271+
"paragraph > text"(node) {
272+
bufferManager.addText(node);
273+
},
274+
"paragraph:exit"(node) {
275+
bufferManager.exit(node);
276+
},
277+
278+
tableCell(node) {
279+
bufferManager.enter(node);
280+
},
281+
"tableCell > text"(node) {
282+
bufferManager.addText(node);
283+
},
284+
"tableCell:exit"(node) {
285+
bufferManager.exit(node);
286+
},
287+
};
288+
},
289+
};

0 commit comments

Comments
 (0)