Always open chat links in new tabs

This commit is contained in:
Claudio Ortolina
2026-03-31 19:17:58 +01:00
parent cf8ad0b826
commit e0a277e215
3 changed files with 100 additions and 14 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ defmodule MusicLibraryWeb.Components.Chat do
@impl true @impl true
def update(%{chunk: chunk}, socket) do 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, {:ok,
socket socket
@@ -246,7 +246,7 @@ defmodule MusicLibraryWeb.Components.Chat do
> >
<p :if={message.role == "user"} class="whitespace-pre-wrap">{message.content}</p> <p :if={message.role == "user"} class="whitespace-pre-wrap">{message.content}</p>
<div :if={message.role == "assistant"} class="dark:prose-invert prose prose-sm"> <div :if={message.role == "assistant"} class="dark:prose-invert prose prose-sm">
{raw(Markdown.to_html(message.content))} {raw(Markdown.to_html(message.content, link_target: "_blank"))}
</div> </div>
</div> </div>
@@ -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" 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"
> >
<div class="dark:prose-invert prose prose-sm"> <div class="dark:prose-invert prose prose-sm">
{raw(Markdown.streaming_to_html(@streaming_doc))} {raw(Markdown.streaming_to_html(@streaming_doc, link_target: "_blank"))}
</div> </div>
</div> </div>
+59 -11
View File
@@ -14,46 +14,67 @@ defmodule MusicLibraryWeb.Markdown do
sanitize: MDEx.Document.default_sanitize_options() 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 """ @doc """
Converts markdown with custom `[[link]]` syntax to HTML. Converts markdown with custom `[[link]]` syntax to HTML.
Double square brackets are converted to search links before the markdown is processed. Double square brackets are converted to search links before the markdown is processed.
""" """
@spec to_html(String.t() | nil) :: String.t() @spec to_html(String.t() | nil, keyword()) :: String.t()
def to_html(markdown_text) when is_binary(markdown_text) do def to_html(markdown_text, opts \\ [])
def to_html(markdown_text, opts) when is_binary(markdown_text) do
:telemetry.span( :telemetry.span(
[:markdown, :to_html], [:markdown, :to_html],
%{}, %{},
fn -> fn ->
processed = process_double_bracket_links(markdown_text)
result = result =
markdown_text if opts[:link_target] do
|> process_double_bracket_links() processed
|> MDEx.to_html!(@mdex_options) |> 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, %{}} {result, %{}}
end end
) )
end end
def to_html(nil), do: "" def to_html(nil, _opts), do: ""
@doc """ @doc """
Creates a new streaming MDEx document for incremental markdown rendering. Creates a new streaming MDEx document for incremental markdown rendering.
""" """
@spec new_streaming_doc() :: MDEx.Document.t() @spec new_streaming_doc(keyword()) :: MDEx.Document.t()
def new_streaming_doc do def new_streaming_doc(opts \\ []) do
MDEx.new([streaming: true] ++ @mdex_options) mdex_options = if opts[:link_target], do: @link_target_mdex_options, else: @mdex_options
MDEx.new([streaming: true] ++ mdex_options)
end end
@doc """ @doc """
Renders a streaming MDEx document to HTML. Renders a streaming MDEx document to HTML.
""" """
@spec streaming_to_html(MDEx.Document.t()) :: String.t() @spec streaming_to_html(MDEx.Document.t(), keyword()) :: String.t()
def streaming_to_html(%MDEx.Document{} = doc) do def streaming_to_html(doc, opts \\ [])
def streaming_to_html(%MDEx.Document{} = doc, opts) do
:telemetry.span( :telemetry.span(
[:markdown, :streaming_to_html], [:markdown, :streaming_to_html],
%{}, %{},
fn -> fn ->
doc = if opts[:link_target], do: open_links_in_new_tab(doc, opts[:link_target]), else: doc
result = MDEx.to_html!(doc) result = MDEx.to_html!(doc)
{result, %{}} {result, %{}}
end end
@@ -97,4 +118,31 @@ defmodule MusicLibraryWeb.Markdown do
_ -> content _ -> content
end end
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(<a href="#{html_escape(url)}" target="#{target}"#{title_attr}>#{html_escape(text)}</a>)
}
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
end
end end
+38
View File
@@ -58,5 +58,43 @@ defmodule MusicLibraryWeb.MarkdownTest do
assert result =~ "http://example.com" assert result =~ "http://example.com"
assert result =~ "link</a>" assert result =~ "link</a>"
end 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</a>"
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</a>"
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
end end