Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_publishing/groups.ex
defmodule PhoenixKit.Modules.Publishing.Groups do
@moduledoc """
Group management functions for the Publishing module.
Handles creating, listing, updating, and removing publishing groups,
as well as slug generation, type/mode normalization, and item naming.
"""
require Logger
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.ActivityLog
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.PublishingGroup
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Shared
alias PhoenixKit.Modules.Publishing.StaleFixer
alias PhoenixKit.Modules.Publishing.Constants
@default_group_mode Constants.default_mode()
@default_group_type Constants.default_type()
@preset_types Constants.preset_types()
@valid_types Constants.valid_types()
@default_featured_layout Constants.default_featured_layout()
@featured_layouts Constants.featured_layouts()
@default_newest_layout Constants.default_newest_layout()
@newest_layouts Constants.newest_layouts()
@default_scrollbar_style Constants.default_scrollbar_style()
@scrollbar_styles Constants.scrollbar_styles()
@default_listing_sort Constants.default_listing_sort()
@listing_sorts Constants.listing_sorts()
@default_timeline_granularity Constants.default_timeline_granularity()
@timeline_granularities Constants.timeline_granularities()
@default_post_date_position Constants.default_post_date_position()
@post_date_positions Constants.post_date_positions()
@default_post_width Constants.default_post_width()
@post_widths Constants.post_widths()
@type_regex ~r/^[a-z][a-z0-9-]{0,31}$/
@type_item_names %{
"blog" => {"post", "posts"},
"faq" => {"question", "questions"},
"legal" => {"document", "documents"}
}
@default_item_singular "item"
@default_item_plural "items"
@type group :: map()
@doc """
Returns all publishing groups from the database.
"""
@spec list_groups() :: [group()]
def list_groups do
DBStorage.list_groups()
|> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end)
end
@doc "Lists groups filtered by status (e.g. 'active', 'trashed')."
@spec list_groups(String.t()) :: [group()]
def list_groups(status) do
DBStorage.list_groups(status)
|> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end)
end
@doc """
Gets a publishing group by slug.
## Examples
iex> Groups.get_group("news")
{:ok, %{"name" => "News", "slug" => "news", ...}}
iex> Groups.get_group("nonexistent")
{:error, :not_found}
"""
@spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found}
def get_group(slug) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil -> {:error, :not_found}
db_group -> {:ok, db_group |> StaleFixer.fix_stale_group() |> db_group_to_map()}
end
end
@doc """
Adds a new publishing group.
## Parameters
* `name` - Display name for the group
* `opts` - Keyword list or map with options:
* `:mode` - Post mode: "timestamp" or "slug" (default: "timestamp")
* `:slug` - Optional custom slug, auto-generated from name if nil
* `:type` - Content type: "blog", "faq", "legal", or custom (default: "blog")
* `:item_singular` - Singular name for items (default: based on type, e.g., "post")
* `:item_plural` - Plural name for items (default: based on type, e.g., "posts")
## Examples
iex> Groups.add_group("News")
{:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blog", ...}}
iex> Groups.add_group("FAQ", type: "faq", mode: "slug")
{:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}}
iex> Groups.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes")
{:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}}
"""
@spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()}
def add_group(name, opts \\ [])
def add_group(name, opts) when is_binary(name) and (is_list(opts) or is_map(opts)) do
trimmed = String.trim(name)
mode = opts |> fetch_option(:mode) |> normalize_mode_with_default()
normalized_type = opts |> fetch_option(:type) |> normalize_type()
cond do
trimmed == "" ->
log_failed_group_create(opts, "", "invalid_name")
{:error, :invalid_name}
is_nil(mode) ->
log_failed_group_create(opts, trimmed, "invalid_mode")
{:error, :invalid_mode}
is_nil(normalized_type) ->
log_failed_group_create(opts, trimmed, "invalid_type")
{:error, :invalid_type}
true ->
groups = list_groups()
preferred_slug = fetch_option(opts, :slug)
with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed),
:ok <- check_slug_availability(requested_slug, groups, preferred_slug) do
slug = ensure_unique_slug(requested_slug, groups)
{default_singular, default_plural} = default_item_names(normalized_type)
item_singular =
opts
|> fetch_option(:item_singular)
|> normalize_item_name(default_singular)
item_plural =
opts
|> fetch_option(:item_plural)
|> normalize_item_name(default_plural)
db_attrs = %{
name: trimmed,
slug: slug,
mode: mode,
data: %{
"type" => normalized_type,
"item_singular" => item_singular,
"item_plural" => item_plural
}
}
create_and_broadcast_group(db_attrs, opts)
else
{:error, reason} = err ->
log_failed_group_create(opts, trimmed, to_string(reason))
err
end
end
end
@doc """
Removes a publishing group by slug.
"""
@spec remove_group(String.t()) :: {:ok, any()} | {:error, any()}
def remove_group(slug) when is_binary(slug) do
remove_group(slug, force: false)
end
@doc """
Removes a publishing group by slug.
By default, refuses to delete groups that contain posts.
Pass `force: true` to cascade-delete the group and all its posts.
"""
@spec remove_group(String.t(), keyword()) :: {:ok, any()} | {:error, any()}
def remove_group(slug, opts) when is_binary(slug) do
force = Keyword.get(opts, :force, false)
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
post_count = DBStorage.count_posts(db_group.slug)
if post_count > 0 and not force do
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => "has_posts", "post_count" => post_count}
)
{:error, {:has_posts, post_count}}
else
delete_and_broadcast_group(db_group, slug, opts)
end
end
end
@doc """
Updates a publishing group's display name and slug.
"""
@spec update_group(String.t(), map() | keyword(), keyword() | map()) ::
{:ok, group()} | {:error, atom()}
def update_group(slug, params, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
with {:ok, name} <- extract_and_validate_name(db_group, params),
{:ok, sanitized_slug} <- extract_and_validate_slug(db_group, params, name),
{:ok, updated} <-
DBStorage.update_group(db_group, %{
name: name,
slug: sanitized_slug,
data: merge_group_config(db_group.data, params)
}) do
# A slug rename orphans the old slug's listing cache entry — it leaks in
# :persistent_term and keeps serving stale data under the old key (L6).
if db_group.slug != updated.slug, do: ListingCache.invalidate(db_group.slug)
group = db_group_to_map(updated)
PublishingPubSub.broadcast_group_updated(group)
ActivityLog.log_manual(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
updated.uuid,
%{"slug" => updated.slug, "previous_slug" => db_group.slug}
)
{:ok, group}
else
{:error, reason} = err ->
ActivityLog.log_failed_mutation(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => to_string_reason(reason)}
)
err
end
end
end
# Converts an error reason from `update_group`'s with-chain into a
# short grep-able tag for activity-log metadata. Only atoms (from the
# validation helpers) and changesets (from `DBStorage.update_group`)
# reach this — keep the matching tight so a future error shape gets
# noticed via FunctionClauseError instead of a silent "error" tag.
defp to_string_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
defp to_string_reason(%Ecto.Changeset{}), do: "changeset_error"
# The full set of per-group display settings `update_group/3` persists into
# the group's `data` JSONB. `GroupSettings.schema/0` mirrors this list as a
# machine-readable spec; the key-parity test in group_settings_test.exs
# asserts the two can't drift (it compares against `config_setting_keys/0`,
# not a hand-maintained copy).
@bool_setting_keys ~w(featured_enabled newest_enabled scroll_progress_enabled
scroll_headings_enabled scroll_timeline_enabled show_breadcrumbs
show_featured_image show_reading_time show_tags show_post_count
show_top_back_link listing_image_links)
@enum_settings [
{"featured_layout", @featured_layouts},
{"newest_layout", @newest_layouts},
{"scrollbar_style", @scrollbar_styles},
{"scroll_timeline_granularity", @timeline_granularities},
{"listing_sort", @listing_sorts},
{"post_date_position", @post_date_positions},
{"post_width", @post_widths}
]
@doc false
# Source of truth for the settings keys `merge_group_config/2` persists —
# exposed (undocumented) so the GroupSettings spec test can assert parity
# against the real write path instead of a hardcoded list.
def config_setting_keys do
@bool_setting_keys ++ Enum.map(@enum_settings, &elem(&1, 0))
end
# Merges the per-group display settings from the edit form (or a programmatic
# caller) into the group's existing `data` JSONB. Only keys the caller
# actually submitted are touched, so a caller that updates just name/slug (or
# a keyword-list caller) leaves config untouched. An unknown enum value is
# ignored rather than persisted, keeping the column to the whitelist.
# `existing_data` is never nil — the schema types `data: map()` (default
# `%{}`) and every PublishingGroup accessor already relies on that.
defp merge_group_config(existing_data, params) when is_map(params) do
data =
Enum.reduce(@bool_setting_keys, existing_data, fn key, acc ->
merge_bool_key(acc, params, key)
end)
data =
Enum.reduce(@enum_settings, data, fn {key, allowed}, acc ->
merge_enum_key(acc, params, key, allowed)
end)
merge_name_i18n(data, params)
end
defp merge_group_config(existing_data, _params), do: existing_data
# Per-language display-name overrides arrive as a `%{lang => name}` map (form
# inputs named `group[name_i18n][<lang>]`). Store the non-blank set wholesale;
# an all-blank submission clears the key. Only touched when submitted, so
# callers that don't edit the name (or keyword-list callers) leave it intact.
# Entries with a non-binary value (e.g. a nested map from crafted params or a
# programmatic caller) are dropped rather than raising, and each override is
# capped to the same max length the primary `name` column enforces.
defp merge_name_i18n(data, params) do
case Map.fetch(params, "name_i18n") do
{:ok, translations} when is_map(translations) ->
cleaned =
translations
|> Enum.flat_map(&clean_name_i18n_entry/1)
|> Map.new()
if cleaned == %{},
do: Map.delete(data, "name_i18n"),
else: Map.put(data, "name_i18n", cleaned)
_ ->
data
end
end
defp clean_name_i18n_entry({lang, value})
when (is_binary(lang) or is_atom(lang)) and is_binary(value) do
case value |> String.trim() |> String.slice(0, Constants.max_group_name_length()) do
"" -> []
trimmed -> [{to_string(lang), trimmed}]
end
end
defp clean_name_i18n_entry(_other), do: []
# Only touch a key the form actually submitted, so a caller that updates just
# name/slug (or a keyword-list caller) leaves config untouched.
defp merge_bool_key(data, params, key) do
case Map.fetch(params, key) do
{:ok, value} -> Map.put(data, key, config_flag_to_bool(value))
:error -> data
end
end
# An unknown enum value is ignored rather than persisted, keeping the column
# to the whitelist.
defp merge_enum_key(data, params, key, allowed) do
case Map.fetch(params, key) do
{:ok, value} -> if value in allowed, do: Map.put(data, key, value), else: data
:error -> data
end
end
defp config_flag_to_bool(value) when value in [true, "true", "on"], do: true
defp config_flag_to_bool(_value), do: false
@doc """
Moves a publishing group to trash (soft-delete).
Sets the group status to "trashed". The group and its posts remain in the
database and can be restored. Trashed groups are hidden from list_groups/0.
"""
@spec trash_group(String.t(), keyword() | map()) :: {:ok, String.t()} | {:error, any()}
def trash_group(slug, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
case DBStorage.trash_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
ActivityLog.log_manual(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
{:error, reason} ->
ActivityLog.log_failed_mutation(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:error, reason}
end
end
end
@doc """
Restores a trashed publishing group.
"""
@spec restore_group(String.t(), keyword() | map()) :: {:ok, String.t()} | {:error, any()}
def restore_group(slug, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
# Check if an active group already uses this slug (created while this was trashed)
active_conflict =
DBStorage.list_groups("active")
|> Enum.any?(fn g -> g.slug == slug and g.uuid != db_group.uuid end)
if active_conflict do
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => "slug_taken"}
)
{:error, :slug_taken}
else
restore_and_broadcast_group(db_group, slug, opts)
end
end
end
@doc """
Lists trashed publishing groups.
"""
@spec list_trashed_groups() :: [map()]
def list_trashed_groups do
DBStorage.list_groups("trashed")
|> Enum.map(&db_group_to_map/1)
end
@doc """
Looks up a publishing group name from its slug.
"""
@spec group_name(String.t()) :: String.t() | nil
def group_name(slug) do
case DBStorage.get_group_by_slug(slug) do
nil -> nil
db_group -> db_group.name
end
end
@doc """
Returns the configured post mode for a publishing group slug.
"""
@spec get_group_mode(String.t()) :: String.t()
def get_group_mode(group_slug) do
case DBStorage.get_group_by_slug(group_slug) do
nil -> @default_group_mode
db_group -> db_group.mode
end
end
@doc """
Returns the preset content types with their default item names.
"""
@spec preset_types() :: [map()]
def preset_types do
[
%{type: "blog", label: "Blog", item_singular: "post", item_plural: "posts"},
%{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"},
%{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"}
]
end
@doc """
Returns the list of valid group type values.
"""
@spec valid_types() :: [String.t()]
def valid_types, do: @valid_types
# ============================================================================
# Private Helpers
# ============================================================================
defp create_and_broadcast_group(db_attrs, opts) do
case DBStorage.create_group(db_attrs) do
{:ok, db_group} ->
group = db_group_to_map(db_group)
PublishingPubSub.broadcast_group_created(group)
ActivityLog.log_manual(
"publishing.group.created",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => db_group.slug, "name" => db_group.name, "mode" => db_group.mode}
)
{:ok, group}
{:error, _changeset} ->
log_failed_group_create(opts, db_attrs[:name] || "", "already_exists")
{:error, :already_exists}
end
end
defp delete_and_broadcast_group(db_group, slug, opts) do
case DBStorage.delete_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
ActivityLog.log_manual(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
error ->
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
error
end
end
defp restore_and_broadcast_group(db_group, slug, opts) do
case DBStorage.restore_group(db_group) do
{:ok, _} ->
ListingCache.regenerate(slug)
PublishingPubSub.broadcast_group_created(%{"slug" => slug, "name" => db_group.name})
ActivityLog.log_manual(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
{:error, reason} ->
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:error, reason}
end
end
# `add_group` validates inputs synchronously before reaching the DB —
# all four early-return branches log a `db_pending: true` audit row so
# the audit trail captures the user-initiated action even when the
# input never made it to a row.
defp log_failed_group_create(opts, name_attempt, reason) do
ActivityLog.log_failed_mutation(
"publishing.group.created",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"name_attempted" => name_attempt, "reason" => reason}
)
end
defp extract_and_validate_name(db_group, params) do
name =
params
|> fetch_option(:name)
|> case do
nil -> db_group.name
value -> String.trim(to_string(value || ""))
end
if name == "", do: {:error, :invalid_name}, else: {:ok, name}
end
defp extract_and_validate_slug(db_group, params, name) do
desired_slug =
params
|> fetch_option(:slug)
|> case do
nil -> db_group.slug
value -> String.trim(to_string(value || ""))
end
cond do
desired_slug == "" ->
auto_slug = Publishing.slugify(name)
if Publishing.valid_slug?(auto_slug),
do: {:ok, auto_slug},
else: {:error, :invalid_slug}
Publishing.valid_slug?(desired_slug) ->
{:ok, desired_slug}
true ->
{:error, :invalid_slug}
end
end
defp db_group_to_map(%{name: name, slug: slug, mode: mode, status: status, data: data}) do
%{
"name" => name,
"slug" => slug,
"mode" => mode || @default_group_mode,
"status" => status || "active",
"type" => Map.get(data, "type", @default_group_type),
"item_singular" => Map.get(data, "item_singular", @default_item_singular),
"item_plural" => Map.get(data, "item_plural", @default_item_plural),
"featured_enabled" => Map.get(data, "featured_enabled", true),
"featured_layout" => Map.get(data, "featured_layout", @default_featured_layout),
"newest_enabled" => Map.get(data, "newest_enabled", false),
"newest_layout" => Map.get(data, "newest_layout", @default_newest_layout),
"scrollbar_style" => Map.get(data, "scrollbar_style", @default_scrollbar_style),
"scroll_progress_enabled" => Map.get(data, "scroll_progress_enabled", false),
"scroll_headings_enabled" => Map.get(data, "scroll_headings_enabled", false),
"scroll_timeline_enabled" => Map.get(data, "scroll_timeline_enabled", false),
"scroll_timeline_granularity" =>
Map.get(data, "scroll_timeline_granularity", @default_timeline_granularity),
"listing_sort" => Map.get(data, "listing_sort", @default_listing_sort),
"show_breadcrumbs" => Map.get(data, "show_breadcrumbs", false),
"post_date_position" => Map.get(data, "post_date_position", @default_post_date_position),
"post_width" => Map.get(data, "post_width", @default_post_width),
"show_featured_image" => Map.get(data, "show_featured_image", false),
"show_reading_time" => Map.get(data, "show_reading_time", false),
"show_tags" => Map.get(data, "show_tags", false),
"show_post_count" => Map.get(data, "show_post_count", false),
"show_top_back_link" => Map.get(data, "show_top_back_link", true),
"listing_image_links" => Map.get(data, "listing_image_links", true),
"name_i18n" => name_i18n_map(data)
}
end
defp name_i18n_map(data) do
case Map.get(data, "name_i18n") do
map when is_map(map) -> map
_ -> %{}
end
end
@doc """
Resolves a group's display name in `lang` from a group MAP (the public-side
shape produced by `db_group_to_map/1`), falling back to the primary-language
`"name"` when there's no translation. Mirrors
`PublishingGroup.translated_name/2` for the struct.
"""
@spec translated_group_name(map(), String.t() | nil) :: String.t() | nil
def translated_group_name(group, lang) when is_map(group) do
translations = name_i18n_map(group)
PublishingGroup.resolve_name_translation(translations, lang) || group["name"]
end
defp derive_requested_slug(nil, fallback_name) do
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
defp derive_requested_slug(slug, fallback_name) when is_binary(slug) do
trimmed = slug |> String.trim()
cond do
trimmed == "" ->
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
Publishing.valid_slug?(trimmed) ->
{:ok, trimmed}
true ->
{:error, :invalid_slug}
end
end
defp derive_requested_slug(_other, fallback_name) do
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
# Check if explicit slug already exists (only when preferred_slug is provided)
defp check_slug_availability(slug, groups, preferred_slug) when not is_nil(preferred_slug) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
{:error, :already_exists}
else
:ok
end
end
defp check_slug_availability(_slug, _groups, nil), do: :ok
defp ensure_unique_slug(slug, groups), do: ensure_unique_slug(slug, groups, 2)
defp ensure_unique_slug(slug, groups, counter) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
ensure_unique_slug("#{slug}-#{counter}", groups, counter + 1)
else
slug
end
end
defp normalize_mode(mode) when is_binary(mode) do
mode
|> String.downcase()
|> case do
"slug" -> "slug"
"timestamp" -> "timestamp"
_ -> nil
end
end
defp normalize_mode(mode) when is_atom(mode), do: normalize_mode(Atom.to_string(mode))
defp normalize_mode(_), do: nil
# Normalize mode with default fallback
defp normalize_mode_with_default(nil), do: @default_group_mode
defp normalize_mode_with_default(mode), do: normalize_mode(mode) || @default_group_mode
# Normalize and validate type
# Preset types are passed through, custom types are validated and normalized
defp normalize_type(nil), do: @default_group_type
defp normalize_type(type) when is_binary(type) do
trimmed = String.trim(type)
downcased = String.downcase(trimmed)
cond do
# Preset type - pass through as-is
downcased in @preset_types ->
downcased
# Empty after trim - use default
trimmed == "" ->
@default_group_type
# Custom type - validate format
true ->
# Normalize: downcase, replace spaces/underscores with hyphens
normalized =
downcased
|> String.replace(~r/[\s_]+/, "-")
|> String.replace(~r/[^a-z0-9-]/, "")
|> String.slice(0, 32)
# Validate against type regex
if Regex.match?(@type_regex, normalized) do
normalized
else
nil
end
end
end
defp normalize_type(type) when is_atom(type), do: normalize_type(Atom.to_string(type))
defp normalize_type(_), do: nil
# Get default item names for a type
defp default_item_names(type) do
Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural})
end
# Normalize item name, using default if nil/empty
defp normalize_item_name(nil, default), do: default
defp normalize_item_name("", default), do: default
defp normalize_item_name(name, default) when is_binary(name) do
trimmed = String.trim(name)
if trimmed == "", do: default, else: trimmed
end
defp normalize_item_name(_, default), do: default
@doc false
defdelegate fetch_option(opts, key), to: Shared
end