Earmark - add my own class names to the default set of markdown tags

I’m looking to provide Markdown support for an app, but I want the default markdown to have custom class names and id placed inline. For example:

<h1 class="fw1 headline red">...</h1>

I’m using the library Earmark for the work.
From reading the docs it looks like I can achieve this goal. This is how I set it up, would like some feedback:

defmodule Portal.Blog.Post 

#..omit a lot of code

      defp parse_attr(:body, value) do
        value 
        |> Earmark.as_ast() 
        |> Portal.Blog.Parser.parsing() 
        |> Earmark.as_html!() 
      end

end

I have a module that will parse the body of a post
• Take the value from the body of a blog post
• Pass it to Earmark.as_ast, that returns a list where each item is an HTML node (See Floki)

{tag_name, attributes, children_nodes}
# example
{"p", [{"class", "headline"}], ["Floki"]}

The results from Earmark.as_ast is an {:ok, results, options} and we pass that
to my custom parser.

Portal.Blog.Parser.parsing() will take the list of results:
• map over each item
• pattern match using parse()
• In this example I’m only interested in p, h3 and h1 tags
• every other tag gets a pass through
• Then after each parse() its passed into Earmark.Transform.transform() which convert it into a string
• Then take the list of strings and concat into one

defmodule Portal.Blog.Parser do
    
    def parsing({:ok, results, _option}) do
      Enum.map(results, fn(item) -> 
         parse(item)
         |> Earmark.Transform.transform() 
      end)
      |> Enum.join("")
    end

    # This follows a similar structure to Floki library
    # link here
    # {tag_name, attributes, children_nodes}
    def parse({"p", _attributes, children_nodes }) do
        {"p", [{"class", "fw5 blue"}], children_nodes }
    end

    def parse({"h3", _attributes, children_nodes }) do
        {"h3", [{"class", "f1 fw1 lh-copy"}], children_nodes }
    end

    def parse({"h1", _attributes, children_nodes }) do
      {"h1", [{"class", "fw6"}], children_nodes }
    end

    def parse({"blockquote", _attributes, children_nodes }) do
      {"blockquote", [{"class", "bg-silver"}], children_nodes }
    end

    def parse(item) do
        item
    end

end

Back to my defmodule Portal.Blog.Post:
We then pass the results of Portal.Blog.Parser.parsing() to Earmark.as_html!()

|> Portal.Blog.Parser.parsing() 
|> Earmark.as_html!()

Everything works. :grinning:
And I think this is how to solve it.
My h1, h3 and p tags all have the correct class names.

The only issue I get is warnings like this:

<no file>:14: warning: Failed to find closing <pre>
<no file>:29: warning: Failed to find closing <pre>
<no file>:1: warning: Failed to find closing <p>
<no file>:15: warning: Failed to find closing <pre>
<no file>:1: warning: Failed to find closing <p>

Looking for feedback if this is how someone would approach this, or is there a better way of solving this.

Thanks

I am a little bit confused why you convert the transformed AST into a String which you feed into as_html! as instead feeding your transformed AST into transform?

I have improved my Portal.Blog.Parser to be a bit clearer.

Problem
How do I change the default markdown so I can provide my own css styles inline. The reason for doing this is I have preference to write css styles inline.

Goal
• Custom css styles for the default Markdown.
• Still support HTML attributes that can be added to any block-level element, by using the Kramdown syntax.
<p> tags are wrapping the <img> tags with markdown. Would like the <p> tags removed. So the <img> tags are on its own. This is my preference. Nothing wrong with the default.

