diff --git a/docs/syntax/links.md b/docs/syntax/links.md index 1cf04f8c8..c29fcf1ca 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -104,6 +104,49 @@ 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. + +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. + +#### 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 new file mode 100644 index 000000000..f68a9cac2 --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs @@ -0,0 +1,145 @@ +// 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; +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(); + + // 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, + Span = new SourceSpan(start, spanEnd), + Line = line, + Column = column + }; + _ = 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); + + 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) + 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/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"; }"); } 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); +}