Simple Phoenix LiveView App: Fonts, logos and sass

In this episode, we'll set up an enhanced version of Markdown for editing show notes, and sanitize user-entered content.

Installing the needed dependencies

We'll be using a stripped down version of Alchemist Markdown, which relies on Earmark. Since the site will also allow user commenting in the future, we'll also need an HTML sanitizer. Add these to the deps section in mix.exs:

      {:earmark, "~> 1.4"},
      {:html_sanitize_ex, "~> 1.3"},

Alchemist Markdown

We'll be using the same Markdown that powers this site (or at least part of it). Much of it is to fill in some gaps between Earmark's feature set and what I want, but it's not worth investing a great deal of time into.

It's just a small wrapper and it's not published on on hex, so just add this file under your lib directory and name it alchemist_markdown.ex:

defmodule AlchemistMarkdown do
  def to_html(markdown \\ "", opts \\ [])

  def to_html(markdown, _opts) do
    |> hrs
    |> divs
    |> Earmark.as_html!(earmark_options())
    |> HtmlSanitizeEx.html5()
    |> smalls
    |> bigs

  # for now, we'll just replace H1 and H2 tags with H3s, but as the site grows and it becomes necessary, we'll add more restrictions to commenters. 
  def to_commenter_html(markdown) do
    |> h3_is_max

  # Earmark doesn't support adding CSS classes to divs yet
  def divs(text) do
    matcher = ~r{(^|\n)::div((\.[\w-]*)*) ?(.*?)(\n):/div ?}s
    matches =, text)

    case matches do
      nil ->

      [matched_part, _, classes, _, inner_md | _tail] ->
        classname = classes |> String.split(".", trim: true) |> Enum.join(" ")
        html = "<div class=#{classname}>#{to_html(inner_md)}</div>"
        String.replace(text, matched_part, html)

  def bigs(text) do
    replace_unless_pre(text, ~r/\+\+(.+)\+\+/, "<big>\\1</big>")

  def hrs(text) do
    Regex.replace(~r{(^|\n)([-*])( *\2 *)+\2}s, text, "\\1<hr />")

  def h3_is_max(text) do
    text = Regex.replace(~r{<h1([^<]*>(.*)<\/)h1>}s, text, "<h3\\1h3>")
    Regex.replace(~r{<h2([^<]*>(.*)<\/)h2>}s, text, "<h3\\1h3>")

  # Replace the input text based on the regex and replacement text provided
  # ... except leave everything inside <pre> blocks as is
  def replace_unless_pre(text, rexp, replacement) do
    Regex.split(~r|<pre[^<]*>.*<\/pre>|s, text, include_captures: true)
    |> str ->
      case String.starts_with?(str, "<pre") do
        true -> str
        _ -> Regex.replace(rexp, str, replacement)
    |> Enum.join("")

  def smalls(text) do
    replace_unless_pre(text, ~r/--(.+)--/, "<small>\\1</small>")

  defp earmark_options() do
      code_class_prefix: "lang-",
      smartypants: false

Note: A previous version of this file was matching for |\r\n|\r|\n to capture newlines from any OS in a couple of functions, but the above replaces it with just \n and the s modifer after closing the regular expression. The s modifier is called dotall and makes newlines match all types.

Generate HTML notes from Markdown

We'll use the AlchemistMarkdown module to generate HTML show notes every time the Markdown show notes are modified. To do so, we'll add a gen_notes function to podcast.ex and update the changeset to use it:

  def changeset(podcast, attrs) do
    |> cast(attrs, [:audio_url, :is_published, :notes_html, :notes_md, :subtitle, :title])
    |> validate_required([:audio_url, :is_published, :notes_html, :notes_md, :subtitle, :title])
    |> validate_required([:audio_url, :notes_md, :subtitle, :title])
    |> unique_constraint(:title)
    |> gen_notes()

  defp gen_notes(%{valid?: true, changes: %{notes_md: text}} = changeset) do
    put_change(changeset, :notes_html, AlchemistMarkdown.to_html(text))

  defp gen_notes(changeset), do: changeset
Back to index

No Comments