eslint-plugin-markdown-preferences 0.4.0 → 0.6.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
@@ -92,8 +92,9 @@ The rules with the following star ⭐ are included in the configs.
92
92
  |:--------|:------------|:-------:|:-----------:|
93
93
  | [markdown-preferences/hard-linebreak-style](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/hard-linebreak-style.html) | enforce consistent hard linebreak style. | 🔧 | ⭐ |
94
94
  | [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. | | ⭐ |
95
- | [markdown-preferences/no-trailing-spaces](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-trailing-spaces.html) | trailing whitespace at the end of lines in Markdown files. | 🔧 | |
95
+ | [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. | 🔧 | |
96
96
  | [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. | 🔧 | |
97
+ | [markdown-preferences/prefer-link-reference-definitions](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-link-reference-definitions.html) | enforce using link reference definitions instead of inline links | 🔧 | |
97
98
  | [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. | 🔧 | |
98
99
 
99
100
  <!--RULES_TABLE_END-->
package/lib/index.d.ts CHANGED
@@ -3,7 +3,78 @@ import * as _eslint_core0 from "@eslint/core";
3
3
  import { RuleDefinition } from "@eslint/core";
4
4
  import { ESLint, Linter } from "eslint";
5
5
 
6
- //#region src/configs/recommended.d.ts
6
+ //#region src/rule-types.d.ts
7
+ declare module 'eslint' {
8
+ namespace Linter {
9
+ interface RulesRecord extends RuleOptions {}
10
+ }
11
+ }
12
+ interface RuleOptions {
13
+ /**
14
+ * enforce consistent hard linebreak style.
15
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/hard-linebreak-style.html
16
+ */
17
+ 'markdown-preferences/hard-linebreak-style'?: Linter.RuleEntry<MarkdownPreferencesHardLinebreakStyle>;
18
+ /**
19
+ * disallow text backslash at the end of a line.
20
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-text-backslash-linebreak.html
21
+ */
22
+ 'markdown-preferences/no-text-backslash-linebreak'?: Linter.RuleEntry<[]>;
23
+ /**
24
+ * disallow trailing whitespace at the end of lines in Markdown files.
25
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-trailing-spaces.html
26
+ */
27
+ 'markdown-preferences/no-trailing-spaces'?: Linter.RuleEntry<MarkdownPreferencesNoTrailingSpaces>;
28
+ /**
29
+ * enforce the use of inline code for specific words.
30
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-inline-code-words.html
31
+ */
32
+ 'markdown-preferences/prefer-inline-code-words'?: Linter.RuleEntry<MarkdownPreferencesPreferInlineCodeWords>;
33
+ /**
34
+ * enforce using link reference definitions instead of inline links
35
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-link-reference-definitions.html
36
+ */
37
+ 'markdown-preferences/prefer-link-reference-definitions'?: Linter.RuleEntry<MarkdownPreferencesPreferLinkReferenceDefinitions>;
38
+ /**
39
+ * enforce the specified word to be a link.
40
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-linked-words.html
41
+ */
42
+ 'markdown-preferences/prefer-linked-words'?: Linter.RuleEntry<MarkdownPreferencesPreferLinkedWords>;
43
+ }
44
+ type MarkdownPreferencesHardLinebreakStyle = [] | [{
45
+ style?: ("backslash" | "spaces");
46
+ }];
47
+ type MarkdownPreferencesNoTrailingSpaces = [] | [{
48
+ skipBlankLines?: boolean;
49
+ ignoreComments?: boolean;
50
+ }];
51
+ type MarkdownPreferencesPreferInlineCodeWords = [] | [{
52
+ words: string[];
53
+ ignores?: {
54
+ words?: (string | string[]);
55
+ node?: {
56
+ [k: string]: unknown | undefined;
57
+ };
58
+ [k: string]: unknown | undefined;
59
+ }[];
60
+ [k: string]: unknown | undefined;
61
+ }];
62
+ type MarkdownPreferencesPreferLinkReferenceDefinitions = [] | [{
63
+ minLinks?: number;
64
+ }];
65
+ type MarkdownPreferencesPreferLinkedWords = [] | [{
66
+ words: ({
67
+ [k: string]: (string | null);
68
+ } | string[]);
69
+ ignores?: {
70
+ words?: (string | string[]);
71
+ node?: {
72
+ [k: string]: unknown | undefined;
73
+ };
74
+ [k: string]: unknown | undefined;
75
+ }[];
76
+ [k: string]: unknown | undefined;
77
+ }];
7
78
  declare namespace recommended_d_exports {
8
79
  export { files, language, name$1 as name, plugins, rules$1 as rules };
9
80
  }
@@ -19,7 +90,7 @@ declare namespace meta_d_exports {
19
90
  export { name, version };
20
91
  }
21
92
  declare const name: "eslint-plugin-markdown-preferences";
22
- declare const version: "0.4.0";
93
+ declare const version: "0.6.0";
23
94
  //#endregion
24
95
  //#region src/index.d.ts
25
96
  declare const configs: {
@@ -34,4 +105,4 @@ declare const _default: {
34
105
  rules: Record<string, RuleDefinition<_eslint_core0.RuleDefinitionTypeOptions>>;
35
106
  };
36
107
  //#endregion
37
- export { configs, _default as default, meta_d_exports as meta, rules };
108
+ export { RuleOptions, configs, _default as default, meta_d_exports as meta, rules };
package/lib/index.js CHANGED
@@ -131,7 +131,7 @@ var no_trailing_spaces_default = createRule("no-trailing-spaces", {
131
131
  meta: {
132
132
  type: "layout",
133
133
  docs: {
134
- description: "trailing whitespace at the end of lines in Markdown files.",
134
+ description: "disallow trailing whitespace at the end of lines in Markdown files.",
135
135
  categories: []
136
136
  },
137
137
  fixable: "whitespace",
@@ -245,13 +245,14 @@ var no_trailing_spaces_default = createRule("no-trailing-spaces", {
245
245
 
246
246
  //#endregion
247
247
  //#region src/utils/search-words.ts
248
- const RE_BOUNDARY = /^[\s\p{Letter_Number}\p{Modifier_Letter}\p{Modifier_Symbol}\p{Nonspacing_Mark}\p{Other_Letter}\p{Other_Symbol}\p{Script=Han}!"#$%&'(),./:;<=>?\\{|}~\u{2ffc}-\u{303d}\u{30a0}-\u{30fb}\u{3192}-\u{32bf}\u{fe10}-\u{fe1f}\u{fe30}-\u{fe6f}\u{ff00}-\u{ffef}\u{2ebf0}-\u{2ee5d}]*$/u;
248
+ const RE_BOUNDARY = /^[\s\p{Letter_Number}\p{Modifier_Letter}\p{Modifier_Symbol}\p{Nonspacing_Mark}\p{Other_Letter}\p{Other_Symbol}\p{Script=Han}!"#$%&'()*+,./:;<=>?\\{|}~\u{2ffc}-\u{303d}\u{30a0}-\u{30fb}\u{3192}-\u{32bf}\u{fe10}-\u{fe1f}\u{fe30}-\u{fe6f}\u{ff00}-\u{ffef}\u{2ebf0}-\u{2ee5d}]*$/u;
249
249
  /**
250
250
  * Iterate through words in a text node that match the specified words.
251
251
  */
252
- function* iterateSearchWords(sourceCode, node, words) {
252
+ function* iterateSearchWords({ sourceCode, node, words, ignores }) {
253
253
  const text = sourceCode.getText(node);
254
254
  for (const word of words) {
255
+ if (ignores.ignore(word)) continue;
255
256
  let startPosition = 0;
256
257
  while (true) {
257
258
  const index = text.indexOf(word, startPosition);
@@ -280,6 +281,57 @@ function* iterateSearchWords(sourceCode, node, words) {
280
281
  }
281
282
  }
282
283
  }
284
+ const IGNORES_SCHEMA = {
285
+ type: "array",
286
+ items: {
287
+ type: "object",
288
+ properties: {
289
+ words: { anyOf: [{ type: "string" }, {
290
+ type: "array",
291
+ items: { type: "string" }
292
+ }] },
293
+ node: { type: "object" }
294
+ },
295
+ additionalProperties: true
296
+ }
297
+ };
298
+ /**
299
+ * Create a context for ignoring specific words or nodes.
300
+ */
301
+ function createSearchWordsIgnoreContext(ignores) {
302
+ if (!ignores || ignores.length === 0) return {
303
+ enter: () => void 0,
304
+ exit: () => void 0,
305
+ ignore: () => false
306
+ };
307
+ const conditions = ignores.map((ignore) => {
308
+ const isIgnoreWord = ignore.words == null ? () => true : Array.isArray(ignore.words) ? (word) => ignore.words.includes(word) : (word) => ignore.words === word;
309
+ const node = ignore.node || {};
310
+ const keys = Object.keys(node);
311
+ return {
312
+ isIgnoreWord,
313
+ isIgnoreNode: (nodeToCheck) => {
314
+ return keys.every((key) => nodeToCheck[key] === node[key]);
315
+ }
316
+ };
317
+ });
318
+ const currentIgnores = /* @__PURE__ */ new Set();
319
+ return {
320
+ enter(node) {
321
+ for (const ignore of conditions) if (ignore.isIgnoreNode(node)) currentIgnores.add({
322
+ node,
323
+ condition: ignore
324
+ });
325
+ },
326
+ exit(node) {
327
+ for (const element of [...currentIgnores]) if (element.node === node) currentIgnores.delete(element);
328
+ },
329
+ ignore(word) {
330
+ for (const { condition } of currentIgnores) if (condition.isIgnoreWord(word)) return true;
331
+ return false;
332
+ }
333
+ };
334
+ }
283
335
 
284
336
  //#endregion
285
337
  //#region src/rules/prefer-inline-code-words.ts
@@ -294,19 +346,30 @@ var prefer_inline_code_words_default = createRule("prefer-inline-code-words", {
294
346
  hasSuggestions: false,
295
347
  schema: [{
296
348
  type: "object",
297
- properties: { words: {
298
- type: "array",
299
- items: { type: "string" }
300
- } },
301
- required: ["words"]
349
+ properties: {
350
+ words: {
351
+ type: "array",
352
+ items: { type: "string" }
353
+ },
354
+ ignores: IGNORES_SCHEMA
355
+ },
356
+ required: ["words"],
357
+ additionalProperties: true
302
358
  }],
303
359
  messages: { requireInlineCode: "The word \"{{name}}\" should be in inline code." }
304
360
  },
305
361
  create(context) {
306
362
  const sourceCode = context.sourceCode;
307
363
  const words = context.options[0]?.words || [];
364
+ const ignores = createSearchWordsIgnoreContext(context.options[0]?.ignores);
308
365
  let shortcutLinkReference = null;
309
366
  return {
367
+ "*"(node) {
368
+ ignores.enter(node);
369
+ },
370
+ "*:exit"(node) {
371
+ ignores.exit(node);
372
+ },
310
373
  linkReference(node) {
311
374
  if (node.referenceType !== "shortcut") return;
312
375
  if (shortcutLinkReference) return;
@@ -316,7 +379,12 @@ var prefer_inline_code_words_default = createRule("prefer-inline-code-words", {
316
379
  if (shortcutLinkReference === node) shortcutLinkReference = null;
317
380
  },
318
381
  text(node) {
319
- for (const { word, loc, range } of iterateSearchWords(sourceCode, node, words)) {
382
+ for (const { word, loc, range } of iterateSearchWords({
383
+ sourceCode,
384
+ node,
385
+ words,
386
+ ignores
387
+ })) {
320
388
  const shortcutLinkReferenceToReport = shortcutLinkReference;
321
389
  context.report({
322
390
  node,
@@ -335,6 +403,140 @@ var prefer_inline_code_words_default = createRule("prefer-inline-code-words", {
335
403
  }
336
404
  });
337
405
 
406
+ //#endregion
407
+ //#region src/rules/prefer-link-reference-definitions.ts
408
+ var prefer_link_reference_definitions_default = createRule("prefer-link-reference-definitions", {
409
+ meta: {
410
+ type: "layout",
411
+ docs: {
412
+ description: "enforce using link reference definitions instead of inline links",
413
+ categories: []
414
+ },
415
+ fixable: "code",
416
+ hasSuggestions: false,
417
+ schema: [{
418
+ type: "object",
419
+ properties: { minLinks: {
420
+ type: "number",
421
+ description: "minimum number of links to trigger the rule (default: 2)",
422
+ default: 2,
423
+ minimum: 1
424
+ } },
425
+ additionalProperties: false
426
+ }],
427
+ messages: { useLinkReferenceDefinitions: "Use link reference definitions instead of inline links." }
428
+ },
429
+ create(context) {
430
+ const sourceCode = context.sourceCode;
431
+ const options = context.options[0] || {};
432
+ const minLinks = options.minLinks ?? 2;
433
+ const definitions = [];
434
+ const links = [];
435
+ const linkReferences = [];
436
+ /**
437
+ * Verify links.
438
+ */
439
+ function verify() {
440
+ const resourceToNodes = /* @__PURE__ */ new Map();
441
+ for (const link of links) getResourceNodes(link).links.push(link);
442
+ for (const linkReference of linkReferences) {
443
+ const definition = definitions.find((def) => def.identifier === linkReference.identifier);
444
+ if (definition) getResourceNodes(definition).linkReferences.push(linkReference);
445
+ }
446
+ for (const definition of definitions) getResourceNodes(definition).definitions.push(definition);
447
+ for (const map of resourceToNodes.values()) for (const nodes of map.values()) {
448
+ if (nodes.links.length === 0 || nodes.links.length + nodes.linkReferences.length < minLinks) continue;
449
+ for (const link of nodes.links) {
450
+ const linkInfo = getLinkInfo(link);
451
+ if (linkInfo.label === "") continue;
452
+ context.report({
453
+ node: link,
454
+ messageId: "useLinkReferenceDefinitions",
455
+ *fix(fixer) {
456
+ const definition = nodes.definitions[0];
457
+ let identifier;
458
+ if (definition) identifier = definition.label ?? definition.identifier;
459
+ else identifier = linkInfo.label.replaceAll(/\]/g, "-");
460
+ yield fixer.replaceText(link, `${sourceCode.text.slice(...linkInfo.labelRange)}${identifier === linkInfo.label ? "" : `[${identifier}]`}`);
461
+ if (!definition) {
462
+ const linkRange = sourceCode.getRange(link);
463
+ const reLineBreaks = /\n#{1,6}\s/gu;
464
+ reLineBreaks.lastIndex = linkRange[1];
465
+ const nextSectionMatch = reLineBreaks.exec(sourceCode.text);
466
+ const insertIndex = !nextSectionMatch ? sourceCode.text.trimEnd().length : nextSectionMatch.index;
467
+ yield fixer.insertTextAfterRange([insertIndex, insertIndex], `${sourceCode.text[insertIndex - 1] === "\n" ? "" : "\n"}\n[${identifier}]: ${sourceCode.text.slice(linkInfo.urlAndTitleRange[0] + 1, linkInfo.urlAndTitleRange[1] - 1).trim()}${nextSectionMatch ? "\n" : ""}`);
468
+ }
469
+ }
470
+ });
471
+ }
472
+ }
473
+ /**
474
+ * Get the resource nodes for a link or definition.
475
+ */
476
+ function getResourceNodes(resource) {
477
+ const url = resource.url;
478
+ const title = resource.title ?? null;
479
+ let map = resourceToNodes.get(url);
480
+ if (!map) {
481
+ map = /* @__PURE__ */ new Map();
482
+ resourceToNodes.set(url, map);
483
+ }
484
+ let nodes = map.get(title);
485
+ if (!nodes) {
486
+ nodes = {
487
+ links: [],
488
+ linkReferences: [],
489
+ definitions: []
490
+ };
491
+ map.set(title, nodes);
492
+ }
493
+ return nodes;
494
+ }
495
+ }
496
+ return {
497
+ link(node) {
498
+ links.push(node);
499
+ },
500
+ linkReference(node) {
501
+ linkReferences.push(node);
502
+ },
503
+ definition(node) {
504
+ definitions.push(node);
505
+ },
506
+ "root:exit"() {
507
+ verify();
508
+ }
509
+ };
510
+ /**
511
+ * Get the range of the link label.
512
+ */
513
+ function getLinkInfo(link) {
514
+ const range = sourceCode.getRange(link);
515
+ const linkLabelRange = getLinkLabelRange();
516
+ const linkLabelWithBracketsText = sourceCode.text.slice(...linkLabelRange);
517
+ const linkLabelText = linkLabelWithBracketsText.slice(1, -1).trim();
518
+ const urlStartIndex = sourceCode.text.indexOf("(", linkLabelRange[1]);
519
+ return {
520
+ label: linkLabelText,
521
+ labelRange: linkLabelRange,
522
+ urlAndTitleRange: [urlStartIndex, range[1]]
523
+ };
524
+ /**
525
+ * Get the range of the link label.
526
+ */
527
+ function getLinkLabelRange() {
528
+ if (link.children.length === 0) {
529
+ const index$1 = sourceCode.text.indexOf("]", range[0] + 1);
530
+ return [range[0], index$1 + 1];
531
+ }
532
+ const lastRange = sourceCode.getRange(link.children[link.children.length - 1]);
533
+ const index = sourceCode.text.indexOf("]", lastRange[0] + 1);
534
+ return [range[0], index + 1];
535
+ }
536
+ }
537
+ }
538
+ });
539
+
338
540
  //#endregion
339
541
  //#region src/rules/prefer-linked-words.ts
340
542
  var prefer_linked_words_default = createRule("prefer-linked-words", {
@@ -348,20 +550,25 @@ var prefer_linked_words_default = createRule("prefer-linked-words", {
348
550
  hasSuggestions: false,
349
551
  schema: [{
350
552
  type: "object",
351
- properties: { words: { anyOf: [{
352
- type: "object",
353
- patternProperties: { "^[\\s\\S]+$": { type: ["string", "null"] } }
354
- }, {
355
- type: "array",
356
- items: { type: "string" }
357
- }] } },
358
- required: ["words"]
553
+ properties: {
554
+ words: { anyOf: [{
555
+ type: "object",
556
+ patternProperties: { "^[\\s\\S]+$": { type: ["string", "null"] } }
557
+ }, {
558
+ type: "array",
559
+ items: { type: "string" }
560
+ }] },
561
+ ignores: IGNORES_SCHEMA
562
+ },
563
+ required: ["words"],
564
+ additionalProperties: true
359
565
  }],
360
566
  messages: { requireLink: "The word \"{{name}}\" should be a link." }
361
567
  },
362
568
  create(context) {
363
569
  const sourceCode = context.sourceCode;
364
570
  const wordsOption = context.options[0]?.words || {};
571
+ const ignores = createSearchWordsIgnoreContext(context.options[0]?.ignores);
365
572
  const links = Object.create(null);
366
573
  const words = [];
367
574
  if (Array.isArray(wordsOption)) words.push(...wordsOption);
@@ -373,18 +580,29 @@ var prefer_linked_words_default = createRule("prefer-linked-words", {
373
580
  }
374
581
  words.push(word);
375
582
  }
376
- let ignore = null;
583
+ let linkedNode = null;
377
584
  return {
378
- "link, linkReference, heading, footnoteDefinition"(node) {
379
- if (ignore) return;
380
- ignore = node;
585
+ "*"(node) {
586
+ ignores.enter(node);
381
587
  },
382
- "link, linkReference, heading, footnoteDefinition:exit"(node) {
383
- if (ignore === node) ignore = null;
588
+ "*:exit"(node) {
589
+ ignores.exit(node);
590
+ },
591
+ "link, linkReference"(node) {
592
+ if (linkedNode) return;
593
+ linkedNode = node;
594
+ },
595
+ "link, linkReference:exit"(node) {
596
+ if (linkedNode === node) linkedNode = null;
384
597
  },
385
598
  text(node) {
386
- if (ignore) return;
387
- for (const { word, loc, range } of iterateSearchWords(sourceCode, node, words)) {
599
+ if (linkedNode) return;
600
+ for (const { word, loc, range } of iterateSearchWords({
601
+ sourceCode,
602
+ node,
603
+ words,
604
+ ignores
605
+ })) {
388
606
  const link = links[word];
389
607
  context.report({
390
608
  node,
@@ -398,17 +616,20 @@ var prefer_linked_words_default = createRule("prefer-linked-words", {
398
616
  }
399
617
  },
400
618
  inlineCode(node) {
401
- if (ignore) return;
402
- for (const word of words) if (node.value === word) {
403
- const link = links[word];
404
- context.report({
405
- node,
406
- messageId: "requireLink",
407
- data: { name: word },
408
- fix: link ? (fixer) => {
409
- return fixer.replaceText(node, `[\`${word}\`](${link})`);
410
- } : null
411
- });
619
+ if (linkedNode) return;
620
+ for (const word of words) {
621
+ if (ignores.ignore(word)) continue;
622
+ if (node.value === word) {
623
+ const link = links[word];
624
+ context.report({
625
+ node,
626
+ messageId: "requireLink",
627
+ data: { name: word },
628
+ fix: link ? (fixer) => {
629
+ return fixer.replaceText(node, `[\`${word}\`](${link})`);
630
+ } : null
631
+ });
632
+ }
412
633
  }
413
634
  }
414
635
  };
@@ -431,6 +652,7 @@ const rules$1 = [
431
652
  no_text_backslash_linebreak_default,
432
653
  no_trailing_spaces_default,
433
654
  prefer_inline_code_words_default,
655
+ prefer_link_reference_definitions_default,
434
656
  prefer_linked_words_default
435
657
  ];
436
658
 
@@ -466,7 +688,7 @@ __export(meta_exports, {
466
688
  version: () => version
467
689
  });
468
690
  const name = "eslint-plugin-markdown-preferences";
469
- const version = "0.4.0";
691
+ const version = "0.6.0";
470
692
 
471
693
  //#endregion
472
694
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-markdown-preferences",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "ESLint plugin that enforces our markdown preferences",
5
5
  "type": "module",
6
6
  "exports": {
@@ -71,6 +71,7 @@
71
71
  "@types/eslint-utils": "^3.0.5",
72
72
  "@types/estree": "^1.0.6",
73
73
  "@types/json-schema": "^7.0.15",
74
+ "@types/mdast": "^4.0.4",
74
75
  "@types/mocha": "^10.0.10",
75
76
  "@types/node": "^22.13.10",
76
77
  "@types/semver": "^7.5.8",