defmodule PhoenixKitWeb.SitemapController 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.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 All endpoints are cached for performance with configurable cache headers. ## XSL Stylesheets The XML sitemap includes an XSL stylesheet reference that enables beautiful browser display without generating separate HTML files. Available styles: - table - Clean table layout - cards - Cards grouped by category - minimal - Simple list of links """ use PhoenixKitWeb, :controller alias PhoenixKit.Sitemap alias PhoenixKit.Sitemap.Generator alias PhoenixKit.Utils.Date, as: UtilsDate @cache_max_age 3600 @valid_xsl_styles ["table", "cards", "minimal"] @doc """ Serves the XML sitemap. - Search engine bots receive clean XML (no XSL reference) - Browsers receive styled HTML page (server-side transformation) Detection is based on Accept header and User-Agent. Returns 404 if sitemap module is disabled. Returns 500 if sitemap generation fails. """ def xml(conn, params) do if Sitemap.enabled?() do config = Sitemap.get_config() # Force HTML format if ?format=html parameter is present (for iframe preview) force_html = Map.get(params, "format") == "html" # Override style from query param if provided (for preview) config = case Map.get(params, "style") do style when style in @valid_xsl_styles -> Map.put(config, :html_style, style) _ -> config end # Check if request is from a browser (wants HTML) vs bot (wants XML) if (force_html or browser_request?(conn)) and Map.get(config, :html_enabled, true) do serve_styled_html(conn, config) else serve_raw_xml(conn, config) end else conn |> put_resp_content_type("text/plain") |> send_resp(404, "Sitemap not available") end end # Serve raw XML for bots and programmatic access defp serve_raw_xml(conn, config) do opts = [ base_url: config.base_url, cache: true, xsl_enabled: false ] case Generator.generate_xml(opts) 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("x-sitemap-url-count", to_string(Sitemap.get_url_count())) |> send_resp(200, xml_content) {:ok, xml_content, _parts} -> conn |> put_resp_content_type("application/xml") |> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}") |> put_resp_header("x-sitemap-url-count", to_string(Sitemap.get_url_count())) |> send_resp(200, xml_content) {:error, reason} -> require Logger Logger.error("Sitemap XML generation failed: #{inspect(reason)}") conn |> put_resp_content_type("text/plain") |> send_resp(500, "Failed to generate sitemap") end end # Serve styled HTML for browsers defp serve_styled_html(conn, config) do xsl_style = get_xsl_style(config) entries = Generator.collect_all_entries(base_url: config.base_url) html_content = render_sitemap_html(entries, xsl_style, config) conn |> put_resp_content_type("text/html") |> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}") |> put_resp_header("x-sitemap-url-count", to_string(length(entries))) |> send_resp(200, html_content) end # Check if request is from a browser (not a bot) defp browser_request?(conn) do accept = get_req_header(conn, "accept") |> List.first() || "" user_agent = get_req_header(conn, "user-agent") |> List.first() || "" user_agent_lower = String.downcase(user_agent) # Bot patterns - these should get raw XML bot_patterns = ~w(googlebot bingbot yandex baidu spider crawler bot slurp) is_bot = Enum.any?(bot_patterns, &String.contains?(user_agent_lower, &1)) # Browser wants HTML if Accept includes text/html and not a bot wants_html = String.contains?(accept, "text/html") wants_html and not is_bot end @doc """ Redirects to XML sitemap (deprecated). HTML sitemap is now served by opening sitemap.xml with XSL styling. This endpoint is kept for backward compatibility. """ def html(conn, _params) do # Redirect to XML sitemap which now has XSL styling 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 """ def xsl_stylesheet(conn, %{"style" => style}) do if style in @valid_xsl_styles 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 else conn |> put_resp_content_type("text/plain") |> send_resp(404, "Invalid stylesheet style") 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 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}") |> send_resp(200, xml_content) {:error, :not_found} -> conn |> put_resp_content_type("text/plain") |> send_resp(404, "Sitemap part not found") end _ -> conn |> put_resp_content_type("text/plain") |> send_resp(400, "Invalid sitemap index") end else conn |> put_resp_content_type("text/plain") |> send_resp(404, "Sitemap not available") end end # Render sitemap as styled HTML (server-side, no XSLT needed) defp render_sitemap_html(entries, style, config) do url_count = length(entries) last_generated = Map.get(config, :last_generated, "Just now") case style do "cards" -> render_cards_html(entries, url_count, last_generated) "minimal" -> render_minimal_html(entries, url_count) _ -> render_table_html(entries, url_count, last_generated) end end defp render_table_html(entries, url_count, _last_generated) do rows = entries |> Enum.sort_by(& &1.loc) |> Enum.map_join("\n", fn entry -> priority_class = cond do (entry.priority || 0.5) >= 0.8 -> "high" (entry.priority || 0.5) >= 0.5 -> "med" true -> "low" end """ #{escape(entry.loc)} #{escape(UtilsDate.format_datetime_full_with_user_format(entry.lastmod))} #{escape(to_string(entry.changefreq || ""))} #{entry.priority || ""} """ end) """ XML Sitemap

XML Sitemap

This sitemap contains #{url_count} URLs

#{rows}
URLLast ModifiedFrequencyPriority
""" end defp render_cards_html(entries, url_count, _last_generated) do # Group entries by category or source grouped = entries |> Enum.group_by(fn entry -> entry.category || to_string(entry.source) || "Other" end) |> Enum.sort_by(fn {name, _} -> name end) cards = Enum.map_join(grouped, "\n", fn {name, group_entries} -> items = group_entries |> Enum.sort_by(& &1.loc) |> Enum.map_join("\n", fn entry -> meta = if entry.lastmod, do: "
#{escape(UtilsDate.format_datetime_full_with_user_format(entry.lastmod))}
", else: "" "
  • #{escape(entry.loc)}#{meta}
  • " end) """
    #{escape(name)} #{length(group_entries)}
    """ end) """ Site Map

    Site Map

    Browse all pages on this website

    #{url_count}
    Total URLs
    #{cards}
    """ end defp render_minimal_html(entries, url_count) do items = entries |> Enum.sort_by(& &1.loc) |> Enum.map_join("\n", fn entry -> "
  • #{escape(entry.loc)}
  • " end) """ Sitemap

    Sitemap

    #{url_count} URLs

    """ end defp escape(nil), do: "" defp escape(text) when is_binary(text) do text |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) end defp escape(other), do: escape(to_string(other)) # Maps old HTML style names to new XSL style names # hierarchical -> cards, grouped -> table, flat -> minimal defp get_xsl_style(config) do html_style = Map.get(config, :html_style, "table") xsl_style = Map.get(config, :xsl_style) # Prefer xsl_style if set, otherwise map from html_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