/** * @fileoverview Rule to prevent missing label references in Markdown. * @author Nicholas C. Zakas */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import { illegalShorthandTailPattern } from "../util.js"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @import { Position } from "unist"; * @import { Text } from "mdast"; * @import { MarkdownRuleDefinition } from "../types.js"; * @import { MarkdownSourceCode } from "../language/markdown-source-code.js"; * @typedef {"notFound"} NoMissingLabelRefsMessageIds * @typedef {[{ allowLabels?: string[] }]} NoMissingLabelRefsOptions * @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingLabelRefsOptions, MessageIds: NoMissingLabelRefsMessageIds }>} NoMissingLabelRefsRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Finds missing references in a node. * @param {Text} node The node to check. * @param {MarkdownSourceCode} sourceCode The Markdown source code object. * @returns {Array<{label:string,position:Position}>} The missing references. */ function findMissingReferences(node, sourceCode) { /** @type {Array<{label:string,position:Position}>} */ const missing = []; const nodeText = sourceCode.getText(node); /** * Matches substrings like `"[foo]"`, `"[]"`, `"[foo][bar]"`, `"[foo][]"`, `"[][bar]"`, or `"[][]"`. * `left` is the content between the first brackets. It can be empty. * `right` is the content between the second brackets. It can be empty, and it can be undefined. */ const labelPattern = /(?<=(?(?:\\.|[^[\]\\])*)\](?:\[(?(?:\\.|[^\]\\])*)\])?/dgu; /** @type {RegExpExecArray | null} */ let match; /* * This loop searches the text inside the node for sequences that * look like label references and reports an error for each one found. */ while ((match = labelPattern.exec(nodeText))) { // skip illegal shorthand tail -- handled by no-invalid-label-refs if (illegalShorthandTailPattern.test(match[0])) { continue; } const { left, right } = match.groups; // `[][]` or `[]` if (!left && !right) { continue; } let label, labelIndices; if (right) { label = right; labelIndices = match.indices.groups.right; } else { label = left; labelIndices = match.indices.groups.left; } const startOffset = labelIndices[0] + node.position.start.offset; const endOffset = labelIndices[1] + node.position.start.offset; missing.push({ label: label.trim(), position: { start: sourceCode.getLocFromIndex(startOffset), end: sourceCode.getLocFromIndex(endOffset), }, }); } return missing; } //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- export default /** @satisfies {NoMissingLabelRefsRuleDefinition} */ ({ meta: { type: "problem", docs: { recommended: true, description: "Disallow missing label references", url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-label-refs.md", }, schema: [ { type: "object", properties: { allowLabels: { type: "array", items: { type: "string", }, uniqueItems: true, }, }, additionalProperties: false, }, ], defaultOptions: [ { allowLabels: [], }, ], messages: { notFound: "Label reference '{{label}}' not found.", }, }, create(context) { const { sourceCode } = context; const allowLabels = new Set(context.options[0].allowLabels); /** @type {Array<{label:string,position:Position}>} */ let allMissingReferences = []; return { "root:exit"() { for (const missingReference of allMissingReferences) { context.report({ loc: missingReference.position, messageId: "notFound", data: { label: missingReference.label, }, }); } }, text(node) { const missingReferences = findMissingReferences(node, sourceCode); for (const missingReference of missingReferences) { if (!allowLabels.has(missingReference.label)) { allMissingReferences.push(missingReference); } } }, definition(node) { /* * Sometimes a poorly-formatted link will end up a text node instead of a link node * even though the label definition exists. Here, we remove any missing references * that have a matching label definition. */ allMissingReferences = allMissingReferences.filter(missingReference => missingReference.label !== node.identifier); }, }; }, });