eslint-plugin-md-style 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/index.d.mts +15 -0
- package/dist/index.mjs +521 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright © 2025-PRESENT Kevin Deng (https://github.com/sxzz)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# eslint-plugin-md-style
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/eslint-plugin-md-style)
|
|
4
|
+
[](https://www.npmjs.com/package/eslint-plugin-md-style)
|
|
5
|
+
[](https://codecov.io/gh/NoiseFan/eslint-plugin-md-style)
|
|
6
|
+
|
|
7
|
+
ESLint plugin for enforcing style rules in Markdown-based documentation.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
`eslint-plugin-md-style` provides Markdown-specific style rules and ready-to-use flat configs for `**/*.md` files.
|
|
12
|
+
|
|
13
|
+
It currently ships:
|
|
14
|
+
|
|
15
|
+
- A `recommended` config for typical documentation linting
|
|
16
|
+
- An `all` config that enables every rule in this plugin
|
|
17
|
+
- Markdown language registration built on top of `@eslint/markdown`
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
Install the required packages:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add -D eslint @eslint/markdown eslint-plugin-md-style
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then enable the recommended config in your ESLint flat config:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import mdStyle from 'eslint-plugin-md-style'
|
|
31
|
+
|
|
32
|
+
export default [
|
|
33
|
+
mdStyle.configs.recommended,
|
|
34
|
+
]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If you want full enforcement, replace `mdStyle.configs.recommended` with `mdStyle.configs.all`.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Manual Flat Config Usage
|
|
42
|
+
|
|
43
|
+
Use the built-in preset directly:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import mdStyle from 'eslint-plugin-md-style'
|
|
47
|
+
|
|
48
|
+
export default [
|
|
49
|
+
mdStyle.configs.recommended,
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You can also enable the plugin manually and choose rules one by one:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import mdStyle from 'eslint-plugin-md-style'
|
|
57
|
+
|
|
58
|
+
export default [
|
|
59
|
+
{
|
|
60
|
+
files: ['**/*.md'],
|
|
61
|
+
plugins: {
|
|
62
|
+
'md-style': mdStyle,
|
|
63
|
+
},
|
|
64
|
+
language: 'md-style/commonmark',
|
|
65
|
+
rules: {
|
|
66
|
+
'md-style/space-between-link': 'error',
|
|
67
|
+
'md-style/valid-heading-anchor': 'error',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
<details>
|
|
74
|
+
<summary>Usage with <code>@antfu/eslint-config</code></summary>
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import antfu from '@antfu/eslint-config'
|
|
78
|
+
import mdStyle from 'eslint-plugin-md-style'
|
|
79
|
+
|
|
80
|
+
export default antfu(
|
|
81
|
+
{
|
|
82
|
+
formatters: true,
|
|
83
|
+
markdown: true,
|
|
84
|
+
},
|
|
85
|
+
mdStyle.configs.recommended,
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For partial adoption, start from `recommended` and override individual rules:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import antfu from '@antfu/eslint-config'
|
|
93
|
+
import mdStyle from 'eslint-plugin-md-style'
|
|
94
|
+
|
|
95
|
+
export default antfu(
|
|
96
|
+
{
|
|
97
|
+
formatters: true,
|
|
98
|
+
markdown: true,
|
|
99
|
+
},
|
|
100
|
+
mdStyle.configs.recommended,
|
|
101
|
+
{
|
|
102
|
+
files: ['**/*.md'],
|
|
103
|
+
rules: {
|
|
104
|
+
'md-style/valid-heading-anchor': 'off',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
</details>
|
|
111
|
+
|
|
112
|
+
## Rules
|
|
113
|
+
|
|
114
|
+
| Rule | Included in `recommended` | Autofix |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `md-style/space-between-link` | ✅ | 🔧 |
|
|
117
|
+
| `md-style/valid-heading-anchor` | ✅ | 🔧 |
|
|
118
|
+
|
|
119
|
+
## Why `@eslint/markdown` Is Required
|
|
120
|
+
|
|
121
|
+
This plugin builds on top of `@eslint/markdown` rather than replacing it.
|
|
122
|
+
|
|
123
|
+
`@eslint/markdown` provides the Markdown processor and language support. This plugin re-exports those capabilities through its own plugin entry and adds documentation style rules on top, including the `md-style/commonmark` language used by the bundled configs.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](./LICENSE) License © 2025-PRESENT [Noise Fan](https://github.com/noisefan)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ESLint, Linter } from "eslint";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
declare const plugin: ESLint.Plugin;
|
|
5
|
+
interface PluginConfigMap {
|
|
6
|
+
recommended: Linter.Config;
|
|
7
|
+
all: Linter.Config;
|
|
8
|
+
}
|
|
9
|
+
declare const configs: PluginConfigMap;
|
|
10
|
+
type MdStylePlugin = ESLint.Plugin & {
|
|
11
|
+
configs: PluginConfigMap;
|
|
12
|
+
};
|
|
13
|
+
declare const mdStylePlugin: MdStylePlugin;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { MdStylePlugin, configs, mdStylePlugin as default, plugin };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import markdown, { MarkdownLanguage } from "@eslint/markdown";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/index.ts
|
|
4
|
+
function createRule({ create, defaultOptions, meta }) {
|
|
5
|
+
return {
|
|
6
|
+
create,
|
|
7
|
+
meta: {
|
|
8
|
+
defaultOptions,
|
|
9
|
+
...meta
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Gets the start and end offsets for a node.
|
|
15
|
+
*/
|
|
16
|
+
function getNodePosition(node) {
|
|
17
|
+
const start = node.position?.start.offset;
|
|
18
|
+
const end = node.position?.end.offset;
|
|
19
|
+
if (start == null || end == null) return {
|
|
20
|
+
position: false,
|
|
21
|
+
start: 0,
|
|
22
|
+
end: 0
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
position: true,
|
|
26
|
+
start,
|
|
27
|
+
end
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/utils/ast.ts
|
|
33
|
+
/**
|
|
34
|
+
* Checks whether an unknown value behaves like an mdast parent node.
|
|
35
|
+
*
|
|
36
|
+
* This intentionally accepts unknown values because ESLint's ancestor API does
|
|
37
|
+
* not expose mdast-specific types.
|
|
38
|
+
*/
|
|
39
|
+
function hasChildren(node) {
|
|
40
|
+
return !!node && typeof node === "object" && "children" in node && Array.isArray(node.children);
|
|
41
|
+
}
|
|
42
|
+
function getNodeContext(context, node) {
|
|
43
|
+
const parent = context.sourceCode.getAncestors(node).at(-1);
|
|
44
|
+
if (!hasChildren(parent)) return {
|
|
45
|
+
prev: void 0,
|
|
46
|
+
next: void 0,
|
|
47
|
+
current: node
|
|
48
|
+
};
|
|
49
|
+
const currentIndex = parent.children.findIndex((child) => child === node);
|
|
50
|
+
if (currentIndex === -1) return {
|
|
51
|
+
parent,
|
|
52
|
+
prev: void 0,
|
|
53
|
+
next: void 0,
|
|
54
|
+
current: node
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
parent,
|
|
58
|
+
prev: parent.children[currentIndex - 1],
|
|
59
|
+
next: parent.children[currentIndex + 1],
|
|
60
|
+
current: node
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/utils/rules/anchor.ts
|
|
66
|
+
/**
|
|
67
|
+
* Match the trailing anchor-like fragment from a heading string.
|
|
68
|
+
* @example `中文标题 {#Chinese-Title}` -> `{#Chinese-Title}`
|
|
69
|
+
* @example `使用 describe #Grouping Tests` -> `#Grouping Tests`
|
|
70
|
+
*/
|
|
71
|
+
function getLikeAnchorMatch(str) {
|
|
72
|
+
const match = str.match(/(\{?#[\w\s.!`-]+\}?$)/);
|
|
73
|
+
return match ? match[0] : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse the trailing anchor-like fragment from a heading string.
|
|
77
|
+
* `isLike` is true when the fragment looks like a loose anchor such as
|
|
78
|
+
* `# Your First Test`; false when it already looks like a compact anchor.
|
|
79
|
+
* `rawLikeAnchor` is the cleaned anchor text without `{`, `}` or leading `#`.
|
|
80
|
+
* @example `# Your First Test` -> { isLike: true, rawLikeAnchor: 'Your First Test' }
|
|
81
|
+
* @example `{#built-in-slug}` -> { isLike: false, rawLikeAnchor: 'built-in-slug' }
|
|
82
|
+
*/
|
|
83
|
+
function getLikeAnchor(str) {
|
|
84
|
+
if (str === void 0) return null;
|
|
85
|
+
const match = getLikeAnchorMatch(str);
|
|
86
|
+
if (!match) return null;
|
|
87
|
+
const rawLikeAnchor = match.replace(/(\{|\})/g, "").replace(/^#/, "").trimStart();
|
|
88
|
+
return {
|
|
89
|
+
isLikeAnchor: rawLikeAnchor.includes(" "),
|
|
90
|
+
rawLikeAnchor
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check if the string has an anchor.
|
|
95
|
+
* @example: {#chinese-anchor}
|
|
96
|
+
*/
|
|
97
|
+
function isStrictAnchor(str) {
|
|
98
|
+
return /\s\{#[a-z0-9]+(?:-[a-z0-9]+)*\}/.test(str);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check whether the string contains CJK Han characters.
|
|
102
|
+
*/
|
|
103
|
+
function hasChinese(str) {
|
|
104
|
+
return /[\u4E00-\u9FA5]/.test(str);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Normalize raw anchor text into the strict anchor format content.
|
|
108
|
+
* - lowercase all letters
|
|
109
|
+
* - convert spaces to `-`
|
|
110
|
+
* - remove unsupported characters
|
|
111
|
+
* - trim leading/trailing `-`
|
|
112
|
+
*/
|
|
113
|
+
function normalizeAnchor(anchor) {
|
|
114
|
+
return anchor.toLowerCase().replace(/[\s.]/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Count wrapper characters contributed by the trailing like-anchor fragment.
|
|
118
|
+
* The value is the length difference between the raw matched fragment and the
|
|
119
|
+
* cleaned anchor text returned by `getLikeAnchor`.
|
|
120
|
+
* @example `# 中文标题 {#Chinese-Title}` -> 3
|
|
121
|
+
* @example `## 使用 \`describe\` 编组测试 #Grouping Tests with \`describe\`` -> 1
|
|
122
|
+
*/
|
|
123
|
+
function calcAnchorPositionCompensate(content) {
|
|
124
|
+
const match = getLikeAnchorMatch(content);
|
|
125
|
+
const anchor = getLikeAnchor(content);
|
|
126
|
+
if (!match || !anchor) return 0;
|
|
127
|
+
return match.length - anchor.rawLikeAnchor.length;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/utils/rules/link.ts
|
|
132
|
+
const LINK_SPACE_MESSAGE_IDS = {
|
|
133
|
+
missingSpaceBeforeLink: "missingSpaceBeforeLink",
|
|
134
|
+
missingSpaceAfterLink: "missingSpaceAfterLink",
|
|
135
|
+
multipleSpacesBeforeLink: "multipleSpacesBeforeLink",
|
|
136
|
+
multipleSpacesAfterLink: "multipleSpacesAfterLink",
|
|
137
|
+
multipleSpacesAfterPunctuation: "multipleSpacesAfterPunctuation",
|
|
138
|
+
unexpectedSpaceBeforeLink: "unexpectedSpaceBeforeLink",
|
|
139
|
+
unexpectedSpaceAfterLink: "unexpectedSpaceAfterLink"
|
|
140
|
+
};
|
|
141
|
+
const OPENING_PAIRED_PUNCTUATION = new Set([
|
|
142
|
+
"(",
|
|
143
|
+
"[",
|
|
144
|
+
"{",
|
|
145
|
+
"<",
|
|
146
|
+
"(",
|
|
147
|
+
"【",
|
|
148
|
+
"《",
|
|
149
|
+
"“",
|
|
150
|
+
"‘"
|
|
151
|
+
]);
|
|
152
|
+
/**
|
|
153
|
+
* Checks whether the character is fullwidth punctuation.
|
|
154
|
+
* @example `。` -> true
|
|
155
|
+
* @example `,` -> false
|
|
156
|
+
*/
|
|
157
|
+
function isFullwidthPunctuation(str) {
|
|
158
|
+
if (!str || str.length !== 1) return false;
|
|
159
|
+
return /^[\u3001-\u303F\uFE10-\uFE1F\uFE30-\uFE4F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65“”‘’…]$/u.test(str);
|
|
160
|
+
}
|
|
161
|
+
const DASH_PUNCTUATION_RE = /^[-\u2013\u2014\u2212]$/u;
|
|
162
|
+
/**
|
|
163
|
+
* Checks whether the character is hyphen-like punctuation.
|
|
164
|
+
* @example `—` -> true
|
|
165
|
+
* @example `.` -> false
|
|
166
|
+
*/
|
|
167
|
+
function isDashPunctuation(str) {
|
|
168
|
+
if (!str || str.length !== 1) return false;
|
|
169
|
+
return DASH_PUNCTUATION_RE.test(str);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Checks whether adjacent text is a custom container marker on the next line.
|
|
173
|
+
*
|
|
174
|
+
* @deprecated Temporary workaround to prevent space-between-link from reporting
|
|
175
|
+
* false positives on custom containers. Remove this and handle the case in a
|
|
176
|
+
* dedicated custom container rule when one exists.
|
|
177
|
+
* @see https://vitepress.dev/guide/markdown#custom-containers
|
|
178
|
+
* @example `\n:::` -> true
|
|
179
|
+
* @example `\n::::` -> true
|
|
180
|
+
* @example `:::` -> false
|
|
181
|
+
*/
|
|
182
|
+
function isCustomContainerMarker(str) {
|
|
183
|
+
return /^[ \t]*\n[ \t]*:{3,}[ \t]*$/u.test(str || "");
|
|
184
|
+
}
|
|
185
|
+
const PUNCTUATION_RE = /^\p{P}$/u;
|
|
186
|
+
/**
|
|
187
|
+
* Checks whether the character is punctuation.
|
|
188
|
+
* Covers fullwidth punctuation, halfwidth punctuation, and other Unicode punctuation characters.
|
|
189
|
+
* @example `。` -> true
|
|
190
|
+
* @example `$` -> false
|
|
191
|
+
*/
|
|
192
|
+
function isPunctuation(str) {
|
|
193
|
+
if (!str || str.length !== 1) return false;
|
|
194
|
+
return PUNCTUATION_RE.test(str);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Gets the count and range of consecutive whitespace at the start or end of a string.
|
|
198
|
+
* @example ` text`, `head` -> { count: 2, start: 0, end: 2 }
|
|
199
|
+
* @example `text `, `tail` -> { count: 2, start: 4, end: 6 }
|
|
200
|
+
*/
|
|
201
|
+
function getWhiteSpace(str, position = "head") {
|
|
202
|
+
const defaultVal = {
|
|
203
|
+
count: 0,
|
|
204
|
+
start: 0,
|
|
205
|
+
end: 0
|
|
206
|
+
};
|
|
207
|
+
if (!str || str.length === 0) return defaultVal;
|
|
208
|
+
if (position === "head") {
|
|
209
|
+
const match = str.match(/^\s+/);
|
|
210
|
+
if (!match || !match[0]) return defaultVal;
|
|
211
|
+
return {
|
|
212
|
+
count: match[0].length,
|
|
213
|
+
start: 0,
|
|
214
|
+
end: match[0].length
|
|
215
|
+
};
|
|
216
|
+
} else {
|
|
217
|
+
const match = str.match(/\s+$/);
|
|
218
|
+
if (!match || match.index == null) return defaultVal;
|
|
219
|
+
return {
|
|
220
|
+
count: match[0].length,
|
|
221
|
+
start: match.index,
|
|
222
|
+
end: str.length
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Checks whether the start or end of a string is adjacent to punctuation.
|
|
228
|
+
* @example `。 hello`, `head` -> true
|
|
229
|
+
* @example `hello .`, `tail` -> true
|
|
230
|
+
*/
|
|
231
|
+
function hasPunctuation(str, position = "head") {
|
|
232
|
+
if (!str) return false;
|
|
233
|
+
str = str.trim();
|
|
234
|
+
if (position === "head") return isPunctuation(str[0]);
|
|
235
|
+
else return isPunctuation(str[str.length - 1]);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Gets the character adjacent to the start or end of a string.
|
|
239
|
+
*/
|
|
240
|
+
function getAdjacentChar(str, position) {
|
|
241
|
+
if (!str) return void 0;
|
|
242
|
+
str = str.trim();
|
|
243
|
+
return position === "head" ? str[0] : str[str.length - 1];
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Extracts the plain-text value of a phrasing node.
|
|
247
|
+
* If the node does not expose `value`, recursively concatenates the text from its children.
|
|
248
|
+
*/
|
|
249
|
+
function getNodeValue(node) {
|
|
250
|
+
if (!node) return;
|
|
251
|
+
if ("value" in node) return node.value;
|
|
252
|
+
if (hasChildren(node)) return node.children.map(getNodeValue).join("") || void 0;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Gets whitespace and punctuation information for text adjacent to a link or inline code node.
|
|
256
|
+
*/
|
|
257
|
+
function getSpaceContext(nodeContext) {
|
|
258
|
+
const { prev, next } = nodeContext;
|
|
259
|
+
const prevValue = getNodeValue(prev);
|
|
260
|
+
const nextValue = getNodeValue(next);
|
|
261
|
+
return {
|
|
262
|
+
prev: {
|
|
263
|
+
value: prevValue,
|
|
264
|
+
whiteSpace: getWhiteSpace(prevValue, "tail"),
|
|
265
|
+
hasPunctuation: hasPunctuation(prevValue, "tail"),
|
|
266
|
+
punctuationType: isFullwidthPunctuation(getAdjacentChar(prevValue, "tail")) ? "full" : "half"
|
|
267
|
+
},
|
|
268
|
+
next: {
|
|
269
|
+
value: nextValue,
|
|
270
|
+
whiteSpace: getWhiteSpace(nextValue),
|
|
271
|
+
hasPunctuation: hasPunctuation(nextValue),
|
|
272
|
+
punctuationType: isFullwidthPunctuation(getAdjacentChar(nextValue, "head")) ? "full" : "half"
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Validates whether a spacing run contains exactly one required space.
|
|
278
|
+
*/
|
|
279
|
+
function validateSingleRequiredSpace(count, missingSpaceMessageId, multipleSpacesMessageId) {
|
|
280
|
+
if (count < 1) return missingSpaceMessageId;
|
|
281
|
+
if (count > 1) return multipleSpacesMessageId;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Validates the spacing before a link when the previous character is punctuation.
|
|
285
|
+
*/
|
|
286
|
+
function validateSpaceBeforeLinkAfterPunctuation(context) {
|
|
287
|
+
if (OPENING_PAIRED_PUNCTUATION.has(getAdjacentChar(context.value, "tail") || "")) {
|
|
288
|
+
if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterPunctuation);
|
|
292
|
+
if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Validates the spacing between the previous node and the current link.
|
|
296
|
+
*/
|
|
297
|
+
function validateSpaceBeforeLink(context) {
|
|
298
|
+
if (context.hasPunctuation) return validateSpaceBeforeLinkAfterPunctuation(context);
|
|
299
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesBeforeLink);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Validates the spacing after a link when the next character is punctuation.
|
|
303
|
+
*/
|
|
304
|
+
function validateSpaceAfterLinkBeforePunctuation(context) {
|
|
305
|
+
if (isDashPunctuation(getAdjacentChar(context.value, "head"))) return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceAfterLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterLink);
|
|
306
|
+
if (getLikeAnchor(context.value) || isCustomContainerMarker(context.value)) return;
|
|
307
|
+
if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceAfterLink;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Validates the spacing between the current link and the next node.
|
|
311
|
+
*/
|
|
312
|
+
function validateSpaceAfterLink(context) {
|
|
313
|
+
if (context.hasPunctuation) return validateSpaceAfterLinkBeforePunctuation(context);
|
|
314
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceAfterLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterLink);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Validates whether the spacing around a link node follows the typography rules.
|
|
318
|
+
* - Regular text and links should be separated by a single space.
|
|
319
|
+
* - Fullwidth punctuation usually touches the link without spaces.
|
|
320
|
+
* - Halfwidth punctuation, hyphens, and similar cases are handled by dedicated rules.
|
|
321
|
+
*/
|
|
322
|
+
function validateSpace(nodeContext) {
|
|
323
|
+
const { prev, next } = nodeContext;
|
|
324
|
+
const spaceContext = getSpaceContext(nodeContext);
|
|
325
|
+
if (!prev || !spaceContext.prev) return;
|
|
326
|
+
const beforeLinkIssue = validateSpaceBeforeLink(spaceContext.prev);
|
|
327
|
+
if (beforeLinkIssue) return beforeLinkIssue;
|
|
328
|
+
if (!next || !spaceContext.next) return;
|
|
329
|
+
return validateSpaceAfterLink(spaceContext.next);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/rules/space-between-link/index.ts
|
|
334
|
+
const RULE_NAME$1 = "space-between-link";
|
|
335
|
+
const BEFORE_LINK_MESSAGE_IDS = new Set([
|
|
336
|
+
LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink,
|
|
337
|
+
LINK_SPACE_MESSAGE_IDS.multipleSpacesBeforeLink,
|
|
338
|
+
LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterPunctuation,
|
|
339
|
+
LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink
|
|
340
|
+
]);
|
|
341
|
+
var space_between_link_default = createRule({
|
|
342
|
+
name: RULE_NAME$1,
|
|
343
|
+
meta: {
|
|
344
|
+
type: "layout",
|
|
345
|
+
docs: { description: "Enforce spacing around Markdown links: one space next to text, no spaces next to punctuation." },
|
|
346
|
+
messages: {
|
|
347
|
+
missingSpaceBeforeLink: "A space is required before the link.",
|
|
348
|
+
missingSpaceAfterLink: "A space is required after the link.",
|
|
349
|
+
multipleSpacesBeforeLink: "Use exactly one space before the link.",
|
|
350
|
+
multipleSpacesAfterLink: "Use exactly one space after the link.",
|
|
351
|
+
multipleSpacesAfterPunctuation: "Use one space after punctuation.",
|
|
352
|
+
unexpectedSpaceBeforeLink: "Do not add a space between punctuation and the link.",
|
|
353
|
+
unexpectedSpaceAfterLink: "Do not add a space between the link and punctuation."
|
|
354
|
+
},
|
|
355
|
+
fixable: "whitespace",
|
|
356
|
+
schema: []
|
|
357
|
+
},
|
|
358
|
+
defaultOptions: [],
|
|
359
|
+
create(context) {
|
|
360
|
+
return { link(node) {
|
|
361
|
+
const { position, start, end } = getNodePosition(node);
|
|
362
|
+
if (!position) return;
|
|
363
|
+
const nodeContext = getNodeContext(context, node);
|
|
364
|
+
const spaceContext = getSpaceContext(nodeContext);
|
|
365
|
+
const messageId = validateSpace(nodeContext);
|
|
366
|
+
if (!messageId) return;
|
|
367
|
+
if (BEFORE_LINK_MESSAGE_IDS.has(messageId) && spaceContext.prev) {
|
|
368
|
+
const { count } = spaceContext.prev.whiteSpace;
|
|
369
|
+
const replaceText = messageId === LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink ? "" : " ";
|
|
370
|
+
context.report({
|
|
371
|
+
node,
|
|
372
|
+
messageId,
|
|
373
|
+
fix(fixer) {
|
|
374
|
+
return fixer.replaceTextRange([start - count, start], replaceText);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (spaceContext.next) {
|
|
380
|
+
const { count } = spaceContext.next.whiteSpace;
|
|
381
|
+
const replaceText = messageId === LINK_SPACE_MESSAGE_IDS.unexpectedSpaceAfterLink ? "" : " ";
|
|
382
|
+
context.report({
|
|
383
|
+
node,
|
|
384
|
+
messageId,
|
|
385
|
+
fix(fixer) {
|
|
386
|
+
return fixer.replaceTextRange([end, end + count], replaceText);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
} };
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/utils/markdown.ts
|
|
396
|
+
const language = new MarkdownLanguage({ mode: "commonmark" });
|
|
397
|
+
/**
|
|
398
|
+
* Parses Markdown with the same CommonMark language implementation used by the
|
|
399
|
+
* plugin tests and returns both the mdast tree and ESLint SourceCode wrapper.
|
|
400
|
+
*/
|
|
401
|
+
function parseMarkdown(markdown$1) {
|
|
402
|
+
const file = {
|
|
403
|
+
path: "test.md",
|
|
404
|
+
physicalPath: "test.md",
|
|
405
|
+
bom: false,
|
|
406
|
+
body: markdown$1
|
|
407
|
+
};
|
|
408
|
+
const parseResult = language.parse(file, { languageOptions: {
|
|
409
|
+
...language.defaultLanguageOptions,
|
|
410
|
+
frontmatter: "yaml"
|
|
411
|
+
} });
|
|
412
|
+
if (!parseResult.ok) throw new Error(parseResult.errors[0]?.message ?? "Failed to parse markdown.");
|
|
413
|
+
return {
|
|
414
|
+
ast: parseResult.ast,
|
|
415
|
+
sourceCode: language.createSourceCode(file, parseResult)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/utils/rules/heading.ts
|
|
421
|
+
/**
|
|
422
|
+
* Returns true when the Markdown document starts with YAML frontmatter.
|
|
423
|
+
*/
|
|
424
|
+
function hasFrontmatter(markdown$1, prevNode) {
|
|
425
|
+
if (prevNode?.type === "thematicBreak") markdown$1 = `---\n${markdown$1}`;
|
|
426
|
+
const { ast } = parseMarkdown(markdown$1);
|
|
427
|
+
return ast.children[0]?.type === "yaml";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/rules/valid-heading-anchor/index.ts
|
|
432
|
+
const RULE_NAME = "valid-heading-anchor";
|
|
433
|
+
const MESSAGE_IDS = {
|
|
434
|
+
missingAnchor: "missingAnchor",
|
|
435
|
+
invalidHeadingAnchor: "invalidHeadingAnchor"
|
|
436
|
+
};
|
|
437
|
+
var valid_heading_anchor_default = createRule({
|
|
438
|
+
name: RULE_NAME,
|
|
439
|
+
meta: {
|
|
440
|
+
type: "layout",
|
|
441
|
+
docs: { description: "Require strict lowercase anchors for headings that contain CJK text." },
|
|
442
|
+
messages: {
|
|
443
|
+
missingAnchor: "Non-ASCII heading must have an anchor in the format \"{#lowercase-anchor}\".",
|
|
444
|
+
invalidHeadingAnchor: "Anchor must use lowercase letters and valid characters only."
|
|
445
|
+
},
|
|
446
|
+
fixable: "whitespace",
|
|
447
|
+
schema: []
|
|
448
|
+
},
|
|
449
|
+
defaultOptions: [],
|
|
450
|
+
create(context) {
|
|
451
|
+
return { heading(node) {
|
|
452
|
+
const { position, start, end } = getNodePosition(node);
|
|
453
|
+
if (!position) return;
|
|
454
|
+
const source = context.sourceCode.text.slice(start, end);
|
|
455
|
+
if (isStrictAnchor(source) || !hasChinese(source)) return;
|
|
456
|
+
if (hasFrontmatter(source, getNodeContext(context, node).prev)) return;
|
|
457
|
+
const liked = getLikeAnchor(source);
|
|
458
|
+
if (!liked) {
|
|
459
|
+
context.report({
|
|
460
|
+
node,
|
|
461
|
+
messageId: MESSAGE_IDS.missingAnchor
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const { rawLikeAnchor, isLikeAnchor } = liked;
|
|
466
|
+
const compensate = calcAnchorPositionCompensate(source);
|
|
467
|
+
const remainingContent = source.slice(0, -rawLikeAnchor.length - compensate).trim();
|
|
468
|
+
const anchor = normalizeAnchor(rawLikeAnchor);
|
|
469
|
+
if (rawLikeAnchor === anchor) return;
|
|
470
|
+
context.report({
|
|
471
|
+
node,
|
|
472
|
+
messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
|
|
473
|
+
fix(fixer) {
|
|
474
|
+
return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
} };
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/rules/index.ts
|
|
483
|
+
const rules = {
|
|
484
|
+
"space-between-link": space_between_link_default,
|
|
485
|
+
"valid-heading-anchor": valid_heading_anchor_default
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/index.ts
|
|
490
|
+
const plugin = {
|
|
491
|
+
rules,
|
|
492
|
+
processors: markdown.processors,
|
|
493
|
+
languages: {
|
|
494
|
+
commonmark: new MarkdownLanguage({ mode: "commonmark" }),
|
|
495
|
+
gfm: new MarkdownLanguage({ mode: "gfm" })
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
const allRuleEntries = Object.keys(rules).map((ruleName) => [`md-style/${ruleName}`, "error"]);
|
|
499
|
+
const recommendedRules = Object.fromEntries(allRuleEntries);
|
|
500
|
+
const allRules = Object.fromEntries(allRuleEntries);
|
|
501
|
+
const configs = {
|
|
502
|
+
recommended: {
|
|
503
|
+
name: "md-style/recommended",
|
|
504
|
+
files: ["**/*.md"],
|
|
505
|
+
plugins: { "md-style": plugin },
|
|
506
|
+
language: "md-style/commonmark",
|
|
507
|
+
rules: recommendedRules
|
|
508
|
+
},
|
|
509
|
+
all: {
|
|
510
|
+
name: "md-style/all",
|
|
511
|
+
files: ["**/*.md"],
|
|
512
|
+
plugins: { "md-style": plugin },
|
|
513
|
+
language: "md-style/commonmark",
|
|
514
|
+
rules: allRules
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const mdStylePlugin = Object.assign(plugin, { configs });
|
|
518
|
+
var src_default = mdStylePlugin;
|
|
519
|
+
|
|
520
|
+
//#endregion
|
|
521
|
+
export { configs, src_default as default, plugin };
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-md-style",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0-beta.2",
|
|
5
|
+
"packageManager": "pnpm@10.21.0",
|
|
6
|
+
"description": "ESLint plugin for enforcing style rules in Markdown-based documentation",
|
|
7
|
+
"author": "noisefan <noisefan@163.com>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://github.com/NoiseFan/eslint-plugin-md-style#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+github.com:NoiseFan/eslint-plugin-md-style.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/NoiseFan/eslint-plugin-md-style/issues"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.mjs",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"main": "./dist/index.mjs",
|
|
22
|
+
"module": "./dist/index.mjs",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.19.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"lint:fix": "pnpm run lint --fix",
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"dev": "tsdown --watch",
|
|
38
|
+
"test": "vitest",
|
|
39
|
+
"test:cov": "vitest --coverage",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"release": "bumpp",
|
|
42
|
+
"prepublishOnly": "pnpm run build",
|
|
43
|
+
"prepare": "simple-git-hooks"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@eslint/markdown": "^7.5.1",
|
|
47
|
+
"eslint": "^9.0.0 || ^10.0.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@antfu/eslint-config": "^6.2.0",
|
|
52
|
+
"@eslint/markdown": "^7.5.1",
|
|
53
|
+
"@types/mdast": "^4.0.4",
|
|
54
|
+
"@types/node": "^24.10.1",
|
|
55
|
+
"@typescript-eslint/utils": "^8.46.4",
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
57
|
+
"bumpp": "^10.3.1",
|
|
58
|
+
"eslint": "9.39.1",
|
|
59
|
+
"eslint-plugin-format": "^1.0.2",
|
|
60
|
+
"eslint-vitest-rule-tester": "^3.0.0",
|
|
61
|
+
"lint-staged": "^16.4.0",
|
|
62
|
+
"simple-git-hooks": "^2.13.1",
|
|
63
|
+
"tinyglobby": "^0.2.16",
|
|
64
|
+
"tsdown": "^0.16.4",
|
|
65
|
+
"typescript": "^5.9.3",
|
|
66
|
+
"vitest": "^4.0.9"
|
|
67
|
+
},
|
|
68
|
+
"simple-git-hooks": {
|
|
69
|
+
"pre-commit": "pnpx lint-staged"
|
|
70
|
+
},
|
|
71
|
+
"lint-staged": {
|
|
72
|
+
"*.{js,ts,md,yml,yaml,json}": [
|
|
73
|
+
"eslint --cache --fix"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|