eslint-markdown 0.9.1 → 0.11.0

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/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  [![npm package eslint-markdown latest version](https://img.shields.io/npm/v/eslint-markdown?label=eslint-markdown@latest&color=6358d4&labelColor=333333&logo=npm)](https://www.npmjs.com/package/eslint-markdown)
9
9
  [![npm package eslint-markdown next version](https://img.shields.io/npm/v/eslint-markdown/next?label=eslint-markdown@next&color=6358d4&labelColor=333333&logo=npm)](https://www.npmjs.com/package/eslint-markdown)
10
+ [![npm package eslint-markdown downloads per month](https://img.shields.io/npm/dm/eslint-markdown?label=downloads&color=6358d4&labelColor=333333&logo=npm)](https://www.npmjs.com/package/eslint-markdown)
10
11
 
11
12
  > [!IMPORTANT]
12
13
  >
@@ -24,6 +24,7 @@ export default function all(plugin: ESLint.Plugin): {
24
24
  readonly 'md/consistent-strong-style': "error";
25
25
  readonly 'md/consistent-thematic-break-style': "error";
26
26
  readonly 'md/consistent-unordered-list-style': "error";
27
+ readonly 'md/no-consecutive-blank-line': "error";
27
28
  readonly 'md/no-control-character': "error";
28
29
  readonly 'md/no-curly-quote': "error";
29
30
  readonly 'md/no-double-punctuation': "error";
@@ -34,6 +35,7 @@ export default function all(plugin: ESLint.Plugin): {
34
35
  readonly 'md/no-irregular-whitespace': "error";
35
36
  readonly 'md/no-tab': "error";
36
37
  readonly 'md/no-url-trailing-slash': "error";
38
+ readonly 'md/require-heading-id': "error";
37
39
  readonly 'md/require-image-title': "error";
38
40
  readonly 'md/require-link-title': "error";
39
41
  };
@@ -21,6 +21,7 @@ export default function stylistic(plugin: ESLint.Plugin): {
21
21
  readonly 'md/consistent-strong-style': "error";
22
22
  readonly 'md/consistent-thematic-break-style': "error";
23
23
  readonly 'md/consistent-unordered-list-style': "error";
24
+ readonly 'md/no-consecutive-blank-line': "error";
24
25
  readonly 'md/no-tab': "error";
25
26
  };
26
27
  };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @fileoverview Utility to escape `RegExp` special characters.
3
+ * @see https://github.com/sindresorhus/escape-string-regexp/tree/v5.0.0
4
+ */
5
+ /**
6
+ * Escape `RegExp` special characters.
7
+ *
8
+ * You can also use this to escape a string that is inserted into the middle of a regex, for example, into a character class.
9
+ *
10
+ * @param {string} string A string to escape.
11
+ * @returns {string} An escaped string.
12
+ * @example
13
+ * ```js
14
+ * import escapeStringRegexp from 'path/to/escape-string-regexp.js';
15
+ *
16
+ * const escapedString = escapeStringRegexp('How much $ for a 🦄?');
17
+ * //=> 'How much \\$ for a 🦄\\?'
18
+ *
19
+ * new RegExp(escapedString);
20
+ * ```
21
+ */
22
+ export default function escapeStringRegexp(string: string): string;
@@ -1,4 +1,5 @@
1
+ import escapeStringRegexp from './escape-string-regexp.js';
1
2
  import getElementsByTagName from './html.js';
2
3
  import isBlankLine from './is-blank-line.js';
3
4
  import SkipRanges from './skip-ranges.js';
4
- export { getElementsByTagName, isBlankLine, SkipRanges };
5
+ export { escapeStringRegexp, getElementsByTagName, isBlankLine, SkipRanges };
@@ -9,7 +9,8 @@ declare const _default: {
9
9
  'consistent-strong-style': import("../core/types.js").RuleModule<import("./consistent-strong-style.js").RuleOptions, "style">;
10
10
  'consistent-thematic-break-style': import("../core/types.js").RuleModule<import("./consistent-thematic-break-style.js").RuleOptions, "style">;
11
11
  'consistent-unordered-list-style': import("../core/types.js").RuleModule<import("./consistent-unordered-list-style.js").RuleOptions, "style">;
12
- 'no-control-character': import("../core/types.js").RuleModule<import("./no-control-character.js").RuleOptions, "noControlCharacter">;
12
+ 'no-consecutive-blank-line': import("../core/types.js").RuleModule<import("./no-consecutive-blank-line.js").RuleOptions, "noConsecutiveBlankLine">;
13
+ 'no-control-character': import("../core/types.js").RuleModule<import("./no-control-character.js").RuleOptions, import("./no-control-character.js").MessageIds>;
13
14
  'no-curly-quote': import("../core/types.js").RuleModule<import("./no-curly-quote.js").RuleOptions, "noCurlyQuote">;
14
15
  'no-double-punctuation': import("../core/types.js").RuleModule<import("./no-double-punctuation.js").RuleOptions, import("./no-double-punctuation.js").MessageIds>;
15
16
  'no-double-space': import("../core/types.js").RuleModule<import("./no-double-space.js").RuleOptions, import("./no-double-space.js").MessageIds>;
@@ -19,6 +20,7 @@ declare const _default: {
19
20
  'no-irregular-whitespace': import("../core/types.js").RuleModule<import("./no-irregular-whitespace.js").RuleOptions, "noIrregularWhitespace">;
20
21
  'no-tab': import("../core/types.js").RuleModule<import("./no-tab.js").RuleOptions, "noTab">;
21
22
  'no-url-trailing-slash': import("../core/types.js").RuleModule<[], "noUrlTrailingSlash">;
23
+ 'require-heading-id': import("../core/types.js").RuleModule<import("./require-heading-id.js").RuleOptions, import("./require-heading-id.js").MessageIds>;
22
24
  'require-image-title': import("../core/types.js").RuleModule<import("./require-image-title.js").RuleOptions, "requireImageTitle">;
23
25
  'require-link-title': import("../core/types.js").RuleModule<import("./require-link-title.js").RuleOptions, "requireLinkTitle">;
24
26
  };
@@ -0,0 +1,60 @@
1
+ declare const _default: {
2
+ meta: {
3
+ type: "layout";
4
+ docs: {
5
+ description: string;
6
+ url: string;
7
+ recommended: boolean;
8
+ stylistic: true;
9
+ };
10
+ fixable: "whitespace";
11
+ schema: {
12
+ type: "object";
13
+ properties: {
14
+ max: {
15
+ type: "integer";
16
+ minimum: number;
17
+ };
18
+ skipCode: {
19
+ oneOf: ({
20
+ type: "boolean";
21
+ items?: never;
22
+ uniqueItems?: never;
23
+ } | {
24
+ type: "array";
25
+ items: {
26
+ type: "string";
27
+ };
28
+ uniqueItems: true;
29
+ })[];
30
+ };
31
+ };
32
+ additionalProperties: false;
33
+ }[];
34
+ defaultOptions: [{
35
+ max: number;
36
+ skipCode: true;
37
+ }];
38
+ messages: {
39
+ noConsecutiveBlankLine: string;
40
+ };
41
+ language: string;
42
+ dialects: string[];
43
+ };
44
+ create(context: import("@eslint/core").RuleContext<{
45
+ LangOptions: import("@eslint/markdown").MarkdownLanguageOptions;
46
+ Code: import("@eslint/markdown").MarkdownSourceCode;
47
+ RuleOptions: RuleOptions;
48
+ Node: import("mdast").Node;
49
+ MessageIds: "noConsecutiveBlankLine";
50
+ }>): {
51
+ code(node: import("mdast").Code): void;
52
+ 'root:exit'(): void;
53
+ };
54
+ };
55
+ export default _default;
56
+ export type RuleOptions = [{
57
+ max: number;
58
+ skipCode: boolean | string[];
59
+ }];
60
+ export type MessageIds = "noConsecutiveBlankLine";
@@ -7,6 +7,7 @@ declare const _default: {
7
7
  recommended: boolean;
8
8
  stylistic: false;
9
9
  };
10
+ hasSuggestions: true;
10
11
  schema: {
11
12
  type: "object";
12
13
  properties: {
@@ -43,6 +44,7 @@ declare const _default: {
43
44
  }];
44
45
  messages: {
45
46
  noControlCharacter: string;
47
+ suggestRemove: string;
46
48
  };
47
49
  language: string;
48
50
  dialects: string[];
@@ -52,7 +54,7 @@ declare const _default: {
52
54
  Code: import("@eslint/markdown").MarkdownSourceCode;
53
55
  RuleOptions: RuleOptions;
54
56
  Node: import("mdast").Node;
55
- MessageIds: "noControlCharacter";
57
+ MessageIds: MessageIds;
56
58
  }>): {
57
59
  code(node: import("mdast").Code): void;
58
60
  inlineCode(node: import("mdast").InlineCode): void;
@@ -65,4 +67,4 @@ export type RuleOptions = [{
65
67
  skipCode: boolean | string[];
66
68
  skipInlineCode: boolean;
67
69
  }];
68
- export type MessageIds = "noControlCharacter";
70
+ export type MessageIds = "noControlCharacter" | "suggestRemove";
@@ -22,7 +22,7 @@ declare const _default: {
22
22
  rightDelimiter: {
23
23
  type: "string";
24
24
  };
25
- ignoreDepth: {
25
+ allowDepths: {
26
26
  type: "array";
27
27
  items: {
28
28
  enum: number[];
@@ -36,7 +36,7 @@ declare const _default: {
36
36
  defaultOptions: ["always", {
37
37
  leftDelimiter: string;
38
38
  rightDelimiter: string;
39
- ignoreDepth: never[];
39
+ allowDepths: never[];
40
40
  }];
41
41
  messages: {
42
42
  headingIdAlways: string;
@@ -59,7 +59,7 @@ export default _default;
59
59
  export type RuleOptions = ["always" | "never", {
60
60
  leftDelimiter: string;
61
61
  rightDelimiter: string;
62
- ignoreDepth: Heading["depth"][];
62
+ allowDepths: Heading["depth"][];
63
63
  }];
64
64
  export type MessageIds = "headingIdAlways" | "headingIdNever";
65
65
  import type { Heading } from 'mdast';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-markdown",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Lint your Markdown with ESLint. Additional rules for use with `@eslint/markdown`.🛠️",
@@ -48,6 +48,7 @@ export default function all(plugin) {
48
48
  'md/consistent-strong-style': 'error',
49
49
  'md/consistent-thematic-break-style': 'error',
50
50
  'md/consistent-unordered-list-style': 'error',
51
+ 'md/no-consecutive-blank-line': 'error',
51
52
  'md/no-control-character': 'error',
52
53
  'md/no-curly-quote': 'error',
53
54
  'md/no-double-punctuation': 'error',
@@ -58,6 +59,7 @@ export default function all(plugin) {
58
59
  'md/no-irregular-whitespace': 'error',
59
60
  'md/no-tab': 'error',
60
61
  'md/no-url-trailing-slash': 'error',
62
+ 'md/require-heading-id': 'error',
61
63
  'md/require-image-title': 'error',
62
64
  'md/require-link-title': 'error',
63
65
  },
@@ -45,6 +45,7 @@ export default function stylistic(plugin) {
45
45
  'md/consistent-strong-style': 'error',
46
46
  'md/consistent-thematic-break-style': 'error',
47
47
  'md/consistent-unordered-list-style': 'error',
48
+ 'md/no-consecutive-blank-line': 'error',
48
49
  'md/no-tab': 'error',
49
50
  },
50
51
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @fileoverview Utility to escape `RegExp` special characters.
3
+ * @see https://github.com/sindresorhus/escape-string-regexp/tree/v5.0.0
4
+ */
5
+
6
+ /*
7
+ * MIT License
8
+ *
9
+ * Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
10
+ *
11
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ * of this software and associated documentation files (the "Software"), to deal
13
+ * in the Software without restriction, including without limitation the rights
14
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ * copies of the Software, and to permit persons to whom the Software is
16
+ * furnished to do so, subject to the following conditions:
17
+ *
18
+ * The above copyright notice and this permission notice shall be included in all
19
+ * copies or substantial portions of the Software.
20
+ *
21
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ * SOFTWARE.
28
+ */
29
+
30
+ // --------------------------------------------------------------------------------
31
+ // Export
32
+ // --------------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Escape `RegExp` special characters.
36
+ *
37
+ * You can also use this to escape a string that is inserted into the middle of a regex, for example, into a character class.
38
+ *
39
+ * @param {string} string A string to escape.
40
+ * @returns {string} An escaped string.
41
+ * @example
42
+ * ```js
43
+ * import escapeStringRegexp from 'path/to/escape-string-regexp.js';
44
+ *
45
+ * const escapedString = escapeStringRegexp('How much $ for a 🦄?');
46
+ * //=> 'How much \\$ for a 🦄\\?'
47
+ *
48
+ * new RegExp(escapedString);
49
+ * ```
50
+ */
51
+ export default function escapeStringRegexp(string) {
52
+ // Escape characters with special meaning either inside or outside character sets.
53
+ // Use a simple backslash escape when it's always valid, and a `\xnn` escape
54
+ // when the simpler form would be disallowed by Unicode patterns' stricter grammar.
55
+ return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
56
+ }
@@ -1,5 +1,6 @@
1
+ import escapeStringRegexp from './escape-string-regexp.js';
1
2
  import getElementsByTagName from './html.js';
2
3
  import isBlankLine from './is-blank-line.js';
3
4
  import SkipRanges from './skip-ranges.js';
4
5
 
5
- export { getElementsByTagName, isBlankLine, SkipRanges };
6
+ export { escapeStringRegexp, getElementsByTagName, isBlankLine, SkipRanges };
@@ -14,8 +14,8 @@ import consistentStrongStyle from './consistent-strong-style.js';
14
14
  import consistentThematicBreakStyle from './consistent-thematic-break-style.js';
15
15
  import consistentUnorderedListStyle from './consistent-unordered-list-style.js';
16
16
  // import enCapitalization from './en-capitalization.js';
17
- // import headingId from './heading-id.js';
18
17
  // import noBoldParagraph from './no-bold-paragraph.js';
18
+ import noConsecutiveBlankLine from './no-consecutive-blank-line.js';
19
19
  import noControlCharacter from './no-control-character.js';
20
20
  import noCurlyQuote from './no-curly-quote.js';
21
21
  import noDoublePunctuation from './no-double-punctuation.js';
@@ -26,6 +26,7 @@ import noIrregularDash from './no-irregular-dash.js';
26
26
  import noIrregularWhitespace from './no-irregular-whitespace.js';
27
27
  import noTab from './no-tab.js';
28
28
  import noUrlTrailingSlash from './no-url-trailing-slash.js';
29
+ import requireHeadingId from './require-heading-id.js';
29
30
  import requireImageTitle from './require-image-title.js';
30
31
  import requireLinkTitle from './require-link-title.js';
31
32
 
@@ -42,8 +43,8 @@ export default {
42
43
  'consistent-thematic-break-style': consistentThematicBreakStyle,
43
44
  'consistent-unordered-list-style': consistentUnorderedListStyle,
44
45
  // 'en-capitalization': enCapitalization,
45
- // 'heading-id': headingId,
46
46
  // 'no-bold-paragraph': noBoldParagraph,
47
+ 'no-consecutive-blank-line': noConsecutiveBlankLine,
47
48
  'no-control-character': noControlCharacter,
48
49
  'no-curly-quote': noCurlyQuote,
49
50
  'no-double-punctuation': noDoublePunctuation,
@@ -54,6 +55,7 @@ export default {
54
55
  'no-irregular-whitespace': noIrregularWhitespace,
55
56
  'no-tab': noTab,
56
57
  'no-url-trailing-slash': noUrlTrailingSlash,
58
+ 'require-heading-id': requireHeadingId,
57
59
  'require-image-title': requireImageTitle,
58
60
  'require-link-title': requireLinkTitle,
59
61
  };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @fileoverview Rule to disallow consecutive blank lines.
3
+ * @author lumir(lumirlumir)
4
+ * @see https://github.com/DavidAnson/markdownlint/blob/main/lib/md012.mjs
5
+ */
6
+
7
+ // --------------------------------------------------------------------------------
8
+ // Import
9
+ // --------------------------------------------------------------------------------
10
+
11
+ import { isBlankLine, SkipRanges } from '../core/ast/index.js';
12
+ import { URL_RULE_DOCS } from '../core/constants.js';
13
+
14
+ // --------------------------------------------------------------------------------
15
+ // Typedef
16
+ // --------------------------------------------------------------------------------
17
+
18
+ /**
19
+ * @import { RuleModule } from '../core/types.js';
20
+ * @typedef {[{ max: number, skipCode: boolean | string[] }]} RuleOptions
21
+ * @typedef {'noConsecutiveBlankLine'} MessageIds
22
+ */
23
+
24
+ // --------------------------------------------------------------------------------
25
+ // Rule Definition
26
+ // --------------------------------------------------------------------------------
27
+
28
+ /** @type {RuleModule<RuleOptions, MessageIds>} */
29
+ export default {
30
+ meta: {
31
+ type: 'layout',
32
+
33
+ docs: {
34
+ description: 'Disallow consecutive blank lines',
35
+ url: URL_RULE_DOCS('no-consecutive-blank-line'),
36
+ recommended: false,
37
+ stylistic: true,
38
+ },
39
+
40
+ fixable: 'whitespace',
41
+
42
+ schema: [
43
+ {
44
+ type: 'object',
45
+ properties: {
46
+ max: {
47
+ type: 'integer',
48
+ minimum: 1,
49
+ },
50
+ skipCode: {
51
+ oneOf: [
52
+ {
53
+ type: 'boolean',
54
+ },
55
+ {
56
+ type: 'array',
57
+ items: {
58
+ type: 'string',
59
+ },
60
+ uniqueItems: true,
61
+ },
62
+ ],
63
+ },
64
+ },
65
+ additionalProperties: false,
66
+ },
67
+ ],
68
+
69
+ defaultOptions: [
70
+ {
71
+ max: 1,
72
+ skipCode: true,
73
+ },
74
+ ],
75
+
76
+ messages: {
77
+ noConsecutiveBlankLine:
78
+ 'More than {{ max }} consecutive blank line(s) are not allowed.',
79
+ },
80
+
81
+ language: 'markdown',
82
+
83
+ dialects: ['commonmark', 'gfm'],
84
+ },
85
+
86
+ create(context) {
87
+ const { options, sourceCode } = context;
88
+ const [{ max, skipCode }] = options;
89
+ const { lines, text } = sourceCode;
90
+
91
+ const skipRanges = new SkipRanges();
92
+
93
+ return {
94
+ code(node) {
95
+ if (
96
+ Array.isArray(skipCode) ? node.lang && skipCode.includes(node.lang) : skipCode
97
+ )
98
+ skipRanges.push(sourceCode.getRange(node)); // Store range information of `Code`.
99
+ },
100
+
101
+ 'root:exit'() {
102
+ let consecutiveBlankLineCount = 0;
103
+
104
+ for (let currentLineIdx = 0; currentLineIdx < lines.length; currentLineIdx++) {
105
+ const startLoc = /** @type {const} */ ({
106
+ line: currentLineIdx + 1,
107
+ column: 1,
108
+ });
109
+ const endLoc = /** @type {const} */ ({
110
+ line: currentLineIdx + 2,
111
+ column: 1,
112
+ });
113
+
114
+ if (
115
+ skipRanges.includes(sourceCode.getIndexFromLoc(startLoc)) ||
116
+ !isBlankLine(lines[currentLineIdx])
117
+ ) {
118
+ consecutiveBlankLineCount = 0;
119
+ } else {
120
+ consecutiveBlankLineCount++;
121
+ }
122
+
123
+ if (max < consecutiveBlankLineCount) {
124
+ const lastAllowedBlankLineIdx =
125
+ currentLineIdx - consecutiveBlankLineCount + max;
126
+
127
+ context.report({
128
+ loc: {
129
+ start: startLoc,
130
+ end: endLoc,
131
+ },
132
+
133
+ data: {
134
+ max,
135
+ },
136
+
137
+ messageId: 'noConsecutiveBlankLine',
138
+
139
+ fix(fixer) {
140
+ /*
141
+ * When the consecutive blank-line run reaches EOF, the fixer must remove the
142
+ * whole excess tail at once instead of removing only the current line.
143
+ *
144
+ * `currentLineIdx + 1 === lines.length` means the current line is the final
145
+ * logical line in `sourceCode.lines`. In a case like `foo\n\n\n ` with
146
+ * `max: 1`, the final line is still a blank line, but it may contain spaces.
147
+ * If we only remove from the current line start, those trailing spaces can
148
+ * survive and produce `foo\n ` instead of the intended `foo\n`.
149
+ *
150
+ * The start of the removal range is calculated from the last blank line that
151
+ * is still allowed by `max`:
152
+ *
153
+ * currentLineIdx - consecutiveBlankLineCount
154
+ * -> zero-based index of the first line in the current blank-line run
155
+ *
156
+ * + max
157
+ * -> zero-based index of the last blank line we want to keep
158
+ *
159
+ * + 1
160
+ * -> convert the zero-based line index to ESLint's one-based loc line
161
+ *
162
+ * The column is `lines[lastAllowedBlankLineIdx].length + 1`, which points to the
163
+ * end of that allowed blank line. The range then ends at `text.length`, so
164
+ * everything after the allowed blank line is removed, including remaining blank
165
+ * lines, line endings, and any spaces on the final blank line.
166
+ *
167
+ * Example with `foo\n\n\n ` and `max: 1`:
168
+ *
169
+ * lines = ['foo', '', '', ' ']
170
+ * currentLineIdx = 3
171
+ * consecutiveBlankLineCount = 3
172
+ * max = 1
173
+ *
174
+ * lastAllowedBlankLineIdx = 3 - 3 + 1 = 1
175
+ *
176
+ * So the fixer removes from the end of line 2 to EOF, producing `foo\n`.
177
+ */
178
+ if (currentLineIdx + 1 === lines.length) {
179
+ return fixer.removeRange([
180
+ sourceCode.getIndexFromLoc({
181
+ line: lastAllowedBlankLineIdx + 1,
182
+ column: lines[lastAllowedBlankLineIdx].length + 1,
183
+ }),
184
+ text.length,
185
+ ]);
186
+ } else {
187
+ return fixer.removeRange([
188
+ sourceCode.getIndexFromLoc(startLoc),
189
+ sourceCode.getIndexFromLoc(endLoc),
190
+ ]);
191
+ }
192
+ },
193
+ });
194
+ }
195
+ }
196
+ },
197
+ };
198
+ },
199
+ };
@@ -17,7 +17,7 @@ import { URL_RULE_DOCS } from '../core/constants.js';
17
17
  /**
18
18
  * @import { RuleModule } from '../core/types.js';
19
19
  * @typedef {[{ allow: string[], skipCode: boolean | string[], skipInlineCode: boolean }]} RuleOptions
20
- * @typedef {'noControlCharacter'} MessageIds
20
+ * @typedef {'noControlCharacter' | 'suggestRemove'} MessageIds
21
21
  */
22
22
 
23
23
  // --------------------------------------------------------------------------------
@@ -43,6 +43,8 @@ export default {
43
43
  stylistic: false,
44
44
  },
45
45
 
46
+ hasSuggestions: true,
47
+
46
48
  schema: [
47
49
  {
48
50
  type: 'object',
@@ -86,6 +88,7 @@ export default {
86
88
 
87
89
  messages: {
88
90
  noControlCharacter: 'Control character `{{ controlCharacter }}` is not allowed.',
91
+ suggestRemove: 'Remove control character `{{ controlCharacter }}`.',
89
92
  },
90
93
 
91
94
  language: 'markdown',
@@ -124,6 +127,8 @@ export default {
124
127
 
125
128
  if (skipRanges.includes(startOffset)) continue;
126
129
 
130
+ const controlCharacterCode = `U+${controlCharacter.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`;
131
+
127
132
  context.report({
128
133
  loc: {
129
134
  start: sourceCode.getLocFromIndex(startOffset),
@@ -131,10 +136,24 @@ export default {
131
136
  },
132
137
 
133
138
  data: {
134
- controlCharacter: `U+${controlCharacter.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`,
139
+ controlCharacter: controlCharacterCode,
135
140
  },
136
141
 
137
142
  messageId: 'noControlCharacter',
143
+
144
+ suggest: [
145
+ {
146
+ messageId: 'suggestRemove',
147
+
148
+ data: {
149
+ controlCharacter: controlCharacterCode,
150
+ },
151
+
152
+ fix(fixer) {
153
+ return fixer.removeRange([startOffset, endOffset]);
154
+ },
155
+ },
156
+ ],
138
157
  });
139
158
  }
140
159
  },
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @fileoverview Rule to enforce the use of heading IDs.
3
+ * @author lumir(lumirlumir)
4
+ */
5
+
6
+ // --------------------------------------------------------------------------------
7
+ // Import
8
+ // --------------------------------------------------------------------------------
9
+
10
+ import { escapeStringRegexp } from '../core/ast/index.js';
11
+ import { URL_RULE_DOCS } from '../core/constants.js';
12
+
13
+ // --------------------------------------------------------------------------------
14
+ // Typedef
15
+ // --------------------------------------------------------------------------------
16
+
17
+ /**
18
+ * @import { Heading } from 'mdast';
19
+ * @import { RuleModule } from '../core/types.js';
20
+ * @typedef {['always' | 'never', { leftDelimiter: string, rightDelimiter: string, allowDepths: Heading['depth'][] }]} RuleOptions
21
+ * @typedef {'headingIdAlways' | 'headingIdNever'} MessageIds
22
+ */
23
+
24
+ // --------------------------------------------------------------------------------
25
+ // Rule Definition
26
+ // --------------------------------------------------------------------------------
27
+
28
+ /** @type {RuleModule<RuleOptions, MessageIds>} */
29
+ export default {
30
+ meta: {
31
+ type: 'problem',
32
+
33
+ docs: {
34
+ description: 'Enforce the use of heading IDs',
35
+ url: URL_RULE_DOCS('require-heading-id'),
36
+ recommended: false,
37
+ stylistic: false,
38
+ },
39
+
40
+ fixable: 'code',
41
+
42
+ schema: [
43
+ {
44
+ enum: ['always', 'never'],
45
+ },
46
+ {
47
+ type: 'object',
48
+ properties: {
49
+ leftDelimiter: {
50
+ type: 'string',
51
+ },
52
+ rightDelimiter: {
53
+ type: 'string',
54
+ },
55
+ allowDepths: {
56
+ type: 'array',
57
+ items: {
58
+ enum: [1, 2, 3, 4, 5, 6],
59
+ },
60
+ uniqueItems: true,
61
+ },
62
+ },
63
+ additionalProperties: false,
64
+ },
65
+ ],
66
+
67
+ defaultOptions: [
68
+ 'always',
69
+ {
70
+ leftDelimiter: '{',
71
+ rightDelimiter: '}',
72
+ allowDepths: [],
73
+ },
74
+ ],
75
+
76
+ messages: {
77
+ headingIdAlways: 'Headings should have an ID attribute.',
78
+ headingIdNever:
79
+ 'Headings should not have an ID attribute. Remove the `{{ headingId }}`.',
80
+ },
81
+
82
+ language: 'markdown',
83
+
84
+ dialects: ['commonmark', 'gfm'],
85
+ },
86
+
87
+ create(context) {
88
+ const { sourceCode } = context;
89
+ const [mode, { leftDelimiter, rightDelimiter, allowDepths }] = context.options;
90
+
91
+ const escapedLeftDelimiter = escapeStringRegexp(leftDelimiter);
92
+ const escapedRightDelimiter = escapeStringRegexp(rightDelimiter);
93
+
94
+ /**
95
+ * We don't use the `[ \t]*$` pattern at the end of the regex because trailing
96
+ * whitespace is already removed from a `heading` node's child `text` node.
97
+ */
98
+ const headingIdRegex = new RegExp(
99
+ `(?<leadingSpaces>[ \t]+)(?<headingId>${escapedLeftDelimiter}#[^${escapedRightDelimiter}]+${escapedRightDelimiter})$`,
100
+ );
101
+
102
+ return {
103
+ heading(node) {
104
+ // If the heading's depth is included in `allowDepths`, skip it.
105
+ if (allowDepths.includes(node.depth)) {
106
+ return;
107
+ }
108
+
109
+ /*
110
+ * Instead of using deep recursive traversal to find the final `text` node,
111
+ * we simply access the node's last child directly with shallow traversal.
112
+ *
113
+ * This is because in the "Not OK" case, the `text` node is located
114
+ * in the last position while DFS(Depth First Search) traversal,
115
+ * but is located under `emphasis` or `strong` nodes.
116
+ *
117
+ * This is the situation we don't want to support.
118
+ * There must be pure ` #{custom-id}` text at the end of the heading
119
+ * without being wrapped by other nodes.
120
+ *
121
+ * OK:
122
+ *
123
+ * ```md
124
+ * # heading {#custom-id}
125
+ * ```
126
+ *
127
+ * Not OK:
128
+ *
129
+ * ```md
130
+ * # heading *{#custom-id}*
131
+ * ^ ^
132
+ *
133
+ * # heading **{#custom-id}**
134
+ * ^^ ^^
135
+ * ```
136
+ */
137
+ const lastChildNode = node.children.at(-1);
138
+
139
+ /*
140
+ * ATX headings and closed ATX headings that contain no content have no child nodes.
141
+ *
142
+ * ATX Headings:
143
+ *
144
+ * ```md
145
+ * #
146
+ *
147
+ * ##
148
+ *
149
+ * ###
150
+ * ```
151
+ *
152
+ * ATX Closed Headings:
153
+ *
154
+ * ```md
155
+ * # #
156
+ *
157
+ * ## ##
158
+ *
159
+ * ### ###
160
+ * ```
161
+ */
162
+ if (!lastChildNode) {
163
+ // If there are no child nodes, intentionally skip it.
164
+ // This is not the part we want to report.
165
+ return;
166
+ }
167
+
168
+ /*
169
+ * Missing heading IDs are reported on the last character of the last child
170
+ * node rather than as a zero-width location (i.e. `column === endColumn`)
171
+ * at the end of that node, because some editors such as VSCode render
172
+ * zero-width diagnostics incorrectly.
173
+ */
174
+ const [lastChildNodeStartOffset, lastChildNodeEndOffset] =
175
+ sourceCode.getRange(lastChildNode);
176
+
177
+ // If the last child node is not a `text` node, report an error.
178
+ if (lastChildNode.type !== 'text') {
179
+ if (mode === 'always') {
180
+ context.report({
181
+ loc: {
182
+ start: sourceCode.getLocFromIndex(lastChildNodeEndOffset - 1),
183
+ end: sourceCode.getLocFromIndex(lastChildNodeEndOffset),
184
+ },
185
+
186
+ messageId: 'headingIdAlways',
187
+ });
188
+ }
189
+
190
+ return;
191
+ }
192
+
193
+ const match = headingIdRegex.exec(sourceCode.getText(lastChildNode));
194
+
195
+ if (!match || !match.groups) {
196
+ if (mode === 'always') {
197
+ context.report({
198
+ loc: {
199
+ start: sourceCode.getLocFromIndex(lastChildNodeEndOffset - 1),
200
+ end: sourceCode.getLocFromIndex(lastChildNodeEndOffset),
201
+ },
202
+
203
+ messageId: 'headingIdAlways',
204
+ });
205
+ }
206
+
207
+ return;
208
+ }
209
+
210
+ if (mode === 'never') {
211
+ const { leadingSpaces, headingId } = match.groups;
212
+ const leadingSpacesStartOffset = lastChildNodeStartOffset + match.index;
213
+ const headingIdStartOffset = leadingSpacesStartOffset + leadingSpaces.length;
214
+ const headingIdEndOffset = headingIdStartOffset + headingId.length;
215
+
216
+ context.report({
217
+ loc: {
218
+ start: sourceCode.getLocFromIndex(headingIdStartOffset),
219
+ end: sourceCode.getLocFromIndex(headingIdEndOffset),
220
+ },
221
+
222
+ data: {
223
+ headingId,
224
+ },
225
+
226
+ messageId: 'headingIdNever',
227
+
228
+ fix(fixer) {
229
+ return fixer.replaceTextRange(
230
+ [leadingSpacesStartOffset, headingIdEndOffset],
231
+ '',
232
+ );
233
+ },
234
+ });
235
+ }
236
+ },
237
+ };
238
+ },
239
+ };
@@ -1,153 +0,0 @@
1
- /**
2
- * @fileoverview Rule to enforce the use of heading IDs.
3
- * @author lumir(lumirlumir)
4
- */
5
-
6
- // @ts-nocheck -- TODO
7
-
8
- // --------------------------------------------------------------------------------
9
- // Import
10
- // --------------------------------------------------------------------------------
11
-
12
- import { URL_RULE_DOCS } from '../core/constants.js';
13
-
14
- // --------------------------------------------------------------------------------
15
- // Typedefs
16
- // --------------------------------------------------------------------------------
17
-
18
- /**
19
- * @import { Heading } from 'mdast';
20
- * @import { RuleModule } from '../core/types.js';
21
- * @typedef {['always' | 'never', { leftDelimiter: string, rightDelimiter: string, ignoreDepth: Heading['depth'][] }]} RuleOptions
22
- * @typedef {'headingIdAlways' | 'headingIdNever'} MessageIds
23
- */
24
-
25
- // --------------------------------------------------------------------------------
26
- // Rule Definition
27
- // --------------------------------------------------------------------------------
28
-
29
- /** @type {RuleModule<RuleOptions, MessageIds>} */
30
- export default {
31
- meta: {
32
- type: 'problem',
33
-
34
- docs: {
35
- description: 'Enforce the use of heading IDs',
36
- url: URL_RULE_DOCS('heading-id'),
37
- recommended: false,
38
- stylistic: false,
39
- },
40
-
41
- fixable: 'code',
42
-
43
- schema: [
44
- {
45
- enum: ['always', 'never'],
46
- },
47
- {
48
- type: 'object',
49
- properties: {
50
- leftDelimiter: {
51
- type: 'string',
52
- },
53
- rightDelimiter: {
54
- type: 'string',
55
- },
56
- ignoreDepth: {
57
- type: 'array',
58
- items: {
59
- enum: [1, 2, 3, 4, 5, 6],
60
- },
61
- uniqueItems: true,
62
- },
63
- },
64
- additionalProperties: false,
65
- },
66
- ],
67
-
68
- defaultOptions: [
69
- 'always',
70
- {
71
- leftDelimiter: '{',
72
- rightDelimiter: '}',
73
- ignoreDepth: [],
74
- },
75
- ],
76
-
77
- messages: {
78
- headingIdAlways: 'Headings should have an ID attribute.',
79
- headingIdNever:
80
- 'Headings should not have an ID attribute. Remove the `{{ headingId }}`.',
81
- },
82
-
83
- language: 'markdown',
84
-
85
- dialects: ['commonmark', 'gfm'],
86
- },
87
-
88
- create(context) {
89
- return {
90
- heading(node) {
91
- const [mode, { leftDelimiter, rightDelimiter, ignoreDepth }] = context.options;
92
-
93
- if (ignoreDepth.includes(node.depth)) return;
94
-
95
- const regex = new RegExp(
96
- `${leftDelimiter}#[^${rightDelimiter}]+${rightDelimiter}[ \t]*$`,
97
- );
98
- const match = context.sourceCode.getText(node).match(regex);
99
-
100
- if (mode === 'always' && match === null) {
101
- context.report({
102
- loc: {
103
- start: {
104
- line: node.position.start.line,
105
- column: node.position.end.column,
106
- },
107
- end: {
108
- line: node.position.start.line,
109
- column: node.position.end.column,
110
- },
111
- },
112
-
113
- messageId: 'headingIdAlways',
114
- });
115
- } else if (mode === 'never' && match !== null) {
116
- const headingIdLength = match[0].length;
117
-
118
- const matchIndexStart = match.index;
119
- const matchIndexEnd = matchIndexStart + headingIdLength;
120
-
121
- context.report({
122
- loc: {
123
- start: {
124
- line: node.position.start.line,
125
- column: node.position.start.column + matchIndexStart,
126
- },
127
- end: {
128
- line: node.position.start.line,
129
- column: node.position.start.column + matchIndexEnd,
130
- },
131
- },
132
-
133
- data: {
134
- headingId: match[0],
135
- },
136
-
137
- messageId: 'headingIdNever',
138
-
139
- fix(fixer) {
140
- return fixer.replaceTextRange(
141
- [
142
- node.position.start.offset + matchIndexStart,
143
- node.position.start.offset + matchIndexEnd,
144
- ],
145
- '',
146
- );
147
- },
148
- });
149
- }
150
- },
151
- };
152
- },
153
- };