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
+2 -1
View File
@@ -18,9 +18,10 @@ deps:
compile: compile:
@make -C app compile @make -C app compile
# Run tests # Run tests (app suite + blogex library suite)
test: test:
@make -C app test @make -C app test
@make -C blogex test
# Format code # Format code
format: format:
+23
View File
@@ -196,6 +196,29 @@ body { font-family: 'Source Sans 3', sans-serif; }
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; } .blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body code { font-size: 0.875em; } .blogex-post-body code { font-size: 0.875em; }
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; } .blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
/* External link marker on links rewritten by Blogex.ExternalLinks (class
`blogex-external`, added at compile time together with target="_blank").
Drawn with a mask so it inherits the link color. Empty ::after content is
invisible to screen readers. Icon shape: "box with arrow out of the top
right" (lucide/feather "external-link"). */
.blogex-post-body a.blogex-external::after {
content: "";
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.2em;
vertical-align: -0.05em;
opacity: 0.85;
background-color: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6'/%3E%3Cpath d='M10 14 21 3'/%3E%3Cpath d='M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E");
mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6'/%3E%3Cpath d='M10 14 21 3'/%3E%3Cpath d='M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
}
.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 {
@@ -35,6 +35,52 @@ defmodule FirehoseWeb.BlogTest do
end end
end end
describe "external links in compiled posts" do
# The marker markup is applied at compile time by Blogex.ExternalLinks
# (blogex/test/blogex/external_links_test.exs covers the rewrite itself);
# these tests verify the real compiled posts make it to the page.
defp latest_post_with_links,
do: Firehose.EngineeringBlog.get_post!("delayed-gratification-in-ai-summer")
test "wikipedia link carries target, rel and marker class in compiled body" do
post = latest_post_with_links()
assert post.body =~
~s(<a href="https://en.wikipedia.org/wiki/Attention_Is_All_You_Need" target="_blank" rel="noopener" class="blogex-external">)
end
test "every external anchor in compiled body is marked up" do
post = latest_post_with_links()
anchors = Regex.scan(~r/<a\s[^>]*>/, post.body) |> List.flatten()
external_anchors =
Enum.filter(anchors, &(&1 =~ ~r<href="https?://>))
assert length(external_anchors) > 1
assert Enum.all?(external_anchors, fn anchor ->
anchor =~ ~s(target="_blank") and
anchor =~ ~s(rel="noopener") and
anchor =~ ~s(class="blogex-external")
end)
end
test "internal links in compiled body are untouched" do
post = latest_post_with_links()
assert post.body =~
~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
end
test "rendered post page shows external links opening in a new tab", %{conn: conn} do
body =
conn |> get("/blog/engineering/delayed-gratification-in-ai-summer") |> html_response(200)
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
end
end
describe "input validation" do describe "input validation" do
test "GET /blog/nonexistent returns 404", %{conn: conn} do test "GET /blog/nonexistent returns 404", %{conn: conn} do
assert conn |> get("/blog/nonexistent") |> html_response(404) assert conn |> get("/blog/nonexistent") |> html_response(404)
+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. Build callback for NimblePublisher.
Extracts the date from the filename path and merges with frontmatter attrs. 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. The `blog` atom is injected by the parent Blog module.
""" """
def build(filename, attrs, body) do def build(filename, attrs, body) do
@@ -81,7 +83,7 @@ defmodule Blogex.Post do
struct!( struct!(
__MODULE__, __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
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 assert post.body == html
end 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 test "defaults published to true" do
post = Post.build("x/2026/01-01-x.md", valid_attrs(), "<p>x</p>") post = Post.build("x/2026/01-01-x.md", valid_attrs(), "<p>x</p>")