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