# BlogEngine

[![Hex.pm](https://img.shields.io/hexpm/v/blog_engine.svg)](https://hex.pm/packages/blog_engine)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/blog_engine)
[![License](https://img.shields.io/hexpm/l/blog_engine.svg)](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
