From e0a277e215e5a49bd591cda4342d19f7c7a64374 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Tue, 31 Mar 2026 19:17:58 +0100 Subject: [PATCH] Always open chat links in new tabs --- lib/music_library_web/components/chat.ex | 6 +- lib/music_library_web/markdown.ex | 70 ++++++++++++++++++++---- test/music_library_web/markdown_test.exs | 38 +++++++++++++ 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/lib/music_library_web/components/chat.ex b/lib/music_library_web/components/chat.ex index 47ed6186..0d77bfe0 100644 --- a/lib/music_library_web/components/chat.ex +++ b/lib/music_library_web/components/chat.ex @@ -25,7 +25,7 @@ defmodule MusicLibraryWeb.Components.Chat do @impl true def update(%{chunk: chunk}, socket) do - doc = socket.assigns.streaming_doc || Markdown.new_streaming_doc() + doc = socket.assigns.streaming_doc || Markdown.new_streaming_doc(link_target: "_blank") {:ok, socket @@ -246,7 +246,7 @@ defmodule MusicLibraryWeb.Components.Chat do >

{message.content}

- {raw(Markdown.to_html(message.content))} + {raw(Markdown.to_html(message.content, link_target: "_blank"))}
@@ -255,7 +255,7 @@ defmodule MusicLibraryWeb.Components.Chat do class="max-w-[85%] rounded-lg bg-zinc-100 px-4 py-2 text-sm text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100" >
- {raw(Markdown.streaming_to_html(@streaming_doc))} + {raw(Markdown.streaming_to_html(@streaming_doc, link_target: "_blank"))}
diff --git a/lib/music_library_web/markdown.ex b/lib/music_library_web/markdown.ex index 36dc0196..f62e0447 100644 --- a/lib/music_library_web/markdown.ex +++ b/lib/music_library_web/markdown.ex @@ -14,46 +14,67 @@ defmodule MusicLibraryWeb.Markdown do sanitize: MDEx.Document.default_sanitize_options() ] + @link_target_sanitize_options Keyword.put( + MDEx.Document.default_sanitize_options(), + :add_tag_attributes, + %{"a" => ["target"]} + ) + + @link_target_mdex_options Keyword.put(@mdex_options, :sanitize, @link_target_sanitize_options) + @doc """ Converts markdown with custom `[[link]]` syntax to HTML. Double square brackets are converted to search links before the markdown is processed. """ - @spec to_html(String.t() | nil) :: String.t() - def to_html(markdown_text) when is_binary(markdown_text) do + @spec to_html(String.t() | nil, keyword()) :: String.t() + def to_html(markdown_text, opts \\ []) + + def to_html(markdown_text, opts) when is_binary(markdown_text) do :telemetry.span( [:markdown, :to_html], %{}, fn -> + processed = process_double_bracket_links(markdown_text) + result = - markdown_text - |> process_double_bracket_links() - |> MDEx.to_html!(@mdex_options) + if opts[:link_target] do + processed + |> MDEx.parse_document!(@link_target_mdex_options) + |> open_links_in_new_tab(opts[:link_target]) + |> MDEx.to_html!() + else + MDEx.to_html!(processed, @mdex_options) + end {result, %{}} end ) end - def to_html(nil), do: "" + def to_html(nil, _opts), do: "" @doc """ Creates a new streaming MDEx document for incremental markdown rendering. """ - @spec new_streaming_doc() :: MDEx.Document.t() - def new_streaming_doc do - MDEx.new([streaming: true] ++ @mdex_options) + @spec new_streaming_doc(keyword()) :: MDEx.Document.t() + def new_streaming_doc(opts \\ []) do + mdex_options = if opts[:link_target], do: @link_target_mdex_options, else: @mdex_options + MDEx.new([streaming: true] ++ mdex_options) end @doc """ Renders a streaming MDEx document to HTML. """ - @spec streaming_to_html(MDEx.Document.t()) :: String.t() - def streaming_to_html(%MDEx.Document{} = doc) do + @spec streaming_to_html(MDEx.Document.t(), keyword()) :: String.t() + def streaming_to_html(doc, opts \\ []) + + def streaming_to_html(%MDEx.Document{} = doc, opts) do :telemetry.span( [:markdown, :streaming_to_html], %{}, fn -> + doc = if opts[:link_target], do: open_links_in_new_tab(doc, opts[:link_target]), else: doc result = MDEx.to_html!(doc) {result, %{}} end @@ -97,4 +118,31 @@ defmodule MusicLibraryWeb.Markdown do _ -> content end end + + defp open_links_in_new_tab(doc, target) do + MDEx.Document.update_nodes(doc, MDEx.Link, fn %MDEx.Link{url: url, title: title, nodes: nodes} -> + text = extract_text(nodes) + + title_attr = + if title != "" and title != nil, do: ~s( title="#{html_escape(title)}"), else: "" + + %MDEx.HtmlInline{ + literal: + ~s(#{html_escape(text)}) + } + end) + end + + defp extract_text(nodes) when is_list(nodes), do: Enum.map_join(nodes, "", &extract_text/1) + defp extract_text(%{literal: literal}), do: literal + defp extract_text(%{nodes: nodes}), do: extract_text(nodes) + defp extract_text(_), do: "" + + defp html_escape(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + end end diff --git a/test/music_library_web/markdown_test.exs b/test/music_library_web/markdown_test.exs index 4577e7f8..ef699dbc 100644 --- a/test/music_library_web/markdown_test.exs +++ b/test/music_library_web/markdown_test.exs @@ -58,5 +58,43 @@ defmodule MusicLibraryWeb.MarkdownTest do assert result =~ "http://example.com" assert result =~ "link" end + + test "does not add target attribute by default" do + result = Markdown.to_html("[link](http://example.com)") + + refute result =~ "target=" + end + end + + describe "to_html/2 with link_target" do + test "adds target and rel attributes to links" do + result = Markdown.to_html("[link](http://example.com)", link_target: "_blank") + + assert result =~ ~s(target="_blank") + assert result =~ ~s(rel="noopener noreferrer") + assert result =~ ~s(href="http://example.com") + assert result =~ ">link" + end + + test "adds target to autolinked URLs" do + result = Markdown.to_html("Visit http://example.com for more", link_target: "_blank") + + assert result =~ ~s(target="_blank") + assert result =~ ~s(href="http://example.com") + end + + test "adds target to double bracket links" do + result = Markdown.to_html("Check [[Porcupine Tree]]", link_target: "_blank") + + assert result =~ ~s(target="_blank") + assert result =~ "Porcupine Tree" + end + + test "preserves link title" do + result = Markdown.to_html(~s|[link](http://example.com "My Title")|, link_target: "_blank") + + assert result =~ ~s(title="My Title") + assert result =~ ~s(target="_blank") + end end end