Skip to content

Commit 2c0e131

Browse files
committed
Add support for custom heading anchors in Markdown.
Custom heading anchors allow more control over anchor names in generated HTML. Inline annotations can now define specific anchors, and normalization ensures consistent formatting. Updated relevant tests, documentation, and rendering logic to support this feature.
1 parent 9d4bd65 commit 2c0e131

File tree

6 files changed

+143
-21
lines changed

6 files changed

+143
-21
lines changed

docs/source/syntax/links.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ A link contains link text (the visible text) and a link destination (the URI tha
66

77
## Inline link
88

9-
```
9+
```markdown
1010
[Link title](links.md)
1111
```
1212

1313
[Link title](links.md)
1414

15-
```
15+
```markdown
1616
[**Hi**, _I'm md_](links.md)
1717
```
1818

@@ -22,12 +22,39 @@ A link contains link text (the visible text) and a link destination (the URI tha
2222

2323
You can link to a heading on a page with an anchor link. The link destination should be a `#` followed by the header text. Convert spaces to dashes (`-`).
2424

25-
```
25+
```markdown
2626
I link to the [Inline link](#inline-link) heading above.
2727
```
2828

2929
I link to the [Inline link](#inline-link) heading above.
3030

31-
```
31+
```markdown
3232
I link to the [Notes](tables.md#notes) heading on the [Tables](tables.md) page.
33-
```
33+
```
34+
35+
## Heading anchors
36+
37+
Headings will automatically create anchor links in the resulting html.
38+
39+
```markdown
40+
## This Is An Header
41+
```
42+
43+
Will have an anchor link injected with the name `this-is-an-header`.
44+
45+
46+
If you need more control over the anchor name you may specify it inline
47+
48+
```markdown
49+
## This Is An Header [but-this-is-my-anchor]
50+
```
51+
52+
Will result in an anchor link named `but-this-my-anchor` to be injected instead. Do note that these inline anchors will be normalized.
53+
54+
Meaning
55+
56+
```markdown
57+
## This Is An Header [What about this for an anchor!]
58+
```
59+
60+
Will result in the anchor `what-about-this-for-an-anchor`.

src/Elastic.Markdown/IO/MarkdownFile.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,12 @@ private void ReadDocumentInstructions(MarkdownDocument document)
9090
var contents = document
9191
.Where(block => block is HeadingBlock { Level: >= 2 })
9292
.Cast<HeadingBlock>()
93-
.Select(h => h.Inline?.FirstChild?.ToString())
94-
.Where(title => !string.IsNullOrWhiteSpace(title))
95-
.Select(title => new PageTocItem { Heading = title!, Slug = _slugHelper.GenerateSlug(title) })
93+
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string))
94+
.Select(h => new PageTocItem
95+
{
96+
Heading = h.Item1!.Replace("`", "").Replace("*", ""),
97+
Slug = _slugHelper.GenerateSlug(h.Item2 ?? h.Item1)
98+
})
9699
.ToList();
97100
_tableOfContent.Clear();
98101
foreach (var t in contents)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Text.RegularExpressions;
6+
using Markdig;
7+
using Markdig.Helpers;
8+
using Markdig.Parsers;
9+
using Markdig.Parsers.Inlines;
10+
using Markdig.Renderers;
11+
using Markdig.Syntax;
12+
13+
namespace Elastic.Markdown.Myst.InlineParsers;
14+
15+
public static class HeadingBlockWithSlugBuilderExtensions
16+
{
17+
public static MarkdownPipelineBuilder UseHeadingsWithSlugs(this MarkdownPipelineBuilder pipeline)
18+
{
19+
pipeline.Extensions.AddIfNotAlready<HeadingBlockWithSlugBuilderExtension>();
20+
return pipeline;
21+
}
22+
}
23+
24+
public class HeadingBlockWithSlugBuilderExtension : IMarkdownExtension
25+
{
26+
public void Setup(MarkdownPipelineBuilder pipeline) =>
27+
pipeline.BlockParsers.Replace<HeadingBlockParser>(new HeadingBlockWithSlugParser());
28+
29+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
30+
}
31+
32+
public class HeadingBlockWithSlugParser : HeadingBlockParser
33+
{
34+
public override bool Close(BlockProcessor processor, Block block)
35+
{
36+
if (block is not HeadingBlock headerBlock)
37+
return base.Close(processor, block);
38+
39+
var text = headerBlock.Lines.Lines[0].Slice.AsSpan();
40+
headerBlock.SetData("header", text.ToString());
41+
42+
if (!HeadingAnchorParser.MatchAnchorLine().IsMatch(text))
43+
return base.Close(processor, block);
44+
45+
var splits = HeadingAnchorParser.MatchAnchor().EnumerateMatches(text);
46+
47+
foreach (var match in splits)
48+
{
49+
var header = text.Slice(0, match.Index);
50+
var anchor = text.Slice(match.Index, match.Length);
51+
52+
var newSlice = new StringSlice(header.ToString());
53+
headerBlock.Lines.Lines[0] = new StringLine(ref newSlice);
54+
headerBlock.SetData("anchor", anchor.ToString());
55+
headerBlock.SetData("header", header.ToString());
56+
return base.Close(processor, block);
57+
}
58+
59+
return base.Close(processor, block);
60+
}
61+
}
62+
63+
public static partial class HeadingAnchorParser
64+
{
65+
[GeneratedRegex(@"^.*(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")]
66+
public static partial Regex MatchAnchorLine();
67+
68+
[GeneratedRegex(@"(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")]
69+
public static partial Regex MatchAnchor();
70+
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class MarkdownParser(
3030
public static MarkdownPipeline MinimalPipeline { get; } =
3131
new MarkdownPipelineBuilder()
3232
.UseYamlFrontMatter()
33+
.UseHeadingsWithSlugs()
3334
.UseDirectives()
3435
.Build();
3536

@@ -38,6 +39,7 @@ public class MarkdownParser(
3839
.EnableTrackTrivia()
3940
.UsePreciseSourceLocation()
4041
.UseDiagnosticLinks()
42+
.UseHeadingsWithSlugs()
4143
.UseEmphasisExtras(EmphasisExtraOptions.Default)
4244
.UseSoftlineBreakAsHardlineBreak()
4345
.UseSubstitution()

src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Markdig.Renderers;
55
using Markdig.Renderers.Html;
66
using Markdig.Syntax;
7+
using Markdig.Syntax.Inlines;
78
using Slugify;
89

910
namespace Elastic.Markdown.Myst;
@@ -29,15 +30,14 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj)
2930
? headings[index]
3031
: $"h{obj.Level}";
3132

32-
var slug = string.Empty;
33-
if (headingText == "h2")
34-
{
35-
renderer.Write(@"<section id=""");
36-
slug = _slugHelper.GenerateSlug(obj.Inline?.FirstChild?.ToString());
37-
renderer.Write(slug);
38-
renderer.Write(@""">");
33+
var header = obj.GetData("header") as string;
34+
var anchor = obj.GetData("anchor") as string;
3935

40-
}
36+
var slug = _slugHelper.GenerateSlug(anchor ?? header);
37+
38+
renderer.Write(@"<section id=""");
39+
renderer.Write(slug);
40+
renderer.Write(@""">");
4141

4242
renderer.Write('<');
4343
renderer.Write(headingText);
@@ -47,16 +47,14 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj)
4747
renderer.WriteLeafInline(obj);
4848

4949

50-
if (headingText == "h2")
51-
// language=html
52-
renderer.WriteLine($@"<a class=""headerlink"" href=""#{slug}"" title=""Link to this heading"">¶</a>");
50+
// language=html
51+
renderer.WriteLine($@"<a class=""headerlink"" href=""#{slug}"" title=""Link to this heading"">¶</a>");
5352

5453
renderer.Write("</");
5554
renderer.Write(headingText);
5655
renderer.WriteLine('>');
5756

58-
if (headingText == "h2")
59-
renderer.Write("</section>");
57+
renderer.Write("</section>");
6058

6159
renderer.EnsureLine();
6260
}

tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ protected override void AddToFileSystem(MockFileSystem fileSystem)
3333
## Sub Requirements
3434
3535
To follow this tutorial you will need to install the following components:
36+
37+
## New Requirements [new-reqs]
38+
39+
These are new requirements
3640
""";
3741
fileSystem.AddFile(@"docs/source/testing/req.md", inclusion);
3842
fileSystem.AddFile(@"docs/source/_static/img/observability.png", new MockFileData(""));
@@ -74,6 +78,24 @@ public void GeneratesHtml() =>
7478
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
7579
}
7680

81+
82+
public class ExternalPageCustomAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output,
83+
"""
84+
[Sub Requirements](testing/req.md#new-reqs)
85+
"""
86+
)
87+
{
88+
[Fact]
89+
public void GeneratesHtml() =>
90+
// language=html
91+
Html.Should().Contain(
92+
"""<p><a href="testing/req.html#new-reqs">Sub Requirements</a></p>"""
93+
);
94+
95+
[Fact]
96+
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
97+
}
98+
7799
public class ExternalPageAnchorAutoTitleTests(ITestOutputHelper output) : AnchorLinkTestBase(output,
78100
"""
79101
[](testing/req.md#sub-requirements)

0 commit comments

Comments
 (0)