Packages

phoenix_kit

2.52.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules sitemap url_entry.ex
Raw

lib/modules/sitemap/url_entry.ex

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)
      "<url>\\n  <loc>https://example.com</loc>\\n  <lastmod>2025-01-15</lastmod>\\n</url>"

      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 = [
      "  <loc>#{escape_xml(entry.loc)}</loc>"
    ]

    parts =
      if entry.lastmod do
        parts ++ ["  <lastmod>#{format_date(entry.lastmod)}</lastmod>"]
      else
        parts
      end

    parts =
      if entry.changefreq && entry.changefreq in @valid_changefreq do
        parts ++ ["  <changefreq>#{entry.changefreq}</changefreq>"]
      else
        parts
      end

    parts =
      if entry.priority do
        priority_value = normalize_priority(entry.priority)
        parts ++ ["  <priority>#{priority_value}</priority>"]
      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(  <xhtml:link rel="alternate" hreflang="#{alt.hreflang}" href="#{escape_xml(alt.href)}"/>)
          end)

        parts ++ alternate_links
      else
        parts
      end

    "<url>\n#{Enum.join(parts, "\n")}\n</url>"
  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("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&apos;")
  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

  @doc """
  Deduplicates entries by `loc`, keeping the richest description of each URL.

  A sitemap file must list a URL once, but more than one producer can arrive at
  the same `loc`. `RouterDiscovery` blindly enumerates every GET route and can
  emit the `loc` a content source (Publishing, Entities, ...) also emits with
  far better metadata; and under `DomainMode` a locale-prefixed clone route is
  re-hosted prefix-free onto that language's domain, landing on the home URL the
  static source already placed there.

  `Enum.uniq_by/2` alone is not enough: it keeps whichever entry happens to come
  first, which depends on source ordering rather than on which entry is actually
  richer.
  """
  @spec dedupe_by_loc([t()]) :: [t()]
  def dedupe_by_loc(entries) do
    entries
    |> Enum.group_by(& &1.loc)
    |> Enum.map(fn {_loc, group} -> Enum.max_by(group, &richness/1) end)
  end

  @doc """
  Ranks an entry for a same-`loc` collision; higher wins.

  A `RouterDiscovery` entry always scores lowest, so any other source wins
  regardless of its own priority: route discovery carries only generic metadata
  (priority 0.5, no hreflang) and must never mask an authoritative content-source
  entry. Among the rest, the entry describing the URL more fully wins.
  """
  @spec richness(t()) :: number()
  def richness(%__MODULE__{source: :router_discovery}), do: 0

  def richness(%__MODULE__{} = entry) do
    canonical_bonus = if entry.canonical_path in [nil, ""], do: 0, else: 2
    alternates_bonus = if entry.alternates in [nil, []], do: 0, else: 2

    1 + canonical_bonus + alternates_bonus + (parse_priority(entry.priority) || 0.0)
  end
end