diff --git a/ConsoleMarkdownRenderer.Tests/RendererTests.cs b/ConsoleMarkdownRenderer.Tests/RendererTests.cs index 544a9e3..f2b036d 100644 --- a/ConsoleMarkdownRenderer.Tests/RendererTests.cs +++ b/ConsoleMarkdownRenderer.Tests/RendererTests.cs @@ -375,6 +375,79 @@ public void RendererTests_AutolinkTest(int index, string expectedContent, string Assert.IsFalse(link.IsImage, $"IsImage should be false at index {index}"); } + [TestMethod] + public void RendererTests_TerminalHyperlinks_DefaultEnabled() + { + // By default, UseTerminalHyperlinks is true so OSC 8 escape sequences should be + // emitted around link text in supported terminals. + Assert.IsTrue(new DisplayOptions().UseTerminalHyperlinks, + "UseTerminalHyperlinks should default to true."); + + var output = RenderMarkdownWithLinkCapableConsole("[two](http://two.example/)", new DisplayOptions()); + + // OSC 8 hyperlink wraps with ESC ] 8 ; ; ESC \ ... ESC ] 8 ; ; ESC \ + Assert.Contains("\u001B]8;", output, "Expected OSC 8 open sequence in output"); + Assert.Contains("http://two.example/", output, "Expected URL in OSC 8 sequence"); + Assert.Contains("\u001B]8;;\u001B\\", output, "Expected OSC 8 close sequence in output"); + } + + [TestMethod] + public void RendererTests_TerminalHyperlinks_OptOut() + { + // When UseTerminalHyperlinks is false, no OSC 8 escape sequences should be emitted. + var options = new DisplayOptions { UseTerminalHyperlinks = false }; + var output = RenderMarkdownWithLinkCapableConsole("[two](http://two.example/)", options); + + Assert.DoesNotContain("\u001B]8;", output, + $"Expected no OSC 8 sequences when UseTerminalHyperlinks is false. Output: {output}"); + // Visible link text/URL should still be present in the rendered output. + Assert.Contains("http://two.example/", output, "URL should still be rendered as visible text"); + } + + [TestMethod] + public void RendererTests_TerminalHyperlinks_AutolinkEmitsOsc8() + { + // Autolinks should also be wrapped in OSC 8 hyperlinks by default. + var output = RenderMarkdownWithLinkCapableConsole("", new DisplayOptions()); + + Assert.Contains("\u001B]8;", output, "Expected OSC 8 open sequence for autolink"); + Assert.Contains("https://auto.example/", output, "Expected autolink URL in output"); + Assert.Contains("\u001B]8;;\u001B\\", output, "Expected OSC 8 close sequence for autolink"); + } + + [TestMethod] + public void RendererTests_TerminalHyperlinks_UrlWithBracketsIsEscaped() + { + // URLs may contain '[' or ']'. The link tag's URL parameter must be escaped via + // Markup.Escape so it doesn't break Spectre's markup parser. + const string url = "http://example.com/path[1]"; + var output = RenderMarkdownWithLinkCapableConsole($"[label](<{url}>)", new DisplayOptions()); + + // The URL should appear unmodified inside the OSC 8 escape sequence. + Assert.Contains($"\u001B]8;id=", output, "Expected OSC 8 open sequence"); + Assert.Contains(url, output, $"Expected the unescaped URL '{url}' in the output"); + } + + [TestMethod] + public void RendererTests_TerminalHyperlinks_ClonePreservesOptOut() + { + var options = new DisplayOptions { UseTerminalHyperlinks = false }; + var clone = options.Clone(); + Assert.IsFalse(clone.UseTerminalHyperlinks, "Clone() should preserve UseTerminalHyperlinks"); + } + + private string RenderMarkdownWithLinkCapableConsole(string markdown, DisplayOptions options) + { + // Configure the test console to actually emit ANSI sequences and advertise OSC 8 + // hyperlink support, so the rendered Output contains the escape sequences we are + // asserting on. + var console = NewConsole(); + console.EmitAnsiSequences = true; + console.Profile.Capabilities.Links = true; + console.Write(Renderer(markdown, options)); + return console.Output; + } + [TestMethod] [DataRow("quote 2." , Decoration.Italic)] [DataRow("should even" , Decoration.Italic | Decoration.Bold)] diff --git a/DisplayOptions.cs b/DisplayOptions.cs index 1f57624..df3d9ef 100644 --- a/DisplayOptions.cs +++ b/DisplayOptions.cs @@ -123,6 +123,16 @@ public sealed class DisplayOptions // When set to true wrap Headers with '#'s public bool WrapHeader { get; set; } = true; + + /// + /// When (the default), links rendered for + /// and + /// are wrapped with Spectre.Console's [link=...]...[/] markup so that + /// supported terminals (iTerm2, Windows Terminal, GNOME Terminal, etc.) render them as + /// clickable OSC 8 hyperlinks. + /// Set to to disable for terminals that render the escape sequences as garbage. + /// + public bool UseTerminalHyperlinks { get; set; } = true; // When set to true the content structure is displayed and detail of unsupported markdown is displayed public bool IncludeDebug = false; @@ -158,6 +168,7 @@ public sealed class DisplayOptions Superscript = this.Superscript, UnknownDelimiterChar = this.UnknownDelimiterChar, UnknownDelimiterContent = this.UnknownDelimiterContent, + UseTerminalHyperlinks = this.UseTerminalHyperlinks, WrapHeader = this.WrapHeader, }; diff --git a/ObjectRenderers/ConsoleRendererBase.cs b/ObjectRenderers/ConsoleRendererBase.cs index 9d440fd..691471f 100644 --- a/ObjectRenderers/ConsoleRendererBase.cs +++ b/ObjectRenderers/ConsoleRendererBase.cs @@ -303,6 +303,14 @@ public T PopLink(string url, bool isImage = false) public T WriteLink(Action writeDisplay, string url, bool isImage = false) { + // When enabled (and the URL is non-empty), wrap the rendered link text with + // Spectre.Console's [link=...]...[/] markup so that supported terminals emit + // an OSC 8 hyperlink, making the link clickable inline. + var useHyperlink = Options.UseTerminalHyperlinks && !string.IsNullOrEmpty(url); + if (useHyperlink) + { + AddInLine($"[link={Markup.Escape(url)}]"); + } if (isImage) { AddInLine("!"); @@ -310,10 +318,15 @@ public T WriteLink(Action writeDisplay, string url, bool isImage = false) WriteEscape("[") .PushLink(); writeDisplay(CastThis); - return PopLink(url, isImage) + PopLink(url, isImage) .WriteEscape("](") .WriteEscape(url) .AddInLine(")"); + if (useHyperlink) + { + AddInLine("[/]"); + } + return CastThis; } public T NewListBlockFrame(ListBlock list) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5be9072..f2d121f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,7 @@ Image - After Image +- [#133](https://github.com/boxofyellow/ConsoleMarkdownRenderer/pull/133): Emit OSC 8 terminal hyperlinks from WriteLink via Spectre Markup ### :wrench: Internal Improvements :wrench: - [#129](https://github.com/boxofyellow/ConsoleMarkdownRenderer/pull/129): Use ConfigureAwait(false) on awaits in published library code