From e3fc7348626e309d68f034e974666ae2d268e251 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Mon, 26 Oct 2026 20:28:16 +0000 Subject: [PATCH] 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 (opens in a new tab) 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 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. --- app/assets/css/app.css | 16 ++++++ .../firehose_web/controllers/blog_test.exs | 7 +++ blogex/lib/blogex/external_links.ex | 40 ++++++++++---- blogex/test/blogex/external_links_test.exs | 52 ++++++++++++++++++- blogex/test/blogex/post_test.exs | 2 +- 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 22c532b..43f652e 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -219,6 +219,22 @@ body { font-family: 'Source Sans 3', sans-serif; } -webkit-mask-size: contain; } +/* Screen-reader hint injected by Blogex.ExternalLinks at the end of every + external link's text. Visually hidden (the icon above is the visual cue), + still announced: "Wikipedia, link, opens in a new tab". */ +.blogex-post-body .blogex-external-hint { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; +} + .blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; } .blogex-tag-list { diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs index 5a5f934..55f6d42 100644 --- a/app/test/firehose_web/controllers/blog_test.exs +++ b/app/test/firehose_web/controllers/blog_test.exs @@ -79,6 +79,13 @@ defmodule FirehoseWeb.BlogTest do assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\"" end + + test "rendered post page carries the screen-reader hint for external links", %{conn: conn} do + body = + conn |> get("/blog/engineering/delayed-gratification-in-ai-summer") |> html_response(200) + + assert body =~ ~s| (opens in a new tab)| + end end describe "input validation" do diff --git a/blogex/lib/blogex/external_links.ex b/blogex/lib/blogex/external_links.ex index 549390b..ff59ce1 100644 --- a/blogex/lib/blogex/external_links.ex +++ b/blogex/lib/blogex/external_links.ex @@ -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 ` (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. + 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(~r/]*>/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|#{@hint_text}| end end diff --git a/blogex/test/blogex/external_links_test.exs b/blogex/test/blogex/external_links_test.exs index 6c25bf8..9bf1330 100644 --- a/blogex/test/blogex/external_links_test.exs +++ b/blogex/test/blogex/external_links_test.exs @@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do rewritten = ExternalLinks.rewrite(html) assert rewritten == - ~s(

See Wikipedia.

) + ~s|

See Wikipedia (opens in a new tab).

| end test "also rewrites external http links" do @@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do rewritten = ExternalLinks.rewrite(html) assert rewritten == - ~s(

a and b

) + ~s|

a (opens in a new tab) and b (opens in a new tab)

| 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(

See Wikipedia.

) + + assert ExternalLinks.rewrite(html) == + ~s|

See Wikipedia (opens in a new tab).

| + end + + test "adds one hint per external link" do + html = ~s(a and b) + + assert html |> ExternalLinks.rewrite() |> hints() == 2 + end + + test "places the hint after inner markup, keeping it untouched" do + html = ~s(docs and more) + + assert ExternalLinks.rewrite(html) =~ + ~s|docs and more (opens in a new tab)| + end + + test "does not add a second hint when applied twice" do + once = ExternalLinks.rewrite(~s(once)) + 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(internal), + ~s(mail), + ~s(jump) + ] 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(Swim the Test) @@ -75,6 +117,12 @@ defmodule Blogex.ExternalLinksTest do assert ExternalLinks.rewrite(html) == html end + test "anchors without an href are unchanged" do + html = ~s(top) + + assert ExternalLinks.rewrite(html) == html + end + test "plain text without anchors is unchanged" do assert ExternalLinks.rewrite("

no links here

") == "

no links here

" end diff --git a/blogex/test/blogex/post_test.exs b/blogex/test/blogex/post_test.exs index e3c62ef..445927c 100644 --- a/blogex/test/blogex/post_test.exs +++ b/blogex/test/blogex/post_test.exs @@ -51,7 +51,7 @@ defmodule Blogex.PostTest do post = Post.build("x/2026/01-01-x.md", valid_attrs(), html) assert post.body =~ - ~s(Wikipedia) + ~s|Wikipedia (opens in a new tab)| end test "leaves internal links in stored body untouched" do