Imports
/-
Copyright (c) 2026 Jonathan Prieto-Cubides. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
-/
import LeanBlog.Config
import VersoBlog
import VersoSearch.DomainSearch
import LeanBlog.IconsLeanBlog's first theme
The generator keeps Verso's traversal and output machinery, but owns the page templates. The templates use stable Tailwind and daisyUI utility classes so a cloned blog has a useful visual baseline before it is customized.
namespace LeanBlogopen Verso Doc Output Htmlopen Verso.Genre.Blogopen Verso.Genre.Blog.Templatenamespace Themeprivate
def monthName
: Nat →
String
| 1 => "Jan"
| 2 => "Feb"
| 3 => "Mar"
| 4 => "Apr"
| 5 => "May"
| 6 => "Jun"
| 7 => "Jul"
| 8 => "Aug"
| 9 => "Sep"
| 10 => "Oct"
| 11 => "Nov"
| 12 => "Dec"
| month => toString monthprivate
def displayDate
(date : Date)
: String
:=
s!"{monthName date.month} {date.day}, {date.year}"private
def readingTime
(post : BlogPost)
: Nat
:=
let wordsPerMinute := 200
let words := post.contents.content.foldl (fun total block => total + block.wordCount) 0
max 1 ((words + wordsPerMinute - 1) / wordsPerMinute)private
def categoryHref
(path : String)
(category : Post.Category)
: String
:=
let base := if path.isEmpty then "" else path ++ "/"
base ++ category.slug ++ "/"private
def tags
(path : String)
(metadata : Post.PartMetadata)
: Html
:=
if metadata.categories.isEmpty then
Html.empty
else
{{<div class="post-tags">
{{metadata.categories.toArray.map fun tag =>
{{<a href={{categoryHref path tag}} class="post-tag">{{tag.name}}</a>}}}}
</div>}}private
def mermaidAssets
: Html
:=
let script := "import mermaid from \"https://cdn.jsdelivr.net/npm/mermaid@11/" ++
"dist/mermaid.esm.min.mjs\";\n" ++
"const renderMermaid = async () => {\n" ++
" const theme = document.documentElement.dataset.theme === \"dark\" ? " ++
"\"dark\" : \"default\";\n" ++
" const diagrams = Array.from(document.querySelectorAll(\".mermaid\"));\n" ++
" if (diagrams.length === 0) return;\n" ++
" for (const diagram of diagrams) {\n" ++
" if (!diagram.dataset.mermaidSource) diagram.dataset.mermaidSource = " ++
"diagram.textContent || \"\";\n" ++
" diagram.removeAttribute(\"data-processed\");\n" ++
" diagram.textContent = diagram.dataset.mermaidSource;\n" ++
" }\n" ++
" mermaid.initialize({ startOnLoad: false, securityLevel: \"strict\", theme });\n" ++
" await mermaid.run({ nodes: diagrams });\n" ++
"};\n" ++
"document.addEventListener(\"DOMContentLoaded\", renderMermaid);\n" ++
"document.addEventListener(\"leanblog-theme-change\", renderMermaid);"
{{<script type="module">{{Html.text false script}}</script>}}private
def themeAssets
(config : SiteConfig)
: Html
:=
let configuredTheme := match config.defaultTheme with
| "dark" => "'dark'"
| "light" => "'light'"
| _ => "preferred"
let script := (r#"
(() => {
const root = document.documentElement;
const stored = (() => {
try { return localStorage.getItem('leanblog-theme'); } catch (_) { return null; }
})();
const preferred = typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.dataset.theme = stored === 'dark' || stored === 'light' ? stored : preferred;
const update = () => {
const dark = root.dataset.theme === 'dark';
document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
button.querySelector('[data-theme-icon-light]').hidden = dark;
button.querySelector('[data-theme-icon-dark]').hidden = !dark;
button.setAttribute('aria-label', dark ? 'Use light theme' : 'Use dark theme');
});
};
const buildTableOfContents = () => {
document.querySelectorAll('[data-post-toc]').forEach((toc) => {
const article = toc.closest('.post-page');
const nav = toc.querySelector('[data-post-toc-nav]');
const requestedDepth = Number.parseInt(toc.dataset.postTocDepth || '3', 10);
const depth = Number.isFinite(requestedDepth) ? Math.max(1, Math.min(6, requestedDepth)) : 3;
const selectors = Array.from({ length: depth }, (_, index) =>
`.leanblog-prose h${index + 1}`).join(', ');
const headings = article
? Array.from(article.querySelectorAll(selectors))
: [];
if (!nav || headings.length < 2) {
toc.hidden = true;
return;
}
const usedIds = new Set();
const slugify = (text) => text.normalize('NFKD').toLowerCase().trim()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const list = document.createElement('ul');
const stack = [{ level: 0, list }];
headings.forEach((heading, index) => {
const baseId = heading.id || slugify(heading.textContent || '') || `section-${index + 1}`;
let id = baseId;
let suffix = 2;
while (
usedIds.has(id) ||
(document.getElementById(id) && document.getElementById(id) !== heading)
) {
id = `${baseId}-${suffix}`;
suffix += 1;
}
usedIds.add(id);
heading.id = id;
heading.setAttribute('data-toc-target', '');
heading.setAttribute('tabindex', '-1');
const level = Number(heading.tagName.slice(1));
while (stack.length > 1 && level <= stack[stack.length - 1].level) stack.pop();
const item = document.createElement('li');
const link = document.createElement('a');
link.className = `post-toc-link post-toc-level-${heading.tagName.slice(1)}`;
link.href = `#${id}`;
link.textContent = heading.textContent || id;
link.addEventListener('click', (event) => {
event.preventDefault();
const target = document.getElementById(id);
if (!target) return;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
history.pushState(null, '', `#${id}`);
target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' });
target.focus({ preventScroll: true });
setActive(target);
});
item.append(link);
stack[stack.length - 1].list.append(item);
const next = headings[index + 1];
const nextLevel = next ? Number(next.tagName.slice(1)) : 0;
if (nextLevel > level) {
const nested = document.createElement('ul');
item.append(nested);
stack.push({ level, list: nested });
}
});
nav.replaceChildren(list);
toc.hidden = false;
const links = Array.from(nav.querySelectorAll('a'));
const setActive = (heading) => {
links.forEach((link) => {
const active = link.getAttribute('href') === `#${heading.id}`;
link.classList.toggle('is-active', active);
if (active) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
};
const updateActive = () => {
let current = headings[0];
headings.forEach((heading) => {
if (heading.getBoundingClientRect().top <= 140) current = heading;
});
setActive(current);
};
updateActive();
window.addEventListener('scroll', updateActive, { passive: true });
const initialTarget = document.getElementById(window.location.hash.slice(1));
if (initialTarget && initialTarget.hasAttribute('data-toc-target')) {
requestAnimationFrame(() => initialTarget.scrollIntoView({ block: 'start' }));
}
});
};
const splitNode = (node) => {
if (node.nodeType === 3) {
return node.textContent.split('\n').map((text) => document.createTextNode(text));
}
if (node.nodeType !== 1) return [node.cloneNode(true)];
const lines = [[]];
Array.from(node.childNodes).forEach((child) => {
splitNode(child).forEach((part, index) => {
if (index > 0) lines.push([]);
lines[lines.length - 1].push(part);
});
});
return lines.map((children) => {
const clone = node.cloneNode(false);
clone.replaceChildren(...children);
return clone;
});
};
const addLineNumbers = (code, source) => {
if (code.dataset.linesReady) return;
const lines = [[]];
Array.from(code.childNodes).forEach((child) => {
splitNode(child).forEach((part, index) => {
if (index > 0) lines.push([]);
lines[lines.length - 1].push(part);
});
});
if (source.endsWith('\n') && lines.length > 1 && lines[lines.length - 1].length === 0) {
lines.pop();
}
const fragment = document.createDocumentFragment();
lines.forEach((children, index) => {
const line = document.createElement('span');
line.className = 'leanblog-code-line';
line.dataset.line = String(index + 1);
line.append(...children);
fragment.append(line);
});
code.replaceChildren(fragment);
code.classList.add('leanblog-code-lines');
code.dataset.linesReady = 'true';
};
const copyText = async (text) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('Copy failed');
};
const enhanceCodeBlocks = () => {
document.querySelectorAll('.leanblog-code').forEach((block) => {
const code = block.querySelector('code.hl.lean.block, pre');
const button = block.querySelector('[data-code-copy]');
if (!code || !button) return;
const source = (block.dataset.codeSource || code.textContent || '')
.replace(/\r\n?/g, '\n');
addLineNumbers(code, source);
button.addEventListener('click', async () => {
try {
await copyText(source);
button.classList.add('copied');
button.setAttribute('aria-label', 'Code copied');
button.title = 'Code copied';
window.setTimeout(() => {
button.classList.remove('copied');
button.setAttribute('aria-label', 'Copy code');
button.title = 'Copy code';
}, 1600);
} catch (_) {
button.setAttribute('aria-label', 'Copy failed');
button.title = 'Copy failed';
}
});
});
};
const setupPostActions = () => {
const feedback = document.querySelector('[data-post-action-feedback]');
const showFeedback = (message) => {
if (!feedback) return;
feedback.textContent = message;
window.setTimeout(() => { feedback.textContent = ''; }, 2200);
};
const share = document.querySelector('[data-share-post]');
share?.addEventListener('click', async () => {
const url = window.location.href.split('#')[0];
try {
if (navigator.share) await navigator.share({ title: document.title, url });
else {
await copyText(url);
showFeedback('Link copied');
}
} catch (error) {
if (error.name !== 'AbortError') showFeedback('Unable to share');
}
});
document.querySelectorAll('[data-print-post]').forEach((button) => {
button.addEventListener('click', () => window.print());
});
};
document.addEventListener('DOMContentLoaded', () => {
update();
buildTableOfContents();
enhanceCodeBlocks();
setupPostActions();
document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
button.addEventListener('click', () => {
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('leanblog-theme', root.dataset.theme); } catch (_) {}
update();
document.dispatchEvent(new CustomEvent('leanblog-theme-change'));
});
});
});
})();
"#).replace
"root.dataset.theme = stored === 'dark' || stored === 'light' ? stored : preferred;"
("root.dataset.theme = stored === 'dark' || stored === 'light' ? stored : " ++
configuredTheme ++ ";")
{{<script>{{Html.text false script}}</script>}}private def (config : SiteConfig) : Html := {{
<footer class="site-footer">
<div class="site-footer-inner">
<span>{{config.footer}}</span>
</div>
</footer>
}}Render the configured archive and primary navigation links.
def
(config : SiteConfig)
: Html
:=
let archive := #[(Html.tag "a" #[
("class", "site-link site-link-strong"), ("href", ".")
] (.text true config.archiveLabel))]
Html.seq <| archive ++ config.navigation.map fun item =>
Html.tag "a" #[
("class", "site-link"), ("href", item.href)
] (.text true item.label)Render metadata shared by generated HTML pages.
def siteMetadata
(config : SiteConfig)
: Html
:=
let author := if config.author.isEmpty then Html.empty else
Html.tag "meta" #[("name", "author"), ("content", config.author)] Html.empty
let canonical := if config.siteUrl.isEmpty then Html.empty else
Html.tag "link" #[("rel", "canonical"), ("href", config.siteUrl)] Html.empty
Html.seq #[
Html.tag "meta" #[("name", "description"), ("content", config.tagline)] Html.empty,
Html.tag "meta" #[("name", "leanblog-base-path"), ("content", config.basePath)] Html.empty,
author,
canonical
]private
def header
(config : SiteConfig)
: TemplateM Html
:= do
let header ← builtinHeader
let emptySegments := (← currentPath).toList.foldl
(fun count segment => if segment.isEmpty then count + 1 else count) 0
let header ← if emptySegments == 0 then
pure header
else
let relativeSegment := "../"
header.visitM (tag := fun name attrs content =>
if name == "base" then
let attrs := attrs.map fun (key, value) =>
if key == "href" then
(key, value.drop (emptySegments * relativeSegment.length) |>.toString)
else
(key, value)
pure <| some (.tag name attrs content)
else
pure none)
pure <| header ++ Verso.Search.searchAssetTags ++ themeAssets config ++ mermaidAssetsprivate
def primary
(config : SiteConfig)
: Template
:= do
let posts := (← param? "posts")
let initialTheme := if config.defaultTheme == "dark" then "dark" else "light"
pure {{
<html lang="en" data-theme={{initialTheme}}>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{{siteMetadata config}}
<title>{{← param (α := String) "title"}}</title>
{{← header config}}
</head>
<body class="site-body min-h-screen bg-base-100 text-base-content">
<div class="site-shell flex min-h-screen flex-col">
<header class="site-header">
<div class="site-header-inner">
<a class="site-brand" href=".">{{config.title}}</a>
<nav class="site-nav" aria-label="Primary">
{{navigation config}}
</nav>
<button type="button" class="site-theme-toggle" data-theme-toggle
aria-label="Toggle color theme">
<span data-theme-icon-light>{{Icon.toHtml .moon}}</span>
<span data-theme-icon-dark hidden>{{Icon.toHtml .sun}}</span>
</button>
</div>
</header>
<main class="site-main w-full flex-1">
<div class="site-content">
{{← param "content"}}
{{match posts with
| none => Html.empty
| some posts => {{
<section class="site-posts" aria-labelledby="recent-posts-title">
<div class="site-section-heading">
<h2 id="recent-posts-title">{{config.archiveTitle}}</h2>
</div>
{{posts}}
</section>
}}}}
</div>
</main>
{{footer config}}
</div>
</body>
</html>
}}private
def page
: Template
:= do
pure {{
<article class="page-content">
<h1 class="page-title">{{← param "title"}}</h1>
<div class="leanblog-prose">{{← param "content"}}</div>
</article>
}}private
def post
: Template
:= do
let path := (← param? (α := String) "path").getD ""
let postPath := (← currentPath).toList.filter (· != "")
let rawHref := String.intercalate "/" (postPath ++ ["raw", ""])
pure {{
<article class="post-page">
<header class="post-header">
<h1 class="post-title">{{← param "title"}}</h1>
{{ match (← param? "metadata") with
| none => Html.empty
| some md => {{
<div class="post-meta">
<span>"By "{{
(md : Post.PartMetadata).authors.map
({{<span>{{Html.text true ·}}</span>}}) |>.toArray
}}</span>
<span aria-hidden="true">"·"</span>
<time datetime={{md.date.toIso8601String}}>
"Published "{{displayDate md.date}}
</time>
</div>
{{tags path md}}
}}
}}
</header>
<div class="post-layout">
<div class="post-main">
<div class="leanblog-prose">{{← param "content"}}</div>
<div class="post-actions post-actions-bottom" aria-label="Post actions">
<button type="button" class="post-action" data-share-post>
{{Icon.toHtml .share}}<span>"Share"</span>
</button>
<a class="post-action" href={{rawHref}}>
{{Icon.toHtml .codeBracket}}<span>"See raw"</span>
</a>
<button type="button" class="post-action" data-print-post>
{{Icon.toHtml .printer}}<span>"Print this"</span>
</button>
<span class="post-action-feedback" data-post-action-feedback aria-live="polite"></span>
</div>
<div id="leanblog-related-posts"></div>
</div>
<aside class="post-toc" data-post-toc data-post-toc-depth="3" hidden>
<p class="post-toc-title">"Sections"</p>
<nav data-post-toc-nav aria-label="Table of contents"></nav>
</aside>
</div>
</article>
}}private
def archiveEntry
: Template
:= do
let post : BlogPost ← param "post"
let path := (← param? (α := String) "path").getD ""
let name ← post.postName'
let target := if path.isEmpty then name else path ++ "/" ++ name
pure #[{{
<li class="post-list-item">
<article class="post-entry">
<div class="post-entry-row">
{{ match post.contents.metadata with
| none => Html.empty
| some md => {{
<time class="post-entry-date" datetime={{md.date.toIso8601String}}>
{{displayDate md.date}}</time>
}}
}}
<h2 class="post-entry-title"><a href={{target ++ "/"}}>
{{post.contents.titleString}}</a></h2>
{{ match post.contents.metadata with
| none => Html.empty
| some _ => {{
<span class="post-entry-details">{{toString (readingTime post)}} " min read"</span>
}}
}}
</div>
</article>
</li>
}}]private
def category
(config : SiteConfig)
: Template
:= do
let category : Post.Category ← param "category"
pure {{
<div class="category-header">
<div>
<p class="page-kicker">"Topic"</p>
<h1 class="page-title">{{category.name}}</h1>
</div>
<a class="site-back-link" href=".">"← "{{config.archiveLabel}}</a>
</div>
}}Build the initial Tailwind/daisyUI theme around an already-built stylesheet.
def make
(css : String)
(config : SiteConfig := {})
: Verso.Genre.Blog.Theme
where
primaryTemplate := primary config
pageTemplate := page
postTemplate := post
archiveEntryTemplate := archiveEntry
categoryTemplate := category config
cssFiles := #[("leanblog.css", css)]end Themeend LeanBlog