defmodule Blogex.ExternalLinks do
@moduledoc """
Post-processes compiled post HTML so that external links open in a new
tab and carry a marker class for styling.
Anchors whose `href` starts with `http://` or `https://` get:
* `target="_blank"` — the link opens in a new tab
* `rel="noopener"` — the new tab cannot access `window.opener`
* `class="blogex-external"` — hook for CSS to draw an external-link
marker icon (the host app's stylesheet owns the actual styling)
* a ` (opens in a new tab)`
at the end of the link text — announced by screen readers, hidden
visually by the host app's stylesheet
Internal links (relative paths), `mailto:` and `#anchor` links are left
untouched.
The rewrite is idempotent: applying it to already-rewritten HTML does
not duplicate attributes or hints.
Assumptions: the HTML comes from NimblePublisher's Earmark conversion,
which emits double-quoted, lowercase attribute names and never adds
`target`, `rel` or `class` to anchors. Attribute values are matched
with case-insensitive regexes to stay robust for other producers.
Anchors are matched together with their closing tag (the hint goes
inside), so a stray unclosed `` is left alone.
"""
@marker_class "blogex-external"
@hint_class "blogex-external-hint"
@hint_text " (opens in a new tab)"
@external_href ~r|^https?://|i
@anchor ~r|(]*>)(.*?)()|is
@doc """
Rewrites all external anchors in an HTML fragment.
"""
@spec rewrite(String.t()) :: String.t()
def rewrite(html) when is_binary(html) do
Regex.replace(@anchor, html, fn _anchor, opening, content, closing ->
{opening, content} = decorate_anchor(opening, content)
opening <> content <> closing
end)
end
defp decorate_anchor(opening, content) do
if external?(href(opening)) do
opening =
opening
|> ensure_attribute("target", "_blank")
|> ensure_rel("noopener")
|> ensure_class(@marker_class)
{opening, ensure_hint(content)}
else
{opening, content}
end
end
defp ensure_hint(content) do
if String.contains?(content, @hint_class) do
content
else
content <> ~s|#{@hint_text}|
end
end
defp href(anchor) do
case Regex.run(~r/\shref\s*=\s*"([^"]*)"/i, anchor) do
[_match, href] -> href
_ -> nil
end
end
defp external?(href), do: is_binary(href) and Regex.match?(@external_href, href)
defp ensure_attribute(anchor, name, value) do
if Regex.match?(~r/\s#{name}\s*=/i, anchor) do
anchor
else
String.replace_suffix(anchor, ">", ~s( #{name}="#{value}">))
end
end
defp ensure_rel(anchor, keyword) do
case Regex.run(~r/\srel\s*=\s*"([^"]*)"/i, anchor) do
[_match, existing] ->
add_space_separated(anchor, "rel", existing, keyword)
_ ->
ensure_attribute(anchor, "rel", keyword)
end
end
defp ensure_class(anchor, class) do
case Regex.run(~r/\sclass\s*=\s*"([^"]*)"/i, anchor) do
[_match, existing] ->
add_space_separated(anchor, "class", existing, class)
_ ->
ensure_attribute(anchor, "class", class)
end
end
# Appends a keyword/class to an existing space-separated attribute value,
# unless it is already present.
defp add_space_separated(anchor, name, existing, addition) do
if addition in String.split(existing) do
anchor
else
String.replace(anchor, ~s(#{name}="#{existing}"), ~s(#{name}="#{existing} #{addition}"))
end
end
end