eslint-plugin-markdown-preferences 0.13.0 → 0.15.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
@@ -13,10 +13,10 @@ A specialized ESLint plugin that helps enforce consistent writing style and form
13
13
 
14
14
  ## 📛 Features
15
15
 
16
- - **⚡ Effortless automation** - Transform your Markdown with auto-fixing that handles formatting, linking, and style consistency automatically
17
- - **📖 Professional documentation** - Enforce consistent line breaks, clean up trailing spaces, and organize link definitions for enterprise-ready documentation
18
- - **🎯 Smart terminology management** - Automatically convert specified words into inline code or clickable links based on your configuration
19
- - **⚙️ Highly customizable configuration** - Fine-tune every aspect with granular rule options, word lists, ignore patterns, and flexible thresholds to match your exact requirements
16
+ - **⚡ Effortless automation** - Transform your Markdown with auto-fixing that handles formatting, casing, and style consistency automatically
17
+ - **📖 Professional documentation** - Enforce consistent headings, table headers, and organize link definitions for enterprise-ready documentation
18
+ - **🎨 Clean formatting** - Remove trailing spaces, control line breaks, standardize code blocks, and ensure consistent list numbering for polished output
19
+ - **⚙️ Flexible customization** - Configure casing styles (Title Case, Sentence case), code block languages, emoji notation, and more with extensive options
20
20
 
