Skip to content

Commit cf80934

Browse files
committed
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.
1 parent 44e4627 commit cf80934

File tree

8 files changed

+251
-9
lines changed

8 files changed

+251
-9
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Slugify;
6+
7+
namespace Elastic.Markdown.Helpers;
8+
9+
public static class SlugExtensions
10+
{
11+
private static readonly SlugHelper _slugHelper = new();
12+
13+
14+
public static string Slugify(this string? text) => _slugHelper.GenerateSlug(text);
15+
16+
}

src/Elastic.Markdown/IO/MarkdownFile.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@
1212
using Markdig;
1313
using Markdig.Extensions.Yaml;
1414
using Markdig.Syntax;
15-
using Slugify;
1615

1716
namespace Elastic.Markdown.IO;
1817

1918

2019
public record MarkdownFile : DocumentationFile
2120
{
22-
private readonly SlugHelper _slugHelper = new();
2321
private string? _navigationTitle;
2422

2523
public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext context)
@@ -160,7 +158,7 @@ private void ReadDocumentInstructions(MarkdownDocument document)
160158
.Select(h => new PageTocItem
161159
{
162160
Heading = h.Item1!.Replace("`", "").Replace("*", ""),
163-
Slug = _slugHelper.GenerateSlug(h.Item2 ?? h.Item1)
161+
Slug = (h.Item2 ?? h.Item1).Slugify()
164162
})
165163
.ToList();
166164
_tableOfContent.Clear();
@@ -170,7 +168,7 @@ private void ReadDocumentInstructions(MarkdownDocument document)
170168
var labels = document.Descendants<DirectiveBlock>()
171169
.Select(b => b.CrossReferenceName)
172170
.Where(l => !string.IsNullOrWhiteSpace(l))
173-
.Select(_slugHelper.GenerateSlug)
171+
.Select(s => s.Slugify())
174172
.ToArray();
175173
foreach (var label in labels)
176174
{

src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ public override bool Close(BlockProcessor processor, Block block)
5151

5252
var newSlice = new StringSlice(header.ToString());
5353
headerBlock.Lines.Lines[0] = new StringLine(ref newSlice);
54+
55+
if (header.IndexOf('$') >= 0)
56+
anchor = HeadingAnchorParser.MatchAnchor().Replace(anchor.ToString(), "");
57+
5458
headerBlock.SetData("anchor", anchor.ToString());
5559
headerBlock.SetData("header", header.ToString());
5660
return base.Close(processor, block);
@@ -67,4 +71,7 @@ public static partial class HeadingAnchorParser
6771

6872
[GeneratedRegex(@"(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")]
6973
public static partial Regex MatchAnchor();
74+
75+
[GeneratedRegex(@"\$\$\$[^\$]+\$\$\$", RegexOptions.IgnoreCase, "en-US")]
76+
public static partial Regex InlineAnchors();
7077
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Elastic.Markdown.Helpers;
6+
using Markdig;
7+
using Markdig.Extensions.SmartyPants;
8+
using Markdig.Helpers;
9+
using Markdig.Parsers;
10+
using Markdig.Parsers.Inlines;
11+
using Markdig.Renderers;
12+
using Markdig.Renderers.Html;
13+
using Markdig.Renderers.Html.Inlines;
14+
using Markdig.Syntax.Inlines;
15+
16+
namespace Elastic.Markdown.Myst.InlineParsers;
17+
18+
public static class InlineAnchorBuilderExtensions
19+
{
20+
public static MarkdownPipelineBuilder UseInlineAnchors(this MarkdownPipelineBuilder pipeline)
21+
{
22+
pipeline.Extensions.AddIfNotAlready<InlineAnchorBuilderExtension>();
23+
return pipeline;
24+
}
25+
}
26+
27+
public class InlineAnchorBuilderExtension : IMarkdownExtension
28+
{
29+
public void Setup(MarkdownPipelineBuilder pipeline) =>
30+
pipeline.InlineParsers.InsertAfter<EmphasisInlineParser>(new InlineAnchorParser());
31+
32+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) =>
33+
renderer.ObjectRenderers.InsertAfter<EmphasisInlineRenderer>(new InlineAnchorRenderer());
34+
}
35+
36+
public class InlineAnchorParser : InlineParser
37+
{
38+
public InlineAnchorParser()
39+
{
40+
OpeningCharacters = ['$'];
41+
}
42+
43+
public override bool Match(InlineProcessor processor, ref StringSlice slice)
44+
{
45+
var startPosition = processor.GetSourcePosition(slice.Start, out var line, out var column);
46+
var c = slice.CurrentChar;
47+
48+
var span = slice.AsSpan();
49+
if (!span.StartsWith("$$$")) return false;
50+
51+
var closingStart = span[3..].IndexOf('$');
52+
if (closingStart <= 0)
53+
return false;
54+
55+
//not ending with three dollar signs
56+
if (!span[(closingStart+3)..].StartsWith("$$$"))
57+
return false;
58+
59+
processor.Inline = new InlineAnchor { Anchor = span[3..(closingStart+3)].ToString().Slugify() };
60+
61+
var sliceEnd = slice.Start + closingStart + 6;
62+
while (slice.Start != sliceEnd)
63+
slice.SkipChar();
64+
65+
return true;
66+
}
67+
68+
69+
}
70+
71+
public class InlineAnchor : LeafInline
72+
{
73+
public required string Anchor { get; init; }
74+
}
75+
76+
public class InlineAnchorRenderer : HtmlObjectRenderer<InlineAnchor>
77+
{
78+
protected override void Write(HtmlRenderer renderer, InlineAnchor obj) =>
79+
renderer.Write("<a id=\"").Write(obj.Anchor).Write("\"></a>");
80+
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class MarkdownParser(
3838
public static MarkdownPipeline Pipeline { get; } =
3939
new MarkdownPipelineBuilder()
4040
.EnableTrackTrivia()
41+
.UseInlineAnchors()
4142
.UsePreciseSourceLocation()
4243
.UseDiagnosticLinks()
4344
.UseHeadingsWithSlugs()

src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44
using Markdig.Renderers;
55
using Markdig.Renderers.Html;
66
using Markdig.Syntax;
7-
using Markdig.Syntax.Inlines;
8-
using Slugify;
7+
using Elastic.Markdown.Helpers;
8+
using Elastic.Markdown.Myst.InlineParsers;
99

1010
namespace Elastic.Markdown.Myst;
1111

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

36-
var slug = _slugHelper.GenerateSlug(anchor ?? header);
35+
var slugTarget = (anchor ?? header) ?? string.Empty;
36+
if (slugTarget.IndexOf('$') >= 0)
37+
slugTarget = HeadingAnchorParser.InlineAnchors().Replace(slugTarget, "");
38+
39+
var slug = slugTarget.Slugify();
3740

3841
renderer.Write(@"<section id=""");
3942
renderer.Write(slug);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Elastic.Markdown.Myst.InlineParsers;
6+
using FluentAssertions;
7+
using Markdig.Syntax;
8+
using Xunit.Abstractions;
9+
10+
namespace Elastic.Markdown.Tests.Inline;
11+
12+
public class InlineAnchorTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
13+
"""
14+
this is regular text and this $$$is-an-inline-anchor$$$ and this continues to be regular text
15+
"""
16+
)
17+
{
18+
[Fact]
19+
public void ParsesBlock()
20+
{
21+
Block.Should().NotBeNull();
22+
Block!.Anchor.Should().Be("is-an-inline-anchor");
23+
}
24+
25+
[Fact]
26+
public void GeneratesAttributesInHtml() =>
27+
// language=html
28+
Html.Should().Contain(
29+
"""<p>this is regular text and this <a id="is-an-inline-anchor"></a> and this continues to be regular text</p>"""
30+
);
31+
}
32+
33+
public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
34+
"""
35+
$$$is-an-inline-anchor$$$ and this continues to be regular text
36+
"""
37+
)
38+
{
39+
[Fact]
40+
public void ParsesBlock()
41+
{
42+
Block.Should().NotBeNull();
43+
Block!.Anchor.Should().Be("is-an-inline-anchor");
44+
}
45+
46+
[Fact]
47+
public void GeneratesAttributesInHtml() =>
48+
// language=html
49+
Html.Should().Be(
50+
"""<p><a id="is-an-inline-anchor"></a> and this continues to be regular text</p>"""
51+
);
52+
}
53+
54+
public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest<InlineAnchor>(output,
55+
"""
56+
this is regular text and this $$$is-an-inline-anchor$$$
57+
"""
58+
)
59+
{
60+
[Fact]
61+
public void ParsesBlock()
62+
{
63+
Block.Should().NotBeNull();
64+
Block!.Anchor.Should().Be("is-an-inline-anchor");
65+
}
66+
67+
[Fact]
68+
public void GeneratesAttributesInHtml() =>
69+
// language=html
70+
Html.Should().Contain(
71+
"""<p>this is regular text and this <a id="is-an-inline-anchor"></a></p>"""
72+
);
73+
}
74+
75+
public class BadStartInlineAnchorTests(ITestOutputHelper output) : BlockTest<ParagraphBlock>(output,
76+
"""
77+
this is regular text and this $$is-an-inline-anchor$$$
78+
"""
79+
)
80+
{
81+
[Fact]
82+
public void GeneratesAttributesInHtml() =>
83+
// language=html
84+
Html.Should().Contain(
85+
"""<p>this is regular text and this $$is-an-inline-anchor$$$</p>"""
86+
);
87+
}
88+
89+
public class BadEndInlineAnchorTests(ITestOutputHelper output) : BlockTest<ParagraphBlock>(output,
90+
"""
91+
this is regular text and this $$$is-an-inline-anchor$$
92+
"""
93+
)
94+
{
95+
[Fact]
96+
public void GeneratesAttributesInHtml() =>
97+
// language=html
98+
Html.Should().Contain(
99+
"""<p>this is regular text and this $$$is-an-inline-anchor$$</p>"""
100+
);
101+
}
102+
103+
public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest<HeadingBlock>(output,
104+
"""
105+
## Hello world $$$my-anchor$$$
106+
"""
107+
)
108+
{
109+
[Fact]
110+
public void GeneratesAttributesInHtml() =>
111+
// language=html
112+
Html.Should().Be(
113+
"""
114+
<section id="hello-world"><h2>Hello world <a id="my-anchor"></a><a class="headerlink" href="#hello-world" title="Link to this heading">¶</a>
115+
</h2>
116+
</section>
117+
""".TrimEnd()
118+
);
119+
}
120+
121+
public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest<HeadingBlock>(output,
122+
"""
123+
## Hello world [#my-anchor]
124+
"""
125+
)
126+
{
127+
[Fact]
128+
public void GeneratesAttributesInHtml() =>
129+
// language=html
130+
Html.Should().Be(
131+
"""
132+
<section id="my-anchor"><h2>Hello world <a class="headerlink" href="#my-anchor" title="Link to this heading">¶</a>
133+
</h2>
134+
</section>
135+
""".TrimEnd()
136+
);
137+
}

tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public virtual async Task InitializeAsync()
130130

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

0 commit comments

Comments
 (0)