eslint-plugin-md-style 0.1.0-beta.1

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,113 @@
1
+ # eslint-plugin-md-style
2
+
3
+ ESLint plugin for enforcing style rules in Markdown-based documentation.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add -D eslint @eslint/markdown eslint-plugin-md-style
9
+ ```
10
+
11
+ `@eslint/markdown` is required because this plugin registers Markdown processors and languages on top of it.
12
+
13
+ ## Usage
14
+
15
+ Use the built-in flat configs to lint Markdown files directly.
16
+
17
+ ### With `@antfu/eslint-config`
18
+
19
+ ```ts
20
+ import antfu from '@antfu/eslint-config'
21
+ import docsStyle from 'eslint-plugin-md-style'
22
+
23
+ export default antfu(
24
+ {
25
+ formatters: true,
26
+ markdown: true,
27
+ },
28
+ docsStyle.configs.recommended,
29
+ )
30
+ ```
31
+
32
+ If you want full enforcement instead of the default recommended preset, replace `plugin.configs.recommended` with `plugin.configs.all`.
33
+
34
+ For partial adoption, you can start from `recommended` and override individual rules:
35
+
36
+ ```ts
37
+ import antfu from '@antfu/eslint-config'
38
+ import docsStyle from 'eslint-plugin-md-style'
39
+
40
+ export default antfu(
41
+ {
42
+ formatters: true,
43
+ markdown: true,
44
+ },
45
+ docsStyle.configs.recommended,
46
+ {
47
+ files: ['**/*.md'],
48
+ rules: {
49
+ 'docs-style/valid-heading-anchor': 'off',
50
+ },
51
+ },
52
+ )
53
+ ```
54
+
55
+ If you only want to enable part of the plugin, register the plugin and select rules manually:
56
+
57
+ ```ts
58
+ import antfu from '@antfu/eslint-config'
59
+ import plugin from 'eslint-plugin-md-style'
60
+
61
+ export default antfu(
62
+ {
63
+ formatters: true,
64
+ markdown: true,
65
+ },
66
+ {
67
+ files: ['**/*.md'],
68
+ plugins: {
69
+ 'docs-style': plugin,
70
+ },
71
+ language: 'docs-style/commonmark',
72
+ rules: {
73
+ 'docs-style/space-between-link': 'error',
74
+ 'docs-style/valid-heading-anchor': 'error',
75
+ },
76
+ },
77
+ )
78
+ ```
79
+
80
+ ### `recommended`
81
+
82
+ ```ts
83
+ import plugin from 'eslint-plugin-md-style'
84
+
85
+ export default [
86
+ plugin.configs.recommended,
87
+ ]
88
+ ```
89
+
90
+ `recommended` is the default entry for production use. It targets `**/*.md`, uses the plugin's `commonmark` language, and enables the stable rules currently recommended by this package.
91
+
92
+ ### `all`
93
+
94
+ ```ts
95
+ import plugin from 'eslint-plugin-md-style'
96
+
97
+ export default [
98
+ plugin.configs.all,
99
+ ]
100
+ ```
101
+
102
+ `all` enables every rule exported by this plugin. It is useful when you want full enforcement or when checking rule behavior during development.
103
+
104
+ ## Rules
105
+
106
+ | Rule | Included in `recommended` | Autofix |
107
+ | --- | --- | --- |
108
+ | `docs-style/space-between-link` | Yes | Yes |
109
+ | `docs-style/valid-heading-anchor` | Yes | Yes |
110
+
111
+ ## License
112
+
113
+ [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 DocsStylePlugin = ESLint.Plugin & {
11
+ configs: PluginConfigMap;
12
+ };
13
+ declare const docsStylePlugin: DocsStylePlugin;
14
+ //#endregion
15
+ export { DocsStylePlugin, configs, docsStylePlugin as default, plugin };
package/dist/index.mjs ADDED
@@ -0,0 +1,528 @@
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
+ function getNodePosition(node) {
14
+ const start = node.position?.start.offset;
15
+ const end = node.position?.end.offset;
16
+ if (start == null || end == null) return {
17
+ position: false,
18
+ start: 0,
19
+ end: 0
20
+ };
21
+ return {
22
+ position: true,
23
+ start,
24
+ end
25
+ };
26
+ }
27
+
28
+ //#endregion
29
+ //#region src/utils/ast.ts
30
+ /**
31
+ * Checks whether an unknown value behaves like an mdast parent node.
32
+ *
33
+ * This intentionally accepts unknown values because ESLint's ancestor API does
34
+ * not expose mdast-specific types.
35
+ */
36
+ function hasChildren(node) {
37
+ return !!node && typeof node === "object" && "children" in node && Array.isArray(node.children);
38
+ }
39
+ /**
40
+ * Narrows any mdast node to a parent-like node with a children array.
41
+ *
42
+ * The Markdown parser can return both container nodes and leaf nodes. This
43
+ * helper keeps traversal code type-safe without relying on a fixed list of
44
+ * container node types.
45
+ */
46
+ function isParentNode(node) {
47
+ return "children" in node && Array.isArray(node.children);
48
+ }
49
+ function getNodeContext(context, node) {
50
+ const parent = context.sourceCode.getAncestors(node).at(-1);
51
+ if (!hasChildren(parent)) return {
52
+ prev: void 0,
53
+ next: void 0,
54
+ current: node
55
+ };
56
+ const currentIndex = parent.children.findIndex((child) => child === node);
57
+ if (currentIndex === -1) return {
58
+ parent,
59
+ prev: void 0,
60
+ next: void 0,
61
+ current: node
62
+ };
63
+ return {
64
+ parent,
65
+ prev: parent.children[currentIndex - 1],
66
+ next: parent.children[currentIndex + 1],
67
+ current: node
68
+ };
69
+ }
70
+
71
+ //#endregion
72
+ //#region src/utils/rules/anchor.ts
73
+ /**
74
+ * Match the trailing anchor-like fragment from a heading string.
75
+ * @example `中文标题 {#Chinese-Title}` -> `{#Chinese-Title}`
76
+ * @example `使用 describe #Grouping Tests` -> `#Grouping Tests`
77
+ */
78
+ function getLikeAnchorMatch(str) {
79
+ const match = str.match(/(\{?#[\w\s`-]+\}?$)/);
80
+ return match ? match[0] : null;
81
+ }
82
+ /**
83
+ * Parse the trailing anchor-like fragment from a heading string.
84
+ * `isLike` is true when the fragment looks like a loose anchor such as
85
+ * `# Your First Test`; false when it already looks like a compact anchor.
86
+ * `rawLikeAnchor` is the cleaned anchor text without `{`, `}` or leading `#`.
87
+ * @example `# Your First Test` -> { isLike: true, rawLikeAnchor: 'Your First Test' }
88
+ * @example `{#built-in-slug}` -> { isLike: false, rawLikeAnchor: 'built-in-slug' }
89
+ */
90
+ function getLikeAnchor(str) {
91
+ if (str === void 0) return null;
92
+ const match = getLikeAnchorMatch(str);
93
+ if (!match) return null;
94
+ const rawLikeAnchor = match.replace(/(\{|\})/g, "").replace(/^#/, "").trimStart();
95
+ return {
96
+ isLikeAnchor: rawLikeAnchor.includes(" "),
97
+ rawLikeAnchor
98
+ };
99
+ }
100
+ /**
101
+ * Check if the string has an anchor.
102
+ * @example: {#chinese-anchor}
103
+ */
104
+ function isStrictAnchor(str) {
105
+ return /\s\{#[a-z0-9]+(?:-[a-z0-9]+)*\}/.test(str);
106
+ }
107
+ /**
108
+ * Check whether the string contains CJK Han characters.
109
+ */
110
+ function hasChinese(str) {
111
+ return /[\u4E00-\u9FA5]/.test(str);
112
+ }
113
+ /**
114
+ * Normalize raw anchor text into the strict anchor format content.
115
+ * - lowercase all letters
116
+ * - convert spaces to `-`
117
+ * - remove unsupported characters
118
+ * - trim leading/trailing `-`
119
+ */
120
+ function normalizeAnchor(anchor) {
121
+ return anchor.toLowerCase().replace(/\s/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
122
+ }
123
+ /**
124
+ * Count wrapper characters contributed by the trailing like-anchor fragment.
125
+ * The value is the length difference between the raw matched fragment and the
126
+ * cleaned anchor text returned by `getLikeAnchor`.
127
+ * @example `# 中文标题 {#Chinese-Title}` -> 3
128
+ * @example `## 使用 \`describe\` 编组测试 #Grouping Tests with \`describe\`` -> 1
129
+ */
130
+ function calcAnchorPositionCompensate(content) {
131
+ const match = getLikeAnchorMatch(content);
132
+ const anchor = getLikeAnchor(content);
133
+ if (!match || !anchor) return 0;
134
+ return match.length - anchor.rawLikeAnchor.length;
135
+ }
136
+
137
+ //#endregion
138
+ //#region src/utils/rules/link.ts
139
+ const LINK_SPACE_MESSAGE_IDS = {
140
+ missingSpaceBeforeLink: "missingSpaceBeforeLink",
141
+ missingSpaceAfterLink: "missingSpaceAfterLink",
142
+ multipleSpacesBeforeLink: "multipleSpacesBeforeLink",
143
+ multipleSpacesAfterLink: "multipleSpacesAfterLink",
144
+ multipleSpacesAfterPunctuation: "multipleSpacesAfterPunctuation",
145
+ unexpectedSpaceBeforeLink: "unexpectedSpaceBeforeLink",
146
+ unexpectedSpaceAfterLink: "unexpectedSpaceAfterLink"
147
+ };
148
+ const OPENING_PAIRED_PUNCTUATION = new Set([
149
+ "(",
150
+ "[",
151
+ "{",
152
+ "<",
153
+ "(",
154
+ "【",
155
+ "《",
156
+ "“",
157
+ "‘"
158
+ ]);
159
+ /**
160
+ * Checks whether the character is fullwidth punctuation.
161
+ * @example `。` -> true
162
+ * @example `,` -> false
163
+ */
164
+ function isFullwidthPunctuation(str) {
165
+ if (!str || str.length !== 1) return false;
166
+ return /^[\u3001-\u303F\uFE10-\uFE1F\uFE30-\uFE4F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65“”‘’…]$/u.test(str);
167
+ }
168
+ const DASH_PUNCTUATION_RE = /^[-\u2013\u2014\u2212]$/u;
169
+ /**
170
+ * Checks whether the character is hyphen-like punctuation.
171
+ * @example `—` -> true
172
+ * @example `.` -> false
173
+ */
174
+ function isDashPunctuation(str) {
175
+ if (!str || str.length !== 1) return false;
176
+ return DASH_PUNCTUATION_RE.test(str);
177
+ }
178
+ /**
179
+ * Checks whether adjacent text is a custom container marker on the next line.
180
+ *
181
+ * @deprecated Temporary workaround to prevent space-between-link from reporting
182
+ * false positives on custom containers. Remove this and handle the case in a
183
+ * dedicated custom container rule when one exists.
184
+ * @see https://vitepress.dev/guide/markdown#custom-containers
185
+ * @example `\n:::` -> true
186
+ * @example `\n::::` -> true
187
+ * @example `:::` -> false
188
+ */
189
+ function isCustomContainerMarker(str) {
190
+ return /^[ \t]*\n[ \t]*:{3,}[ \t]*$/u.test(str || "");
191
+ }
192
+ const PUNCTUATION_RE = /^\p{P}$/u;
193
+ /**
194
+ * Checks whether the character is punctuation.
195
+ * Covers fullwidth punctuation, halfwidth punctuation, and other Unicode punctuation characters.
196
+ * @example `。` -> true
197
+ * @example `$` -> false
198
+ */
199
+ function isPunctuation(str) {
200
+ if (!str || str.length !== 1) return false;
201
+ return PUNCTUATION_RE.test(str);
202
+ }
203
+ /**
204
+ * Gets the count and range of consecutive whitespace at the start or end of a string.
205
+ * @example ` text`, `head` -> { count: 2, start: 0, end: 2 }
206
+ * @example `text `, `tail` -> { count: 2, start: 4, end: 6 }
207
+ */
208
+ function getWhiteSpace(str, position = "head") {
209
+ const defaultVal = {
210
+ count: 0,
211
+ start: 0,
212
+ end: 0
213
+ };
214
+ if (!str || str.length === 0) return defaultVal;
215
+ if (position === "head") {
216
+ const match = str.match(/^\s+/);
217
+ if (!match || !match[0]) return defaultVal;
218
+ return {
219
+ count: match[0].length,
220
+ start: 0,
221
+ end: match[0].length
222
+ };
223
+ } else {
224
+ const match = str.match(/\s+$/);
225
+ if (!match || match.index == null) return defaultVal;
226
+ return {
227
+ count: match[0].length,
228
+ start: match.index,
229
+ end: str.length
230
+ };
231
+ }
232
+ }
233
+ /**
234
+ * Checks whether the start or end of a string is adjacent to punctuation.
235
+ * @example `。 hello`, `head` -> true
236
+ * @example `hello .`, `tail` -> true
237
+ */
238
+ function hasPunctuation(str, position = "head") {
239
+ if (!str) return false;
240
+ str = str.trim();
241
+ if (position === "head") return isPunctuation(str[0]);
242
+ else return isPunctuation(str[str.length - 1]);
243
+ }
244
+ /**
245
+ * Gets the character adjacent to the start or end of a string.
246
+ */
247
+ function getAdjacentChar(str, position) {
248
+ if (!str) return void 0;
249
+ str = str.trim();
250
+ return position === "head" ? str[0] : str[str.length - 1];
251
+ }
252
+ /**
253
+ * Extracts the plain-text value of a phrasing node.
254
+ * If the node does not expose `value`, recursively concatenates the text from its children.
255
+ */
256
+ function getNodeValue(node) {
257
+ if (!node) return;
258
+ if ("value" in node) return node.value;
259
+ if (isParentNode(node)) return node.children.map(getNodeValue).join("") || void 0;
260
+ }
261
+ /**
262
+ * Gets whitespace and punctuation information for text adjacent to a link or inline code node.
263
+ */
264
+ function getSpaceContext(nodeContext) {
265
+ const { prev, next } = nodeContext;
266
+ const prevValue = getNodeValue(prev);
267
+ const nextValue = getNodeValue(next);
268
+ return {
269
+ prev: {
270
+ value: prevValue,
271
+ whiteSpace: getWhiteSpace(prevValue, "tail"),
272
+ hasPunctuation: hasPunctuation(prevValue, "tail"),
273
+ punctuationType: isFullwidthPunctuation(getAdjacentChar(prevValue, "tail")) ? "full" : "half"
274
+ },
275
+ next: {
276
+ value: nextValue,
277
+ whiteSpace: getWhiteSpace(nextValue),
278
+ hasPunctuation: hasPunctuation(nextValue),
279
+ punctuationType: isFullwidthPunctuation(getAdjacentChar(nextValue, "head")) ? "full" : "half"
280
+ }
281
+ };
282
+ }
283
+ /**
284
+ * Validates whether a spacing run contains exactly one required space.
285
+ */
286
+ function validateSingleRequiredSpace(count, missingSpaceMessageId, multipleSpacesMessageId) {
287
+ if (count < 1) return missingSpaceMessageId;
288
+ if (count > 1) return multipleSpacesMessageId;
289
+ }
290
+ /**
291
+ * Validates the spacing before a link when the previous character is punctuation.
292
+ */
293
+ function validateSpaceBeforeLinkAfterPunctuation(context) {
294
+ if (OPENING_PAIRED_PUNCTUATION.has(getAdjacentChar(context.value, "tail") || "")) {
295
+ if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink;
296
+ return;
297
+ }
298
+ if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterPunctuation);
299
+ if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink;
300
+ }
301
+ /**
302
+ * Validates the spacing between the previous node and the current link.
303
+ */
304
+ function validateSpaceBeforeLink(context) {
305
+ if (context.hasPunctuation) return validateSpaceBeforeLinkAfterPunctuation(context);
306
+ return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesBeforeLink);
307
+ }
308
+ /**
309
+ * Validates the spacing after a link when the next character is punctuation.
310
+ */
311
+ function validateSpaceAfterLinkBeforePunctuation(context) {
312
+ if (isDashPunctuation(getAdjacentChar(context.value, "head"))) return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceAfterLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterLink);
313
+ if (getLikeAnchor(context.value) || isCustomContainerMarker(context.value)) return;
314
+ if (context.whiteSpace.count > 0) return LINK_SPACE_MESSAGE_IDS.unexpectedSpaceAfterLink;
315
+ }
316
+ /**
317
+ * Validates the spacing between the current link and the next node.
318
+ */
319
+ function validateSpaceAfterLink(context) {
320
+ if (context.hasPunctuation) return validateSpaceAfterLinkBeforePunctuation(context);
321
+ return validateSingleRequiredSpace(context.whiteSpace.count, LINK_SPACE_MESSAGE_IDS.missingSpaceAfterLink, LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterLink);
322
+ }
323
+ /**
324
+ * Validates whether the spacing around a link node follows the typography rules.
325
+ * - Regular text and links should be separated by a single space.
326
+ * - Fullwidth punctuation usually touches the link without spaces.
327
+ * - Halfwidth punctuation, hyphens, and similar cases are handled by dedicated rules.
328
+ */
329
+ function validateSpace(nodeContext) {
330
+ const { prev, next } = nodeContext;
331
+ const spaceContext = getSpaceContext(nodeContext);
332
+ if (!prev || !spaceContext.prev) return;
333
+ const beforeLinkIssue = validateSpaceBeforeLink(spaceContext.prev);
334
+ if (beforeLinkIssue) return beforeLinkIssue;
335
+ if (!next || !spaceContext.next) return;
336
+ return validateSpaceAfterLink(spaceContext.next);
337
+ }
338
+
339
+ //#endregion
340
+ //#region src/rules/space-between-link/index.ts
341
+ const RULE_NAME$1 = "space-between-link";
342
+ const BEFORE_LINK_MESSAGE_IDS = new Set([
343
+ LINK_SPACE_MESSAGE_IDS.missingSpaceBeforeLink,
344
+ LINK_SPACE_MESSAGE_IDS.multipleSpacesBeforeLink,
345
+ LINK_SPACE_MESSAGE_IDS.multipleSpacesAfterPunctuation,
346
+ LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink
347
+ ]);
348
+ var space_between_link_default = createRule({
349
+ name: RULE_NAME$1,
350
+ meta: {
351
+ type: "layout",
352
+ docs: { description: "Enforce spacing around Markdown links: one space next to text, no spaces next to punctuation." },
353
+ messages: {
354
+ missingSpaceBeforeLink: "A space is required before the link.",
355
+ missingSpaceAfterLink: "A space is required after the link.",
356
+ multipleSpacesBeforeLink: "Use exactly one space before the link.",
357
+ multipleSpacesAfterLink: "Use exactly one space after the link.",
358
+ multipleSpacesAfterPunctuation: "Use one space after punctuation.",
359
+ unexpectedSpaceBeforeLink: "Do not add a space between punctuation and the link.",
360
+ unexpectedSpaceAfterLink: "Do not add a space between the link and punctuation."
361
+ },
362
+ fixable: "whitespace",
363
+ schema: []
364
+ },
365
+ defaultOptions: [],
366
+ create(context) {
367
+ return { link(node) {
368
+ const { position, start, end } = getNodePosition(node);
369
+ if (!position) return;
370
+ const nodeContext = getNodeContext(context, node);
371
+ const spaceContext = getSpaceContext(nodeContext);
372
+ const messageId = validateSpace(nodeContext);
373
+ if (!messageId) return;
374
+ if (BEFORE_LINK_MESSAGE_IDS.has(messageId) && spaceContext.prev) {
375
+ const { count } = spaceContext.prev.whiteSpace;
376
+ const replaceText = messageId === LINK_SPACE_MESSAGE_IDS.unexpectedSpaceBeforeLink ? "" : " ";
377
+ context.report({
378
+ node,
379
+ messageId,
380
+ fix(fixer) {
381
+ return fixer.replaceTextRange([start - count, start], replaceText);
382
+ }
383
+ });
384
+ return;
385
+ }
386
+ if (spaceContext.next) {
387
+ const { count } = spaceContext.next.whiteSpace;
388
+ const replaceText = messageId === LINK_SPACE_MESSAGE_IDS.unexpectedSpaceAfterLink ? "" : " ";
389
+ context.report({
390
+ node,
391
+ messageId,
392
+ fix(fixer) {
393
+ return fixer.replaceTextRange([end, end + count], replaceText);
394
+ }
395
+ });
396
+ }
397
+ } };
398
+ }
399
+ });
400
+
401
+ //#endregion
402
+ //#region src/utils/markdown.ts
403
+ const language = new MarkdownLanguage({ mode: "commonmark" });
404
+ /**
405
+ * Parses Markdown with the same CommonMark language implementation used by the
406
+ * plugin tests and returns both the mdast tree and ESLint SourceCode wrapper.
407
+ */
408
+ function parseMarkdown(markdown$1) {
409
+ const file = {
410
+ path: "test.md",
411
+ physicalPath: "test.md",
412
+ bom: false,
413
+ body: markdown$1
414
+ };
415
+ const parseResult = language.parse(file, { languageOptions: {
416
+ ...language.defaultLanguageOptions,
417
+ frontmatter: "yaml"
418
+ } });
419
+ if (!parseResult.ok) throw new Error(parseResult.errors[0]?.message ?? "Failed to parse markdown.");
420
+ return {
421
+ ast: parseResult.ast,
422
+ sourceCode: language.createSourceCode(file, parseResult)
423
+ };
424
+ }
425
+
426
+ //#endregion
427
+ //#region src/utils/rules/heading.ts
428
+ /**
429
+ * Returns true when the Markdown document starts with YAML frontmatter.
430
+ */
431
+ function hasFrontmatter(markdown$1, prevNode) {
432
+ if (prevNode?.type === "thematicBreak") markdown$1 = `---\n${markdown$1}`;
433
+ const { ast } = parseMarkdown(markdown$1);
434
+ return ast.children[0]?.type === "yaml";
435
+ }
436
+
437
+ //#endregion
438
+ //#region src/rules/valid-heading-anchor/index.ts
439
+ const RULE_NAME = "valid-heading-anchor";
440
+ const MESSAGE_IDS = {
441
+ missingAnchor: "missingAnchor",
442
+ invalidHeadingAnchor: "invalidHeadingAnchor"
443
+ };
444
+ var valid_heading_anchor_default = createRule({
445
+ name: RULE_NAME,
446
+ meta: {
447
+ type: "layout",
448
+ docs: { description: "Require strict lowercase anchors for headings that contain CJK text." },
449
+ messages: {
450
+ missingAnchor: "Non-ASCII heading must have an anchor in the format \"{#lowercase-anchor}\".",
451
+ invalidHeadingAnchor: "Anchor must use lowercase letters and valid characters only."
452
+ },
453
+ fixable: "whitespace",
454
+ schema: []
455
+ },
456
+ defaultOptions: [],
457
+ create(context) {
458
+ return { heading(node) {
459
+ const { position, start, end } = getNodePosition(node);
460
+ if (!position) return;
461
+ const source = context.sourceCode.text.slice(start, end);
462
+ if (isStrictAnchor(source) || !hasChinese(source)) return;
463
+ if (hasFrontmatter(source, getNodeContext(context, node).prev)) return;
464
+ const liked = getLikeAnchor(source);
465
+ if (!liked) {
466
+ context.report({
467
+ node,
468
+ messageId: MESSAGE_IDS.missingAnchor
469
+ });
470
+ return;
471
+ }
472
+ const { rawLikeAnchor, isLikeAnchor } = liked;
473
+ const compensate = calcAnchorPositionCompensate(source);
474
+ const remainingContent = source.slice(0, -rawLikeAnchor.length - compensate).trim();
475
+ const anchor = normalizeAnchor(rawLikeAnchor);
476
+ if (rawLikeAnchor === anchor) return;
477
+ context.report({
478
+ node,
479
+ messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
480
+ fix(fixer) {
481
+ return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
482
+ }
483
+ });
484
+ } };
485
+ }
486
+ });
487
+
488
+ //#endregion
489
+ //#region src/rules/index.ts
490
+ const rules = {
491
+ "space-between-link": space_between_link_default,
492
+ "valid-heading-anchor": valid_heading_anchor_default
493
+ };
494
+
495
+ //#endregion
496
+ //#region src/index.ts
497
+ const plugin = {
498
+ rules,
499
+ processors: markdown.processors,
500
+ languages: {
501
+ commonmark: new MarkdownLanguage({ mode: "commonmark" }),
502
+ gfm: new MarkdownLanguage({ mode: "gfm" })
503
+ }
504
+ };
505
+ const allRuleEntries = Object.keys(rules).map((ruleName) => [`docs-style/${ruleName}`, "error"]);
506
+ const recommendedRules = Object.fromEntries(allRuleEntries);
507
+ const allRules = Object.fromEntries(allRuleEntries);
508
+ const configs = {
509
+ recommended: {
510
+ name: "docs-style/recommended",
511
+ files: ["**/*.md"],
512
+ plugins: { "docs-style": plugin },
513
+ language: "docs-style/commonmark",
514
+ rules: recommendedRules
515
+ },
516
+ all: {
517
+ name: "docs-style/all",
518
+ files: ["**/*.md"],
519
+ plugins: { "docs-style": plugin },
520
+ language: "docs-style/commonmark",
521
+ rules: allRules
522
+ }
523
+ };
524
+ const docsStylePlugin = Object.assign(plugin, { configs });
525
+ var src_default = docsStylePlugin;
526
+
527
+ //#endregion
528
+ 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.1",
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
+ }