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
    markdown
    |> hrs
    |> divs
    |> Earmark.as_html!(earmark_options())
    |> HtmlSanitizeEx.html5()
    |> smalls
    |> bigs
  end

  # 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
    to_html(markdown)
    |> h3_is_max
  end

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

    case matches do
      nil ->
        text

      [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)
    end
  end

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

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

  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>")
  end

  # 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)
    |> Enum.map(fn str ->
      case String.starts_with?(str, "<pre") do
        true -> str
        _ -> Regex.replace(rexp, str, replacement)
      end
    end)
    |> Enum.join("")
  end

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

  defp earmark_options() do
    %Earmark.Options{
      code_class_prefix: "lang-",
      smartypants: false
    }
  end
end

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
    podcast
    |> 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()
  end

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

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

No Comments