Packages

phoenix_kit

2.41.0
2.62.2 2.62.1 2.62.0 2.61.0 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 sources static.ex
Raw

lib/modules/sitemap/sources/static.ex

defmodule PhoenixKit.Modules.Sitemap.Sources.Static do
  @moduledoc """
  Static routes source for sitemap generation.

  Collects configurable static routes for the sitemap. Routes are configured
  through Settings and resolved via RouteResolver - NO hardcoded fallbacks.

  ## Settings

  - `sitemap_static_routes` - JSON array of route configurations
  - `sitemap_custom_urls` - JSON array of custom URL entries

  ## Route Configuration Format

  Each route in `sitemap_static_routes` can have:

      %{
        "plug" => "PhoenixKitWeb.Users.Registration",  # Module to resolve via RouteResolver
        "path" => "/custom/path",                       # OR explicit path (overrides plug)
        "priority" => 0.7,                              # Sitemap priority (0.0-1.0)
        "changefreq" => "monthly",                      # Change frequency
        "title" => "Register",                          # Display title
        "category" => "Authentication",                 # Category for grouping
        "prefixed" => true                              # Use PhoenixKit URL prefix
      }

  ## Custom URL Format

  Each entry in `sitemap_custom_urls`:

      %{
        "path" => "/about-us",
        "priority" => 0.8,
        "changefreq" => "monthly",
        "title" => "About Us",
        "category" => "Company"
      }

  ## Default Configuration

  By default, includes:
  - Homepage (/) - Priority: 0.9, daily
  - Registration page - Priority: 0.7, monthly (if route exists)
  - Login page - Priority: 0.7, monthly (if route exists)

  ## No Hardcoded Fallbacks

  If RouteResolver cannot find a route and no explicit path is configured,
  the route is skipped. This ensures sitemap only contains valid URLs.
  """

  @behaviour PhoenixKit.Modules.Sitemap.Sources.Source

  alias PhoenixKit.Modules.Languages
  alias PhoenixKit.Modules.Sitemap.DomainMode
  alias PhoenixKit.Modules.Sitemap.LocalePath
  alias PhoenixKit.Modules.Sitemap.RouteResolver
  alias PhoenixKit.Modules.Sitemap.UrlEntry
  alias PhoenixKit.Settings
  alias PhoenixKit.Utils.Routes

  # Login is ALWAYS excluded from sitemap (auth pages shouldn't be indexed).
  # Registration is conditional via sitemap_include_registration setting.
  @default_static_routes [
    %{
      "path" => "/",
      "priority" => 0.9,
      "changefreq" => "daily",
      "title" => "Home",
      "category" => "Main",
      "prefixed" => false,
      # Don't add language prefix to homepage - it usually doesn't have localized route
      "skip_language_prefix" => true
    }
  ]

  @registration_route %{
    "path" => "/users/register",
    "priority" => 0.7,
    "changefreq" => "monthly",
    "title" => "Register",
    "category" => "Authentication",
    "prefixed" => true
  }

  @impl true
  def source_name, do: :static

  @impl true
  def enabled?, do: true

  @impl true
  def sitemap_filename, do: "sitemap-static"

  @doc """
  Returns the built-in default static routes (always includes the homepage).

  These apply whenever `sitemap_static_routes` is unset. Once that setting
  is saved (even as an empty list), it replaces this list entirely — so
  saving `[]` removes the homepage entry too. Exposed so the settings UI can
  pre-fill the editor with the current effective routes instead of an empty
  list that looks safe to submit as-is but isn't.
  """
  @spec default_static_routes() :: [map()]
  def default_static_routes, do: @default_static_routes

  @impl true
  def collect(opts \\ []) do
    is_default = Keyword.get(opts, :is_default_language, true)
    language = Keyword.get(opts, :language)
    # Only the domain pass asks for these: the URL built for a domain-hosted
    # language is a locale-prefixed INTERMEDIATE that `DomainMode` re-hosts
    # prefix-free onto that language's domain. It must never reach the legacy
    # set (which unmapped hosts are served verbatim), where nothing would
    # rewrite it and `https://<primary>/fr/` is usually not a real page.
    #
    # The default language never needs the lookup either: it is emitted either
    # way and keeps `skip_language_prefix` regardless.
    domain_hosted? =
      Keyword.get(opts, :domain_pass, false) and not is_default and
        domain_hosted_language?(language)

    # Static pages only generate URLs for the default language: in a
    # prefix-based install a "/de/..." static page would 404.
    #
    # A language with a domain of its own is the exception — it serves these
    # pages from that host, prefix-free, so they are real 200s. Without this
    # every non-primary domain's sitemap loses its own home page while still
    # listing its products. The prefixed URL built here is what DomainMode
    # re-hosts onto the language's domain.
    if is_default or domain_hosted? do
      base_url = Keyword.get(opts, :base_url)

      static_entries = collect_static_routes(base_url, language, is_default, domain_hosted?)
      custom_entries = collect_custom_urls(base_url, language, is_default)

      (static_entries ++ custom_entries)
      |> Enum.reject(&is_nil/1)
    else
      []
    end
  rescue
    error ->
      require Logger
      Logger.warning("Static routes sitemap source failed to collect: #{inspect(error)}")
      []
  end

  defp collect_static_routes(base_url, language, is_default, domain_hosted?) do
    get_static_routes_config()
    |> Enum.map(&build_static_entry(&1, base_url, language, is_default, domain_hosted?))
  end

  # True when `language` has a domain of its own under domain mode. Resolved
  # once per collect/1 — DomainMode reads (and validates) the host app's
  # provider on every call.
  defp domain_hosted_language?(nil), do: false

  defp domain_hosted_language?(language) do
    base = Languages.DialectMapper.extract_base(language)
    Enum.any?(DomainMode.domains(), &(&1.language == base))
  rescue
    _ -> false
  end

  defp collect_custom_urls(base_url, language, is_default) do
    get_custom_urls_config()
    |> Enum.map(&build_custom_entry(&1, base_url, language, is_default))
  end

  defp get_static_routes_config do
    base_routes =
      case Settings.get_setting("sitemap_static_routes") do
        nil ->
          @default_static_routes

        json_string when is_binary(json_string) ->
          case JSON.decode(json_string) do
            {:ok, routes} when is_list(routes) -> routes
            _ -> @default_static_routes
          end

        routes when is_list(routes) ->
          routes

        _ ->
          @default_static_routes
      end

    # Conditionally include registration page
    if include_registration?() do
      base_routes ++ [@registration_route]
    else
      base_routes
    end
  end

  defp include_registration? do
    Settings.get_boolean_setting("sitemap_include_registration", false)
  rescue
    _ -> false
  end

  defp get_custom_urls_config do
    case Settings.get_setting("sitemap_custom_urls") do
      nil ->
        []

      json_string when is_binary(json_string) ->
        case JSON.decode(json_string) do
          {:ok, urls} when is_list(urls) -> urls
          _ -> []
        end

      urls when is_list(urls) ->
        urls

      _ ->
        []
    end
  end

  defp build_static_entry(config, base_url, language, is_default, domain_hosted?) do
    path = resolve_path(config)

    if path do
      prefixed = Map.get(config, "prefixed", false)

      # `skip_language_prefix` exists because the home page usually has no
      # localized route. A language with its own domain does have one, and the
      # prefix is what tells DomainMode which host to re-file the entry under —
      # without it every language's home page collapses onto one URL.
      skip_language =
        Map.get(config, "skip_language_prefix", false) and (is_default or not domain_hosted?)

      # Canonical path without language prefix (for hreflang grouping)
      canonical_path = if prefixed, do: Routes.path(path), else: path

      # Build localized path (skip language prefix if configured)
      localized_path =
        if skip_language do
          canonical_path
        else
          build_path_with_language(canonical_path, language, is_default)
        end

      url = build_url_from_localized_path(localized_path, base_url, prefixed)

      UrlEntry.new(%{
        loc: url,
        lastmod: static_lastmod(path),
        changefreq: Map.get(config, "changefreq", "weekly"),
        priority: Map.get(config, "priority", 0.5),
        title: Map.get(config, "title", path),
        category: Map.get(config, "category", "Static"),
        source: :static,
        canonical_path: canonical_path
      })
    else
      # Route not found and no explicit path - skip
      nil
    end
  end

  defp build_custom_entry(config, base_url, language, is_default) do
    path = Map.get(config, "path")

    if path do
      # Canonical path without language prefix (for hreflang grouping)
      canonical_path = path
      localized_path = build_path_with_language(path, language, is_default)
      url = build_url_from_localized_path(localized_path, base_url, false)

      UrlEntry.new(%{
        loc: url,
        lastmod: static_lastmod(path),
        changefreq: Map.get(config, "changefreq", "weekly"),
        priority: Map.get(config, "priority", 0.5),
        title: Map.get(config, "title", path),
        category: Map.get(config, "category", "Custom"),
        source: :static,
        canonical_path: canonical_path
      })
    else
      nil
    end
  end

  # For the homepage, use the newest published-content date across all
  # publishing groups so crawlers see an honest "site was updated when its
  # newest post landed" signal. Other static pages have no associated
  # content date, so today's date is the best approximation.
  defp static_lastmod("/") do
    alias PhoenixKit.Modules.Sitemap.Sources.Publishing

    if Code.ensure_loaded?(Publishing) and
         function_exported?(Publishing, :latest_post_date_global, 0) do
      Publishing.latest_post_date_global() || Date.utc_today()
    else
      Date.utc_today()
    end
  rescue
    _ -> Date.utc_today()
  end

  defp static_lastmod(_path), do: Date.utc_today()

  # Resolve path from config: explicit path OR via RouteResolver
  defp resolve_path(%{"path" => path}) when is_binary(path) and path != "" do
    path
  end

  defp resolve_path(%{"plug" => plug_string}) when is_binary(plug_string) do
    # Try to resolve module via RouteResolver
    module = String.to_existing_atom("Elixir." <> plug_string)
    RouteResolver.find_route(module)
  rescue
    # Module doesn't exist - return nil (no hardcoded fallback!)
    _ -> nil
  end

  defp resolve_path(_), do: nil

  # Build URL from already localized path
  defp build_url_from_localized_path(path, nil, _prefixed) do
    base = Settings.get_setting("site_url", "")
    normalized_base = String.trim_trailing(base, "/")
    "#{normalized_base}#{path}"
  end

  defp build_url_from_localized_path(path, base_url, _prefixed) when is_binary(base_url) do
    normalized_base = String.trim_trailing(base_url, "/")
    "#{normalized_base}#{path}"
  end

  # Adds the locale segment to `path` when applicable. Decision rules
  # live in `LocalePath.emit_prefix?/2` — see that module for the
  # canonical policy shared across all sitemap sources. Static sitemap
  # entries use the base code (no dialect suffix) for the segment.
  defp build_path_with_language(path, language, is_default) do
    if LocalePath.emit_prefix?(language, is_default) do
      "/#{Languages.DialectMapper.extract_base(language)}#{path}"
    else
      path
    end
  end
end