From 2c0e131903ce0bf885b5295cc1b49be7172a2c5b Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 8 Jan 2025 13:32:15 +0100 Subject: [PATCH 1/3] Add support for custom heading anchors in Markdown. Custom heading anchors allow more control over anchor names in generated HTML. Inline annotations can now define specific anchors, and normalization ensures consistent formatting. Updated relevant tests, documentation, and rendering logic to support this feature. --- docs/source/syntax/links.md | 37 ++++++++-- src/Elastic.Markdown/IO/MarkdownFile.cs | 9 ++- .../HeadingBlockWithSlugParser.cs | 70 +++++++++++++++++++ src/Elastic.Markdown/Myst/MarkdownParser.cs | 2 + .../Myst/SectionedHeadingRenderer.cs | 24 +++---- .../Inline/AnchorLinkTests.cs | 22 ++++++ 6 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs diff --git a/docs/source/syntax/links.md b/docs/source/syntax/links.md index 804a25d93..6b81aaf5c 100644 --- a/docs/source/syntax/links.md +++ b/docs/source/syntax/links.md @@ -6,13 +6,13 @@ A link contains link text (the visible text) and a link destination (the URI tha ## Inline link -``` +```markdown [Link title](links.md) ``` [Link title](links.md) -``` +```markdown [**Hi**, _I'm md_](links.md) ``` @@ -22,12 +22,39 @@ A link contains link text (the visible text) and a link destination (the URI tha You can link to a heading on a page with an anchor link. The link destination should be a `#` followed by the header text. Convert spaces to dashes (`-`). -``` +```markdown I link to the [Inline link](#inline-link) heading above. ``` I link to the [Inline link](#inline-link) heading above. -``` +```markdown I link to the [Notes](tables.md#notes) heading on the [Tables](tables.md) page. -``` \ No newline at end of file +``` + +## Heading anchors + +Headings will automatically create anchor links in the resulting html. + +```markdown +## This Is An Header +``` + +Will have an anchor link injected with the name `this-is-an-header`. + + +If you need more control over the anchor name you may specify it inline + +```markdown +## This Is An Header [but-this-is-my-anchor] +``` + +Will result in an anchor link named `but-this-my-anchor` to be injected instead. Do note that these inline anchors will be normalized. + +Meaning + +```markdown +## This Is An Header [What about this for an anchor!] +``` + +Will result in the anchor `what-about-this-for-an-anchor`. \ No newline at end of file diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 3166b3bf6..0a2392d81 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -90,9 +90,12 @@ private void ReadDocumentInstructions(MarkdownDocument document) var contents = document .Where(block => block is HeadingBlock { Level: >= 2 }) .Cast() - .Select(h => h.Inline?.FirstChild?.ToString()) - .Where(title => !string.IsNullOrWhiteSpace(title)) - .Select(title => new PageTocItem { Heading = title!, Slug = _slugHelper.GenerateSlug(title) }) + .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) + }) .ToList(); _tableOfContent.Clear(); foreach (var t in contents) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs new file mode 100644 index 000000000..45a696dbe --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs @@ -0,0 +1,70 @@ +// 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 System.Text.RegularExpressions; +using Markdig; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.InlineParsers; + +public static class HeadingBlockWithSlugBuilderExtensions +{ + public static MarkdownPipelineBuilder UseHeadingsWithSlugs(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +public class HeadingBlockWithSlugBuilderExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.BlockParsers.Replace(new HeadingBlockWithSlugParser()); + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { } +} + +public class HeadingBlockWithSlugParser : HeadingBlockParser +{ + public override bool Close(BlockProcessor processor, Block block) + { + if (block is not HeadingBlock headerBlock) + return base.Close(processor, block); + + var text = headerBlock.Lines.Lines[0].Slice.AsSpan(); + headerBlock.SetData("header", text.ToString()); + + if (!HeadingAnchorParser.MatchAnchorLine().IsMatch(text)) + return base.Close(processor, block); + + var splits = HeadingAnchorParser.MatchAnchor().EnumerateMatches(text); + + foreach (var match in splits) + { + var header = text.Slice(0, match.Index); + var anchor = text.Slice(match.Index, match.Length); + + var newSlice = new StringSlice(header.ToString()); + headerBlock.Lines.Lines[0] = new StringLine(ref newSlice); + headerBlock.SetData("anchor", anchor.ToString()); + headerBlock.SetData("header", header.ToString()); + return base.Close(processor, block); + } + + return base.Close(processor, block); + } +} + +public static partial class HeadingAnchorParser +{ + [GeneratedRegex(@"^.*(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MatchAnchorLine(); + + [GeneratedRegex(@"(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MatchAnchor(); +} diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index a24432ed8..d45b1cc46 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -30,6 +30,7 @@ public class MarkdownParser( public static MarkdownPipeline MinimalPipeline { get; } = new MarkdownPipelineBuilder() .UseYamlFrontMatter() + .UseHeadingsWithSlugs() .UseDirectives() .Build(); @@ -38,6 +39,7 @@ public class MarkdownParser( .EnableTrackTrivia() .UsePreciseSourceLocation() .UseDiagnosticLinks() + .UseHeadingsWithSlugs() .UseEmphasisExtras(EmphasisExtraOptions.Default) .UseSoftlineBreakAsHardlineBreak() .UseSubstitution() diff --git a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs index 2e603596e..9567281e4 100644 --- a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs +++ b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs @@ -4,6 +4,7 @@ using Markdig.Renderers; using Markdig.Renderers.Html; using Markdig.Syntax; +using Markdig.Syntax.Inlines; using Slugify; namespace Elastic.Markdown.Myst; @@ -29,15 +30,14 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj) ? headings[index] : $"h{obj.Level}"; - var slug = string.Empty; - if (headingText == "h2") - { - renderer.Write(@"
"); + var header = obj.GetData("header") as string; + var anchor = obj.GetData("anchor") as string; - } + var slug = _slugHelper.GenerateSlug(anchor ?? header); + + renderer.Write(@"
"); renderer.Write('<'); renderer.Write(headingText); @@ -47,16 +47,14 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj) renderer.WriteLeafInline(obj); - if (headingText == "h2") - // language=html - renderer.WriteLine($@""); + // language=html + renderer.WriteLine($@""); renderer.Write("'); - if (headingText == "h2") - renderer.Write("
"); + renderer.Write("
"); renderer.EnsureLine(); } diff --git a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs index 5f84e8574..e1d39db25 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs @@ -33,6 +33,10 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) ## Sub Requirements To follow this tutorial you will need to install the following components: + +## New Requirements [new-reqs] + +These are new requirements """; fileSystem.AddFile(@"docs/source/testing/req.md", inclusion); fileSystem.AddFile(@"docs/source/_static/img/observability.png", new MockFileData("")); @@ -74,6 +78,24 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } + +public class ExternalPageCustomAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, +""" +[Sub Requirements](testing/req.md#new-reqs) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Sub Requirements

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + public class ExternalPageAnchorAutoTitleTests(ITestOutputHelper output) : AnchorLinkTestBase(output, """ [](testing/req.md#sub-requirements) From 48c527b6a5d4f78bd36508b1a15103a9b06cd5d1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 8 Jan 2025 15:05:01 +0100 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Jan Calanog --- docs/source/syntax/links.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/syntax/links.md b/docs/source/syntax/links.md index 6b81aaf5c..6c4ef4506 100644 --- a/docs/source/syntax/links.md +++ b/docs/source/syntax/links.md @@ -37,7 +37,7 @@ I link to the [Notes](tables.md#notes) heading on the [Tables](tables.md) page. Headings will automatically create anchor links in the resulting html. ```markdown -## This Is An Header +## This Is A Header ``` Will have an anchor link injected with the name `this-is-an-header`. @@ -46,7 +46,7 @@ Will have an anchor link injected with the name `this-is-an-header`. If you need more control over the anchor name you may specify it inline ```markdown -## This Is An Header [but-this-is-my-anchor] +## This Is A Header [but-this-is-my-anchor] ``` Will result in an anchor link named `but-this-my-anchor` to be injected instead. Do note that these inline anchors will be normalized. @@ -54,7 +54,7 @@ Will result in an anchor link named `but-this-my-anchor` to be injected instead. Meaning ```markdown -## This Is An Header [What about this for an anchor!] +## This Is A Header [What about this for an anchor!] ``` Will result in the anchor `what-about-this-for-an-anchor`. \ No newline at end of file From d730cf5a6a8a5bddd29ee61ade2c0c4380f3b222 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 8 Jan 2025 15:35:24 +0100 Subject: [PATCH 3/3] Document [#anchor] as the suggested syntax for header anchors --- docs/source/syntax/links.md | 6 +++--- tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/syntax/links.md b/docs/source/syntax/links.md index 6c4ef4506..527a03017 100644 --- a/docs/source/syntax/links.md +++ b/docs/source/syntax/links.md @@ -46,12 +46,12 @@ Will have an anchor link injected with the name `this-is-an-header`. If you need more control over the anchor name you may specify it inline ```markdown -## This Is A Header [but-this-is-my-anchor] +## This Is A Header [#but-this-is-my-anchor] ``` -Will result in an anchor link named `but-this-my-anchor` to be injected instead. Do note that these inline anchors will be normalized. +Will result in an anchor link named `but-this-my-anchor` to be injected instead. -Meaning +Do note that these inline anchors will be normalized. ```markdown ## This Is A Header [What about this for an anchor!] diff --git a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs index e1d39db25..2b7c91311 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs @@ -34,7 +34,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) To follow this tutorial you will need to install the following components: -## New Requirements [new-reqs] +## New Requirements [#new-reqs] These are new requirements """;