/** * @fileoverview Rule to prevent bare URLs in Markdown. * @author xbinaryx */ /* * Here's a note on how the approach (algorithm) works: * * - When entering an `Html` node that is a child of a `Heading`, `Paragraph` or `TableCell`, * we check whether it is an opening or closing tag. * If we encounter an opening tag, we store the tag name and set `lastTagName`. * (`lastTagName` serves as a state to represent whether we're between opening and closing HTML tags.) * If we encounter a closing tag, we reset the stored tag name and `tempLinkNodes`. * * - When entering a `Link` node that is a child of a `Heading`, `Paragraph` or `TableCell`, * we check whether it is between opening and closing HTML tags. * If it's between opening and closing HTML tags, we add it to `tempLinkNodes`. * If it's not between opening and closing HTML tags, we add it to `linkNodes`. * * - When exiting a `Heading`, `Paragraph` or `TableCell`, we add all `tempLinkNodes` to `linkNodes`. * If there are any remaining `tempLinkNodes`, it means they are not between opening and closing HTML tags. (ex. `
...
`) * If there are no remaining `tempLinkNodes`, it means they are between opening and closing HTML tags. * * - When exiting a `root` node, we report all `Link` nodes for bare URLs. */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @import { Link, Html } from "mdast"; * @import { MarkdownRuleDefinition } from "../types.js"; * @typedef {"bareUrl"} NoBareUrlsMessageIds * @typedef {[]} NoBareUrlsOptions * @typedef {MarkdownRuleDefinition<{ RuleOptions: NoBareUrlsOptions, MessageIds: NoBareUrlsMessageIds }>} NoBareUrlsRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- const htmlTagNamePattern = /^<(?[^!>][^/\s>]*)/u; /** * Parses an HTML tag to extract its name and closing status * @param {string} tagText The HTML tag text to parse * @returns {{ name: string, isClosing: boolean } | null} Object containing tag name and closing status, or null if not a valid tag */ function parseHtmlTag(tagText) { const match = tagText.match(htmlTagNamePattern); if (match) { const tagName = match.groups.tagName.toLowerCase(); const isClosing = tagName.startsWith("/"); return { name: isClosing ? tagName.slice(1) : tagName, isClosing, }; } return null; } //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- export default /** @satisfies {NoBareUrlsRuleDefinition} */ ({ meta: { type: "problem", docs: { description: "Disallow bare URLs", url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-bare-urls.md", }, fixable: "code", messages: { bareUrl: "Unexpected bare URL. Use autolink () or link ([text](URL)) instead.", }, }, create(context) { const { sourceCode } = context; /** * This array is used to store all `Link` nodes for the final report. * @type {Array} */ const linkNodes = []; /** * This array is used to store `Link` nodes that are estimated to be between opening and closing HTML tags. * @type {Array} */ const tempLinkNodes = []; /** @type {string | null} */ let lastTagName = null; /** * Resets `tempLinkNodes` and `lastTagName` * @returns {void} */ function reset() { tempLinkNodes.length = 0; lastTagName = null; } return { ":matches(heading, paragraph, tableCell) html"( /** @type {Html} */ node) { const tagInfo = parseHtmlTag(node.value); if (!tagInfo) { return; } if (!tagInfo.isClosing && lastTagName === null) { lastTagName = tagInfo.name; } if (tagInfo.isClosing && lastTagName === tagInfo.name) { reset(); } }, ":matches(heading, paragraph, tableCell) link"( /** @type {Link} */ node) { if (lastTagName !== null) { tempLinkNodes.push(node); } else { linkNodes.push(node); } }, "heading:exit"() { linkNodes.push(...tempLinkNodes); reset(); }, "paragraph:exit"() { linkNodes.push(...tempLinkNodes); reset(); }, "tableCell:exit"() { linkNodes.push(...tempLinkNodes); reset(); }, "root:exit"() { for (const linkNode of linkNodes) { const text = sourceCode.getText(linkNode); const { url } = linkNode; if (url === text || url === `http://${text}` || url === `mailto:${text}`) { context.report({ node: linkNode, messageId: "bareUrl", fix(fixer) { return fixer.replaceText(linkNode, `<${text}>`); }, }); } } }, }; }, });