PhoenixKit.Modules.Publishing (phoenix_kit v1.7.42)

Copy Markdown View Source

Publishing module for managing content groups and their posts.

This keeps content in the filesystem while providing an admin-friendly UI for creating timestamped or slug-based markdown posts with multi-language support.

Summary

Functions

Adds a new publishing group.

Creates a new version of a slug-mode post by copying from the latest version.

Creates a new post for the given publishing group using the current timestamp.

Creates a new version from an existing version or blank.

Deletes a specific language translation from a post.

Deletes an entire version of a post.

Disables the publishing module. Always writes to the new key.

Enables the publishing module. Always writes to the new key.

Returns true when the publishing module is enabled. Checks new key first, falls back to legacy key.

Gets a publishing group by slug.

Returns the configured storage mode for a publishing group slug.

Returns count of posts by version structure status.

Returns count of posts by primary_language status. Alias for count_primary_language_status/1.

Looks up a publishing group name from its slug.

Returns all configured publishing groups. Checks new key first, falls back to legacy keys.

Lists posts for a given publishing group slug. Accepts optional preferred_language to show titles in user's language.

Migrates all posts in a group to use the current global primary_language.

Migrates all legacy structure posts in a group to versioned structure (v1/).

Checks if any posts in a group need primary_language migration.

Checks if any posts in a group need version structure migration.

Returns the preset content types with their default item names.

Publishes a version, making it the only published version.

Removes a publishing group by slug.

Sets a translation's status and marks it as manually overridden.

Generates a slug from a user-provided group name. Returns empty string if the name contains only invalid characters.

Enqueues an Oban job to translate a post to all enabled languages using AI.

Moves a publishing group to trash by renaming its directory with timestamp. The group is removed from the active groups list and its directory is renamed to: GROUPNAME-YYYY-MM-DD-HH-MM-SS

Moves a post to the trash folder.

Updates a publishing group's display name and slug.

Updates a post and moves the file if the publication timestamp changes.

Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern, and is not a reserved language code.

Types

group()

@type group() :: map()

Functions

add_group(name, opts \\ [])

@spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()}

Adds a new publishing group.

Parameters

  • name - Display name for the group
  • opts - Keyword list or map with options:
    • :mode - Storage mode: "timestamp" or "slug" (default: "timestamp")
    • :slug - Optional custom slug, auto-generated from name if nil
    • :type - Content type: "blogging", "faq", "legal", or custom (default: "blogging")
    • :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> Publishing.add_group("News")
{:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blogging", ...}}

iex> Publishing.add_group("FAQ", type: "faq", mode: "slug")
{:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}}

iex> Publishing.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes")
{:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}}

add_language_to_entry(blog_slug, post_path, language_code)

add_language_to_post(group_slug, identifier, language_code, version \\ nil)

@spec add_language_to_post(String.t(), String.t(), String.t(), integer() | nil) ::
  {:ok, PhoenixKit.Modules.Publishing.Storage.post()} | {:error, any()}

Adds a new language file to an existing post.

For slug-mode groups, accepts an optional version parameter to specify which version to add the translation to. If not specified, uses the version from the identifier path (if present) or defaults to the latest version.

