Home
The secret’s out, …building a Phoenix LiveView powered static site has never been easier thanks to NimblePublisher
. Also, fly.io makes publishing your markdown to the world a breeze. In this post, I’ll skip on describing how to setup and implement NimblePublisher - the docs are excellent and there are loads of resources online to refer to if you get stuck.
That out of the way, this note walks through creating a linkable, file-structure-like menu that would auto-generate each time a markdown file is added or removed. The menu should represent the static pages that exist in a file structure looking like:
Notice that only the directories with markdown files show up in the tree of contents menu.
To accomplish this, a basic tree data structure representing the directories and markdown file slugs within those nested directories seemed like a reasonable approach. The code is a work in progress; there is room for improvements and refinement. Nonetheless, this was a first pass and it’s functioning as initially designed. Let’s run through some of the important steps to build the Tree Of Contents menu and you can checkout the source here.
Steps
-
1. Add structs to represent the menu link nodes:
-
2. Map directories and slugs to build a tree structure
-
3. Integrate with
NimblePublisher
-
4. Integrate with
PhoenixLiveView
1. Add structs to represent the menu link nodes:
-
%Tree{}
- represents a directory to toggledefmodule MarksDown.Directories.Tree do @moduledoc """ A tree data structure that encapsulates a directory in the file system """ defstruct [:id, :name, :path, slugs: [], children: %{}] end
-
%Slug{}
(abbreviated) - represents an actual link to a markdown filedefmodule MarksDown.Directories.Slug do @moduledoc """ Data structure that encapsulates a slug(.md) in the file system """ alias __MODULE__ defstruct name: nil, path: nil, file: nil, parent_dir: nil @ignore_path "priv/" def build(path) do %Slug{ name: slug_name(path), file: file_name(path), parent_dir: parent_directory(path), path: path } end #... def parent_dirs(slug) do drop_down(from_docs_root(slug.path)) end #... defp drop_down(path) do Path.split(path) |> Enum.drop(-1) |> Enum.reduce([], fn dir, results -> case List.last(results) do nil -> [dir] root -> if root == "/" do results ++ ["#{root}#{dir}"] else results ++ ["#{root}/#{dir}"] end end end) end end end
2. Map directories and files to build a tree structure
-
Directories Module(abbreviated)
defmodule MarksDown.Directories do @moduledoc """ Maps the directories and it's files from the file system """ alias MarksDown.Directories.{Tree, Slug} @root_path "priv/notes" def list_slug_files(path \\ @root_path) do cond do File.regular?(path) -> [path] true -> list = Path.wildcard(Path.join(path, "/*")) -- [@root_path] Enum.map(list, fn path -> ls_regular(path) end) |> List.flatten() end end defp ls_regular(path) do #... end end
-
TreeOfContents Module(abbreviated)
defmodule MarksDown.TreeOfContents do @moduledoc """ Builds the tree menu structure """ alias MarksDown.Directories alias MarksDown.Directories.{Tree, Slug} @root "priv/" @notes_dir "notes" @doc """ Given a path as a parameter, sort top level leaf_slugs by name and then, starting with empty tree, Enum.reduce to map the directories. """ def build_menu(path \\ "#{@root}#{@notes_dir}") do leaves = leaf_slugs(path) Enum.reduce(leaves, %Tree{}, fn leaf, root -> add_leaf(Slug.parent_dirs(leaf), leaf, root) end).children[@notes_dir] end def leaf_slugs(path) do Enum.map( Directories.list_slug_files(path), fn path -> case File.read(path) do {:ok, _data} -> file_name(path) Slug.build(path) {:error, _} -> nil end end ) |> Enum.reject(&is_nil/1) |> Enum.sort_by(& &1.path, :desc) end #... # if empty [], its a slug def add_leaf([], _slug, root), do: root # if not an empty [], its a directory def add_leaf([parent | rest], leaf, root) do tree = case Map.get(root.children, Path.basename(parent)) do nil -> %Tree{ id: get_id_from_path(parent), name: get_name_from_path(parent), path: parent, slugs: get_slugs_in_dir(parent, leaf) } tree -> tree end tree = add_leaf(rest, leaf, tree) %{ root | children: Map.put( root.children, Path.basename(parent), tree ) } end def get_slugs_in_dir(parent, leaf) do dir_path = "#{@root}#{parent}/" case parent == slug_parent(leaf.path) do true -> File.ls!(dir_path) |> Enum.filter(&String.contains?(&1, ".md")) |> Enum.reduce_while([], fn entry, acc -> if entry |> String.contains?(".md") do {:cont, acc ++ [entry |> String.replace(".md", ".html")]} else {:halt, acc} end end) false -> [] end end #... end
# data structure after # TreeOfContents.build_tree_menu() # is called. notice that some trees have an # empty [] for slugs. these represent an # empty parent directory %MarksDown.Directories.Tree{ id: "notes", name: "notes", path: "notes", slugs: ["elevator-pitch.html"], children: %{ "concepts" => %MarksDown.Directories.Tree{ id: "notes-concepts", name: "concepts", path: "notes/concepts", slugs: [], children: %{ "cs" => %MarksDown.Directories.Tree{ id: "notes-concepts-cs", name: "cs", path: "notes/concepts/cs", slugs: [], children: %{ "languages" => %MarksDown.Directories.Tree{ id: "notes-concepts-cs-languages", name: "languages", path: "notes/concepts/cs/languages", slugs: [], children: %{ "elixir" => %MarksDown.Directories.Tree{ id: "notes-concepts-cs-languages-elixir", name: "elixir", path: "notes/concepts/cs/languages/elixir", slugs: ["memory.html"], children: %{ "features" => %MarksDown.Directories.Tree{ id: "notes-concepts-cs-languages-elixir-features", name: "features", path: "notes/concepts/cs/languages/elixir/features", slugs: ["tree-of-contents.html"], children: %{} }, "idioms" => %MarksDown.Directories.Tree{ id: "notes-concepts-cs-languages-elixir-idioms", name: "idioms", path: "notes/concepts/cs/languages/elixir/idioms", slugs: ["enum.html", "elixir-tips.html"], children: %{} } } } } } } } } } } }
3. Integrate with NimblePublisher
-
Contents Module(abbreviated)
# The Main NimblePublisher Module defmodule MarksDown.Contents do #... @tree_of_contents TreeOfContents.build_menu_tree() #... def tree_of_contents, do: @tree_of_contents #... end
4. Integrate with PhoenixLiveView
-
First, I created a
live_session
on_mount
hook to preload the common static data needed by the two live_viewsdefmodule MarksDownWeb.Router do #... scope "/", MarksDownWeb do pipe_through(:browser) live_session :preload_datas, on_mount: MarksDownWeb.PreloadDatas do live("/", Notes.IndexLive, :index) live("/:slug", Notes.ShowLive, :show) end end #... end # then, implement the live_session on_mount hook defmodule MarksDownWeb.PreloadDatas do #... def on_mount(:default, _params, _session, socket) do {:cont, socket |> assign( notes: Contents.all_notes(), tree_of_contents: Contents.tree_of_contents() |> Map.from_struct() ) } end #... end
-
TreeMenuComponent(abbreviated)
Finally, a live component recursively builds the html menu
defmodule MarksDownWeb.TreeMenuComponent do use MarksDownWeb, :live_component def render(assigns) do ~H""" <div class="overflow-x-scroll overflow-y-scroll"> <ul> <%= tree_menu(assigns) %> </ul> </div> """ end def tree_menu(assigns) do ~H""" <li class={if @notes.id == "notes", do: "root-node"}> <details open> <summary><%= sanitize_text(@notes.name) %></summary> <ul id={"#{assigns.id}-#{@notes.id}"}> <li :for={slug <- @notes.slugs}> <.link class="slug" navigate={~p"/#{slug}"}> <%= sanitize_text(slug) %> </.link> </li> <%= for child_key <- Map.keys assigns.notes.children do %> <% child = assigns.notes.children[child_key] %> <% assigns = assign(assigns, :notes, child) %> <%!-- recurse --%> <%= tree_menu(assigns) %> <% end %> </ul> </details> </li> """ end #...
Conclusion
And there you have it, a tree menu that dynamically builds itself as markdown files are added and removed from the directory structure. This read was a bit long even though I attempted to only included the important bits of the feature; be sure to check out the source. Lastly, iterative improvements will be made as time permits and I will try to keep this note in sync with code changes. Thanks for reading!