21
21
  **Try it live:** Check out the [Online Demo](https://eslint-online-playground.netlify.app/#eslint-plugin-markdown-preferences) to see the plugin in action!
22
22
 
@@ -29,7 +29,7 @@ For detailed usage instructions, rule configurations, and examples, visit our co
29
29
  ## 💿 Installation
30
30
 
31
31
  ```sh
32
- npm install --save-dev eslint eslint-plugin-markdown-preferences
32
+ npm install --save-dev eslint @eslint/markdown eslint-plugin-markdown-preferences
33
33
  ```
34
34
 
35
35
  <!--DOCS_IGNORE_END-->
@@ -90,26 +90,33 @@ The rules with the following star ⭐ are included in the configs.
90
90
 
91
91
  ### Preference Rules
92
92
 
93
+ - Rules to unify the expression and description style of documents.
94
+
93
95
  | Rule ID | Description | Fixable | RECOMMENDED |
94
96
  |:--------|:------------|:-------:|:-----------:|
95
97
  | [markdown-preferences/canonical-code-block-language](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/canonical-code-block-language.html) | enforce canonical language names in code blocks | 🔧 | |
96
- | [markdown-preferences/emoji-notation](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/emoji-notation.html) | Enforce consistent emoji notation style in Markdown files. | 🔧 | |
98
+ | [markdown-preferences/emoji-notation](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/emoji-notation.html) | enforce consistent emoji notation style in Markdown files. | 🔧 | |
97
99
  | [markdown-preferences/heading-casing](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/heading-casing.html) | enforce consistent casing in headings. | 🔧 | |
98
- | [markdown-preferences/no-text-backslash-linebreak](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-text-backslash-linebreak.html) | disallow text backslash at the end of a line. | | ⭐ |
99
100
  | [markdown-preferences/ordered-list-marker-start](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/ordered-list-marker-start.html) | enforce that ordered list markers start with 1 or 0 | 🔧 | |
100
101
  | [markdown-preferences/prefer-inline-code-words](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-inline-code-words.html) | enforce the use of inline code for specific words. | 🔧 | |
101
102
  | [markdown-preferences/prefer-linked-words](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-linked-words.html) | enforce the specified word to be a link. | 🔧 | |
103
+ | [markdown-preferences/table-header-casing](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/table-header-casing.html) | enforce consistent casing in table header cells. | 🔧 | |
102
104
 
103
105
  ### Stylistic Rules
104
106
 
107
+ - Rules related to the formatting and visual style of Markdown.
108
+
105
109
  | Rule ID | Description | Fixable | RECOMMENDED |
106
110
  |:--------|:------------|:-------:|:-----------:|
107
111
  | [markdown-preferences/atx-headings-closing-sequence-length](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/atx-headings-closing-sequence-length.html) | enforce consistent length for the closing sequence (trailing #s) in ATX headings. | 🔧 | |
108
112
  | [markdown-preferences/atx-headings-closing-sequence](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/atx-headings-closing-sequence.html) | enforce consistent use of closing sequence in ATX headings. | 🔧 | |
113
+ | [markdown-preferences/blockquote-marker-alignment](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/blockquote-marker-alignment.html) | enforce consistent alignment of blockquote markers | 🔧 | ⭐ |
109
114
  | [markdown-preferences/definitions-last](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/definitions-last.html) | require link definitions and footnote definitions to be placed at the end of the document | 🔧 | |
110
115
  | [markdown-preferences/hard-linebreak-style](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/hard-linebreak-style.html) | enforce consistent hard linebreak style. | 🔧 | ⭐ |
116
+ | [markdown-preferences/list-marker-alignment](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/list-marker-alignment.html) | enforce consistent alignment of list markers | 🔧 | ⭐ |
111
117
  | [markdown-preferences/no-laziness-blockquotes](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-laziness-blockquotes.html) | disallow laziness in blockquotes | | ⭐ |
112
118
  | [markdown-preferences/no-multiple-empty-lines](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-multiple-empty-lines.html) | disallow multiple empty lines in Markdown files. | 🔧 | |
119
+ | [markdown-preferences/no-text-backslash-linebreak](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-text-backslash-linebreak.html) | disallow text backslash at the end of a line. | | ⭐ |
113
120
  | [markdown-preferences/no-trailing-spaces](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-trailing-spaces.html) | disallow trailing whitespace at the end of lines in Markdown files. | 🔧 | |
114
121
  | [markdown-preferences/ordered-list-marker-sequence](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/ordered-list-marker-sequence.html) | enforce that ordered list markers use sequential numbers | 🔧 | |
115
122
  | [markdown-preferences/prefer-autolinks](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-autolinks.html) | enforce the use of autolinks for URLs | 🔧 | ⭐ |
package/lib/index.d.ts CHANGED
@@ -20,6 +20,11 @@ interface RuleOptions {
20
20
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/atx-headings-closing-sequence-length.html
21
21
  */
22
22
  'markdown-preferences/atx-headings-closing-sequence-length'?: Linter.RuleEntry<MarkdownPreferencesAtxHeadingsClosingSequenceLength>;
23
+ /**
24
+ * enforce consistent alignment of blockquote markers
25
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/blockquote-marker-alignment.html
26
+ */
27
+ 'markdown-preferences/blockquote-marker-alignment'?: Linter.RuleEntry<[]>;
23
28
  /**
24
29
  * enforce canonical language names in code blocks
25
30
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/canonical-code-block-language.html
@@ -31,7 +36,7 @@ interface RuleOptions {
31
36
  */
32
37
  'markdown-preferences/definitions-last'?: Linter.RuleEntry<[]>;
33
38
  /**
34
- * Enforce consistent emoji notation style in Markdown files.
39
+ * enforce consistent emoji notation style in Markdown files.
35
40
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/emoji-notation.html
36
41
  */
37
42
  'markdown-preferences/emoji-notation'?: Linter.RuleEntry<MarkdownPreferencesEmojiNotation>;
@@ -45,6 +50,11 @@ interface RuleOptions {
45
50
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/heading-casing.html
46
51
  */
47
52
  'markdown-preferences/heading-casing'?: Linter.RuleEntry<MarkdownPreferencesHeadingCasing>;
53
+ /**
54
+ * enforce consistent alignment of list markers
55
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/list-marker-alignment.html
56
+ */
57
+ 'markdown-preferences/list-marker-alignment'?: Linter.RuleEntry<MarkdownPreferencesListMarkerAlignment>;
48
58
  /**
49
59
  * disallow laziness in blockquotes
50
60
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-laziness-blockquotes.html
@@ -105,6 +115,11 @@ interface RuleOptions {
105
115
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/sort-definitions.html
106
116
  */
107
117
  'markdown-preferences/sort-definitions'?: Linter.RuleEntry<MarkdownPreferencesSortDefinitions>;
118
+ /**
119
+ * enforce consistent casing in table header cells.
120
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/table-header-casing.html
121
+ */
122
+ 'markdown-preferences/table-header-casing'?: Linter.RuleEntry<MarkdownPreferencesTableHeaderCasing>;
108
123
  }
109
124
  type MarkdownPreferencesAtxHeadingsClosingSequence = [] | [{
110
125
  closingSequence?: ("always" | "never");
@@ -132,6 +147,9 @@ type MarkdownPreferencesHeadingCasing = [] | [{
132
147
  ignorePatterns?: string[];
133
148
  minorWords?: string[];
134
149
  }];
150
+ type MarkdownPreferencesListMarkerAlignment = [] | [{
151
+ align?: ("left" | "right");
152
+ }];
135
153
  type MarkdownPreferencesNoMultipleEmptyLines = [] | [{
136
154
  max?: number;
137
155
  maxEOF?: number;
@@ -178,6 +196,12 @@ type MarkdownPreferencesSortDefinitions = [] | [{
178
196
  })[];
179
197
  alphabetical?: boolean;
180
198
  }];
199
+ type MarkdownPreferencesTableHeaderCasing = [] | [{
200
+ style?: ("Title Case" | "Sentence case");
201
+ preserveWords?: string[];
202
+ ignorePatterns?: string[];
203
+ minorWords?: string[];
204
+ }];
181
205
  declare namespace recommended_d_exports {
182
206
  export { files, language, languageOptions, name$1 as name, plugins, rules$1 as rules };
183
207
  }
@@ -196,7 +220,7 @@ declare namespace meta_d_exports {
196
220
  export { name, version };
197
221
  }
198
222
  declare const name: "eslint-plugin-markdown-preferences";
199
- declare const version: "0.13.0";
223
+ declare const version: "0.15.0";
200
224
  //#endregion
201
225
  //#region src/index.d.ts
202
226
  declare const configs: {
package/lib/index.js CHANGED
@@ -108,7 +108,7 @@ function parseATXHeadingClosingSequenceFromText(text) {
108
108
 
109
109
  //#endregion
110
110
  //#region src/utils/lines.ts
111
- const cache = /* @__PURE__ */ new WeakMap();
111
+ const cache$1 = /* @__PURE__ */ new WeakMap();
112
112
  var ParsedLines = class {
113
113
  lines;
114
114
  constructor(codeText) {
@@ -150,11 +150,11 @@ var ParsedLines = class {
150
150
  * @param sourceCode source code to parse
151
151
  * @returns parsed lines
152
152
  */
153
- function parseLines(sourceCode) {
154
- const cached = cache.get(sourceCode);
153
+ function getParsedLines(sourceCode) {
154
+ const cached = cache$1.get(sourceCode);
155
155
  if (cached) return cached;
156
156
  const parsedLines = new ParsedLines(sourceCode.text);
157
- cache.set(sourceCode, parsedLines);
157
+ cache$1.set(sourceCode, parsedLines);
158
158
  return parsedLines;
159
159
  }
160
160
 
@@ -228,9 +228,8 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
228
228
  } };
229
229
  })() : option.mode === "fixed-line-length" ? (() => {
230
230
  const totalLength = option.length || 80;
231
- const lines = parseLines(sourceCode);
232
231
  const getExpected = (_node, parsed) => {
233
- return totalLength - getContentLength(lines, parsed);
232
+ return totalLength - getContentLength(parsed);
234
233
  };
235
234
  return { heading(node) {
236
235
  verifyATXHeadingClosingSequenceLength(node, getExpected);
@@ -246,7 +245,6 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
246
245
  } else verifyATXHeadingClosingSequenceLength(node, getExpected);
247
246
  } };
248
247
  })() : option.mode === "consistent-line-length" ? (() => {
249
- const lines = parseLines(sourceCode);
250
248
  const headings = [];
251
249
  return {
252
250
  heading(node) {
@@ -260,9 +258,9 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
260
258
  "root:exit"() {
261
259
  let mostLongContentHeading = null;
262
260
  for (const heading of headings) {
263
- const contentLength = getContentLength(lines, heading.parsed);
261
+ const contentLength = getContentLength(heading.parsed);
264
262
  if (mostLongContentHeading == null || contentLength > mostLongContentHeading.contentLength) {
265
- const lineLength = getLineLength(lines, heading.parsed);
263
+ const lineLength = getLineLength(heading.parsed);
266
264
  mostLongContentHeading = {
267
265
  ...heading,
268
266
  contentLength,
@@ -273,11 +271,11 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
273
271
  if (!mostLongContentHeading) return;
274
272
  let minLineLength = mostLongContentHeading.lineLength;
275
273
  for (const heading of headings) {
276
- const lineLength = getLineLength(lines, heading.parsed);
274
+ const lineLength = getLineLength(heading.parsed);
277
275
  if (mostLongContentHeading.contentLength < lineLength && lineLength < minLineLength) minLineLength = Math.min(minLineLength, lineLength);
278
276
  }
279
277
  const getExpected = (_node, parsed) => {
280
- return minLineLength - getContentLength(lines, parsed);
278
+ return minLineLength - getContentLength(parsed);
281
279
  };
282
280
  for (const { node } of headings) verifyATXHeadingClosingSequenceLength(node, getExpected);
283
281
  }
@@ -286,7 +284,8 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
286
284
  /**
287
285
  * Get the content length of the heading.
288
286
  */
289
- function getContentLength(lines, parsed) {
287
+ function getContentLength(parsed) {
288
+ const lines = getParsedLines(sourceCode);
290
289
  const line = lines.get(parsed.closingSequence.loc.start.line);
291
290
  const beforeClosing = sourceCode.text.slice(line.range[0], parsed.closingSequence.range[0]);
292
291
  return getTextWidth(beforeClosing);
@@ -294,7 +293,8 @@ var atx_headings_closing_sequence_length_default = createRule("atx-headings-clos
294
293
  /**
295
294
  * Get the line length of the heading.
296
295
  */
297
- function getLineLength(lines, parsed) {
296
+ function getLineLength(parsed) {
297
+ const lines = getParsedLines(sourceCode);
298
298
  const line = lines.get(parsed.closingSequence.loc.start.line);
299
299
  const lineText = sourceCode.text.slice(line.range[0], parsed.closingSequence.range[1]);
300
300
  return getTextWidth(lineText);
@@ -380,6 +380,123 @@ var atx_headings_closing_sequence_default = createRule("atx-headings-closing-seq
380
380
  }
381
381
  });
382
382
 
383
+ //#endregion
384
+ //#region src/utils/blockquotes.ts
385
+ const cache = /* @__PURE__ */ new WeakMap();
386
+ /**
387
+ * Helper function to get blockquote level information.
388
+ */
389
+ function getBlockquoteLevelFromLine(sourceCode, lineNumber) {
390
+ let map = cache.get(sourceCode);
391
+ if (!map) {
392
+ map = /* @__PURE__ */ new Map();
393
+ cache.set(sourceCode, map);
394
+ }
395
+ const cached = map.get(lineNumber);
396
+ if (cached) return cached;
397
+ const lineText = sourceCode.lines[lineNumber - 1];
398
+ let prefix = "";
399
+ let level = 0;
400
+ const blockquoteMarkers = /* @__PURE__ */ new Map();
401
+ for (const c of lineText) {
402
+ if (c === ">") {
403
+ level++;
404
+ blockquoteMarkers.set(level, { index: prefix.length });
405
+ } else if (c.trim()) break;
406
+ prefix += c;
407
+ }
408
+ const result = {
409
+ line: lineNumber,
410
+ prefix,
411
+ level,
412
+ blockquoteMarkers
413
+ };
414
+ map.set(lineNumber, result);
415
+ return result;
416
+ }
417
+
418
+ //#endregion
419
+ //#region src/rules/blockquote-marker-alignment.ts
420
+ var blockquote_marker_alignment_default = createRule("blockquote-marker-alignment", {
421
+ meta: {
422
+ type: "layout",
423
+ docs: {
424
+ description: "enforce consistent alignment of blockquote markers",
425
+ categories: ["recommended"],
426
+ listCategory: "Stylistic"
427
+ },
428
+ fixable: "whitespace",
429
+ hasSuggestions: false,
430
+ schema: [],
431
+ messages: { inconsistentAlignment: "Blockquote markers should be consistently aligned at the same nesting level." }
432
+ },
433
+ create(context) {
434
+ const sourceCode = context.sourceCode;
435
+ let blockquoteStack = {
436
+ node: sourceCode.ast,
437
+ level: 0,
438
+ upper: null
439
+ };
440
+ return {
441
+ blockquote(node) {
442
+ blockquoteStack = {
443
+ node,
444
+ level: blockquoteStack.level + 1,
445
+ upper: blockquoteStack,
446
+ reported: blockquoteStack.reported
447
+ };
448
+ if (blockquoteStack.reported) return;
449
+ const blockquoteLevel = blockquoteStack.level;
450
+ const loc = sourceCode.getLoc(node);
451
+ const startLine = loc.start.line;
452
+ const endLine = loc.end.line;
453
+ const base = getBlockquoteLevelFromLine(sourceCode, startLine).blockquoteMarkers.get(blockquoteLevel);
454
+ if (!base) return;
455
+ for (let lineNumber = startLine + 1; lineNumber <= endLine; lineNumber++) {
456
+ const marker = getBlockquoteLevelFromLine(sourceCode, lineNumber).blockquoteMarkers.get(blockquoteLevel);
457
+ if (!marker) continue;
458
+ if (base.index === marker.index) continue;
459
+ blockquoteStack.reported = true;
460
+ context.report({
461
+ node,
462
+ loc: {
463
+ start: {
464
+ line: lineNumber,
465
+ column: marker.index + 1
466
+ },
467
+ end: {
468
+ line: lineNumber,
469
+ column: marker.index + 2
470
+ }
471
+ },
472
+ messageId: "inconsistentAlignment",
473
+ fix(fixer) {
474
+ const lines = getParsedLines(sourceCode);
475
+ const line = lines.get(lineNumber);
476
+ if (marker.index < base.index) {
477
+ const addSpaces = " ".repeat(base.index - marker.index);
478
+ return fixer.insertTextBeforeRange([line.range[0] + marker.index, line.range[0] + marker.index], addSpaces);
479
+ }
480
+ if (blockquoteLevel === 1) {
481
+ const expectedSpaces = " ".repeat(base.index);
482
+ return fixer.replaceTextRange([line.range[0], line.range[0] + marker.index], expectedSpaces);
483
+ }
484
+ const itemBefore = line.text.slice(0, line.range[0] + marker.index);
485
+ if (itemBefore.includes(" ")) return null;
486
+ let removeStartIndex = marker.index;
487
+ for (; removeStartIndex > base.index; removeStartIndex--) if (line.text[removeStartIndex - 1] !== " ") break;
488
+ return fixer.removeRange([line.range[0] + removeStartIndex, line.range[0] + marker.index]);
489
+ }
490
+ });
491
+ }
492
+ },
493
+ "blockquote:exit"() {
494
+ blockquoteStack = blockquoteStack.upper;
495
+ }
496
+ };
497
+ }
498
+ });
499
+
383
500
  //#endregion
384
501
  //#region src/utils/ast.ts
385
502
  /**
@@ -2546,7 +2663,7 @@ var emoji_notation_default = createRule("emoji-notation", {
2546
2663
  meta: {
2547
2664
  type: "suggestion",
2548
2665
  docs: {
2549
- description: "Enforce consistent emoji notation style in Markdown files.",
2666
+ description: "enforce consistent emoji notation style in Markdown files.",
2550
2667
  categories: [],
2551
2668
  listCategory: "Preference"
2552
2669
  },
@@ -2813,6 +2930,7 @@ const defaultPreserveWords = [
2813
2930
  "Biome",
2814
2931
  "oxc",
2815
2932
  "swc",
2933
+ "markdownlint",
2816
2934
  "Webpack",
2817
2935
  "Vite",
2818
2936
  "Babel",
@@ -3148,7 +3266,8 @@ const defaultPreserveWords = [
3148
3266
  "Insomnia",
3149
3267
  "Redoc",
3150
3268
  "Stoplight",
3151
- "FAQ"
3269
+ "FAQ",
3270
+ "YouTube"
3152
3271
  ];
3153
3272
 
3154
3273
  //#endregion
@@ -3187,44 +3306,113 @@ const defaultMinorWords = [
3187
3306
  ];
3188
3307
 
3189
3308
  //#endregion
3190
- //#region src/rules/heading-casing.ts
3309
+ //#region src/utils/word-casing.ts
3191
3310
  /**
3192
- * Parse preserve words and phrases from the options
3193
- * - Single words are added to preserveWords
3194
- * - Multi-word phrases are added to preservePhrases and added to preserveWords with no spaces
3311
+ * Converts the casing of a word based on the specified case style and whether it is the first or last word in a phrase.
3195
3312
  */
3196
- function parsePreserveWords(preserveWordsOption) {
3197
- const preserveWords = /* @__PURE__ */ new Map();
3198
- /**
3199
- * Add a single word to the preserveWords map
3200
- */
3201
- function addPreserveWord(word) {
3202
- const lowerWord = word.toLowerCase();
3203
- let list = preserveWords.get(lowerWord);
3204
- if (!list) {
3205
- list = [];
3206
- preserveWords.set(lowerWord, list);
3313
+ function convertWordCasing({ word, first, last }, { caseStyle, minorWords }) {
3314
+ if (caseStyle === "Title Case") {
3315
+ if (first || last) return {
3316
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3317
+ isMinorWord: false
3318
+ };
3319
+ if (minorWords.some((minorWord) => minorWord.toLowerCase() === word.toLowerCase())) return {
3320
+ word: word.toLowerCase(),
3321
+ isMinorWord: true
3322
+ };
3323
+ return {
3324
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3325
+ isMinorWord: false
3326
+ };
3327
+ }
3328
+ if (first) return {
3329
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3330
+ isMinorWord: false
3331
+ };
3332
+ return {
3333
+ word: word.toLowerCase(),
3334
+ isMinorWord: false
3335
+ };
3336
+ }
3337
+
3338
+ //#endregion
3339
+ //#region src/utils/preserve-words.ts
3340
+ var PreserveWordsContext = class {
3341
+ preserveWords;
3342
+ preservePhrases;
3343
+ constructor(preserveWordsOption) {
3344
+ const preserveWords = /* @__PURE__ */ new Map();
3345
+ /**
3346
+ * Add a single word to the preserveWords map
3347
+ */
3348
+ function addPreserveWord(word) {
3349
+ const lowerWord = word.toLowerCase();
3350
+ let list = preserveWords.get(lowerWord);
3351
+ if (!list) {
3352
+ list = [];
3353
+ preserveWords.set(lowerWord, list);
3354
+ }
3355
+ list.push(word);
3207
3356
  }
3208
- list.push(word);
3357
+ const preservePhrases = /* @__PURE__ */ new Map();
3358
+ for (const word of preserveWordsOption) {
3359
+ const splitted = word.split(/\s+/);
3360
+ if (splitted.length <= 1) addPreserveWord(word);
3361
+ else {
3362
+ preservePhrases.set(word, splitted);
3363
+ addPreserveWord(splitted.join(""));
3364
+ }
3365
+ }
3366
+ this.preserveWords = preserveWords;
3367
+ this.preservePhrases = [...preservePhrases.values()].sort((a, b) => b.length - a.length);
3368
+ }
3369
+ findPreserveWord(word) {
3370
+ return this.preserveWords.get(word.toLowerCase()) ?? null;
3209
3371
  }
3210
- const preservePhrases = /* @__PURE__ */ new Map();
3211
- for (const word of preserveWordsOption) {
3212
- const splitted = word.split(/\s+/);
3213
- if (splitted.length <= 1) addPreserveWord(word);
3214
- else {
3215
- preservePhrases.set(word, splitted);
3216
- addPreserveWord(splitted.join(""));
3372
+ findPreservePhrase(words) {
3373
+ const firstWord = words.next();
3374
+ if (firstWord.done) return null;
3375
+ const firstLowerWord = firstWord.value.toLowerCase();
3376
+ let returnCandidate = null;
3377
+ const subWords = [firstWord.value];
3378
+ for (const phrase of this.preservePhrases) {
3379
+ if (returnCandidate && returnCandidate.preservePhrase.length !== phrase.length) break;
3380
+ if (firstLowerWord !== phrase[0].toLowerCase()) continue;
3381
+ while (subWords.length < phrase.length) {
3382
+ const word = words.next();
3383
+ if (word.done) return null;
3384
+ subWords.push(word.value);
3385
+ }
3386
+ if (subWords.every((word, i) => word.toLowerCase() === phrase[i].toLowerCase())) {
3387
+ let matchCount = 0;
3388
+ for (let i = 0; i < subWords.length; i++) {
3389
+ const word = subWords[i];
3390
+ if (word === phrase[i]) matchCount++;
3391
+ }
3392
+ if (!returnCandidate || matchCount > returnCandidate.matchCount) returnCandidate = {
3393
+ preservePhrase: phrase,
3394
+ matchCount
3395
+ };
3396
+ }
3217
3397
  }
3398
+ return returnCandidate?.preservePhrase ?? null;
3218
3399
  }
3219
- return {
3220
- preserveWords,
3221
- preservePhrases: [...preservePhrases.values()].sort((a, b) => b.length - a.length)
3222
- };
3400
+ };
3401
+ /**
3402
+ * Parse preserve words and phrases from the options
3403
+ * - Single words are added to preserveWords
3404
+ * - Multi-word phrases are added to preservePhrases and added to preserveWords with no spaces
3405
+ */
3406
+ function parsePreserveWordsOption(preserveWordsOption) {
3407
+ return new PreserveWordsContext(preserveWordsOption);
3223
3408
  }
3409
+
3410
+ //#endregion
3411
+ //#region src/utils/words.ts
3224
3412
  /**
3225
3413
  * Parse text into words with offsets
3226
3414
  */
3227
- function parseText(text, firstNode, lastNode) {
3415
+ function parseWordsFromText(text, firstNode, lastNode) {
3228
3416
  const words = [];
3229
3417
  const pattern = /(\w+(?:[^\s\w]\w+)*|:\w+:|[^\s\w]+|\s+)/gu;
3230
3418
  let match;
@@ -3255,33 +3443,9 @@ function parseText(text, firstNode, lastNode) {
3255
3443
  }
3256
3444
  return words;
3257
3445
  }
3258
- /**
3259
- * Convert a single word based on case style and context
3260
- */
3261
- function convertWord({ word, first, last }, caseStyle, minorWords) {
3262
- if (caseStyle === "Title Case") {
3263
- if (first || last) return {
3264
- word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3265
- isMinorWord: false
3266
- };
3267
- if (minorWords.some((minorWord) => minorWord.toLowerCase() === word.toLowerCase())) return {
3268
- word: word.toLowerCase(),
3269
- isMinorWord: true
3270
- };
3271
- return {
3272
- word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3273
- isMinorWord: false
3274
- };
3275
- }
3276
- if (first) return {
3277
- word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
3278
- isMinorWord: false
3279
- };
3280
- return {
3281
- word: word.toLowerCase(),
3282
- isMinorWord: false
3283
- };
3284
- }
3446
+
3447
+ //#endregion
3448
+ //#region src/rules/heading-casing.ts
3285
3449
  var heading_casing_default = createRule("heading-casing", {
3286
3450
  meta: {
3287
3451
  type: "suggestion",
@@ -3324,14 +3488,14 @@ var heading_casing_default = createRule("heading-casing", {
3324
3488
  create(context) {
3325
3489
  const sourceCode = context.sourceCode;
3326
3490
  const caseStyle = context.options[0]?.style || "Title Case";
3327
- const { preserveWords, preservePhrases } = parsePreserveWords(context.options[0]?.preserveWords || defaultPreserveWords);
3491
+ const preserveWordsOption = parsePreserveWordsOption(context.options[0]?.preserveWords || defaultPreserveWords);
3328
3492
  const minorWords = context.options[0]?.minorWords || defaultMinorWords;
3329
3493
  const ignorePatterns = (context.options[0]?.ignorePatterns || [
3330
3494
  "/^v\\d+/u",
3331
3495
  "/\\w+\\.[a-z\\d]+$/u",
3332
- "/\\w*(?:API|Api)$/u",
3333
- "/\\w*(?:SDK|Sdk)$/u",
3334
- "/\\w*(?:CLI|Cli)$/u"
3496
+ "/\\w+(?:API|Api)$/u",
3497
+ "/\\w+(?:SDK|Sdk)$/u",
3498
+ "/\\w+(?:CLI|Cli)$/u"
3335
3499
  ]).map((pattern) => {
3336
3500
  if (isRegExp(pattern)) return toRegExp(pattern);
3337
3501
  try {
@@ -3347,7 +3511,7 @@ var heading_casing_default = createRule("heading-casing", {
3347
3511
  */
3348
3512
  function checkTextNode(node, firstNode, lastNode) {
3349
3513
  const text = sourceCode.getText(node);
3350
- const wordAndOffsets = parseText(text, firstNode, lastNode);
3514
+ const wordAndOffsets = parseWordsFromText(text, firstNode, lastNode);
3351
3515
  const processed = /* @__PURE__ */ new Set();
3352
3516
  for (let index = 0; index < wordAndOffsets.length; index++) {
3353
3517
  if (processed.has(index)) continue;
@@ -3355,7 +3519,12 @@ var heading_casing_default = createRule("heading-casing", {
3355
3519
  const wordAndOffset = wordAndOffsets[index];
3356
3520
  if (wordAndOffset.punctuation) continue;
3357
3521
  if (ignorePatterns.some((pattern) => pattern.test(wordAndOffset.word))) continue;
3358
- const preservePhrase = findPreservePhrase(wordAndOffsets, index);
3522
+ const preservePhrase = preserveWordsOption.findPreservePhrase((function* () {
3523
+ const firstWord = wordAndOffsets[index];
3524
+ if (firstWord.punctuation) return;
3525
+ yield firstWord.word;
3526
+ for (let next = index + 1; next < wordAndOffsets.length; next++) yield wordAndOffsets[next].word;
3527
+ })());
3359
3528
  if (preservePhrase) {
3360
3529
  for (let wordIndex = 0; wordIndex < preservePhrase.length; wordIndex++) {
3361
3530
  processed.add(index + wordIndex);
@@ -3363,12 +3532,15 @@ var heading_casing_default = createRule("heading-casing", {
3363
3532
  }
3364
3533
  continue;
3365
3534
  }
3366
- const preserveWordList = preserveWords.get(wordAndOffset.word.toLowerCase());
3535
+ const preserveWordList = preserveWordsOption.findPreserveWord(wordAndOffset.word);
3367
3536
  if (preserveWordList) {
3368
3537
  if (!preserveWordList.some((w) => w === wordAndOffset.word)) verifyWord(wordAndOffset, preserveWordList[0], "preserved");
3369
3538
  continue;
3370
3539
  }
3371
- const expectedWordResult = convertWord(wordAndOffset, caseStyle, minorWords);
3540
+ const expectedWordResult = convertWordCasing(wordAndOffset, {
3541
+ caseStyle,
3542
+ minorWords
3543
+ });
3372
3544
  verifyWord(wordAndOffset, expectedWordResult.word, expectedWordResult.isMinorWord ? "minor" : "normal");
3373
3545
  }
3374
3546
  /**
@@ -3393,58 +3565,109 @@ var heading_casing_default = createRule("heading-casing", {
3393
3565
  });
3394
3566
  }
3395
3567
  }
3396
- /**
3397
- * Find a preserve phrase starting from the given index
3398
- * Returns the longest matching phrase or null if no match is found
3399
- */
3400
- function findPreservePhrase(wordAndOffsets, index) {
3401
- const firstWord = wordAndOffsets[index];
3402
- if (firstWord.punctuation) return null;
3403
- const firstLowerWord = firstWord.word.toLowerCase();
3404
- let returnCandidate = null;
3405
- let subWords = null;
3406
- for (const phrase of preservePhrases) {
3407
- if (returnCandidate && returnCandidate.preservePhrase.length !== phrase.length) break;
3408
- if (firstLowerWord !== phrase[0].toLowerCase()) continue;
3409
- if (!subWords || subWords.length !== phrase.length) subWords = wordAndOffsets.slice(index, index + phrase.length).map((wo) => wo.word);
3410
- if (subWords.length === phrase.length && subWords.every((word, i) => word.toLowerCase() === phrase[i].toLowerCase())) {
3411
- let matchCount = 0;
3412
- for (let i = 0; i < subWords.length; i++) {
3413
- const word = subWords[i];
3414
- if (word === phrase[i]) matchCount++;
3415
- }
3416
- if (!returnCandidate || matchCount > returnCandidate.matchCount) returnCandidate = {
3417
- preservePhrase: phrase,
3418
- matchCount
3419
- };
3420
- }
3421
- }
3422
- return returnCandidate?.preservePhrase ?? null;
3423
- }
3424
3568
  return { heading(node) {
3425
3569
  if (!node.children.length) return;
3426
3570
  const children = node.children.filter((child) => child.type !== "text" || child.value.trim());
3427
3571
  children.forEach((child, i) => {
3428
- if (child.type === "text") checkTextNode(child, i === 0, i === node.children.length - 1);
3572
+ if (child.type === "text") checkTextNode(child, i === 0, i === children.length - 1);
3429
3573
  });
3430
3574
  } };
3431
3575
  }
3432
3576
  });
3433
3577
 
3578
+ //#endregion
3579
+ //#region src/rules/list-marker-alignment.ts
3580
+ const ALIGN_TO_POSITION_NAME = {
3581
+ left: "start",
3582
+ right: "end"
3583
+ };
3584
+ var list_marker_alignment_default = createRule("list-marker-alignment", {
3585
+ meta: {
3586
+ type: "layout",
3587
+ docs: {
3588
+ description: "enforce consistent alignment of list markers",
3589
+ categories: ["recommended"],
3590
+ listCategory: "Stylistic"
3591
+ },
3592
+ fixable: "whitespace",
3593
+ hasSuggestions: false,
3594
+ schema: [{
3595
+ type: "object",
3596
+ properties: { align: { enum: ["left", "right"] } },
3597
+ additionalProperties: false
3598
+ }],
3599
+ messages: { incorrectAlignment: "List marker alignment is inconsistent. Expected {{expected}} characters of indentation, but got {{actual}}." }
3600
+ },
3601
+ create(context) {
3602
+ const sourceCode = context.sourceCode;
3603
+ const alignPositionName = ALIGN_TO_POSITION_NAME[context.options[0]?.align ?? "left"];
3604
+ /**
3605
+ * Get the marker location of a list item
3606
+ */
3607
+ function getMarkerLocation(node) {
3608
+ const start = sourceCode.getLoc(node).start;
3609
+ const startColumnIndex = start.column - 1;
3610
+ const marker = getListItemMarker(sourceCode, node);
3611
+ return {
3612
+ line: start.line,
3613
+ start: startColumnIndex,
3614
+ end: startColumnIndex + marker.raw.length
3615
+ };
3616
+ }
3617
+ /**
3618
+ * Check if list items have consistent alignment
3619
+ */
3620
+ function checkListAlignment(listNode) {
3621
+ const items = listNode.children;
3622
+ if (items.length <= 1) return;
3623
+ const referenceMarkerLocation = getMarkerLocation(items[0]);
3624
+ for (const item of items.slice(1)) {
3625
+ const markerLocation = getMarkerLocation(item);
3626
+ const diff = markerLocation[alignPositionName] - referenceMarkerLocation[alignPositionName];
3627
+ if (diff === 0) continue;
3628
+ context.report({
3629
+ node: item,
3630
+ loc: {
3631
+ start: {
3632
+ line: markerLocation.line,
3633
+ column: markerLocation.start + 1
3634
+ },
3635
+ end: {
3636
+ line: markerLocation.line,
3637
+ column: markerLocation.end + 1
3638
+ }
3639
+ },
3640
+ messageId: "incorrectAlignment",
3641
+ data: {
3642
+ expected: String(markerLocation.start - diff),
3643
+ actual: String(markerLocation.start)
3644
+ },
3645
+ fix(fixer) {
3646
+ const lines = getParsedLines(sourceCode);
3647
+ const line = lines.get(markerLocation.line);
3648
+ if (diff < 0) {
3649
+ const addSpaces = " ".repeat(-diff);
3650
+ return fixer.insertTextBeforeRange([line.range[0] + markerLocation.start, line.range[0] + markerLocation.start], addSpaces);
3651
+ }
3652
+ const itemBefore = line.text.slice(0, markerLocation.start - diff);
3653
+ if (itemBefore.includes(" ")) return null;
3654
+ const referenceMarkerBefore = lines.get(referenceMarkerLocation.line).text.slice(0, referenceMarkerLocation.start);
3655
+ if (referenceMarkerBefore === itemBefore) {
3656
+ const removeEndIndex = line.range[0] + markerLocation.start;
3657
+ const removeStartIndex = removeEndIndex - diff;
3658
+ return fixer.removeRange([removeStartIndex, removeEndIndex]);
3659
+ }
3660
+ return null;
3661
+ }
3662
+ });
3663
+ }
3664
+ }
3665
+ return { list: checkListAlignment };
3666
+ }
3667
+ });
3668
+
3434
3669
  //#endregion
3435
3670
  //#region src/rules/no-laziness-blockquotes.ts
3436
- /**
3437
- * Helper function to get blockquote line information.
3438
- */
3439
- function getBlockquoteLineInfo(line) {
3440
- const regex = /^\s*(?:>\s*)*/u;
3441
- const match = regex.exec(line.text);
3442
- return {
3443
- line,
3444
- prefix: match[0],
3445
- level: (match[0].match(/>/gu) || []).length
3446
- };
3447
- }
3448
3671
  var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3449
3672
  meta: {
3450
3673
  type: "problem",
@@ -3465,7 +3688,6 @@ var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3465
3688
  create(context) {
3466
3689
  const sourceCode = context.sourceCode;
3467
3690
  const checkedLines = /* @__PURE__ */ new Set();
3468
- const lines = parseLines(sourceCode);
3469
3691
  /**
3470
3692
  * Report invalid blockquote lines.
3471
3693
  */
@@ -3489,15 +3711,16 @@ var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3489
3711
  for (const group of invalidGroups) {
3490
3712
  const first = group.lines[0];
3491
3713
  const last = group.lines.at(-1);
3714
+ const lines = getParsedLines(sourceCode);
3492
3715
  context.report({
3493
3716
  loc: {
3494
3717
  start: {
3495
- line: first.line.line,
3718
+ line: first.line,
3496
3719
  column: 1
3497
3720
  },
3498
3721
  end: {
3499
- line: last.line.line,
3500
- column: last.line.text.length + 1
3722
+ line: last.line,
3723
+ column: lines.get(last.line).text.length + 1
3501
3724
  }
3502
3725
  },
3503
3726
  messageId: "lazyBlockquoteLine",
@@ -3509,12 +3732,16 @@ var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3509
3732
  messageId: "addMarker",
3510
3733
  data: { missingMarkers: `${base.level - group.level}` },
3511
3734
  *fix(fixer) {
3512
- for (const invalidLine of group.lines) yield fixer.replaceTextRange([invalidLine.line.range[0], invalidLine.line.range[0] + invalidLine.prefix.length], base.prefix);
3735
+ for (const invalidLine of group.lines) {
3736
+ const parsedLine = lines.get(invalidLine.line);
3737
+ yield fixer.replaceTextRange([parsedLine.range[0], parsedLine.range[0] + invalidLine.prefix.length], base.prefix);
3738
+ }
3513
3739
  }
3514
3740
  }, {
3515
3741
  messageId: "addLineBreak",
3516
3742
  fix: (fixer) => {
3517
- return fixer.insertTextBeforeRange([first.line.range[0], first.line.range[0]], `${first.prefix.trimEnd()}\n`);
3743
+ const parsedLine = lines.get(first.line);
3744
+ return fixer.insertTextBeforeRange([parsedLine.range[0], parsedLine.range[0]], `${first.prefix.trimEnd()}\n`);
3518
3745
  }
3519
3746
  }]
3520
3747
  });
@@ -3524,7 +3751,7 @@ var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3524
3751
  const loc = sourceCode.getLoc(node);
3525
3752
  const startLine = loc.start.line;
3526
3753
  const endLine = loc.end.line;
3527
- const base = getBlockquoteLineInfo(lines.get(startLine));
3754
+ const base = getBlockquoteLevelFromLine(sourceCode, startLine);
3528
3755
  const invalidLines = [];
3529
3756
  for (let lineNumber = startLine + 1; lineNumber <= endLine; lineNumber++) {
3530
3757
  if (checkedLines.has(lineNumber)) {
@@ -3533,8 +3760,7 @@ var no_laziness_blockquotes_default = createRule("no-laziness-blockquotes", {
3533
3760
  continue;
3534
3761
  }
3535
3762
  checkedLines.add(lineNumber);
3536
- const line = lines.get(lineNumber);
3537
- const current = getBlockquoteLineInfo(line);
3763
+ const current = getBlockquoteLevelFromLine(sourceCode, lineNumber);
3538
3764
  if (base.level <= current.level) {
3539
3765
  reportInvalidLines(invalidLines, base);
3540
3766
  invalidLines.length = 0;
@@ -3611,7 +3837,7 @@ var no_multiple_empty_lines_default = createRule("no-multiple-empty-lines", {
3611
3837
  toml: addIgnoreLoc,
3612
3838
  json: addIgnoreLoc,
3613
3839
  "root:exit"() {
3614
- const lines = [...parseLines(sourceCode)];
3840
+ const lines = [...getParsedLines(sourceCode)];
3615
3841
  const bofEmptyLines = [];
3616
3842
  while (lines.length) {
3617
3843
  if (lines[0].text.trim()) break;
@@ -3710,11 +3936,11 @@ var no_multiple_empty_lines_default = createRule("no-multiple-empty-lines", {
3710
3936
  //#region src/rules/no-text-backslash-linebreak.ts
3711
3937
  var no_text_backslash_linebreak_default = createRule("no-text-backslash-linebreak", {
3712
3938
  meta: {
3713
- type: "suggestion",
3939
+ type: "layout",
3714
3940
  docs: {
3715
3941
  description: "disallow text backslash at the end of a line.",
3716
3942
  categories: ["recommended"],
3717
- listCategory: "Preference"
3943
+ listCategory: "Stylistic"
3718
3944
  },
3719
3945
  fixable: void 0,
3720
3946
  hasSuggestions: true,
@@ -3825,7 +4051,7 @@ var no_trailing_spaces_default = createRule("no-trailing-spaces", {
3825
4051
  "root:exit"() {
3826
4052
  const re = /[^\S\n\r]+$/u;
3827
4053
  const skipMatch = /^[^\S\n\r]*$/u;
3828
- const lines = parseLines(sourceCode);
4054
+ const lines = getParsedLines(sourceCode);
3829
4055
  const commentLineNumbers = getCommentLineNumbers();
3830
4056
  for (const lineInfo of lines) {
3831
4057
  const matches = re.exec(lineInfo.text);
@@ -4113,7 +4339,6 @@ var prefer_fenced_code_blocks_default = createRule("prefer-fenced-code-blocks",
4113
4339
  },
4114
4340
  create(context) {
4115
4341
  const sourceCode = context.sourceCode;
4116
- const lines = parseLines(sourceCode);
4117
4342
  return { code(node) {
4118
4343
  const kind = getCodeBlockKind(sourceCode, node);
4119
4344
  if (kind === "backtick-fenced" || kind === "tilde-fenced") return;
@@ -4123,6 +4348,7 @@ var prefer_fenced_code_blocks_default = createRule("prefer-fenced-code-blocks",
4123
4348
  messageId: "useFencedCodeBlock",
4124
4349
  fix(fixer) {
4125
4350
  if (!isFixableIndentedCodeBlock(node)) return null;
4351
+ const lines = getParsedLines(sourceCode);
4126
4352
  const startColumnOffset = loc.start.column - 1;
4127
4353
  const removeRanges = [];
4128
4354
  let prefixText = null;
@@ -4158,6 +4384,7 @@ var prefer_fenced_code_blocks_default = createRule("prefer-fenced-code-blocks",
4158
4384
  */
4159
4385
  function isFixableIndentedCodeBlock(node) {
4160
4386
  if (!node.value.startsWith(" ")) return true;
4387
+ const lines = getParsedLines(sourceCode);
4161
4388
  const loc = sourceCode.getLoc(node);
4162
4389
  const firstLine = lines.get(loc.start.line);
4163
4390
  const codeBlockFirstLine = normalizePrefix(node.value.split(/\r?\n/u)[0]);
@@ -4781,14 +5008,14 @@ var sort_definitions_default = createRule("sort-definitions", {
4781
5008
  }
4782
5009
  /** Compile order option */
4783
5010
  function compileOption(orderOption) {
4784
- const cache$1 = /* @__PURE__ */ new Map();
5011
+ const cache$2 = /* @__PURE__ */ new Map();
4785
5012
  const compiled = compileOptionWithoutCache(orderOption);
4786
5013
  return {
4787
5014
  match: (node) => {
4788
- const cached = cache$1.get(node);
5015
+ const cached = cache$2.get(node);
4789
5016
  if (cached != null) return cached;
4790
5017
  const result = compiled.match(node);
4791
- cache$1.set(node, result);
5018
+ cache$2.set(node, result);
4792
5019
  return result;
4793
5020
  },
4794
5021
  sort: compiled.sort
@@ -4891,16 +5118,152 @@ function normalizedURL(url) {
4891
5118
  return urlObj.href.endsWith("/") ? urlObj.href : `${urlObj.href}/`;
4892
5119
  }
4893
5120
 
5121
+ //#endregion
5122
+ //#region src/rules/table-header-casing.ts
5123
+ var table_header_casing_default = createRule("table-header-casing", {
5124
+ meta: {
5125
+ type: "suggestion",
5126
+ fixable: "code",
5127
+ docs: {
5128
+ description: "enforce consistent casing in table header cells.",
5129
+ categories: [],
5130
+ listCategory: "Preference"
5131
+ },
5132
+ schema: [{
5133
+ type: "object",
5134
+ properties: {
5135
+ style: { enum: ["Title Case", "Sentence case"] },
5136
+ preserveWords: {
5137
+ type: "array",
5138
+ items: { type: "string" },
5139
+ description: "Words that should be preserved as-is (case-insensitive matching)"
5140
+ },
5141
+ ignorePatterns: {
5142
+ type: "array",
5143
+ items: { type: "string" },
5144
+ description: "Regular expression patterns for words to ignore during casing checks"
5145
+ },
5146
+ minorWords: {
5147
+ type: "array",
5148
+ items: { type: "string" },
5149
+ description: "Words that should not be capitalized in Title Case (unless they're the first or last word)"
5150
+ }
5151
+ },
5152
+ additionalProperties: false
5153
+ }],
5154
+ messages: {
5155
+ expectedTitleCase: "Expected \"{{actual}}\" to be \"{{expected}}\" (Title Case).",
5156
+ expectedTitleCaseMinorWord: "Expected \"{{actual}}\" to be \"{{expected}}\" (Title Case - minor word).",
5157
+ expectedSentenceCase: "Expected \"{{actual}}\" to be \"{{expected}}\" (Sentence case).",
5158
+ expectedPreserveWord: "Expected \"{{actual}}\" to be \"{{expected}}\" (preserved word)."
5159
+ }
5160
+ },
5161
+ create(context) {
5162
+ const sourceCode = context.sourceCode;
5163
+ const caseStyle = context.options[0]?.style || "Title Case";
5164
+ const preserveWordsOption = parsePreserveWordsOption(context.options[0]?.preserveWords || defaultPreserveWords);
5165
+ const minorWords = context.options[0]?.minorWords || defaultMinorWords;
5166
+ const ignorePatterns = (context.options[0]?.ignorePatterns || [
5167
+ "/^v\\d+/u",
5168
+ "/\\w+\\.[a-z\\d]+$/u",
5169
+ "/\\w+(?:API|Api)$/u",
5170
+ "/\\w+(?:SDK|Sdk)$/u",
5171
+ "/\\w+(?:CLI|Cli)$/u"
5172
+ ]).map((pattern) => {
5173
+ if (isRegExp(pattern)) return toRegExp(pattern);
5174
+ try {
5175
+ return new RegExp(pattern, "v");
5176
+ } catch {}
5177
+ try {
5178
+ return new RegExp(pattern, "u");
5179
+ } catch {}
5180
+ return new RegExp(pattern);
5181
+ });
5182
+ /**
5183
+ * Check text node and report word-level errors
5184
+ */
5185
+ function checkTextNode(node, firstNode, lastNode) {
5186
+ const text = sourceCode.getText(node);
5187
+ const wordAndOffsets = parseWordsFromText(text, firstNode, lastNode);
5188
+ const processed = /* @__PURE__ */ new Set();
5189
+ for (let index = 0; index < wordAndOffsets.length; index++) {
5190
+ if (processed.has(index)) continue;
5191
+ processed.add(index);
5192
+ const wordAndOffset = wordAndOffsets[index];
5193
+ if (wordAndOffset.punctuation) continue;
5194
+ if (ignorePatterns.some((pattern) => pattern.test(wordAndOffset.word))) continue;
5195
+ const preservePhrase = preserveWordsOption.findPreservePhrase((function* () {
5196
+ const firstWord = wordAndOffsets[index];
5197
+ if (firstWord.punctuation) return;
5198
+ yield firstWord.word;
5199
+ for (let next = index + 1; next < wordAndOffsets.length; next++) yield wordAndOffsets[next].word;
5200
+ })());
5201
+ if (preservePhrase) {
5202
+ for (let wordIndex = 0; wordIndex < preservePhrase.length; wordIndex++) {
5203
+ processed.add(index + wordIndex);
5204
+ verifyWord(wordAndOffsets[index + wordIndex], preservePhrase[wordIndex], "preserved");
5205
+ }
5206
+ continue;
5207
+ }
5208
+ const preserveWordList = preserveWordsOption.findPreserveWord(wordAndOffset.word);
5209
+ if (preserveWordList) {
5210
+ if (!preserveWordList.some((w) => w === wordAndOffset.word)) verifyWord(wordAndOffset, preserveWordList[0], "preserved");
5211
+ continue;
5212
+ }
5213
+ const expectedWordResult = convertWordCasing(wordAndOffset, {
5214
+ caseStyle,
5215
+ minorWords
5216
+ });
5217
+ verifyWord(wordAndOffset, expectedWordResult.word, expectedWordResult.isMinorWord ? "minor" : "normal");
5218
+ }
5219
+ /**
5220
+ * Verify a single word against the expected casing
5221
+ */
5222
+ function verifyWord(wordAndOffset, expectedWord, wordType = "normal") {
5223
+ const { word, offset } = wordAndOffset;
5224
+ if (word === expectedWord) return;
5225
+ const [nodeStart] = sourceCode.getRange(node);
5226
+ const range = [nodeStart + offset, nodeStart + offset + word.length];
5227
+ context.report({
5228
+ node,
5229
+ messageId: wordType === "preserved" ? "expectedPreserveWord" : caseStyle === "Title Case" ? wordType === "minor" ? "expectedTitleCaseMinorWord" : "expectedTitleCase" : "expectedSentenceCase",
5230
+ data: {
5231
+ actual: word,
5232
+ expected: expectedWord
5233
+ },
5234
+ loc: getSourceLocationFromRange(sourceCode, node, range),
5235
+ fix(fixer) {
5236
+ return fixer.replaceTextRange(range, expectedWord);
5237
+ }
5238
+ });
5239
+ }
5240
+ }
5241
+ return { table(node) {
5242
+ if (!node.children.length) return;
5243
+ const headerRow = node.children[0];
5244
+ if (headerRow.type !== "tableRow") return;
5245
+ for (const cell of headerRow.children) {
5246
+ const children = cell.children.filter((child) => child.type !== "text" || child.value.trim());
5247
+ children.forEach((child, i) => {
5248
+ if (child.type === "text") checkTextNode(child, i === 0, i === children.length - 1);
5249
+ });
5250
+ }
5251
+ } };
5252
+ }
5253
+ });
5254
+
4894
5255
  //#endregion
4895
5256
  //#region src/utils/rules.ts
4896
5257
  const rules$1 = [
4897
5258
  atx_headings_closing_sequence_length_default,
4898
5259
  atx_headings_closing_sequence_default,
5260
+ blockquote_marker_alignment_default,
4899
5261
  canonical_code_block_language_default,
4900
5262
  definitions_last_default,
4901
5263
  emoji_notation_default,
4902
5264
  hard_linebreak_style_default,
4903
5265
  heading_casing_default,
5266
+ list_marker_alignment_default,
4904
5267
  no_laziness_blockquotes_default,
4905
5268
  no_multiple_empty_lines_default,
4906
5269
  no_text_backslash_linebreak_default,
@@ -4912,7 +5275,8 @@ const rules$1 = [
4912
5275
  prefer_inline_code_words_default,
4913
5276
  prefer_link_reference_definitions_default,
4914
5277
  prefer_linked_words_default,
4915
- sort_definitions_default
5278
+ sort_definitions_default,
5279
+ table_header_casing_default
4916
5280
  ];
4917
5281
 
4918
5282
  //#endregion
@@ -4937,7 +5301,9 @@ const plugins = {
4937
5301
  }
4938
5302
  };
4939
5303
  const rules$2 = {
5304
+ "markdown-preferences/blockquote-marker-alignment": "error",
4940
5305
  "markdown-preferences/hard-linebreak-style": "error",
5306
+ "markdown-preferences/list-marker-alignment": "error",
4941
5307
  "markdown-preferences/no-laziness-blockquotes": "error",
4942
5308
  "markdown-preferences/no-text-backslash-linebreak": "error",
4943
5309
  "markdown-preferences/prefer-autolinks": "error",
@@ -4952,7 +5318,7 @@ __export(meta_exports, {
4952
5318
  version: () => version
4953
5319
  });
4954
5320
  const name = "eslint-plugin-markdown-preferences";
4955
- const version = "0.13.0";
5321
+ const version = "0.15.0";
4956
5322
 
4957
5323
  //#endregion
4958
5324
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-markdown-preferences",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "ESLint plugin that enforces our markdown preferences",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,6 +22,7 @@
22
22
  "lint": "eslint .",
23
23
  "tsc": "tsc --project tsconfig.build.json",
24
24
  "eslint-fix": "eslint . --fix",
25
+ "markdownlint": "npx -y markdownlint-cli2 .",
25
26
  "test": "npm run mocha -- \"tests/src/**/*.ts\" --reporter=dot --timeout=60000",
26
27
  "cover": "c8 --reporter=lcov npm run test",
27
28
  "test:update": "npm run mocha -- \"tests/src/**/*.ts\" --reporter=dot --update",
@@ -59,6 +60,10 @@
59
60
  "@eslint/markdown": "^7.1.0",
60
61
  "eslint": ">=9.0.0"
61
62
  },
63
+ "dependencies": {
64
+ "emoji-regex-xs": "^2.0.1",
65
+ "string-width": "^7.2.0"
66
+ },
62
67
  "devDependencies": {
63
68
  "@changesets/changelog-github": "^0.5.1",
64
69
  "@changesets/cli": "^2.28.1",
@@ -121,9 +126,5 @@
121
126
  },
122
127
  "publishConfig": {
123
128
  "access": "public"
124
- },
125
- "dependencies": {
126
- "emoji-regex-xs": "^2.0.1",
127
- "string-width": "^7.2.0"
128
129
  }
129
130
  }