Skip to content

Commit

Permalink
Add support for custom heading anchors in Markdown. (#153)
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Calanog <[email protected]>
  • Loading branch information
Mpdreamz and reakaleek authored Jan 8, 2025
1 parent 9d4bd65 commit 59eb87c
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 21 deletions.
37 changes: 32 additions & 5 deletions docs/source/syntax/links.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ A link contains link text (the visible text) and a link destination (the URI tha

## Inline link

```
```markdown
[Link title](links.md)
```

[Link title](links.md)

```
```markdown
[**Hi**, _I'm md_](links.md)
```

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

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 (`-`).

```
```markdown
I link to the [Inline link](#inline-link) heading above.
```

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

```
```markdown
I link to the [Notes](tables.md#notes) heading on the [Tables](tables.md) page.
```
```

## Heading anchors

Headings will automatically create anchor links in the resulting html.

```markdown
## This Is A Header
```

Will have an anchor link injected with the name `this-is-an-header`.


If you need more control over the anchor name you may specify it inline

```markdown
## This Is A Header [#but-this-is-my-anchor]
```

Will result in an anchor link named `but-this-my-anchor` to be injected instead.

Do note that these inline anchors will be normalized.

```markdown
## This Is A Header [What about this for an anchor!]
```

Will result in the anchor `what-about-this-for-an-anchor`.
9 changes: 6 additions & 3 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,12 @@ private void ReadDocumentInstructions(MarkdownDocument document)
var contents = document
.Where(block => block is HeadingBlock { Level: >= 2 })
.Cast<HeadingBlock>()
.Select(h => h.Inline?.FirstChild?.ToString())
.Where(title => !string.IsNullOrWhiteSpace(title))
.Select(title => new PageTocItem { Heading = title!, Slug = _slugHelper.GenerateSlug(title) })
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string))
.Select(h => new PageTocItem
{
Heading = h.Item1!.Replace("`", "").Replace("*", ""),
Slug = _slugHelper.GenerateSlug(h.Item2 ?? h.Item1)
})
.ToList();
_tableOfContent.Clear();
foreach (var t in contents)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.RegularExpressions;
using Markdig;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Syntax;

namespace Elastic.Markdown.Myst.InlineParsers;

public static class HeadingBlockWithSlugBuilderExtensions
{
public static MarkdownPipelineBuilder UseHeadingsWithSlugs(this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<HeadingBlockWithSlugBuilderExtension>();
return pipeline;
}
}

public class HeadingBlockWithSlugBuilderExtension : IMarkdownExtension
{
public void Setup(MarkdownPipelineBuilder pipeline) =>
pipeline.BlockParsers.Replace<HeadingBlockParser>(new HeadingBlockWithSlugParser());

public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
}

public class HeadingBlockWithSlugParser : HeadingBlockParser
{
public override bool Close(BlockProcessor processor, Block block)
{
if (block is not HeadingBlock headerBlock)
return base.Close(processor, block);

var text = headerBlock.Lines.Lines[0].Slice.AsSpan();
headerBlock.SetData("header", text.ToString());

if (!HeadingAnchorParser.MatchAnchorLine().IsMatch(text))
return base.Close(processor, block);

var splits = HeadingAnchorParser.MatchAnchor().EnumerateMatches(text);

foreach (var match in splits)
{
var header = text.Slice(0, match.Index);
var anchor = text.Slice(match.Index, match.Length);

var newSlice = new StringSlice(header.ToString());
headerBlock.Lines.Lines[0] = new StringLine(ref newSlice);
headerBlock.SetData("anchor", anchor.ToString());
headerBlock.SetData("header", header.ToString());
return base.Close(processor, block);
}

return base.Close(processor, block);
}
}

public static partial class HeadingAnchorParser
{
[GeneratedRegex(@"^.*(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")]
public static partial Regex MatchAnchorLine();

[GeneratedRegex(@"(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")]
public static partial Regex MatchAnchor();
}
2 changes: 2 additions & 0 deletions src/Elastic.Markdown/Myst/MarkdownParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class MarkdownParser(
public static MarkdownPipeline MinimalPipeline { get; } =
new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.UseHeadingsWithSlugs()
.UseDirectives()
.Build();

Expand All @@ -38,6 +39,7 @@ public class MarkdownParser(
.EnableTrackTrivia()
.UsePreciseSourceLocation()
.UseDiagnosticLinks()
.UseHeadingsWithSlugs()
.UseEmphasisExtras(EmphasisExtraOptions.Default)
.UseSoftlineBreakAsHardlineBreak()
.UseSubstitution()
Expand Down
24 changes: 11 additions & 13 deletions src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Slugify;

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

var slug = string.Empty;
if (headingText == "h2")
{
renderer.Write(@"<section id=""");
slug = _slugHelper.GenerateSlug(obj.Inline?.FirstChild?.ToString());
renderer.Write(slug);
renderer.Write(@""">");
var header = obj.GetData("header") as string;
var anchor = obj.GetData("anchor") as string;

}
var slug = _slugHelper.GenerateSlug(anchor ?? header);

renderer.Write(@"<section id=""");
renderer.Write(slug);
renderer.Write(@""">");

renderer.Write('<');
renderer.Write(headingText);
Expand All @@ -47,16 +47,14 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj)
renderer.WriteLeafInline(obj);


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

renderer.Write("</");
renderer.Write(headingText);
renderer.WriteLine('>');

if (headingText == "h2")
renderer.Write("</section>");
renderer.Write("</section>");

renderer.EnsureLine();
}
Expand Down
22 changes: 22 additions & 0 deletions tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ protected override void AddToFileSystem(MockFileSystem fileSystem)
## Sub Requirements
To follow this tutorial you will need to install the following components:
## New Requirements [#new-reqs]
These are new requirements
""";
fileSystem.AddFile(@"docs/source/testing/req.md", inclusion);
fileSystem.AddFile(@"docs/source/_static/img/observability.png", new MockFileData(""));
Expand Down Expand Up @@ -74,6 +78,24 @@ public void GeneratesHtml() =>
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
}


public class ExternalPageCustomAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output,
"""
[Sub Requirements](testing/req.md#new-reqs)
"""
)
{
[Fact]
public void GeneratesHtml() =>
// language=html
Html.Should().Contain(
"""<p><a href="testing/req.html#new-reqs">Sub Requirements</a></p>"""
);

[Fact]
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
}

public class ExternalPageAnchorAutoTitleTests(ITestOutputHelper output) : AnchorLinkTestBase(output,
"""
[](testing/req.md#sub-requirements)
Expand Down

0 comments on commit 59eb87c

Please sign in to comment.