lib/surface/components/markdown.ex

defmodule Surface.Components.Markdown do
  @moduledoc File.read!("README.md")

  use Surface.MacroComponent

  alias Surface.MacroComponent
  alias Surface.IOHelper
  alias Surface.AST

  @doc "The CSS class for the wrapping `<div>`"
  prop class, :string

  @doc "Removes the wrapping `<div>`, if `true`"
  prop unwrap, :boolean, static: true, default: false

  @doc """
  Keyword list with options to be passed down to `Earmark.as_html/2`.

  For a full list of available options, please refer to the
  [Earmark.as_html/2](https://hexdocs.pm/earmark/Earmark.html#as_html/2)
  documentation.
  """
  prop opts, :keyword, static: true, default: []

  @doc "The markdown text to be translated to HTML"
  slot default

  def expand(attributes, content, meta) do
    static_props = MacroComponent.eval_static_props!(__MODULE__, attributes, meta.caller)

    unwrap = static_props[:unwrap]

    class = AST.find_attribute_value(attributes, :class) || get_config(:default_class) || ""

    config_opts =
      case get_config(:default_opts) do
        nil -> []
        opts -> opts
      end

    opts = Keyword.merge(config_opts, static_props[:opts] || [])

    html =
      content
      |> trim_leading_space()
      |> String.replace(~S("\""), ~S("""), global: true)
      # Need to reconstruct the relative line
      |> markdown_as_html!(meta, opts)

    if unwrap do
      quote_surface caller: meta.caller do
        ~F"{^html}"
      end
    else
      quote_surface caller: meta.caller do
        ~F"<div class={^class}>{^html}</div>"
      end
    end
  end

  defp trim_leading_space(markdown) do
    lines =
      markdown
      |> String.split("\n")
      |> Enum.drop_while(fn str -> String.trim(str) == "" end)

    case lines do
      [first | _] ->
        [space] = Regex.run(~r/^\s*/, first)

        lines
        |> Enum.map(fn line -> String.replace_prefix(line, space, "") end)
        |> Enum.join("\n")

      _ ->
        ""
    end
  end

  defp markdown_as_html!(markdown, meta, opts) do
    markdown
    |> Earmark.as_html(struct(Earmark.Options, opts))
    |> handle_result!(meta)
  end

  defp handle_result!({_, html, messages}, meta) do
    {errors, warnings_and_deprecations} =
      Enum.split_with(messages, fn {type, _line, _message} -> type == :error end)

    Enum.each(warnings_and_deprecations, fn {_type, line, message} ->
      actual_line = meta.line + line
      IOHelper.warn(message, meta.caller, actual_line)
    end)

    if errors != [] do
      [{_type, line, message} | _] = errors
      actual_line = meta.line + line
      IOHelper.compile_error(message, meta.caller.file, actual_line)
    end

    html
  end
end