Packages
phoenix_kit
1.7.33
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/web/controller.ex
defmodule PhoenixKit.Modules.Sitemap.Web.Controller do
@moduledoc """
Controller for serving XML sitemaps with XSL styling.
Provides public endpoints for sitemap access:
- GET /{prefix}/sitemap.xml - XML sitemap with XSL stylesheet reference
- GET /{prefix}/sitemap.xml?format=html - Server-rendered HTML (for iframe previews)
- GET /{prefix}/sitemap.html - Redirects to sitemap.xml (deprecated)
- GET /{prefix}/sitemap-{n}.xml - Sitemap index parts (for large sites)
- GET /{prefix}/assets/sitemap-{style}.xsl - XSL stylesheets
## Architecture: File-Only Storage
Sitemaps are stored in `priv/static/sitemap.xml` for:
- Direct nginx/CDN serving (bypasses Phoenix entirely)
- ETag from file mtime (always reflects actual content)
- On-demand generation (first request generates if missing)
## How It Works
The XML sitemap includes an XSL stylesheet reference. When opened in a browser,
the browser applies the XSL transformation client-side, rendering a beautiful
HTML page. Search engine bots ignore the XSL and read the raw XML.
For iframe previews (admin panel), use `?format=html` to get server-rendered HTML,
since iframes cannot apply XSL transformations.
## XSL Styles
Available styles (configurable in settings):
- table - Clean table layout (default)
- cards - Cards grouped by category
- minimal - Simple list of links
"""
use PhoenixKitWeb, :controller
require Logger
alias PhoenixKit.Modules.Sitemap
alias PhoenixKit.Modules.Sitemap.FileStorage
alias PhoenixKit.Modules.Sitemap.Generator
@cache_max_age 3600
@valid_xsl_styles ["table", "cards", "minimal"]
@doc """
Serves the XML sitemap with XSL stylesheet reference.
Query parameters:
- `style` - Override XSL style (table, cards, minimal)
- `format=html` - Force server-side HTML rendering (for iframe previews)
Returns 404 if sitemap module is disabled.
Generates on first request if file doesn't exist.
"""
def xml(conn, params) do
if Sitemap.enabled?() do
config = Sitemap.get_config()
# Override style from query param if provided
xsl_style =
case Map.get(params, "style") do
style when style in @valid_xsl_styles -> style
_ -> get_xsl_style(config)
end
# Check if HTML format is requested (for iframe previews)
if Map.get(params, "format") == "html" do
serve_html(conn, config, xsl_style)
else
serve_xml(conn, xsl_style)
end
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "Sitemap not available")
end
end
# Serve XML from file or generate on first request
defp serve_xml(conn, xsl_style) do
if FileStorage.exists?() do
serve_existing_file(conn)
else
generate_and_serve(conn, xsl_style)
end
end
# Serve existing file with ETag from mtime
defp serve_existing_file(conn) do
etag = generate_file_etag()
if client_has_fresh_cache?(conn, etag) do
send_not_modified(conn, etag)
else
case FileStorage.load() do
{:ok, xml_content} ->
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> put_resp_header("x-sitemap-url-count", to_string(Sitemap.get_url_count()))
|> send_resp(200, xml_content)
:error ->
# File disappeared between exists? and load - regenerate
generate_and_serve(conn, "table")
end
end
end
# Generate sitemap on first request, save to file, serve
defp generate_and_serve(conn, xsl_style) do
base_url = Sitemap.get_base_url()
if base_url == "" do
Logger.warning("SitemapController: Base URL not configured")
conn
|> put_resp_content_type("text/plain")
|> send_resp(503, "Sitemap not configured. Please set base URL in settings.")
else
opts = [
base_url: base_url,
cache: false,
xsl_style: xsl_style,
xsl_enabled: true
]
case Generator.generate_xml(opts) do
{:ok, xml_content} ->
# Save to file for subsequent requests
FileStorage.save(xml_content)
# Update URL count in stats
entries = Generator.collect_all_entries(base_url: base_url)
Sitemap.update_generation_stats(%{url_count: length(entries)})
etag = generate_content_etag(xml_content)
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> put_resp_header("x-sitemap-url-count", to_string(length(entries)))
|> send_resp(200, xml_content)
{:ok, xml_content, _parts} ->
# Sitemap index generated
FileStorage.save(xml_content)
entries = Generator.collect_all_entries(base_url: base_url)
Sitemap.update_generation_stats(%{url_count: length(entries)})
etag = generate_content_etag(xml_content)
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> put_resp_header("x-sitemap-url-count", to_string(length(entries)))
|> send_resp(200, xml_content)
{:error, reason} ->
Logger.error("SitemapController: Generation failed: #{inspect(reason)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(500, "Failed to generate sitemap")
end
end
end
# Serve server-rendered HTML (for iframe previews where XSL can't be applied)
defp serve_html(conn, _config, xsl_style) do
base_url = Sitemap.get_base_url()
if base_url == "" do
conn
|> put_resp_content_type("text/plain")
|> send_resp(503, "Sitemap not configured. Please set base URL in settings.")
else
html_style = xsl_to_html_style(xsl_style)
opts = [
base_url: base_url,
cache: false,
style: html_style
]
case Generator.generate_html(opts) do
{:ok, html_content} ->
etag = generate_content_etag(html_content)
conn
|> put_resp_content_type("text/html")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> put_resp_header("x-sitemap-url-count", to_string(Sitemap.get_url_count()))
|> send_resp(200, html_content)
{:error, reason} ->
Logger.error("SitemapController: HTML generation failed: #{inspect(reason)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(500, "Failed to generate HTML sitemap")
end
end
end
# Map XSL styles to HTML styles used by Generator
defp xsl_to_html_style("cards"), do: "hierarchical"
defp xsl_to_html_style("table"), do: "grouped"
defp xsl_to_html_style("minimal"), do: "flat"
defp xsl_to_html_style(_), do: "hierarchical"
# Check if client has a fresh cached version via If-None-Match header
defp client_has_fresh_cache?(conn, etag) do
case get_req_header(conn, "if-none-match") do
[client_etag] -> client_etag == etag
_ -> false
end
end
# Send 304 Not Modified response
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("etag", etag)
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> send_resp(304, "")
end
# Generate ETag from file mtime and size (most reliable)
defp generate_file_etag do
case FileStorage.get_file_stat() do
{:ok, mtime, size} ->
hash =
:crypto.hash(:md5, "#{inspect(mtime)}-#{size}")
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
"\"#{hash}\""
:error ->
"\"default\""
end
end
# Generate ETag from content (for freshly generated content)
defp generate_content_etag(content) do
hash =
:crypto.hash(:md5, content)
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
"\"#{hash}\""
end
@doc """
Redirects to XML sitemap (deprecated).
HTML sitemap is now served by opening sitemap.xml - browsers apply XSL styling.
This endpoint is kept for backward compatibility.
"""
def html(conn, _params) do
prefix = PhoenixKit.Config.get_url_prefix()
redirect(conn, to: "#{prefix}/sitemap.xml")
end
@doc """
Serves XSL stylesheet files for sitemap display.
Available styles: table, cards, minimal
Default style (when accessed via /sitemap.xsl): table
"""
def xsl_stylesheet(conn, %{"style" => style}) when style in @valid_xsl_styles do
serve_xsl_stylesheet(conn, style)
end
def xsl_stylesheet(conn, _params) do
# Default to "table" style when accessed via /sitemap.xsl without style param
serve_xsl_stylesheet(conn, "table")
end
defp serve_xsl_stylesheet(conn, style) do
xsl_path = Application.app_dir(:phoenix_kit, "priv/static/assets/sitemap-#{style}.xsl")
if File.exists?(xsl_path) do
content = File.read!(xsl_path)
conn
|> put_resp_content_type("application/xslt+xml")
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_resp(200, content)
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "Stylesheet not found")
end
end
@doc """
Serves sitemap index part files for large sitemaps.
URL format: /sitemap-{index}.xml where index is 1-based.
Returns 404 if the index doesn't exist.
"""
def index_part(conn, %{"index" => index_str}) do
if Sitemap.enabled?() do
serve_index_part(conn, index_str)
else
send_plain_error(conn, 404, "Sitemap not available")
end
end
defp serve_index_part(conn, index_str) do
etag = generate_file_etag()
# Check if client already has this version cached
if client_has_fresh_cache?(conn, etag) do
send_not_modified(conn, etag)
else
do_serve_index_part(conn, index_str, etag)
end
end
defp do_serve_index_part(conn, index_str, etag) do
case Integer.parse(index_str) do
{index, ""} when index > 0 ->
case Generator.get_sitemap_part(index) do
{:ok, xml_content} ->
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> send_resp(200, xml_content)
{:error, :not_found} ->
send_plain_error(conn, 404, "Sitemap part not found")
end
_ ->
send_plain_error(conn, 400, "Invalid sitemap index")
end
end
defp send_plain_error(conn, status, message) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(status, message)
end
@doc """
Returns current sitemap style and version for live-reload.
Used by JavaScript in XSL templates to detect style changes.
"""
def version(conn, _params) do
if Sitemap.enabled?() do
config = Sitemap.get_config()
xsl_style = get_xsl_style(config)
# Use last_generated timestamp or current time as version
version =
case config.last_generated do
nil ->
DateTime.utc_now() |> DateTime.to_unix()
iso when is_binary(iso) ->
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> DateTime.to_unix(dt)
_ -> DateTime.utc_now() |> DateTime.to_unix()
end
end
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "no-cache, no-store, must-revalidate")
|> put_resp_header("access-control-allow-origin", "*")
|> send_resp(200, Jason.encode!(%{style: xsl_style, version: version}))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(%{error: "Sitemap disabled"}))
end
end
# Helper Functions
defp get_xsl_style(config) do
html_style = Map.get(config, :html_style, "table")
xsl_style = Map.get(config, :xsl_style)
cond do
xsl_style && xsl_style in @valid_xsl_styles ->
xsl_style
html_style == "hierarchical" ->
"cards"
html_style == "grouped" ->
"table"
html_style == "flat" ->
"minimal"
html_style in @valid_xsl_styles ->
html_style
true ->
"table"
end
end
end