Packages
blog_engine
0.1.0
A standalone, tenant-aware blog domain library for Elixir and PostgreSQL applications
Current section
Files
Jump to
Current section
Files
blog_engine
README.md
README.md
# BlogEngine
[](https://hex.pm/packages/blog_engine)
[](https://hexdocs.pm/blog_engine)
[](https://github.com/agoodway/blogengine/blob/main/LICENSE)
> A tenant-aware blog domain engine for Elixir and PostgreSQL.
BlogEngine owns the blog domain: posts, immutable revisions, review and publication
lifecycles, authors, tags, route history, audit events, and curated cross-blog
syndication. Your application owns identity, authorization policy, HTTP, and
presentation.
## Why BlogEngine?
- **Drop-in blog domain** — every project hand-wires the same posts, drafts, tags,
and publishing rules, or pulls in an overly complex CMS.
- **Multi-tenant by default** — every operation runs through a context carrying a
`tenant_key`; tenant isolation is enforced by the engine, not by caller discipline.
- **Immutable revisions** — published content is never edited in place. Every change is
a revision with a full review trail (submit, approve, reject) and audit events.
- **Host-owned boundary** — no routes, controllers, or LiveViews are mounted. Your app
supplies authorization, route coordination, and event delivery through three small
behaviours; optional generators scaffold editor and public UIs you then own.
- **Postgres is the source of truth** — the schema installs into its own PostgreSQL
prefix through [EctoEvolver](https://github.com/agoodway/ecto_evolver) raw SQL
migrations, wrapped in a host-owned, version-pinned Ecto migration.
## Prerequisites
- Elixir 1.15+
- PostgreSQL with a host Ecto repo (Ecto SQL + Postgrex)
## Installation
Add `blog_engine` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:blog_engine, "~> 0.1.0"}
]
end
```
Then fetch dependencies:
```bash
mix deps.get
```
Igniter hosts can instead run `mix igniter.install blog_engine`, which performs the
setup below automatically.
## Setup
Configure the host repo and PostgreSQL prefix:
```elixir
# config/config.exs
config :my_app, ecto_repos: [MyApp.Repo]
config :blog_engine, repo: MyApp.Repo, prefix: "blog_engine"
```
The prefix is host-configurable; for example, an existing application can use
`prefix: "custom_blog"` consistently in its configuration, generated migration,
and runtime contexts.
Generate the pinned host migration, run it, and verify the installed schema:
```bash
mix blog_engine.setup --repo MyApp.Repo --prefix blog_engine
mix ecto.migrate
mix blog_engine.check_schema --repo MyApp.Repo --prefix blog_engine
```
`blog_engine.setup` delegates to the host's `ecto.gen.migration`. It generates a
host-owned Ecto migration wrapper pinned to the installed schema version and never
runs a migration itself. Commit the wrapper so package upgrades cannot silently change an
already-deployed migration. Later upgrades use
`mix blog_engine.gen.migration --from N --to N+1`.
> **One host per BEAM VM.** The `:blog_engine` application environment describes exactly
> one host: one repo, one prefix, one set of adapters. To run multiple hosts in one VM,
> pass `repo:`, `prefix:`, and the adapter modules explicitly to every
> `BlogEngine.Context.new/1` call.
## Usage
Every operation receives a context: tenant key, actor, repo/prefix, and host adapters.
```elixir
context =
BlogEngine.Context.new(
repo: MyApp.Repo,
prefix: "blog_engine",
tenant_key: tenant.id,
actor: %{type: "user", key: user.id},
authorizer: MyApp.BlogAuthorizer,
route_registry: MyApp.BlogRouteRegistry,
notifier: MyApp.BlogNotifier
)
```
### Author and Publish
```elixir
{:ok, author} = BlogEngine.Authors.create(context, %{name: "Ada Editor", slug: "ada-editor"})
{:ok, blog} =
BlogEngine.Blogs.create(context, %{
publisher_type: "publication",
publisher_key: publisher.id,
name: "News",
default_author_id: author.id,
index_route_template: "/news",
post_route_template: "/news/:post_slug"
})
{:ok, draft} =
BlogEngine.Posts.create_draft(context, blog.id, %{
author_id: author.id,
slug: "opening-day",
title: "Opening day",
content_markdown: "# Welcome"
})
{:ok, published} = BlogEngine.Posts.publish(context, draft.id)
```
### Review Lifecycle
Revisions are immutable; edits produce a new working revision that moves through review:
```elixir
{:ok, post} = BlogEngine.Posts.update_working_revision(context, post.id, %{title: "Updated"})
{:ok, post} = BlogEngine.Posts.submit(context, post.id)
{:ok, post} = BlogEngine.Posts.approve_revision(context, post.id, revision_id)
# or: BlogEngine.Posts.reject_revision(context, post.id, revision_id, "needs sources")
# Moderation
BlogEngine.Posts.unpublish(context, post.id)
BlogEngine.Posts.archive(context, post.id)
BlogEngine.Posts.withhold(context, post.id, "legal review")
BlogEngine.Posts.restore(context, post.id)
```
### Public Reads and Routing
```elixir
{:ok, %BlogEngine.Page{entries: entries}} = BlogEngine.Public.list_feed(context, blog.id)
{:ok, post} = BlogEngine.Public.get_post_by_slug(context, blog.id, "opening-day")
{:ok, tags} = BlogEngine.Public.list_tags_with_counts(context, blog.id)
# Resolve any canonical path (index, post, or historical redirect)
BlogEngine.Routing.resolve(context, "/news/opening-day")
```
Route history is first-class: moving a blog's templates or renaming a published post
retains the former path so hosts can serve permanent redirects.
### Tags and Syndication
```elixir
{:ok, tag} = BlogEngine.Tags.find_or_create(context, "Engineering")
# Curated cross-blog placement: request from the source, approve at the target
{:ok, placement} = BlogEngine.Syndication.request(context, post.id, target_blog.id)
{:ok, placement} = BlogEngine.Syndication.approve(context, placement.id)
```
## Host Behaviours
| Behaviour | Responsibility |
|----------------------------|----------------------------------------------------------------------------------|
| `BlogEngine.Authorizer` | Re-query host roles/ownership per operation — never trusts caller maps |
| `BlogEngine.RouteRegistry` | Coordinate canonical paths with the host's other routes, inside the transaction |
| `BlogEngine.Notifier` | Receive committed events; durable delivery and retry stay host-owned |
The engine enforces tenant, lifecycle, revision, and route invariants after host
authorization succeeds.
HTTP, JSON, OpenAPI, and frontend contracts are host-owned; BlogEngine exposes only
the in-process domain capabilities and extension boundaries hosts build on.
## Mix Tasks
| Task | Description |
|---------------------------------|--------------------------------------------------------|
| `mix blog_engine.setup` | Generate the initial pinned host migration |
| `mix blog_engine.gen.migration` | Generate one adjacent version-upgrade wrapper |
| `mix blog_engine.check_schema` | Verify the installed schema version (CI/deploy gate) |
| `mix blog_engine.gen.editor` | Scaffold a host-owned Phoenix editor UI (optional) |
| `mix blog_engine.gen.public` | Scaffold host-owned public blog pages (optional) |
| `mix blog_engine.gen.ai` | Scaffold a ReqLLM-backed writing assistant (optional) |
Generated files are conflict-safe, host-owned scaffolds. BlogEngine upgrades never
overwrite them, and no runtime UI or routes are mounted by the library itself.
## Guides
- [`guides/installation.md`](guides/installation.md) — prefixes, upgrades, backups,
contexts, lifecycle examples, and extension behaviours
- [CHANGELOG](CHANGELOG.md) — releases
## Testing
```bash
mix test
mix check # format check, compile --warnings-as-errors, credo --strict, doctor, test
```
Integration tests compile disposable host projects and resolve real Hex
dependencies, so they are excluded from the default run:
```bash
mix test --include integration
```
Quality gates used by this repo:
```bash
mix quality # compile --warnings-as-errors, format check, sobelow, ex_dna, doctor, credo --strict
mix dialyzer
```
## License
MIT