|
3 | 3 | * Licensed under the MIT License. |
4 | 4 | */ |
5 | 5 |
|
6 | | -// eslint-plugin-no-markdown-comments/lib/rules/no-markdown-links-in-comments.js |
7 | | -module.exports = { |
| 6 | +//@ts-check |
| 7 | +/** |
| 8 | + * @typedef {import("eslint").Rule.RuleModule} RuleModule |
| 9 | + * @typedef {import('@microsoft/tsdoc').DocNode} DocNode |
| 10 | + * @typedef {import('@microsoft/tsdoc').DocPlainText} DocPlainText |
| 11 | + * |
| 12 | + * @typedef {{ |
| 13 | + * linkText: string; |
| 14 | + * linkTarget: string; |
| 15 | + * startIndex: number; |
| 16 | + * endIndex: number; |
| 17 | + * }} MarkdownLinkInfo |
| 18 | + */ |
| 19 | + |
| 20 | +const { fail } = require("node:assert"); |
| 21 | +const { DocNodeKind, TSDocParser } = require("@microsoft/tsdoc"); |
| 22 | + |
| 23 | +const parser = new TSDocParser(); |
| 24 | + |
| 25 | +/** |
| 26 | + * Finds instances of Markdown-syntax links within the provided plain text. |
| 27 | + * @param {DocPlainText} plainTextNode - The plain text node to check. |
| 28 | + * @returns {MarkdownLinkInfo[]} The list of found Markdown links. |
| 29 | + */ |
| 30 | +function findMarkdownLinksInPlainText(plainTextNode) { |
| 31 | + const textRange = |
| 32 | + plainTextNode.textExcerpt?.getContainingTextRange() ?? |
| 33 | + fail("Expected textExcerpt to be defined."); |
| 34 | + // RegEx explanation: |
| 35 | + // \[ - Match the opening square bracket |
| 36 | + // ([^\]]*) - Capture group 1: Match zero or more characters that are not a closing square bracket (the link text) |
| 37 | + // \] - Match the closing square bracket |
| 38 | + // \( - Match the opening parenthesis |
| 39 | + // ([^)]*) - Capture group 2: Match zero or more characters that are not a closing parenthesis (the link target) |
| 40 | + // \) - Match the closing parenthesis |
| 41 | + const matches = plainTextNode.text.matchAll(/\[([^\]]*)\]\(([^)]*)\)/g); |
| 42 | + return Array.from(matches, (match) => ({ |
| 43 | + linkText: match[1], |
| 44 | + linkTarget: match[2], |
| 45 | + startIndex: textRange.pos + match.index, |
| 46 | + endIndex: textRange.pos + match.index + match[0].length, |
| 47 | + })); |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * Finds instances of Markdown-syntax links within the provided comment body. |
| 52 | + * @param { DocNode } commentBodyNode - The doc node representing the body of the comment. |
| 53 | + * @returns {MarkdownLinkInfo[]} The list of found Markdown links. |
| 54 | + */ |
| 55 | +function findMarkdownLinks(commentBodyNode) { |
| 56 | + // Walk down all children to find all plain text nodes. |
| 57 | + // Search those nodes for Markdown links. |
| 58 | + // We only search plain text because we want to ignore link syntax that may appear in other |
| 59 | + // contexts like code spans / code blocks where they would not be interpreted as links, and |
| 60 | + // where they may exist to serve as examples, etc. |
| 61 | + if (commentBodyNode.kind === DocNodeKind.PlainText) { |
| 62 | + return findMarkdownLinksInPlainText(/** @type {DocPlainText} */ (commentBodyNode)); |
| 63 | + } |
| 64 | + |
| 65 | + const childNodes = commentBodyNode.getChildNodes(); |
| 66 | + |
| 67 | + const links = []; |
| 68 | + for (const childNode of childNodes) { |
| 69 | + links.push(...findMarkdownLinks(childNode)); |
| 70 | + } |
| 71 | + return links; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Eslint rule to disallow Markdown link syntax in JSDoc/TSDoc comments. |
| 76 | + * `{@link}` syntax should be used instead. |
| 77 | + * |
| 78 | + * @type {RuleModule} |
| 79 | + */ |
| 80 | +const rule = { |
8 | 81 | meta: { |
9 | 82 | type: "problem", |
10 | 83 | docs: { |
@@ -32,33 +105,74 @@ module.exports = { |
32 | 105 | .filter((comment) => comment.type === "Block" && comment.value.startsWith("*")); |
33 | 106 |
|
34 | 107 | for (const comment of comments) { |
35 | | - // +2 for the leading "/*", which is omitted by `comment.value`, but included in `comment.range`. |
36 | | - const commentStartIndex = comment.range[0] + 2; |
37 | | - |
38 | | - const matches = comment.value.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g); |
39 | | - for (const match of matches) { |
40 | | - const [fullMatch, text, url] = match; |
41 | | - |
42 | | - const startIndex = commentStartIndex + match.index; |
43 | | - const endIndex = startIndex + fullMatch.length; |
44 | | - |
45 | | - context.report({ |
46 | | - loc: { |
47 | | - start: sourceCode.getLocFromIndex(startIndex), |
48 | | - end: sourceCode.getLocFromIndex(endIndex), |
49 | | - }, |
50 | | - messageId: "markdownLink", |
51 | | - fix(fixer) { |
52 | | - const trimmedText = text?.trim(); |
53 | | - const tsdocLink = trimmedText |
54 | | - ? `{@link ${url} | ${trimmedText}}` |
55 | | - : `{@link ${url}}`; |
56 | | - return fixer.replaceTextRange([startIndex, endIndex], tsdocLink); |
57 | | - }, |
58 | | - }); |
| 108 | + if (comment.range === undefined) { |
| 109 | + continue; |
| 110 | + } |
| 111 | + |
| 112 | + const commentStartIndex = comment.range[0]; |
| 113 | + |
| 114 | + // TSDoc parser requires the surrounding "/**" and "*/", but eslint strips those off in `comment.value`. |
| 115 | + const parserContext = parser.parseString(`/**${comment.value}*/`); |
| 116 | + const parsedComment = parserContext.docComment; |
| 117 | + |
| 118 | + const blocksToCheck = [ |
| 119 | + ...parsedComment.customBlocks, |
| 120 | + ...parsedComment.seeBlocks, |
| 121 | + ]; |
| 122 | + if (parsedComment.remarksBlock) { |
| 123 | + blocksToCheck.push(parsedComment.remarksBlock); |
| 124 | + } |
| 125 | + if (parsedComment.privateRemarks) { |
| 126 | + blocksToCheck.push(parsedComment.privateRemarks); |
| 127 | + } |
| 128 | + if (parsedComment.deprecatedBlock) { |
| 129 | + blocksToCheck.push(parsedComment.deprecatedBlock); |
| 130 | + } |
| 131 | + if (parsedComment.returnsBlock) { |
| 132 | + blocksToCheck.push(parsedComment.returnsBlock); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * Checks the provided comment block for Markdown-syntax links and report eslint errors for them. |
| 137 | + * @param {DocNode} node - The comment block to check. |
| 138 | + * @returns {void} |
| 139 | + */ |
| 140 | + function checkCommentBlock(node) { |
| 141 | + const links = findMarkdownLinks(node); |
| 142 | + for (const link of links) { |
| 143 | + const startIndex = commentStartIndex + link.startIndex - 1; |
| 144 | + const endIndex = commentStartIndex + link.endIndex - 1; |
| 145 | + |
| 146 | + context.report({ |
| 147 | + loc: { |
| 148 | + start: sourceCode.getLocFromIndex(startIndex), |
| 149 | + end: sourceCode.getLocFromIndex(endIndex), |
| 150 | + }, |
| 151 | + messageId: "markdownLink", |
| 152 | + fix(fixer) { |
| 153 | + const trimmedText = link.linkText.trim(); |
| 154 | + const tsdocLink = trimmedText.length > 0 |
| 155 | + ? `{@link ${link.linkTarget} | ${trimmedText}}` |
| 156 | + : `{@link ${link.linkTarget}}`; |
| 157 | + return fixer.replaceTextRange( |
| 158 | + [startIndex, endIndex], |
| 159 | + tsdocLink, |
| 160 | + ); |
| 161 | + }, |
| 162 | + }); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + // Note: the TSDoc format makes it difficult to extract the range information for the block content specifically. |
| 167 | + // Instead, we just report the range for the tag itself. |
| 168 | + checkCommentBlock(parsedComment.summarySection); |
| 169 | + for (const block of blocksToCheck) { |
| 170 | + checkCommentBlock(block.content); |
59 | 171 | } |
60 | 172 | } |
61 | 173 | }, |
62 | 174 | }; |
63 | 175 | }, |
64 | 176 | }; |
| 177 | + |
| 178 | +module.exports = rule; |
0 commit comments