Skip to content

Commit

Permalink
Add support for inline anchors in Markdown parsing (#331)
Browse files Browse the repository at this point in the history
* Add support for inline anchors in Markdown parsing

Introduced a parser for inline anchors using `$$$` syntax and updated related components to handle and render them as HTML anchor tags. Enhanced heading slug generation to exclude inline anchors, and added comprehensive tests to ensure correct behavior.

* add documentation for inline anchors

* dotnet format

* add inline anchors to additional labels on markdown file so they can be resolved

* added tests linking to inline anchors

* dotnet format
  • Loading branch information
Mpdreamz authored Jan 23, 2025
1 parent 1269895 commit 62f580a
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 12 deletions.
13 changes: 12 additions & 1 deletion docs/syntax/links.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,15 @@ Do note that these inline anchors will be normalized.
## This Is A Header [What about this for an anchor!]
```

Will result in the anchor `what-about-this-for-an-anchor`.
Will result in the anchor `what-about-this-for-an-anchor`.


## Inline anchors

Docsbuilder temporary supports the abbility to create a linkable anchor anywhere on any document.

```markdown
This is text and $$$this-is-an-inline-anchor$$$
```

This feature exists to aid with migration however is scheduled for removal and new content should **NOT** utilize this feature.
16 changes: 16 additions & 0 deletions src/Elastic.Markdown/Helpers/SlugExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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 Slugify;

namespace Elastic.Markdown.Helpers;

public static class SlugExtensions
{
private static readonly SlugHelper _slugHelper = new();


public static string Slugify(this string? text) => _slugHelper.GenerateSlug(text);

}
11 changes: 5 additions & 6 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,17 @@
using Elastic.Markdown.Myst;
using Elastic.Markdown.Myst.Directives;
using Elastic.Markdown.Myst.FrontMatter;
using Elastic.Markdown.Myst.InlineParsers;
using Elastic.Markdown.Slices;
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using Slugify;

namespace Elastic.Markdown.IO;


public record MarkdownFile : DocumentationFile
{
private readonly SlugHelper _slugHelper = new();
private string? _navigationTitle;

public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext context)
Expand Down Expand Up @@ -151,16 +150,14 @@ private void ReadDocumentInstructions(MarkdownDocument document)
Collector.EmitWarning(FilePath, "Document has no title, using file name as title.");
}



var contents = document
.Where(block => block is HeadingBlock { Level: >= 2 })
.Cast<HeadingBlock>()
.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)
Slug = (h.Item2 ?? h.Item1).Slugify()
})
.ToList();
_tableOfContent.Clear();
Expand All @@ -170,8 +167,10 @@ private void ReadDocumentInstructions(MarkdownDocument document)
var labels = document.Descendants<DirectiveBlock>()
.Select(b => b.CrossReferenceName)
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(_slugHelper.GenerateSlug)
.Select(s => s.Slugify())
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
.ToArray();

foreach (var label in labels)
{
if (!string.IsNullOrEmpty(label))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public override bool Close(BlockProcessor processor, Block block)

var newSlice = new StringSlice(header.ToString());
headerBlock.Lines.Lines[0] = new StringLine(ref newSlice);

if (header.IndexOf('$') >= 0)
anchor = HeadingAnchorParser.MatchAnchor().Replace(anchor.ToString(), "");

headerBlock.SetData("anchor", anchor.ToString());
headerBlock.SetData("header", header.ToString());
return base.Close(processor, block);
Expand All @@ -67,4 +71,7 @@ public static partial class HeadingAnchorParser

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

[GeneratedRegex(@"\$\$\$[^\$]+\$\$\$", RegexOptions.IgnoreCase, "en-US")]
public static partial Regex InlineAnchors();
}
81 changes: 81 additions & 0 deletions src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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 Elastic.Markdown.Helpers;
using Markdig;
using Markdig.Extensions.SmartyPants;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Renderers.Html.Inlines;
using Markdig.Syntax.Inlines;

namespace Elastic.Markdown.Myst.InlineParsers;

public static class InlineAnchorBuilderExtensions
{
public static MarkdownPipelineBuilder UseInlineAnchors(this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<InlineAnchorBuilderExtension>();
return pipeline;
}
}

public class InlineAnchorBuilderExtension : IMarkdownExtension
{
public void Setup(MarkdownPipelineBuilder pipeline) =>
pipeline.InlineParsers.InsertAfter<EmphasisInlineParser>(new InlineAnchorParser());

public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) =>
renderer.ObjectRenderers.InsertAfter<EmphasisInlineRenderer>(new InlineAnchorRenderer());
}

public class InlineAnchorParser : InlineParser
{
public InlineAnchorParser()
{
OpeningCharacters = ['$'];
}

public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
var startPosition = processor.GetSourcePosition(slice.Start, out var line, out var column);
var c = slice.CurrentChar;

var span = slice.AsSpan();
if (!span.StartsWith("$$$"))
return false;

var closingStart = span[3..].IndexOf('$');
if (closingStart <= 0)
return false;

//not ending with three dollar signs
if (!span[(closingStart + 3)..].StartsWith("$$$"))
return false;

processor.Inline = new InlineAnchor { Anchor = span[3..(closingStart + 3)].ToString().Slugify() };

var sliceEnd = slice.Start + closingStart + 6;
while (slice.Start != sliceEnd)
slice.SkipChar();

return true;
}


}

public class InlineAnchor : LeafInline
{
public required string Anchor { get; init; }
}

public class InlineAnchorRenderer : HtmlObjectRenderer<InlineAnchor>
{
protected override void Write(HtmlRenderer renderer, InlineAnchor obj) =>
renderer.Write("<a id=\"").Write(obj.Anchor).Write("\"></a>");
}
2 changes: 2 additions & 0 deletions src/Elastic.Markdown/Myst/MarkdownParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ public class MarkdownParser(
public static MarkdownPipeline MinimalPipeline { get; } =
new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.UseInlineAnchors()
.UseHeadingsWithSlugs()
.UseDirectives()
.Build();

public static MarkdownPipeline Pipeline { get; } =
new MarkdownPipelineBuilder()
.EnableTrackTrivia()
.UseInlineAnchors()
.UsePreciseSourceLocation()
.UseDiagnosticLinks()
.UseHeadingsWithSlugs()
Expand Down
11 changes: 7 additions & 4 deletions src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
// 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 Elastic.Markdown.Helpers;
using Elastic.Markdown.Myst.InlineParsers;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Slugify;

namespace Elastic.Markdown.Myst;

public class SectionedHeadingRenderer : HtmlObjectRenderer<HeadingBlock>
{
private readonly SlugHelper _slugHelper = new();
private static readonly string[] HeadingTexts =
[
"h1",
Expand All @@ -33,7 +32,11 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj)
var header = obj.GetData("header") as string;
var anchor = obj.GetData("anchor") as string;

var slug = _slugHelper.GenerateSlug(anchor ?? header);
var slugTarget = (anchor ?? header) ?? string.Empty;
if (slugTarget.IndexOf('$') >= 0)
slugTarget = HeadingAnchorParser.InlineAnchors().Replace(slugTarget, "");

var slug = slugTarget.Slugify();

renderer.Write(@"<section id=""");
renderer.Write(slug);
Expand Down
Loading

0 comments on commit 62f580a

Please sign in to comment.