Packages

phoenix_kit

1.7.23
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 XML and HTML sitemaps by collecting URL entries from all enabled
sources (Entities, Publishing, Pages, etc.) and formatting them according to
sitemaps.org protocol.
## Features
- **XML Sitemap** - Standards-compliant XML sitemap for search engines
- **HTML Sitemap** - Human-readable sitemap with 3 display styles
- **Sitemap Index** - Automatic splitting for large sitemaps (>50,000 URLs)
- **Caching** - ETS-based caching for fast repeated access
- **Multi-source** - Collects from all enabled PhoenixKit modules
## Usage
# Generate XML sitemap
{:ok, xml} = Generator.generate_xml(base_url: "https://example.com")
# Generate HTML sitemap (hierarchical style)
{:ok, html} = Generator.generate_html(
base_url: "https://example.com",
style: "hierarchical"
)
# Clear cache when content changes
Generator.invalidate_cache()
## HTML Styles
- `"hierarchical"` - Tree structure with categories and nested lists
- `"grouped"` - Sections grouped by source/category with headers
- `"flat"` - Simple list of all URLs
## Sitemap Index
When a sitemap exceeds 50,000 URLs or 50MB, it's automatically split into
multiple part files with a sitemap index file pointing to them.
## Configuration
config :phoenix_kit, :sitemap,
cache_enabled: true,
max_urls_per_file: 50_000,
sources: [
PhoenixKit.Modules.Sitemap.Sources.Static,
PhoenixKit.Modules.Sitemap.Sources.Publishing
]
"""
require Logger
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Sitemap.Cache
alias PhoenixKit.Modules.Sitemap.FileStorage
alias PhoenixKit.Modules.Sitemap.SchedulerWorker
alias PhoenixKit.Modules.Sitemap.Sources.Source
alias PhoenixKit.Modules.Sitemap.UrlEntry
@max_urls_per_file 50_000
@xml_declaration ~s(<?xml version="1.0" encoding="UTF-8"?>)
# Include xhtml namespace for hreflang alternate links
@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
@valid_xsl_styles ["table", "cards", "minimal"]
@doc """
Generates XML sitemap from all enabled sources.
Returns either a single sitemap or a sitemap index if URLs exceed limit.
## Options
- `:base_url` - Base URL for building full URLs (required)
- `:language` - Preferred language for content (optional)
- `:cache` - Enable/disable caching (default: true)
- `:xsl_style` - XSL stylesheet style: "table", "cards", or "minimal" (optional)
- `:xsl_enabled` - Enable XSL stylesheet reference in XML (default: true)
## XSL Stylesheets
When `xsl_enabled` is true (default), the generated XML includes an XSL stylesheet
reference that allows browsers to render the sitemap as a styled HTML page.
Available styles:
- `"table"` - Clean table layout similar to Yoast SEO
- `"cards"` - Cards grouped by category
- `"minimal"` - Simple list of links
## Returns
- `{:ok, xml_string}` - Generated sitemap XML
- `{:ok, xml_string, parts}` - Sitemap index with parts list
- `{:error, reason}` - Generation failed
## Examples
{:ok, xml} = Generator.generate_xml(base_url: "https://example.com")
{:ok, xml} = Generator.generate_xml(
base_url: "https://example.com",
xsl_style: "cards"
)
{:ok, index_xml, parts} = Generator.generate_xml(
base_url: "https://example.com",
cache: false
)
"""
@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)
cache_enabled = Keyword.get(opts, :cache, true)
xsl_style = Keyword.get(opts, :xsl_style, "table")
xsl_enabled = Keyword.get(opts, :xsl_enabled, true)
cond do
is_nil(base_url) ->
{:error, :base_url_required}
cache_enabled ->
generate_xml_cached(base_url, xsl_style, xsl_enabled, opts)
true ->
generate_xml_fresh(base_url, xsl_style, xsl_enabled, opts)
end
end
# Generate XML with caching support
defp generate_xml_cached(base_url, xsl_style, xsl_enabled, opts) do
xml_cache_key = :"xml_#{xsl_style}"
case Cache.get(xml_cache_key) do
{:ok, cached_xml} ->
Logger.debug("Sitemap: Using cached XML (#{xsl_style})")
{:ok, cached_xml}
:error ->
generate_and_cache_xml(base_url, xsl_style, xsl_enabled, opts, xml_cache_key)
end
end
# Generate fresh XML and cache if successful
defp generate_and_cache_xml(base_url, xsl_style, xsl_enabled, opts, cache_key) do
Logger.debug("Sitemap: XML cache miss (#{xsl_style}), generating...")
result = generate_xml_fresh(base_url, xsl_style, xsl_enabled, opts)
# Cache only single sitemap results (not sitemap index with parts)
case result do
{:ok, xml} ->
# Save to ETS cache for background regeneration
Cache.put(cache_key, xml)
# Save to file (primary storage, used by controller)
FileStorage.save(xml)
result
_ ->
result
end
end
# Generate XML without caching (fresh generation)
defp generate_xml_fresh(base_url, xsl_style, xsl_enabled, opts) do
entries = get_or_collect_entries(opts)
build_xml_with_style(entries, base_url, xsl_style, xsl_enabled, opts)
end
# Get entries from cache or collect fresh
defp get_or_collect_entries(opts) do
cache_enabled = Keyword.get(opts, :cache, true)
if cache_enabled do
case Cache.get(:entries) do
{:ok, cached_entries} ->
Logger.debug("Sitemap: Using cached entries (#{length(cached_entries)} URLs)")
cached_entries
:error ->
Logger.debug("Sitemap: Entries cache miss, collecting...")
new_entries = collect_all_entries(opts)
Cache.put(:entries, new_entries)
new_entries
end
else
collect_all_entries(opts)
end
end
# Build XML from entries with specific XSL style
defp build_xml_with_style(entries, base_url, xsl_style, xsl_enabled, opts) do
if length(entries) > @max_urls_per_file do
generate_sitemap_index(entries, base_url, opts)
else
xml_urls = Enum.map(entries, &UrlEntry.to_xml/1)
xsl_line = build_xsl_line(xsl_style, xsl_enabled)
xml =
[@xml_declaration, xsl_line, @urlset_open, Enum.join(xml_urls, "\n"), @urlset_close]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
{:ok, xml}
end
end
# Build XSL stylesheet reference line with cache-busting version
defp build_xsl_line(xsl_style, true) when xsl_style in @valid_xsl_styles do
prefix = PhoenixKit.Config.get_url_prefix()
# Add timestamp for cache-busting when style changes
version = DateTime.utc_now() |> DateTime.to_unix()
~s(<?xml-stylesheet type="text/xsl" href="#{prefix}/assets/sitemap/#{xsl_style}?v=#{version}"?>)
end
defp build_xsl_line(_, _), do: ""
@doc """
Generates HTML sitemap from all enabled sources.
## Options
- `:base_url` - Base URL for building full URLs (required)
- `:style` - Display style: "hierarchical", "grouped", or "flat" (default: "hierarchical")
- `:language` - Preferred language for content (optional)
- `:cache` - Enable/disable caching (default: true)
- `:title` - Page title (default: "Sitemap")
## Examples
{:ok, html} = Generator.generate_html(
base_url: "https://example.com",
style: "hierarchical"
)
{:ok, html} = Generator.generate_html(
base_url: "https://example.com",
style: "grouped",
title: "Site Navigation"
)
"""
@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}
true ->
cache_key = :"html_#{style}"
# Check cache first
if cache_enabled do
case Cache.get(cache_key) do
{:ok, cached} ->
Logger.debug("Sitemap: Using cached HTML sitemap (#{style})")
{:ok, cached}
:error ->
generate_and_cache_html(opts, cache_key)
end
else
generate_and_cache_html(opts, 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 between language versions.
## Options
- `:base_url` - Base URL for building full URLs (required)
- `:language` - Preferred language for content (optional, auto-detected if Languages enabled)
- `:sources` - List of source modules to collect from (optional)
- `:multilingual` - Enable multilingual collection (default: auto-detect from Languages module)
## Examples
entries = Generator.collect_all_entries(base_url: "https://example.com")
length(entries) # => 1234
"""
@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
# Collect entries for a single language (legacy behavior)
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)
|> Enum.uniq_by(& &1.loc)
|> Enum.sort_by(& &1.loc)
end
# Collect entries for all languages and add hreflang alternates
defp collect_multilingual_entries(opts, sources, languages) do
base_url = Keyword.get(opts, :base_url)
all_language_codes = Enum.map(languages, & &1.code)
# Collect entries for each language IN PARALLEL
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)
# Group entries by canonical_path and add hreflang alternates
all_entries =
entries_by_language
|> Enum.flat_map(fn {_lang, entries} -> entries end)
# Get non-default language codes for default entry detection
non_default_codes =
languages
|> Enum.reject(& &1.is_default)
|> Enum.map(& &1.code)
# Build alternates map by canonical_path
alternates_by_canonical =
all_entries
|> Enum.filter(& &1.canonical_path)
|> Enum.group_by(& &1.canonical_path)
|> Enum.map(fn {canonical_path, entries} ->
# Find default language entry for x-default
# Entry from default language has no language prefix in loc
default_entry =
Enum.find(entries, fn e ->
not Enum.any?(non_default_codes, fn code ->
String.contains?(e.loc, "/#{code}/")
end)
end) || List.first(entries)
alternates =
entries
|> Enum.map(fn entry ->
# Extract language from entry (stored during collection)
lang_code = extract_language_from_entry(entry, base_url)
%{hreflang: lang_code, href: entry.loc}
end)
# Add x-default pointing to default language
alternates =
if default_entry do
alternates ++ [%{hreflang: "x-default", href: default_entry.loc}]
else
alternates
end
{canonical_path, alternates}
end)
|> Map.new()
# Add alternates to each entry
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)
|> Enum.uniq_by(& &1.loc)
|> Enum.sort_by(& &1.loc)
end
# Extract language code from entry URL
defp extract_language_from_entry(entry, base_url) do
# Try to extract language from URL path
path =
if base_url do
String.replace(entry.loc, base_url, "")
else
entry.loc
end
# Check for language prefix pattern like /en/, /et/, /fr/
case Regex.run(~r/^\/([a-z]{2})(?:\/|$)/, path) do
[_, lang] -> lang
# Default to English if no prefix found
_ -> "en"
end
end
@doc """
Invalidates all cached sitemaps.
Should be called when content changes (new pages, updated content, etc).
## Examples
Generator.invalidate_cache()
"""
@spec invalidate_cache() :: :ok
def invalidate_cache do
Logger.debug("Sitemap: Invalidating cache")
Cache.invalidate()
end
@doc """
Invalidates cache AND triggers async regeneration.
This is the preferred method for clearing cache from Admin UI,
as it immediately starts background regeneration via Oban.
File-based fallback ensures users still get content during regeneration.
## Returns
- `{:ok, job}` - Regeneration job scheduled
- `{:error, reason}` - Failed to schedule job
## Examples
case Generator.invalidate_and_regenerate() do
{:ok, _job} -> flash(:info, "Regenerating...")
{:error, reason} -> flash(:error, reason)
end
"""
@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).
Used when sitemap is split into multiple files due to size limits.
## Examples
{:ok, xml} = Generator.get_sitemap_part(1)
{:error, :not_found} = Generator.get_sitemap_part(999)
"""
@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 ->
{:error, :not_found}
_ ->
{:error, :not_found}
end
end
def get_sitemap_part(_), do: {:error, :not_found}
# Private functions
defp generate_and_cache_html(opts, cache_key, cache_opts \\ []) do
entries = collect_all_entries(opts)
style = Keyword.get(opts, :style, "hierarchical")
title = Keyword.get(opts, :title, "Sitemap")
cache_enabled = Keyword.get(cache_opts, :cache, true)
html =
case style do
"hierarchical" -> generate_hierarchical_html(entries, title)
"grouped" -> generate_grouped_html(entries, title)
"flat" -> generate_flat_html(entries, title)
end
result = {:ok, html}
# Cache result (ETS + file)
if cache_enabled do
Cache.put(cache_key, html)
# Also save to file for fallback after restart
FileStorage.save("html_#{style}", html)
end
result
end
defp generate_sitemap_index(entries, base_url, opts) do
# Split entries into chunks
chunks = Enum.chunk_every(entries, @max_urls_per_file)
xsl_style = Keyword.get(opts, :xsl_style, "table")
xsl_enabled = Keyword.get(opts, :xsl_enabled, true)
xsl_line = build_xsl_line(xsl_style, xsl_enabled)
# Generate part files metadata
parts =
chunks
|> Enum.with_index(1)
|> Enum.map(fn {chunk, index} ->
part_urls = Enum.map(chunk, &UrlEntry.to_xml/1)
part_xml =
[@xml_declaration, @urlset_open, Enum.join(part_urls, "\n"), @urlset_close]
|> Enum.join("\n")
lastmod =
chunk
|> Enum.map(& &1.lastmod)
|> Enum.reject(&is_nil/1)
|> case do
[] ->
DateTime.utc_now()
dates ->
dates
|> Enum.map(&normalize_to_datetime/1)
|> Enum.max(DateTime)
end
%{
index: index,
loc: "#{base_url}/sitemap-#{index}.xml",
lastmod: lastmod,
xml: part_xml,
url_count: length(chunk)
}
end)
# Generate sitemap index
sitemap_entries =
Enum.map(parts, fn part ->
lastmod_str =
case part.lastmod do
%DateTime{} = dt -> DateTime.to_iso8601(dt)
%NaiveDateTime{} = ndt -> NaiveDateTime.to_iso8601(ndt)
%Date{} = d -> Date.to_iso8601(d)
_ -> DateTime.utc_now() |> DateTime.to_iso8601()
end
"""
<sitemap>
<loc>#{UrlEntry.escape_xml(part.loc)}</loc>
<lastmod>#{lastmod_str}</lastmod>
</sitemap>
"""
end)
# XML declaration MUST be first, then XSL stylesheet PI, then content
index_xml =
[
@xml_declaration,
xsl_line,
@sitemapindex_open,
Enum.join(sitemap_entries, "\n"),
@sitemapindex_close
]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
{:ok, index_xml, parts}
end
# Generate HTML head section with pure inline CSS (no external dependencies)
# This ensures iframe preview works without CDN/JavaScript issues
defp html_head(title) do
"""
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>#{UrlEntry.escape_xml(title)}</title>
<style>
:root {
--base-100: #ffffff;
--base-200: #f3f4f6;
--base-content: #1f2937;
--primary: #3b82f6;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--base-100: #1f2937;
--base-200: #374151;
--base-content: #f3f4f6;
--primary: #60a5fa;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--base-100);
color: var(--base-content);
line-height: 1.5;
}
.container { width: 100%; max-width: 1280px; margin: 0 auto; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.max-w-4xl { max-width: 56rem; }
.max-w-6xl { max-width: 72rem; }
.text-4xl { font-size: 2.25rem; font-weight: 700; }
.text-lg { font-size: 1.125rem; }
.text-sm { font-size: 0.875rem; }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.text-center { text-align: center; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mt-2 { margin-top: 0.5rem; }
.ml-4 { margin-left: 1rem; }
.card {
background-color: var(--base-200);
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card-body { padding: 1.5rem; }
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.grid { display: grid; gap: 1rem; }
.gap-4 { gap: 1rem; }
@media (min-width: 768px) {
.md\\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.md\\:columns-2 { column-count: 2; }
}
@media (min-width: 1024px) {
.lg\\:columns-3 { column-count: 3; }
}
ul { list-style: none; }
.list-disc { list-style-type: disc; }
.list-inside { list-style-position: inside; }
.space-y-1 > * + * { margin-top: 0.25rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
a.link {
color: var(--primary);
text-decoration: none;
transition: opacity 0.15s;
}
a.link:hover { opacity: 0.8; text-decoration: underline; }
.text-muted { opacity: 0.7; }
.columns-1 { column-count: 1; }
</style>
"""
end
defp generate_hierarchical_html(entries, title) do
# Group by category, then by first letter
grouped =
entries
|> Enum.group_by(fn entry -> entry.category || "Other" end)
|> Enum.sort_by(fn {category, _} -> category end)
category_sections =
Enum.map_join(grouped, "\n", fn {category, category_entries} ->
# Group by first letter
letter_groups =
category_entries
|> Enum.group_by(fn entry ->
title = entry.title || entry.loc
String.upcase(String.at(title, 0) || "")
end)
|> Enum.sort_by(fn {letter, _} -> letter end)
letter_sections =
Enum.map_join(letter_groups, "\n", fn {letter, letter_entries} ->
links =
letter_entries
|> Enum.sort_by(fn entry -> entry.title || entry.loc end)
|> Enum.map_join("\n ", fn entry ->
display_title = entry.title || entry.loc
~s(<li><a href="#{UrlEntry.escape_xml(entry.loc)}" class="link">#{UrlEntry.escape_xml(display_title)}</a></li>)
end)
"""
<div class="mb-4">
<h4 class="text-sm font-semibold text-muted mb-2">#{letter}</h4>
<ul class="ml-4 space-y-1">
#{links}
</ul>
</div>
"""
end)
"""
<div class="card mb-4">
<div class="card-body">
<h3 class="card-title text-lg">#{UrlEntry.escape_xml(category)}</h3>
<div class="mt-2">
#{letter_sections}
</div>
</div>
</div>
"""
end)
"""
<!DOCTYPE html>
<html>
<head>
#{html_head(title)}</head>
<body>
<div class="container px-4 py-8 max-w-6xl">
<h1 class="text-4xl font-bold mb-8 text-center">#{UrlEntry.escape_xml(title)}</h1>
<div class="grid md:grid-cols-2 gap-4">
#{category_sections}
</div>
</div>
</body>
</html>
"""
end
defp generate_grouped_html(entries, title) do
# Group by source or category
grouped =
entries
|> Enum.group_by(fn entry ->
entry.category || (entry.source && to_string(entry.source)) || "Other"
end)
|> Enum.sort_by(fn {group, _} -> group end)
sections =
Enum.map_join(grouped, "\n", fn {group, group_entries} ->
links =
group_entries
|> Enum.sort_by(fn entry -> entry.title || entry.loc end)
|> Enum.map_join("\n ", fn entry ->
display_title = entry.title || entry.loc
~s(<li><a href="#{UrlEntry.escape_xml(entry.loc)}" class="link">#{UrlEntry.escape_xml(display_title)}</a></li>)
end)
"""
<div class="card mb-6">
<div class="card-body">
<h2 class="card-title">#{UrlEntry.escape_xml(group)}</h2>
<ul class="list-disc list-inside space-y-2 mt-2">
#{links}
</ul>
<div class="text-sm text-muted mt-2">
#{length(group_entries)} page#{if length(group_entries) != 1, do: "s", else: ""}
</div>
</div>
</div>
"""
end)
"""
<!DOCTYPE html>
<html>
<head>
#{html_head(title)}</head>
<body>
<div class="container px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-8 text-center">#{UrlEntry.escape_xml(title)}</h1>
#{sections}
</div>
</body>
</html>
"""
end
defp generate_flat_html(entries, title) do
links =
entries
|> Enum.sort_by(fn entry -> entry.title || entry.loc end)
|> Enum.map_join("\n ", fn entry ->
display_title = entry.title || entry.loc
~s(<li><a href="#{UrlEntry.escape_xml(entry.loc)}" class="link">#{UrlEntry.escape_xml(display_title)}</a></li>)
end)
"""
<!DOCTYPE html>
<html>
<head>
#{html_head(title)}</head>
<body>
<div class="container px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-8 text-center">#{UrlEntry.escape_xml(title)}</h1>
<div class="card">
<div class="card-body">
<p class="text-muted mb-4">Total: #{length(entries)} pages</p>
<ul class="list-disc list-inside space-y-2 columns-1 md:columns-2 lg:columns-3">
#{links}
</ul>
</div>
</div>
</div>
</body>
</html>
"""
end
defp get_sources do
# Get configured sources or use default list
Application.get_env(:phoenix_kit, :sitemap, [])
|> Keyword.get(:sources, default_sources())
end
defp default_sources do
# List of default source modules
# RouterDiscovery first to auto-discover all GET routes from parent router
# Note: Pages content is now handled by Entities source (universal entity support)
[
PhoenixKit.Modules.Sitemap.Sources.RouterDiscovery,
PhoenixKit.Modules.Sitemap.Sources.Static,
PhoenixKit.Modules.Sitemap.Sources.Publishing,
PhoenixKit.Modules.Sitemap.Sources.Entities,
PhoenixKit.Modules.Sitemap.Sources.Posts
]
end
# Get list of enabled languages from Languages module
# Returns list of maps with :code and :is_default keys
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"] || lang[:code] || "en"),
is_default: lang["is_default"] || lang[:is_default] || false
}
end)
_ ->
[%{code: "en", is_default: true}]
end
else
[%{code: "en", is_default: true}]
end
rescue
_ ->
# Languages module not available or error
[%{code: "en", is_default: true}]
end
# Helper to normalize dates to DateTime for comparison
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: DateTime.utc_now()
end