Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading