Fix heading auto-link hijacking nested link labels (#668) (#949)

* Fix heading auto-link hijacking nested link labels (#668)

* Refactor heading reference resolution to init-only state (#949)

---------

Co-authored-by: Alexandre Mutel <alexandre_mutel@live.com>
This commit is contained in:
dualfrozandAlexandre Mutel committed 2026-09-19 11:41:48 +02:00
1 parent 40eebc0cd8
commit 9006f0b4a2
6 files changed
+114 -1

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ You can then link to it:
See [Getting Started](#getting-started).
```
With the default `AutoLink` option, `[Getting Started]` also links to the heading.
These implicit heading references do not resolve inside another still-open link or
image bracket. For example, `[Read [Getting Started]](/guide)` links the entire
label `Read [Getting Started]` to `/guide`. Explicit reference definitions such as
`[Getting Started]: /guide` retain normal CommonMark reference-link behavior.
## ID generation rules
1. Convert to lowercase
+56
View File
@@ -21,4 +21,60 @@ public class TestAutoLinks
Assert.That(html, Is.EqualTo(expected).IgnoreWhiteSpace);
}
// https://github.com/xoofx/markdig/issues/668
// A heading's implicit reference must not resolve inside another still-open
// link bracket, which would break the outer link.
[Test]
public void TestAutoIdentifierHeadingLinkDoesNotHijackNestedLinkLabel()
{
var markdown = "# Testing Markdown\n\n" +
"### Header\n\n" +
"[Testing [Header]](https://www.bing.com)\n\n" +
"[Testing [test]](https://www.google.com)\n";
var expected = "<h1 id=\"testing-markdown\">Testing Markdown</h1>\n" +
"<h3 id=\"header\">Header</h3>\n" +
"<p><a href=\"https://www.bing.com\">Testing [Header]</a></p>\n" +
"<p><a href=\"https://www.google.com\">Testing [test]</a></p>";
var pipeline = new MarkdownPipelineBuilder()
.UseAutoLinks()
.UseAutoIdentifiers()
.Build();
var html = Markdown.ToHtml(markdown, pipeline);
Assert.That(html, Is.EqualTo(expected).IgnoreWhiteSpace);
}
[TestCase("[Header]", "<a href=\"#header\">Header</a>")]
[TestCase("[Header][]", "<a href=\"#header\">Header</a>")]
[TestCase("[label][Header]", "<a href=\"#header\">label</a>")]
[TestCase("[Testing [Header]](/target)", "<a href=\"/target\">Testing [Header]</a>")]
[TestCase("[Testing [Header][]](/target)", "<a href=\"/target\">Testing [Header][]</a>")]
[TestCase("[Testing [label][Header]](/target)", "<a href=\"/target\">Testing [label][Header]</a>")]
[TestCase("![Testing [Header]](/image.png)", "<img src=\"/image.png\" alt=\"Testing [Header]\" />")]
public void TestAutoIdentifierReferenceResolution(string markdown, string expected)
{
foreach (var trackTrivia in new[] { false, true })
{
var builder = new MarkdownPipelineBuilder().UseAutoLinks().UseAutoIdentifiers();
if (trackTrivia)
{
builder.EnableTrackTrivia();
}
var html = Markdown.ToHtml("# Header\n\n" + markdown, builder.Build());
Assert.That(html, Is.EqualTo("<h1 id=\"header\">Header</h1>\n<p>" + expected + "</p>").IgnoreWhiteSpace, $"TrackTrivia: {trackTrivia}");
}
}
[Test]
public void TestExplicitReferenceStillResolvesInsideOpenLink()
{
var pipeline = new MarkdownPipelineBuilder().UseAutoLinks().UseAutoIdentifiers().Build();
var html = Markdown.ToHtml("[Header]: /explicit\n\n# Header\n\n[Testing [Header]](/target)", pipeline);
Assert.That(html, Is.EqualTo("<h1 id=\"header\">Header</h1>\n<p>[Testing <a href=\"/explicit\">Header</a>](/target)</p>").IgnoreWhiteSpace);
}
}
@@ -18,6 +18,9 @@ public class HeadingLinkReferenceDefinition : LinkReferenceDefinition
public HeadingLinkReferenceDefinition(HeadingBlock headling)
{
Heading = headling;
// Created implicitly, so it must not resolve inside another still-open
// link bracket, e.g. [Some text [Heading]](url).
AllowResolutionInsideOpenLink = false;
}
/// <summary>
@@ -150,6 +150,13 @@ public class LinkInlineParser : InlineParser
return false;
}
// An implicit definition must not hijack a bracket nested inside a
// still-open link, which would break the outer one.
if (!linkRef.AllowResolutionInsideOpenLink && HasActiveAncestorLink(parent))
{
return false;
}
Inline? link = null;
// Try to use a callback directly defined on the LinkReferenceDefinition
if (linkRef.CreateLinkInline != null)
@@ -430,6 +437,24 @@ public class LinkInlineParser : InlineParser
}
}
/// <summary>
/// Determines whether the delimiter is nested inside another link/image
/// delimiter that may still resolve.
/// </summary>
private static bool HasActiveAncestorLink(LinkDelimiterInline delimiter)
{
var inline = delimiter.Parent;
while (inline != null)
{
if (inline is LinkDelimiterInline { IsActive: true })
{
return true;
}
inline = inline.Parent;
}
return false;
}
private static void MarkParentAsInactive(Inline? inline)
{
while (inline != null)
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
#if !NET5_0_OR_GREATER
namespace System.Runtime.CompilerServices;
// Required by the compiler for init-only properties on older target frameworks.
internal static class IsExternalInit
{
}
#endif
+12 -1
View File
@@ -34,6 +34,7 @@ public class LinkReferenceDefinition : LeafBlock
public LinkReferenceDefinition() : base(null)
{
IsOpen = false;
AllowResolutionInsideOpenLink = true;
}
/// <summary>
@@ -136,6 +137,16 @@ public class LinkReferenceDefinition : LeafBlock
/// </remarks>
public CreateLinkInlineDelegate? CreateLinkInline { get; set; }
/// <summary>
/// Gets whether this definition may resolve into a link while its label is
/// nested inside another still-open link or image bracket.
/// </summary>
/// <remarks>
/// <c>true</c> for user-authored definitions, preserving CommonMark's "links
/// cannot contain links". Implicitly generated ones can initialize it to <c>false</c>.
/// </remarks>
internal bool AllowResolutionInsideOpenLink { get; private protected init; }
/// <summary>
/// Tries to the parse the specified text into a definition.
/// </summary>
@@ -241,4 +252,4 @@ public class LinkReferenceDefinition : LeafBlock
public StringSlice UnescapedTitle;
public char TitleEnclosingCharacter;
}
}
}