README.md

# PhoenixBlog

![](github/blog_page_search.png)

Plug-and-play blog engine for Phoenix with [Editor.js](https://editorjs.io/) integration.

- Public blog with search, tag filtering, and pagination
- Admin dashboard with Editor.js rich text editor
- Auto-save drafts — posts save automatically as you type
- SEO out of the box — Open Graph, Twitter Cards, canonical URLs, and JSON-LD structured data
- Optional likes — database-backed, auth-required toggle with like counts
- Optional share buttons — Copy link, Twitter/X, Facebook, LinkedIn
- Embeddable recent posts component for any page
- Self-contained CSS and JS — loads its own assets automatically
- Supports PostgreSQL, MySQL, and SQLite
- Authentication via host app's `on_mount` hooks

![editorjs screenshot](github/editorjs.png)

## Installation

Add `phoenix_blog` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:phoenix_blog, "~> 0.1"}
  ]
end
```

## Setup

### 1. Configure the repo

```elixir
# config/config.exs
config :phoenix_blog, repo: MyApp.Repo
```

### 2. Create and run the migration

```bash
mix ecto.gen.migration add_phoenix_blog
```

```elixir
defmodule MyApp.Repo.Migrations.AddPhoenixBlog do
  use Ecto.Migration

  def up, do: PhoenixBlog.Migration.up()
  def down, do: PhoenixBlog.Migration.down()
end
```

```bash
mix ecto.migrate
```

### 3. Serve static assets

Add to your `endpoint.ex`, **before** the existing `Plug.Static`:

```elixir
plug Plug.Static,
  at: "/phoenix_blog",
  from: {:phoenix_blog, "priv/static"}
```

### 4. Register the JS hooks

In your `assets/js/app.js`:

```javascript
import { PhoenixBlogHooks } from "../../deps/phoenix_blog/priv/static/editorjs/hook.js"

const liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: { ...PhoenixBlogHooks, ...yourOtherHooks }
})
```

Editor.js scripts are loaded dynamically by the hook — no additional `<script>` tags needed.

### 5. Add Tailwind source

Add the library's templates to your Tailwind source paths so its utility classes get generated. In `assets/css/app.css`:

```css
@source "../../deps/phoenix_blog/lib";
```

### 6. Mount routes

In your `router.ex`:

```elixir
use PhoenixBlog.Web, :router

# Public blog (accessible to everyone)
scope "/" do
  pipe_through :browser
  phoenix_blog "/blog"
end

# Admin dashboard (protected by your auth)
scope "/" do
  pipe_through [:browser, :require_authenticated_user]
  phoenix_blog_dashboard "/admin/blog",
    on_mount: [{MyAppWeb.UserAuth, :require_authenticated}]
end
```

That's it. The blog renders inside your app's root layout automatically.

### 7. Add SEO tags to your root layout

Add these two lines to the `<head>` in your root layout (`lib/my_app_web/components/layouts/root.html.heex`):

```heex
<PhoenixBlog.Web.SEO.meta_tags seo={assigns[:seo]} />
<PhoenixBlog.Web.SEO.json_ld seo={assigns[:seo]} />
```

These are no-ops on non-blog pages (when `@seo` is nil), so they're safe to include globally. On blog pages they automatically render Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data.

Alternatively, create a dedicated blog root layout (e.g. `blog_root.html.heex`) with the SEO tags included and pass it to the router:

```elixir
phoenix_blog "/blog", root_layout: {MyAppWeb.Layouts, :blog_root}
```

![article preview](github/article_preview.png)

## SEO

Every blog page ships with complete SEO metadata — zero configuration required.

### What's rendered automatically

**Blog post pages** (`/blog/:slug`):
- `<title>` set to the post title
- `<meta name="description">` from the admin's SEO description field, or auto-generated from the first paragraph
- Open Graph tags: `og:title`, `og:description`, `og:image`, `og:url`, `og:type` (`article`), `og:site_name`, `og:locale`
- Article tags: `article:published_time`, `article:modified_time`, `article:author`, `article:tag`
- Twitter Card tags: `twitter:card` (`summary_large_image` when an image exists), `twitter:title`, `twitter:description`, `twitter:image`
- `<link rel="canonical">` with the clean URL (no query params)
- JSON-LD structured data (`Article` schema with headline, author, dates, publisher)

**Blog index page** (`/blog`):
- Open Graph tags with `og:type` set to `website`
- Twitter Card, canonical URL, and JSON-LD (`Blog` schema)

### Optional SEO configuration

All optional — sensible defaults are used if omitted:

```elixir
# config/config.exs
config :phoenix_blog,
  site_name: "My Blog",                          # default: "Blog"
  default_og_image: "https://example.com/og.png", # fallback when post has no featured image
  twitter_site: "@myhandle",                       # Twitter/X site handle
  locale: "en_US"                                  # Open Graph locale
