148 lines
5.8 KiB
JavaScript
148 lines
5.8 KiB
JavaScript
/**
|
|
* @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 = /(?<=(?<!\\)(?:\\{2})*)\[(?<left>(?:\\.|[^[\]\\])*)\](?:\[(?<right>(?:\\.|[^\]\\])*)\])?/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);
|
|
},
|
|
};
|
|
},
|
|
});
|