PhoenixKit Publishing

A standalone PhoenixKit plugin module that provides a database-backed content management system with multi-language support, collaborative editing, and dual URL modes.

Installation

Add to your parent app's mix.exs:

{:phoenix_kit_publishing, "~> 0.1"}

Or for local development:

{:phoenix_kit_publishing, path: "../phoenix_kit_publishing"}

Then run mix deps.get and mix phoenix_kit.install. The module is auto-discovered by PhoenixKit at startup — no additional config needed. The installer also adds the necessary Tailwind CSS @source directive so all styles render correctly.

Database Setup

The publishing tables (phoenix_kit_publishing_groups, _posts, _versions, _contents) are created by PhoenixKit's core versioned migrations (V59). Run mix phoenix_kit.install in the host app and they're set up automatically — no module-owned migration to invoke.

Enable the Module

Via admin UI: navigate to Admin > Modules > Publishing > toggle on.

Or via code:

PhoenixKit.Modules.Publishing.enable_system()

Features

URL Modes

Timestamp Mode (default)

Posts addressed by publication date and time. Ideal for news, announcements, changelogs.

/{language}/{group-slug}/{YYYY-MM-DD}/{HH:MM}

Slug Mode

Posts addressed by semantic slug. Ideal for documentation, guides, evergreen content.

/{language}/{group-slug}/{post-slug}

Single-language mode omits the language segment automatically.

Architecture

Database Schema (4 tables)

Group (1) ──→ (many) Post (1) ──→ (many) Version (1) ──→ (many) Content

phoenix_kit_publishing_groups — Content containers

ColumnTypePurpose
uuidUUIDv7PK
namestringDisplay name
slugstringURL identifier (unique)
modestring"timestamp" or "slug" — locked at creation
statusstring"active" or "trashed"
positionintegerDisplay ordering
dataJSONBtype, item_singular/plural, icon, comments/likes/views_enabled
title_i18nJSONBTranslatable group title (keyed by language code)
description_i18nJSONBTranslatable group description (keyed by language code)

phoenix_kit_publishing_posts — Routing shell

Posts hold URL identity and point to their live version. No content or metadata — that lives on versions.

ColumnTypePurpose
uuidUUIDv7PK
group_uuidUUIDv7FK → groups
slugstringURL path segment (slug mode, unique per group)
modestring"timestamp" or "slug"
post_datedateURL date segment (timestamp mode)
post_timetimeURL time segment (timestamp mode, unique per group+date)
active_version_uuidUUIDv7FK → versions — the live version (null = unpublished)
trashed_atutc_datetimeSoft delete timestamp (null = active)
created_by_uuidUUIDv7FK → users (audit)
updated_by_uuidUUIDv7FK → users (audit)

Publishing = setting active_version_uuid. Trashing = setting trashed_at.

phoenix_kit_publishing_versions — Source of truth

Each post has one or more versions. The version holds all metadata that applies across languages.

ColumnTypePurpose
uuidUUIDv7PK
post_uuidUUIDv7FK → posts
version_numberintegerSequential (v1, v2, ...), unique per post
statusstring"draft" / "published" / "archived"
published_atutc_datetimeWhen this version was first published
created_by_uuidUUIDv7FK → users (audit)
dataJSONBfeatured_image_uuid, tags, seo, description, allow_version_access, notes, created_from

phoenix_kit_publishing_contents — Per-language title + body

One row per language per version. All languages share the version's status and metadata.

ColumnTypePurpose
uuidUUIDv7PK
version_uuidUUIDv7FK → versions
languagestringLanguage code (unique per version)
titlestringPost title in this language
contenttextMarkdown/PHK body in this language
url_slugstringPer-language URL slug (for localized URLs)
statusstringReserved for future per-language overrides (unused by UI)
dataJSONBReserved for future per-language overrides (unused by UI)

All tables use UUIDv7 primary keys. Language fallback chain: requested language → site default → first available.

Module Structure

lib/phoenix_kit_publishing/
publishing.ex # Main facade (PhoenixKit.Module behaviour)
groups.ex # Group CRUD
posts.ex # Post operations
versions.ex # Version management
translation_manager.ex # Language/translation ops
db_storage.ex # Database CRUD layer
listing_cache.ex # In-memory listing cache
renderer.ex # Markdown + component rendering
page_builder.ex # PHK XML component system
stale_fixer.ex # Data consistency repair
presence.ex # Collaborative editing presence
pubsub.ex # Real-time broadcasting
routes.ex # Admin route definitions
schemas/ # Ecto schemas (4 files)
web/ # LiveViews, controller, templates
workers/ # Oban background jobs

Core Modules