```

## Likes & Share (Optional)

Both features are opt-in and disabled by default. Enable either or both in your config:

```elixir
# config/config.exs
config :phoenix_blog,
  likes_enabled: true,
  share_enabled: true
```

### Likes

Likes require a user identity. Since the library doesn't own your users table, you provide a function that extracts the current user from the LiveView socket:

```elixir
# config/config.exs
config :phoenix_blog,
  likes_enabled: true,
  get_current_user: fn socket ->
    case socket.assigns[:current_scope] do
      %{user: user} -> user
      _ -> nil
    end
  end
```

The default `get_current_user` function returns `socket.assigns[:current_user]`, which works if your app assigns the user directly. The example above works with Phoenix 1.8's `mix phx.gen.auth` which uses `current_scope`.

#### Likes migration

Create a second migration for the likes table:

```bash
mix ecto.gen.migration add_phoenix_blog_likes
```

```elixir
defmodule MyApp.Repo.Migrations.AddPhoenixBlogLikes do
  use Ecto.Migration

  def up, do: PhoenixBlog.Migration.up(version: 2)
  def down, do: PhoenixBlog.Migration.down(version: 2)
end
```

```bash
mix ecto.migrate
```

#### Passing user identity to the blog

The blog routes need an `on_mount` hook that assigns the current user to the socket. For example, with `mix phx.gen.auth`:

```elixir
# lib/my_app_web/live/user_auth_hook.ex
defmodule MyAppWeb.UserAuthHook do
  import Phoenix.Component

  alias MyApp.Accounts
  alias MyApp.Accounts.Scope

  def on_mount(:maybe_assign_user, _params, session, socket) do
    user =
      case session do
        %{"user_token" => token} ->
          case Accounts.get_user_by_session_token(token) do
            {user, _token_inserted_at} -> user
            nil -> nil
          end
        _ -> nil
      end

    {:cont, assign(socket, :current_scope, Scope.for_user(user))}
  end
end
```

Then pass it to the blog routes:

```elixir
scope "/" do
  pipe_through :browser
  phoenix_blog "/blog",
    on_mount: [{MyAppWeb.UserAuthHook, :maybe_assign_user}]
end
```

When a user is logged in, they can toggle likes on posts. Anonymous visitors see the like count but cannot interact.

### Share buttons

When `share_enabled: true`, each blog post page shows social share buttons for Copy link, Twitter/X, Facebook, and LinkedIn. No database or authentication required.

The Copy link button uses the `PhoenixBlogCopyLink` hook which is included in `PhoenixBlogHooks` — no additional JS setup needed if you followed the hooks setup in step 4.

## Usage

After setup, the built-in pages at `/blog` and `/admin/blog` are ready to use. You can also embed blog content into your own pages using the provided components.

### Embedding Recent Posts

Show the latest blog posts on any LiveView page:

```elixir
<.live_component
  module={PhoenixBlog.Web.Components.RecentPosts}
  id="recent-posts"
  blog_path="/blog"
