Skip to content

Commit 9b58e36

Browse files
authored
fix: recognize Definition node in no-missing-link-fragments (#603)
1 parent 0d68897 commit 9b58e36

2 files changed

Lines changed: 85 additions & 35 deletions

File tree

src/rules/no-missing-link-fragments.js

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
//-----------------------------------------------------------------------------
99

1010
import GithubSlugger from "github-slugger";
11-
import { htmlCommentPattern } from "../util.js";
11+
import { stripHtmlComments } from "../util.js";
1212

1313
//-----------------------------------------------------------------------------
1414
// Type Definitions
1515
//-----------------------------------------------------------------------------
1616

1717
/**
18-
* @import { Link } from "mdast";
18+
* @import { Definition, Link } from "mdast";
1919
* @import { MarkdownRuleDefinition } from "../types.js";
2020
* @typedef {"invalidFragment"} NoMissingLinkFragmentsMessageIds
2121
* @typedef {[{ ignoreCase?: boolean; allowPattern?: string }]} NoMissingLinkFragmentsOptions
@@ -76,17 +76,16 @@ export default {
7676
},
7777

7878
create(context) {
79-
const [{ allowPattern: allowPatternString, ignoreCase }] =
80-
context.options;
81-
const allowPattern = allowPatternString
82-
? new RegExp(allowPatternString, "u")
79+
const [{ allowPattern, ignoreCase }] = context.options;
80+
const allowPatternOrNull = allowPattern
81+
? new RegExp(allowPattern, "u")
8382
: null;
8483

8584
const fragmentIds = new Set(["top"]);
8685
const slugger = new GithubSlugger();
8786

88-
/** @type {Array<{node: Link, fragment: string}>} */
89-
const linkNodes = [];
87+
/** @type {Array<Definition | Link>} */
88+
const relevantNodes = [];
9089
/** @type {string} */
9190
let headingText;
9291

@@ -101,58 +100,52 @@ export default {
101100

102101
"heading:exit"() {
103102
const customIdMatch = headingText.match(customHeadingIdPattern);
104-
const baseId = customIdMatch
103+
const id = customIdMatch
105104
? customIdMatch.groups.id
106105
: headingText;
107-
const finalId = slugger.slug(baseId);
108-
fragmentIds.add(ignoreCase ? finalId.toLowerCase() : finalId);
106+
107+
fragmentIds.add(slugger.slug(id));
109108
},
110109

111110
html(node) {
112111
// 1. Remove all comments
113-
const htmlTextWithoutComments = node.value
114-
.trim()
115-
.replace(htmlCommentPattern, "");
112+
const htmlTextWithoutComments = stripHtmlComments(node.value);
116113

117114
// 2. Then look for IDs in the remaining text
118115
for (const match of htmlTextWithoutComments.matchAll(
119116
htmlIdNamePattern,
120117
)) {
121-
const extractedId = match.groups.id;
122-
const finalId = slugger.slug(extractedId);
123-
fragmentIds.add(
124-
ignoreCase ? finalId.toLowerCase() : finalId,
125-
);
118+
const { id } = match.groups;
119+
120+
fragmentIds.add(slugger.slug(id));
126121
}
127122
},
128123

129-
link(node) {
130-
const url = node.url;
131-
if (!url || !url.startsWith("#")) {
132-
return;
133-
}
124+
"definition, link"(/** @type {Definition | Link} */ node) {
125+
const { url } = node;
134126

135-
const fragment = url.slice(1);
136-
if (!fragment) {
127+
// If `url` is empty, `"#"`, or does not start with `"#"`, skip it.
128+
if (url === "" || url === "#" || !url.startsWith("#")) {
137129
return;
138130
}
139131

140-
linkNodes.push({ node, fragment });
132+
relevantNodes.push(node);
141133
},
142134

143135
"root:exit"() {
144-
for (const { node, fragment } of linkNodes) {
145-
/** @type {string} */
146-
let decodedFragment;
136+
for (const node of relevantNodes) {
137+
const fragment = node.url.slice(1);
138+
let decodedFragment = fragment;
139+
140+
// Decode URI component to handle encoded characters such as `%20`.
147141
try {
148142
decodedFragment = decodeURIComponent(fragment);
149143
} catch {
150-
// fallback if not valid encoding
151-
decodedFragment = fragment;
144+
// If decoding fails due to an invalid URI sequence, use the original fragment.
152145
}
153146

154147
if (
155-
allowPattern?.test(decodedFragment) ||
148+
allowPatternOrNull?.test(decodedFragment) ||
156149
githubLineReferencePattern.test(decodedFragment)
157150
) {
158151
continue;

tests/rules/no-missing-link-fragments.test.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,20 @@ const ruleTester = new RuleTester({
2525

2626
ruleTester.run("no-missing-link-fragments", rule, {
2727
valid: [
28-
// Basic heading match
28+
// Basic heading match with `Link` node
2929
dedent`
3030
# Heading Name
3131
[Link](#heading-name)
3232
`,
3333

34+
// Basic heading match with `Definition` node
35+
dedent`
36+
# Heading Name
37+
[Link][reference]
38+
39+
[reference]: #heading-name
40+
`,
41+
3442
// Custom heading ID
3543
dedent`
3644
# Heading Name {#custom-name}
@@ -383,7 +391,7 @@ ruleTester.run("no-missing-link-fragments", rule, {
383391
],
384392

385393
invalid: [
386-
// Basic invalid case
394+
// Basic invalid case with `Link` node
387395
{
388396
code: dedent`
389397
[Invalid](#non-existent)
@@ -400,6 +408,25 @@ ruleTester.run("no-missing-link-fragments", rule, {
400408
],
401409
},
402410

411+
// Basic invalid case with `Definition` node
412+
{
413+
code: dedent`
414+
[Invalid][reference]
415+
416+
[reference]: #non-existent
417+
`,
418+
errors: [
419+
{
420+
messageId: "invalidFragment",
421+
data: { fragment: "non-existent" },
422+
line: 3,
423+
column: 1,
424+
endLine: 3,
425+
endColumn: 27,
426+
},
427+
],
428+
},
429+
403430
// Case-sensitive mismatch (with ignoreCase false option)
404431
{
405432
code: dedent`
@@ -520,6 +547,36 @@ ruleTester.run("no-missing-link-fragments", rule, {
520547
},
521548
],
522549
},
550+
{
551+
code: dedent`
552+
[Invalid Format](#l20)
553+
`,
554+
errors: [
555+
{
556+
messageId: "invalidFragment",
557+
data: { fragment: "l20" },
558+
line: 1,
559+
column: 1,
560+
endLine: 1,
561+
endColumn: 23,
562+
},
563+
],
564+
},
565+
{
566+
code: dedent`
567+
[Invalid Format](#l20-l30)
568+
`,
569+
errors: [
570+
{
571+
messageId: "invalidFragment",
572+
data: { fragment: "l20-l30" },
573+
line: 1,
574+
column: 1,
575+
endLine: 1,
576+
endColumn: 27,
577+
},
578+
],
579+
},
523580

524581
// Invalid link to suffixed heading that shouldn't exist
525582
{

0 commit comments

Comments
 (0)