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
+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 code { font-size: 0.875em; }
.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-tag-list {
@@ -35,6 +35,52 @@ defmodule FirehoseWeb.BlogTest do
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
test "GET /blog/nonexistent returns 404", %{conn: conn} do
assert conn |> get("/blog/nonexistent") |> html_response(404)