Current section

Files

Jump to

README.md

# 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`:
```elixir
{:phoenix_kit_publishing, "~> 0.5"}
```
Or for local development:
```elixir
{: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 7 publishing tables — `phoenix_kit_publishing_groups`, `_posts`,
`_versions`, `_contents` (core `V135` baseline) and `_categories`,
`_post_categories`, `_post_views` (core `V159`) — are created by PhoenixKit's
core versioned migrations. Run `mix phoenix_kit.install` in the host app and
they're set up automatically. This module also owns their *future* shape
through its own versioned chain, `PhoenixKitPublishing.Migrations`
(`migration_module/0`), which `mix phoenix_kit.update` discovers and drives
alongside core's own chain — no separate command to run. Its current version
(V1) is a pure adoption of the shape core already creates: it changes
nothing on an existing install beyond stamping a version marker.
### Enable the Module
Via admin UI: navigate to Admin > Modules > Publishing > toggle on.
Or via code:
```elixir
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 (7 tables)
```
Group (1) ──→ (many) Post (1) ──→ (many) Version (1) ──→ (many) Content
Group (1) ──→ (many) Category (self-referencing tree)
Post (many) ──→ (many) Category via PostCategory
Post (1) ──→ (many) PostView (one row per day)
```
#### `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, `media_folder_uuid` (see [Media folders](#media-folders)) |
| 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, `media_folder_uuid` (the post's folder, on every version — see [Media folders](#media-folders)) |
#### `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) |
#### `phoenix_kit_publishing_categories` — Hierarchical per-group taxonomy
WordPress-parity categories. `slug` is unique per group (not globally).
| Column | Type | Purpose |
|--------|------|---------|
| uuid | UUIDv7 | PK |
| group_uuid | UUIDv7 | FK → groups (`ON DELETE CASCADE`) |
| parent_uuid | UUIDv7 | FK → categories, self-referencing (`ON DELETE SET NULL` — deleting a parent lifts children to the root) |
| name | string | Display name |
| slug | string | URL segment, unique per group |
| name_i18n | JSONB | Per-language display-name overrides |
| description | string | Optional description |
| position | integer | Display ordering |
#### `phoenix_kit_publishing_post_categories` — Post ↔ category assignment
Many-to-many, post-level (not per-version) — WordPress semantics. Composite
primary key `(post_uuid, category_uuid)`; both FKs cascade.
#### `phoenix_kit_publishing_post_views` — Per-day view counters
One `(post_uuid, view_date)` row incremented in place; no per-request rows,
no reader PII. Composite primary key `(post_uuid, view_date)`; `post_uuid`
FK cascades. Queried schemaless (no Ecto schema module) via `Publishing.Views`.
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
media_folders.ex # Group media folders + ready-made host hooks
media_adoption.ex # One-time filing of existing post media
media_reorganizer.ex # Plan source for core's media reorganizer
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
| 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` | MDEx 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 |
| `Publishing.MediaFolders` | One media folder per group; files picked in the editor go there |
| `Publishing.MediaAdoption` | Files the media posts already use into their group's folder |
| `Publishing.MediaReorganizer` | Group folders for `mix phoenix_kit.media.reorganize` |
## IEx / CLI Usage
```elixir
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
### How Dispatch Works (and how it interacts with host routes)
Public URLs are dynamic — the group slug is a database row, not a compile-time
literal — so these routes can't be declared normally. Publishing registers its
catch-all under an internal prefix and overrides the host router's `call/2`
(`RouterDispatch`): on each `GET`/`HEAD`, if the first non-locale path segment
matches a known group slug, the path is rewritten to the internal prefix and
Phoenix matches it there. Otherwise the request passes through untouched.
Two consequences worth knowing:
- **A host route whose first segment equals a group slug will never match.** The
rewrite happens in `call/2`, before route matching — declaration order in
`router.ex` doesn't help. Only `GET`/`HEAD` are rewritten, so a `POST /blog/...`
still reaches the host.
- **`mix phx.routes` shows these routes under `__phoenix_kit_publishing_dispatch`**,
not at their public paths.
### Reserved Route Prefixes
Another PhoenixKit module can claim a top-level segment by implementing
`PhoenixKit.Module.reserved_route_prefixes/0` (added in `phoenix_kit` 1.7.170):
```elixir
@impl PhoenixKit.Module
def reserved_route_prefixes, do: ["shop"]
```
`RouterDispatch.known_group?/1` consults
`PhoenixKit.ModuleRegistry.all_reserved_route_prefixes/0` and refuses to claim a
reserved segment **even when a group with that exact slug exists in publishing's
own data**. On `phoenix_kit` older than 1.7.170 the callback is absent and nothing
is reserved.
Reserve a prefix only if your module actually renders that route — a reservation
removes the path from publishing's dispatch, and if nothing takes over, the result
is a 404. `phoenix_kit_legal` reserved `"legal"` in its 0.1.6 without shipping a
renderer and 404'd public legal pages on every host app; it was reverted in 0.1.7,
and legal pages are once again served here as an ordinary group. If your module
stores its content as publishing posts, letting publishing render them is usually
the right call — you inherit languages, translations, canonical/`og:*`/hreflang,
and the editor for free.
## Caching
### Listing Cache
Uses `:persistent_term` for near-zero-cost reads. Invalidated on post create/update, status change, translation add, or version create.
```elixir
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:
```elixir
# 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:
```markdown
# 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 |
| `publishing_media_folder_uuid` | — | The module's media folder, written by the ready-made media hook (see below) |
## Media folders
Off by default: a host that configures nothing keeps today's behaviour and no
folder is created. To keep each group's media in its own folder —
`Publishing/News`, `Publishing/Legal` — add the ready-made hooks:
```elixir
config :phoenix_kit_publishing,
attachments_parent_folder: {PhoenixKit.Modules.Publishing.MediaFolders, :module_folder},
attachments_folder_name: {PhoenixKit.Modules.Publishing.MediaFolders, :folder_name}
```
For a folder per post inside its group's — `Publishing/News/spring-fair`, a
timestamp post's named by its date and time — add:
```elixir
config :phoenix_kit_publishing, :post_media_folders, true
```
Or point either hook key at your own function (core's `Storage.ResourceFolders`
convention): `parent_for(:group, actor_uuid, group)` answers
`{:ok, folder_uuid}` or `nil` for the media root; `name_for(subject, actor_uuid)`
— a group, or a post with post folders on — answers `{:ok, name}` or `nil` for
`publishing-group-<uuid>` / `publishing-post-<uuid>`. A group or post keeps its
folder when it is renamed later.
Then, once:
```bash
mix phoenix_kit_publishing.media.adopt # dry run: what would be filed
mix phoenix_kit_publishing.media.adopt --apply # file it
```
(In a release: `PhoenixKit.Modules.Publishing.MediaAdoption.run(actor_uuid, apply?: true)`.)
This files every file a group's posts use — featured, OG and audio slots,
`<Image>`/`<Audio>`/`<Showcase>` components, baked `/file/<uuid>/…` URLs — into
the group's folder, or each post's. A file with no folder is moved in; a file
that already lives in another folder stays there and is linked in (with post
folders, one in the group's own folder moves down into the post's). A file
several posts use lives in the first post's folder and is linked into the
others'. File URLs do not change.
From then on every file picked in the post editor lands in the post's (or
group's) folder by itself, and `mix phoenix_kit.media.reorganize` moves the group folders when
you change the hooks later — post folders travel with their group's (it also
reports a group or post whose files are outside its folder, the folder of a
trashed group or post, and a name hook that can't be called).
With the ready-made hooks, even its dry run may create the `Publishing` folder
if it is missing: the parent hook creates it on first use.
Folders are only ever looked up and created in the site's media library
(Media), never in a person's own library. If a configured hook cannot be
called, the adoption step refuses to plan; if one fails while applying, that
group is left unfiled rather than filed at the media root.
> #### Trashing a group's folder trashes the posts' pictures {: .warning}
>
> After adoption, a file that used to have no folder lives in its group's (or
> post's) folder. Moving `Publishing/<group>`, a post's folder, or `Publishing`
> itself to the trash in
> the media browser trashes every file whose home is inside it — including one
> that is also shown somewhere else by URL (a page, another module) — and those
> pictures stop showing until the folder is restored from the trash. A file
> that is only *linked* into the folder keeps its own home and is not affected.
> Rename or move these folders freely; trash them only together with their
> group or post.
## Removing this module
There is deliberately **no automated uninstall**.
`PhoenixKitPublishing.Migrations.down/1` never drops any of the 7 tables or a
row in them, for any target version — a host that merely removes this
dependency from `mix.exs` has not consented to deleting every content group,
post, version, per-language content row, category, category assignment, and
view counter, and a migration whose result depended on which packages happen
to be compiled in would be nondeterministic. Removing the data is therefore
a deliberate, manual operator step:
```sql
-- Only after removing :phoenix_kit_publishing from mix.exs, and only if you
-- actually want every group, post, version, content row, category,
-- assignment, and view counter gone for good.
--
-- One statement, on purpose: posts <-> versions is a genuine FK cycle
-- (posts.active_version_uuid points forward to the live version,
-- versions.post_uuid points back to the owning post), so dropping the tables
-- one at a time fails with "other objects depend on it". A single DROP TABLE
-- listing all 7 resolves the cycle itself, without naming any constraint —
-- which matters on a host whose constraints were renamed.
DROP TABLE
phoenix_kit_publishing_post_views,
phoenix_kit_publishing_post_categories,
phoenix_kit_publishing_categories,
phoenix_kit_publishing_contents,
phoenix_kit_publishing_versions,
phoenix_kit_publishing_posts,
phoenix_kit_publishing_groups;
```
Dropping `phoenix_kit_publishing_groups` last also removes the
`pkpub_schema:<N>` version marker, which is a `COMMENT` on that table — no
separate step is needed.
If you want to keep the tables (e.g. you plan to reinstall the module
later) but stop this chain from tracking them, clear the version marker
instead:
```sql
COMMENT ON TABLE phoenix_kit_publishing_groups IS NULL;
```
## Testing
Unit tests run without a database. Integration and controller tests require PostgreSQL:
```bash
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 |
| `mdex` | Markdown rendering (comrak) |
| `saxy` | XML parsing for PHK components |
| `oban` | Background translation and migration workers |
## License
MIT