/**
* @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}>`);
},
});
}
}
},
};
},
});