Packages
phoenix_kit
1.7.96
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
Current section
Files
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.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 (for group listing pages)
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
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
# Build group path with PhoenixKit prefix and optional language
# Format: /{prefix}/{lang?}/{segments...}
# When in single language mode, no language prefix is added for anyone
# When in multi-language mode, ALL languages get prefix (including default)
defp build_group_path(segments, language, _is_default) do
prefix_parts = url_prefix_segments()
# Add language prefix when:
# 1. Language is specified
# 2. Multiple languages are enabled (not single language mode)
lang_parts =
if language && !single_language_mode?() do
# Use display code to match controller's canonical URL logic
# This returns base code ("en") when single dialect enabled,
# or full code ("en-US") when multiple dialects enabled
[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
# Check if we're in single language mode (no locale prefix needed)
# Returns true when languages module is off OR only one language is enabled
# Mirrors PublishingHTML.single_language_mode?/0 logic
defp single_language_mode? do
not Languages.enabled?() or length(Languages.get_enabled_languages()) <= 1
rescue
_ -> true
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