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 are created by PhoenixKit's core migrations. If you need to set them up independently (e.g. fresh install without core migrations), the module includes a consolidated migration:

PhoenixKit.Modules.Publishing.Migrations.PublishingTables.up(%{prefix: nil})

All statements use IF NOT EXISTS, so it's safe to run even when tables already exist.

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

Column Type Purpose
uuid UUIDv7 PK
name string Display name
slug string URL identifier (unique)
mode string "timestamp" or "slug" — locked at creation
status string "active" or "trashed"
position integer Display ordering
data JSONB type, item_singular/plural, icon, comments/likes/views_enabled
title_i18n JSONB Translatable group title (keyed by language code)
description_i18n JSONB Translatable 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.

Column Type Purpose
uuid UUIDv7 PK
group_uuid UUIDv7 FK → groups
slug string URL path segment (slug mode, unique per group)
mode string "timestamp" or "slug"
post_date date URL date segment (timestamp mode)
post_time time URL time segment (timestamp mode, unique per group+date)
active_version_uuid UUIDv7 FK → versions — the live version (null = unpublished)
trashed_at utc_datetime Soft delete timestamp (null = active)
created_by_uuid UUIDv7 FK → users (audit)
updated_by_uuid UUIDv7 FK → 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.

Column Type Purpose
uuid UUIDv7 PK
post_uuid UUIDv7 FK → posts
version_number integer Sequential (v1, v2, ...), unique per post
status string "draft" / "published" / "archived"
published_at utc_datetime When this version was first published
created_by_uuid UUIDv7 FK → users (audit)
data JSONB featured_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.

Column Type Purpose
uuid UUIDv7 PK
version_uuid UUIDv7 FK → versions
language string Language code (unique per version)
title string Post title in this language
content text Markdown/PHK body in this language
url_slug string Per-language URL slug (for localized URLs)
status string Reserved for future per-language overrides (unused by UI)
data JSONB Reserved 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
  migrations/                # Consolidated DB migration

Core Modules

Module Role
PhoenixKit.Modules.Publishing Main context/facade — delegates to all submodules
Publishing.DBStorage Direct Ecto queries for all CRUD operations
Publishing.ListingCache:persistent_term cache with sub-microsecond reads
Publishing.Renderer Earmark markdown + PHK component rendering with ETS cache
Publishing.PageBuilder XML parser (Saxy) for <Image>, <Hero>, etc. components
Publishing.StaleFixer Reconciles DB/cache state, auto-cleans empty posts
Publishing.Presence Phoenix.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

Route LiveView Purpose
/admin/publishing Index Groups overview
/admin/publishing/new-group New Create group
/admin/publishing/edit-group/:group Edit Group settings
/admin/publishing/:group Listing Posts list with status tabs
/admin/publishing/:group/new Editor Create post
/admin/publishing/:group/:uuid/edit Editor Edit post
/admin/publishing/:group/preview Preview Live preview
/admin/settings/publishing Settings Cache 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.

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

Key Default Description
publishing_enabledfalse Enable/disable module
publishing_public_enabledtrue Show public routes
publishing_posts_per_page20 Listing pagination
publishing_memory_cache_enabledtrue Listing cache toggle
publishing_render_cache_enabledtrue Render cache global toggle
publishing_render_cache_enabled_<slug>true Per-group render cache

Testing

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

createdb phoenix_kit_publishing_test
mix test

Integration tests are automatically excluded when the database is unavailable.

Dependencies

Package Purpose
phoenix_kit Module behaviour, Settings, Auth, Cache, shared components
phoenix_live_view Admin LiveView pages
earmark Markdown rendering
saxy XML parsing for PHK components
oban Background translation and migration workers

License

MIT