Rule - MD051#
| Property | Value |
|---|---|
| Aliases | md051, link-fragments |
| Autofix Available | No |
| Enabled By Default | Yes |
Summary#
Local link fragments should be valid.
Reasoning#
Correctness#
Broken internal links degrade the reader's experience: a reader who clicks a
link expecting to land on a specific section, only to find the fragment does not
resolve to any target, is left with a confusing, unhelpful result. Unlike
external targets, local link fragments (such as #target) are defined entirely
within the same document, so there is no reason not to validate them.
Spec Basis#
There is no official GitHub Flavored Markdown specification for what constitutes a valid link target. This rule collects the target text from one of the following Markdown elements:
- ATX heading: any text on the same line, excluding any trailing
#characters that are part of an ATX heading close. - Setext heading: any text that is part of the paragraph before the Setext heading characters.
- HTML Blocks and Raw HTML: the
nameattribute for anastart tag (i.e.<a name="bookmark">) or any start tag'sidattribute (i.e.<a id="bookmark">).
For the ATX and Setext heading cases, the collected text is transformed into a fragment using the historic GitHub heading algorithm. The transformation steps, and how they are affected by configuration, are documented in the Configuration section.
Examples#
Failure Scenarios#
This rule triggers when there is an inline link with a local fragment specified, but the document does not contain any targets with that name.
Explanation: This example fails because the inline link points to the local fragment
#local-fragment, but the document contains no heading (or other target) that generates that fragment. The only heading,# Heading Name, produces the fragment#heading-name(lowercased, with spaces converted to dashes). Because the referenced fragment does not resolve to any real target, the unresolved fragment is invalid.
Unlike the previous example, which used an inline link, this scenario places the invalid fragment inside a link reference definition. The rule triggers on the definition itself rather than on the shorter reference that uses it.
Explanation: This example fails for the same underlying reason as the previous one, but the invalid fragment is defined in a link reference definition rather than written inline. The definition
[Local Link]: #local-fragmentpoints to a fragment that no target in the document generates. Because the rule evaluates the destination of the link regardless of how it is written, it triggers on the link reference definition itself — not on the shorter[Local Link]reference that uses it — since that definition is where the unresolvable fragment actually lives.
Unlike the previous examples, which used ATX headings, this scenario uses a Setext heading. The heading text still generates a fragment, but the referenced fragment does not match it.
Explanation: This example fails because the Setext heading
Heading Namegenerates the fragment#heading-name(text lowercased, spaces converted to dashes), but the link points to#heading-name-xyz, which no heading or target produces. The fragment is therefore unresolved and invalid, even though the heading is a Setext heading rather than an ATX heading.
Unlike the previous examples, which relied on headings, this scenario uses an
HTML id attribute as the only target in the document. The referenced
fragment does not match the id.
Explanation: This example fails because the only target in the document is the HTML element with
id="real-bookmark", which provides the fragment#real-bookmark. The link points to#wrong-bookmark, which matches neither the HTMLid/nametarget nor any heading, so the fragment does not resolve to any target in the document.
Unlike the previous example, which used an HTML id attribute as the only
target in the document, this scenario uses an HTML name attribute
instead. The rule treats name and id as equivalent targets, but the
referenced fragment still does not match the name value.
Explanation: This example fails because the only target in the document is the HTML element with
name="real-bookmark", which provides the fragment#real-bookmark. The link points to#wrong-bookmark, which does not match the name value and does not correspond to any heading, so the fragment does not resolve to any target in the document. The failure is structurally identical to theidcase in the previous scenario; it confirms that the rule validates bothnameandidHTML targets.
Unlike the previous examples, which each had a single target in the document,
this scenario contains two identical headings. The rule appends an
incrementing integer to duplicate headings, but the link references a third
occurrence (#heading-name-3) that does not exist.
Explanation: This example fails because the document contains only two occurrences of the heading
Heading Name. The rule assigns the fragments#heading-name(first occurrence) and#heading-name-2(second occurrence), but no fragment#heading-name-3is generated. The link[Local Link](#heading-name-3)therefore references a fragment that no target in the document produces, and the rule flags it as an unresolved local fragment. This is the inverse of the "duplicate heading" correct scenario: that one targets an existing numbered fragment, while this one targets a number beyond the count of duplicates.
Unlike the previous examples, which each relied on a fragment that did not
match the target at all, this scenario isolates the case-sensitivity behavior
controlled by ignore-case. The fragment differs from the heading-generated
fragment only in case, and the configuration ignore-case is set to False
(the non-default value), so the rule performs a case-sensitive comparison and
flags the mismatch.
Explanation: The heading
# Heading Namegenerates the lowercase fragment#heading-name. The link uses#HEADING-NAME, which differs only in case. Withignore-caseset toFalse(non-default), the rule compares the fragment case-sensitively:#HEADING-NAMEdoes not equal#heading-name, so the fragment does not resolve to any target in the document and the rule triggers. This is the inverse of the "case-insensitive match" correct scenario: that one uses the same fragment withignore-caseset toTrue(the default) and passes, while this one uses the same fragment withignore-caseset toFalseand fails.
Correct Scenarios#
This rule does not trigger when there is a link with a local fragment specified, with the document containing a target with that name.
Explanation: This example passes because the local fragment
#heading-namematches the fragment generated by the heading# Heading Name. The heading text is converted to lowercase, punctuation is removed, and the space is converted to a dash, producing exactly#heading-name. Since that target exists in the document, the link resolves to a real section and a reader clicking it is taken to the expected place. Because the fragment is valid, the rule does not trigger.
Unlike the previous correct scenario, where a fragment must match a generated heading,
this scenario uses the special target #top, which the rule recognizes without
requiring a corresponding heading or element in the document.
Explanation: This example passes because
#topis a universally recognized special target that does not require a corresponding heading or element in the document. The rule exempts this well-known fragment from validation, so the link resolves to the top of the page without needing a matching target.
Unlike the previous correct scenarios, this example's link fragment
(#figure-1a) would normally be invalid because no heading generates that
fragment. It is allowed here only because the configuration
ignore-pattern-regex is set to a non-default value (^figure-); under the
default empty-string setting this would be a failure.
Explanation: With the default
ignore-pattern-regexvalue of(empty string), this fragment would be flagged as unresolved. In this example the configurationignore-pattern-regexis set to^figure-, so the rule consults this pattern before evaluating the fragment and, becausefigure-1amatches the^figure-prefix, the rule skips validation. This is the intended mechanism for tolerating generator-added anchors (e.g., image captions labelledfigure-1,figure-2, …) that do not correspond to any author-defined heading.
Unlike the previous examples, this scenario involves a repeated heading, so
the
heading the link intends is the second occurrence. Because the rule appends an
incrementing integer to repeated headings, the second one resolves to
#heading-name-2, not the bare #heading-name.
Explanation: This example passes because the document contains two occurrences of the heading
Heading Name. The rule appends an incrementing integer to duplicate headings to ensure unique fragments: the first occurrence generates#heading-nameand the second generates#heading-name-2. The link[Local Link](#heading-name-2)correctly targets the second occurrence's fragment, which is a valid, resolvable target in the document.
Unlike the previous examples, this scenario isolates the case-sensitivity behavior
controlled by ignore-case. With the value of ignore-case set to True (its
default),
the following example does not trigger the rule.
Explanation: The heading
# Heading Namegenerates the lowercase fragment#heading-name. The link uses#HEADING-NAME, which differs only in case. Withignore-caseset toTrue(the default) the rule treats these as matching and does not trigger this rule.
Unlike the previous correct scenarios, which used ATX headings, this scenario uses a Setext heading whose text still generates a matching fragment.
Explanation: This example passes because the Setext heading
Heading Namegenerates the fragment#heading-nameusing the same transformation as ATX headings (lowercase, spaces to dashes, punctuation removed). The link[Local Link](#heading-name)therefore resolves to a real target, and the rule does not trigger.
Unlike the previous correct scenarios, which relied on headings, this scenario uses
an HTML id attribute as the matching target.
Explanation: This example passes because the HTML element's
idattribute provides the fragment#my-bookmark, and the link[Local Link](#my-bookmark)points directly at it. The rule treats HTMLid(andname) targets as valid link-fragment targets, so the fragment resolves and the rule does not trigger.
Unlike the previous correct scenarios, which used inline links, this scenario uses a link reference definition whose destination matches an existing heading fragment.
Explanation: This example passes because the link reference definition
[ref]: #heading-nameresolves to the fragment#heading-name, which is generated by the ATX heading# Heading Namelater in the document. The rule evaluates the destination of the link regardless of how it is written, so the resolvable fragment satisfies the rule and no failure is reported.
Fix Description#
This rule cannot be auto-fixed because the only valid correction is to change the
fragment to point at a target the author actually intended. PyMarkdown cannot reliably
infer that intent from an invalid fragment alone (for example, #local-fragment
could be a typo for #heading-name, #local-fragments, or a new heading the author
plans to add). Because any auto-correction could silently redirect the reader to
the wrong section, the rule reports the failure and leaves the correction to the
author.
Configuration#
| Prefixes |
|---|
plugins.md051. |
plugins.link-fragments. |
| Value Name | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
True |
Whether the Rule Plugin is enabled. |
ignore-case |
boolean |
True |
Whether the Rule Plugin ignores case when matching local link fragments. |
ignore-pattern-regex |
string |
(empty string) |
If not empty, regular expression for link fragment text to ignore. |
Fragment Computation#
The HTML Block and Raw HTML targets are used directly, specified by the tag's
id or name attribute value.
For the ATX heading and Setext heading target information, the text contained
within the heading must be processed to determine the appropriate fragment.
While section links are not part of the CommonMark specification, this rule
enforces the historic GitHub heading algorithm
to generate those fragments by:
- Converting the text to lowercase (if
ignore-caseis set toTrue) - Removing any punctuation characters
- Converting any spaces to dashes
- Appending an incrementing integer (as needed for uniqueness)
- URI-encoding the result
Examples of this are:
- Simple text
- Example:
# Heading Name-->#heading-name
- Example:
- Simple text with punctuation characters
- Example:
# Heading & Name !-->#heading--name-
- Example:
- Unicode text (Ã =
\xC3in unicode, when encoded in utf-8, becomes\xC3\xA3)- Example:
# Heading à name-->#heading-%C3%A3-name
- Example:
- Backslash escapes
- Example:
# Heading \* Name-->#heading--name
- Example:
- Numeric character references
- Example:
# Heading A Name-->#heading-a-name
- Example:
- Entity character references
- Example:
# Heading © Name-->#heading--name
- Example:
- Emphasis
- Example:
# Heading *foo* Name-->#heading-foo-name
- Example:
- Links
- Example:
# Heading [Google](www.google.com) Name-->#heading-google-name
- Example:
- Code spans
- Example:
# Heading `foo` Name-->#heading-foo-name
- Example:
- URI Autolinks
- Example:
# Heading <http://foo.bar.baz> Name-->#heading-httpfoobarbaz-name
- Example:
- Email Autolinks
- Example:
# Heading <foo@bar.example.com> Name-->#heading-foobarexamplecom-name
- Example:
- Raw HTML
- Example:
# Heading <del>d</del> Name-->#heading-d-name
- Example:
Origination of Rule#
This rule is largely inspired by the MarkdownLint rule MD051.
Differences From MarkdownLint Rule#
The Line and Line/Column link format is supported by the MarkdownLint rule but not
by this rule. At its core, this format allows for link fragments
formatted
as #l89 to specify that the 89th line of the current document should be highlighted
and #L19C5-L21C11 to specify that from line 19, column 5 through line 21,
column 11 of the current document should be highlighted.
During testing of these formats, two observations emerged. First,
pointing to a Markdown document on GitHub is non-trivial: to view the Markdown itself,
you
need to add ?plain=1 to the end of the URL like this
just to point at the Markdown document itself. Then, to point to the correct line,
say line 40, you need to
add ?plain=1#L40 to the end of the URL, like this.
This creates ambiguity about whether you are even adding a link fragment to the
document itself, or appending
some text to the value for the plain option of the URL.
Second, GitHub's processing of these link fragments is inconsistent: After reviewing
this page,
over ten combinations were tested to
determine how to validate the link fragments. All were accepted by GitHub —
including malformed ones such as #xL40-L41 that were expected to be
rejected. As a result, it was not possible to determine which fragments
were actually valid.
Due to these observations and the ambiguity they introduced, the Line and Line/Column formats are not supported.