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:
@@ -219,6 +219,22 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
|||||||
-webkit-mask-size: contain;
|
-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-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 {
|
.blogex-tag-list {
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ defmodule FirehoseWeb.BlogTest do
|
|||||||
|
|
||||||
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
|
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
|
||||||
end
|
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|<span class="blogex-external-hint"> (opens in a new tab)</span>|
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "input validation" do
|
describe "input validation" do
|
||||||
|
|||||||
@@ -9,38 +9,60 @@ defmodule Blogex.ExternalLinks do
|
|||||||
* `rel="noopener"` — the new tab cannot access `window.opener`
|
* `rel="noopener"` — the new tab cannot access `window.opener`
|
||||||
* `class="blogex-external"` — hook for CSS to draw an external-link
|
* `class="blogex-external"` — hook for CSS to draw an external-link
|
||||||
marker icon (the host app's stylesheet owns the actual styling)
|
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
|
Internal links (relative paths), `mailto:` and `#anchor` links are left
|
||||||
untouched.
|
untouched.
|
||||||
|
|
||||||
The rewrite is idempotent: applying it to already-rewritten HTML does
|
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,
|
Assumptions: the HTML comes from NimblePublisher's Earmark conversion,
|
||||||
which emits double-quoted, lowercase attribute names and never adds
|
which emits double-quoted, lowercase attribute names and never adds
|
||||||
`target`, `rel` or `class` to anchors. Attribute values are matched
|
`target`, `rel` or `class` to anchors. Attribute values are matched
|
||||||
with case-insensitive regexes to stay robust for other producers.
|
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"
|
@marker_class "blogex-external"
|
||||||
|
@hint_class "blogex-external-hint"
|
||||||
|
@hint_text " (opens in a new tab)"
|
||||||
@external_href ~r|^https?://|i
|
@external_href ~r|^https?://|i
|
||||||
|
@anchor ~r|(<a\s[^>]*>)(.*?)(</a>)|is
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Rewrites all external anchors in an HTML fragment.
|
Rewrites all external anchors in an HTML fragment.
|
||||||
"""
|
"""
|
||||||
@spec rewrite(String.t()) :: String.t()
|
@spec rewrite(String.t()) :: String.t()
|
||||||
def rewrite(html) when is_binary(html) do
|
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
|
end
|
||||||
|
|
||||||
defp decorate_anchor(anchor) do
|
defp decorate_anchor(opening, content) do
|
||||||
if external?(href(anchor)) do
|
if external?(href(opening)) do
|
||||||
anchor
|
opening =
|
||||||
|
opening
|
||||||
|> ensure_attribute("target", "_blank")
|
|> ensure_attribute("target", "_blank")
|
||||||
|> ensure_rel("noopener")
|
|> ensure_rel("noopener")
|
||||||
|> ensure_class(@marker_class)
|
|> ensure_class(@marker_class)
|
||||||
|
|
||||||
|
{opening, ensure_hint(content)}
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do
|
|||||||
rewritten = ExternalLinks.rewrite(html)
|
rewritten = ExternalLinks.rewrite(html)
|
||||||
|
|
||||||
assert rewritten ==
|
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
|
end
|
||||||
|
|
||||||
test "also rewrites external http links" do
|
test "also rewrites external http links" do
|
||||||
@@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do
|
|||||||
rewritten = ExternalLinks.rewrite(html)
|
rewritten = ExternalLinks.rewrite(html)
|
||||||
|
|
||||||
assert rewritten ==
|
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
|
end
|
||||||
|
|
||||||
test "preserves other attributes on the anchor" do
|
test "preserves other attributes on the anchor" do
|
||||||
@@ -56,6 +56,48 @@ defmodule Blogex.ExternalLinksTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "rewrite/1 leaves other links untouched" do
|
||||||
test "internal relative links are unchanged" do
|
test "internal relative links are unchanged" do
|
||||||
html = ~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
|
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
|
assert ExternalLinks.rewrite(html) == html
|
||||||
end
|
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
|
test "plain text without anchors is unchanged" do
|
||||||
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
|
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ defmodule Blogex.PostTest do
|
|||||||
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
|
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
|
||||||
|
|
||||||
assert post.body =~
|
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
|
end
|
||||||
|
|
||||||
test "leaves internal links in stored body untouched" do
|
test "leaves internal links in stored body untouched" do
|
||||||
|
|||||||
Reference in New Issue
Block a user