Reimplement MD042/no-empty-links using micromark tokens.

This commit is contained in:
David Anson 2024-06-29 16:45:40 -07:00
parent 7377b75383
commit 964e4c80ab
6 changed files with 219 additions and 97 deletions

View file

@ -1523,6 +1523,21 @@ function filterByTypes(tokens, types, htmlFlow) {
return filterByPredicate(tokens, predicate); return filterByPredicate(tokens, predicate);
} }
/**
* Gets a list of nested Micromark token descendants by type path.
*
* @param {Token|Token[]} parent Micromark token parent or parents.
* @param {TokenType[]} typePath Micromark token type path.
* @returns {Token[]} Micromark token descendants.
*/
function getDescendantsByType(parent, typePath) {
let tokens = Array.isArray(parent) ? parent : [ parent ];
for (const type of typePath) {
tokens = tokens.flatMap((t) => t.children).filter((t) => t.type === type);
}
return tokens;
}
/** /**
* Gets the heading level of a Micromark heading tokan. * Gets the heading level of a Micromark heading tokan.
* *
@ -1691,6 +1706,7 @@ module.exports = {
"parse": micromarkParse, "parse": micromarkParse,
filterByPredicate, filterByPredicate,
filterByTypes, filterByTypes,
getDescendantsByType,
getHeadingLevel, getHeadingLevel,
getHeadingStyle, getHeadingStyle,
getHeadingText, getHeadingText,
@ -5813,8 +5829,9 @@ module.exports = {
const { addErrorContext, escapeForRegExp, filterTokens } = const { addErrorContext } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js");
__webpack_require__(/*! ../helpers */ "../helpers/helpers.js"); const { getDescendantsByType, filterByTypes } = __webpack_require__(/*! ../helpers/micromark.cjs */ "../helpers/micromark.cjs");
const { referenceLinkImageData } = __webpack_require__(/*! ./cache */ "../lib/cache.js");
// eslint-disable-next-line jsdoc/valid-types // eslint-disable-next-line jsdoc/valid-types
/** @type import("./markdownlint").Rule */ /** @type import("./markdownlint").Rule */
@ -5822,44 +5839,56 @@ module.exports = {
"names": [ "MD042", "no-empty-links" ], "names": [ "MD042", "no-empty-links" ],
"description": "No empty links", "description": "No empty links",
"tags": [ "links" ], "tags": [ "links" ],
"parser": "markdownit", "parser": "micromark",
"function": function MD042(params, onError) { "function": function MD042(params, onError) {
filterTokens(params, "inline", function forToken(token) { const { definitions } = referenceLinkImageData();
let inLink = false; const isReferenceDefinitionHash = (token) => {
let linkText = ""; const definition = definitions.get(token.text.trim());
let emptyLink = false; return (definition && (definition[1] === "#"));
for (const child of token.children) { };
if (child.type === "link_open") { const links = filterByTypes(
inLink = true; params.parsers.micromark.tokens,
linkText = ""; [ "link" ]
for (const attr of child.attrs) { );
if (attr[0] === "href" && (!attr[1] || (attr[1] === "#"))) { for (const link of links) {
emptyLink = true; const labelText = getDescendantsByType(link, [ "label", "labelText" ]);
} const reference = getDescendantsByType(link, [ "reference" ]);
} const resource = getDescendantsByType(link, [ "resource" ]);
} else if (child.type === "link_close") { const referenceString = getDescendantsByType(reference, [ "referenceString" ]);
inLink = false; const resourceDestination = getDescendantsByType(resource, [ "resourceDestination" ]);
if (emptyLink) { const resourceDestinationString = [
let context = `[${linkText}]`; ...getDescendantsByType(resourceDestination, [ "resourceDestinationRaw", "resourceDestinationString" ]),
let range = null; ...getDescendantsByType(resourceDestination, [ "resourceDestinationLiteral", "resourceDestinationString" ])
const match = child.line.match( ];
new RegExp(`${escapeForRegExp(context)}\\((?:|#|<>)\\)`) const hasLabelText = labelText.length > 0;
); const hasReference = reference.length > 0;
if (match) { const hasResource = resource.length > 0;
context = match[0]; const hasReferenceString = referenceString.length > 0;
// @ts-ignore const hasResourceDestinationString = resourceDestinationString.length > 0;
range = [ match.index + 1, match[0].length ]; let error = false;
} if (
addErrorContext( hasLabelText &&
onError, child.lineNumber, context, null, null, range ((!hasReference && !hasResource) || (hasReference && !hasReferenceString))
); ) {
emptyLink = false; error = isReferenceDefinitionHash(labelText[0]);
} } else if (hasReferenceString && !hasResourceDestinationString) {
} else if (inLink) { error = isReferenceDefinitionHash(referenceString[0]);
linkText += child.content; } else if (!hasReferenceString && hasResourceDestinationString) {
} error = (resourceDestinationString[0].text.trim() === "#");
} else if (!hasReferenceString && !hasResourceDestinationString) {
error = true;
} }
}); if (error) {
addErrorContext(
onError,
link.startLine,
link.text,
undefined,
undefined,
[ link.startColumn, link.endColumn - link.startColumn ]
);
}
}
} }
}; };

