defmodule PhoenixKit.Modules.Sitemap.UrlEntry do
@moduledoc """
Struct representing a single URL entry in sitemap.
Used for both XML and HTML sitemap generation. Contains all necessary
metadata for proper sitemap formatting according to sitemaps.org protocol.
## Fields
- `loc` - Full URL (required)
- `lastmod` - Last modification date/time
- `changefreq` - Change frequency hint (weekly, daily, monthly, etc.)
- `priority` - Priority value 0.0-1.0
- `title` - Display title for HTML sitemap
- `category` - Category/group for organizing HTML sitemap
- `source` - Source module that generated this entry (:entities, :publishing, etc.)
- `alternates` - List of alternate language versions for hreflang (optional)
- `canonical_path` - Canonical path without language prefix (for grouping alternates)
## Alternates Format
Each alternate is a map with:
- `hreflang` - Language code (e.g., "en", "et", "x-default")
- `href` - Full URL for that language version
## Usage
entry = UrlEntry.new(%{
loc: "https://example.com/blog/my-post",
lastmod: ~U[2025-01-15 10:00:00Z],
changefreq: "weekly",
priority: 0.8,
title: "My Blog Post",
category: "Blog",
source: :publishing,
alternates: [
%{hreflang: "en", href: "https://example.com/blog/my-post"},
%{hreflang: "et", href: "https://example.com/et/blog/my-post"},
%{hreflang: "x-default", href: "https://example.com/blog/my-post"}
]
})
xml = UrlEntry.to_xml(entry)
"""
@type alternate :: %{hreflang: String.t(), href: String.t()}
@type t :: %__MODULE__{
loc: String.t(),
lastmod: DateTime.t() | Date.t() | NaiveDateTime.t() | nil,
changefreq: String.t() | nil,
priority: float() | String.t() | nil,
title: String.t() | nil,
category: String.t() | nil,
source: atom(),
alternates: [alternate()] | nil,
canonical_path: String.t() | nil
}
defstruct [
:loc,
:lastmod,
:changefreq,
:priority,
:title,
:category,
:source,
:alternates,
:canonical_path
]
@valid_changefreq ~w(always hourly daily weekly monthly yearly never)
@doc """
Creates a new UrlEntry struct from attributes.
## Examples
iex> UrlEntry.new(%{loc: "https://example.com/page"})
%UrlEntry{loc: "https://example.com/page"}
iex> UrlEntry.new(%{loc: "https://example.com", priority: 0.8, changefreq: "weekly"})
%UrlEntry{loc: "https://example.com", priority: 0.8, changefreq: "weekly"}
"""
@spec new(map()) :: t()
def new(attrs) when is_map(attrs) do
struct(__MODULE__, attrs)
end
@doc """
Converts a UrlEntry to XML format for sitemap.
Supports hreflang alternate links via xhtml:link elements when `alternates` is set.
## Examples
iex> entry = UrlEntry.new(%{loc: "https://example.com", lastmod: ~D[2025-01-15]})
iex> UrlEntry.to_xml(entry)
"\\n https://example.com\\n 2025-01-15\\n"
iex> entry = UrlEntry.new(%{
...> loc: "https://example.com/page",
...> alternates: [
...> %{hreflang: "en", href: "https://example.com/page"},
...> %{hreflang: "et", href: "https://example.com/et/page"}
...> ]
...> })
iex> UrlEntry.to_xml(entry) |> String.contains?("xhtml:link")
true
"""
@spec to_xml(t()) :: String.t()
def to_xml(%__MODULE__{} = entry) do
parts = [
" #{escape_xml(entry.loc)}"
]
parts =
if entry.lastmod do
parts ++ [" #{format_date(entry.lastmod)}"]
else
parts
end
parts =
if entry.changefreq && entry.changefreq in @valid_changefreq do
parts ++ [" #{entry.changefreq}"]
else
parts
end
parts =
if entry.priority do
priority_value = normalize_priority(entry.priority)
parts ++ [" #{priority_value}"]
else
parts
end
# Add hreflang alternate links if present
parts =
if entry.alternates && not Enum.empty?(entry.alternates) do
alternate_links =
Enum.map(entry.alternates, fn alt ->
~s( )
end)
parts ++ alternate_links
else
parts
end
"\n#{Enum.join(parts, "\n")}\n"
end
@doc """
Formats date/datetime to ISO8601 string for lastmod element.
"""
@spec format_date(DateTime.t() | Date.t() | NaiveDateTime.t() | nil) :: String.t() | nil
def format_date(nil), do: nil
def format_date(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
def format_date(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_iso8601(ndt)
def format_date(%Date{} = d), do: Date.to_iso8601(d)
def format_date(other) when is_binary(other) do
# Already a string, return as-is
other
end
def format_date(_), do: nil
@doc """
Normalizes priority value to a float between 0.0 and 1.0.
"""
@spec normalize_priority(float() | String.t() | nil) :: float()
def normalize_priority(nil), do: 0.5
def normalize_priority(priority) when is_float(priority) do
priority
|> max(0.0)
|> min(1.0)
|> Float.round(1)
end
def normalize_priority(priority) when is_binary(priority) do
case Float.parse(priority) do
{value, _} -> normalize_priority(value)
:error -> 0.5
end
end
def normalize_priority(priority) when is_integer(priority) do
normalize_priority(priority / 1.0)
end
def normalize_priority(_), do: 0.5
@doc """
Escapes XML special characters in a string.
"""
@spec escape_xml(String.t()) :: String.t()
def escape_xml(nil), do: ""
def escape_xml(str) when is_binary(str) do
str
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
|> String.replace("'", "'")
end
@doc """
Parses priority from various formats.
## Examples
iex> UrlEntry.parse_priority("0.8")
0.8
iex> UrlEntry.parse_priority(0.7)
0.7
iex> UrlEntry.parse_priority(nil)
nil
"""
@spec parse_priority(String.t() | float() | nil) :: float() | nil
def parse_priority(nil), do: nil
def parse_priority(priority) when is_float(priority), do: priority
def parse_priority(priority) when is_binary(priority) do
case Float.parse(priority) do
{value, _} -> value
:error -> nil
end
end
def parse_priority(_), do: nil
end