Skip to content

Commit 62f580a

Browse files
authored
Add support for inline anchors in Markdown parsing (#331)
* 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
1 parent 1269895 commit 62f580a

File tree

9 files changed

+342
-12
lines changed

9 files changed

+342
-12
lines changed

docs/syntax/links.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,15 @@ Do note that these inline anchors will be normalized.
9191
## This Is A Header [What about this for an anchor!]
9292
```
9393

94-
Will result in the anchor `what-about-this-for-an-anchor`.
94+
Will result in the anchor `what-about-this-for-an-anchor`.
95+
96+
97+
## Inline anchors
98+
99+
Docsbuilder temporary supports the abbility to create a linkable anchor anywhere on any document.
100+
101+
```markdown
102+
This is text and $$$this-is-an-inline-anchor$$$
103+
```
104+
105+
This feature exists to aid with migration however is scheduled for removal and new content should **NOT** utilize this feature.
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: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@
88
using Elastic.Markdown.Myst;
99
using Elastic.Markdown.Myst.Directives;
1010
using Elastic.Markdown.Myst.FrontMatter;
11+
using Elastic.Markdown.Myst.InlineParsers;
1112
using Elastic.Markdown.Slices;
1213
using Markdig;
1314
using Markdig.Extensions.Yaml;
1415
using Markdig.Syntax;
15-
using Slugify;
1616

1717
namespace Elastic.Markdown.IO;
1818

1919

2020
public record MarkdownFile : DocumentationFile
2121
{
22-
private readonly SlugHelper _slugHelper = new();
2322
private string? _navigationTitle;
2423

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

154-
155-
156153
var contents = document
157154
.Where(block => block is HeadingBlock { Level: >= 2 })
158155
.Cast<HeadingBlock>()
159156
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string))
160157
.Select(h => new PageTocItem
161158
{
162159
Heading = h.Item1!.Replace("`", "").Replace("*", ""),
163-
Slug = _slugHelper.GenerateSlug(h.Item2 ?? h.Item1)
160+
Slug = (h.Item2 ?? h.Item1).Slugify()
164161
})
165162
.ToList();
166163
_tableOfContent.Clear();
@@ -170,8 +167,10 @@ private void ReadDocumentInstructions(MarkdownDocument document)
170167
var labels = document.Descendants<DirectiveBlock>()
171168
.Select(b => b.CrossReferenceName)
172169
.Where(l => !string.IsNullOrWhiteSpace(l))
173-
.Select(_slugHelper.GenerateSlug)
170+
.Select(s => s.Slugify())
171+
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
174172
.ToArray();
173+
175174
foreach (var label in labels)
176175
{
177176
if (!string.IsNullOrEmpty(label))

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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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("$$$"))
50+
return false;
51+
52+
var closingStart = span[3..].IndexOf('$');
53+
if (closingStart <= 0)
54+
return false;
55+
56+
//not ending with three dollar signs
57+
if (!span[(closingStart + 3)..].StartsWith("$$$"))
58+
return false;
59+
60+
processor.Inline = new InlineAnchor { Anchor = span[3..(closingStart + 3)].ToString().Slugify() };
61+
62+
var sliceEnd = slice.Start + closingStart + 6;
63+
while (slice.Start != sliceEnd)
64+
slice.SkipChar();
65+
66+
return true;
67+
}
68+
69+
70+
}
71+
72+
public class InlineAnchor : LeafInline
73+
{
74+
public required string Anchor { get; init; }
75+
}
76+
77+
public class InlineAnchorRenderer : HtmlObjectRenderer<InlineAnchor>
78+
{
79+
protected override void Write(HtmlRenderer renderer, InlineAnchor obj) =>
80+
renderer.Write("<a id=\"").Write(obj.Anchor).Write("\"></a>");
81+
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ public class MarkdownParser(
3131
public static MarkdownPipeline MinimalPipeline { get; } =
3232
new MarkdownPipelineBuilder()
3333
.UseYamlFrontMatter()
34+
.UseInlineAnchors()
3435
.UseHeadingsWithSlugs()
3536
.UseDirectives()
3637
.Build();
3738

3839
public static MarkdownPipeline Pipeline { get; } =
3940
new MarkdownPipelineBuilder()
4041
.EnableTrackTrivia()
42+
.UseInlineAnchors()
4143
.UsePreciseSourceLocation()
4244
.UseDiagnosticLinks()
4345
.UseHeadingsWithSlugs()

src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
// Licensed to Elasticsearch B.V under one or more agreements.
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
4+
using Elastic.Markdown.Helpers;
5+
using Elastic.Markdown.Myst.InlineParsers;
46
using Markdig.Renderers;
57
using Markdig.Renderers.Html;
68
using Markdig.Syntax;
7-
using Markdig.Syntax.Inlines;
8-
using Slugify;
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);

0 commit comments

Comments
 (0)