Skip to content

Commit 59eb87c

Browse files
Mpdreamzreakaleek
andauthored
Add support for custom heading anchors in Markdown. (#153)
Co-authored-by: Jan Calanog <[email protected]>
1 parent 9d4bd65 commit 59eb87c

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 A 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 A Header [#but-this-is-my-anchor]
50+
```
51+
52+
Will result in an anchor link named `but-this-my-anchor` to be injected instead.
53+
54+
Do note that these inline anchors will be normalized.
55+
56+
```markdown
57+
## This Is A 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)