Roll Your Own Blog in Elixir and Phoenix

ELIXIR MARKDOWN

Rolling your own blog is a practice much in vogue today with the proliferation of static blogging frameworks like Jekyll and Hugo.

In the past, I had a number of personal and professional blogs on WordPress, but this time I desired to blog using the same framework as I do for my application code.

Consequently, when the opportunity came to create a blog for Cassava, the decision to go with Elixir and Phoenix was a no-brainer.

At the end of this article, you will have a clear understanding of the approach I adopted to roll my blog and the tradeoffs you can expect to face should you choose to do the same.

Blog as First-Class Citizen

In software development, we are always trying to minimize context switching to boost productivity. Having a blog that follows the same development and deployment patterns as your application code is one sure way to satisfy this tenant.

Blog as you code. Code as you blog.

With Vim as my editor and Markdown as my syntax of choice, articles would be committed to source as feature branches, be peer-reviewed via pull requests, and be deployed to production using Ansible.

Anyone looking from the outside would not see any distinction between this workflow and the one utilized for the application code.

So did you go static?

The short answer is no, even though they are numerous examples of how to achieve a static blogging framework in Elixir and Phoenix.

Dashbit chronicled their blogging journey in the article Off-the-shelf or roll our own? in which they adopted a hybrid approach by pre-compiling posts into memory.

A more traditional static approach can be found on Sebastian Seilunds’ website.

However, what you will not find on any of these blogs, and many other static ones, is support for native comments or any comments for that matter. For this reason, I opted for a dynamic approach that leverages the same database powering Cassava.

The value of a conversation

Many blogs today no longer allow comments for fear of the spam that they can attract. However, this decision is just another example of throwing the baby out with the bath water.

Yes, over the years, spam has become a pain, and the rising appeal of social media has been embraced as the solution. What you see today are posts void of any commentary with directions redirecting the reader to follow the discussion elsewhere.

What is your why for blogging?

Blogs are first and foremost about fostering conversations.

Having someone come to your blog only to be told to go elsewhere for a discussion is akin to inviting them to your home for a barbecue and leaving instructions on where to go for the burgers and beers.

Your article and the conversation that ensues should live and thrive together. Otherwise, you are taking your audience for granted.

Lastly, the folks over at Postmatic have a treasure trove of articles detailing the value proposition of the conversations you curate if you are still not convinced.

Privacy still matters

You may be inclined to reach for a free third-party commenting framework and keep things moving at this juncture. But before you do, keep in mind that free can be pretty expensive with regard to privacy.

You can pay with your privacy or your money. — DHH

In releasing Hey to the world, Basecamp’s co-founder echoed the sentiment above. No more accurate statement has been made in the world of privacy today than this one.

For this reason, I decided to host my own comments with all the privacy assurances I can confer.

Markdown Assemble!

The process outline here is a natural extension of how I develop and deploy the application code for Cassava.

Each post is written as a Markdown document which consists of frontmatter and a body.

---
title: Roll Your Own Blog in Elixir and Phoenix
slug: roll-your-own-blog-in-elixir-phoenix
date: 2022-06-04T07:30:00.000000-05:00
description: ...
keywords: blog,elixir
banner_image:
featured: false
author: Michael
---

What you are now reading lives here...

An Elixir module is responsible for publishing the Markdown document to the database. This workflow involves parsing the file, converting the Markdown to HTML, and persisting the content as a Post struct. This pipeline requires adding three dependencies:

{:earmark, "~> 1.4.20"},
{:yamerl, "~> 0.9.0"},
{:html_sanitize_ex, "~> 1.4.2"},

Yamerl parses the markdown. Earmark allows us to convert the body from Markdown to HTML, which we persist to the database. A sanitized version of the content enables full-text search tokens to be generated. This sanitized version is not persisted in the database.

defmodule CassavaBlog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field(:slug, :string)
    field(:author, :string)
    field(:title, :string)
    field(:description, :string)
    field(:featured, :boolean)
    field(:banner_image, :string)
    field(:content, :string)
    field(:published_date, :utc_datetime)
    field(:sanitize_content, :string)

    has_many(:comments, Comment)
    has_many(:tags, TagPost)

    timestamps()
  end
