Build a Static Site in Elixir Under 5 Minutes with Phoenix Components
How to turn markdown files into HTML in memory and use Phoenix components inside them
In this tutorial, you'll learn how to quickly build a static blog site using Elixir. This guide will walk you through converting .md files into in-memory content that can be rendered as a blog on your Elixir Phoenix website. You'll see how to use Phoenix components within your markdown files, which makes for rapid implementation and an impressive mix of static content and Phoenix components.
If you’re as excited as I am, then yes, you can execute Phoenix components —both stateless components and LiveView module the middle of an .md file, and then convert and keep them as HTML in memory.
Libraries We'll Use in this article
- Nimble_publisher
- Mdex
- Yaml_elixir (optional)
- Html_entities or Floki
Step 1: Install Required Dependencies
If you’ve already installed these libraries or are familiar with their use, feel free to skip to the next section to save some time.
Nimble Publisher: This library is a macro that lets you stack a module to hold converted
.mdfiles. It also provides callbacks to separate responsibilities such as parsing, converting, and building—all of which we will explore.For this tutorial, we are using version 1.1.0. Simply add it to your
mix.exsas shown:def deps do [{:nimble_publisher, "~> 1.1"}] endMDEx: This is the real star of the show—it provides excellent features for converting
.mdfiles for use in your Elixir project, including the ability to call stateless Phoenix components directly.We're using version 0.2.0. Just add it as follows in
mix.exs:def deps do [{:mdex, "~> 0.2.0"}] endYaml Elixir: We use this library (optional) to create variables for HTML and SEO settings. You could use other approaches—even regex would work—but we use it because our blog has lots of code snippets and metadata like author information. Version 2.11.0 is what we use:
def deps do [{:yaml_elixir, "~> 2.11"}] endMakeup: This optional library is great if you want a technical blog that displays code snippets nicely. If you’re using MDEx, you could skip Makeup, as MDEx can implement highlighting (we only mention it briefly here).
Html Entities and Floki: Choose either one for HTML encoding. It's used just once during conversion and not needed afterward. We used versions 0.5.2 and 0.36.2 respectively:
def deps do [{:html_entities, "~> 0.5.2"}] # or [{:floki, "~> 0.36.2"}] end
Step 2: Building the Blog
- Specify the Path and Fetch
.mdFiles Define where to find the.mdfiles and create an environment to process each file independently.use NimblePublisher, build: __MODULE__.HTMLParser, parser: __MODULE__.HTMLParser, from: Application.app_dir(:mishka, "priv/articles/**/*.md"), as: :articles, html_converter: __MODULE__.MDExConverter, highlighters: [:makeup_eex, :makeup_elixir] - Parse Each
.mdFile Create helper functions and an error-specific module as below:Now you have thedefmodule NotFoundError do defexception [:message, plug_status: 404] end alias Mishka.Blog.NotFoundError # The @articles variable is first defined by NimblePublisher. # Let's further modify it by sorting all articles by descending date. @articles Enum.sort_by(@articles, & &1.date, {:desc, Date}) # Let's also get all tags @tags @articles |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort() # And finally export them def all_articles, do: @articles def all_tags, do: @tags def get_post_by_id!(id) do Enum.find(all_articles(), &(&1.id == id)) || raise NotFoundError, "Unfortunately, the article you are looking for is not available on the Mishka website." end def get_posts_by_tag(tag) do Enum.filter(all_articles(), &(tag in &1.tags)) end def get_posts_by_author(author_name) do Enum.filter(all_articles(), &(author_name == &1.author["full_name"])) end.mdfiles loaded into memory, and you can call them easily using the provided helper functions. - Build and Parse Next, create the
buildandparsermodules. The parser handles the initial conversion, while the builder turns the raw data into a structured map. ParseBuild# I got this code from: https://github.com/LostKobrakai/kobrakai_elixir def parse(_path, contents) do ["---\n" <> yaml, body] = contents |> String.replace("\r\n", "\n") |> :binary.split(["\n---\n"]) {:ok, attrs} = YamlElixir.read_from_string(yaml) attrs = Map.new(attrs, fn {k, v} -> {String.to_atom(k), v} end) attrs = case :binary.split(body, ["\n<!-- excerpt -->\n"]) do [excerpt | [_ | _]] -> Map.put(attrs, :excerpt, String.trim(excerpt)) _ -> attrs end {attrs, body} enddef build(filename, attrs, body) do [year, month, day, id] = filename |> Path.basename(".md") |> String.split("-", parts: 4) date = Date.from_iso8601!("#{year}-#{month}-#{day}") body = body |> String.replace("<pre>", "<pre class=\"highlight\">") Logger.debug( "The desired content was copied with ID #{inspect(id)} for Mishka static Blog section." ) %__MODULE__{ id: id, headline: Map.get(attrs, :headline), title: attrs[:title], date: date, excerpt: attrs[:excerpt], draft: !!attrs[:draft], tags: Map.get(attrs, :tags, []), author: Map.get(attrs, :author, %{}), read_time: attrs[:read_time], description: attrs[:description], keywords: attrs[:keywords], image: attrs[:image], body: body } end - Convert Body to HTML Use MDEx to convert the
.mdcontent to HTML:def convert(filepath, body, attrs, _opts) do if Path.extname(filepath) in [".md", ".markdown"] do to_html!(filepath, body, %{attrs: attrs}) |> String.replace( "<pre class=\"autumn-hl\" style=\"background-color: #282C34; color: #ABB2BF;\">", "<pre class=\"highlight\">" ) end end - Convert Phoenix Components Create a module to convert Phoenix components into HTML, allowing you to use components like Phoenix's
linkto navigate pages.This code may look complicated because it handles various tasks, such as listing components used in# I got code from https://gist.github.com/leandrocp/e65fd43e58640b0cc0cfa02a03d36718 def to_html!(filepath, markdown, assigns \\ %{}) do opts = [ extension: [ strikethrough: true, tagfilter: true, table: true, tasklist: true, footnotes: true, shortcodes: true ], parse: [ relaxed_tasklist_matching: true ], render: [ unsafe_: true ], features: [syntax_highlight_inline_style: false] ] markdown |> MDEx.to_html!(opts) |> unescape() |> render_heex!(filepath, assigns) end defp unescape(html) do ~r/(<pre.*?<\/pre>)/s |> Regex.split(html, include_captures: true) |> Enum.map(fn part -> if String.starts_with?(part, "<pre") do part else Floki.parse_document!(part) |> Floki.raw_html(encode: false) end end) |> Enum.join() end defp render_heex!(html, filepath, assigns) do env = env() opts = [ source: html, engine: Phoenix.LiveView.TagEngine, tag_handler: Phoenix.LiveView.HTMLEngine, file: filepath, caller: env, line: 1, indentation: 0 ] {rendered, _} = html |> EEx.compile_string(opts) |> Code.eval_quoted([assigns: assigns], env) rendered |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() end defp env do import Phoenix.Component, warn: false import MishkaWeb.Components.Typography, warn: false ... __ENV__ end.mdfiles, compiling them, and handling core elements from Phoenix and LiveView before encoding them into HTML. - Replace Floki (if needed) If you don't want to use Floki, replace the following code snippet: with:
Floki.parse_document!(part) |> Floki.raw_html(encode: false)HtmlEntities.decode(part)
Bonus: Importing LiveView Components
Sometimes you may want to only call the function within Phoenix's components rather than their full module name—like with Phoenix's core components. Simply import them in the env function to achieve this.
defp env do
import Phoenix.Component, warn: false
import MishkaWeb.Components.Typography, warn: false
__ENV__
endBeyond the basics, you could even use LiveView modules within the middle of your content—think of displaying a dynamic form inside a blog post. The answer is yes, this is possible!
Below is an example of using the component to convert your HTML content into an array based on the presence of live content or not (<!-- [YourComponentModule] -->).
defmodule CustomContent do
use Phoenix.Component
# import MishkaWeb.Components.Typography, only: [h2: 1]
@doc type: :component
# For example: <!-- [YourComponentModule] -->
# Component should be like:
# https://github.com/LostKobrakai/kobrakai_elixir/blob/main/lib/kobrakai_web/live/one_to_many_form.ex
# It should call <.custom_content conn={@conn} content={@post.body} />
def custom_content(assigns) do
stream = Stream.cycle([:html, :live])
parts = :binary.split(assigns.content, ["<!-- [", "] -->"], [:global])
assigns = assigns |> assign(:parts, Enum.zip([stream, parts]))
~H"""
<%= for p <- @parts do %>
<%= case p do %>
<% {:live, live} -> %>
<%= live_render(@conn, Module.concat([live])) %>
<% {:html, html} -> %>
<%= Phoenix.HTML.raw(html) %>
<% end %>
<% end %>
"""
end
endIf you enjoyed this post, please share it on social media!
Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.