View file

@ -304,6 +304,21 @@ function filterByTypes(tokens, types, htmlFlow) {
return filterByPredicate(tokens, predicate); return filterByPredicate(tokens, predicate);
} }
/**
* Gets a list of nested Micromark token descendants by type path.
*
* @param {Token|Token[]} parent Micromark token parent or parents.
* @param {TokenType[]} typePath Micromark token type path.
* @returns {Token[]} Micromark token descendants.
*/
function getDescendantsByType(parent, typePath) {
let tokens = Array.isArray(parent) ? parent : [ parent ];
for (const type of typePath) {
tokens = tokens.flatMap((t) => t.children).filter((t) => t.type === type);
}
return tokens;
}
/** /**
* Gets the heading level of a Micromark heading tokan. * Gets the heading level of a Micromark heading tokan.
* *
@ -472,6 +487,7 @@ module.exports = {
"parse": micromarkParse, "parse": micromarkParse,
filterByPredicate, filterByPredicate,
filterByTypes, filterByTypes,
getDescendantsByType,
getHeadingLevel, getHeadingLevel,
getHeadingStyle, getHeadingStyle,
getHeadingText, getHeadingText,

View file

@ -2,8 +2,9 @@
"use strict"; "use strict";
const { addErrorContext, escapeForRegExp, filterTokens } = const { addErrorContext } = require("../helpers");
require("../helpers"); const { getDescendantsByType, filterByTypes } = require("../helpers/micromark.cjs");
const { referenceLinkImageData } = require("./cache");
// eslint-disable-next-line jsdoc/valid-types // eslint-disable-next-line jsdoc/valid-types
/** @type import("./markdownlint").Rule */ /** @type import("./markdownlint").Rule */
@ -11,43 +12,55 @@ module.exports = {
"names": [ "MD042", "no-empty-links" ], "names": [ "MD042", "no-empty-links" ],
"description": "No empty links", "description": "No empty links",
"tags": [ "links" ], "tags": [ "links" ],
"parser": "markdownit", "parser": "micromark",
"function": function MD042(params, onError) { "function": function MD042(params, onError) {
filterTokens(params, "inline", function forToken(token) { const { definitions } = referenceLinkImageData();
let inLink = false; const isReferenceDefinitionHash = (token) => {
let linkText = ""; const definition = definitions.get(token.text.trim());
let emptyLink = false; return (definition && (definition[1] === "#"));
for (const child of token.children) { };
if (child.type === "link_open") { const links = filterByTypes(
inLink = true; params.parsers.micromark.tokens,
linkText = ""; [ "link" ]
for (const attr of child.attrs) { );
if (attr[0] === "href" && (!attr[1] || (attr[1] === "#"))) { for (const link of links) {
emptyLink = true; const labelText = getDescendantsByType(link, [ "label", "labelText" ]);
} const reference = getDescendantsByType(link, [ "reference" ]);
} const resource = getDescendantsByType(link, [ "resource" ]);
} else if (child.type === "link_close") { const referenceString = getDescendantsByType(reference, [ "referenceString" ]);
inLink = false; const resourceDestination = getDescendantsByType(resource, [ "resourceDestination" ]);
if (emptyLink) { const resourceDestinationString = [
let context = `[${linkText}]`; ...getDescendantsByType(resourceDestination, [ "resourceDestinationRaw", "resourceDestinationString" ]),
let range = null; ...getDescendantsByType(resourceDestination, [ "resourceDestinationLiteral", "resourceDestinationString" ])
const match = child.line.match( ];
new RegExp(`${escapeForRegExp(context)}\\((?:|#|<>)\\)`) const hasLabelText = labelText.length > 0;
); const hasReference = reference.length > 0;
if (match) { const hasResource = resource.length > 0;
context = match[0]; const hasReferenceString = referenceString.length > 0;
// @ts-ignore const hasResourceDestinationString = resourceDestinationString.length > 0;
range = [ match.index + 1, match[0].length ]; let error = false;
} if (
addErrorContext( hasLabelText &&
onError, child.lineNumber, context, null, null, range ((!hasReference && !hasResource) || (hasReference && !hasReferenceString))
); ) {
emptyLink = false; error = isReferenceDefinitionHash(labelText[0]);
} } else if (hasReferenceString && !hasResourceDestinationString) {
} else if (inLink) { error = isReferenceDefinitionHash(referenceString[0]);
linkText += child.content; } else if (!hasReferenceString && hasResourceDestinationString) {
} error = (resourceDestinationString[0].text.trim() === "#");
} else if (!hasReferenceString && !hasResourceDestinationString) {
error = true;
} }
}); if (error) {
addErrorContext(
onError,
link.startLine,
link.text,
undefined,
undefined,
[ link.startColumn, link.endColumn - link.startColumn ]
);
}
}
} }
}; };

