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
- Dual URL Modes — Timestamp-based (blog/news) or slug-based (docs/FAQ), locked at group creation
- Multi-Language Support — Separate content per language with language switcher and smart fallbacks
- Versioning — Independent version history per post with publish/archive controls
- Collaborative Editing — Real-time presence tracking with owner/spectator locking
- AI Translation — Background translation via OpenRouter integration (Oban workers)
- Two-Layer Caching — Listing cache (
:persistent_term, ~0.1us reads) + render cache (ETS, 6hr TTL) - Rich Content — Markdown + inline PHK components (Image, Hero, CTA, Video, Headline, EntityForm)
- Admin Interface — Full CRUD with inline status controls, skeleton loading, trash management
- Public Routes — SEO-friendly URLs with smart language/date fallbacks, pagination, breadcrumbs
- Featured Images — Media library integration via PhoenixKit's MediaSelectorModal
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) Contentphoenix_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 jobsCore 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.
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
- Missing language → tries default language, then other available languages
- Missing timestamp post → tries other times on same date, then group listing
- All fallbacks include a flash message explaining the redirect
- Invalid group slugs fall back to 404 only after exhausting all alternatives
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_enabled | false | Enable/disable module |
publishing_public_enabled | true | Show public routes |
publishing_default_language_no_prefix | false | Omit the locale prefix from default-language public URLs; prefixed requests 301-redirect |
publishing_posts_per_page | 20 | Listing pagination |
publishing_memory_cache_enabled | true | Listing cache toggle |
publishing_render_cache_enabled | true | Render cache global toggle |
publishing_render_cache_enabled_<slug> | true | Per-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
| 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