Full Implementation
Table of contents, component:
attr :headers, :list
def table_of_contents(assigns) do
~H"""
<div
id="table-of-contents"
class="sticky top-[calc(var(--header-height))] py-1 pr-5"
data-file={__ENV__.file}
data-line={__ENV__.line}
phx-hook={Application.fetch_env!(:derpy_tools, :show_inspector?) && "SourceInspector"}
>
<h5
id="toc"
class="text-slate-900 font-semibold mb-2 text-sm leading-6 dark:text-slate-100"
phx-hook="TableOfContents"
>
On this page
</h5>
<a
href="#"
class="block py-1 font-medium hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300"
>
<i class="hero-chevron-up w-5.5 h-5.5 text-slate-500 dark:text-navy-100" /> Top
</a>
<.nested_header
headers={@headers}
class="max-h-[calc(100svh-(var(--header-height)))] overflow-auto"
/>
</div>
"""
end
attr :id, :string, default: nil
attr :headers, :list
attr :class, :string, default: nil
attr :parent, :list, default: []
def nested_header(assigns) do
~H"""
<ul id={@id} class={["space-y-1 font-inter font-medium list-none not-prose", @class]}>
<li
:for={%{header: header, id: id, title: title, children: children} <- @headers}
class="not-prose"
>
<a
id={"#{id}-link"}
key={id}
href={"##{id}"}
tabindex="0"
data-parent={@parent |> Enum.join(">")}
class={[
"block py-1 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300",
case header do
"h2" -> "font-semibold"
"h3" -> "font-medium"
"h4" -> "font-normal"
"h5" -> "font-normal text-xs"
end
]}
phx-click={JS.toggle(to: "##{id}-container")}
>
<i :if={header in ~w{h3 h4 h5}} class="hero-chevron-right-mini" />
<span><%= title %></span>
</a>
<.nested_header
:if={children}
id={"#{id}-container"}
headers={children |> Enum.reverse()}
class="pl-4 hidden"
parent={[id, @parent |> Enum.join(">")]}
/>
</li>
</ul>
"""
end
Using the TOC component in Left Nav
Pass any function component to the parse_blog function, and it’ll work.
attr :post, :map
attr :class, :string, default: nil
def left_nav(assigns) do
parsed_blog =
__MODULE__
|> apply(assigns.post.body, [assigns])
|> parse_blog()
assigns =
assigns
|> assign(
headers: extract_headers(parsed_blog),
reading_time: reading_time(parsed_blog)
)
~H"""
<aside class={["flex flex-col", @class]}>
<span><%= @reading_time %></span>
<.table_of_contents headers={@headers} />
</aside>
"""
end
Parsing-related code in private functions:
defp parse_blog(function_component) do
function_component.static
|> Floki.parse_fragment!()
|> Floki.find("article")
end
defp extract_headers([parsed_blog]) do
parsed_blog
|> Floki.children()
|> Enum.filter(&match?({name, _, _} when name in ~w(h2 h3 h4 h5), &1))
|> Enum.map(fn
{header, meta, [{"a", _, [title | _]} | _rest]} ->
{_, id} = Enum.find(meta, &match?({"id", _}, &1))
{header, id, title |> String.trim()}
{header, meta, [title | _rest]} ->
{_, id} = Enum.find(meta, &match?({"id", _}, &1))
{header, id, title |> String.trim()}
end)
|> Enum.reduce([], fn
{"h2", id, title}, acc ->
[%{header: "h2", id: id, title: title, children: []} | acc]
{"h3", id, title}, acc ->
children = path(0 / :children)
Pathex.set!(acc, children, [
%{header: "h3", id: id, title: title, children: []} | Pathex.get(acc, children)
])
{"h4", id, title}, acc ->
children = path(0 / :children / 0 / :children)
Pathex.set!(acc, children, [
%{header: "h4", id: id, title: title, children: []} | Pathex.get(acc, children)
])
{"h5", id, title}, acc ->
children = path(0 / :children / 0 / :children / 0 / :children)
Pathex.set!(acc, children, [
%{header: "h5", id: id, title: title, children: nil} | Pathex.get(acc, children)
])
end)
|> Enum.reverse()
end
defp reading_time(parsed_blog) do
parsed_blog
|> Floki.text()
|> String.replace(~r/@|#|\$|%|&|\^|:|_|!|,/u, " ")
|> String.split()
|> Enum.count()
|> div(@wpm)
|> Timex.Duration.from_minutes()
|> Timex.Format.Duration.Formatter.format(:humanized)
end
JS Hook
const TableOfContents = {
mounted() {
if (location.hash) {
const hash = location.hash.replace("#", "");
const header = document.getElementById(hash);
header &&
header.scrollIntoView({
behavior: "instant",
block: "start",
inline: "end",
});
highlightNav(
hash,
[
"hover:text-slate-900",
"dark:text-slate-400",
"dark:hover:text-slate-300",
],
["text-sky-500", "dark:text-sky-400"]
);
}
window.addEventListener("hashchange", handleHashChange);
},
destroyed() {
window.removeEventListener("hashchange", handleHashChange);
},
};
function handleHashChange(event) {
if (event.oldURL.includes("#")) {
const hash = event.oldURL.split("#").pop();
highlightNav(
hash,
["text-sky-500", "dark:text-sky-400"],
[
"hover:text-slate-900",
"dark:text-slate-400",
"dark:hover:text-slate-300",
]
);
}
if (location.hash) {
const hash = location.hash.replace("#", "");
highlightNav(
hash,
[
"hover:text-slate-900",
"dark:text-slate-400",
"dark:hover:text-slate-300",
],
["text-sky-500", "dark:text-sky-400"]
);
}
}
function highlightNav(hash, remove, add) {
const nav = document.getElementById(`${hash}-link`);
if (nav) {
nav.classList.remove(...remove);
nav.classList.add(...add);
const { parent } = nav.dataset;
if (parent) {
parent.split(">").forEach((parentId) => {
const parent = document.getElementById(`${parentId}-link`);
if (parent) {
parent.classList.remove(...remove);
parent.classList.add(...add);
}
const parentContainer = document.getElementById(
`${parentId}-container`
);
if (parentContainer) {
parentContainer.classList.remove("hidden");
if (location.hash == `#${hash}`) parentContainer.style.display = null;
}
});
}
}
}
export default TableOfContents;
Example headers, with self linking.
<h2 id="installation" class="group flex whitespace-nowrap not-prose">
<a href="#installation" class="relative flex items-center">
Installation
<span class="absolute -ml-8 opacity-0 group-hover:opacity-100 transition-opacity duration-500 group-focus:opacity-100 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 dark:bg-slate-700 dark:text-slate-300 dark:shadow-none dark:ring-0">
<svg width="12" height="12" fill="none" aria-hidden="true">
<path
d="M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
</path>
</svg>
</span>
</a>
</h2>
<h3 id="linux">Linux</h3>
<h4 id="ubuntu" class="group whitespace-nowrap not-prose">
<a href="#ubuntu" class="relative flex items-center">
Ubuntu
<span class="absolute -ml-8 opacity-0 group-hover:opacity-100 transition-opacity duration-500 group-focus:opacity-100 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 dark:bg-slate-700 dark:text-slate-300 dark:shadow-none dark:ring-0">
<svg width="12" height="12" fill="none" aria-hidden="true">
<path
d="M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
</path>
</svg>
</span>
</a>
</h4>
Sorry about the wall of text, but I don’t know how to string some nice description for the code.
Perhaps I’ll write a blog post about it.
Features:
- The nav stays selected, even on page refresh.
- Only the highlight part is JS, rest is happening on the Elixir side.
- Even the parent nav-links, light/open up, when it’s child is selected.
…
Rest you can figure out the features and kinks.
Thanks everyone. 
P.S. I left the reading time estimator in the code, in case someone finds better way to do that thing. 