External links: screen-reader hint on links that leave the blog

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.
This commit is contained in:
Willem van den Ende
2026-10-26 20:28:16 +00:00
parent 4b4d0c491c
commit e3fc734862
5 changed files with 105 additions and 12 deletions
+31 -9
View File
@@ -9,38 +9,60 @@ defmodule Blogex.ExternalLinks do
* `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.
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(~r/<a\s[^>]*>/i, html, &decorate_anchor/1)
Regex.replace(@anchor, html, fn _anchor, opening, content, closing ->
{opening, content} = decorate_anchor(opening, content)
opening <> content <> closing
end)
end
defp decorate_anchor(anchor) do
if external?(href(anchor)) do
anchor
|> ensure_attribute("target", "_blank")
|> ensure_rel("noopener")
|> ensure_class(@marker_class)
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
anchor
{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