Packages

phoenix_kit

1.7.206
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 shop.ex
Raw

lib/modules/sitemap/sources/shop.ex

defmodule PhoenixKit.Modules.Sitemap.Sources.Shop do
@compile {:no_warn_undefined, PhoenixKitEcommerce}
@moduledoc """
Shop source for sitemap generation.
Collects catalog page, active category pages, and active product pages
from the PhoenixKit Shop module for inclusion in the sitemap.
## URL Structure
- Catalog: `/shop` (or `/et/shop` for non-default language)
- Categories: `/shop/category/:slug` (or `/et/shop/category/:slug`)
- Products: `/shop/product/:slug` (or `/et/shop/product/:slug`)
## Excluded URLs
- `/cart` — user-specific
- `/checkout` — user-specific
- `/checkout/complete/:uuid` — user-specific
## Enabling
This source is enabled when:
1. Shop module is enabled (`shop_enabled` setting)
2. Shop sitemap inclusion is enabled (`sitemap_include_shop` setting, default: true)
## Sitemap Properties
- Catalog page: priority 0.8, changefreq "daily", category "Shop"
- Categories: priority 0.7, changefreq "weekly", category "Shop > Categories"
- Products: priority 0.8, changefreq "weekly", category "Shop > Products"
"""
@behaviour PhoenixKit.Modules.Sitemap.Sources.Source
require Logger
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Sitemap.UrlEntry
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
alias PhoenixKitEcommerce, as: Shop
# Future: Hook into Shop.create_product/update_product to invalidate sitemap-shop
@impl true
def source_name, do: :shop
@impl true
def sitemap_filename, do: "sitemap-shop"
@impl true
def enabled? do
Shop.enabled?() and
Settings.get_boolean_setting("sitemap_include_shop", true)
rescue
_ -> false
end
@impl true
def collect(opts \\ []) do
if enabled?() do
do_collect(opts)
else
[]
end
rescue
error ->
Logger.warning("Shop sitemap source failed to collect: #{inspect(error)}")
[]
end
defp do_collect(opts) do
base_url = Keyword.get(opts, :base_url)
language = Keyword.get(opts, :language, default_language())
is_default = Keyword.get(opts, :is_default_language, true)
catalog_entries(base_url, language, is_default) ++
category_entries(base_url, language, is_default) ++
product_entries(base_url, language, is_default)
end
# Catalog page entry (/shop)
defp catalog_entries(base_url, language, is_default) do
canonical_path = "/shop"
url = build_url(Shop.catalog_url(language), base_url)
[
UrlEntry.new(%{
loc: url,
lastmod: nil,
changefreq: "daily",
priority: 0.8,
title: "Shop",
category: "Shop",
source: :shop,
canonical_path: build_canonical(canonical_path, language, is_default)
})
]
end
# Active category entries (/shop/category/:slug)
# Includes categories with fallback slugs from other languages
# Excludes categories with no active products (all archived/draft)
defp category_entries(base_url, language, is_default) do
# Pre-compute set of category IDs that have at least one active product
categories_with_products = active_product_category_ids()
Shop.list_active_categories()
|> Enum.filter(fn cat ->
has_any_slug?(cat) and MapSet.member?(categories_with_products, cat.uuid)
end)
|> Enum.map(fn category ->
# Use current language if slug exists, otherwise fallback to best available
effective_lang =
if has_slug?(category, language), do: language, else: best_available_language(category)
url = build_url(Shop.category_url(category, effective_lang), base_url)
canonical_path = "/shop/category/#{category_canonical_slug(category)}"
UrlEntry.new(%{
loc: url,
lastmod: category.updated_at,
changefreq: "weekly",
priority: 0.7,
title: category_name(category, language),
category: "Shop > Categories",
source: :shop,
canonical_path: build_canonical(canonical_path, language, is_default)
})
end)
rescue
error ->
Logger.warning("Failed to collect shop categories: #{inspect(error)}")
[]
end
# Active product entries (/shop/product/:slug)
# Includes products with fallback slugs from other languages
# Excludes products in hidden categories (inaccessible to users)
defp product_entries(base_url, language, is_default) do
Shop.list_products(status: "active", exclude_hidden_categories: true)
|> Enum.filter(&has_any_slug?/1)
|> Enum.map(fn product ->
# Use current language if slug exists, otherwise fallback to best available
effective_lang =
if has_slug?(product, language), do: language, else: best_available_language(product)
url = build_url(Shop.product_url(product, effective_lang), base_url)
canonical_path = "/shop/product/#{product_canonical_slug(product)}"
UrlEntry.new(%{
loc: url,
lastmod: product.updated_at,
changefreq: "weekly",
priority: 0.8,
title: product_title(product, language),
category: "Shop > Products",
source: :shop,
canonical_path: build_canonical(canonical_path, language, is_default)
})
end)
rescue
error ->
Logger.warning("Failed to collect shop products: #{inspect(error)}")
[]
end
# Extract display name from localized category name map
defp category_name(category, language) do
case category.name do
%{} = names ->
dialect = Languages.DialectMapper.base_to_dialect(language)
Map.get(names, language) || Map.get(names, dialect) || first_value(names)
name when is_binary(name) ->
name
_ ->
"Category"
end
end
# Extract display title from localized product title map
defp product_title(product, language) do
case product.title do
%{} = titles ->
dialect = Languages.DialectMapper.base_to_dialect(language)
Map.get(titles, language) || Map.get(titles, dialect) || first_value(titles)
title when is_binary(title) ->
title
_ ->
"Product"
end
end
# Get canonical slug (default language or first available) for canonical_path.
# Slug field is always a map (localized slugs) or nil.
defp category_canonical_slug(category) do
case category.slug do
%{} = slugs ->
default = default_language()
dialect = Languages.DialectMapper.base_to_dialect(default)
Map.get(slugs, default) || Map.get(slugs, dialect) || first_value(slugs) ||
to_string(category.uuid)
_ ->
to_string(category.uuid)
end
end
defp product_canonical_slug(product) do
case product.slug do
%{} = slugs ->
default = default_language()
dialect = Languages.DialectMapper.base_to_dialect(default)
Map.get(slugs, default) || Map.get(slugs, dialect) || first_value(slugs) ||
to_string(product.uuid)
_ ->
to_string(product.uuid)
end
end
# Check if entity has a slug for the given language (base code, dialect, or raw key)
defp has_slug?(entity, language) do
slug_map = entity.slug || %{}
base = Languages.DialectMapper.extract_base(language)
dialect = Languages.DialectMapper.base_to_dialect(language)
Map.has_key?(slug_map, language) or
Map.has_key?(slug_map, base) or
Map.has_key?(slug_map, dialect)
end
# Check if entity has any slug at all (in any language)
defp has_any_slug?(entity) do
case entity.slug do
%{} = slugs when map_size(slugs) > 0 -> true
_ -> false
end
end
# Find the best available language for an entity's slug.
# Prefers content default, then Routes default, then first available.
defp best_available_language(entity) do
slug_map = entity.slug || %{}
content_default = default_language()
routes_default = Routes.get_default_admin_locale()
dialect_routes = Languages.DialectMapper.base_to_dialect(routes_default)
cond do
Map.has_key?(slug_map, content_default) -> content_default
Map.has_key?(slug_map, routes_default) -> routes_default
Map.has_key?(slug_map, dialect_routes) -> dialect_routes
true -> slug_map |> Map.keys() |> List.first()
end
end
# Returns a MapSet of category IDs that have at least one active product.
# Used to exclude empty categories (all products archived/draft) from sitemap.
defp active_product_category_ids do
Shop.list_products(status: "active", exclude_hidden_categories: true)
|> Enum.map(& &1.category_uuid)
|> Enum.reject(&is_nil/1)
|> MapSet.new()
end
defp first_value(map) when map_size(map) > 0 do
map |> Map.values() |> List.first()
end
defp first_value(_), do: nil
# Build canonical path only for default language entries (used for hreflang grouping)
defp build_canonical(path, _language, true), do: path
defp build_canonical(path, _language, _is_default), do: path
defp default_language do
if Languages.enabled?() do
case Languages.get_default_language() do
%{code: code} -> Languages.DialectMapper.extract_base(code)
_ -> "en"
end
else
"en"
end
rescue
_ -> "en"
end
# Build full URL: if the path from Shop.*_url already includes the base,
# just return it; otherwise prepend base_url
defp build_url(path, nil) do
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