Skip to content

Add support for inline anchors in Markdown parsing #331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 23, 2025
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
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
Loading