Packages
phoenix_kit
2.10.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/domain_mode.ex
defmodule PhoenixKit.Modules.Sitemap.DomainMode do
@moduledoc """
Multi-domain sitemap post-processing (one domain = one canonical language).
Activated by a host-app provider:
config :phoenix_kit, :sitemap_domains_provider, {MyAppWeb.SEO, :sitemap_domains}
The provider returns `[%{host: "example.fr", language: "fr", primary: false}, ...]`.
The result is VALIDATED here — exactly one `primary: true`, every host a
distinct language (base-code level), well-formed hosts. Any violation
deactivates domain mode (with a warning) rather than silently producing
malformed URLs or dropping x-default.
`rebuild_for_domains/2` regroups the entries the generator already
collected across languages: for the domain of language L, every canonical
group that HAS an L entry contributes one URL — L's own entry re-hosted on
that domain with its locale prefix stripped (L is the default there) —
carrying an alternates set computed ONCE per group (identical across all
domain files by construction): each group language on its home URL, plus
`x-default` for the primary domain's language when the group has it
(omitted otherwise — never mislabel another language as x-default).
Runs inside Oban jobs and unpiped controller requests: language resolution
here never consults the request-scoped default-language override — the
unprefixed-entry fallback reads the raw `is_default` flag, mirroring the
generator's hardened `get_languages/0`.
NB: the generator's `base_url` (Settings `site_url`) and the provider's
primary host are independently-edited settings that must refer to the same
site; rel-path extraction below is host-string-agnostic, so a mismatch
yields consistent (if unexpected-host) URLs rather than corruption.
"""
require Logger
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Sitemap.UrlEntry
@host_re ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
@locale_prefix_re ~r/^\/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)(?:\/|$)/
@type domain :: %{host: String.t(), language: String.t(), primary: boolean()}
@doc "Validated provider domains; [] (inactive) on any contract violation."
@spec domains() :: [domain()]
def domains do
case Application.get_env(:phoenix_kit, :sitemap_domains_provider) do
{mod, fun} -> mod |> apply(fun, []) |> validate()
_ -> []
end
rescue
error ->
Logger.warning("[Sitemap] sitemap_domains_provider raised: #{Exception.message(error)}")
[]
end
@doc false
@spec active?() :: boolean()
def active?, do: domains() != []
defp validate(list) when is_list(list) and list != [] do
normalized =
Enum.map(list, fn %{host: host, language: language} = d ->
%{
host: host |> to_string() |> String.downcase(),
language: DialectMapper.extract_base(to_string(language)),
primary: Map.get(d, :primary, false) == true
}
end)
cond do
Enum.any?(normalized, &(not Regex.match?(@host_re, &1.host))) ->
warn_invalid("malformed host")
Enum.count(normalized, & &1.primary) != 1 ->
warn_invalid("exactly one primary domain required")
normalized |> Enum.map(& &1.language) |> Enum.uniq() |> length() != length(normalized) ->
warn_invalid("each host must map a distinct language")
true ->
normalized
end
rescue
error ->
Logger.warning("[Sitemap] invalid sitemap_domains_provider data: #{inspect(error)}")
[]
end
defp validate(_), do: []
defp warn_invalid(reason) do
Logger.warning("[Sitemap] domain mode disabled: #{reason}")
[]
end
@doc """
Regroups collected entries into per-domain URL sets: `%{host => [UrlEntry.t()]}`.
"""
@spec rebuild_for_domains([UrlEntry.t()], String.t()) :: %{String.t() => [UrlEntry.t()]}
def rebuild_for_domains(entries, base_url) do
case domains() do
[] ->
%{}
domains ->
default_lang = raw_default_language()
enabled_bases = enabled_language_bases()
host_by_lang = Map.new(domains, &{&1.language, &1.host})
primary = Enum.find(domains, & &1.primary)
groups =
entries
|> Enum.group_by(fn e -> e.canonical_path || {:loc, e.loc} end)
|> Map.values()
|> Enum.map(&group_by_language(&1, base_url, default_lang, enabled_bases))
Map.new(domains, fn %{host: host, language: lang} ->
{host, domain_entries(groups, lang, host_by_lang, primary, base_url)}
end)
end
end
defp group_by_language(members, base_url, default_lang, enabled_bases) do
Map.new(members, fn e -> {entry_language(e, base_url, default_lang, enabled_bases), e} end)
end
defp domain_entries(groups, lang, host_by_lang, primary, base_url) do
groups
|> Enum.flat_map(fn by_lang ->
case by_lang[lang] do
nil ->
[]
entry ->
alternates = group_alternates(by_lang, host_by_lang, primary, base_url)
[
%{
entry
| loc: home_url(lang, entry, host_by_lang, primary, base_url),
alternates: alternates
}
]
end
end)
|> Enum.sort_by(& &1.loc)
end
# Computed once per group and reused verbatim for every domain that carries
# the group — the spec's "identical hreflang set on every duplicate".
defp group_alternates(by_lang, host_by_lang, primary, base_url) do
links =
by_lang
|> Enum.sort_by(fn {lang, _} -> lang end)
|> Enum.map(fn {lang, entry} ->
%{hreflang: lang, href: home_url(lang, entry, host_by_lang, primary, base_url)}
end)
case by_lang[primary.language] do
nil ->
links
primary_entry ->
links ++
[
%{
hreflang: "x-default",
href: home_url(primary.language, primary_entry, host_by_lang, primary, base_url)
}
]
end
end
defp home_url(lang, %UrlEntry{loc: loc}, host_by_lang, primary, base_url) do
rel = String.replace_prefix(loc, String.trim_trailing(base_url, "/"), "")
rel = if String.starts_with?(rel, "/"), do: rel, else: "/" <> rel
case host_by_lang[lang] do
nil -> "https://#{primary.host}#{rel}"
host -> "https://#{host}#{strip_own_prefix(rel, lang)}"
end
end
defp strip_own_prefix(rel, lang) do
case String.split(rel, "/", parts: 3) do
["", ^lang] -> "/"
["", ^lang, rest] -> "/" <> rest
_ -> rel
end
end
# Language of an entry, from its loc's locale prefix — only when the
# segment is a real enabled language (a "/api/..." first segment is not a
# locale). Unprefixed entries (home, router-discovery) belong to the site
# default. Reads the RAW is_default flag — never the request-scoped
# override (Oban/unpiped safe).
defp entry_language(%UrlEntry{loc: loc}, base_url, default_lang, enabled_bases) do
rel = String.replace_prefix(loc, String.trim_trailing(base_url, "/"), "")
with [_, code] <- Regex.run(@locale_prefix_re, rel),
base = DialectMapper.extract_base(code),
true <- base in enabled_bases do
base
else
_ -> default_lang
end
end
defp enabled_language_bases do
if Languages.enabled?() do
Languages.get_enabled_languages()
|> Enum.map(&DialectMapper.extract_base(&1.code))
|> MapSet.new()
else
MapSet.new()
end
rescue
_ -> MapSet.new()
end
defp raw_default_language do
if Languages.enabled?() do
case Enum.find(Languages.get_languages(), & &1.is_default) do
%{code: code} -> DialectMapper.extract_base(code)
_ -> "en"
end
else
"en"
end
rescue
_ -> "en"
end
end