diff --git a/Makefile b/Makefile
index 6888d38..a368b87 100644
--- a/Makefile
+++ b/Makefile
@@ -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:
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index 5425fd3..22c532b 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -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 {
diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs
index 6826289..5a5f934 100644
--- a/app/test/firehose_web/controllers/blog_test.exs
+++ b/app/test/firehose_web/controllers/blog_test.exs
@@ -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()
+ end
+
+ test "every external anchor in compiled body is marked up" do
+ post = latest_post_with_links()
+
+ anchors = Regex.scan(~r/]*>/, post.body) |> List.flatten()
+
+ external_anchors =
+ Enum.filter(anchors, &(&1 =~ ~r))
+
+ 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(Swim the Test)
+ 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)
diff --git a/blogex/Makefile b/blogex/Makefile
new file mode 100644
index 0000000..a38fd0b
--- /dev/null
+++ b/blogex/Makefile
@@ -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
\ No newline at end of file
diff --git a/blogex/lib/blogex/external_links.ex b/blogex/lib/blogex/external_links.ex
new file mode 100644
index 0000000..549390b
--- /dev/null
+++ b/blogex/lib/blogex/external_links.ex
@@ -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/]*>/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
diff --git a/blogex/lib/blogex/post.ex b/blogex/lib/blogex/post.ex
index d484912..4a994c2 100644
--- a/blogex/lib/blogex/post.ex
+++ b/blogex/lib/blogex/post.ex
@@ -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
diff --git a/blogex/test/blogex/external_links_test.exs b/blogex/test/blogex/external_links_test.exs
new file mode 100644
index 0000000..6c25bf8
--- /dev/null
+++ b/blogex/test/blogex/external_links_test.exs
@@ -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(See Wikipedia.
)
+
+ rewritten = ExternalLinks.rewrite(html)
+
+ assert rewritten ==
+ ~s(See Wikipedia.
)
+ end
+
+ test "also rewrites external http links" do
+ rewritten = ExternalLinks.rewrite(~s(about))
+
+ 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(
a and b
)
+
+ rewritten = ExternalLinks.rewrite(html)
+
+ assert rewritten ==
+ ~s(a and b
)
+ end
+
+ test "preserves other attributes on the anchor" do
+ html = ~s(docs)
+
+ 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(fancy)
+
+ rewritten = ExternalLinks.rewrite(html)
+
+ assert rewritten =~ ~s(class="fancy blogex-external")
+ end
+
+ test "is idempotent — no duplicated attributes when applied twice" do
+ html = ~s(once)
+
+ 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(Swim the Test)
+
+ assert ExternalLinks.rewrite(html) == html
+ end
+
+ test "mailto links are unchanged" do
+ html = ~s(mail)
+
+ assert ExternalLinks.rewrite(html) == html
+ end
+
+ test "anchor-only links are unchanged" do
+ html = ~s(jump)
+
+ assert ExternalLinks.rewrite(html) == html
+ end
+
+ test "plain text without anchors is unchanged" do
+ assert ExternalLinks.rewrite("no links here
") == "no links here
"
+ end
+
+ test "URLs appearing in text but not in an href are unchanged" do
+ html = ~s(read https://example.com/inline today
)
+
+ assert ExternalLinks.rewrite(html) == html
+ end
+ end
+end
diff --git a/blogex/test/blogex/post_test.exs b/blogex/test/blogex/post_test.exs
index bf996fc..e3c62ef 100644
--- a/blogex/test/blogex/post_test.exs
+++ b/blogex/test/blogex/post_test.exs
@@ -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(See Wikipedia
)
+
+ post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
+
+ assert post.body =~
+ ~s(Wikipedia)
+ end
+
+ test "leaves internal links in stored body untouched" do
+ html = ~s(See Swim the Test
)
+
+ 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(), "x
")