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
Current section
221 Versions
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 warning — use `".*"` 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…