/>
```

![recent posts](github/embed_posts.png)

| Attribute | Default | Description |
|-----------|---------|-------------|
| `blog_path` | **(required)** | Path where your blog is mounted |
| `count` | `3` | Number of posts to display |
| `title` | `"Latest Posts"` | Section heading (`nil` to hide) |
| `class` | `nil` | Additional CSS classes on the wrapper |

### Embedding the Blog Feed

Use the `BlogFeed` component to add a full blog listing with search, tag filters, and pagination to any page — for example, a homepage:

```elixir
defmodule MyAppWeb.HomeLive do
  use MyAppWeb, :live_view

  alias PhoenixBlog.Web.Components.BlogFeed

  def mount(_params, _session, socket), do: {:ok, socket}

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to my site</h1>

      <.live_component
        module={BlogFeed}
        id="home-feed"
        blog_path="/blog"
        per_page={6}
        show_hero={false}
      />
    </Layouts.app>
    """
  end
end
```

Post cards link to `/blog/:slug` by default. The `blog_path` attribute must match the path you mounted `phoenix_blog` on in your router.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `blog_path` | **(required)** | Path where your blog is mounted |
| `per_page` | `12` | Items per page |
| `show_search` | `true` | Show search bar |
| `show_tags` | `true` | Show tag filter buttons |
| `show_hero` | `false` | Show hero banner with title and post count |
| `class` | `nil` | Additional CSS classes for the wrapper |
| `grid_class` | `"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"` | CSS classes for the posts grid |

#### Custom post cards with slots

Override the default card design using the `:post_card` slot. This is useful when your app uses a design system (e.g. daisyUI) and you want the blog feed to match:

```elixir
<.live_component module={BlogFeed} id="home-feed" blog_path="/blog">
  <:post_card :let={post}>
    <a href={"/blog/#{post.slug}"} class="card bg-base-100 border hover:shadow-lg transition-all">
      <figure :if={post.featured_image_url} class="h-48">
        <img src={post.featured_image_url} alt={post.title} class="w-full h-full object-cover" />
      </figure>
      <div class="card-body p-5">
        <h2 class="card-title text-lg">{post.title}</h2>
        <p :if={post.author} class="text-sm opacity-50">{post.author}</p>
      </div>
    </a>
  </:post_card>

  <:empty>
    <p class="text-center py-12">Nothing here yet!</p>
  </:empty>
</.live_component>
```

The post struct passed to `:let` contains: `title`, `slug`, `body`, `tags`, `author`, `featured_image_url`, `published_at`, `seo_description`.

### Custom Blog Post View

To build your own post page (e.g. with a sidebar or related posts), use the `:show_view` router option and the `BlogPost` component:

```elixir
# router.ex
phoenix_blog "/blog", show_view: MyAppWeb.CustomBlogShow
```

```elixir
defmodule MyAppWeb.CustomBlogShow do
  use MyAppWeb, :live_view

  import PhoenixBlog.Web.Components.BlogPost

  def mount(_params, _session, socket), do: {:ok, socket}

  def handle_params(%{"slug" => slug}, uri, socket) do
    post = PhoenixBlog.get_post_by_slug!(slug)

    {:noreply,
     socket
     |> assign(:post, post)
     |> assign(:page_title, post.title)
     |> PhoenixBlog.Web.SEO.assign_seo(post, uri)}
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-4xl mx-auto px-4 py-12">
      <.blog_post post={@post} blog_path="/blog" show_tags_footer={false} />

      <aside class="mt-12">
        <.live_component
          module={PhoenixBlog.Web.Components.RecentPosts}
          id="related-posts"
          blog_path="/blog"
          title="More articles"
          count={3}
        />
      </aside>
    </div>
    """
  end
end
```

Call `PhoenixBlog.Web.SEO.assign_seo/3` in your `handle_params` to get full SEO metadata on custom views.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `post` | **(required)** | A `%PhoenixBlog.Post{}` struct |
| `blog_path` | `"/blog"` | Path for back and tag links |
| `show_back_link` | `true` | Show "Back to blog" link |
| `show_header` | `true` | Show title, tags, author, and date |
| `show_featured_image` | `true` | Show the featured image |
| `show_tags_footer` | `true` | Show tags section at the bottom |
| `class` | `nil` | CSS classes for the wrapper `<article>` element |

### Rendering Editor.js Content

Use `render_editor_blocks/1` to render post content anywhere:

```elixir
import PhoenixBlog.Web.BlogComponents

