External links in post bodies already opened in a new tab and showed an icon, but the icon is invisible to screen readers, so nothing announced that the link leaves the site. - blogex: ExternalLinks.rewrite/1 appends <span class="blogex-external-hint"> (opens in a new tab)</span> as the last child of every external anchor. No aria-label, so the accessible name still contains the visible link text (WCAG 2.5.3); the icon colour and 2.5.3 both hold. Idempotent: a second pass adds no second hint. - blogex: anchors are now matched together with their closing tag (the hint goes inside), so a stray unclosed <a> is left alone. - app: sr-only rule for .blogex-external-hint in .blogex-post-body. The split stays as before: blogex marks up, the host app styles. - tests: hint cases in blogex (appended, per link, after inner markup, idempotent, not on internal/mailto/anchor links) + anchors without href (Blogex.ExternalLinks now at 100% line coverage), and an app test that the hint reaches the rendered post page. Two exact-string expectations updated for the intended markup change. make test: app 179, blogex 162. make verify: green.
116 lines
3.6 KiB
Elixir
116 lines
3.6 KiB
Elixir
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 `<span class="blogex-external-hint"> (opens in a new tab)</span>`
|
|
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 `<a>` 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|(<a\s[^>]*>)(.*?)(</a>)|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|<span class="#{@hint_class}">#{@hint_text}</span>|
|
|
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
|