Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing publishing.ex
Raw

lib/phoenix_kit_publishing/publishing.ex

defmodule PhoenixKit.Modules.Publishing do
@moduledoc """
Publishing module for managing content groups and their posts.
Database-backed CMS for creating timestamped or slug-based posts
with multi-language support and versioning.
This module acts as a facade, delegating to focused submodules:
- `Publishing.Groups` — Group CRUD
- `Publishing.Posts` — Post CRUD, reading, and listing
- `Publishing.Versions` — Version create, publish, delete
- `Publishing.TranslationManager` — Language/translation management
- `Publishing.StaleFixer` — Stale value detection and repair
"""
use PhoenixKit.Module
require Logger
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Publishing.ActivityLog
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.SlugHelpers
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
# ============================================================================
# Language Utility Delegates
# ============================================================================
defdelegate get_language_info(language_code), to: LanguageHelpers
defdelegate enabled_language_codes(), to: LanguageHelpers
defdelegate get_primary_language(), to: LanguageHelpers
defdelegate get_primary_language_base(), to: LanguageHelpers
defdelegate default_language_no_prefix?(), to: LanguageHelpers
defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers
defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers
defdelegate use_language_prefix?(language_code), to: LanguageHelpers
defdelegate url_language_code(language_code), to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages),
to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages, primary),
to: LanguageHelpers
# ============================================================================
# Slug Utility Delegates
# ============================================================================
defdelegate validate_slug(slug), to: SlugHelpers
defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers
defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers
defdelegate clear_url_slug_from_post(group_slug, post_slug, url_slug), to: DBStorage
# ============================================================================
# Cache Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.ListingCache
defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate
defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate
defdelegate cache_exists?(group_slug), to: ListingCache, as: :exists?
defdelegate find_cached_post(group_slug, post_slug), to: ListingCache, as: :find_post
defdelegate find_cached_post_by_path(group_slug, date, time),
to: ListingCache,
as: :find_post_by_path
# ============================================================================
# Group Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Groups
alias PhoenixKit.Modules.Publishing.GroupSettings
# Machine-readable spec of the per-group display settings, for AI/agent/script
# driven configuration. See PhoenixKit.Modules.Publishing.GroupSettings.
defdelegate group_settings_schema(), to: GroupSettings, as: :schema
defdelegate group_settings_keys(), to: GroupSettings, as: :keys
defdelegate group_settings_defaults(), to: GroupSettings, as: :default_config
defdelegate validate_group_settings(params), to: GroupSettings, as: :validate_params
defdelegate list_groups(), to: Groups
defdelegate list_groups(status), to: Groups
defdelegate get_group(slug), to: Groups
defdelegate add_group(name, opts \\ []), to: Groups
defdelegate remove_group(slug), to: Groups
defdelegate remove_group(slug, opts), to: Groups
defdelegate update_group(slug, params), to: Groups
defdelegate update_group(slug, params, opts), to: Groups
defdelegate trash_group(slug), to: Groups
defdelegate trash_group(slug, opts), to: Groups
defdelegate group_name(slug), to: Groups
defdelegate translated_group_name(group, lang), to: Groups
defdelegate get_group_mode(group_slug), to: Groups
defdelegate preset_types(), to: Groups
defdelegate valid_types(), to: Groups
defdelegate restore_group(slug), to: Groups
defdelegate restore_group(slug, opts), to: Groups
defdelegate list_trashed_groups(), to: Groups
# ============================================================================
# Post Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Posts
defdelegate list_posts(group_slug, preferred_language \\ nil), to: Posts
defdelegate list_posts_by_status(group_slug, status), to: Posts
defdelegate list_raw_posts(group_slug, status \\ nil), to: Posts
defdelegate create_post(group_slug, opts \\ %{}), to: Posts
defdelegate read_post(group_slug, identifier, language \\ nil, version \\ nil), to: Posts
defdelegate read_post_by_uuid(post_uuid, language \\ nil, version \\ nil), to: Posts
defdelegate update_post(group_slug, post, params, opts \\ %{}), to: Posts
defdelegate trash_post(group_slug, post_uuid), to: Posts
defdelegate trash_post(group_slug, post_uuid, opts), to: Posts
defdelegate restore_post(group_slug, post_uuid), to: Posts
defdelegate restore_post(group_slug, post_uuid, opts), to: Posts
defdelegate count_posts_on_date(group_slug, date), to: Posts
defdelegate list_times_on_date(group_slug, date), to: Posts
defdelegate read_post_by_datetime(group_slug, date, time), to: DBStorage
defdelegate find_by_url_slug(group_slug, language, url_slug), to: Posts
defdelegate find_by_previous_url_slug(group_slug, language, url_slug), to: Posts
defdelegate extract_slug_version_and_language(group_slug, identifier), to: Posts
@doc """
Stub: always returns `false`. Versioning is opt-in via the editor's
explicit "create new version" action, not implicit on every save —
so this predicate is intentionally a no-op kept for API stability
with older callers that branch on it.
"""
@spec should_create_new_version?(map(), map(), String.t()) :: false
def should_create_new_version?(_post, _params, _editing_language), do: false
@doc "Returns true when the given post is a DB-backed post (has a UUID)."
@spec db_post?(map()) :: boolean()
defdelegate db_post?(post), to: Posts
# ============================================================================
# Version Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Versions
defdelegate list_versions(group_slug, post_slug), to: Versions
defdelegate get_published_version(group_slug, post_slug), to: Versions
defdelegate get_version_status(group_slug, post_slug, version_number, language), to: Versions
defdelegate get_version_metadata(group_slug, post_slug, version_number, language), to: Versions
defdelegate create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}),
to: Versions
defdelegate publish_version(group_slug, post_uuid, version, opts \\ []), to: Versions
defdelegate create_version_from(
group_slug,
post_uuid,
source_version,
params \\ %{},
opts \\ %{}
),
to: Versions
defdelegate unpublish_post(group_slug, post_uuid, opts \\ []), to: Versions
defdelegate delete_version(group_slug, post_uuid, version), to: Versions
defdelegate delete_version(group_slug, post_uuid, version, opts), to: Versions
@doc false
defdelegate broadcast_version_created(group_slug, broadcast_id, new_version), to: Versions
# ============================================================================
# Translation Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.TranslationManager
defdelegate add_language_to_post(group_slug, post_uuid, language_code, version, opts),
to: TranslationManager
defdelegate add_language_to_post(group_slug, post_uuid, language_code, version \\ nil),
to: TranslationManager
@doc false
defdelegate add_language_to_db(group_slug, post_uuid, language_code, version_number),
to: TranslationManager
defdelegate delete_language(group_slug, post_uuid, language_code, version, opts),
to: TranslationManager
defdelegate delete_language(group_slug, post_uuid, language_code, version \\ nil),
to: TranslationManager
defdelegate clear_translation(group_slug, post_uuid, language_code, version, opts),
to: TranslationManager
defdelegate clear_translation(group_slug, post_uuid, language_code, version \\ nil),
to: TranslationManager
defdelegate set_translation_status(group_slug, post_identifier, version, language, status),
to: TranslationManager
defdelegate translate_post_to_all_languages(group_slug, post_uuid, opts \\ []),
to: TranslationManager
# ============================================================================
# Stale Value Correction Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.StaleFixer
defdelegate fix_stale_group(group), to: StaleFixer
defdelegate fix_stale_post(post), to: StaleFixer
defdelegate fix_stale_version(version), to: StaleFixer
defdelegate fix_stale_content(content), to: StaleFixer
defdelegate fix_all_stale_values(), to: StaleFixer
defdelegate reconcile_post_status(post), to: StaleFixer
# ============================================================================
# Module Behaviour Callbacks
# ============================================================================
@publishing_enabled_key "publishing_enabled"
@impl PhoenixKit.Module
@spec enabled?() :: boolean()
def enabled? do
settings_call(:get_boolean_setting, [@publishing_enabled_key, false])
end
@spec ai_translatables() :: [{String.t(), module()}]
def ai_translatables do
[{PhoenixKitPublishing.AITranslatable.resource_type(), PhoenixKitPublishing.AITranslatable}]
end
@impl PhoenixKit.Module
@spec enable_system() :: {:ok, any()} | {:error, any()}
def enable_system do
result = settings_call(:update_boolean_setting, [@publishing_enabled_key, true])
with {:ok, _} <- result do
ActivityLog.log_manual(
"publishing.module.enabled",
nil,
"publishing_module",
nil,
%{}
)
end
result
end
@impl PhoenixKit.Module
@spec disable_system() :: {:ok, any()} | {:error, any()}
def disable_system do
result = settings_call(:update_boolean_setting, [@publishing_enabled_key, false])
with {:ok, _} <- result do
ActivityLog.log_manual(
"publishing.module.disabled",
nil,
"publishing_module",
nil,
%{}
)
end
result
end
@impl PhoenixKit.Module
def module_key, do: "publishing"
@impl PhoenixKit.Module
def module_name, do: "Publishing"
# ============================================================================
# OG module integration — exposes per-post variables that the
# `phoenix_kit_og` plugin can wire to a template's slots at
# assignment time.
# ============================================================================
@doc """
Variables this module makes available for OG template wiring. Each
entry declares its `type` (`:text` / `:image`), which lets the OG
assignment UI show only compatible variables for a given slot.
"""
def og_variables do
[
%{
name: "post_title",
type: :text,
label: "Post title",
description: "The post's title"
},
%{
name: "post_description",
type: :text,
label: "Post description",
description: "Short description text"
},
%{
name: "post_url",
type: :text,
label: "Post URL",
description: "Canonical URL of the post"
},
%{
name: "post_featured_image",
type: :image,
label: "Featured image",
description: "The post's featured image (media UUID)"
},
%{
name: "post_group_name",
type: :text,
label: "Group name",
description: "The publishing group this post belongs to"
},
%{
name: "post_group_slug",
type: :text,
label: "Group slug",
description: "URL slug of the publishing group"
},
%{
name: "post_first_words",
type: :text,
label: "First words of description",
description: "First ~20 words of the description — nicer for OG cards than a raw excerpt"
},
%{
name: "post_published_at",
type: :text,
label: "Published date",
description: "When the post was published"
}
]
end
@doc """
Resolves a variable name from `og_variables/0` against the current
post supplied in `context.resource`. Falls back to `nil` for unknown
names so unwired slots stay visible rather than blowing up.
"""
def og_resolve("post_title", %{resource: post}),
do: og_override(post, "title") || og_get_meta(post, :title)
def og_resolve("post_description", %{resource: post}),
do: og_override(post, "description") || og_get_meta(post, :description)
# The post map has no `:url` field (URL building needs request-time
# scheme/host, not just the DB record) — build it from the group slug
# (`post.group`) the same way `Web.Controller.build_og_data/4` derives
# its own canonical_url, using the conn the OG module passes in context.
def og_resolve("post_url", %{resource: post, conn: %Plug.Conn{} = conn} = context) do
case og_get_meta(post, :group) do
nil ->
nil
group_slug ->
absolute_post_url(
conn,
PublishingHTML.build_post_url(group_slug, post, Map.get(context, :language))
)
end
end
def og_resolve("post_url", %{resource: _post}), do: nil
def og_resolve("post_featured_image", %{resource: post}),
do: og_override(post, "image_uuid") || og_get_meta(post, :featured_image_uuid)
# The post map stores the group's slug under `:group` (there is no
# separate `:group_slug` key) and doesn't carry the group's display name
# at all — look that up via Groups.
def og_resolve("post_group_name", %{resource: post}) do
case og_get_meta(post, :group) do
nil -> nil
group_slug -> Groups.group_name(group_slug)
end
end
def og_resolve("post_group_slug", %{resource: post}), do: og_get_meta(post, :group)
def og_resolve("post_first_words", %{resource: post}) do
text = og_override(post, "description") || og_get_meta(post, :description) || ""
text
|> to_string()
|> String.split(~r/\s+/, trim: true)
|> Enum.take(20)
|> Enum.join(" ")
end
def og_resolve("post_published_at", %{resource: post}) do
case og_get_meta(post, :published_at) do
nil -> nil
v -> to_string(v)
end
end
def og_resolve(_, _), do: nil
# Reads a per-post OG override field (`title`, `description`,
# `image_uuid`) surfaced by the editor. When set, it's the author's
# explicit preference for what the OG image should show — the plugin
# uses it instead of the post's own field.
defp og_override(post, key) when is_map(post) do
meta = Map.get(post, :metadata) || Map.get(post, "metadata") || %{}
og = Map.get(meta, :og) || Map.get(meta, "og") || %{}
case Map.get(og, key) || Map.get(og, String.to_atom(key)) do
v when is_binary(v) and v != "" -> v
_ -> nil
end
end
defp og_override(_, _), do: nil
# Publishing post shapes vary a bit between the DB record path and
# the rendered-for-display path — try atom then string keys on the
# metadata sub-map, and fall back to the top-level map for safety.
defp og_get_meta(post, key) when is_map(post) do
meta = Map.get(post, :metadata) || Map.get(post, "metadata") || %{}
Map.get(meta, key) ||
Map.get(meta, to_string(key)) ||
Map.get(post, key) ||
Map.get(post, to_string(key))
end
defp og_get_meta(_, _), do: nil
defp absolute_post_url(%Plug.Conn{} = conn, path) when is_binary(path) do
port_suffix = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
"#{conn.scheme}://#{conn.host}#{port_suffix}#{path}"
end
@impl PhoenixKit.Module
def version, do: "0.4.2"
@impl PhoenixKit.Module
def get_config do
%{
enabled: enabled?(),
groups_count: length(list_groups())
}
end
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "publishing",
label: "Publishing",
icon: "hero-document-duplicate",
description: "Database-backed CMS pages and multi-language content"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 600,
level: :admin,
permission: "publishing",
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
dynamic_children: &__MODULE__.publishing_children/1,
gettext_backend: PhoenixKitPublishing.Gettext
)
]
end
@doc "Dynamic children function for Publishing sidebar tabs."
def publishing_children(_scope) do
groups = load_publishing_groups_for_tabs()
groups
|> Enum.with_index()
|> Enum.map(fn {group, idx} ->
slug = group["slug"] || ""
name = group["name"] || slug
hash = :erlang.phash2(slug) |> Integer.to_string(16) |> String.downcase()
sanitized = slug |> String.replace(~r/[^a-zA-Z0-9_]/, "_") |> String.slice(0, 50)
%Tab{
id: :"admin_publishing_#{sanitized}_#{hash}",
label: name,
icon: "hero-document-text",
path: "publishing/#{slug}",
priority: 601 + idx,
level: :admin,
permission: "publishing",
match: :prefix,
parent: :admin_publishing
}
end)
rescue
# Narrow: dashboard tabs are rendered at every admin LV mount, so a
# transient DB blip shouldn't crash the whole admin shell. Genuine
# programmer errors (UndefinedFunctionError, MatchError, …) should
# still bubble up so the bug isn't masked.
e in [Ecto.QueryError, DBConnection.ConnectionError, Postgrex.Error] ->
Logger.warning("[Publishing] dashboard_tabs DB failure: #{inspect(e)}")
[]
end
defp load_publishing_groups_for_tabs do
alias PhoenixKit.Settings
publishing_enabled = Settings.get_boolean_setting("publishing_enabled", false)
if publishing_enabled do
alias PhoenixKit.Modules.Publishing.DBStorage
DBStorage.list_groups()
|> Enum.map(fn g -> %{"name" => g.name, "slug" => g.slug} end)
else
[]
end
rescue
e in [Ecto.QueryError, DBConnection.ConnectionError, Postgrex.Error] ->
Logger.warning("[Publishing] load_publishing_groups_for_tabs DB failure: #{inspect(e)}")
[]
end
@impl PhoenixKit.Module
def settings_tabs do
[
Tab.new!(
id: :admin_settings_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 921,
level: :admin,
parent: :admin_settings,
permission: "publishing",
gettext_backend: PhoenixKitPublishing.Gettext
)
]
end
@impl PhoenixKit.Module
def children do
[
PhoenixKit.Modules.Publishing.Presence,
# Owns the ListingCache regeneration-lock ETS table so it outlives the
# transient request processes that would otherwise create (and destroy) it.
PhoenixKit.Modules.Publishing.ListingCache.LockTableOwner,
# Per-post render cache (Renderer.render_post_cached/1). Without this the
# cache GenServer never starts, so every published view re-renders markdown
# and logs a per-request warning. max_size bounds growth — the cache key
# folds in a content hash, so each edit mints a new key (FIFO-evicted).
Supervisor.child_spec(
{PhoenixKit.Cache, name: :publishing_posts, ttl: :timer.hours(24), max_size: 2000},
id: :publishing_posts_cache
)
]
end
@impl PhoenixKit.Module
def route_module, do: PhoenixKitPublishing.Routes
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_publishing]
# ============================================================================
# Shared Helpers (used across submodules)
# ============================================================================
alias PhoenixKit.Modules.Publishing.Shared
@doc """
Lowercases `name`, replaces non-alphanumeric runs with `-`, and trims
leading/trailing hyphens. Pairs with `valid_slug?/1` — callers should
re-validate the result because slug-shape is enforced separately from
generation (reserved language codes, empty results).
## Examples
iex> PhoenixKit.Modules.Publishing.slugify("My First Post!")
"my-first-post"
iex> PhoenixKit.Modules.Publishing.slugify(" spaces & ampersands ")
"spaces-ampersands"
"""
@spec slugify(String.t()) :: String.t()
def slugify(name) when is_binary(name) do
SlugHelpers.slugify(name)
end
@doc """
Returns true when slugifying `text` would be shortened to fit the slug cap —
i.e. the full title didn't fit. Auto-generation never errors (it truncates);
this lets the editor warn that not all of the title became the slug.
"""
@spec slug_truncated?(String.t() | nil) :: boolean()
defdelegate slug_truncated?(text), to: SlugHelpers
@doc """
Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern,
and is not a reserved language code.
Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity.
"""
@spec valid_slug?(any()) :: boolean()
def valid_slug?(slug) when is_binary(slug) do
slug != "" and SlugHelpers.matches_shape?(slug) and not reserved_language_code?(slug)
end
def valid_slug?(_), do: false
defp reserved_language_code?(slug) do
language_codes =
try do
Languages.get_language_codes()
rescue
e ->
Logger.debug(
"[Publishing] reserved_language_code? check failed, assuming no reserved codes: #{inspect(e)}"
)
[]
end
slug in language_codes
end
@doc false
defdelegate fetch_option(opts, key), to: Shared
@doc false
defdelegate audit_metadata(scope, action), to: Shared
# ============================================================================
# Settings Helpers (private)
# ============================================================================
defp settings_module do
case PhoenixKit.Config.get(:publishing_settings_module) do
:not_found -> PhoenixKit.Settings
{:ok, module} -> module
end
end
defp settings_call(fun, args) do
module = settings_module()
apply(module, fun, args)
end
end