defmodule CassavaBlog.Publisher do
  alias CassavaBlog.Post

  @doc false
  def publish(content) when is_bitstring(content) do
    {frontmatter, body} = content |> split_file_content()
    slug = get_value(frontmatter, "slug")

    Multi.new()
    |> Multi.insert(
      :post,
        Post.changeset(
          %Post{},
          %{
            author: get_value(frontmatter, "author"),
            banner_image: get_value(frontmatter, "banner_image"),
            content: to_html(body),
            description: get_value(frontmatter, "description"),
            featured: get_value(frontmatter, "featured"),
            published_date: get_value(frontmatter, "date") |> DateTime.from_iso8601() |> elem(1),
            title: get_value(frontmatter, "title")
          }
        )
    )
  end

  defp split_file_content(content) do
    [unparsed_frontmatter, body] =
      content
      |> String.split(~r/\n-{3,}\n/, parts: 2)

    frontmatter =
      unparsed_frontmatter
      |> :yamerl_constr.string()
      |> List.first()

    {frontmatter, body}
  end

  defp get_value(frontmatter, key) do
    key
    |> String.to_charlist()
    |> :proplists.get_value(frontmatter)
    |> case do
      :undefined -> nil
      :null -> nil
      value when is_list(value) -> List.to_string(value) |> String.trim()
      value when is_boolean(value) -> value
    end
  end

  defp to_html(body) do
    body
    |> Earmark.as_html(compact_output: true)
    |> case do
      {:ok, data, _} ->
        data

      {:error, reason} ->
        nil
    end
  end
end

The metadata comes into play when building the page in Phoenix as it allows us to set our Facebook and Twitter tags.

defmodule CassavaWeb.Blog.PostController do
  use CassavaWeb, :controller
  @blog_context Application.fetch_env!(:cassava_web, :blog_context)

  @doc false
  def show(conn, %{"slug" => slug}) do
    post = @blog_context.fetch_post(slug)
    changeset = %Comment{} |> Ecto.Changeset.change()

    conn
    |> assign(:post_slug, slug)
    |> render("show.html",
      post: post,
      meta_tags: build_meta_tags(conn, post),
      changeset: changeset
    )
  end

  defp build_meta_tags(conn, post) do
    post_url = Routes.blog_post_url(conn, :show, post)

    [
      title: post.title,
      link: [{"canonical", post_url}],
      meta:
        [
          {"description", post.description},
          {"og:url", post_url},
          {"og:title", post.title},
          {"og:description", post.description},
          {"og:image", post.banner_image},
          {"og:type", "article"},
          {"twitter:title", post.title},
          {"twitter:site", "@cassava"},
          {"twitter:description", post.description},
          {"twitter:image", post.banner_image},
          {"twitter:card", "summary"}
        ] ++
          (post.tags |> Enum.map(&{"tags", String.downcase(&1.name)}))
    ]
  end
end

And back in our app.html.eex template, we call into our PostView to inject these metadata tags into our page:

<%= CassavaWeb.Blog.PostView.inject_headers(assigns[:meta_tags]) %>
defmodule CassavaWeb.Blog.PostView do
  use CassavaWeb, :view

  def inject_headers(meta_tags) when is_list(meta_tags) do
    meta =
      (meta_tags[:meta] || [])
      |> Enum.map(fn {key, value} ->
        case key do
          "og:" <> _ ->
            raw("""
            <meta property="#{key}" content="#{value}">
            """)

          _ ->
            raw("""
            <meta name="#{key}" content="#{value}">
            """)
        end
      end)

    link =
      (meta_tags[:link] || [])
      |> Enum.map(fn {key, value} ->
        raw("""
        <link rel="#{key}" href="#{value}"/>
        """)
      end)

    title = [
      raw("""
      <title>#{meta_tags[:title]}</title>
      """)
    ]

    meta ++ link ++ title
  end
end

Previews of draft articles are created using the same publish workflow above. A helper module in an iex.exs script is suited for this task.

alias CassavaBlog.{Publisher}

defmodule Publish do
  def draft(path, markdown) when is_atom(markdown) do
    "#{path}/#{markdown}.md"
    |> Publisher.publish()
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.put_change(
      :published_date,
      DateHelper.seconds_ago(30) |> DateHelper.trunc()
    )
    |> Repo.update()
  end

# Sample use:
# Publish.draft("/tmp", :blog_as_first_class_citizen)

Once it is time to publish a post, we need to determine how best to inform our running application. Redeploying Cassava for each new article would be inconvenient, so new articles are side-loaded using Ansible. The same DevOps process we use to deploy our application is leveraged to publish one-off articles.

An Ansible playbook reads an article from the repository and then executes an Elixir process which hydrates the new Post and caches the index view.

Elixir’s release RPC command makes all of this possible by remotely executing a function in the running system.

Moderation

As bloggers, our responsibility is to maintain an open and welcoming space for our guests. This means taking ownership of what comments are allowed to be posted by the community.

New comments trigger alerts to a dedicated channel in Slack. This integration makes moderating content a breeze and helps to keep the conversation flowing.

Summary

So there you have it.

I hope this discussion motivates you to roll your own blog taking the tradeoffs at each step into account.

Your feedback is welcomed as always.

Michael

P.S. “The Roll Your Own …” title pays homage to a series of articles by Jeremy Miller that was fundamental to improving my craftsmanship as a young .NET developer many moons ago.


You no doubt have an opinion bubbling to the surface.
Let's go one step farther and add your voice to the conversation.
Your email is used to display your Gravatar and is never disclosed. As always, do review our moderation guidelines to keep the converstion friendly and respectful.