/** * @fileoverview Rule to prevent reversed link and image syntax in Markdown. * @author xbinaryx */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @import { Heading, Paragraph, TableCell, Html, Image, ImageReference, InlineCode, LinkReference } from "mdast"; * @import { InlineMath } from "mdast-util-math"; * @import { MarkdownRuleDefinition } from "../types.js"; * @typedef {"reversedSyntax"} NoReversedMediaSyntaxMessageIds * @typedef {[]} NoReversedMediaSyntaxOptions * @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReversedMediaSyntaxOptions, MessageIds: NoReversedMediaSyntaxMessageIds }>} NoReversedMediaSyntaxRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** Matches reversed link/image syntax like `(text)[url]`, ignoring escaped characters like `\(text\)[url]`. */ const reversedPattern = /(?<=(?(?:\\.|[^()\\]|\([\s\S]*\))*)\)\[(?(?:\\.|[^\]\\\r\n])*)\](?!\()/gu; //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- export default /** @satisfies {NoReversedMediaSyntaxRuleDefinition} */ ({ meta: { type: "problem", docs: { recommended: true, description: "Disallow reversed link and image syntax", url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reversed-media-syntax.md", }, fixable: "code", messages: { reversedSyntax: "Unexpected reversed syntax found. Use [label](URL) syntax instead.", }, }, create(context) { const { sourceCode } = context; /** @type {string[]} */ let buffer; /** @type {number} */ let nodeStartOffset; return { "heading, paragraph, tableCell"( /** @type {Heading | Paragraph | TableCell} */ node) { // Use UTF-16 code units so the buffer stays aligned with source offsets. buffer = sourceCode.getText(node).split(""); // Store the start offset of the node for later calculations. nodeStartOffset = node.position.start.offset; }, ":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"( /** @type {Html | Image | ImageReference | InlineCode | LinkReference | InlineMath} */ node) { const [startOffset, endOffset] = sourceCode.getRange(node); // Mask the content of `html`, `image`, `imageReference`, `inlineCode`, `linkReference`, and `inlineMath` nodes with whitespaces. for (let i = startOffset; i < endOffset; i++) { buffer[i - nodeStartOffset] = " "; } }, ":matches(heading, paragraph, tableCell):exit"() { const maskedText = buffer.join(""); /** @type {RegExpExecArray | null} */ let match; while ((match = reversedPattern.exec(maskedText)) !== null) { const { label, url } = match.groups; const startOffset = match.index + nodeStartOffset; // Adjust `reversedPattern` match index to the full source code. const endOffset = startOffset + match[0].length; const labelStartOffset = startOffset + 1; // Skip "(" const labelEndOffset = labelStartOffset + label.length; const urlStartOffset = labelEndOffset + 2; // Skip ")[" const urlEndOffset = urlStartOffset + url.length; context.report({ loc: { start: sourceCode.getLocFromIndex(startOffset), end: sourceCode.getLocFromIndex(endOffset), }, messageId: "reversedSyntax", fix(fixer) { return fixer.replaceTextRange([startOffset, endOffset], `[${sourceCode.text.slice(labelStartOffset, labelEndOffset)}](${sourceCode.text.slice(urlStartOffset, urlEndOffset)})`); }, }); } }, }; }, });