263 lines
7.8 KiB
Elixir
263 lines
7.8 KiB
Elixir
defmodule Blogex.LinkValidator do
|
|
@moduledoc """
|
|
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
|
|
expected format (lowercase alphanumeric with hyphens).
|
|
|
|
This module is pure and does not depend on any stored posts — it validates
|
|
link format and semantics only. Post existence checking is handled separately.
|
|
|
|
## Valid blog IDs
|
|
|
|
* `:engineering` — maps to `/blog/engineering/`
|
|
* `:release_notes` — maps to `/blog/releases/`
|
|
|
|
## Valid slug format
|
|
|
|
* Lowercase alphanumeric characters and hyphens only
|
|
* Must not start or end with a hyphen
|
|
* 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
|
|
LinkValidator.validate_link("/blog/engineering/hello-world")
|
|
# => :ok
|
|
|
|
# Validate multiple links with line numbers
|
|
LinkValidator.validate_links(["/blog/engineering/a", "/blog/bad/b"])
|
|
# => {:error, [{2, "/blog/bad/b", "unknown blog ID: bad"}]}
|
|
|
|
# Validate all links in a post body
|
|
LinkValidator.validate_body(body, :engineering, post_id: "my-post")
|
|
# => :ok or {:error, [...]}
|
|
"""
|
|
|
|
@valid_blog_ids %{
|
|
"engineering" => :engineering,
|
|
"releases" => :release_notes
|
|
}
|
|
|
|
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
|
|
@doc """
|
|
Extracts internal blog links from a post body.
|
|
|
|
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.
|
|
|
|
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>")
|
|
["/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)")
|
|
[]
|
|
"""
|
|
@spec extract_links(String.t()) :: [String.t()]
|
|
def extract_links(body) when is_binary(body) do
|
|
(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
|
|
nil -> false
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates a single link path.
|
|
|
|
Returns `:ok` if the link has valid semantics, or `{:error, reason}` otherwise.
|
|
|
|
## Examples
|
|
|
|
iex> validate_link("/blog/engineering/hello-world")
|
|
:ok
|
|
|
|
iex> validate_link("/blog/unknown/post")
|
|
{:error, "unknown blog ID: unknown"}
|
|
|
|
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
|
|
case parse_blog_link(link) do
|
|
nil ->
|
|
{:error, "not a blog link: #{link}"}
|
|
|
|
{blog_id_str, slug_part} ->
|
|
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
|
{: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
|
|
end
|
|
|
|
@doc """
|
|
Validates a list of links, returning errors with line numbers.
|
|
|
|
Returns `:ok` if all links are valid, or `{:error, errors}` where each
|
|
error is `{line_number, link, reason}`.
|
|
|
|
## Examples
|
|
|
|
iex> validate_links(["/blog/engineering/a", "/blog/bad/b"])
|
|
{:error, [{2, "/blog/bad/b", "unknown blog ID: bad"}]}
|
|
"""
|
|
@spec validate_links([String.t()]) :: :ok | {:error, [{integer(), String.t(), String.t()}]}
|
|
def validate_links(links) when is_list(links) do
|
|
links
|
|
|> Enum.with_index(1)
|
|
|> Enum.map(fn {link, line} ->
|
|
case validate_link(link) do
|
|
:ok -> nil
|
|
{:error, reason} -> {line, link, reason}
|
|
end
|
|
end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|> case do
|
|
[] -> :ok
|
|
errors -> {:error, errors}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates all internal blog links in a post body.
|
|
|
|
Extracts links from the body, validates each one, and returns the result
|
|
with optional post context.
|
|
|
|
## Options
|
|
|
|
* `:post_id` — the post slug/id, included in error tuples for debugging
|
|
|
|
## Examples
|
|
|
|
iex> validate_body("<p>[link](/blog/engineering/post)</p>", :engineering)
|
|
:ok
|
|
|
|
iex> validate_body("<p>[link](/blog/unknown/post)</p>", :engineering)
|
|
{:error, [{1, "/blog/unknown/post", "unknown blog ID: unknown", post_id: nil}]}
|
|
|
|
iex> validate_body("<p>[link](/blog/unknown/post)</p>", :engineering, post_id: "my-post")
|
|
{:error, [{1, "/blog/unknown/post", "unknown blog ID: unknown", post_id: "my-post"}]}
|
|
"""
|
|
@spec validate_body(String.t(), atom(), keyword()) ::
|
|
:ok | {:error, [{integer(), String.t(), String.t(), keyword()}]}
|
|
def validate_body(body, blog, opts \\ []) when is_binary(body) and is_atom(blog) do
|
|
post_id = Keyword.get(opts, :post_id)
|
|
links = extract_links(body)
|
|
|
|
case validate_links(links) do
|
|
:ok ->
|
|
:ok
|
|
|
|
{:error, errors} ->
|
|
enriched_errors =
|
|
Enum.map(errors, fn {line, link, reason} ->
|
|
{line, link, reason, post_id: post_id}
|
|
end)
|
|
|
|
{:error, enriched_errors}
|
|
end
|
|
end
|
|
|
|
# --- Private helpers ---
|
|
|
|
@doc false
|
|
@spec parse_blog_link(String.t()) :: {String.t(), String.t()} | nil
|
|
def parse_blog_link(path) do
|
|
with ["", "blog", blog_id, rest] <- String.split(path, "/", parts: 4) do
|
|
slug = String.slice(rest, 0, slug_end(rest))
|
|
{blog_id, slug}
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
@spec slug_end(String.t()) :: integer()
|
|
defp slug_end(str) do
|
|
# `String.split/3` always returns a non-empty list whose head is a binary,
|
|
# so there is no empty/other case to handle here.
|
|
[slug | _] = String.split(str, ["?", "#"], parts: 2)
|
|
String.length(slug)
|
|
end
|
|
|
|
@doc false
|
|
@spec validate_slug(String.t()) :: :ok | {:error, String.t()}
|
|
defp validate_slug(slug) when slug == "" do
|
|
{:error, "empty slug"}
|
|
end
|
|
|
|
defp validate_slug(slug) do
|
|
if Regex.match?(@slug_regex, slug) do
|
|
:ok
|
|
else
|
|
{: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
|