lib/gemtext_to_html.ex

defmodule GemtextToHTML do
  @moduledoc """
  Render Gemtext as HTML.

  Phoenix function components are used to transform individual Gemtext elements to HTML. Simply implement the
  `GemtextToHTML.Components` behaviour and pass it to the `:components` option of `render/2`.

  ## Example

      gemtext = \"\"\"
      # Hello, world

      * this is a list
      * indeed

      pretty neat
      \"\"\"

      GemtextToHTML.render_to_string(gemtext, components: GemtextToHTML.DefaultComponents)
      # => "<h1>Hello, world</h1><ul><li>this is a list</li><li>indeed</li></ul><p>pretty neat</p>"

  """
  @default_opts %{
    components: GemtextToHTML.DefaultComponents
  }

  @initial_state %{
    prev_el: nil,
    lis: []
  }

  @doc """
  Returns a safe iolist of HTML rendered from a gemtext document.

  ## Options

  - `:components` - the `GemtextToHTML.Components` callback module used to render each element; defaults to `GemtextToHTML.DefaultComponents`

  Any additional options may be specified, which will be passed to the components as a map in the `@opts` assign.
  """
  @spec render(String.t(), keyword) :: {:safe, iolist()}
  def render(gemtext, opts \\ []) do
    {:safe, render_to_iolist(gemtext, opts)}
  end

  @doc """
  Returns an iolist of HTML rendered from a gemtext document.

  See `render/2` for options.
  """
  @spec render_to_iolist(String.t(), keyword) :: iolist()
  def render_to_iolist(gemtext, opts \\ []) do
    opts = Map.merge(@default_opts, Map.new(opts))

    gemtext
    |> Gemtext.parse()
    |> run(opts)
  end

  @doc """
  Returns a string of HTML rendered from a gemtext document.

  See `render/2` for options.
  """
  @spec render_to_string(String.t(), keyword) :: String.t()
  def render_to_string(gemtext, opts \\ []) do
    gemtext
    |> render_to_iolist(opts)
    |> :erlang.iolist_to_binary()
  end

  defp run(nodes, opts, state \\ @initial_state, rendered \\ [])

  defp run([], _opts, _state, rendered) do
    rendered
    |> Enum.reverse()
    |> Enum.map(fn rendered ->
      rendered
      |> Phoenix.HTML.html_escape()
      |> then(fn {:safe, iodata} -> iodata end)
    end)
  end

  # Render the list after the final sequential list item
  defp run([el | _] = rest, opts, %{prev_el: :li} = state, rendered) when elem(el, 0) != :li do
    rendered = [opts.components.list(%{items: Enum.reverse(state.lis), opts: opts}) | rendered]
    run(rest, opts, %{state | prev_el: nil, lis: []}, rendered)
  end

  # Render basic text elements
  for simple_element <- [:h1, :h2, :h3, :quote, :text, :pre] do
    defp run([{unquote(simple_element), text} | rest], opts, state, rendered) do
      rendered = [
        opts.components.unquote(simple_element)(%{text: trim(text), opts: opts}) | rendered
      ]

      run_next(unquote(simple_element), rest, opts, state, rendered)
    end
  end

  # Render a link
  defp run([{:link, %{to: to}, text} | rest], opts, state, rendered) do
    rendered = [opts.components.link(%{text: trim(text), to: to, opts: opts}) | rendered]
    run_next(:link, rest, opts, state, rendered)
  end

  # Render preformatted text with a title
  defp run([{:pre, %{title: title}, text} | rest], opts, state, rendered) do
    rendered = [opts.components.pre(%{text: trim(text), title: title, opts: opts}) | rendered]
    run_next(:pre, rest, opts, state, rendered)
  end

  # Accumulate a list item (don't render anything yet)
  defp run([{:li, text} | rest], opts, state, rendered) do
    run_next(:li, rest, opts, %{state | lis: [text | state.lis]}, rendered)
  end

  # Ignore anything weird
  defp run([unexpected | rest], opts, state, rendered) do
    run_next(elem(unexpected, 0), rest, opts, state, rendered)
  end

  defp run_next(el, rest, opts, state, rendered) do
    run(rest, opts, %{state | prev_el: el}, rendered)
  end

  defp trim(text) when is_binary(text), do: String.trim(text)
  defp trim(value), do: value
end