diff --git a/docs/source/syntax/links.md b/docs/source/syntax/links.md index 804a25d93..527a03017 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 A 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 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. + +```markdown +## 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 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..2b7c91311 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)