Fix compile-time link validation and add tag page links
- Restore HTML link extraction in LinkValidator (removed in a83634d
under the false premise that post bodies are raw markdown; they
are HTML rendered by NimblePublisher at compile time). The missing
regex made extract_links/1 find zero links, silently disabling
compile-time validation.
- Support /blog/{blog_id}/tag/{tag} links: validate blog ID,
require non-empty tag (tags are user-defined, e.g. pi.dev).
- Fix invalid links in two posts: tag/Pi.dev -> tag/pi.dev,
2026-07-13-synthetic-tdd.md -> synthetic-tdd.
- Fix test warnings: use Plug.Test deprecation, unused import,
runtime-defined TestBlogValid module.
- Add regression tests for HTML extraction and tag page links.
This commit is contained in:
+1
-1
@@ -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
|
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?
|
> Now I have two commits. what if I wanted to make a Pull Request on github for each commit separately?
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ I had an integration test that could serve as a starting point. But the workflow
|
|||||||
5. Add more steps
|
5. Add more steps
|
||||||
6. Go to 4.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ defmodule Blogex.LinkValidator do
|
|||||||
* Must not contain consecutive hyphens
|
* Must not contain consecutive hyphens
|
||||||
* Query strings and anchor fragments are allowed after the slug
|
* 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
|
## Usage
|
||||||
|
|
||||||
# Validate a single link
|
# Validate a single link
|
||||||
@@ -50,21 +56,34 @@ defmodule Blogex.LinkValidator do
|
|||||||
`/blog/{engineering|releases}/{slug}`. External links and non-blog
|
`/blog/{engineering|releases}/{slug}`. External links and non-blog
|
||||||
internal links are ignored.
|
internal links are ignored.
|
||||||
|
|
||||||
Handles markdown link syntax `[text](url)`.
|
Handles both markdown link syntax `[text](url)` and HTML `<a href="url">`.
|
||||||
|
(Post bodies are HTML, rendered by NimblePublisher at compile time.)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> extract_links("[link](/blog/engineering/post)")
|
iex> extract_links("[link](/blog/engineering/post)")
|
||||||
["/blog/engineering/post"]
|
["/blog/engineering/post"]
|
||||||
|
|
||||||
|
iex> extract_links("<p><a href=\"/blog/engineering/post\">link</a></p>")
|
||||||
|
["/blog/engineering/post"]
|
||||||
|
|
||||||
iex> extract_links("See [GitHub](https://github.com)")
|
iex> extract_links("See [GitHub](https://github.com)")
|
||||||
[]
|
[]
|
||||||
"""
|
"""
|
||||||
@spec extract_links(String.t()) :: [String.t()]
|
@spec extract_links(String.t()) :: [String.t()]
|
||||||
def extract_links(body) when is_binary(body) do
|
def extract_links(body) when is_binary(body) do
|
||||||
|
markdown_links =
|
||||||
~r/\[([^\]]+)\]\(([^)]+)\)/
|
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||||
|> Regex.scan(body)
|
|> Regex.scan(body)
|
||||||
|> Enum.map(fn [_, _, path] -> path end)
|
|> Enum.map(fn [_, _, path] -> path end)
|
||||||
|
|
||||||
|
html_links =
|
||||||
|
~r/<a\s+href=["']([^"']*)["']/i
|
||||||
|
|> Regex.scan(body)
|
||||||
|
|> Enum.map(fn [_, path] -> path end)
|
||||||
|
|
||||||
|
(markdown_links ++ html_links)
|
||||||
|
|> Enum.uniq()
|
||||||
|> Enum.filter(&internal_blog_link?/1)
|
|> Enum.filter(&internal_blog_link?/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -90,6 +109,9 @@ defmodule Blogex.LinkValidator do
|
|||||||
|
|
||||||
iex> validate_link("/blog/engineering/My-Post")
|
iex> validate_link("/blog/engineering/My-Post")
|
||||||
{:error, "slug must be lowercase alphanumeric with hyphens: 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()}
|
@spec validate_link(String.t()) :: :ok | {:error, String.t()}
|
||||||
def validate_link(link) when is_binary(link) do
|
def validate_link(link) when is_binary(link) do
|
||||||
@@ -99,7 +121,13 @@ defmodule Blogex.LinkValidator do
|
|||||||
|
|
||||||
{blog_id_str, slug_part} ->
|
{blog_id_str, slug_part} ->
|
||||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
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}"}
|
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -209,4 +237,12 @@ defmodule Blogex.LinkValidator do
|
|||||||
{:error, "slug must be lowercase alphanumeric with hyphens: #{slug}"}
|
{:error, "slug must be lowercase alphanumeric with hyphens: #{slug}"}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ defmodule Blogex.BlogIntegrationTest do
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
[{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
[{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||||
assert TestBlogValid.title() == "Test Blog"
|
assert apply(TestBlogValid, :title, []) == "Test Blog"
|
||||||
|
|
||||||
File.rm!(tmp_file)
|
File.rm!(tmp_file)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ defmodule Blogex.LinkValidatorTest do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "extracts internal blog links from HTML body" do
|
||||||
|
body =
|
||||||
|
~s(<p>Check out <a href="/blog/engineering/hello-world">hello world</a> and <a href="/blog/releases/v1-0-0">release v1</a>.</p>)
|
||||||
|
|
||||||
|
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(<p>See <a href="/blog/engineering/tag/pi.dev">pi.dev posts</a>.</p>)
|
||||||
|
|
||||||
|
assert LinkValidator.extract_links(body) == ["/blog/engineering/tag/pi.dev"]
|
||||||
|
end
|
||||||
|
|
||||||
test "ignores external links" do
|
test "ignores external links" do
|
||||||
body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)."
|
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
|
assert LinkValidator.validate_link("/blog/engineering/post#section") == :ok
|
||||||
end
|
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
|
test "rejects non-blog path" do
|
||||||
assert LinkValidator.validate_link("/about") ==
|
assert LinkValidator.validate_link("/about") ==
|
||||||
{:error, "not a blog link: /about"}
|
{:error, "not a blog link: /about"}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
defmodule Blogex.RegistryTest do
|
defmodule Blogex.RegistryTest do
|
||||||
use ExUnit.Case
|
use ExUnit.Case
|
||||||
|
|
||||||
import Blogex.Test.PostBuilder
|
|
||||||
alias Blogex.Registry
|
alias Blogex.Registry
|
||||||
|
|
||||||
defmodule AlphaBlog do
|
defmodule AlphaBlog do
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
defmodule Blogex.RouterTest do
|
defmodule Blogex.RouterTest do
|
||||||
use ExUnit.Case
|
use ExUnit.Case
|
||||||
use Plug.Test
|
import Plug.Test
|
||||||
|
|
||||||
import Blogex.Test.PostBuilder
|
import Blogex.Test.PostBuilder
|
||||||
alias Blogex.Test.FakeBlog
|
alias Blogex.Test.FakeBlog
|
||||||
|
|||||||
Reference in New Issue
Block a user