|
3 | 3 | * @author xbinaryx
|
4 | 4 | */
|
5 | 5 |
|
| 6 | +/* |
| 7 | + * Here's a note on how the approach (algorithm) works: |
| 8 | + * |
| 9 | + * - When entering an `Html` node that is a child of a `Heading`, `Paragraph` or `TableCell`, |
| 10 | + * we check whether it is an opening or closing tag. |
| 11 | + * If we encounter an opening tag, we store the tag name and set `lastTagName`. |
| 12 | + * (`lastTagName` serves as a state to represent whether we're between opening and closing HTML tags.) |
| 13 | + * If we encounter a closing tag, we reset the stored tag name and `tempLinkNodes`. |
| 14 | + * |
| 15 | + * - When entering a `Link` node that is a child of a `Heading`, `Paragraph` or `TableCell`, |
| 16 | + * we check whether it is between opening and closing HTML tags. |
| 17 | + * If it's between opening and closing HTML tags, we add it to `tempLinkNodes`. |
| 18 | + * If it's not between opening and closing HTML tags, we add it to `linkNodes`. |
| 19 | + * |
| 20 | + * - When exiting a `Heading`, `Paragraph` or `TableCell`, we add all `tempLinkNodes` to `linkNodes`. |
| 21 | + * If there are any remaining `tempLinkNodes`, it means they are not between opening and closing HTML tags. (ex. `<br> ... <br>`) |
| 22 | + * If there are no remaining `tempLinkNodes`, it means they are between opening and closing HTML tags. |
| 23 | + * |
| 24 | + * - When exiting a `root` node, we report all `Link` nodes for bare URLs. |
| 25 | + */ |
| 26 | + |
6 | 27 | //-----------------------------------------------------------------------------
|
7 | 28 | // Type Definitions
|
8 | 29 | //-----------------------------------------------------------------------------
|
9 | 30 |
|
10 | 31 | /**
|
11 |
| - * @import { Node, Heading, Paragraph, TableCell, Link } from "mdast"; |
| 32 | + * @import { Link, Html } from "mdast"; |
12 | 33 | * @import { MarkdownRuleDefinition } from "../types.js";
|
13 | 34 | * @typedef {"bareUrl"} NoBareUrlsMessageIds
|
14 | 35 | * @typedef {[]} NoBareUrlsOptions
|
|
19 | 40 | // Helpers
|
20 | 41 | //-----------------------------------------------------------------------------
|
21 | 42 |
|
22 |
| -const htmlTagNamePattern = /^<([^!>][^/\s>]*)/u; |
| 43 | +const htmlTagNamePattern = /^<(?<tagName>[^!>][^/\s>]*)/u; |
23 | 44 |
|
24 | 45 | /**
|
25 | 46 | * Parses an HTML tag to extract its name and closing status
|
26 | 47 | * @param {string} tagText The HTML tag text to parse
|
27 |
| - * @returns {{ name: string; isClosing: boolean; } | null} Object containing tag name and closing status, or null if not a valid tag |
| 48 | + * @returns {{ name: string, isClosing: boolean } | null} Object containing tag name and closing status, or null if not a valid tag |
28 | 49 | */
|
29 | 50 | function parseHtmlTag(tagText) {
|
30 | 51 | const match = tagText.match(htmlTagNamePattern);
|
31 | 52 | if (match) {
|
32 |
| - const tagName = match[1].toLowerCase(); |
| 53 | + const tagName = match.groups.tagName.toLowerCase(); |
33 | 54 | const isClosing = tagName.startsWith("/");
|
34 | 55 |
|
35 | 56 | return {
|
@@ -65,105 +86,94 @@ export default {
|
65 | 86 |
|
66 | 87 | create(context) {
|
67 | 88 | const { sourceCode } = context;
|
68 |
| - /** @type {Array<Link>} */ |
69 |
| - const bareUrls = []; |
70 | 89 |
|
71 | 90 | /**
|
72 |
| - * Finds bare URLs in markdown nodes while handling HTML tags. |
73 |
| - * When an HTML tag is found, it looks for its closing tag and skips all nodes |
74 |
| - * between them to prevent checking for bare URLs inside HTML content. |
75 |
| - * @param {Paragraph|Heading|TableCell} node The node to process |
76 |
| - * @returns {void} |
| 91 | + * This array is used to store all `Link` nodes for the final report. |
| 92 | + * @type {Array<Link>} |
77 | 93 | */
|
78 |
| - function findBareUrls(node) { |
79 |
| - /** |
80 |
| - * Recursively traverses the AST to find bare URLs, skipping over HTML blocks. |
81 |
| - * @param {Node} currentNode The current AST node being traversed. |
82 |
| - * @returns {void} |
83 |
| - */ |
84 |
| - function traverse(currentNode) { |
85 |
| - if ( |
86 |
| - "children" in currentNode && |
87 |
| - Array.isArray(currentNode.children) |
88 |
| - ) { |
89 |
| - for (let i = 0; i < currentNode.children.length; i++) { |
90 |
| - const child = currentNode.children[i]; |
91 |
| - |
92 |
| - if (child.type === "html") { |
93 |
| - const tagInfo = parseHtmlTag( |
94 |
| - sourceCode.getText(child), |
95 |
| - ); |
96 |
| - |
97 |
| - if (tagInfo && !tagInfo.isClosing) { |
98 |
| - for ( |
99 |
| - let j = i + 1; |
100 |
| - j < currentNode.children.length; |
101 |
| - j++ |
102 |
| - ) { |
103 |
| - const nextChild = currentNode.children[j]; |
104 |
| - if (nextChild.type === "html") { |
105 |
| - const closingTagInfo = parseHtmlTag( |
106 |
| - sourceCode.getText(nextChild), |
107 |
| - ); |
108 |
| - if ( |
109 |
| - closingTagInfo?.name === |
110 |
| - tagInfo.name && |
111 |
| - closingTagInfo?.isClosing |
112 |
| - ) { |
113 |
| - i = j; |
114 |
| - break; |
115 |
| - } |
116 |
| - } |
117 |
| - } |
118 |
| - continue; |
119 |
| - } |
120 |
| - } |
121 |
| - |
122 |
| - if (child.type === "link") { |
123 |
| - const text = sourceCode.getText(child); |
124 |
| - const { url } = child; |
125 |
| - |
126 |
| - if ( |
127 |
| - text === url || |
128 |
| - url === `http://${text}` || |
129 |
| - url === `mailto:${text}` |
130 |
| - ) { |
131 |
| - bareUrls.push(child); |
132 |
| - } |
133 |
| - } |
134 |
| - |
135 |
| - traverse(child); |
136 |
| - } |
137 |
| - } |
138 |
| - } |
| 94 | + const linkNodes = []; |
139 | 95 |
|
140 |
| - traverse(node); |
| 96 | + /** |
| 97 | + * This array is used to store `Link` nodes that are estimated to be between opening and closing HTML tags. |
| 98 | + * @type {Array<Link>} |
| 99 | + */ |
| 100 | + const tempLinkNodes = []; |
| 101 | + |
| 102 | + /** @type {string | null} */ |
| 103 | + let lastTagName = null; |
| 104 | + |
| 105 | + /** |
| 106 | + * Resets `tempLinkNodes` and `lastTagName` |
| 107 | + * @returns {void} |
| 108 | + */ |
| 109 | + function reset() { |
| 110 | + tempLinkNodes.length = 0; |
| 111 | + lastTagName = null; |
141 | 112 | }
|
142 | 113 |
|
143 | 114 | return {
|
144 |
| - "root:exit"() { |
145 |
| - for (const bareUrl of bareUrls) { |
146 |
| - context.report({ |
147 |
| - node: bareUrl, |
148 |
| - messageId: "bareUrl", |
149 |
| - fix(fixer) { |
150 |
| - const text = sourceCode.getText(bareUrl); |
151 |
| - return fixer.replaceText(bareUrl, `<${text}>`); |
152 |
| - }, |
153 |
| - }); |
| 115 | + ":matches(heading, paragraph, tableCell) html"( |
| 116 | + /** @type {Html} */ node, |
| 117 | + ) { |
| 118 | + const tagInfo = parseHtmlTag(node.value); |
| 119 | + |
| 120 | + if (!tagInfo) { |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + if (!tagInfo.isClosing && lastTagName === null) { |
| 125 | + lastTagName = tagInfo.name; |
| 126 | + } |
| 127 | + |
| 128 | + if (tagInfo.isClosing && lastTagName === tagInfo.name) { |
| 129 | + reset(); |
154 | 130 | }
|
155 | 131 | },
|
156 | 132 |
|
157 |
| - paragraph(node) { |
158 |
| - findBareUrls(node); |
| 133 | + ":matches(heading, paragraph, tableCell) link"( |
| 134 | + /** @type {Link} */ node, |
| 135 | + ) { |
| 136 | + if (lastTagName !== null) { |
| 137 | + tempLinkNodes.push(node); |
| 138 | + } else { |
| 139 | + linkNodes.push(node); |
| 140 | + } |
159 | 141 | },
|
160 | 142 |
|
161 |
| - heading(node) { |
162 |
| - findBareUrls(node); |
| 143 | + "heading:exit"() { |
| 144 | + linkNodes.push(...tempLinkNodes); |
| 145 | + reset(); |
163 | 146 | },
|
164 | 147 |
|
165 |
| - tableCell(node) { |
166 |
| - findBareUrls(node); |
| 148 | + "paragraph:exit"() { |
| 149 | + linkNodes.push(...tempLinkNodes); |
| 150 | + reset(); |
| 151 | + }, |
| 152 | + |
| 153 | + "tableCell:exit"() { |
| 154 | + linkNodes.push(...tempLinkNodes); |
| 155 | + reset(); |
| 156 | + }, |
| 157 | + |
| 158 | + "root:exit"() { |
| 159 | + for (const linkNode of linkNodes) { |
| 160 | + const text = sourceCode.getText(linkNode); |
| 161 | + const { url } = linkNode; |
| 162 | + |
| 163 | + if ( |
| 164 | + url === text || |
| 165 | + url === `http://${text}` || |
| 166 | + url === `mailto:${text}` |
| 167 | + ) { |
| 168 | + context.report({ |
| 169 | + node: linkNode, |
| 170 | + messageId: "bareUrl", |
| 171 | + fix(fixer) { |
| 172 | + return fixer.replaceText(linkNode, `<${text}>`); |
| 173 | + }, |
| 174 | + }); |
| 175 | + } |
| 176 | + } |
167 | 177 | },
|
168 | 178 | };
|
169 | 179 | },
|
|
0 commit comments