Packages
phoenix_kit
2.12.0
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
Current section
Files
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