Packages

phoenix_kit

1.7.171
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

221 Versions

Jump to

Compare versions

10 files changed
+223 additions
-27 deletions
unknownCHANGELOG.md
File is too large to be displayed (100 KB limit).
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}.
2 2 {<<"name">>,<<"phoenix_kit">>}.
3 - {<<"version">>,<<"1.7.170">>}.
3 + {<<"version">>,<<"1.7.171">>}.
4 4 {<<"description">>,
5 5 <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105,
6 6 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105,
  @@ -9,6 +9,7 @@ defmodule PhoenixKit.Modules.SEO do
9 9 use PhoenixKit.Module
10 10
11 11 alias PhoenixKit.Dashboard.Tab
12 + alias PhoenixKit.Modules.Sitemap.Generator
12 13 alias PhoenixKit.Settings
13 14
14 15 @module_enabled_key "seo_module_enabled"
  @@ -69,7 +70,23 @@ defmodule PhoenixKit.Modules.SEO do
69 70 Updates the directive to the provided boolean value.
70 71 """
71 72 def update_no_index(enabled?) when is_boolean(enabled?) do
72 - Settings.update_boolean_setting_with_module(@no_index_key, enabled?, @module_name)
73 + result = Settings.update_boolean_setting_with_module(@no_index_key, enabled?, @module_name)
74 +
75 + # The directive flips what the sitemap must advertise. Drop the cached
76 + # sitemap and regenerate so `/sitemap.xml` reflects the new state instead of
77 + # serving a stale file. Best-effort — never let it break the toggle.
78 + refresh_sitemap()
79 +
80 + result
81 + end
82 +
83 + # Invalidate + regenerate the sitemap after a noindex change. Wrapped so a
84 + # sitemap/Oban hiccup can never fail the settings write.
85 + defp refresh_sitemap do
86 + Generator.invalidate_and_regenerate()
87 + :ok
88 + rescue
89 + _ -> :ok
73 90 end
74 91
75 92 @impl PhoenixKit.Module
  @@ -31,6 +31,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
31 31 require Logger
32 32
33 33 alias PhoenixKit.Modules.Languages
34 + alias PhoenixKit.Modules.SEO
34 35 alias PhoenixKit.Modules.Sitemap
35 36 alias PhoenixKit.Modules.Sitemap.Cache
36 37 alias PhoenixKit.Modules.Sitemap.FileStorage
  @@ -86,15 +87,47 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
86 87 defp do_generate_all(base_url, opts) do
87 88 xsl_style = Keyword.get(opts, :xsl_style, "table")
88 89 xsl_enabled = Keyword.get(opts, :xsl_enabled, true)
89 - sources = get_sources()
90 90
91 - if Sitemap.flat_mode?() do
92 - do_generate_flat(base_url, opts, sources, xsl_style, xsl_enabled)
93 - else
94 - do_generate_index(base_url, opts, sources, xsl_style, xsl_enabled)
91 + cond do
92 + seo_no_index?() ->
93 + do_generate_no_index(xsl_style, xsl_enabled)
94 +
95 + Sitemap.flat_mode?() ->
96 + do_generate_flat(base_url, opts, get_sources(), xsl_style, xsl_enabled)
97 +
98 + true ->
99 + do_generate_index(base_url, opts, get_sources(), xsl_style, xsl_enabled)
95 100 end
96 101 end
97 102
103 + # When the SEO module's global `noindex` directive is active the site is
104 + # asking search engines not to index it, so we publish an empty (but valid)
105 + # `<urlset>` rather than advertising crawlable URLs, for both flat and index
106 + # modes. Note this makes `/sitemap.xml` serve a `<urlset>` (not a
107 + # `<sitemapindex>`) while noindex is on — an empty urlset is schema-valid and
108 + # harmless. Any leftover per-module files are removed.
109 + #
110 + # Keyed on `no_index_enabled?/0` alone (not `module_enabled?/0`) to stay in
111 + # lockstep with the `<meta name="robots" content="noindex">` directive host
112 + # layouts emit for the same setting — an empty sitemap always pairs with a
113 + # noindex meta.
114 + defp do_generate_no_index(xsl_style, xsl_enabled) do
115 + Logger.debug("Sitemap: seo_no_index active — publishing empty sitemap")
116 +
117 + xml = build_urlset_xml([], xsl_style, xsl_enabled)
118 + FileStorage.save_index(xml)
119 + FileStorage.delete_all_modules()
120 +
121 + {:ok, %{index_xml: xml, modules: [], total_urls: 0}}
122 + end
123 +
124 + # Defensive: never let an SEO lookup error break sitemap generation.
125 + defp seo_no_index? do
126 + SEO.no_index_enabled?()
127 + rescue
128 + _ -> false
129 + end
130 +
98 131 defp do_generate_index(base_url, opts, sources, xsl_style, xsl_enabled) do
99 132 Logger.info("Sitemap: Generating sitemapindex from #{length(sources)} sources")
100 133
  @@ -336,6 +369,11 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
336 369 style not in ["hierarchical", "grouped", "flat"] ->
337 370 {:error, :invalid_style}
338 371
372 + seo_no_index?() ->
373 + # noindex active: never advertise URLs and never serve a stale cached
374 + # HTML sitemap — render an empty one, bypassing the cache.
375 + HtmlGenerator.generate(opts, [], :"html_#{style}", cache: false)
376 +
339 377 true ->
340 378 cache_key = :"html_#{style}"
341 379
  @@ -468,7 +506,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
468 506 Logger.debug("Sitemap: Collected #{length(entries)} entries from #{inspect(source_module)}")
469 507 entries
470 508 end)
471 - |> Enum.uniq_by(& &1.loc)
509 + |> dedupe_by_loc()
472 510 |> Enum.sort_by(& &1.loc)
473 511 end
474 512
  @@ -557,7 +595,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
557 595 entry
558 596 end
559 597 end)
560 - |> Enum.uniq_by(& &1.loc)
598 + |> dedupe_by_loc()
561 599 |> Enum.sort_by(& &1.loc)
562 600 end
563 601
  @@ -575,6 +613,36 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
575 613 end
576 614 end
577 615
616 + # Deduplicates entries by `loc`, preferring the entry with the richer
617 + # sitemap metadata when a URL is emitted by more than one source. This
618 + # matters because RouterDiscovery blindly enumerates every GET route and
619 + # can emit the same `loc` as a content source (Publishing, Entities, ...)
620 + # that also generates a richer entry (canonical_path/alternates for
621 + # hreflang, higher priority) for that same URL. A RouterDiscovery entry
622 + # always loses to a same-loc entry from any other source: it only carries
623 + # generic route metadata (priority 0.5, no hreflang) and must never mask
624 + # an authoritative content-source entry. `Enum.uniq_by/2` alone is not
625 + # enough here since it keeps whichever entry happens to come first, which
626 + # depends on source ordering rather than which entry is actually richer.
627 + defp dedupe_by_loc(entries) do
628 + entries
629 + |> Enum.group_by(& &1.loc)
630 + |> Enum.map(fn {_loc, group} -> Enum.max_by(group, &entry_richness/1) end)
631 + end
632 +
633 + # RouterDiscovery entries always score lowest (0), so any other source
634 + # wins a same-loc collision regardless of its own priority/canonical_path.
635 + # Among non-RouterDiscovery entries, richer entries win ties.
636 + defp entry_richness(%UrlEntry{source: :router_discovery}), do: 0
637 +
638 + defp entry_richness(%UrlEntry{} = entry) do
639 + canonical_bonus = if entry.canonical_path in [nil, ""], do: 0, else: 2
640 + alternates_bonus = if entry.alternates in [nil, []], do: 0, else: 2
641 + priority = UrlEntry.parse_priority(entry.priority) || 0.0
642 +
643 + 1 + canonical_bonus + alternates_bonus + priority
644 + end
645 +
578 646 # ── Internal: XML building ─────────────────────────────────────────
579 647
580 648 defp build_urlset_xml(entries, xsl_style, xsl_enabled) do
  @@ -13,6 +13,13 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery do
13 13 - `sitemap_router_discovery_include_only` - JSON array of regex patterns for whitelist mode
14 14 - `sitemap_protected_pipelines` - JSON array of pipeline names that require authentication
15 15
16 + ## Pattern Syntax
17 +
18 + Exclude and include-only patterns are **regular expressions** (compiled with
19 + `Regex.compile/1`), not shell globs. A bare `"*"` is an invalid regex and is
20 + ignored with a logged warninguse `".*"` to match everything or `"^/prefix"`
21 + to match a path prefix. Invalid patterns never silently disable the source.
22 +
16 23 ## Default Exclusions
17 24
18 25 By default, the following patterns are excluded:
  @@ -20,6 +27,9 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery do
20 27 - `^/api` - API endpoints
21 28 - `^/phoenix_kit` - PhoenixKit admin routes
22 29 - `^/dev` - Development routes
30 + - `^/__` - Internal/technical routes (double-underscore convention, e.g.
31 + Publishing's internal dispatch scope)
32 + - `^/maintenance$` - PhoenixKit's reserved maintenance page route
23 33 - `:[a-z_]+` - Routes with parameters
24 34 - `\\*` - Wildcard routes
25 35
  @@ -76,6 +86,11 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery do
76 86 "^/dev",
77 87 "^/test",
78 88 "^/dashboard",
89 + # Internal/technical routes (double-underscore convention, e.g. Publishing's
90 + # "/__phoenix_kit_publishing_dispatch" catch-all dispatch scope) and
91 + # PhoenixKit's reserved maintenance page - neither is public content
92 + "^/__",
93 + "^/maintenance$",
79 94 ":[a-z_]+",
80 95 "\\*",
81 96 # Auth pages - should not be indexed by search engines
  @@ -153,8 +168,8 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery do
153 168
154 169 defp do_collect(opts) do
155 170 base_url = Keyword.get(opts, :base_url)
156 - exclude_patterns = get_exclude_patterns()
157 - include_only = get_include_only_patterns()
171 + exclude_patterns = compile_patterns(get_exclude_patterns(), "exclude")
172 + include_only = compile_include_only(get_include_only_patterns())
158 173
159 174 RouteResolver.get_routes()
160 175 |> Enum.filter(&valid_for_sitemap?(&1, exclude_patterns, include_only))
  @@ -256,25 +271,41 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery do
256 271 route.verb == :get
257 272 end
258 273
259 - defp excluded?(path, patterns) do
260 - Enum.any?(patterns, fn pattern ->
261 - case Regex.compile(pattern) do
262 - {:ok, regex} -> Regex.match?(regex, path)
263 - _ -> false
264 - end
265 - end)
274 + defp excluded?(path, regexes) do
275 + Enum.any?(regexes, &Regex.match?(&1, path))
266 276 end
267 277
268 - defp included?(_path, []) do
269 - # Empty include_only = include all
270 - true
271 - end
278 + # `:all` means no include-only patterns were configured → include everything.
279 + defp included?(_path, :all), do: true
280 + defp included?(path, {:whitelist, regexes}), do: Enum.any?(regexes, &Regex.match?(&1, path))
272 281
273 - defp included?(path, patterns) do
274 - Enum.any?(patterns, fn pattern ->
282 + # An unset/empty whitelist keeps the "include all" behavior; a configured
283 + # whitelist is compiled so an all-invalid list still excludes everything
284 + # (now with a logged warning) rather than silently flipping to include-all.
285 + defp compile_include_only([]), do: :all
286 +
287 + defp compile_include_only(patterns),
288 + do: {:whitelist, compile_patterns(patterns, "include_only")}
289 +
290 + # Compile raw pattern strings into `Regex` structs, logging and dropping any
291 + # that fail to compile. Patterns are regular expressions (not shell globs):
292 + # a bare "*" fails to compile and would otherwise be swallowed silently,
293 + # masking a misconfiguration (e.g. an include-only "*" would suppress the
294 + # entire source with no diagnostic trail).
295 + defp compile_patterns(patterns, context) do
296 + Enum.flat_map(patterns, fn pattern ->
275 297 case Regex.compile(pattern) do
276 - {:ok, regex} -> Regex.match?(regex, path)
277 - _ -> false
298 + {:ok, regex} ->
299 + [regex]
300 +
301 + {:error, reason} ->
302 + Logger.warning(
303 + "RouterDiscovery: ignoring invalid #{context} pattern #{inspect(pattern)} " <>
304 + "(#{inspect(reason)}). Patterns are regular expressions, not globs — " <>
305 + "use \".*\" to match everything or \"^/prefix\" for a path prefix."
306 + )
307 +
308 + []
278 309 end
279 310 end)
280 311 end
Loading more files…