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