External link marker: new tab + icon on links leaving the blog

External links in post bodies now open in a new tab and show a
box-with-arrow marker icon, so readers can tell at a glance which
links leave the site.

- blogex: new Blogex.ExternalLinks.rewrite/1 marks up anchors with
  http(s) hrefs at compile time with target="_blank", rel="noopener"
  and class="blogex-external" (idempotent, merges into existing
  class/rel values, leaves relative, mailto: and #anchor links alone)
- blogex: Post.build/3 applies the rewrite, so every blog gets it
- app: CSS in .blogex-post-body draws the marker via a mask so it
  inherits the link colour; empty ::after content stays invisible
  to screen readers
- make test now also runs the blogex suite; it is currently red
  with 3 pre-existing blog_integration_test failures caused by the
  vacuous link validator (parked yak
  link-validator-never-sees-compiled-html-...)
This commit is contained in:
Willem van den Ende
2026-10-02 12:11:10 +00:00
parent 0e603d4bbc
commit 127eb474d2
8 changed files with 284 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
# Makefile for Blogex library
MISE_BIN ?= $(HOME)/.local/bin/mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: test format
test:
$(MISE_EXEC) mix test
format:
$(MISE_EXEC) mix format
+93
View File
@@ -0,0 +1,93 @@
defmodule Blogex.ExternalLinks do
@moduledoc """
Post-processes compiled post HTML so that external links open in a new
tab and carry a marker class for styling.
Anchors whose `href` starts with `http://` or `https://` get:
* `target="_blank"` — the link opens in a new tab
* `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)
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.
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.
"""
@marker_class "blogex-external"
@external_href ~r|^https?://|i
@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)
end
defp decorate_anchor(anchor) do
if external?(href(anchor)) do
anchor
|> ensure_attribute("target", "_blank")
|> ensure_rel("noopener")
|> ensure_class(@marker_class)
else
anchor
end
end
defp href(anchor) do
case Regex.run(~r/\shref\s*=\s*"([^"]*)"/i, anchor) do
[_match, href] -> href
_ -> nil
end
end
defp external?(href), do: is_binary(href) and Regex.match?(@external_href, href)
defp ensure_attribute(anchor, name, value) do
if Regex.match?(~r/\s#{name}\s*=/i, anchor) do
anchor
else
String.replace_suffix(anchor, ">", ~s( #{name}="#{value}">))
end
end
defp ensure_rel(anchor, keyword) do
case Regex.run(~r/\srel\s*=\s*"([^"]*)"/i, anchor) do
[_match, existing] ->
add_space_separated(anchor, "rel", existing, keyword)
_ ->
ensure_attribute(anchor, "rel", keyword)
end
end
defp ensure_class(anchor, class) do
case Regex.run(~r/\sclass\s*=\s*"([^"]*)"/i, anchor) do
[_match, existing] ->
add_space_separated(anchor, "class", existing, class)
_ ->
ensure_attribute(anchor, "class", class)
end
end
# Appends a keyword/class to an existing space-separated attribute value,
# unless it is already present.
defp add_space_separated(anchor, name, existing, addition) do
if addition in String.split(existing) do
anchor
else
String.replace(anchor, ~s(#{name}="#{existing}"), ~s(#{name}="#{existing} #{addition}"))
end
end
end
+3 -1
View File
@@ -67,6 +67,8 @@ defmodule Blogex.Post do
Build callback for NimblePublisher.
Extracts the date from the filename path and merges with frontmatter attrs.
External links in the HTML body are marked up by `Blogex.ExternalLinks`
(new tab, `rel="noopener"`, marker class).
The `blog` atom is injected by the parent Blog module.
"""
def build(filename, attrs, body) do
@@ -81,7 +83,7 @@ defmodule Blogex.Post do
struct!(
__MODULE__,
[id: id, date: date, body: body] ++ Map.to_list(attrs)
[id: id, date: date, body: Blogex.ExternalLinks.rewrite(body)] ++ Map.to_list(attrs)
)
end
end
@@ -0,0 +1,88 @@
defmodule Blogex.ExternalLinksTest do
use ExUnit.Case, async: true
alias Blogex.ExternalLinks
describe "rewrite/1 on anchors with http(s) URLs" do
test "adds target, rel and marker class to an external https link" do
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a>.</p>)
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>)
end
test "also rewrites external http links" do
rewritten = ExternalLinks.rewrite(~s(<a href="http://www.exampler.com/about/">about</a>))
assert rewritten =~ ~s(target="_blank" rel="noopener" class="blogex-external")
end
test "rewrites each external link in a document with many links" do
html =
~s(<p><a href="https://a.example/">a</a> and <a href="https://b.example/">b</a></p>)
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>)
end
test "preserves other attributes on the anchor" do
html = ~s(<a title="Docs" href="https://example.com/docs">docs</a>)
rewritten = ExternalLinks.rewrite(html)
assert rewritten =~ ~s(title="Docs")
assert rewritten =~ ~s(target="_blank" rel="noopener" class="blogex-external")
end
test "merges the marker class into an existing class attribute" do
html = ~s(<a class="fancy" href="https://example.com/">fancy</a>)
rewritten = ExternalLinks.rewrite(html)
assert rewritten =~ ~s(class="fancy blogex-external")
end
test "is idempotent — no duplicated attributes when applied twice" do
html = ~s(<a href="https://example.com/">once</a>)
once = ExternalLinks.rewrite(html)
twice = ExternalLinks.rewrite(once)
assert once == twice
end
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>)
assert ExternalLinks.rewrite(html) == html
end
test "mailto links are unchanged" do
html = ~s(<a href="mailto:hi@example.com">mail</a>)
assert ExternalLinks.rewrite(html) == html
end
test "anchor-only links are unchanged" do
html = ~s(<a href="#section">jump</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
test "URLs appearing in text but not in an href are unchanged" do
html = ~s(<p>read https://example.com/inline today</p>)
assert ExternalLinks.rewrite(html) == html
end
end
end
+17
View File
@@ -45,6 +45,23 @@ defmodule Blogex.PostTest do
assert post.body == html
end
test "adds target, rel and marker class to external links in stored body" do
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a></p>)
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>)
end
test "leaves internal links in stored body untouched" do
html = ~s(<p>See <a href="/blog/engineering/swim-the-test">Swim the Test</a></p>)
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
assert post.body == html
end
test "defaults published to true" do
post = Post.build("x/2026/01-01-x.md", valid_attrs(), "<p>x</p>")