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. See Wikipedia (opens in a new tab). a (opens in a new tab) and b (opens in a new tab) See Wikipedia.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