Use Shortcodes in Elixir to Embed Raw HTML in Markdown

Posted on Jun 12, 2022 by Michael

ELIXIR MARKDOWN

Blogging in Markdown syntax is both a writing pleasure and a performance gain. The declarative structure of Markdown allows for intent to be expressed succinctly.

However, one can sometimes desire to include raw HTML to fill the gaps not readily expressed by Markdown.

Today we will demonstrate how easy it is to create custom Markdown elements using Shortcodes.

Click to Tweet

The option to allow visitors to share snippets of a blog via a Click to Tweet button is prevalent. Indeed, there are readily available plugins on WordPress and other blogging frameworks to streamline this task, as well as the clicktotweet website itself.

But what can you do if you are rolling your blog and don’t wish your visitors to be tracked via 3rd-party plugins?

The short answer is to go to the source and build the functionality yourself.

A quick search of Twitter’s documentation shows the underlying Intent API that powers this feature.

Essentially, what is needed is to attach a well-formed link to the HTML snippet to be tweeted. Twitter suggests using their javascript widget.js to aid in this task, but it can be omitted, as you will see next.

http://twitter.com/intent/tweet?url=<permalink>&text=<content>&hashtags=<tags>

But remember, we are using Markdown syntax, and while it is true that you can embed raw HTML, this is not the authoring flow that fits my fancy.

A better option would be to express the click-to-tweet intent in Markdown.

Shortcodes

I first became aware of Shortcodes while experimenting with the static blogging framework Hugo.

"Shortcodes are snippets inside your content files calling built-in or custom templates." — Hugo documentation

Clicking the Tweet button results in the familiar submission form below.

A rendering of a tweet explaining shortcodes

Let us derive a solution to use a Tweet shortcode in our Markdown.

{{<  tweet text="<text>" >}}

This Markdown element will be detected and be expanded to an HTML blockquote during publishing.

Detection & Substitution

The plan is to find and replace all occurrences of the custom shortcode with an appropriate HTML block of the form:

<blockquote>
  <p>
    text...
  </p>
  <p>
    <a href="https://twitter.com/intent/tweet?url=<url>&text=<text>&hashtags=<tags>" target="_blank">
      <img src="/twitter_logo.png" title="Click to tweet" />
    </a>
  </p>
</blockquote>

I think you would agree that embedding one or more instances of this blockquote in your Markdown would not lend itself to an elegant writing experience.

The remainder of this tutorial builds on the publish workflow, which you can find in a previous article introducing our blog.

Let’s first define a helper function to build the Tweet intent. Notice that both the text and hashtags are URI encoded.


@doc false
def tweet_intent(text, slug, tags) when is_list(tags) do
  "https://twitter.com/intent/tweet?" <>
    "url=https://gocassava.com/blog/#{slug}" <>
    "&" <>
    "text=#{URI.encode(truncate(text, 280))}" <>
    "&" <>
    "hashtags=#{Enum.join(Enum.map(tags, &URI.encode(String.replace(&1, " ", ""))), ",")}" <>
    "&" <>
    "via=cassavaexp"
end

Next, modify the publish function to account for the shortcode detection and expansion.

The only change is the to_html function. The slug and keywords are included in the processing of the content.

@doc false
def publish(content) when is_bitstring(content) do
  {frontmatter, body} = content |> split_file_content()
  slug = get_value(frontmatter, "slug")
  keywords = get_value(frontmatter, "keywords")

  Multi.new()
  |> Multi.insert(
    :post,
      Post.changeset(
        %Post{},
        %{
          author: get_value(frontmatter, "author"),
          banner_image: get_value(frontmatter, "banner_image"),
          content: to_html(body, slug: slug, keywords: keywords),
          description: get_value(frontmatter, "description"),
          featured: get_value(frontmatter, "featured"),
          published_date: get_value(frontmatter, "date") |> DateTime.from_iso8601() |> elem(1),
          title: get_value(frontmatter, "title")
        }
      )
  )
end

defp to_html(body, opts) do
  body
  |> expand_shortcodes(:tweet, opts)
  |> Earmark.as_html(compact_output: true)
  |> case do
    {:ok, data, _} ->
      data

    {:error, reason} ->
      nil
  end
end

A regex scan is performed to identify all the Tweet custom codes. The text in each code is captured and used to build the HTML blockquote. Finally, the two results are zipped, and a replacement operation is performed on the original content.

defp expand_shortcodes(content, :tweet, opts) do
  snippets =
    ~r/{{< tweet text=(?:".+") >}}/
    |> Regex.scan(content)
    |> List.flatten()

  block_quotes =
    snippets
    |> Enum.map(&Regex.named_captures(~r/text=(?<text>.+) >}}/, &1))
    |> Enum.map(&(blockquote_tweet(&1, opts) |> String.replace("  ", "")))

  content
  |> replace_shortcode(Enum.zip(snippets, block_quotes))
end
defp blockquote_tweet(%{"text" => text}, opts) do
    pruned_text =
      text
      |> String.replace_prefix("\"", "")
      |> String.replace_suffix("\"", "")
      |> String.replace("\\", "")

    """
    <blockquote>
      <p>#{pruned_text}</p>
      <p>
        <a href="#{StringHelper.tweet_intent(pruned_text, opts[:slug], opts[:keywords])}" target="_blank">
          <img src="#{@assets_url}/assets/twitter_logo.png" title="Click to tweet" />
        </a>
      </p>
    </blockquote>
    """
end

defp replace_shortcode(content, [{snippet, block_quote} | remainder]),
  do:
    content
    |> String.replace(snippet, block_quote)
    |> replace_shortcode(remainder)

defp replace_shortcode(content, []), do: content

So there you have it.

A quick demonstration of how you can roll your custom Markdown shortcodes to encapsulate the complexity of raw HTML and maintain elegant-looking and concise Markdown.

This template makes it straightforward to support other shortcodes by adding to the expand_shortcodes function.

Your feedback is welcomed as always.

Michael


You no doubt have an opinion bubbling to the surface.
Let's go one step farther and add your voice to the conversation.
Your email is used to display your Gravatar and is never disclosed. As always, do review our moderation guidelines to keep the converstion friendly and respectful.


Placeholder image

John Smith @johnsmith 31m
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ornare magna eros, eu pellentesque tortor sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit vestibulum ut. Maecenas non massa sem. Etiam finibus odio quis feugiat facilisis.

Placeholder image

John Smith @johnsmith 31m
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ornare magna eros, eu pellentesque tortor sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit vestibulum ut. Maecenas non massa sem. Etiam finibus odio quis feugiat facilisis.