Packages

phoenix_kit

2.28.1
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 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
phoenix_kit lib modules sitemap generator.ex
Raw

lib/modules/sitemap/generator.ex

defmodule PhoenixKit.Modules.Sitemap.Generator do
  @moduledoc """
  Main sitemap generator for PhoenixKit.

  Generates a `<sitemapindex>` at `/sitemap.xml` referencing per-module
  sitemap files at `/sitemaps/sitemap-{source}.xml`.

  ## Architecture

  - `/sitemap.xml` - Always a `<sitemapindex>` referencing per-module files
  - `/sitemaps/sitemap-static.xml` - Static pages
  - `/sitemaps/sitemap-routes.xml` - Router discovery
  - `/sitemaps/sitemap-publishing.xml` - Publishing posts (or per-blog files)
  - `/sitemaps/sitemap-shop.xml` - Shop products (auto-split at 50k)
  - `/sitemaps/sitemap-entities.xml` - Entities (or per-type files)

  HTML sitemaps are rendered by `PhoenixKit.Modules.Sitemap.HtmlGenerator`.

  ## Usage

      # Generate all sitemaps (index + per-module files)
      {:ok, result} = Generator.generate_all(base_url: "https://example.com")

      # Generate HTML sitemap (collects from all sources)
      {:ok, html} = Generator.generate_html(base_url: "https://example.com")

      # Backward compatible: generate_xml returns the sitemapindex
      {:ok, xml} = Generator.generate_xml(base_url: "https://example.com")
  """

  require Logger

  alias PhoenixKit.Modules.Crawlers
  alias PhoenixKit.Modules.Languages
  alias PhoenixKit.Modules.Sitemap
  alias PhoenixKit.Modules.Sitemap.Cache
  alias PhoenixKit.Modules.Sitemap.DomainMode
  alias PhoenixKit.Modules.Sitemap.FileStorage
  alias PhoenixKit.Modules.Sitemap.HtmlGenerator
  alias PhoenixKit.Modules.Sitemap.SchedulerWorker
  alias PhoenixKit.Modules.Sitemap.SitemapFile
  alias PhoenixKit.Modules.Sitemap.Sources.Source
  alias PhoenixKit.Modules.Sitemap.UrlEntry
  alias PhoenixKit.Utils.Date, as: UtilsDate
  alias PhoenixKit.Utils.Routes

  @max_urls_per_file 50_000
  @xml_declaration ~s(<?xml version="1.0" encoding="UTF-8"?>)
  @urlset_open ~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">)
  @urlset_close "</urlset>"
  @sitemapindex_open ~s(<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
  @sitemapindex_close "</sitemapindex>"

  @valid_xsl_styles ["table", "minimal"]

  # ── Main entry point ───────────────────────────────────────────────

  @doc """
  Generates all sitemaps: per-module files and the sitemapindex.

  ## Options

  - `:base_url` - Base URL for building full URLs (required)
  - `:xsl_style` - XSL stylesheet style: "table" or "minimal" (default: "table")
  - `:xsl_enabled` - Enable XSL stylesheet reference (default: true)

  ## Returns

      {:ok, %{
        index_xml: "<?xml ...sitemapindex...",
        modules: [
          %{filename: "sitemap-static", url_count: 3, lastmod: ~U[...]}
        ],
        total_urls: 150
      }}
  """
  @spec generate_all(keyword()) :: {:ok, map()} | {:error, any()}
  def generate_all(opts \\ []) do
    base_url = Keyword.get(opts, :base_url)

    if is_nil(base_url) do
      {:error, :base_url_required}
    else
      do_generate_all(base_url, opts)
    end
  end

  defp do_generate_all(base_url, opts) do
    xsl_style = Keyword.get(opts, :xsl_style, "table")
    xsl_enabled = Keyword.get(opts, :xsl_enabled, true)

    cond do
      crawlers_no_index?() and not sitemap_exempt_from_no_index?() ->
        do_generate_no_index(xsl_style, xsl_enabled)

      Sitemap.flat_mode?() ->
        do_generate_flat(base_url, opts, get_sources(), xsl_style, xsl_enabled)

      true ->
        do_generate_index(base_url, opts, get_sources(), xsl_style, xsl_enabled)
    end
  end

  # When the Crawlers module's global `noindex` directive is active the site is
  # asking search engines not to index it, so we publish an empty (but valid)
  # `<urlset>` rather than advertising crawlable URLs, for both flat and index
  # modes. Note this makes `/sitemap.xml` serve a `<urlset>` (not a
  # `<sitemapindex>`) while noindex is on — an empty urlset is schema-valid and
  # harmless. Any leftover per-module files are removed.
  #
  # Keyed on `no_index_enabled?/0` alone (not `module_enabled?/0`) to stay in
  # lockstep with the `<meta name="robots" content="noindex">` directive host
  # layouts emit for the same setting — an empty sitemap always pairs with a
  # noindex meta, UNLESS the operator has explicitly opted the sitemap out via
  # `Crawlers.sitemap_exempt_from_no_index?/0` (see the caller in
  # `do_generate_all/2`). That second flag only ever narrows this branch —
  # it cannot fire on its own, and the meta directive itself is untouched by
  # it — so the lockstep still holds for every install that hasn't set it.
  defp do_generate_no_index(xsl_style, xsl_enabled) do
    Logger.debug("Sitemap: crawlers_no_index active — publishing empty sitemap")

    xml = build_urlset_xml([], xsl_style, xsl_enabled)
    FileStorage.save_index(xml)
    FileStorage.delete_all_modules()

    # Domain mode inherits noindex: an empty urlset per mapped host, so
    # previously-generated real-URL files stop being served the moment
    # noindex flips on (mirrors delete_all_modules for the legacy set).
    for %{host: host} <- DomainMode.domains() do
      FileStorage.write_domain_sitemap(host, "sitemap", xml)
    end

    cleanup_stale_domain_dirs()

    {:ok, %{index_xml: xml, modules: [], total_urls: 0}}
  end

  # Defensive: never let a Crawlers lookup error break sitemap generation.
  defp crawlers_no_index? do
    Crawlers.no_index_enabled?()
  rescue
    _ -> false
  catch
    # An unreachable database RAISES on an unowned checkout but EXITS on a
    # dead pool; rescue alone leaves the exit path unguarded.
    :exit, _ -> false
  end

  # Defensive, same as crawlers_no_index?/0 above. Errs toward `false` (not
  # exempt) on lookup failure, i.e. toward the pre-existing blank-on-noindex
  # behavior rather than toward silently publishing a full sitemap.
  defp sitemap_exempt_from_no_index? do
    Crawlers.sitemap_exempt_from_no_index?()
  rescue
    _ -> false
  catch
    :exit, _ -> false
  end

  # Static pages (home page, and whatever else a host lists in
  # `sitemap_static_routes` / `sitemap_custom_urls`) are collected for the site
  # default language only: in a prefix install a "/fr/..." static page has no
  # localized route. A language with a domain of its own does serve them — from
  # its own host, prefix-free — so its copies are collected HERE, for the domain
  # files alone.
  #
  # Deliberately not folded into the shared collection: these entries carry a
  # locale prefix that only means something to `DomainMode.rebuild_for_domains/2`,
  # which strips it while re-hosting. In the legacy set (served verbatim to
  # unmapped hosts) nothing would rewrite them, and `https://<primary>/fr/` is
  # usually not a page at all.
  defp domain_static_entries(opts, base_url) do
    static = PhoenixKit.Modules.Sitemap.Sources.Static

    if static in get_sources() do
      mapped = MapSet.new(DomainMode.domains(), & &1.language)

      get_languages()
      |> Enum.reject(& &1.is_default)
      |> Enum.filter(&MapSet.member?(mapped, &1.code))
      |> Enum.flat_map(fn %{code: code} ->
        static.collect(
          Keyword.merge(opts,
            base_url: base_url,
            language: code,
            is_default_language: false,
            domain_pass: true
          )
        )
      end)
    else
      []
    end
  end

  # Multi-domain post-processing (DomainMode): one flat urlset per mapped
  # host (its language's entries, re-hosted prefix-free, cross-domain
  # alternates), with the 50k splitter per host. `entries` may be passed in
  # (flat mode reuses its collection) or nil (index mode — collected here).
  # Inactive provider ⇒ no-op; stale host dirs are always cleaned up.
  defp generate_domain_sitemaps(entries, base_url, opts, xsl_style, xsl_enabled) do
    if DomainMode.active?() do
      # Index mode re-collects here (flat mode passes its entries through).
      # Accepted trade-off: one extra collection pass per scheduled/on-demand
      # generation, in exchange for zero restructuring of the per-module
      # legacy path. Content changing between the two passes within one run
      # can skew module stats vs domain files until the next generation.
      #
      # Deliberately NOT `force: true` here: index mode's own per-module
      # files honor each source's `enabled?/0` (see `generate_module/2`),
      # and domain files must match that guarantee. Flat mode's pre-existing
      # force-collect (a documented, separate trade-off) only reaches this
      # function via the already-collected `entries` argument, so it is
      # untouched by this line — forcing it here would additionally leak
      # disabled-module URLs into index mode, which never leaked before.
      entries = entries || collect_all_entries(opts, get_sources())

      per_host =
        DomainMode.rebuild_for_domains(entries ++ domain_static_entries(opts, base_url), base_url)

      for {host, host_entries} <- per_host do
        write_domain_files(host, host_entries, xsl_style, xsl_enabled)
      end

      Logger.info("Sitemap: Generated domain sitemaps for #{map_size(per_host)} hosts")
    end

    cleanup_stale_domain_dirs()
    :ok
  end

  defp write_domain_files(host, entries, xsl_style, xsl_enabled) do
    if length(entries) > @max_urls_per_file do
      chunks = Enum.chunk_every(entries, @max_urls_per_file)

      filenames =
        chunks
        |> Enum.with_index(1)
        |> Enum.map(fn {chunk, i} ->
          filename = "sitemap-#{i}"
          xml = build_urlset_xml(chunk, xsl_style, xsl_enabled)
          FileStorage.write_domain_sitemap(host, filename, xml)
          Cache.put({:domain_xml, host, filename}, xml)
          filename
        end)

      index_xml = build_domain_index_xml(host, filenames, xsl_style, xsl_enabled)
      FileStorage.write_domain_sitemap(host, "sitemap", index_xml)
      Cache.put({:domain_xml, host, "sitemap"}, index_xml)
    else
      xml = build_urlset_xml(entries, xsl_style, xsl_enabled)
      FileStorage.write_domain_sitemap(host, "sitemap", xml)
      Cache.put({:domain_xml, host, "sitemap"}, xml)
    end
  end

  defp build_domain_index_xml(host, filenames, xsl_style, xsl_enabled) do
    lastmod = DateTime.utc_now() |> DateTime.to_iso8601()

    # Same prefix handling as the legacy generate_index/4 — hosts served
    # under a non-root url_prefix must reference their chunks there too.
    prefix =
      case PhoenixKit.Config.get_url_prefix() do
        "/" -> ""
        p -> String.trim_trailing(p, "/")
      end

    entries_xml =
      Enum.map_join(filenames, "\n", fn filename ->
        "  <sitemap>\n    <loc>https://#{host}#{prefix}/sitemaps/#{filename}.xml</loc>\n    <lastmod>#{lastmod}</lastmod>\n  </sitemap>"
      end)

    [
      @xml_declaration,
      build_index_xsl_line(xsl_style, xsl_enabled),
      @sitemapindex_open,
      entries_xml,
      @sitemapindex_close
    ]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  # Hosts with generated dirs that are no longer in the provider list.
  defp cleanup_stale_domain_dirs do
    active_hosts = MapSet.new(DomainMode.domains(), & &1.host)

    for host <- FileStorage.list_domain_hosts(),
        host not in active_hosts do
      FileStorage.delete_domain_files(host)
    end

    :ok
  rescue
    _ -> :ok
  end

  defp do_generate_index(base_url, opts, sources, xsl_style, xsl_enabled) do
    Logger.info("Sitemap: Generating sitemapindex from #{length(sources)} sources")

    # Generate per-module files
    module_infos =
      sources
      |> Enum.flat_map(fn source_module ->
        generate_module(source_module, opts)
      end)

    # Build and save sitemapindex
    index_xml = generate_index(module_infos, base_url, xsl_style, xsl_enabled)
    FileStorage.save_index(index_xml)

    # Clean up stale module files from disabled sources
    cleanup_stale_modules(module_infos)

    generate_domain_sitemaps(nil, base_url, opts, xsl_style, xsl_enabled)

    total_urls = Enum.reduce(module_infos, 0, fn info, acc -> acc + info.url_count end)

    Logger.info(
      "Sitemap: Generated #{length(module_infos)} module files, #{total_urls} total URLs"
    )

    {:ok,
     %{
       index_xml: index_xml,
       modules: module_infos,
       total_urls: total_urls
     }}
  end

  defp do_generate_flat(base_url, opts, sources, xsl_style, xsl_enabled) do
    Logger.info("Sitemap: Generating flat sitemap from #{length(sources)} sources")

    flat_opts = Keyword.put(opts, :force, true)
    entries = collect_all_entries(flat_opts, sources)
    xml = build_urlset_xml(entries, xsl_style, xsl_enabled)
    FileStorage.save_index(xml)

    # Clean up any leftover per-module files
    FileStorage.delete_all_modules()

    generate_domain_sitemaps(entries, base_url, opts, xsl_style, xsl_enabled)

    total_urls = length(entries)

    Logger.info("Sitemap: Generated flat sitemap with #{total_urls} URLs")

    {:ok,
     %{
       index_xml: xml,
       modules: [
         %SitemapFile{filename: "flat", url_count: total_urls, lastmod: UtilsDate.utc_now()}
       ],
       total_urls: total_urls
     }}
  end

  # ── Per-module generation ──────────────────────────────────────────

  @doc """
  Generates sitemap file(s) for a single source module.

  Returns a list of `%SitemapFile{}` structs (one per file generated).
  Empty sources produce no files and return [].
  """
  @spec generate_module(module(), keyword()) :: [SitemapFile.t()]
  def generate_module(source_module, opts \\ []) do
    if Source.valid_source?(source_module) and source_module.enabled?() do
      do_generate_module(source_module, opts)
    else
      []
    end
  rescue
    error ->
      Logger.warning(
        "Sitemap: Failed to generate module #{inspect(source_module)}: #{inspect(error)}"
      )

      []
  end

  defp do_generate_module(source_module, opts) do
    xsl_style = Keyword.get(opts, :xsl_style, "table")
    xsl_enabled = Keyword.get(opts, :xsl_enabled, true)
    base_filename = Source.get_sitemap_filename(source_module)

    # Check for sub-sitemaps (per-group splitting)
    case Source.get_sub_sitemaps(source_module, opts) do
      nil ->
        # Single file: collect all entries for this source
        entries = collect_source_entries(source_module, opts)
        build_and_save_module_files(entries, base_filename, xsl_style, xsl_enabled)

      sub_maps when is_list(sub_maps) ->
        # Per-group files
        sub_maps
        |> Enum.flat_map(fn {group_name, entries} ->
          filename = "#{base_filename}-#{group_name}"
          build_and_save_module_files(entries, filename, xsl_style, xsl_enabled)
        end)
    end
  end

  # Collect entries for a single source using multilingual collection
  defp collect_source_entries(source_module, opts) do
    languages = get_languages()
    multilingual_enabled = length(languages) > 1

    if multilingual_enabled do
      collect_multilingual_entries(opts, [source_module], languages)
    else
      collect_single_language_entries(opts, [source_module])
    end
  end

  # Build urlset XML, auto-split at 50k, save files, return module_info list
  defp build_and_save_module_files(entries, filename, xsl_style, xsl_enabled) do
    if entries == [] do
      # Clean up any existing file for empty source
      FileStorage.delete_module(filename)
      []
    else
      if length(entries) > @max_urls_per_file do
        # Auto-split into numbered files
        entries
        |> Enum.chunk_every(@max_urls_per_file)
        |> Enum.with_index(1)
        |> Enum.map(fn {chunk, index} ->
          numbered_filename = "#{filename}-#{index}"
          xml = build_urlset_xml(chunk, xsl_style, xsl_enabled)
          FileStorage.save_module(numbered_filename, xml)
          Cache.put_module(numbered_filename, xml)

          %SitemapFile{
            filename: numbered_filename,
            url_count: length(chunk),
            lastmod: latest_lastmod(chunk)
          }
        end)
      else
        xml = build_urlset_xml(entries, xsl_style, xsl_enabled)
        FileStorage.save_module(filename, xml)
        Cache.put_module(filename, xml)

        [
          %SitemapFile{
            filename: filename,
            url_count: length(entries),
            lastmod: latest_lastmod(entries)
          }
        ]
      end
    end
  end

  # ── Sitemapindex generation ────────────────────────────────────────

  @doc """
  Builds `<sitemapindex>` XML from a list of `%SitemapFile{}` structs.
  """
  @spec generate_index([SitemapFile.t()], String.t(), String.t(), boolean()) :: String.t()
  def generate_index(module_infos, base_url, xsl_style \\ "table", xsl_enabled \\ true) do
    xsl_line = build_index_xsl_line(xsl_style, xsl_enabled)
    normalized_base = String.trim_trailing(base_url, "/")
    prefix = PhoenixKit.Config.get_url_prefix()
    normalized_prefix = if prefix == "/", do: "", else: prefix

    sitemap_entries =
      module_infos
      |> Enum.map(fn info ->
        loc = "#{normalized_base}#{normalized_prefix}/sitemaps/#{info.filename}.xml"
        lastmod_str = format_lastmod(info.lastmod)

        """
          <sitemap>
            <loc>#{UrlEntry.escape_xml(loc)}</loc>
            <lastmod>#{lastmod_str}</lastmod>
          </sitemap>
        """
      end)

    [
      @xml_declaration,
      xsl_line,
      @sitemapindex_open,
      Enum.join(sitemap_entries, "\n"),
      @sitemapindex_close
    ]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  # ── Backward-compatible public API ─────────────────────────────────

  @doc """
  Generates XML sitemap. Returns the sitemapindex XML.

  Delegates to `generate_all/1` and returns the index XML for backward compatibility.
  """
  @spec generate_xml(keyword()) ::
          {:ok, String.t()} | {:ok, String.t(), [map()]} | {:error, any()}
  def generate_xml(opts \\ []) do
    base_url = Keyword.get(opts, :base_url)

    if is_nil(base_url) do
      {:error, :base_url_required}
    else
      case generate_all(opts) do
        {:ok, %{index_xml: xml, modules: modules}} ->
          {:ok, xml, modules}

        {:error, _} = error ->
          error
      end
    end
  end

  @doc """
  Generates HTML sitemap from all enabled sources.

  Delegates to `PhoenixKit.Modules.Sitemap.HtmlGenerator`.

  ## Options

  - `:base_url` - Base URL for building full URLs (required)
  - `:style` - Display style: "hierarchical", "grouped", or "flat" (default: "hierarchical")
  - `:cache` - Enable/disable caching (default: true)
  - `:title` - Page title (default: "Sitemap")
  """
  @spec generate_html(keyword()) :: {:ok, String.t()} | {:error, any()}
  def generate_html(opts \\ []) do
    base_url = Keyword.get(opts, :base_url)
    style = Keyword.get(opts, :style, "hierarchical")
    cache_enabled = Keyword.get(opts, :cache, true)

    cond do
      !base_url ->
        {:error, :base_url_required}

      style not in ["hierarchical", "grouped", "flat"] ->
        {:error, :invalid_style}

      crawlers_no_index?() ->
        # noindex active: never advertise URLs and never serve a stale cached
        # HTML sitemap — render an empty one, bypassing the cache.
        HtmlGenerator.generate(opts, [], :"html_#{style}", cache: false)

      true ->
        cache_key = :"html_#{style}"

        if cache_enabled do
          case Cache.get(cache_key) do
            {:ok, cached} ->
              Logger.debug("Sitemap: Using cached HTML sitemap (#{style})")
              {:ok, cached}

            :error ->
              entries = collect_all_entries(opts)
              HtmlGenerator.generate(opts, entries, cache_key)
          end
        else
          entries = collect_all_entries(opts)
          HtmlGenerator.generate(opts, entries, cache_key, cache: false)
        end
    end
  end

  @doc """
  Collects URL entries from all enabled sources.

  When the Languages module is enabled, automatically collects entries for all
  enabled languages and adds hreflang alternate links.
  """
  @spec collect_all_entries(keyword(), [module()]) :: [UrlEntry.t()]
  def collect_all_entries(opts \\ [], sources \\ get_sources()) do
    languages = get_languages()
    multilingual_enabled = length(languages) > 1

    Logger.debug(
      "Sitemap: Collecting entries from #{length(sources)} sources, " <>
        "languages: #{inspect(Enum.map(languages, & &1.code))}, multilingual: #{multilingual_enabled}"
    )

    if multilingual_enabled do
      collect_multilingual_entries(opts, sources, languages)
    else
      collect_single_language_entries(opts, sources)
    end
  end

  @doc """
  Invalidates all cached sitemaps.
  """
  @spec invalidate_cache() :: :ok
  def invalidate_cache do
    Logger.debug("Sitemap: Invalidating cache")
    Cache.invalidate()
  end

  @doc """
  Invalidates cache AND triggers async regeneration.
  """
  @spec invalidate_and_regenerate() :: {:ok, Oban.Job.t()} | {:error, term()}
  def invalidate_and_regenerate do
    Logger.info("Sitemap: Invalidating cache and triggering regeneration")
    Cache.invalidate()
    SchedulerWorker.regenerate_now()
  end

  @doc """
  Gets a specific sitemap part by index (1-based).

  Legacy function for backward compatibility with old numbered sitemap parts.
  """
  @spec get_sitemap_part(integer()) :: {:ok, String.t()} | {:error, :not_found}
  def get_sitemap_part(index) when is_integer(index) and index > 0 do
    case Cache.get(:parts) do
      {:ok, parts} when is_list(parts) ->
        case Enum.find(parts, fn part -> part.index == index end) do
          nil -> {:error, :not_found}
          %{xml: xml} -> {:ok, xml}
        end

      _ ->
        {:error, :not_found}
    end
  end

  def get_sitemap_part(_), do: {:error, :not_found}

  # ── Internal: source list ──────────────────────────────────────────

  @doc false
  def get_sources do
    base_sources =
      Application.get_env(:phoenix_kit, :sitemap, [])
      |> Keyword.get(:sources, default_sources())

    # Append sitemap sources contributed by external modules (e.g. Entities)
    # via the PhoenixKit.Module `sitemap_sources/0` callback. Deduplicated so
    # a module already listed in host config / defaults isn't run twice.
    #
    # Semantics note: a host `config :phoenix_kit, sitemap: [sources: [...]]`
    # is now a BASE list that module-contributed sources EXTEND — it no longer
    # fully replaces them. A host that intentionally pruned `:sources` will
    # still get an opted-in module's source appended. Only sources from
    # *enabled* modules are appended (see `all_sitemap_sources/0`), so a disabled
    # module emits nothing even in flat mode (where collection is forced and the
    # per-source `enabled?/0` is bypassed). In index mode the source's own
    # `enabled?/0` is still honored as a secondary gate (see `generate_module/2`).
    (base_sources ++ module_sitemap_sources())
    |> Enum.uniq()
  end

  defp module_sitemap_sources do
    PhoenixKit.ModuleRegistry.all_sitemap_sources()
  rescue
    _ -> []
  end

  defp default_sources do
    [
      PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery,
      PhoenixKit.Modules.Sitemap.Sources.Static,
      PhoenixKit.Modules.Sitemap.Sources.Publishing,
      PhoenixKit.Modules.Sitemap.Sources.Posts,
      PhoenixKit.Modules.Sitemap.Sources.Shop
    ]
  end

  # ── Internal: entry collection ─────────────────────────────────────

  defp collect_single_language_entries(opts, sources) do
    sources
    |> Enum.flat_map(fn source_module ->
      entries = Source.safe_collect(source_module, opts)
      Logger.debug("Sitemap: Collected #{length(entries)} entries from #{inspect(source_module)}")
      entries
    end)
    |> UrlEntry.dedupe_by_loc()
    |> Enum.sort_by(& &1.loc)
  end

  defp collect_multilingual_entries(opts, sources, languages) do
    base_url = Keyword.get(opts, :base_url)
    all_language_codes = Enum.map(languages, & &1.code)

    entries_by_language =
      languages
      |> Task.async_stream(
        fn lang ->
          language_opts =
            opts ++
              [
                language: lang.code,
                is_default_language: lang.is_default,
                all_languages: all_language_codes
              ]

          entries =
            sources
            |> Enum.flat_map(fn source_module ->
              Source.safe_collect(source_module, language_opts)
            end)

          {lang.code, entries}
        end,
        ordered: false,
        max_concurrency: System.schedulers_online() * 2,
        timeout: 60_000
      )
      |> Enum.reduce(%{}, fn
        {:ok, {lang_code, entries}}, acc ->
          Map.put(acc, lang_code, entries)

        {:exit, reason}, acc ->
          Logger.warning("Sitemap: Language collection failed: #{inspect(reason)}")
          acc
      end)

    all_entries =
      entries_by_language
      |> Enum.flat_map(fn {_lang, entries} -> entries end)

    non_default_codes =
      languages
      |> Enum.reject(& &1.is_default)
      |> Enum.map(& &1.code)

    alternates_by_canonical =
      all_entries
      |> Enum.filter(& &1.canonical_path)
      |> Enum.group_by(& &1.canonical_path)
      |> Enum.map(fn {canonical_path, entries} ->
        default_entry =
          Enum.find(entries, fn e ->
            # Case-insensitive: enabled codes are stored BCP-47 ("en-GB")
            # while sibling-dialect URLs render lowercase ("/en-gb/…") — a
            # case-sensitive contains? let a sibling entry pass as the
            # default and claim x-default.
            loc_down = String.downcase(e.loc)

            not Enum.any?(non_default_codes, fn code ->
              String.contains?(loc_down, "/#{String.downcase(code)}/")
            end)
          end) || List.first(entries)

        alternates =
          entries
          |> Enum.map(fn entry ->
            lang_code = extract_language_from_entry(entry, base_url)
            %{hreflang: lang_code, href: entry.loc}
          end)

        alternates =
          if default_entry do
            alternates ++ [%{hreflang: "x-default", href: default_entry.loc}]
          else
            alternates
          end

        {canonical_path, alternates}
      end)
      |> Map.new()

    all_entries
    |> Enum.map(fn entry ->
      if entry.canonical_path do
        alternates = Map.get(alternates_by_canonical, entry.canonical_path, [])
        %{entry | alternates: alternates}
      else
        entry
      end
    end)
    |> UrlEntry.dedupe_by_loc()
    |> Enum.sort_by(& &1.loc)
  end

  defp extract_language_from_entry(entry, base_url) do
    path =
      if base_url do
        String.replace(entry.loc, base_url, "")
      else
        entry.loc
      end

    case Regex.run(~r/^\/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)(?:\/|$)/, path) do
      [_, lang] -> lang
      _ -> Routes.get_default_admin_locale()
    end
  end

  # ── Internal: XML building ─────────────────────────────────────────

  defp build_urlset_xml(entries, xsl_style, xsl_enabled) do
    xml_urls = Enum.map(entries, &UrlEntry.to_xml/1)
    xsl_line = build_xsl_line(xsl_style, xsl_enabled)

    [@xml_declaration, xsl_line, @urlset_open, Enum.join(xml_urls, "\n"), @urlset_close]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp build_xsl_line(xsl_style, true) when xsl_style in @valid_xsl_styles do
    prefix = PhoenixKit.Config.get_url_prefix()
    normalized_prefix = if prefix == "/", do: "", else: prefix
    version = UtilsDate.utc_now() |> DateTime.to_unix()

    ~s(<?xml-stylesheet type="text/xsl" href="#{normalized_prefix}/assets/sitemap/#{xsl_style}?v=#{version}"?>)
  end

  defp build_xsl_line(_, _), do: ""

  defp build_index_xsl_line(xsl_style, true) when xsl_style in @valid_xsl_styles do
    prefix = PhoenixKit.Config.get_url_prefix()
    normalized_prefix = if prefix == "/", do: "", else: prefix
    version = UtilsDate.utc_now() |> DateTime.to_unix()

    ~s(<?xml-stylesheet type="text/xsl" href="#{normalized_prefix}/assets/sitemap-index/#{xsl_style}?v=#{version}"?>)
  end

  defp build_index_xsl_line(_, _), do: ""

  # ── Internal: helpers ──────────────────────────────────────────────

  defp get_languages do
    if Languages.enabled?() do
      case Languages.get_enabled_languages() do
        languages when is_list(languages) and languages != [] ->
          languages
          |> Enum.map(fn lang ->
            %{
              code: Languages.DialectMapper.extract_base(lang.code || "en"),
              is_default: lang.is_default
            }
          end)

        _ ->
          [%{code: "en", is_default: true}]
      end
    else
      [%{code: "en", is_default: true}]
    end
  rescue
    _ -> [%{code: "en", is_default: true}]
  end

  defp latest_lastmod(entries) do
    entries
    |> Enum.map(& &1.lastmod)
    |> Enum.reject(&is_nil/1)
    |> case do
      [] -> UtilsDate.utc_now()
      dates -> dates |> Enum.map(&normalize_to_datetime/1) |> Enum.max(DateTime)
    end
  end

  defp format_lastmod(nil), do: UtilsDate.utc_now() |> DateTime.to_iso8601()
  defp format_lastmod(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  defp format_lastmod(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_iso8601(ndt)
  defp format_lastmod(%Date{} = d), do: Date.to_iso8601(d)
  defp format_lastmod(_), do: UtilsDate.utc_now() |> DateTime.to_iso8601()

  defp normalize_to_datetime(%DateTime{} = dt), do: dt

  defp normalize_to_datetime(%NaiveDateTime{} = ndt) do
    DateTime.from_naive!(ndt, "Etc/UTC")
  end

  defp normalize_to_datetime(%Date{} = d) do
    DateTime.new!(d, ~T[00:00:00], "Etc/UTC")
  end

  defp normalize_to_datetime(_), do: UtilsDate.utc_now()

  # Remove module files that are no longer generated by any enabled source
  defp cleanup_stale_modules(current_module_infos) do
    current_filenames = MapSet.new(Enum.map(current_module_infos, & &1.filename))
    existing_files = FileStorage.list_module_files()

    Enum.each(existing_files, fn file ->
      unless MapSet.member?(current_filenames, file) do
        Logger.debug("Sitemap: Cleaning up stale module file: #{file}.xml")
        FileStorage.delete_module(file)
      end
    end)
  end
end