diff --git a/app/priv/blog/engineering/2026/07-18-does-ai-assistance-support-tiny-open-source-projects.md b/app/priv/blog/engineering/2026/07-18-does-ai-assistance-support-tiny-open-source-projects.md index ad4dda0..15e995c 100644 --- a/app/priv/blog/engineering/2026/07-18-does-ai-assistance-support-tiny-open-source-projects.md +++ b/app/priv/blog/engineering/2026/07-18-does-ai-assistance-support-tiny-open-source-projects.md @@ -23,7 +23,7 @@ As mentioned above, it is not something I do often. When working in client proje Making two pull requests in one go --- -So how do I make two pull requests out of it? I ask [my assistant](/blog/engineering/tag/Pi.dev), learning something in the process: +So how do I make two pull requests out of it? I ask [my assistant](/blog/engineering/tag/pi.dev), learning something in the process: > Now I have two commits. what if I wanted to make a Pull Request on github for each commit separately? diff --git a/app/priv/blog/engineering/2026/07-28-learning-to-swim-test.md b/app/priv/blog/engineering/2026/07-28-learning-to-swim-test.md index 70266fd..59c8f3d 100644 --- a/app/priv/blog/engineering/2026/07-28-learning-to-swim-test.md +++ b/app/priv/blog/engineering/2026/07-28-learning-to-swim-test.md @@ -102,7 +102,7 @@ I had an integration test that could serve as a starting point. But the workflow 5. Add more steps 6. Go to 4. -For 1. I found a forum post, and I already had some [Synthetic](/blog/engineering/2026/07-13-synthetic-tdd.md) tests. This was also a good opportunity to re-read [the documentation](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveViewTest.html). Rendering pages, components, selecting elements and getting the text back is all built in, so all we need to do is wrap it in a page and save the parts. I did consider writing the reports out as markdown, with html snippets at some point. When rendering html to a pdf, the page breaks happen in the middle of screenshots sometimes. But the PDF already lacks the styling. +For 1. I found a forum post, and I already had some [Synthetic](/blog/engineering/synthetic-tdd) tests. This was also a good opportunity to re-read [the documentation](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveViewTest.html). Rendering pages, components, selecting elements and getting the text back is all built in, so all we need to do is wrap it in a page and save the parts. I did consider writing the reports out as markdown, with html snippets at some point. When rendering html to a pdf, the page breaks happen in the middle of screenshots sometimes. But the PDF already lacks the styling. I initially iterated with [Pi](/blog/engineering/tag/pi.dev) on how to collect tests. I had a fancy idea of collecting the various dialogs in a process (well supported e.g. by Elixir GenServers), then thought of doing it the unix way (write out dialogs, than `cat` them all together), and ended up collecting step outputs in a list, and rendering the list at the end. This did require re-ordering the test a bit: the `assert` has to come at the end, after creating the report. diff --git a/blogex/lib/blogex/link_validator.ex b/blogex/lib/blogex/link_validator.ex index 36e6ef5..1ca90b6 100644 --- a/blogex/lib/blogex/link_validator.ex +++ b/blogex/lib/blogex/link_validator.ex @@ -21,6 +21,12 @@ defmodule Blogex.LinkValidator do * Must not contain consecutive hyphens * Query strings and anchor fragments are allowed after the slug + ## Tag page links + + * `/blog/{blog_id}/tag/{tag}` links point to tag pages + * The tag may not be empty; otherwise any non-empty tag is accepted + (tags are user-defined and may contain dots, uppercase letters, etc.) + ## Usage # Validate a single link @@ -50,21 +56,34 @@ defmodule Blogex.LinkValidator do `/blog/{engineering|releases}/{slug}`. External links and non-blog internal links are ignored. - Handles markdown link syntax `[text](url)`. + Handles both markdown link syntax `[text](url)` and HTML ``. + (Post bodies are HTML, rendered by NimblePublisher at compile time.) ## Examples iex> extract_links("[link](/blog/engineering/post)") ["/blog/engineering/post"] + iex> extract_links("

link

