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.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 All endpoints are cached for performance with configurable cache headers. ## 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 alias PhoenixKit.Sitemap alias PhoenixKit.Sitemap.Cache 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 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. Returns 500 if sitemap generation fails. """ 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, config, xsl_style) end else conn |> put_resp_content_type("text/plain") |> send_resp(404, "Sitemap not available") end end # Serve XML with XSL stylesheet reference (default for browsers and bots) defp serve_xml(conn, config, xsl_style) do opts = [ base_url: config.base_url, cache: true, xsl_enabled: true, xsl_style: xsl_style ] etag = generate_etag(config) 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("etag", etag) |> 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("etag", etag) |> 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 server-rendered HTML (for iframe previews where XSL can't be applied) defp serve_html(conn, config, xsl_style) do cache_key = :"html_#{xsl_style}" # Try to get cached HTML content {html_content, url_count} = case Cache.get(cache_key) do {:ok, %{html: html, url_count: count}} -> {html, count} _ -> # Cache miss - generate and cache entries = Generator.collect_all_entries(base_url: config.base_url) html = render_sitemap_html(entries, xsl_style, config) count = length(entries) # Store in cache for future requests Cache.put(cache_key, %{html: html, url_count: count}) {html, count} end etag = generate_etag(config) 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(url_count)) |> send_resp(200, html_content) 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 """ 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 config = Sitemap.get_config() etag = generate_etag(config) 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} -> 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 # HTML Rendering Functions (for iframe preview) 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 """
This sitemap contains #{url_count} URLs
| URL | Last Modified | Frequency | Priority |
|---|
Browse all pages on this website
#{url_count} URLs