Packages

phoenix_kit

2.41.2
2.63.0 2.62.2 2.62.1 2.62.0 2.61.0 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 sources publishing.ex
Raw

lib/modules/sitemap/sources/publishing.ex

defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do
  @moduledoc """
  Publishing source for sitemap generation.

  Collects published posts from the PhoenixKit Publishing system.
  Includes both group listing pages and individual post pages.

  ## URL Structure

  Uses PhoenixKit URL prefix from config:
  - Group listing: `/{prefix}/{group_slug}` (default language)
  - Group listing: `/{prefix}/{lang}/{group_slug}` (non-default language)

  For slug mode posts:
  - `/{prefix}/{group_slug}/{post_slug}` (default language)
  - `/{prefix}/{lang}/{group_slug}/{post_slug}` (non-default language)

  For timestamp mode posts:
  - Single post on date: `/{prefix}/{group_slug}/{date}` (e.g., /blog/2025-12-09)
  - Multiple posts on date: `/{prefix}/{group_slug}/{date}/{time}` (e.g., /blog/2025-12-09/16:26)

  ## Exclusion

  Posts can be excluded by setting `post.metadata.sitemap_exclude = true`.

  ## Sitemap Properties

  - Group listings:
    - Priority: 0.7
    - Change frequency: daily
    - Category: Group name

  - Individual posts:
    - Priority: 0.8
    - Change frequency: weekly
    - Category: Group name
    - Last modified: Post's date_updated or timestamp
  """

  @behaviour PhoenixKit.Modules.Sitemap.Sources.Source

  @compile {:no_warn_undefined,
            [
              {PhoenixKit.Modules.Publishing, :enabled?, 0},
              {PhoenixKit.Modules.Publishing, :list_groups, 0},
              {PhoenixKit.Modules.Publishing, :list_posts, 2}
            ]}

  require Logger

  alias PhoenixKit.Config
  alias PhoenixKit.Modules.Languages
  alias PhoenixKit.Modules.Sitemap.LocalePath
  alias PhoenixKit.Modules.Sitemap.UrlEntry

  @publishing_mod PhoenixKit.Modules.Publishing

  # Future: Hook into Publishing post creation/update to invalidate sitemap-publishing

  @impl true
  def source_name, do: :publishing

  @impl true
  def sitemap_filename, do: "sitemap-publishing"

  @doc """
  When `sitemap_publishing_split_by_group` is enabled, returns per-group sub-sitemaps.
  Otherwise returns nil (single file).
  """
  @impl true
  def sub_sitemaps(opts) do
    if split_by_group?() and enabled?() do
      base_url = Keyword.get(opts, :base_url)
      language = Keyword.get(opts, :language)
      is_default = Keyword.get(opts, :is_default_language, true)

      groups = @publishing_mod.list_groups()
      included_groups = Enum.reject(groups, &group_excluded?/1)

      sub_maps =
        included_groups
        |> Enum.map(fn group ->
          slug = group["slug"]

          entries =
            collect_group_listings([group], language, is_default, base_url) ++
              collect_group_posts(group, language, is_default, base_url)

          {slug, entries}
        end)
        |> Enum.reject(fn {_slug, entries} -> entries == [] end)

      if sub_maps == [], do: nil, else: sub_maps
    else
      nil
    end
  rescue
    _ -> nil
  end

  defp split_by_group? do
    PhoenixKit.Settings.get_boolean_setting("sitemap_publishing_split_by_group", false)
  rescue
    _ -> false
  end

  @impl true
  def enabled? do
    Code.ensure_loaded?(@publishing_mod) and
      function_exported?(@publishing_mod, :enabled?, 0) and
      @publishing_mod.enabled?()
  rescue
    _ -> false
  end

  @impl true
  def collect(opts \\ []) do
    if enabled?() do
      base_url = Keyword.get(opts, :base_url)
      language = Keyword.get(opts, :language)
      is_default = Keyword.get(opts, :is_default_language, true)

      groups = @publishing_mod.list_groups()

      # Filter out groups with sitemap_exclude setting
      included_groups = Enum.reject(groups, &group_excluded?/1)

      group_listings = collect_group_listings(included_groups, language, is_default, base_url)

      group_posts =
        Enum.flat_map(included_groups, fn group ->
          collect_group_posts(group, language, is_default, base_url)
        end)

      group_listings ++ group_posts
    else
      []
    end
  rescue
    error ->
      Logger.warning("Publishing sitemap source failed to collect: #{inspect(error)}")

      []
  end

  # Check if group is excluded from sitemap via settings
  defp group_excluded?(group) do
    case group do
      %{"sitemap_exclude" => true} -> true
      %{"sitemap_exclude" => "true"} -> true
      %{"settings" => %{"sitemap_exclude" => true}} -> true
      %{"settings" => %{"sitemap_exclude" => "true"}} -> true
      _ -> false
    end
  end

  defp collect_group_listings(groups, language, is_default, base_url) do
    groups
    |> Enum.filter(fn group -> group_has_posts_for_language?(group, language) end)
    |> Enum.map(fn group ->
      slug = group["slug"]
      name = group["name"]
      # Canonical path without language prefix (for hreflang grouping)
      canonical_path = build_group_path([slug], nil, true)
      path = build_group_path([slug], language, is_default)
      url = build_url(path, base_url)

      UrlEntry.new(%{
        loc: url,
        lastmod: latest_post_date(slug, language),
        changefreq: "daily",
        priority: 0.7,
        title: name,
        category: name,
        source: :publishing,
        canonical_path: canonical_path
      })
    end)
  rescue
    error ->
      Logger.warning("Failed to collect group listings: #{inspect(error)}")

      []
  end

  # Check if a group has at least one published post for the given language
  defp group_has_posts_for_language?(group, language) do
    slug = group["slug"]
    post_language = language || get_default_language()

    @publishing_mod.list_posts(slug, post_language)
    |> Enum.filter(&published?/1)
    |> Enum.reject(&excluded?/1)
    |> Enum.any?(fn post -> has_translation?(post, language) end)
  rescue
    _ -> false
  end

  defp collect_group_posts(group, language, is_default, base_url) do
    slug = group["slug"]
    name = group["name"]
    post_language = language || get_default_language()

    posts =
      @publishing_mod.list_posts(slug, post_language)
      |> Enum.filter(&published?/1)
      |> Enum.reject(&excluded?/1)
      |> Enum.filter(fn post -> has_translation?(post, language) end)

    # Optimization: Pre-compute date counts for timestamp mode posts
    date_counts = build_date_counts_cache(posts)

    Enum.map(posts, fn post ->
      build_post_entry(post, slug, name, language, is_default, base_url, date_counts)
    end)
  rescue
    error ->
      Logger.warning(
        "Failed to collect posts for group #{inspect(group["slug"])}: #{inspect(error)}"
      )

      []
  end

  # Build a map of date -> post count for timestamp mode posts
  defp build_date_counts_cache(posts) do
    posts
    |> Enum.filter(fn post -> post.mode == :timestamp end)
    |> Enum.map(&extract_date_for_url/1)
    |> Enum.frequencies()
  end

  defp published?(post) do
    case post do
      %{metadata: %{status: "published"}} -> true
      %{metadata: %{"status" => "published"}} -> true
      _ -> false
    end
  end

  defp excluded?(post) do
    case post do
      %{metadata: %{sitemap_exclude: true}} -> true
      %{metadata: %{"sitemap_exclude" => true}} -> true
      %{metadata: %{sitemap_exclude: "true"}} -> true
      %{metadata: %{"sitemap_exclude" => "true"}} -> true
      _ -> false
    end
  end

  # Check if post has translation for the requested language.
  # Returns true if:
  # - language is nil (default language request, always include)
  # - language matches one of available_languages (exact or base code match)
  defp has_translation?(_post, nil), do: true

  defp has_translation?(post, language) do
    available = Map.get(post, :available_languages, [])
    base_lang = Languages.DialectMapper.extract_base(language)

    Enum.any?(available, fn lang ->
      lang == language || Languages.DialectMapper.extract_base(lang) == base_lang
    end)
  end

  defp build_post_entry(post, group_slug, group_name, language, is_default, base_url, date_counts) do
    # Canonical path without language prefix (for hreflang grouping)
    canonical_path = build_post_path(post, group_slug, nil, true, date_counts)
    path = build_post_path(post, group_slug, language, is_default, date_counts)
    url = build_url(path, base_url)

    title = get_post_title(post)
    lastmod = get_post_lastmod(post)

    UrlEntry.new(%{
      loc: url,
      lastmod: lastmod,
      changefreq: "weekly",
      priority: 0.8,
      title: title,
      category: group_name,
      source: :publishing,
      canonical_path: canonical_path
    })
  end

  # Build post path based on mode (slug vs timestamp)
  # Uses pre-computed date_counts cache instead of per-post queries
  defp build_post_path(post, group_slug, language, is_default, date_counts) do
    case post.mode do
      :timestamp ->
        # For timestamp mode, use date (and time if multiple posts on same date)
        date = extract_date_for_url(post)
        post_count = Map.get(date_counts, date, 1)

        if post_count > 1 do
          # Multiple posts on this date - include time
          time = extract_time_for_url(post)
          build_group_path([group_slug, date, time], language, is_default)
        else
          # Single post on this date - date only
          build_group_path([group_slug, date], language, is_default)
        end

      :slug ->
        # For slug mode, use the post slug
        post_slug = post.slug || extract_slug_from_path(post.path)
        build_group_path([group_slug, post_slug], language, is_default)

      _ ->
        # Fallback to slug mode behavior
        post_slug = post.slug || extract_slug_from_path(post.path)
        build_group_path([group_slug, post_slug], language, is_default)
    end
  end

  # Extract date string for URL from post (YYYY-MM-DD format)
  defp extract_date_for_url(post) do
    cond do
      # First try post.date (set for timestamp mode posts)
      not is_nil(post.date) ->
        Date.to_iso8601(post.date)

      # Then try metadata.published_at
      is_binary(Map.get(post.metadata, :published_at)) ->
        case DateTime.from_iso8601(post.metadata.published_at) do
          {:ok, dt, _} -> Date.to_iso8601(DateTime.to_date(dt))
          _ -> "2025-01-01"
        end

      is_binary(Map.get(post.metadata, "published_at")) ->
        case DateTime.from_iso8601(post.metadata["published_at"]) do
          {:ok, dt, _} -> Date.to_iso8601(DateTime.to_date(dt))
          _ -> "2025-01-01"
        end

      true ->
        "2025-01-01"
    end
  end

  # Extract time string for URL from post (HH:MM format)
  defp extract_time_for_url(post) do
    cond do
      # First try post.time (set for timestamp mode posts)
      not is_nil(post.time) ->
        post.time |> Time.truncate(:second) |> Time.to_string() |> String.slice(0..4)

      # Then try metadata.published_at
      is_binary(Map.get(post.metadata, :published_at)) ->
        case DateTime.from_iso8601(post.metadata.published_at) do
          {:ok, dt, _} ->
            dt
            |> DateTime.to_time()
            |> Time.truncate(:second)
            |> Time.to_string()
            |> String.slice(0..4)

          _ ->
            "00:00"
        end

      is_binary(Map.get(post.metadata, "published_at")) ->
        case DateTime.from_iso8601(post.metadata["published_at"]) do
          {:ok, dt, _} ->
            dt
            |> DateTime.to_time()
            |> Time.truncate(:second)
            |> Time.to_string()
            |> String.slice(0..4)

          _ ->
            "00:00"
        end

      true ->
        "00:00"
    end
  end

  defp extract_slug_from_path(path) do
    path
    |> Path.basename(".md")
    |> String.trim()
  end

  defp get_post_title(post) do
    case post do
      %{metadata: %{title: title}} when is_binary(title) -> title
      %{metadata: %{"title" => title}} when is_binary(title) -> title
      %{slug: slug} when is_binary(slug) -> format_slug(slug)
      _ -> "Post"
    end
  end

  # Latest lastmod among published posts in a group (drives group listing <lastmod>).
  defp latest_post_date(group_slug, language) do
    post_language = language || get_default_language()

    @publishing_mod.list_posts(group_slug, post_language)
    |> Enum.filter(&published?/1)
    |> Enum.reject(&excluded?/1)
    |> Enum.map(&get_post_lastmod/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.max(Date, fn -> nil end)
  rescue
    _ -> nil
  end

  @doc """
  Max post lastmod across all included groups, using the default language.

  Drives the homepage `<lastmod>` from `Sources.Static`. Equivalent to taking
  `max(:lastmod)` across `collect/1`'s URL entries, but skips the URL-entry
  construction work. One `list_posts/2` call per included group instead of
  three (as in `collect/1`).
  """
  @spec latest_post_date_global() :: Date.t() | nil
  def latest_post_date_global do
    if enabled?() do
      language = get_default_language()

      dates =
        @publishing_mod.list_groups()
        |> Enum.reject(&group_excluded?/1)
        |> Enum.flat_map(fn group ->
          @publishing_mod.list_posts(group["slug"], language)
          |> Enum.filter(&published?/1)
          |> Enum.reject(&excluded?/1)
          |> Enum.map(&get_post_lastmod/1)
          |> Enum.reject(&is_nil/1)
        end)

      case dates do
        [] -> nil
        _ -> Enum.max(dates, Date)
      end
    end
  rescue
    _ -> nil
  end

  defp get_post_lastmod(post) do
    case post do
      # Check metadata fields first (PhoenixKit Publishing uses published_at)
      %{metadata: %{published_at: dt}} when not is_nil(dt) and dt != "" ->
        parse_datetime(dt)

      %{metadata: %{"published_at" => dt}} when not is_nil(dt) and dt != "" ->
        parse_datetime(dt)

      %{metadata: %{date_updated: dt}} ->
        parse_datetime(dt)

      %{metadata: %{"date_updated" => dt}} ->
        parse_datetime(dt)

      %{metadata: %{updated_at: dt}} ->
        parse_datetime(dt)

      %{metadata: %{"updated_at" => dt}} ->
        parse_datetime(dt)

      # Fallback to post date/time fields (timestamp mode)
      %{date: date, time: time} when not is_nil(date) ->
        combine_date_time(date, time)

      %{date: date} when not is_nil(date) ->
        date

      _ ->
        nil
    end
  end

  defp parse_datetime(nil), do: nil
  defp parse_datetime(%DateTime{} = dt), do: dt
  defp parse_datetime(%Date{} = d), do: d

  defp parse_datetime(str) when is_binary(str) do
    case DateTime.from_iso8601(str) do
      {:ok, dt, _offset} ->
        dt

      _ ->
        case Date.from_iso8601(str) do
          {:ok, d} -> d
          _ -> nil
        end
    end
  end

  defp parse_datetime(_), do: nil

  defp combine_date_time(%Date{} = date, nil) do
    date
  end

  defp combine_date_time(%Date{} = date, %Time{} = time) do
    DateTime.new!(date, time)
  rescue
    _ -> date
  end

  defp combine_date_time(date, _), do: date

  defp format_slug(slug) do
    slug
    |> String.replace("-", " ")
    |> String.replace("_", " ")
    |> String.split()
    |> Enum.map_join(" ", &String.capitalize/1)
  end

  # Builds group path with PhoenixKit prefix and optional language.
  # Format: /{prefix}/{lang?}/{segments...}. The locale-segment policy
  # lives in `LocalePath.emit_prefix?/2` — see that module for the
  # canonical decision rules shared across all sitemap sources.
  #
  # Publishing emits the **display code** (`get_display_code/2`) for
  # the language segment so hreflang entries match the controller's
  # canonical URL logic — base code when only one dialect is enabled,
  # full dialect code when multiple dialects are enabled.
  defp build_group_path(segments, language, is_default) do
    prefix_parts = url_prefix_segments()

    lang_parts =
      if LocalePath.emit_prefix?(language, is_default) do
        [get_display_code(language)]
      else
        []
      end

    all_parts =
      prefix_parts ++
        lang_parts ++
        (segments
         |> Enum.reject(&(&1 in [nil, ""]))
         |> Enum.map(&to_string/1))

    case all_parts do
      [] -> "/"
      _ -> "/" <> Enum.join(all_parts, "/")
    end
  end

  # Get URL prefix segments from config
  defp url_prefix_segments do
    Config.get_url_prefix()
    |> case do
      "/" -> []
      prefix -> prefix |> String.trim("/") |> String.split("/", trim: true)
    end
  end

  # Get default language from the Languages module
  defp get_default_language do
    case Languages.get_default_language() do
      %{code: code} -> Languages.DialectMapper.extract_base(code)
      nil -> "en"
    end
  end

  # Get the display code for a language, matching the controller's canonical URL logic.
  # Returns base code ("en") when only one dialect is enabled,
  # or full code ("en-US") when multiple dialects of same language are enabled.
  defp get_display_code(language_code) do
    base_code = Languages.DialectMapper.extract_base(language_code)
    enabled_languages = Languages.get_enabled_languages()

    # Count how many enabled languages share this base code
    dialects_count =
      Enum.count(enabled_languages, fn lang ->
        Languages.DialectMapper.extract_base(lang) == base_code
      end)

    # If more than one dialect of this base language is enabled, show full code
    if dialects_count > 1 do
      language_code
    else
      base_code
    end
  rescue
    _ -> Languages.DialectMapper.extract_base(language_code)
  end

  # Build full URL from path and base_url
  defp build_url(path, nil) do
    # Fallback to site_url from settings
    base = PhoenixKit.Settings.get_setting("site_url", "")
    normalized_base = String.trim_trailing(base, "/")
    "#{normalized_base}#{path}"
  end

  defp build_url(path, base_url) when is_binary(base_url) do
    normalized_base = String.trim_trailing(base_url, "/")
    "#{normalized_base}#{path}"
  end
end