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

")