") + ["/blog/engineering/post"] + iex> extract_links("See [GitHub](https://github.com)") [] """ @spec extract_links(String.t()) :: [String.t()] def extract_links(body) when is_binary(body) do - ~r/\[([^\]]+)\]\(([^)]+)\)/ - |> Regex.scan(body) - |> Enum.map(fn [_, _, path] -> path end) + markdown_links = + ~r/\[([^\]]+)\]\(([^)]+)\)/ + |> Regex.scan(body) + |> Enum.map(fn [_, _, path] -> path end) + + html_links = + ~r/ Regex.scan(body) + |> Enum.map(fn [_, path] -> path end) + + (markdown_links ++ html_links) + |> Enum.uniq() |> Enum.filter(&internal_blog_link?/1) end @@ -90,6 +109,9 @@ defmodule Blogex.LinkValidator do iex> validate_link("/blog/engineering/My-Post") {:error, "slug must be lowercase alphanumeric with hyphens: My-Post"} + + iex> validate_link("/blog/engineering/tag/pi.dev") + :ok """ @spec validate_link(String.t()) :: :ok | {:error, String.t()} def validate_link(link) when is_binary(link) do @@ -99,7 +121,13 @@ defmodule Blogex.LinkValidator do {blog_id_str, slug_part} -> case Map.fetch(@valid_blog_ids, blog_id_str) do - {:ok, _blog_atom} -> validate_slug(slug_part) + {:ok, _blog_atom} -> + if String.starts_with?(slug_part, "tag/") do + validate_tag(String.replace_prefix(slug_part, "tag/", "")) + else + validate_slug(slug_part) + end + :error -> {:error, "unknown blog ID: #{blog_id_str}"} end end @@ -209,4 +237,12 @@ defmodule Blogex.LinkValidator do {:error, "slug must be lowercase alphanumeric with hyphens: #{slug}"} end end + + @doc false + @spec validate_tag(String.t()) :: :ok | {:error, String.t()} + defp validate_tag(tag) when tag == "" do + {:error, "empty tag in tag link"} + end + + defp validate_tag(_tag), do: :ok end diff --git a/blogex/test/blogex/blog_integration_test.exs b/blogex/test/blogex/blog_integration_test.exs index 5e6ee61..351bf5c 100644 --- a/blogex/test/blogex/blog_integration_test.exs +++ b/blogex/test/blogex/blog_integration_test.exs @@ -59,7 +59,7 @@ defmodule Blogex.BlogIntegrationTest do """) [{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file) - assert TestBlogValid.title() == "Test Blog" + assert apply(TestBlogValid, :title, []) == "Test Blog" File.rm!(tmp_file) end diff --git a/blogex/test/blogex/link_validator_test.exs b/blogex/test/blogex/link_validator_test.exs index a68017c..3c42010 100644 --- a/blogex/test/blogex/link_validator_test.exs +++ b/blogex/test/blogex/link_validator_test.exs @@ -13,6 +13,22 @@ defmodule Blogex.LinkValidatorTest do ] end + test "extracts internal blog links from HTML body" do + body = + ~s(

Check out hello world and release v1.

) + + assert LinkValidator.extract_links(body) == [ + "/blog/engineering/hello-world", + "/blog/releases/v1-0-0" + ] + end + + test "extracts tag page links from HTML body" do + body = ~s(

See pi.dev posts.

) + + assert LinkValidator.extract_links(body) == ["/blog/engineering/tag/pi.dev"] + end + test "ignores external links" do body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)." @@ -132,6 +148,25 @@ defmodule Blogex.LinkValidatorTest do assert LinkValidator.validate_link("/blog/engineering/post#section") == :ok end + test "allows tag page links" do + assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev") == :ok + assert LinkValidator.validate_link("/blog/releases/tag/v1") == :ok + end + + test "allows tag page links with fragments" do + assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev#top") == :ok + end + + test "rejects tag page link with empty tag" do + assert LinkValidator.validate_link("/blog/engineering/tag/") == + {:error, "empty tag in tag link"} + end + + test "rejects unknown blog ID in tag page link" do + assert LinkValidator.validate_link("/blog/unknown/tag/pi.dev") == + {:error, "unknown blog ID: unknown"} + end + test "rejects non-blog path" do assert LinkValidator.validate_link("/about") == {:error, "not a blog link: /about"} diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs index cfc7c72..bacbbdd 100644 --- a/blogex/test/blogex/registry_test.exs +++ b/blogex/test/blogex/registry_test.exs @@ -1,7 +1,6 @@ defmodule Blogex.RegistryTest do use ExUnit.Case - import Blogex.Test.PostBuilder alias Blogex.Registry defmodule AlphaBlog do diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs index 004bf75..90edb51 100644 --- a/blogex/test/blogex/router_test.exs +++ b/blogex/test/blogex/router_test.exs @@ -1,6 +1,6 @@ defmodule Blogex.RouterTest do use ExUnit.Case - use Plug.Test + import Plug.Test import Blogex.Test.PostBuilder alias Blogex.Test.FakeBlog