defmodule Portal.Blog.Parser do
    
    @moduledoc """
    This module will parse the body of a blog post and update the Markdown
    attributes with custom HTML and css attributes.

    This module is used within the Portal.Blog
    """
    def parsing({:ok, results, _option}) do
      Enum.map(results, fn(item) -> 
         parse(item)
         |> Earmark.Transform.transform() 
      end)
    end

    @doc """
    Customize your own css_styles by 
    providing your own tuple of css attributes
    """

    @css_style %{
      "img" =>  [{"class", "mw8 db"}],
      "p"   =>  [{"class", "mw7 lh-copy"}],
      "h1"  =>  [{"class", "mw7 lh-copy"}],
      "h2"  =>  [{"class", "mw7 lh-copy"}],
      "h3"  =>  [{"class", "mw7 lh-copy"}],
      "ul"  =>  [{"class", "mw7 mb4 lh-copy"}],
      "ol"  =>  [{"class", "mw7 lh-copy"}],
      "blockquote" => [{"class", "mw7 lh-copy"}]
      }


    @doc """
    The Markdown wraps all <img> tags with a <p> tag. This function will patttern match any
    <p> tags that might contain a nested <img> tag.

    Then it will take that <img> node and pass it through with some css style if it exists.

    If no img tag is found the <p> is passed through.
    """
    def parse({"p", attributes, children_nodes} = node) do
        first_child = List.first(children_nodes)
        case first_child do
          {"img", img_attr, img_child_nodes} -> parse({"img", img_attr ++ attributes, img_child_nodes})
          _no_img_tag -> {"p", merge_attributes(attributes, Map.get(@css_style, "p")), children_nodes }
        end
    end

    @doc """
    If the `tag` exists in the @css_style this function will merge the existing attributes with
    the new `css_style` attributes.

    If no `tag` exists in the @css_style the node will just pass through.
    """

    def parse({tag, attributes, children_nodes}) do
      {tag, merge_attributes(attributes, Map.get(@css_style, tag)), children_nodes }
    end

    @doc """
    If the css_style is nil just return the attributes
    """
    def merge_attributes(attributes, css_style) when is_nil(css_style) do
      attributes
    end

    @doc """  
    Will concat two list of tuples into one list. Then will merge
    any tuples that have a similar key value in the first index.

    Given the following params these are the expected results.

    # Params example 1
      attributes  = [{"class", "f1"}]
      css_style   = [{"class", "mw7"}]

    iex > merge_attributes(attributes, css_style)
    iex > [{"class", "f1 mw7"}]

    # Params example 2
      attributes    = [{"class", "f1"}, {"id", "headline"}]
      css_style     = [{"class", "mw7"}, {"id", "red"}]

    iex > merge_attributes(attributes, css_style)
    iex > [{"class", "f1 mw7"}, {"id", "headline red"}]

    """
    def merge_attributes(attributes, css_style) do
      attributes ++ css_style 
      |> merge_attributes
    end

    @doc """  
    With one list of tuples some of the items will have duplicate values in the first index.

    This function will merge the duplicates and return a list of tuples.

    # Params 
      attributes = [{"class", "f1"}, {"class", "mw7"}, {"id", "foo"}, {"title", "something"}, {"id", "header"}]

    iex > merge_attributes(attributes)
    iex > [{"class", "f1 mw7"}, {"id", "foo header"}, {"title", "something"}]
    """
    def merge_attributes(attributes) do
      group = 
        Enum.group_by(attributes, fn({key, _value}) -> key end)

      keys = 
        Map.keys(group)

      results = 
        Enum.map(keys, fn(key) -> 
          Enum.reduce(group[key], "", fn({_key, value}, acc) -> 
            acc <> " " <> value 
            |> String.trim
          end)
        end)

      Enum.zip(keys, results)
    end

end

Solution
This solution is working. Maybe the structure can be improved upon. Still learning.

1 Like

Yes, converting it into a string was an unnecessary step. At that time I had another step later down the chain where I would take that string and then use regular expression to further modify the results but I don’t need to do that anymore.

Did you ever find why you were getting errors about

"Seems dashes are outlawed now. Will fix that...\r\n\r\n<pre><code>00:45 bundler:config\r\n      01 $HOME/.rbenv/bin/rbenv exec bundle config --local deployment true\r\n      01 Your /home/deploy/.bundle/config config includes `BUNDLE_BUILD__OR-TOOLS`, which contains the dash character (`-`).\r\n      01 This is deprecated, because configuration through `ENV` should be possible, but `ENV` keys cannot include dashes.\r\n      01 Please edit /home/deploy/.bundle/config and replace any dashes in configuration keys with a triple underscore (`___`).\n</code></pre>\r\n\r\nOk, it's true\r\n\r\n<pre><code>deploy@li762-169:~$ cat .bundle/config \r\n---\r\nBUNDLE_BUILD__OR-TOOLS: &quot;--with-or-tools-dir=/usr/local/bin/or-tools&quot;\n</code></pre>\r\n\r\nTwiddle\r\n\r\n<pre><code>deploy@li762-169:~$ cat .bundle/config \r\n---\r\nBUNDLE_BUILD__OR___TOOLS: &quot;--with-or-tools-dir=/usr/local/bin/or-tools&quot;\n</code></pre>\r\n\r\nAnd it's stopped moaning...\r\n\r\n<pre><code>00:25 bundler:config\r\n      01 $HOME/.rbenv/bin/rbenv exec bundle config --local deployment true\n</code></pre>\r\n\r\n"
<no file>:0: deprecated: The smartypants option has no effect anymore and will be removed in EarmarkParser 1.5
<no file>:3: warning: Failed to find closing <pre>
<no file>:12: warning: Failed to find closing <pre>
<no file>:19: warning: Failed to find closing <pre>
<no file>:26: warning: Failed to find closing <pre>

I am finding the same thing with something I am working on.

I actually replaced my solution with a new system nimble_publisher.

In the README you have a “Learn More” section that has an excellent tutorial.

1 Like