From d6bc94c1ab1a1d74c23153bd59e85eb6caf7e198 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 10:35:36 +0100 Subject: [PATCH 1/5] Add automatic links from URLs --- .../InlineParsers/AutoLinkInlineParser.cs | 143 ++++++++++ src/Elastic.Markdown/Myst/MarkdownParser.cs | 1 + .../Inline/AutoLinkTests.cs | 262 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs create mode 100644 tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs diff --git a/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs new file mode 100644 index 000000000..8233be7bd --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs @@ -0,0 +1,143 @@ +// 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.Diagnostics; +using Markdig; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Myst.InlineParsers; + +public static class AutoLinkBuilderExtensions +{ + public static MarkdownPipelineBuilder UseAutoLinks(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +public class AutoLinkBuilderExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.InlineParsers.InsertBefore(new AutoLinkInlineParser()); + + public void Setup(MarkdownPipeline pipeline, Markdig.Renderers.IMarkdownRenderer renderer) + { + // No custom renderer needed - we create standard LinkInline objects + // that are rendered by HtmxLinkInlineRenderer + } +} + +/// +/// Parses bare https:// URLs and converts them to clickable links. +/// URLs containing elastic.co/docs emit a hint suggesting crosslinks or relative links. +/// +public class AutoLinkInlineParser : InlineParser +{ + public AutoLinkInlineParser() => OpeningCharacters = ['h']; + + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + // Must start with https:// + var span = slice.AsSpan(); + if (!span.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + return false; + + // Find the end of the URL + var urlLength = FindUrlEnd(span); + if (urlLength <= "https://".Length) + return false; // Just "https://" with nothing after is not valid + + var url = span[..urlLength].ToString(); + + // Create a LinkInline with the URL as both href and text + var linkInline = new LinkInline(url, string.Empty) + { + IsClosed = true, + IsAutoLink = true + }; + _ = linkInline.AppendChild(new LiteralInline(url)); + + // Store context data for the renderer (same pattern as DiagnosticLinkInlineParser) + var context = processor.GetContext(); + linkInline.SetData(nameof(context.CurrentUrlPath), context.CurrentUrlPath); + linkInline.SetData("isCrossLink", false); + + // Emit hint for elastic.co/docs URLs + if (url.Contains("elastic.co/docs", StringComparison.OrdinalIgnoreCase)) + { + processor.EmitHint( + processor.LineIndex + 1, + slice.Start + 1, + urlLength, + $"Autolink '{url}' points to elastic.co/docs. Consider using a crosslink or relative link instead." + ); + } + + processor.Inline = linkInline; + + // Advance the slice past the URL + var end = slice.Start + urlLength; + while (slice.Start < end) + slice.SkipChar(); + + return true; + } + + /// + /// Finds the end of a URL in the given span, handling trailing punctuation correctly. + /// + private static int FindUrlEnd(ReadOnlySpan span) + { + var length = 0; + var parenDepth = 0; + var bracketDepth = 0; + + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + + // URL terminates at whitespace or control characters + if (char.IsWhiteSpace(c) || char.IsControl(c)) + break; + + // Track balanced parentheses (common in Wikipedia URLs) + if (c == '(') + parenDepth++; + else if (c == ')') + { + if (parenDepth > 0) + parenDepth--; + else + break; // Unbalanced closing paren - not part of URL + } + + // Track balanced brackets + if (c == '[') + bracketDepth++; + else if (c == ']') + { + if (bracketDepth > 0) + bracketDepth--; + else + break; // Unbalanced closing bracket - not part of URL + } + + // These characters end the URL (Markdown syntax) + if (c is '<' or '>') + break; + + length = i + 1; + } + + // Remove trailing punctuation that's likely sentence punctuation, not part of URL + while (length > 0 && span[length - 1] is '.' or ',' or ';' or ':' or '!' or '?' or '\'' or '"') + length--; + + return length; + } +} diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 9eeff5659..aa3f02f94 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -157,6 +157,7 @@ public static MarkdownPipeline Pipeline .UsePreciseSourceLocation() .UseFootnotes() // Must be before UseDiagnosticLinks to ensure FootnoteLinkParser is inserted correctly .UseDiagnosticLinks() + .UseAutoLinks() .UseHeadingsWithSlugs() .UseEmphasisExtras(EmphasisExtraOptions.Default) .UseSubstitutionInlineCode() diff --git a/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs new file mode 100644 index 000000000..c86e05e69 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs @@ -0,0 +1,262 @@ +// 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.Documentation.Diagnostics; +using FluentAssertions; +using JetBrains.Annotations; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Tests.Inline; + +/// +/// Base class for autolink tests that expect a LinkInline to be found. +/// +public abstract class AutoLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) + : InlineTest(output, content) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); +} + +/// +/// Base class for autolink tests that expect NO LinkInline to be found. +/// +public abstract class AutoLinkNotFoundTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) + : InlineTest(output, content) +{ +} + +public class BasicAutoLinkTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Check out https://example.com for more info. +""" +) +{ + [Fact] + public void GeneratesHtml() => + Html.Should().Contain( + """https://example.com""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkWithPathTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Visit https://example.com/path/to/page for details. +""" +) +{ + [Fact] + public void GeneratesHtml() => + Html.Should().Contain( + """https://example.com/path/to/page""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkWithQueryStringTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +See https://example.com/search?q=test&page=1 for results. +""" +) +{ + [Fact] + public void GeneratesHtml() => + Html.Should().Contain( + """https://example.com/search?q=test&page=1""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkWithAnchorTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Jump to https://example.com/page#section for the section. +""" +) +{ + [Fact] + public void GeneratesHtml() => + Html.Should().Contain( + """https://example.com/page#section""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkTrailingPeriodTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Check out https://example.com. +""" +) +{ + [Fact] + public void ExcludesTrailingPeriod() => + Html.Should().Contain( + """https://example.com.""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkTrailingCommaTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Visit https://example.com, https://another.com, or https://third.com for info. +""" +) +{ + [Fact] + public void ExcludesTrailingCommas() => + Html.Should().Contain( + """https://example.com,""" + ).And.Contain( + """https://another.com,""" + ).And.Contain( + """https://third.com""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkInParenthesesTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +See the docs (https://example.com) for details. +""" +) +{ + [Fact] + public void ExcludesClosingParen() => + Html.Should().Contain( + """(https://example.com)""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkWithBalancedParensTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +Check https://en.wikipedia.org/wiki/Rust_(programming_language) for more. +""" +) +{ + [Fact] + public void IncludesBalancedParens() => + Html.Should().Contain( + """https://en.wikipedia.org/wiki/Rust_(programming_language)""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkElasticDocsHintTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +See https://www.elastic.co/docs/deploy-manage for deployment info. +""" +) +{ + [Fact] + public void GeneratesHtml() => + Html.Should().Contain( + """https://www.elastic.co/docs/deploy-manage""" + ); + + [Fact] + public void EmitsHint() + { + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Hint && + d.Message.Contains("elastic.co/docs") && + d.Message.Contains("crosslink or relative link") + ); + } +} + +public class AutoLinkInCodeBlockTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, +""" +``` +https://example.com/should/not/be/linked +``` +""" +) +{ + [Fact] + public void DoesNotCreateLink() => + Html.Should().NotContain(" + Html.Should().Contain("https://example.com/api") + .And.NotContain(""" Collector.Diagnostics.Should().HaveCount(0); +} + +public class AutoLinkDoesNotMatchHttpTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, +""" +This http://example.com should not be autolinked. +""" +) +{ + [Fact] + public void DoesNotCreateLink() => + Html.Should().NotContain(" + Html.Should().Contain( + """Example""" + ).And.Contain( + """https://another.com""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class MultipleAutoLinksTests(ITestOutputHelper output) : AutoLinkTestBase(output, +""" +First https://first.com then https://second.com and finally https://third.com are all linked. +""" +) +{ + [Fact] + public void AllLinksAreCreated() => + Html.Should().Contain(""" Collector.Diagnostics.Should().HaveCount(0); +} From 165a0761850fbe9c4e57152c7e599638218eec78 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 10:37:50 +0100 Subject: [PATCH 2/5] Update documentation --- docs/syntax/links.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/syntax/links.md b/docs/syntax/links.md index 1cf04f8c8..340f98d6b 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -104,6 +104,25 @@ Link to websites and resources outside the Elastic docs: [Elastic Documentation](https://www.elastic.co/guide) ``` +### Autolinks + +Bare `https://` URLs in text are automatically converted to clickable links: + +```markdown +Check out https://example.com for more info. +``` + +Autolinks: + +- Only work with `https://` URLs (not `http://`). +- Open in a new tab. +- Display the external link indicator. +- Are not rendered inside code blocks or inline code. + +:::{hint} +If an autolink points to `elastic.co/docs`, consider replacing it with a [cross-repository link](#cross-repository-links) or a relative link for better maintainability. +::: + ## Link formatting ### Style link text From aeed11216c39846f2a7cbf44ea29d1045e4396af Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 11:10:06 +0100 Subject: [PATCH 3/5] Fix test --- tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs b/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs index 5af9d58af..895e02250 100644 --- a/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs @@ -186,7 +186,8 @@ public void ContainsContentWithColons() { var html = Html; html.Should().Contain("Time: 10:30 AM"); - html.Should().Contain("URL: https://example.com:8080/path"); + // URL is now autolinked + html.Should().Contain("""URL: https://example.com:8080/path"""); html.Should().Contain("Configuration: key:value pairs"); html.Should().Contain("function test() { return "hello:world"; }"); } From 6e6a0e3d7d7cc4d88a2aad508338ddaa7daee869 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 11:14:41 +0100 Subject: [PATCH 4/5] Fix docs --- docs/syntax/links.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/syntax/links.md b/docs/syntax/links.md index 340f98d6b..cc1dafc08 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -119,7 +119,7 @@ Autolinks: - Display the external link indicator. - Are not rendered inside code blocks or inline code. -:::{hint} +:::{tip} If an autolink points to `elastic.co/docs`, consider replacing it with a [cross-repository link](#cross-repository-links) or a relative link for better maintainability. ::: From 9948e5d402d448c645260ff8b8e25a1be0132d37 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 11:26:58 +0100 Subject: [PATCH 5/5] Fix elastic.co/docs hint --- docs/syntax/links.md | 38 +++++++++++++++---- .../InlineParsers/AutoLinkInlineParser.cs | 26 +++++++------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/docs/syntax/links.md b/docs/syntax/links.md index cc1dafc08..c29fcf1ca 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -106,11 +106,7 @@ Link to websites and resources outside the Elastic docs: ### Autolinks -Bare `https://` URLs in text are automatically converted to clickable links: - -```markdown -Check out https://example.com for more info. -``` +Bare `https://` URLs in text are automatically converted to clickable links. Autolinks: @@ -119,10 +115,38 @@ Autolinks: - Display the external link indicator. - Are not rendered inside code blocks or inline code. -:::{tip} -If an autolink points to `elastic.co/docs`, consider replacing it with a [cross-repository link](#cross-repository-links) or a relative link for better maintainability. +#### Examples + +::::{tab-set} + +:::{tab-item} Output + +- Documentation: https://example.com/docs/guide +- Search: https://example.com/search?q=elasticsearch&page=1 +- Section link: https://example.com/page#configuration + +::: + +:::{tab-item} Markdown + +```markdown +- Documentation: https://example.com/docs/guide +- Search: https://example.com/search?q=elasticsearch&page=1 +- Section link: https://example.com/page#configuration +``` + ::: +:::: + +#### Hint for elastic.co/docs URLs + +Autolinks pointing to `elastic.co/docs` trigger a hint during build, suggesting you replace them with a [cross-repository link](#cross-repository-links) or relative link for better maintainability. + +For example, this autolink triggers the hint: https://www.elastic.co/docs + +Instead, use a cross-repository link or a relative link. + ## Link formatting ### Style link text diff --git a/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs index 8233be7bd..f68a9cac2 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs @@ -7,6 +7,7 @@ using Markdig.Helpers; using Markdig.Parsers; using Markdig.Parsers.Inlines; +using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace Elastic.Markdown.Myst.InlineParsers; @@ -54,11 +55,19 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) var url = span[..urlLength].ToString(); + // Get source position for proper diagnostics + var startPosition = slice.Start; + var start = processor.GetSourcePosition(startPosition, out var line, out var column); + var spanEnd = start + urlLength - 1; + // Create a LinkInline with the URL as both href and text var linkInline = new LinkInline(url, string.Empty) { IsClosed = true, - IsAutoLink = true + IsAutoLink = true, + Span = new SourceSpan(start, spanEnd), + Line = line, + Column = column }; _ = linkInline.AppendChild(new LiteralInline(url)); @@ -67,19 +76,12 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) linkInline.SetData(nameof(context.CurrentUrlPath), context.CurrentUrlPath); linkInline.SetData("isCrossLink", false); - // Emit hint for elastic.co/docs URLs - if (url.Contains("elastic.co/docs", StringComparison.OrdinalIgnoreCase)) - { - processor.EmitHint( - processor.LineIndex + 1, - slice.Start + 1, - urlLength, - $"Autolink '{url}' points to elastic.co/docs. Consider using a crosslink or relative link instead." - ); - } - processor.Inline = linkInline; + // Emit hint for elastic.co/docs URLs (after setting Inline so position is correct) + if (url.Contains("elastic.co/docs", StringComparison.OrdinalIgnoreCase)) + processor.EmitHint(linkInline, "Autolink points to elastic.co/docs. Consider using a crosslink or relative link instead."); + // Advance the slice past the URL var end = slice.Start + urlLength; while (slice.Start < end)