yak(link-validator-never-sees-compiled-html-internal-link-validation-is-vacuous-ddd1): Link validator never sees compiled HTML — internal-link validation is vacuous

This commit is contained in:
Willem van den Ende
2026-10-28 22:08:45 +00:00
parent 4e7798c906
commit c04d8c856c
8 changed files with 175 additions and 19 deletions
+32 -19
View File
@@ -1,6 +1,12 @@
defmodule Blogex.LinkValidator do
@moduledoc """
Validates internal blog link semantics in markdown post bodies.
Validates internal blog link semantics in compiled post bodies.
Post bodies reach this module as HTML: `Blogex.Blog` validates them in
`@before_compile`, after NimblePublisher has rendered the markdown. Markdown
link syntax is still understood so bodies and snippets can be validated
before rendering, but the HTML `<a href="…">` anchors are the ones that
matter in the real pipeline.
Checks that internal links follow the pattern `/blog/{blog_id}/{slug}`
where `blog_id` is a valid blog identifier and `slug` matches the
@@ -50,21 +56,25 @@ defmodule Blogex.LinkValidator do
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
@doc """
Extracts internal blog links from a markdown body.
Extracts internal blog links from a post body.
Returns a list of link paths (strings) that match the pattern
`/blog/{engineering|releases}/{slug}`. External links and non-blog
internal links are ignored.
Returns a list of link paths (strings) that look like `/blog/…` links.
External links and non-blog internal links are ignored. Blog IDs and slugs
are *not* judged here — `validate_link/1` classifies those.
Handles both markdown link syntax `[text](url)` and HTML `<a href="url">`.
(Post bodies are HTML, rendered by NimblePublisher at compile time.)
HTML anchors are read with Floki, so attribute order, quoting and extra
attributes do not matter. Markdown link syntax `[text](url)` is extracted
too (raw markdown bodies and snippets), and the two scans are merged.
## Examples
iex> extract_links("[link](/blog/engineering/post)")
["/blog/engineering/post"]
iex> extract_links("<p><a href=\"/blog/engineering/post\">link</a></p>")
iex> extract_links("<p><a href=\\"/blog/engineering/post\\">link</a></p>")
["/blog/engineering/post"]
iex> extract_links("<p><a class=\\"x\\" href='/blog/engineering/post'>link</a></p>")
["/blog/engineering/post"]
iex> extract_links("See [GitHub](https://github.com)")
@@ -72,21 +82,24 @@ defmodule Blogex.LinkValidator do
"""
@spec extract_links(String.t()) :: [String.t()]
def extract_links(body) when is_binary(body) do
markdown_links =
~r/\[([^\]]+)\]\(([^)]+)\)/
|> Regex.scan(body)
|> 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)
(markdown_links(body) ++ html_links(body))
|> Enum.uniq()
|> Enum.filter(&internal_blog_link?/1)
end
defp markdown_links(body) do
~r/\[([^\]]+)\]\(([^)]+)\)/
|> Regex.scan(body)
|> Enum.map(fn [_, _, path] -> path end)
end
defp html_links(body) do
body
|> Floki.parse_document!()
|> Floki.find("a[href]")
|> Enum.flat_map(&Floki.attribute(&1, "href"))
end
defp internal_blog_link?(path) do
case parse_blog_link(path) do
{_, _} -> true