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
+50 -2
View File
@@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do
rewritten = ExternalLinks.rewrite(html)
assert rewritten ==
~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>.</p>)
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
end
test "also rewrites external http links" do
@@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do
rewritten = ExternalLinks.rewrite(html)
assert rewritten ==
~s(<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a</a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b</a></p>)
~s|<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a<span class="blogex-external-hint"> (opens in a new tab)</span></a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b<span class="blogex-external-hint"> (opens in a new tab)</span></a></p>|
end
test "preserves other attributes on the anchor" do
@@ -56,6 +56,48 @@ defmodule Blogex.ExternalLinksTest do
end
end
describe "rewrite/1 accessible hint" do
test "appends a visually hidden hint inside the external anchor" do
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a>.</p>)
assert ExternalLinks.rewrite(html) ==
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
end
test "adds one hint per external link" do
html = ~s(<a href="https://a.example/">a</a> and <a href="https://b.example/">b</a>)
assert html |> ExternalLinks.rewrite() |> hints() == 2
end
test "places the hint after inner markup, keeping it untouched" do
html = ~s(<a href="https://example.com/"><code>docs</code> and more</a>)
assert ExternalLinks.rewrite(html) =~
~s|<code>docs</code> and more<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
end
test "does not add a second hint when applied twice" do
once = ExternalLinks.rewrite(~s(<a href="https://example.com/">once</a>))
twice = ExternalLinks.rewrite(once)
assert twice == once
assert hints(twice) == 1
end
test "adds no hint to links that are not external" do
for html <- [
~s(<a href="/blog/engineering/swim-the-test">internal</a>),
~s(<a href="mailto:hi@example.com">mail</a>),
~s(<a href="#section">jump</a>)
] do
assert ExternalLinks.rewrite(html) == html
end
end
defp hints(html), do: length(Regex.scan(~r/class="blogex-external-hint"/, html))
end
describe "rewrite/1 leaves other links untouched" do
test "internal relative links are unchanged" do
html = ~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
@@ -75,6 +117,12 @@ defmodule Blogex.ExternalLinksTest do
assert ExternalLinks.rewrite(html) == html
end
test "anchors without an href are unchanged" do
html = ~s(<a name="top">top</a>)
assert ExternalLinks.rewrite(html) == html
end
test "plain text without anchors is unchanged" do
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
end
+1 -1
View File
@@ -51,7 +51,7 @@ defmodule Blogex.PostTest do
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
assert post.body =~
~s(<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>)
~s|<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
end
test "leaves internal links in stored body untouched" do