ModuleRole
PhoenixKit.Modules.PublishingMain context/facade — delegates to all submodules
Publishing.DBStorageDirect Ecto queries for all CRUD operations
Publishing.ListingCache:persistent_term cache with sub-microsecond reads
Publishing.RendererEarmark markdown + PHK component rendering with ETS cache
Publishing.PageBuilderXML parser (Saxy) for <Image>, <Hero>, etc. components
Publishing.StaleFixerReconciles DB/cache state, auto-cleans empty posts
Publishing.PresencePhoenix.Presence for collaborative editor locking

IEx / CLI Usage

alias PhoenixKit.Modules.Publishing
# Groups
{:ok, _} = Publishing.add_group("Documentation", mode: "slug")
{:ok, _} = Publishing.add_group("Company News", mode: "timestamp")
Publishing.list_groups()
# Posts
{:ok, post} = Publishing.create_post("docs", %{title: "Getting Started"})
{:ok, post} = Publishing.read_post("docs", "getting-started")
{:ok, _} = Publishing.update_post("docs", post, %{"content" => "# Updated"})
# Translations
{:ok, _} = Publishing.add_language_to_post("docs", post_uuid, "es")
:ok = Publishing.delete_language("docs", post_uuid, "fr")
# Versions
{:ok, v2} = Publishing.create_version_from("docs", post_uuid, 1)
:ok = Publishing.publish_version("docs", post_uuid, 2)
# Cache
Publishing.regenerate_cache("docs")
Publishing.invalidate_cache("docs")

Admin Routes

RouteLiveViewPurpose
/admin/publishingIndexGroups overview
/admin/publishing/new-groupNewCreate group
/admin/publishing/edit-group/:groupEditGroup settings
/admin/publishing/:groupListingPosts list with status tabs
/admin/publishing/:group/newEditorCreate post
/admin/publishing/:group/:uuid/editEditorEdit post
/admin/publishing/:group/previewPreviewLive preview
/admin/settings/publishingSettingsCache config

Public Routes

Multi-language mode:

/{language}/{group-slug} # Group listing
/{language}/{group-slug}/{post-slug} # Slug-mode post
/{language}/{group-slug}/{post-slug}/v/{version} # Versioned post
/{language}/{group-slug}/{date}/{time} # Timestamp-mode post

Single-language mode omits the /{language} segment.

When publishing_default_language_no_prefix is enabled, the default-language URL also drops its prefix (e.g. /blog instead of /en/blog), and requests to the prefixed form 301-redirect to the canonical prefixless URL.

Fallback Behavior

Caching

Listing Cache

Uses :persistent_term for near-zero-cost reads. Invalidated on post create/update, status change, translation add, or version create.

Publishing.regenerate_cache("my-blog")
Publishing.find_cached_post("my-blog", "post-slug")

Render Cache

ETS-based with 6-hour TTL and content-hash keys. Toggled globally or per-group:

# Global toggle
PhoenixKit.Settings.update_setting("publishing_render_cache_enabled", "true")
# Per-group toggle
PhoenixKit.Settings.update_setting("publishing_render_cache_enabled_docs", "false")
# Manual clear
PhoenixKit.Modules.Publishing.Renderer.clear_group_cache("docs")
PhoenixKit.Modules.Publishing.Renderer.clear_all_cache()

Content Format

Posts use Markdown with optional PHK components:

# My Post Title
Regular **Markdown** content with all GitHub-flavored features.
<Image file_id="019a6f96-..." alt="Description" />
<Hero variant="centered">
<Headline>Welcome</Headline>
<CTA primary="true" action="/signup">Get Started</CTA>
</Hero>
<EntityForm entity="contact" />

Supported components: Image, Hero, CTA, Headline, Subheadline, Video, EntityForm.

Settings

KeyDefaultDescription
publishing_enabledfalseEnable/disable module
publishing_public_enabledtrueShow public routes
publishing_default_language_no_prefixfalseOmit the locale prefix from default-language public URLs; prefixed requests 301-redirect
publishing_posts_per_page20Listing pagination
publishing_memory_cache_enabledtrueListing cache toggle
publishing_render_cache_enabledtrueRender cache global toggle
publishing_render_cache_enabled_<slug>truePer-group render cache

Testing

Unit tests run without a database. Integration and controller tests require PostgreSQL:

createdb phoenix_kit_publishing_test
mix test

Integration tests are automatically excluded when the database is unavailable. Controller tests run through a minimal Phoenix.Endpoint + Router + Layouts shipped under test/support/ — see AGENTS.md for details.

Dependencies

PackagePurpose
phoenix_kitModule behaviour, Settings, Auth, Cache, shared components
phoenix_live_viewAdmin LiveView pages
earmarkMarkdown rendering
saxyXML parsing for PHK components
obanBackground translation and migration workers

License

MIT