cache_exists?(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.exists?/1.

check_primary_language_status(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.check_primary_language_status/2.

content_changed?(post, params)

See PhoenixKit.Modules.Publishing.Storage.content_changed?/2.

count_legacy_structure_status(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.count_legacy_structure_status/1.

count_primary_language_status(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.count_primary_language_status/1.

create_entry(blog_slug)

create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{})

@spec create_new_version(
  String.t(),
  PhoenixKit.Modules.Publishing.Storage.post(),
  map(),
  map() | keyword()
) :: {:ok, PhoenixKit.Modules.Publishing.Storage.post()} | {:error, any()}

Creates a new version of a slug-mode post by copying from the latest version.

The new version starts as draft with status: "draft". Content and metadata updates from params are applied to the new version.

Note: For more control over which version to branch from, use create_version_from/5.

create_post(group_slug, opts \\ %{})

@spec create_post(String.t(), map() | keyword()) ::
  {:ok, PhoenixKit.Modules.Publishing.Storage.post()} | {:error, any()}

Creates a new post for the given publishing group using the current timestamp.

create_version_from(group_slug, post_slug, source_version, params \\ %{}, opts \\ %{})

@spec create_version_from(
  String.t(),
  String.t(),
  integer() | nil,
  map(),
  map() | keyword()
) ::
  {:ok, PhoenixKit.Modules.Publishing.Storage.post()} | {:error, any()}

Creates a new version from an existing version or blank.

Parameters

  • group_slug - The publishing group slug
  • post_slug - The post slug
  • source_version - Version to copy from, or nil for blank version
  • params - Optional parameters for the new version
  • opts - Options including :scope for audit metadata

Examples

# Create blank version
iex> Publishing.create_version_from("blog", "my-post", nil, %{}, scope: scope)
{:ok, %{version: 3, ...}}

# Branch from version 1
iex> Publishing.create_version_from("blog", "my-post", 1, %{}, scope: scope)
{:ok, %{version: 3, ...}}

delete_language(group_slug, post_identifier, language_code, version \\ nil)

@spec delete_language(String.t(), String.t(), String.t(), integer() | nil) ::
  :ok | {:error, term()}

Deletes a specific language translation from a post.

For versioned posts, specify the version. For legacy posts, version is ignored. Refuses to delete the last remaining language file.

Returns :ok on success or {:error, reason} on failure.

delete_version(group_slug, post_identifier, version)

@spec delete_version(String.t(), String.t(), integer()) :: :ok | {:error, term()}

Deletes an entire version of a post.

Moves the version folder to trash instead of permanent deletion. Refuses to delete the last remaining version or the live version.

Returns :ok on success or {:error, reason} on failure.

detect_post_structure(post_path)

See PhoenixKit.Modules.Publishing.Storage.detect_post_structure/1.

disable_system()

@spec disable_system() :: {:ok, any()} | {:error, any()}

Disables the publishing module. Always writes to the new key.

enable_system()

@spec enable_system() :: {:ok, any()} | {:error, any()}

Enables the publishing module. Always writes to the new key.

enabled?()

@spec enabled?() :: boolean()

Returns true when the publishing module is enabled. Checks new key first, falls back to legacy key.

enabled_language_codes()

See PhoenixKit.Modules.Publishing.Storage.enabled_language_codes/0.

find_cached_post(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.ListingCache.find_post/2.

find_cached_post_by_path(group_slug, date, time)

See PhoenixKit.Modules.Publishing.ListingCache.find_post_by_path/3.

generate_unique_slug(group_slug, title)

See PhoenixKit.Modules.Publishing.Storage.generate_unique_slug/2.

generate_unique_slug(group_slug, title, preferred_slug)

See PhoenixKit.Modules.Publishing.Storage.generate_unique_slug/3.

generate_unique_slug(group_slug, title, preferred_slug, opts)

See PhoenixKit.Modules.Publishing.Storage.generate_unique_slug/4.

get_display_code(language_code, enabled_languages)

See PhoenixKit.Modules.Publishing.Storage.get_display_code/2.

get_group(slug)

@spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found}

Gets a publishing group by slug.

Examples

iex> Publishing.get_group("news")
{:ok, %{"name" => "News", "slug" => "news", ...}}

iex> Publishing.get_group("nonexistent")
{:error, :not_found}

get_group_mode(group_slug)

@spec get_group_mode(String.t()) :: String.t()

Returns the configured storage mode for a publishing group slug.

get_language_info(language_code)

See PhoenixKit.Modules.Publishing.Storage.get_language_info/1.

get_latest_published_version(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.get_latest_published_version/2.

get_latest_version(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.get_latest_version/2.

get_legacy_structure_status(group_slug)

@spec get_legacy_structure_status(String.t()) :: map()

Returns count of posts by version structure status.

get_post_primary_language(group_slug, post_slug, version \\ nil)

See PhoenixKit.Modules.Publishing.Storage.get_post_primary_language/3.

get_primary_language()

See PhoenixKit.Modules.Publishing.Storage.get_primary_language/0.

get_primary_language_migration_status(group_slug)

@spec get_primary_language_migration_status(String.t()) :: map()

Returns count of posts by primary_language status. Alias for count_primary_language_status/1.

get_published_version(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.get_published_version/2.

get_version_metadata(group_slug, post_slug, version, language)

See PhoenixKit.Modules.Publishing.Storage.get_version_metadata/4.

get_version_status(group_slug, post_slug, version, language)

See PhoenixKit.Modules.Publishing.Storage.get_version_status/4.

group_name(slug)

@spec group_name(String.t()) :: String.t() | nil

Looks up a publishing group name from its slug.

has_legacy_groups?()

See PhoenixKit.Modules.Publishing.Storage.has_legacy_groups?/0.

invalidate_cache(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.invalidate/1.

language_enabled?(language_code, enabled_languages)

See PhoenixKit.Modules.Publishing.Storage.language_enabled?/2.

legacy_group?(group_slug)

See PhoenixKit.Modules.Publishing.Storage.legacy_group?/1.

list_entries(blog_slug, preferred_language \\ nil)

list_groups()

@spec list_groups() :: [group()]

Returns all configured publishing groups. Checks new key first, falls back to legacy keys.

list_posts(group_slug, preferred_language \\ nil)

@spec list_posts(String.t(), String.t() | nil) :: [
  PhoenixKit.Modules.Publishing.Storage.post()
]

Lists posts for a given publishing group slug. Accepts optional preferred_language to show titles in user's language.

Uses the ListingCache for fast lookups when available, falling back to filesystem scan on cache miss.

list_versions(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.list_versions/2.

migrate_group(group_slug)

See PhoenixKit.Modules.Publishing.Storage.migrate_group/1.

migrate_post_to_versioned(post)

See PhoenixKit.Modules.Publishing.Storage.migrate_post_to_versioned/1.

migrate_post_to_versioned(post, language)

See PhoenixKit.Modules.Publishing.Storage.migrate_post_to_versioned/2.

migrate_posts_to_current_primary_language(group_slug)

@spec migrate_posts_to_current_primary_language(String.t()) ::
  {:ok, integer()} | {:error, any()}

Migrates all posts in a group to use the current global primary_language.

This updates the primary_language field in all .phk files and regenerates the listing cache. The migration is idempotent - running it multiple times is safe and will skip posts that are already at the current primary language.

Returns {:ok, count} where count is the number of posts updated.

migrate_posts_to_versioned_structure(group_slug)

@spec migrate_posts_to_versioned_structure(String.t()) ::
  {:ok, integer()} | {:error, any()}

Migrates all legacy structure posts in a group to versioned structure (v1/).

This moves files from the post root into a v1/ subdirectory and updates metadata. The migration is idempotent - already versioned posts are skipped.

Returns {:ok, count} where count is the number of posts migrated.

order_languages_for_display(available_languages, enabled_languages)

See PhoenixKit.Modules.Publishing.Storage.order_languages_for_display/2.

posts_need_primary_language_migration?(group_slug)

@spec posts_need_primary_language_migration?(String.t()) :: boolean()

Checks if any posts in a group need primary_language migration.

posts_need_version_migration?(group_slug)

@spec posts_need_version_migration?(String.t()) :: boolean()

Checks if any posts in a group need version structure migration.

posts_needing_primary_language_migration(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.posts_needing_primary_language_migration/1.

posts_needing_version_migration(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.posts_needing_version_migration/1.

preset_types()

@spec preset_types() :: [map()]

Returns the preset content types with their default item names.

Examples

iex> Publishing.preset_types()
[
  %{type: "blogging", 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"}
]

publish_version(group_slug, post_slug, version, opts \\ [])

@spec publish_version(String.t(), String.t(), integer(), keyword()) ::
  :ok | {:error, any()}

Publishes a version, making it the only published version.

  • All files in the target version (primary and translations) → status: "published"
  • All files in other versions that were "published" → status: "archived"
  • Draft/archived files in other versions keep their current status

Options

  • :source_id - ID of the source (e.g., socket.id) to include in broadcasts, allowing receivers to ignore their own messages

Examples

iex> Publishing.publish_version("blog", "my-post", 2)
:ok

iex> Publishing.publish_version("blog", "my-post", 2, source_id: "phx-abc123")
:ok

iex> Publishing.publish_version("blog", "nonexistent", 1)
{:error, :not_found}

read_entry(blog_slug, relative_path)

read_post(group_slug, identifier, language \\ nil, version \\ nil)

@spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) ::
  {:ok, PhoenixKit.Modules.Publishing.Storage.post()} | {:error, any()}

Reads an existing post.

For slug-mode groups, accepts an optional version parameter. If version is nil, reads the latest version.

regenerate_cache(group_slug)

See PhoenixKit.Modules.Publishing.ListingCache.regenerate/1.

remove_group(slug)

@spec remove_group(String.t()) :: {:ok, any()} | {:error, any()}

Removes a publishing group by slug.

set_translation_status(group_slug, post_slug, version, language, status)

@spec set_translation_status(
  String.t(),
  String.t(),
  integer(),
  String.t(),
  String.t()
) ::
  :ok | {:error, any()}

Sets a translation's status and marks it as manually overridden.

When a translation status is set manually, it will NOT inherit status changes from the primary language when publishing.

Examples

iex> Publishing.set_translation_status("blog", "my-post", 2, "es", "draft")
:ok

should_create_new_version?(post, params, editing_language)

See PhoenixKit.Modules.Publishing.Storage.should_create_new_version?/3.

slug_exists?(group_slug, post_slug)

See PhoenixKit.Modules.Publishing.Storage.slug_exists?/2.

slugify(name)

@spec slugify(String.t()) :: String.t()

Generates a slug from a user-provided group name. Returns empty string if the name contains only invalid characters.

status_change_only?(post, params)

See PhoenixKit.Modules.Publishing.Storage.status_change_only?/2.

translate_post_to_all_languages(group_slug, post_slug, opts \\ [])

@spec translate_post_to_all_languages(String.t(), String.t(), keyword()) ::
  {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}

Enqueues an Oban job to translate a post to all enabled languages using AI.

This creates a background job that will:

  1. Read the source post in the primary language
  2. Translate the content to each target language using the AI module
  3. Create or update translation files for each language

Options

  • :endpoint_id - AI endpoint ID to use for translation (required if not set in settings)
  • :source_language - Source language to translate from (defaults to primary language)
  • :target_languages - List of target language codes (defaults to all enabled except source)
  • :version - Version number to translate (defaults to latest/published)
  • :user_id - User ID for audit trail

Configuration

Set the default AI endpoint for translations:

PhoenixKit.Settings.update_setting("publishing_translation_endpoint_id", "1")

Examples

# Translate to all enabled languages using default endpoint
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started")

# Translate with specific endpoint
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started",
  endpoint_id: 1
)

# Translate to specific languages only
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started",
  endpoint_id: 1,
  target_languages: ["es", "fr", "de"]
)

Returns

  • {:ok, %Oban.Job{}} - Job was successfully enqueued
  • {:error, changeset} - Failed to enqueue job

trash_group(slug)

@spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()}

Moves a publishing group to trash by renaming its directory with timestamp. The group is removed from the active groups list and its directory is renamed to: GROUPNAME-YYYY-MM-DD-HH-MM-SS

trash_post(group_slug, post_identifier)

@spec trash_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}

Moves a post to the trash folder.

For slug-mode groups, provide the post slug. For timestamp-mode groups, provide the date/time path (e.g., "2025-01-15/14:30").

Returns {:ok, trash_path} on success or {:error, reason} on failure.

update_entry(blog_slug, post, params)

update_group(slug, params)

@spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()}

Updates a publishing group's display name and slug.

update_post(group_slug, post, params, opts \\ %{})

Updates a post and moves the file if the publication timestamp changes.

update_post_primary_language(group_slug, post_slug, new_primary_language)

See PhoenixKit.Modules.Publishing.Storage.update_post_primary_language/3.

valid_slug?(slug)

@spec valid_slug?(String.t()) :: boolean()

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.

validate_slug(slug)

See PhoenixKit.Modules.Publishing.Storage.validate_slug/1.