Packages

phoenix_kit

2.41.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 posts.ex
Raw

lib/modules/sitemap/sources/posts.ex

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

  Collects public posts from the PhoenixKit Posts system.
  Automatically detects if public routes exist for posts.

  ## URL Structure

  - Posts index: `/posts` (or with language prefix for non-default: `/et/posts`)
  - Individual posts: `/posts/:slug` (or `/et/posts/:slug`)

  ## Enabling

  This source is enabled when:
  1. Posts module is enabled (`posts_enabled` setting)
  2. Public routes exist for posts in the parent router
  3. Routes do NOT require authentication (checked via on_mount hooks)

  Routes with the following on_mount hooks are considered private and excluded:
  - `:phoenix_kit_ensure_authenticated_scope` - requires logged-in user
  - `:phoenix_kit_ensure_admin` - requires admin access
  - `:ensure_authenticated` - generic authentication requirement
  - `:require_authenticated_user` - requires authenticated user

  ## Exclusion

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

  ## Sitemap Properties

  - Posts index:
    - Priority: 0.7
    - Change frequency: daily
    - Category: "Posts"

  - Individual posts:
    - Priority: 0.8
    - Change frequency: weekly
    - Category: "Posts"
    - Last modified: Post's updated_at or published_at
  """

  @behaviour PhoenixKit.Modules.Sitemap.Sources.Source

  require Logger

  alias PhoenixKit.Modules.Languages
  alias PhoenixKit.Modules.Sitemap.LocalePath
  # PhoenixKitPosts is an optional external package
  # All calls are guarded with Code.ensure_loaded?/1
  alias PhoenixKit.Modules.Sitemap.RouteResolver
  alias PhoenixKit.Modules.Sitemap.UrlEntry
  alias PhoenixKit.Settings

  @impl true
  def source_name, do: :posts

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

  @impl true
  def enabled? do
    # Only enabled if PhoenixKitPosts package is installed and posts module is enabled
    Code.ensure_loaded?(PhoenixKitPosts) and posts_module_enabled?()
  rescue
    _ -> false
  end

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

    # Posts only generate URLs for the default language
    # Non-default language URLs would lead to 404 errors
    if enabled?() and is_default do
      do_collect(opts)
    else
      []
    end
  rescue
    error ->
      Logger.warning("Posts sitemap source failed to collect: #{inspect(error)}")
      []
  end

  defp posts_module_enabled? do
    Settings.get_setting_cached("posts_enabled", "true") == "true"
  rescue
    _ -> false
  end

  defp do_collect(opts) do
    # Optimization: Get routes ONCE for all checks
    routes = RouteResolver.get_routes()

    cond do
      # Early exit if no public posts routes exist
      not has_public_posts_route?(routes) ->
        Logger.debug("Sitemap: No public posts routes found, skipping posts source")
        []

      # Early exit if routes require authentication
      posts_route_requires_auth?(routes) ->
        Logger.debug("Sitemap: Posts routes require authentication, skipping")
        []

      # Proceed with collection
      true ->
        do_collect_posts(opts)
    end
  end

  defp do_collect_posts(opts) do
    base_url = Keyword.get(opts, :base_url)
    language = Keyword.get(opts, :language)
    is_default = Keyword.get(opts, :is_default_language, true)

    index_entry = build_index_entry(base_url, language, is_default)
    post_entries = collect_posts(base_url, language, is_default)

    [index_entry | post_entries]
    |> Enum.reject(&is_nil/1)
  end

  # Check if posts route exists in cached routes
  defp has_public_posts_route?(routes) do
    find_posts_content_route(routes) != nil
  end

  # Check if posts route requires auth using cached routes
  defp posts_route_requires_auth?(routes) do
    case find_posts_content_route(routes) do
      nil -> false
      route -> RouteResolver.route_requires_auth?(route)
    end
  end

  # Find posts content route in cached routes (reusable)
  defp find_posts_content_route(routes) do
    Enum.find(routes, fn route ->
      route.verb == :get and
        (String.contains?(route.path, ":slug") or String.contains?(route.path, ":id")) and
        posts_route_match?(route)
    end)
  end

  defp posts_route_match?(route) do
    path_lower = String.downcase(route.path)
    plug_name = route.plug |> to_string() |> String.downcase()

    String.contains?(path_lower, "/posts/") or
      String.starts_with?(path_lower, "/posts/") or
      (String.contains?(plug_name, "post") and not String.contains?(plug_name, "page"))
  end

  defp build_index_entry(base_url, language, is_default) do
    # Canonical path without language prefix (for hreflang grouping)
    canonical_path = "/posts"
    path = build_path_with_language(canonical_path, language, is_default)
    url = build_url(path, base_url)

    UrlEntry.new(%{
      loc: url,
      lastmod: nil,
      changefreq: "daily",
      priority: 0.7,
      title: "Posts",
      category: "Posts",
      source: :posts,
      canonical_path: canonical_path
    })
  end

  defp collect_posts(base_url, language, is_default) do
    if Code.ensure_loaded?(PhoenixKitPosts) do
      mod = Module.concat([PhoenixKitPosts])
      posts = mod.list_public_posts(preload: [])

      posts
      |> Enum.reject(&excluded?/1)
      |> Enum.map(fn post ->
        build_post_entry(post, base_url, language, is_default)
      end)
    else
      []
    end
  rescue
    error ->
      Logger.warning("Failed to collect posts: #{inspect(error)}")
      []
  end

  defp build_post_entry(post, base_url, language, is_default) do
    slug = post.slug || to_string(post.uuid)
    # Canonical path without language prefix (for hreflang grouping)
    canonical_path = "/posts/#{slug}"
    path = build_path_with_language(canonical_path, language, is_default)
    url = build_url(path, base_url)

    lastmod = post.updated_at || post.published_at

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

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

  # Adds the locale segment to `path` when applicable. Decision rules
  # live in `LocalePath.emit_prefix?/2` — see that module for the
  # canonical policy shared across all sitemap sources. Post sitemap
  # entries use the base code (no dialect suffix) for the segment.
  defp build_path_with_language(path, language, is_default) do
    if LocalePath.emit_prefix?(language, is_default) do
      "/#{Languages.DialectMapper.extract_base(language)}#{path}"
    else
      path
    end
  end

  defp build_url(path, nil) do
    # Fallback to site_url from settings
    base = 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