<.render_editor_blocks blocks={Map.get(@post.body, "blocks", [])} />
```

Supported block types: **paragraph**, **header** (h1-h6), **list** (ordered/unordered), **quote** (with caption), **code**, **table**, **delimiter**, **embed** (YouTube, Twitter, Vimeo), and **image** (with caption).

### Excerpt Extraction

Extract a plain-text excerpt from any post's Editor.js body:

```elixir
import PhoenixBlog.Web.BlogComponents

extract_excerpt(post)       # 150 chars (default)
extract_excerpt(post, 100)  # custom length
```

Returns the first paragraph's text, stripped of HTML tags and truncated with `...`.

## Configuration

| Option | Default | Description |
|--------|---------|-------------|
| `:repo` | **(required)** | Your Ecto repo module |
| `:table_name` | `"phoenix_blog_posts"` | Database table name |
| `:likes_enabled` | `false` | Enable post likes |
| `:share_enabled` | `false` | Enable social share buttons |
| `:get_current_user` | `fn socket -> socket.assigns[:current_user] end` | Function to extract current user from socket |
| `:likes_table_name` | `"phoenix_blog_post_likes"` | Database table name for likes |
| `:site_name` | `"Blog"` | Site name for Open Graph and JSON-LD |
| `:default_og_image` | `nil` | Fallback OG image URL when post has no featured image |
| `:twitter_site` | `nil` | Twitter/X site handle (e.g. `"@myhandle"`) |
| `:locale` | `"en_US"` | Open Graph locale |

## Router Options

### `phoenix_blog/2`

| Option | Default | Description |
|--------|---------|-------------|
| `:on_mount` | `[]` | Additional `on_mount` hooks |
| `:as` | `:phoenix_blog` | Live session name |
| `:layout` | `nil` | Custom app layout `{Module, :template}` |
| `:root_layout` | `nil` | Override root layout (e.g. a dedicated `blog_root` layout) |
| `:index_view` | `PhoenixBlog.Web.Live.Public.Index` | Custom LiveView for the blog index |
| `:show_view` | `PhoenixBlog.Web.Live.Public.Show` | Custom LiveView for the blog post page |

### `phoenix_blog_dashboard/2`

| Option | Default | Description |
|--------|---------|-------------|
| `:on_mount` | `[]` | Authentication hooks (recommended) |
| `:as` | `:phoenix_blog_dashboard` | Live session name |
| `:layout` | `nil` | Custom app layout `{Module, :template}` |

## Features

### Public Blog (`/blog`)

- Responsive card grid with featured images
- Full-text search
- Tag filtering
- Pagination
- SEO-friendly slugs
- Full SEO metadata (Open Graph, Twitter Cards, JSON-LD, canonical URLs)
- Optional likes with per-post counts
- Optional social share buttons (Copy link, Twitter/X, Facebook, LinkedIn)

### Admin Dashboard (`/admin/blog`)

- Post list with search, status filter, and pagination
- Editor.js rich text editor with auto-save
- Draft/Published/Archived status management
- SEO metadata (description, slug, tags)
- Featured image support (via URL)
- Soft delete and restore

### Editor.js Tools

The following tools are included out of the box:

- **Header** (h1-h6)
- **List** (ordered/unordered)
- **Quote** (with caption)
- **Code** (code blocks)
- **Table** (rows/cols)
- **Delimiter** (section break)
- **Embed** (YouTube, Twitter, Vimeo, Instagram, CodePen)
- **Image** (via URL)

## Public API

The `PhoenixBlog` module exposes functions for querying posts from your own code:

```elixir
# Latest published posts (for widgets, feeds, etc.)
PhoenixBlog.list_latest_published_posts(5)

# Paginated published posts with filters
PhoenixBlog.list_published_posts(page: 1, per_page: 10, tag: "elixir")

# Single post by slug
PhoenixBlog.get_post_by_slug!("my-post")

# Admin CRUD
PhoenixBlog.create_post(%{"title" => "Hello", "status" => "draft", ...})
PhoenixBlog.update_post(post, %{"status" => "published"})
PhoenixBlog.publish_post(post)
PhoenixBlog.soft_delete_post(post)
PhoenixBlog.restore_post(post)
```