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:
@@ -18,9 +18,10 @@ deps:
|
||||
compile:
|
||||
@make -C app compile
|
||||
|
||||
# Run tests
|
||||
# Run tests (app suite + blogex library suite)
|
||||
test:
|
||||
@make -C app test
|
||||
@make -C blogex test
|
||||
|
||||
# Format code
|
||||
format:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user