-
Notifications
You must be signed in to change notification settings - Fork 71
feat: add no-missing-link-fragments rule #380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
52d5cf5
9a4a870
b051c1a
97d8a3c
aa94245
f21dcd1
8356bc5
3027e41
7960dc8
587e32a
84f74c6
ae59bf6
c4fdda3
a36b406
485529c
364b8ac
77ce41e
a3ee316
d8b1d08
56fb763
b7d1ee3
5e57917
4df0427
d54f48b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
# no-missing-link-fragments | ||
|
||
Disallow link fragments that don't exist in the document. | ||
|
||
## Background | ||
|
||
Ensures that link fragments (URLs that start with `#`) reference valid headings or anchors in the document. This rule helps prevent broken internal links. | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
This rule uses GitHub's heading algorithm for generating heading IDs, implemented via the [`github-slugger`](https://github.com/Flet/github-slugger) package. This ensures compatibility with how GitHub renders Markdown heading anchors. | ||
|
||
```markdown | ||
# Introduction | ||
|
||
[Link](#introduction) | ||
``` | ||
|
||
## Rule Details | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
This rule is triggered when a link fragment does not match any of the fragments that are automatically generated for headings in a document or explicitly defined via HTML anchors or custom heading IDs. | ||
|
||
Examples of **incorrect** code for this rule: | ||
|
||
```markdown | ||
<!-- eslint markdown/no-missing-link-fragments: "error" --> | ||
|
||
[Invalid Link](#non-existent-heading) | ||
|
||
# Some Heading | ||
|
||
[Case Mismatch](#other-heading) | ||
``` | ||
|
||
Examples of **correct** code for this rule: | ||
|
||
```markdown | ||
<!-- eslint markdown/no-missing-link-fragments: "error" --> | ||
|
||
# Introduction | ||
|
||
[Valid Link](#introduction) | ||
|
||
# Another Section {#custom-id} | ||
|
||
[Link to custom ID](#custom-id) | ||
|
||
<h1 id="html-anchor">HTML Anchor</h1> | ||
|
||
[Link to HTML anchor](#html-anchor) | ||
|
||
<a name="named-anchor">Named Anchor</a> | ||
|
||
[Link to named anchor](#named-anchor) | ||
|
||
[Link to top of page](#top) | ||
|
||
[Link](#L2) | ||
``` | ||
|
||
## Options | ||
|
||
This rule supports the following options: | ||
|
||
* `ignoreCase: boolean` - | ||
When `true`, link fragments are compared with heading and anchor IDs in a case-insensitive manner. (default: `false`) | ||
|
||
Examples of **correct** code when configured as `"no-missing-link-fragments": ["error", { ignoreCase: true }]`: | ||
|
||
```markdown | ||
<!-- eslint markdown/no-missing-link-fragments: ["error", { ignoreCase: true }] --> | ||
|
||
# Case Test | ||
|
||
[Valid Link with different case](#CASE-TEST) | ||
|
||
``` | ||
|
||
* `allowPattern: string` - | ||
A regular expression string. If a link fragment matches this pattern, it will be ignored by the rule. This is useful for fragments that are dynamically generated or handled by other tools. (default: `""`) | ||
|
||
Examples of **correct** code when configured as `"no-missing-link-fragments": ["error", { allowPattern: "" }]`: | ||
|
||
```markdown | ||
<!-- eslint markdown/no-missing-link-fragments: ["error", { allowPattern: "^figure-" }] --> | ||
|
||
[Ignored Link](#figure-19) | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
You might consider disabling this rule if: | ||
|
||
* You are using a Markdown processor or static site generator that has a significantly different algorithm for generating heading IDs, and this rule produces too many false positives. | ||
* You have many dynamically generated links or fragments that cannot be easily covered by the `allowPattern` option. | ||
|
||
## Further Reading | ||
|
||
* [GitHub's heading anchor links](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#section-links) | ||
|
||
## Prior Art | ||
|
||
* [MD051 - Link fragments should be valid](https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
/** | ||
* @fileoverview Rule to ensure link fragments (URLs that start with #) reference valid headings | ||
* @author Sweta Tanwar (@SwetaTanwar) | ||
*/ | ||
|
||
//----------------------------------------------------------------------------- | ||
// Imports | ||
//----------------------------------------------------------------------------- | ||
|
||
import GithubSlugger from "github-slugger"; | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
//----------------------------------------------------------------------------- | ||
// Type Definitions | ||
//----------------------------------------------------------------------------- | ||
|
||
/** | ||
* @typedef {import("../types.ts").MarkdownRuleDefinition<{ | ||
* RuleOptions: [{ | ||
* ignoreCase?: boolean; | ||
* allowPattern?: string; | ||
* }]; | ||
* }>} NoMissingLinkFragmentsRuleDefinition | ||
*/ | ||
|
||
//----------------------------------------------------------------------------- | ||
// Helpers | ||
//----------------------------------------------------------------------------- | ||
|
||
const githubLineReferencePattern = /^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$/u; | ||
const customHeadingIdPattern = /\{#([a-z0-9_-]+)\}\s*$/u; | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const htmlIdNamePattern = /<(?:[^>]+)\s+(?:id|name)="([^"]+)"/gu; | ||
const headingPrefixPattern = /^#{1,6}\s+/u; | ||
|
||
/** | ||
* Checks if the fragment is a valid GitHub line reference | ||
* @param {string} fragment The fragment to check | ||
* @returns {boolean} Whether the fragment is a valid GitHub line reference | ||
*/ | ||
function isGitHubLineReference(fragment) { | ||
return githubLineReferencePattern.test(fragment); | ||
} | ||
|
||
//----------------------------------------------------------------------------- | ||
// Rule Definition | ||
//----------------------------------------------------------------------------- | ||
|
||
/** @type {NoMissingLinkFragmentsRuleDefinition} */ | ||
export default { | ||
meta: { | ||
type: "problem", | ||
|
||
docs: { | ||
recommended: true, | ||
lumirlumir marked this conversation as resolved.
Show resolved
Hide resolved
|
||
description: | ||
"Disallow link fragments that do not reference valid headings", | ||
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-link-fragments.md", | ||
}, | ||
|
||
schema: [ | ||
{ | ||
type: "object", | ||
properties: { | ||
ignoreCase: { | ||
type: "boolean", | ||
default: false, | ||
}, | ||
allowPattern: { | ||
type: "string", | ||
default: "", | ||
}, | ||
}, | ||
additionalProperties: false, | ||
}, | ||
], | ||
|
||
messages: { | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
invalidFragment: | ||
"Link fragment '#{{fragment}}' does not reference a heading or anchor in this document.", | ||
}, | ||
|
||
defaultOptions: [ | ||
{ | ||
ignoreCase: false, | ||
allowPattern: "", | ||
}, | ||
], | ||
}, | ||
|
||
create(context) { | ||
const { allowPattern: allowPatternString, ignoreCase } = | ||
context.options[0]; | ||
const allowPattern = allowPatternString | ||
? new RegExp(allowPatternString, "u") | ||
: null; | ||
|
||
const fragmentIds = new Set(["top"]); | ||
const slugger = new GithubSlugger(); | ||
SwetaTanwar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const linkNodes = []; | ||
|
||
return { | ||
heading(node) { | ||
const rawHeadingTextWithPrefix = | ||
context.sourceCode.getText(node); | ||
const rawHeadingText = rawHeadingTextWithPrefix | ||
.replace(headingPrefixPattern, "") | ||
.trim(); | ||
|
||
let baseId; | ||
const customIdMatch = rawHeadingText.match( | ||
customHeadingIdPattern, | ||
); | ||
|
||
if (customIdMatch) { | ||
baseId = customIdMatch[1]; | ||
} else { | ||
const tempSlugger = new GithubSlugger(); | ||
baseId = tempSlugger.slug(rawHeadingText); | ||
} | ||
|
||
const finalId = slugger.slug(baseId); | ||
fragmentIds.add(ignoreCase ? finalId.toLowerCase() : finalId); | ||
Comment on lines
+113
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can I ask why you're using If it's intended to handle the case I mentioned in the earlier comment, I'd like to suggest traversing the
The current logic has a problem, since For example: # foo
# foo The first heading If we declare But in this situation, if we declare So in conclusion, I'd recommend traversing the It would be nice if you could reference the logic implemented in https://github.com/remarkjs/strip-markdown. |
||
}, | ||
|
||
html(node) { | ||
const htmlText = node.value.trim(); | ||
if (htmlText.startsWith("<!--") && htmlText.endsWith("-->")) { | ||
return; | ||
} | ||
|
||
for (const match of htmlText.matchAll(htmlIdNamePattern)) { | ||
const extractedId = match[1]; | ||
const finalId = slugger.slug(extractedId); | ||
fragmentIds.add( | ||
ignoreCase ? finalId.toLowerCase() : finalId, | ||
); | ||
} | ||
}, | ||
|
||
link(node) { | ||
const url = node.url; | ||
if (!url || !url.startsWith("#")) { | ||
return; | ||
} | ||
|
||
const fragment = url.slice(1); | ||
if (!fragment) { | ||
return; | ||
} | ||
|
||
linkNodes.push({ node, fragment }); | ||
}, | ||
|
||
"root:exit"() { | ||
for (const { node, fragment } of linkNodes) { | ||
if (allowPattern?.test(fragment)) { | ||
continue; | ||
} | ||
|
||
if (isGitHubLineReference(fragment)) { | ||
continue; | ||
} | ||
|
||
const normalizedFragment = ignoreCase | ||
? fragment.toLowerCase() | ||
: fragment; | ||
|
||
if (!fragmentIds.has(normalizedFragment)) { | ||
context.report({ | ||
loc: node.position, | ||
messageId: "invalidFragment", | ||
data: { fragment }, | ||
}); | ||
} | ||
} | ||
}, | ||
}; | ||
}, | ||
}; |
Uh oh!
There was an error while loading. Please reload this page.