Skip to main content

lib/astral/plugin/sitemap.ex

defmodule Astral.Plugin.Sitemap do
  @moduledoc """
  Generated `sitemap.xml` plugin.

  The plugin is intentionally implemented through `XM` so the XML DSL is
  dogfooded by a real site feature and can later be extracted into a generic
  library.

  ## Options

    * `:site_url` - required absolute site URL, for example `"https://example.com"`.
    * `:path` - sitemap route path. Defaults to `"/sitemap.xml"`.
    * `:include_routes` - include plugin-generated routes in addition to pages.
      Defaults to `true`.
    * `:exclude` - list of route paths or one-arity predicate returning true for
      routes to exclude.
    * `:lastmod` - one-arity function returning lastmod for a page or route.
    * `:changefreq` - atom/string or one-arity function for `<changefreq>`.
    * `:priority` - number/string or one-arity function for `<priority>`.
  """

  @behaviour Astral.Plugin

  import XM, only: [document: 1]

  @impl true
  def name, do: "sitemap"

  @impl true
  def routes(site, opts) do
    path = Keyword.get(opts, :path, "/sitemap.xml")
    [Astral.Route.new(path, site.config, content_type: "application/xml")]
  end

  @impl true
  def render_route(%Astral.Route{path: path}, site, opts) do
    sitemap_path = Keyword.get(opts, :path, "/sitemap.xml")

    if path == sitemap_path do
      {:ok, render(site, opts), "application/xml"}
    end
  end

  def render_route(_route, _site, _opts), do: nil

  defp render(site, opts) do
    site_url = required!(opts, :site_url)
    urls = sitemap_urls(site, opts)

    document do
      schema do
        default("http://www.sitemaps.org/schemas/sitemap/0.9",
          location: "https://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
        )
      end

      urlset do
        for url <- urls do
          url do
            loc(absolute_url(site_url, url.path))
            lastmod(url.lastmod)

            if url.changefreq do
              changefreq(url.changefreq)
            end

            if url.priority do
              priority(url.priority)
            end
          end
        end
      end
    end
  end

  defp sitemap_urls(site, opts) do
    site
    |> sitemap_sources(opts)
    |> Enum.reject(&excluded?(&1, opts))
    |> Enum.map(&url(&1, opts))
  end

  defp sitemap_sources(site, opts) do
    page_sources = site.pages

    route_sources =
      if Keyword.get(opts, :include_routes, true) do
        Enum.reject(site.routes, &(&1.path == Keyword.get(opts, :path, "/sitemap.xml")))
      else
        []
      end

    page_sources ++ route_sources
  end

  defp url(source, opts) do
    %{
      path: route_path(source),
      lastmod: source |> lastmod(opts) |> date(),
      changefreq: option_value(opts, :changefreq, source),
      priority: option_value(opts, :priority, source)
    }
  end

  defp route_path(%Astral.Page{} = page), do: page.route_path
  defp route_path(%Astral.Route{} = route), do: route.path

  defp lastmod(source, opts) do
    case Keyword.get(opts, :lastmod) do
      fun when is_function(fun, 1) -> fun.(source)
      nil -> default_lastmod(source)
    end
  end

  defp default_lastmod(%Astral.Page{entry: %Astral.Entry{} = entry}) do
    Map.get(entry.data, :updated) || Map.get(entry.data, :date) || Date.utc_today()
  end

  defp default_lastmod(%Astral.Page{} = page) do
    page.content.metadata
    |> Map.get("updated", Map.get(page.content.metadata, "date", Date.utc_today()))
  end

  defp default_lastmod(%Astral.Route{}), do: Date.utc_today()

  defp excluded?(source, opts) do
    case Keyword.get(opts, :exclude, []) do
      fun when is_function(fun, 1) -> fun.(source)
      paths when is_list(paths) -> route_path(source) in paths
      path when is_binary(path) -> route_path(source) == path
      _ -> false
    end
  end

  defp option_value(opts, key, source) do
    case Keyword.get(opts, key) do
      fun when is_function(fun, 1) -> fun.(source)
      value -> value
    end
  end

  defp date(%Date{} = date), do: date
  defp date(%DateTime{} = datetime), do: DateTime.to_date(datetime)
  defp date(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_date(datetime)

  defp date(value) when is_binary(value) do
    case Date.from_iso8601(value) do
      {:ok, date} -> date
      {:error, _reason} -> Date.utc_today()
    end
  end

  defp date(_value), do: Date.utc_today()

  defp absolute_url(site_url, path), do: String.trim_trailing(site_url, "/") <> path

  defp required!(opts, key) do
    Keyword.get(opts, key) ||
      raise ArgumentError, "Astral.Plugin.Sitemap requires #{inspect(key)}"
  end
end