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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user