View file

@ -24,6 +24,10 @@
[text][ frag ] {MD042} [text][ frag ] {MD042}
[frag][] {MD042}
[frag] {MD042}
[frag]: # [frag]: #
## Non-empty links ## Non-empty links

View file

@ -11837,9 +11837,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text]( <> )',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
12,
],
fixInfo: null, fixInfo: null,
lineNumber: 9, lineNumber: 9,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11850,9 +11853,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text](<> "title")',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
18,
],
fixInfo: null, fixInfo: null,
lineNumber: 11, lineNumber: 11,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11863,9 +11869,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text]( <> "title" )',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
20,
],
fixInfo: null, fixInfo: null,
lineNumber: 13, lineNumber: 13,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11892,9 +11901,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text]( # )',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
11,
],
fixInfo: null, fixInfo: null,
lineNumber: 17, lineNumber: 17,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11905,9 +11917,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text](# "title")',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
17,
],
fixInfo: null, fixInfo: null,
lineNumber: 19, lineNumber: 19,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11918,9 +11933,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text]( # "title" )',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
19,
],
fixInfo: null, fixInfo: null,
lineNumber: 21, lineNumber: 21,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11931,9 +11949,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text][frag]',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
12,
],
fixInfo: null, fixInfo: null,
lineNumber: 23, lineNumber: 23,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11944,9 +11965,12 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]', errorContext: '[text][ frag ]',
errorDetail: null, errorDetail: null,
errorRange: null, errorRange: [
1,
14,
],
fixInfo: null, fixInfo: null,
lineNumber: 25, lineNumber: 25,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
@ -11957,14 +11981,30 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
{ {
errorContext: '[text]()', errorContext: '[frag][]',
errorDetail: null, errorDetail: null,
errorRange: [ errorRange: [
1, 1,
8, 8,
], ],
fixInfo: null, fixInfo: null,
lineNumber: 70, lineNumber: 27,
ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [
'MD042',
'no-empty-links',
],
},
{
errorContext: '[frag]',
errorDetail: null,
errorRange: [
1,
6,
],
fixInfo: null,
lineNumber: 29,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md', ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [ ruleNames: [
@ -11996,7 +12036,7 @@ Generated by [AVA](https://avajs.dev).
8, 8,
], ],
fixInfo: null, fixInfo: null,
lineNumber: 76, lineNumber: 78,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md', ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [ ruleNames: [
@ -12012,7 +12052,7 @@ Generated by [AVA](https://avajs.dev).
8, 8,
], ],
fixInfo: null, fixInfo: null,
lineNumber: 79, lineNumber: 80,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md', ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [ ruleNames: [
@ -12028,7 +12068,23 @@ Generated by [AVA](https://avajs.dev).
8, 8,
], ],
fixInfo: null, fixInfo: null,
lineNumber: 81, lineNumber: 83,
ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [
'MD042',
'no-empty-links',
],
},
{
errorContext: '[text]()',
errorDetail: null,
errorRange: [
1,
8,
],
fixInfo: null,
lineNumber: 85,
ruleDescription: 'No empty links', ruleDescription: 'No empty links',
ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md', ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md042.md',
ruleNames: [ ruleNames: [
@ -12063,6 +12119,10 @@ Generated by [AVA](https://avajs.dev).
[text][ frag ] {MD042}␊ [text][ frag ] {MD042}␊
[frag][] {MD042}␊
[frag] {MD042}␊
[frag]: #␊ [frag]: #␊
## Non-empty links␊ ## Non-empty links␊