Enhancing Hugo with Dynamic Markdown, Mermaid, and TOC
Enhancing Hugo with Dynamic Markdown, Mermaid, and Styled TOC
After building my Hugo + LoveIt personal website,
I wanted to explore how to render Markdown dynamically in the browser
instead of pre‑rendering all content at Hugo build time.
In this guide, you will learn how to:
- Render raw Markdown files dynamically with a GitHub‑style viewer
- Add Mermaid diagrams for flowcharts and sequence diagrams
- Enable MathJax for inline math expressions
- Apply GitHub‑style tables and code block styling
- Generate a dynamic Table of Contents (TOC) at runtime
1 Why Dynamic Markdown Rendering
By default, Hugo converts Markdown in content/
into static HTML at build time.
This is great for most blogs, but it has limitations if you want:
- To view standalone
.md
files directly (perfect for sharing raw notes) - To avoid converting Markdown into Hugo shortcodes every time
- To experiment with a GitHub‑style Markdown viewer on your site
To achieve this, I used Marked.js to render Markdown at runtime directly in the browser.
2 Adding Dynamic Markdown Viewer
To enable runtime Markdown rendering in Hugo, I created:
- A custom layout →
layouts/markdown-viewer.html
- A JavaScript renderer →
static/js/markdown-viewer.js
The script:
- Fetches Markdown files from
static/files/*.md
- Renders them with Marked.js
- Applies syntax highlighting with Highlight.js
HTML Structure:
<div id="markdown-container" data-file="/files/sample.md">
Loading markdown...
</div>
<!-- Include scripts -->
<link rel="stylesheet" href="/css/github-markdown.min.css">
<script src="/js/marked.min.js"></script>
<script src="/js/highlight.min.js"></script>
<script src="/js/markdown-viewer.js"></script>
3 Table Styling and GitHub Look
By default, Markdown tables in Hugo are plain and lack visual distinction.
To improve readability, I added GitHub‑style table formatting:
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #d0d7de;
padding: 6px 13px;
}
.markdown-body tr:nth-child(even) {
background-color: #f6f8fa;
}
And ensured markdown-viewer.js
adds .markdown-body
class for styling.
All tables automatically inherit GitHub-like styling.
4 Adding Mermaid Diagram Support
LoveIt does not natively support Mermaid for dynamically loaded Markdown.
Steps I followed:
- Added Mermaid.js to
static/js/mermaid.min.js
- In
markdown-viewer.js
, converted fenced code blocks:
// Convert mermaid code fences into
container.querySelectorAll('pre code.language-mermaid').forEach(block => {
const div = document.createElement('div');
div.classList.add('mermaid');
div.textContent = block.textContent;
block.parentNode.replaceWith(div);
});
if (window.mermaid) {
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: ".mermaid" });
}
Now my Markdown with:
```mermaid graph TD A[Start] --> B[Process] --> C[End] ```
…renders a live Mermaid diagram in the browser.
5 MathJax for Math Expressions
To support LaTeX‑style math like:
$$ 10 \mod 7 = 3 $$
I included MathJax v3:
<script>
window.MathJax = {
tex: { inlineMath: [['$', '$'], ['\\(', '\\)']] },
svg: { fontCache: 'global' }
};
</script>
<script src="/js/tex-svg.js"></script>
Then triggered it in JS:
if (window.MathJax) {
window.MathJax.typesetPromise();
}
6 Dynamic Table of Contents (TOC)
Hugo normally builds TOC at build time.
Since my Markdown is dynamic, I built the TOC after rendering:
- Detected headings
h1
, h2
, h3
in #markdown-container
- Populated the existing LoveIt
<nav id="TableOfContents">
dynamically
Simplified Example:
const tocNav = document.getElementById("TableOfContents");
const headings = container.querySelectorAll("h1, h2, h3");
if (tocNav && headings.length > 0) {
const ul = document.createElement("ul");
headings.forEach(h => {
const id = h.id || h.textContent.trim().replace(/\s+/g, "-").toLowerCase();
h.id = id;
const li = document.createElement("li");
const a = document.createElement("a");
a.textContent = h.textContent;
a.href = "#" + id;
li.appendChild(a);
ul.appendChild(li);
});
tocNav.innerHTML = "";
tocNav.appendChild(ul);
}
Now LoveIt’s floating TOC shows headings for dynamically loaded Markdown too.
7 I’ll show step‑by‑step how to
- Create a HTML viewer layout in Hugo
- Add a JavaScript file to render Markdown dynamically
- Support Mermaid diagrams, MathJax formulas, and GitHub‑style tables
- Generate a dynamic Table of Contents (TOC) for the loaded Markdown file
7.1 Create a Hugo Layout for the Markdown Viewer
Inside your Hugo project:
layouts/markdown-viewer.html
Example:
html
{{- define "title" }}{{ .Title }} - {{ .Site.Title }}{{ end -}}
{{- define "content" -}}
<!-- Markdown Viewer Scripts -->
<!-- MathJax v3 Configuration -->
<script>
window.MathJax = {
tex: { inlineMath: [['$', '$'], ['\\(', '\\)']] },
svg: { fontCache: 'global' }
};
</script>
<!-- Load MathJax AFTER config -->
<script src="/js/tex-svg.js"></script>
<!-- <link rel="stylesheet" href="/css/markdown-viewer.css"> -->
<link rel="stylesheet" href="/css/github.min.css">
<link rel="stylesheet" href="/css/github-markdown.min.css">
<script src="/js/marked.min.js"></script>
<script src="/js/highlight.min.js"></script>
<script src="/js/markdown-viewer.js"></script>
<script src="/js/mermaid.min.js"></script>
<script>
mermaid.initialize({ startOnLoad: false });
</script>
<!-- Markdown Viewer Scripts -->
{{- $params := .Scratch.Get "params" -}}
{{- $toc := $params.toc -}}
{{- if eq $toc true -}}
{{- $toc = .Site.Params.page.toc | default dict -}}
{{- else if eq $toc false -}}
{{- $toc = dict "enable" false -}}
{{- end -}}
{{- /* Auto TOC */ -}}
{{- if ne $toc.enable false -}}
<div class="toc" id="toc-auto">
<h2 class="toc-title">{{ T "contents" }}</h2>
<div class="toc-content{{ if eq $toc.auto false }} always-active{{ end }}" id="toc-content-auto"></div>
</div>
{{- end -}}
<article class="page single">
{{- /* Title */ -}}
<h1 class="single-title animate__animated animate__flipInX">{{ .Title | emojify }}</h1>
{{- /* Subtitle */ -}}
{{- with $params.subtitle -}}
<h2 class="single-subtitle">{{ . }}</h2>
{{- end -}}
{{- /* Meta */ -}}
<div class="post-meta">
<div class="post-meta-line">
{{- $author := $params.author | default .Site.Params.Author.name | default (T "author") -}}
{{- $authorLink := $params.authorlink | default .Site.Params.Author.link | default .Site.Home.RelPermalink -}}
<span class="post-author">
{{- $options := dict "Class" "author" "Destination" $authorLink "Title" "Author" "Rel" "author" "Icon" (dict "Class" "fas fa-user-circle fa-fw") "Content" $author -}}
{{- partial "plugin/a.html" $options -}}
</span>
{{- $categories := slice -}}
{{- range .Params.categories -}}
{{- $category := partialCached "function/path.html" . . | printf "/categories/%v" | $.Site.GetPage -}}
{{- $categories = $categories | append (printf `<a href="%v"><i class="far fa-folder fa-fw" aria-hidden="true"></i>%v</a>` $category.RelPermalink $category.Title) -}}
{{- end -}}
{{- with delimit $categories " " -}}
<span class="post-category">
{{- dict "Categories" . | T "includedInCategories" | safeHTML -}}
</span>
{{- end -}}
</div>
<div class="post-meta-line">
{{- with .Site.Params.dateformat | default "2006-01-02" | .PublishDate.Format -}}
<i class="far fa-calendar-alt fa-fw" aria-hidden="true"></i> <time datetime="{{ . }}">{{ . }}</time>
{{- end -}}
<i class="fas fa-pencil-alt fa-fw" aria-hidden="true"></i> {{ T "wordCount" .WordCount }}
<i class="far fa-clock fa-fw" aria-hidden="true"></i> {{ T "readingTime" .ReadingTime }}
{{- $comment := .Scratch.Get "comment" | default dict -}}
{{- if $comment.enable | and $comment.valine.enable | and $comment.valine.visitor -}}
<span id="{{ .RelPermalink }}" class="leancloud_visitors" data-flag-title="{{ .Title }}">
<i class="far fa-eye fa-fw" aria-hidden="true"></i> <span class=leancloud-visitors-count></span> {{ T "views" }}
</span>
{{- end -}}
</div>
</div>
{{- /* Featured image */ -}}
{{- $image := $params.featuredimage -}}
{{- with .Resources.GetMatch "featured-image" -}}
{{- $image = .RelPermalink -}}
{{- end -}}
{{- with $image -}}
<div class="featured-image">
{{- dict "Src" . "Title" $.Description "Resources" $.Resources | partial "plugin/img.html" -}}
</div>
{{- end -}}
{{- /* Static TOC */ -}}
{{- if ne $toc.enable false -}}
<div class="details toc" id="toc-static" data-kept="{{ if $toc.keepStatic }}true{{ end }}">
<div class="details-summary toc-title">
<span>{{ T "contents" }}</span>
<span><i class="details-icon fas fa-angle-right" aria-hidden="true"></i></span>
</div>
<div class="details-content toc-content" id="toc-content-static">
{{- dict "Content" .TableOfContents "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
</div>
</div>
{{- end -}}
{{- /* Content */ -}}
<div class="content" id="content">
{{- dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
</div>
{{- /* Footer */ -}}
{{- partial "single/footer.html" . -}}
{{- /* Comment */ -}}
{{- partial "comment.html" . -}}
</article>
{{- end -}}
Key points:
data-file="{{ .Params.markdownFile }}"
→ Tells JS which Markdown to load
- TOC
<nav>
is pre‑rendered for LoveIt floating TOC
7.2 Create the JavaScript Renderer
File:
static/js/markdown-viewer.js
Full Script (Development Version)
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("markdown-container");
if (!container) return;
const file = container.dataset.file;
fetch(file)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(md => {
// 1 Render Markdown to HTML using Marked.js
container.innerHTML = marked.parse(md);
// 2 Highlight regular code blocks
hljs.highlightAll();
// 3 Convert mermaid fenced code blocks to <div class="mermaid">
container.querySelectorAll('pre code.language-mermaid').forEach(block => {
const div = document.createElement('div');
div.classList.add('mermaid');
div.textContent = block.textContent; // keep inner text as raw mermaid code
block.parentNode.replaceWith(div);
});
// 4 Force table styling if no CSS is applied
container.querySelectorAll('table').forEach(tbl => {
tbl.classList.add('table', 'table-striped', 'table-bordered', 'table-hover');
// Inline fallback styling (guaranteed)
tbl.style.borderCollapse = 'collapse';
tbl.style.border = '1px solid #d0d7de';
tbl.style.margin = '1rem 0';
tbl.style.width = '100%';
tbl.querySelectorAll('th, td').forEach(cell => {
cell.style.border = '1px solid #d0d7de';
cell.style.padding = '6px 13px';
});
tbl.querySelectorAll('tr:nth-child(even)').forEach(row => {
row.style.backgroundColor = '#f6f8fa'; // GitHub-like striped rows
});
});
// 5 Trigger Mermaid rendering if available
if (window.mermaid) {
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: ".mermaid" });
}
// 6 Trigger MathJax typesetting for equations
if (window.MathJax) {
window.MathJax.typesetPromise();
}
// 7 Minimal LoveIt-style Dynamic TOC generation
console.log("🔹 Starting dynamic TOC generation...");
const tocAuto = document.getElementById("toc-content-auto");
if (!tocAuto) {
console.warn("⚠️ TOC container #toc-content-auto NOT found in DOM.");
} else {
console.log("✅ Found #toc-content-auto container:", tocAuto);
}
const markdownContainer = document.getElementById("markdown-container");
if (!markdownContainer) {
console.warn("⚠️ Markdown container #markdown-container NOT found in DOM.");
} else {
console.log("✅ Found #markdown-container container:", markdownContainer);
}
if (tocAuto && markdownContainer) {
const tocNav = document.getElementById("TableOfContents");
if (!tocNav) {
console.warn("⚠️ <nav id='TableOfContents'> NOT found inside #toc-auto.");
} else {
console.log("✅ Found <nav id='TableOfContents'> inside #toc-auto:", tocNav);
}
const headings = markdownContainer.querySelectorAll("h1, h2, h3");
console.log("🔹 Headings detected inside #markdown-container:", headings.length);
if (tocNav && headings.length > 0) {
const ul = document.createElement("ul");
headings.forEach((h, idx) => {
// Generate a safe ID for the heading
const headingId =
h.id ||
h.textContent
.trim()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
h.id = headingId;
console.log(` ➜ Heading ${idx + 1}: "${h.textContent}" -> #${headingId}`);
const li = document.createElement("li");
const a = document.createElement("a");
a.textContent = h.textContent;
a.href = "#" + headingId;
// Mark first heading as active for LoveIt styling
if (idx === 0) {
li.classList.add("has-active");
a.classList.add("active");
}
li.appendChild(a);
ul.appendChild(li);
});
// Clear any existing content and append generated TOC
tocNav.innerHTML = "";
tocNav.appendChild(ul);
console.log("✅ Dynamic TOC successfully generated with", headings.length, "entries.");
} else {
console.warn("⚠️ TOC generation skipped: either no headings or tocNav missing.");
}
}
})
.catch(err => {
container.innerHTML = `<p style="color:red">Failed to load markdown: ${err}</p>`;
console.error(err);
});
});
This script:
- Loads Markdown dynamically
- Highlights code and styles tables
- Supports Mermaid and MathJax
- Generates LoveIt‑style TOC
7.3 Create a Sample Hugo Content Page
Inside your Hugo project:
content/posts/markdown-demo/index.md
Front Matter:
---
title: "Dynamic Markdown Demo"
date: 2025-08-01
---
<div id="markdown-container" data-file="/files/sample.md">
Loading markdown...
</div>
- Uses the
markdown-viewer
layout
- Loads Markdown from
/static/files/sample.md
7.4 Provide a Sample Markdown File
static/files/sample.md
Example Markdown:
# 1️⃣ Introduction
This is a dynamic markdown demo with **Mermaid**, **MathJax**, and **TOC**.
```mermaid
graph TD
A[Start] --> B[Process] --> C[End]
```
$10 \mod 7 = 3$
| Item | Price |
| ------ | ----- |
| Apple | $1 |
| Banana | $2 |
8 Test in Local Hugo Server
hugo server -D
Open the page:
http://localhost:1313/posts/markdown-demo/
You should see:
- Rendered Markdown
- Highlighted code
- Mermaid diagram
- Math formula
- GitHub‑style table
- Floating TOC
“Technically authored by me, accelerated with insights from ChatGPT by OpenAI.” Refer: Leverage ChatGPT
Happy Learning