Skip to content

Commit

Permalink
Add support for inline anchors in Markdown parsing
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Mpdreamz committed Jan 23, 2025
1 parent 44e4627 commit cf80934
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 9 deletions.
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);

}
6 changes: 2 additions & 4 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@
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 @@ -160,7 +158,7 @@ private void ReadDocumentInstructions(MarkdownDocument document)
.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,7 +168,7 @@ 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())
.ToArray();
foreach (var label in labels)
{
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();
}
80 changes: 80 additions & 0 deletions src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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>");
}
1 change: 1 addition & 0 deletions src/Elastic.Markdown/Myst/MarkdownParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class MarkdownParser(
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
Expand Up @@ -4,14 +4,13 @@
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Slugify;
using Elastic.Markdown.Helpers;
using Elastic.Markdown.Myst.InlineParsers;

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
137 changes: 137 additions & 0 deletions tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// 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.Myst.InlineParsers;
using FluentAssertions;
using Markdig.Syntax;
using Xunit.Abstractions;

namespace Elastic.Markdown.Tests.Inline;

public class InlineAnchorTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
"""
this is regular text and this $$$is-an-inline-anchor$$$ and this continues to be regular text
"""
)
{
[Fact]
public void ParsesBlock()
{
Block.Should().NotBeNull();
Block!.Anchor.Should().Be("is-an-inline-anchor");
}

[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Contain(
"""<p>this is regular text and this <a id="is-an-inline-anchor"></a> and this continues to be regular text</p>"""
);
}

public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
"""
$$$is-an-inline-anchor$$$ and this continues to be regular text
"""
)
{
[Fact]
public void ParsesBlock()
{
Block.Should().NotBeNull();
Block!.Anchor.Should().Be("is-an-inline-anchor");
}

[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Be(
"""<p><a id="is-an-inline-anchor"></a> and this continues to be regular text</p>"""
);
}

public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
"""
this is regular text and this $$$is-an-inline-anchor$$$
"""
)
{
[Fact]
public void ParsesBlock()
{
Block.Should().NotBeNull();
Block!.Anchor.Should().Be("is-an-inline-anchor");
}

[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Contain(
"""<p>this is regular text and this <a id="is-an-inline-anchor"></a></p>"""
);
}

public class BadStartInlineAnchorTests(ITestOutputHelper output) : BlockTest<ParagraphBlock>(output,
"""
this is regular text and this $$is-an-inline-anchor$$$
"""
)
{
[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Contain(
"""<p>this is regular text and this $$is-an-inline-anchor$$$</p>"""
);
}

public class BadEndInlineAnchorTests(ITestOutputHelper output) : BlockTest<ParagraphBlock>(output,
"""
this is regular text and this $$$is-an-inline-anchor$$
"""
)
{
[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Contain(
"""<p>this is regular text and this $$$is-an-inline-anchor$$</p>"""
);
}

public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest<HeadingBlock>(output,
"""
## Hello world $$$my-anchor$$$
"""
)
{
[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Be(
"""
<section id="hello-world"><h2>Hello world <a id="my-anchor"></a><a class="headerlink" href="#hello-world" title="Link to this heading">¶</a>
</h2>
</section>
""".TrimEnd()
);
}

public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest<HeadingBlock>(output,
"""
## Hello world [#my-anchor]
"""
)
{
[Fact]
public void GeneratesAttributesInHtml() =>
// language=html
Html.Should().Be(
"""
<section id="my-anchor"><h2>Hello world <a class="headerlink" href="#my-anchor" title="Link to this heading">¶</a>
</h2>
</section>
""".TrimEnd()
);
}
2 changes: 1 addition & 1 deletion tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public virtual async Task InitializeAsync()

Document = await File.ParseFullAsync(default);
var html = File.CreateHtml(Document).AsSpan();
var find = "</section>";
var find = "</h1>\n</section>";
var start = html.IndexOf(find, StringComparison.Ordinal);
Html = start >= 0 && !TestingFullDocument
? html[(start + find.Length)..].ToString().Trim(Environment.NewLine.ToCharArray())
Expand Down

0 comments on commit cf80934

Please sign in to comment.