Build a Static Site in Elixir Under 5 Minutes with Phoenix Components
Phoenix
LiveView
Build a Static Site in Elixir Under 5 Minutes with Phoenix Components
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.
Note: All these libraries are needed when importing
.md
files into system memory.
After importing, they are no longer needed, similar to compile-time dependencies—unless you're
planning to handle runtime user input or
.md
sources from less secure locations.
Note: Our entire blog is built this way, so you can expect to see Phoenix components throughout.
The full code for the sections covered in this guide is provided at the end.
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
.md
files.
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.exs as shown:
defdepsdo[{:nimble_publisher,"~> 1.1"}]end
MDEx: This is the real star of the show—it provides excellent features for converting
.md
files 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:
defdepsdo[{:mdex,"~> 0.2.0"}]end
Yaml 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:
defdepsdo[{:yaml_elixir,"~> 2.11"}]end
Makeup: 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:
Parse Each
.md
File
Create helper functions and an error-specific module as below:
defmoduleNotFoundErrordodefexception[:message,plug_status:404]endaliasMishka.Blog.NotFoundError# The @articles variable is first defined by NimblePublisher.# Let's further modify it by sorting all articles by descending date.@articlesEnum.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 themdefall_articles,do:@articlesdefall_tags,do:@tagsdefget_post_by_id!(id)doEnum.find(all_articles(),&(&1.id==id))||raiseNotFoundError,"Unfortunately, the article you are looking for is not available on the Mishka website."enddefget_posts_by_tag(tag)doEnum.filter(all_articles(),&(tagin&1.tags))enddefget_posts_by_author(author_name)doEnum.filter(all_articles(),&(author_name==&1.author["full_name"]))end
Now you have the
.md
files loaded into memory,
and you can call them easily using the provided helper functions.
Build and Parse
Next, create the
build
and
parser
modules.
The parser handles the initial conversion,
while the builder turns the raw data into a structured map.
Parse
# I got this code from: https://github.com/LostKobrakai/kobrakai_elixirdefparse(_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))_->attrsend{attrs,body}end
Build
defbuild(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
.md
content to HTML:
Convert Phoenix Components
Create a module to convert Phoenix components into HTML, allowing you to use components
like Phoenix's
link
to navigate pages.
# I got code from https://gist.github.com/leandrocp/e65fd43e58640b0cc0cfa02a03d36718defto_html!(filepath,markdown,assigns\\%{})doopts=[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)enddefpunescape(html)do~r/(<pre.*?<\/pre>)/s|>Regex.split(html,include_captures:true)|>Enum.map(fnpart->ifString.starts_with?(part,"<pre")dopartelseFloki.parse_document!(part)|>Floki.raw_html(encode:false)endend)|>Enum.join()enddefprender_heex!(html,filepath,assigns)doenv=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()enddefpenvdoimportPhoenix.Component,warn:falseimportMishkaWeb.Components.Typography,warn:false...__ENV__end
This code may look complicated because it handles various tasks, such as listing components
used in
.md
files, 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:
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.
Beyond 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] -->
).
defmoduleCustomContentdousePhoenix.Component# import MishkaWeb.Components.Typography, only: [h2: 1]@doctype::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} />defcustom_content(assigns)dostream=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 %>
"""endend
Notice that for live content, it uses
live_render
, whereas for simple HTML, it uses
Phoenix.HTML.raw
.
Note: This component/module should be of the
:live_view
type.
Note: Each
.md
file should be named like
2024-08-11-mishka-chelekom-0.0.1.md
.
If you need a different structure, modify the code accordingly. In this guide,
the
.md
files are
located in
priv/articles
,
but you can change this path if needed.
If you enjoyed this post, please share it on social media!