diff --git a/docs/syntax/links.md b/docs/syntax/links.md index 1c8dc27..610654f 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -91,4 +91,15 @@ Do note that these inline anchors will be normalized. ## 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 +Will result in the anchor `what-about-this-for-an-anchor`. + + +## Inline anchors + +Docsbuilder temporary supports the abbility to create a linkable anchor anywhere on any document. + +```markdown +This is text and $$$this-is-an-inline-anchor$$$ +``` + +This feature exists to aid with migration however is scheduled for removal and new content should **NOT** utilize this feature. \ No newline at end of file diff --git a/src/Elastic.Markdown/Helpers/SlugExtensions.cs b/src/Elastic.Markdown/Helpers/SlugExtensions.cs new file mode 100644 index 0000000..dd1c0e2 --- /dev/null +++ b/src/Elastic.Markdown/Helpers/SlugExtensions.cs @@ -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); + +} diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index e5ff571..03cdc01 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -8,18 +8,17 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; +using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Slices; 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) @@ -151,8 +150,6 @@ private void ReadDocumentInstructions(MarkdownDocument document) Collector.EmitWarning(FilePath, "Document has no title, using file name as title."); } - - var contents = document .Where(block => block is HeadingBlock { Level: >= 2 }) .Cast() @@ -160,7 +157,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(); @@ -170,8 +167,10 @@ private void ReadDocumentInstructions(MarkdownDocument document) var labels = document.Descendants() .Select(b => b.CrossReferenceName) .Where(l => !string.IsNullOrWhiteSpace(l)) - .Select(_slugHelper.GenerateSlug) + .Select(s => s.Slugify()) + .Concat(document.Descendants().Select(a => a.Anchor)) .ToArray(); + foreach (var label in labels) { if (!string.IsNullOrEmpty(label)) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs index 45a696d..e752784 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs @@ -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); @@ -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(); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs new file mode 100644 index 0000000..c96052e --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs @@ -0,0 +1,81 @@ +// 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(); + return pipeline; + } +} + +public class InlineAnchorBuilderExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.InlineParsers.InsertAfter(new InlineAnchorParser()); + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => + renderer.ObjectRenderers.InsertAfter(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 +{ + protected override void Write(HtmlRenderer renderer, InlineAnchor obj) => + renderer.Write(""); +} diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 82f1ce0..152fb62 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -31,6 +31,7 @@ public class MarkdownParser( public static MarkdownPipeline MinimalPipeline { get; } = new MarkdownPipelineBuilder() .UseYamlFrontMatter() + .UseInlineAnchors() .UseHeadingsWithSlugs() .UseDirectives() .Build(); @@ -38,6 +39,7 @@ public class MarkdownParser( public static MarkdownPipeline Pipeline { get; } = new MarkdownPipelineBuilder() .EnableTrackTrivia() + .UseInlineAnchors() .UsePreciseSourceLocation() .UseDiagnosticLinks() .UseHeadingsWithSlugs() diff --git a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs index 9567281..cd1c4a8 100644 --- a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs +++ b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs @@ -1,17 +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 Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.InlineParsers; using Markdig.Renderers; using Markdig.Renderers.Html; using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using Slugify; namespace Elastic.Markdown.Myst; public class SectionedHeadingRenderer : HtmlObjectRenderer { - private readonly SlugHelper _slugHelper = new(); private static readonly string[] HeadingTexts = [ "h1", @@ -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(@"
(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( + """

this is regular text and this and this continues to be regular text

""" + ); +} + +public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest(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( + """

and this continues to be regular text

""" + ); +} + +public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest(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( + """

this is regular text and this

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

this is regular text and this $$is-an-inline-anchor$$$

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

this is regular text and this $$$is-an-inline-anchor$$

""" + ); +} + +public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest(output, + """ + ## Hello world $$$my-anchor$$$ + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Be( + """ +

Hello world +

+
+ """.TrimEnd() + ); +} + +public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest(output, + """ + ## Hello world [#my-anchor] + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Be( + """ +

Hello world +

+
+ """.TrimEnd() + ); +} + + +public abstract class InlineAnchorLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) + : InlineTest(output, +$""" +## Hello world + +A paragraph + +{content} + +$$$same-page-anchor$$$ + +""") +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // language=markdown + var inclusion = +""" +# Special Requirements + +## Sub Requirements + +To follow this tutorial you will need to install the following components: + +## New Requirements [#new-reqs] + +These are new requirements + +With a custom anchor that exists temporarily. $$$custom-anchor$$$ +"""; + fileSystem.AddFile(@"docs/testing/req.md", inclusion); + fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); + } + +} + +public class InlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, +""" +[Hello](#same-page-anchor) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Hello

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class ExternalPageInlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, +""" +[Sub Requirements](testing/req.md#custom-anchor) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Sub Requirements

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 1513f01..4d794a8 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -130,7 +130,7 @@ public virtual async Task InitializeAsync() Document = await File.ParseFullAsync(default); var html = File.CreateHtml(Document).AsSpan(); - var find = "
"; + var find = "\n"; var start = html.IndexOf(find, StringComparison.Ordinal); Html = start >= 0 && !TestingFullDocument ? html[(start + find.Length)..].ToString().Trim(Environment.NewLine.ToCharArray())