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 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
+ [![npm version](https://img.shields.io/npm/v/eslint-plugin-md-style)](https://www.npmjs.com/package/eslint-plugin-md-style)
4
+ [![npm downloads](https://img.shields.io/npm/dm/eslint-plugin-md-style)](https://www.npmjs.com/package/eslint-plugin-md-style)
5
+ [![codecov](https://codecov.io/gh/NoiseFan/eslint-plugin-md-style/graph/badge.svg)](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)
@@ -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
+ }