Imports
/-
Copyright (c) 2026 Jonathan Prieto-Cubides. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
-/
import LeanBlog
import Lean.Data.Json
import SubVerso.Compat
import SubVerso.Highlighting.Code
import VersoBlog
import Verso.Output.Html.ElasticLunr
import VersoLiterateCode
import VersoSearch
leanblog command
The CLI reads Verso's generated xref.json automatically. A target registry is still accepted as
an override for declarations documented elsewhere.
namespace LeanBlog.Cliopen Leanopen Verso Doc Output Htmlopen Verso.Genre.Blogopen Verso.Searchprivate def starterPost : String := r#"---
title: Your first LeanBlog post
date: 2026-08-08
authors: Your Name
tags: lean, tutorial
---
Write ordinary Markdown here. Once your project has generated API documentation, declaration
links use standard Markdown syntax with a `lean:` destination.
Lean fences are highlighted with Verso and SubVerso:
```lean
def answer : Nat := 42
#eval answer
```
"#private def starterConfig : String := r#"{
"title": "Your LeanBlog",
"tagline": "A calm home for your writing.",
"author": "",
"siteUrl": "",
"basePath": "/",
"footer": "Built with LeanBlog, Verso, Tailwind, and daisyUI.",
"archiveTitle": "Recent posts",
"archiveLabel": "All posts",
"docsRoot": "/api",
"docsDirectory": "api",
"defaultTheme": "system",
"navigation": []
}
"#private def starterGitignore : String := ".lake/\ntheme/node_modules/\ntheme/dist/\n"private def starterToolchain : String := "leanprover/lean4:v4.33.1\n"private def starterLakefile : String := r#"import Lake
open Lake DSL
package «my-leanblog» where
version := v!"0.1.0"
leanOptions := #[
⟨`autoImplicit, false⟩,
⟨`relaxedAutoImplicit, false⟩
]
require leanblog from git
"https://github.com/jonaprieto/lean-blog" @ "main"
lean_exe «leanblog-site» where
root := `Main
"#private def starterMain : String := r#"import LeanBlog.Cli
def main (args : List String) : IO UInt32 := LeanBlog.Cli.main args
"#private def starterThemePackage : String := include_str "../../theme/package.json"private def starterThemeLock : String := include_str "../../theme/package-lock.json"private
def starterThemeCss
: String
:=
(include_str "../../theme/src/app.css")
|>.replace "@source \"../../site\";" "@source \"../../posts\";"
|>.replace "@source \"../../src\";" "@source \"../../posts\";"private def starterBuildScript : String := r#"#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const run = (label, command, args) => {
console.log(`\n==> ${label}`);
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error) throw new Error(`${label} could not start: ${result.error.message}`);
if (result.status !== 0) throw new Error(`${label} failed with exit code ${result.status}`);
};
try {
run("Install and build theme", "npm", ["ci", "--prefix", "theme"]);
run("Build Tailwind and daisyUI CSS", "npm", ["run", "build:css", "--prefix", "theme"]);
run("Validate posts", "lake", ["exe", "leanblog-site", "check", "posts"]);
run("Render site", "lake", [
"exe", "leanblog-site", "build", "posts", "--css", "theme/dist/site.css",
]);
console.log("\nBuilt .lake/build/site");
} catch (error) {
console.error(`\nBuild failed: ${error.message}`);
process.exitCode = 1;
}
"#private def starterWorkflow : String := r#"name: Pages
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: lts/*
cache: npm
cache-dependency-path: theme/package-lock.json
- uses: leanprover/lean-action@v1
with:
build-args: "leanblog"
- name: Build site
run: node tools/build-site.mjs
- uses: actions/upload-pages-artifact@v4
with:
path: .lake/build/site
deploy:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v4
id: deployment
"#private def starterReadme : String := r#"# My LeanBlog
A Lean-aware blog generated with [LeanBlog](https://github.com/jonaprieto/lean-blog).
## Build
Install Lean 4.32.2 and Node.js, then run:
```text
npm ci --prefix theme
node tools/build-site.mjs
```
The generated site is `.lake/build/site`. Edit files in `posts/` and `leanblog.json`; the
generator accepts both `.md` and `.lean.md` posts, walks nested directories, and validates
`lean:` declaration links against Verso's generated documentation.
## Deploy
The included `.github/workflows/pages.yml` publishes the site with GitHub Pages after you enable
Pages for the repository using GitHub Actions.
"#structure LinkConfig where
targets : Option String := none
xref : Option String := none
docsRoot : String := "/api"structure BuildConfig where
links : LinkConfig := {}
site : SiteConfig := {}
output : String := ".lake/build/site"
css : String := "theme/dist/site.css"
docsDirectory : String := "api"structure InitOptions where
directory : Option String := nonestructure CheckOptions where
source : String
config : Option String := none
targets : Option String := none
xref : Option String := none
docsRoot : Option String := nonestructure BuildOptions where
source : String
config : Option String := none
targets : Option String := none
xref : Option String := none
docsRoot : Option String := none
docsDirectory : Option String := none
output : String := ".lake/build/site"
css : String := "theme/dist/site.css"inductive Action where
| init (options : InitOptions)
| check (options : CheckOptions)
| build (options : BuildOptions)private def usage : String := r#"Usage: leanblog <command> [options]
Commands:
init [DIRECTORY] Create a standalone starter project
check SOURCE [options] Validate Markdown posts and declaration links
build SOURCE [options] Render the blog and copy generated API docs
Common options:
--config PATH Site configuration JSON file
--targets PATH Declaration target registry (TSV)
--xref PATH Verso cross-reference index
--docs-root URL URL path for generated API docs
Build options:
--docs-directory PATH Generated API docs directory
--output PATH Generated site directory
--css PATH Compiled stylesheet
-h, --help Show this help
--version Show the version
"#structure RawOptions where
positionals : List String := []
config : Option String := none
targets : Option String := none
xref : Option String := none
docsRoot : Option String := none
docsDirectory : Option String := none
output : Option String := none
css : Option String := noneprivate def parseRaw : List String → RawOptions → Except String RawOptions
| [], options => .ok options
| "--config" :: [], _ => .error "--config requires a value"
| "--config" :: value :: rest, options =>
if value.startsWith "-" then .error "--config requires a value"
else parseRaw rest {options with config := some value}
| "--targets" :: [], _ => .error "--targets requires a value"
| "--targets" :: value :: rest, options =>
if value.startsWith "-" then .error "--targets requires a value"
else parseRaw rest {options with targets := some value}
| "--xref" :: [], _ => .error "--xref requires a value"
| "--xref" :: value :: rest, options =>
if value.startsWith "-" then .error "--xref requires a value"
else parseRaw rest {options with xref := some value}
| "--docs-root" :: [], _ => .error "--docs-root requires a value"
| "--docs-root" :: value :: rest, options =>
if value.startsWith "-" then .error "--docs-root requires a value"
else parseRaw rest {options with docsRoot := some value}
| "--docs-directory" :: [], _ => .error "--docs-directory requires a value"
| "--docs-directory" :: value :: rest, options =>
if value.startsWith "-" then .error "--docs-directory requires a value"
else parseRaw rest {options with docsDirectory := some value}
| "--output" :: [], _ => .error "--output requires a value"
| "--output" :: value :: rest, options =>
if value.startsWith "-" then .error "--output requires a value"
else parseRaw rest {options with output := some value}
| "--css" :: [], _ => .error "--css requires a value"
| "--css" :: value :: rest, options =>
if value.startsWith "-" then .error "--css requires a value"
else parseRaw rest {options with css := some value}
| "-h" :: _, _ | "--help" :: _, _ => .error "help"
| "--version" :: _, _ => .error "version"
| option :: rest, options =>
if option.startsWith "-" then .error s!"unknown option: {option}"
else parseRaw rest {options with positionals := options.positionals ++ [option]}
termination_by args _ => argsprivate
def parseAction
: List String →
Except String Action
| [] => .error "missing command"
| "init" :: rest => do
let options ← parseRaw rest {}
match options.positionals with
| [] => pure <| .init {}
| [directory] => pure <| .init {directory := some directory}
| _ => .error "init accepts at most one DIRECTORY"
| "check" :: rest => do
let options ← parseRaw rest {}
let source ← match options.positionals with
| [source] => pure source
| [] => .error "check requires SOURCE"
| _ => .error "check accepts exactly one SOURCE"
pure <| .check {
source
config := options.config
targets := options.targets
xref := options.xref
docsRoot := options.docsRoot
}
| "build" :: rest => do
let options ← parseRaw rest {}
let source ← match options.positionals with
| [source] => pure source
| [] => .error "build requires SOURCE"
| _ => .error "build accepts exactly one SOURCE"
pure <| .build {
source
config := options.config
targets := options.targets
xref := options.xref
docsRoot := options.docsRoot
docsDirectory := options.docsDirectory
output := options.output.getD ".lake/build/site"
css := options.css.getD "theme/dist/site.css"
}
| command :: _ => .error s!"unknown command: {command}"private
def fromExcept
{α : Type}
: Except String α →
IO α
| .ok value => pure value
| .error error => throw <| IO.userError errorprivate
def createIfMissing
(path : System.FilePath)
(contents : String)
: IO Bool
:= do
if ← path.pathExists then
pure false
else
IO.FS.writeFile path contents
pure trueprivate def defaultDocsDirectory : System.FilePath := ".lake/build/literate-html"private
def copyFile
(source target : System.FilePath)
: IO Unit
:= do
IO.FS.withFile source .read fun input =>
IO.FS.withFile target .write fun output => do
while true do
let contents ← input.read 65536
if contents.isEmpty then
break
output.write contentsprivate
def copyDirectory
(source target : System.FilePath)
: IO Unit
:= do
let mut todo : List (System.FilePath × System.FilePath) := [(source, target)]
while !todo.isEmpty do
match todo with
| [] => break
| (source, target) :: rest =>
todo := rest
IO.FS.createDirAll target
for entry in ← source.readDir do
let destination := target.join entry.fileName
if ← entry.path.isDir then
todo := (entry.path, destination) :: todo
else
copyFile entry.path destinationprivate
def joinUrlPath
(root : System.FilePath)
(url : String)
: System.FilePath
:=
url.splitOn "/" |>.filter (!·.isEmpty) |>.foldl (init := root) fun path segment =>
path.join ⟨segment⟩private
def copyGeneratedDocs
(config : BuildConfig)
: IO Bool
:= do
let xref := defaultDocsDirectory.join "xref.json"
let useLocalDocs := match config.links.xref with
| none => true
| some path => (path : System.FilePath) == xref
if !useLocalDocs || !(← xref.pathExists) then
pure false
else
let destination := joinUrlPath ⟨config.output⟩ config.docsDirectory
copyDirectory defaultDocsDirectory destination
pure trueprivate
def initBlog
(directory : String)
: IO Unit
:= do
let root : System.FilePath := directory
let posts := root.join "posts"
let themeSource := root.join "theme" |>.join "src"
let workflowDirectory := root.join ".github" |>.join "workflows"
let toolsDirectory := root.join "tools"
IO.FS.createDirAll posts
IO.FS.createDirAll themeSource
IO.FS.createDirAll workflowDirectory
IO.FS.createDirAll toolsDirectory
let files := #[
(posts.join "starter.lean.md", starterPost),
(root.join "README.md", starterReadme),
(root.join "leanblog.json", starterConfig),
(root.join ".gitignore", starterGitignore),
(root.join "lean-toolchain", starterToolchain),
(root.join "lakefile.lean", starterLakefile),
(root.join "Main.lean", starterMain),
(root.join "theme" |>.join "package.json", starterThemePackage),
(root.join "theme" |>.join "package-lock.json", starterThemeLock),
(themeSource.join "app.css", starterThemeCss),
(toolsDirectory.join "build-site.mjs", starterBuildScript),
(workflowDirectory.join "pages.yml", starterWorkflow)
]
let mut created := 0
let mut skipped := 0
for (path, contents) in files do
if ← createIfMissing path contents then
created := created + 1
IO.println s!"created {path}"
else
skipped := skipped + 1
IO.println s!"kept {path}"
IO.println s!"initialized {root} ({created} created, {skipped} kept)"
let starter := posts.join "starter.lean.md"
IO.println s!"next: edit {starter}"
IO.println s!"then run: node {toolsDirectory.join "build-site.mjs"}"private
def parseTargetLine
(line : String)
: Except String (Option (Lean.Name × Target))
:= do
let line := line.trimAscii.toString
if line.isEmpty || "#".isPrefixOf line then
pure none
else
match line.splitOn "\t" with
| [name, href, description] =>
let name := name.trimAscii.toString.toName
if name == .anonymous then
.error s!"Invalid declaration name in targets file: '{line}'"
else
pure <| some (name, {
href := href.trimAscii.toString
description := description.trimAscii.toString
})
| _ => .error s!"Targets must have three tab-separated columns: '{line}'"private
def findXref
(configured? : Option String)
: IO (Option System.FilePath)
:= do
match configured? with
| some path =>
unless ← (path : System.FilePath).pathExists do
throw <| IO.userError s!"Verso cross-reference file not found: {path}"
pure <| some path
| none =>
let candidates : Array System.FilePath := #[
".lake/build/literate-html/xref.json",
"xref.json"
]
pure <| ← candidates.findM? (·.pathExists)private
def loadSiteConfig
(configured? : Option String)
: IO SiteConfig
:= do
let path : System.FilePath := configured?.getD "leanblog.json"
if !(← path.pathExists) then
pure {}
else
let json ← fromExcept <| Json.parse (← IO.FS.readFile path)
match SiteConfig.fromJson? json with
| .ok config => pure config
| .error error =>
throw <| IO.userError s!"{path}: invalid site configuration: {error}"private
def loadXref
(path : System.FilePath)
(docsRoot : String)
(index : DeclarationIndex)
: IO DeclarationIndex
:= do
let json ← fromExcept <| Json.parse (← IO.FS.readFile path)
let domains ← fromExcept <| json.getObj?
let some constants := domains.get? "VersoHtml.constant"
| pure index
let contentsJson ← fromExcept <| Json.getObjVal? constants "contents"
let contents ← fromExcept <| Json.getObj? contentsJson
let mut index := index
for (nameString, entriesJson) in contents.toList do
let name := String.toName nameString
if name == Lean.Name.anonymous || (index.resolve name).isSome then
continue
let entries ← fromExcept <| Json.getArr? entriesJson
if let some entry := entries[0]? then
let address ← fromExcept <| Json.getObjValAs? entry String "address"
let id ← fromExcept <| Json.getObjValAs? entry String "id"
let href := (docsRoot.dropSuffix "/").toString ++ address ++ "#" ++ id
index := index.add name {
href
description := s!"Declaration `{nameString}`"
}
pure indexprivate
def loadTargets
(config : LinkConfig)
: IO DeclarationIndex
:= do
let mut index := DeclarationIndex.empty
if let some path := config.targets then
let contents ← IO.FS.readFile path
for line in contents.splitOn "\n" do
if let some (name, target) ← fromExcept (parseTargetLine line) then
index := index.add name target
if let some path ← findXref config.xref then
index ← loadXref path config.docsRoot index
pure indexprivate
def codeLinks
(index : DeclarationIndex)
(name : Lean.Name)
: Array Verso.Code.CodeLink
:=
(index.resolve name).getD #[] |>.map fun target => {
shortDescription := "docs"
description := target.description
href := target.href
}private
def codeLinkTargets
(index : DeclarationIndex)
: Verso.Code.LinkTargets TraverseContext
where
const := fun name _ => codeLinks index name
option := fun name _ => codeLinks index name
definition := fun name _ => codeLinks index name
moduleName := fun name _ => codeLinks index namestructure LoadedPost where
path : System.FilePath
raw : String
source : PostSourceprivate def : Nat := 3private
def
(left right : List String)
: Nat
:=
left.foldl (init := 0) fun count tag =>
if right.contains tag then count + 1 else countprivate
def newerDate
(left right : Date)
: Bool
:=
if left.year != right.year then decide (left.year > right.year)
else if left.month != right.month then decide (left.month > right.month)
else decide (left.day > right.day)private
def
(current : LoadedPost)
(posts : Array LoadedPost)
: Array LoadedPost
:=
let candidates := posts.filter (·.path != current.path)
(candidates.qsort fun left right =>
let leftScore := sharedTagCount current.source.tags left.source.tags
let rightScore := sharedTagCount current.source.tags right.source.tags
if leftScore == rightScore then newerDate left.source.date right.source.date
else decide (leftScore > rightScore)).take relatedLimitprivate
def
(post : LoadedPost)
: Verso.Output.Html
:=
let href := defaultPostName post.source.date post.source.title ++ "/"
let tags := String.intercalate " · " post.source.tags
let dateAndTags := post.source.date.toIso8601String ++
(if tags.isEmpty then "" else " · " ++ tags)
Verso.Output.Html.tag "a" #[("href", href), ("class", "leanblog-related-card")] <|
Verso.Output.Html.tag "div" #[("class", "related-card-body")] <|
Verso.Output.Html.seq #[
Verso.Output.Html.tag "h3" #[] (.text true post.source.title),
Verso.Output.Html.tag "p" #[] (.text true dateAndTags)
]private
def
(posts : Array LoadedPost)
: String
:=
let content := Verso.Output.Html.seq #[
Verso.Output.Html.tag "h2" #[
("id", "leanblog-related-title"), ("class", "leanblog-related-heading")
] (.text true "Continue reading"),
Verso.Output.Html.tag "div" #[
("class", "leanblog-related-grid")
] (Verso.Output.Html.seq (posts.map relatedCard))
]
Verso.Output.Html.asString (breakLines := false) <|
Verso.Output.Html.tag "section" #[
("class", "leanblog-related"), ("aria-labelledby", "leanblog-related-title")
] contentprivate
def injectRelatedPosts
(output : String)
(posts : Array LoadedPost)
: IO Unit
:= do
let marker := "<div id=\"leanblog-related-posts\"></div>"
for current in posts do
let slug := defaultPostName current.source.date current.source.title
let page := (System.FilePath.mk output).join slug |>.join "index.html"
let html ← IO.FS.readFile page
unless html.contains marker do
throw <| IO.userError s!"Verso post template marker not found in {page}"
let related := relatedPosts current posts
IO.FS.writeFile page <| html.replace marker (relatedSection related)private
def rawPage
(config : SiteConfig)
(post : LoadedPost)
: String
:=
let content := {{
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<base href="../.././"/>
<title>{{post.source.title}} " · raw"</title>
<link rel="stylesheet" href="-verso-data/leanblog.css"/>
</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="Raw source navigation">
<a class="site-link site-link-strong" href="../">"Back to post"</a>
</nav>
</div>
</header>
<main class="site-main w-full flex-1"><div class="site-content">
<article class="raw-source-page">
<p class="page-kicker">"Raw source"</p>
<h1 class="page-title">{{post.source.title}}</h1>
<pre class="raw-source-code"><code>{{Html.text true post.raw}}</code></pre>
</article>
</div></main>
</div>
</body>
</html>
}}
content.asString (breakLines := true)private def searchInitJs : String := r##"
import { domainMappers, searchPriorities } from "./domain-mappers.js";
import { registerSearch } from "./search-box.js";
const searchHTML = `<div id="search-wrapper" class="verso-search-results">
<div class="combobox combobox-list">
<div class="group">
<div
id="cb1-input"
class="cb_edit"
contenteditable="true"
role="searchbox"
placeholder="Search posts..."
aria-autocomplete="list"
aria-expanded="false"
aria-controls="cb1-listbox"
aria-haspopup="listbox"
aria-label="Search posts"
spellcheck="false"
autocorrect="false"
autocapitalize="none"
inputmode="search"
></div>
</div>
<ul id="cb1-listbox" role="listbox" aria-label="Search results"></ul>
</div>
</div>`;
const data = fetch("xref.json").then((response) => {
if (!response.ok) throw new Error(`Search metadata failed: ${response.status}`);
return response.json();
});
window.addEventListener("load", () => {
if (document.querySelector("[data-search-host]")) return;
const mount = document.querySelector(".site-header-inner");
if (!mount) return;
mount.insertAdjacentHTML("beforeend", searchHTML);
const searchWrapper = document.querySelector(".combobox-list");
data.then((json) => {
registerSearch({
searchWrapper,
data: json,
domainMappers,
searchPriorities,
docPriorities: window.docPriorities ?? {},
searchPagePath: window.searchPagePath ?? "search/",
});
}).catch((error) => console.error(error));
});
document.addEventListener("keydown", (event) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (target instanceof HTMLElement &&
target.closest("input, textarea, select, button, a, [contenteditable='true']")) return;
const search = document.querySelector("#search-wrapper .cb_edit, #search-page-input");
if (!(search instanceof HTMLElement)) return;
event.preventDefault();
search.focus();
});
"##private
def searchThemeJs
(config : SiteConfig)
: String
:=
let configuredTheme := match config.defaultTheme with
| "dark" => "'dark'"
| "light" => "'light'"
| _ => "preferred"
(r#"
(() => {
const root = document.documentElement;
const stored = (() => {
try { return localStorage.getItem("leanblog-theme"); } catch (_) { return null; }
})();
const preferred = 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");
});
};
document.addEventListener("DOMContentLoaded", () => {
update();
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();
});
});
});
})();
"#).replace
"root.dataset.theme = stored === \"dark\" || stored === \"light\" ? stored : preferred;"
("root.dataset.theme = stored === \"dark\" || stored === \"light\" ? stored : " ++
configuredTheme ++ ";")private
def searchPage
(config : SiteConfig)
: String
:=
let searchAssets := Verso.Search.searchAssetTags
let initialTheme := if config.defaultTheme == "dark" then "dark" else "light"
let page := {{
<html lang="en" data-theme={{initialTheme}}>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{{Theme.siteMetadata config}}
<base href=".././"/>
<title>"Search · "{{config.title}}</title>
<link rel="stylesheet" href="-verso-data/leanblog.css"/>
{{searchAssets}}
<script>{{Html.text false (searchThemeJs config)}}</script>
</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">
{{Theme.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">
<article class="search-page-content">
<p class="page-kicker">"Archive"</p>
<h1 class="page-title">"Search posts"</h1>
<p class="search-page-intro">
"Search titles, tags, headings, prose, mathematics, and code."
</p>
<div data-search-host class="search-page-host" role="search"
aria-label="Search posts"></div>
<div id="search-page-results"></div>
</article>
</div>
</main>
<footer class="site-footer">
<div class="site-footer-inner">
{{config.footer}}
</div>
</footer>
</div>
<script type="module" src="-verso-search/search-page.js"></script>
</body>
</html>
}}
page.asString (breakLines := true)private
def searchableAttrText
(text : Array MD4Lean.AttrText)
: String
:=
text.foldl (init := "") fun result part =>
match part with
| .normal value | .entity value => result ++ value
| .nullchar => resultmutual
private def searchableInline : MD4Lean.Text → String
| .normal value | .entity value => value
| .nullchar => ""
| .br value | .softbr value => value
| .em content | .strong content | .u content | .del content => searchableInlines content
| .code content => String.join content.toList
| .latexMath content | .latexMathDisplay content => String.join content.toList
| .img _ _title alt => searchableInlines alt
| .wikiLink _target content => searchableInlines content
| .a _href _title _isAuto content => searchableInlines content
private def searchableInlines (content : Array MD4Lean.Text) : String :=
content.foldl (init := "") fun result inline => result ++ searchableInline inline
private def searchableBlock : MD4Lean.Block → String
| .p content => searchableInlines content
| .header _level content => searchableInlines content
| .code _info _lang _fence content => String.join content.toList
| .hr => ""
| .blockquote _ | .ul _ _ _ | .ol _ _ _ _ | .html _ | .table _ _ => ""
endprivate
def searchableBody
(document : MD4Lean.Document)
: String
:=
document.blocks.toList.map searchableBlock |> String.intercalate "\n\n"private def searchBucket (ref : String) : UInt8 := Id.run do
let mut hash := 0
let mut index := 0
while h : index < ref.utf8ByteSize do
hash := hash + ref.getUTF8Byte ⟨index⟩ h
index := index + 1
hashprivate
def writeSearchAssets
(config : BuildConfig)
(posts : Array LoadedPost)
: IO Unit
:= do
let builder := ({refField := "id" : IndexBuilder})
|>.addField "id"
|>.addField "header"
|>.addField "contents"
let mut index := builder.build
for post in posts do
let ref := defaultPostName post.source.date post.source.title ++ "/"
let tags := String.intercalate " " post.source.tags
let contents := tags ++ "\n\n" ++ searchableBody post.source.document
index := index.addDoc ref #[ref, post.source.title, contents]
let (extracted, docs) := index.extractDocs
let indexData := extracted.toJson.compress
let version := Verso.Search.hashHex (hash indexData)
let mut buckets : Std.HashMap UInt8 (Std.HashMap String Doc) := {}
for (ref, doc) in docs do
let doc := doc.insert "context" ""
buckets := buckets.alter (searchBucket ref) fun existing =>
some (existing.getD {} |>.insert ref doc)
let searchDir := (System.FilePath.mk config.output).join "-verso-search"
IO.FS.createDirAll searchDir
for (bucket, bucketDocs) in buckets do
let docsJson := Verso.Search.bucketDocsToJson bucketDocs {}
IO.FS.writeFile (searchDir / s!"searchIndex_{bucket}.{version}.js")
s!"window.docContents[{bucket}].resolve({docsJson.compress});"
let indexJs := "const __verso_searchIndexData = " ++ indexData ++ ";\n\n" ++
"const __versoSearchIndex = elasticlunr ? " ++
"elasticlunr.Index.load(__verso_searchIndexData) : null;\n" ++
"window.docContents = {};\n" ++
"window.searchIndex = elasticlunr ? __versoSearchIndex : null;\n" ++
"window.docPriorities = {};\n" ++
"window.searchIndexVersion = " ++ toString (Json.str version) ++ ";\n"
IO.FS.writeFile (searchDir / "searchIndex.js") indexJs
IO.FS.writeFile (searchDir / "elasticlunr.min.js") Verso.Output.Html.elasticlunr.min.js
VersoLiterateCode.emitSearchBox searchDir (some "search/")
IO.FS.writeFile (searchDir / "search-init.js") searchInitJs
-- The quick-jump combobox and the full-text page share Verso's declaration index. Keep the
-- source generated by `:literateHtml` at the site root so nested pages resolve it against their
-- `<base>` element just like the copied API documentation does.
let xref := match config.links.xref with
| some path => (path : System.FilePath)
| none => defaultDocsDirectory.join "xref.json"
let xrefOutput := (System.FilePath.mk config.output).join "xref.json"
if ← xref.pathExists then
copyFile xref xrefOutput
else
IO.FS.writeFile xrefOutput "{}\n"
let searchPageDir := (System.FilePath.mk config.output).join "search"
IO.FS.createDirAll searchPageDir
IO.FS.writeFile (searchPageDir / "index.html") (searchPage config.site)private
def versionCssHref
(html css : String)
: String
:=
let marker := "href=\"-verso-data/leanblog.css"
let version := Verso.Search.hashHex (hash css)
let replacement := s!"href=\"-verso-data/leanblog.css?v={version}"
match html.splitOn marker with
| [] => html
| first :: rest =>
first ++ String.intercalate "" (rest.map fun part =>
replacement ++ (part.dropWhile (· != '"')).copy)private
def versionCssLinks
(output : String)
(css : String)
: IO Unit
:= do
let root : System.FilePath := output
for page in (← root.walkDir).filter (·.toString.endsWith ".html") do
let html ← IO.FS.readFile page
let versioned := versionCssHref html css
unless versioned == html do
IO.FS.writeFile page versionedprivate
def writeRawPages
(output : String)
(site : SiteConfig)
(posts : Array LoadedPost)
: IO Unit
:= do
for post in posts do
let slug := defaultPostName post.source.date post.source.title
let directory := (System.FilePath.mk output).join slug |>.join "raw"
IO.FS.createDirAll directory
IO.FS.writeFile (directory.join "index.html") (rawPage site post)private
def sourceFiles
(sourcePath : String)
: IO (Array System.FilePath)
:= do
let isMarkdown (path : System.FilePath) := path.toString.endsWith ".md"
let path : System.FilePath := sourcePath
unless ← path.pathExists do
throw <| IO.userError s!"Source path not found: {sourcePath}"
if ← path.isDir then
let paths ← path.walkDir
let files :=
(paths.filter isMarkdown).qsort fun left right =>
left.toString < right.toString
if files.isEmpty then
throw <| IO.userError s!"No Markdown posts found below {sourcePath}"
pure files
else if isMarkdown path then
pure #[path]
else
throw <| IO.userError s!"Expected a .md file or posts directory: {sourcePath}"private
def loadPost
(path : System.FilePath)
: IO LoadedPost
:= do
let source ← IO.FS.readFile path
match parsePost source with
| .ok parsed => pure {path, raw := source, source := parsed}
| .error error => throw <| IO.userError s!"{path}: {error}"private
def loadPosts
(sourcePath : String)
: IO (Array LoadedPost)
:= do
let paths ← sourceFiles sourcePath
paths.mapM loadPostprivate structure LeanCodeBlock where
postPath : System.FilePath
source : Stringprivate
def markdownText
(text : Array MD4Lean.AttrText)
: String
:=
text.foldl (init := "") fun result part =>
match part with
| .normal value | .entity value => result ++ value
| .nullchar => resultprivate def leanCodeBlocks (posts : Array LoadedPost) : Array LeanCodeBlock := Id.run do
let mut blocks := #[]
for post in posts do
for block in post.source.document.blocks do
match block with
| .code _info lang _fence content =>
if markdownText lang == "lean" || markdownText lang == "lean4" then
blocks := blocks.push {
postPath := post.path
source := String.join content.toList
}
| _ => pure ()
blocksprivate structure HighlightResult where
rendered : SubVerso.Highlighting.Highlighted
environment : Lean.Environment
declarations : Array Lean.Nameprivate
def highlightLean
(code : String)
(environment : Lean.Environment)
: IO (Option HighlightResult)
:= do
try
let inputCtx := Parser.mkInputContext code "<leanblog-code>"
let commandState : Lean.Elab.Command.State := {
env := environment
maxRecDepth := 100000
}
let initialState : Lean.Elab.Frontend.State := {
commandState
parserState := {}
cmdPos := 0
}
let (result, finalState) ←
(SubVerso.Compat.Frontend.processCommands Lean.mkNullNode).run
{inputCtx} |>.run initialState
let result := result.updateLeading code
let result := {result with
items := result.items.map (fun item => {item with messages := Lean.MessageLog.empty})}
let action : Lean.Elab.Command.CommandElabM SubVerso.Highlighting.Highlighted := do
Lean.Elab.Command.runTermElabM fun _ => do
withTheReader Core.Context (fun context => {context with fileMap := inputCtx.fileMap}) do
let highlighted ← SubVerso.Highlighting.highlightFrontendResult result
pure <| highlighted.foldl (· ++ ·) .empty
let commandContext : Lean.Elab.Command.Context := {
cmdPos := 0
fileName := inputCtx.fileName
fileMap := inputCtx.fileMap
snap? := none
cancelTk? := none
}
match ← EIO.toIO' (action.run commandContext |>.run finalState.commandState) with
| .ok (highlighted, commandState) =>
let declarations := commandState.env.constants.toList.filterMap fun (name, _) =>
if environment.constants.find? name |>.isSome then none else some name
pure <| some {
rendered := highlighted
environment := commandState.env
declarations := declarations.toArray
}
| .error _ => pure none
catch _ =>
pure noneprivate structure HighlightedCode where
source : String
rendered : SubVerso.Highlighting.Highlighted
declarations : Array Lean.Name
target : Targetprivate
def highlightLeanCodes
(posts : Array LoadedPost)
: IO (Array HighlightedCode)
:= do
let mut highlighted := #[]
let mut environment ← Lean.mkEmptyEnvironment
for block in leanCodeBlocks posts do
if let some result ← highlightLean block.source environment then
let some post := posts.find? (·.path == block.postPath)
| continue
let slug := defaultPostName post.source.date post.source.title
let target : Target := {
href := slug ++ "/"
description := s!"Declaration from `{post.source.title}`"
}
highlighted := highlighted.push {
source := block.source
rendered := result.rendered
declarations := result.declarations
target
}
environment := result.environment
pure highlightedprivate
def addLocalTargets
(index : DeclarationIndex)
(highlighted : Array HighlightedCode)
: DeclarationIndex
:=
highlighted.foldl (init := index) fun index code =>
code.declarations.foldl (init := index) fun index name =>
index.add name code.targetprivate
def lowerPost
(post : LoadedPost)
(index : DeclarationIndex)
(highlight? : String → Option SubVerso.Highlighting.Highlighted)
: IO (Part Post)
:= do
match post.source.toPartWithHighlight index highlight? with
| .ok contents => pure contents
| .error error => throw <| IO.userError s!"{post.path}: {error}"private
def checkSource
(sourcePath : String)
(links : LinkConfig)
: IO Unit
:= do
let posts ← loadPosts sourcePath
let index ← loadTargets links
for post in posts do
let _ ← lowerPost post index (fun _ => none)
IO.println s!"checked {post.path} ({post.source.document.blocks.size} blocks)"
IO.println s!"checked {posts.size} post(s)"private
def buildSource
(sourcePath : String)
(config : BuildConfig)
: IO Unit
:= do
let posts ← loadPosts sourcePath
let index ← loadTargets config.links
let highlighted ← highlightLeanCodes posts
let index := addLocalTargets index highlighted
let highlight? := fun source =>
highlighted.find? (·.source == source) |>.map (·.rendered)
let contents ← posts.mapM (fun post => lowerPost post index highlight?)
let css ← IO.FS.readFile config.css
let home : Part Page := Verso.Doc.Part.mk #[.text config.site.title] config.site.title none
#[.para #[.text config.site.tagline]] #[]
let blogPosts := contents.mapIdx fun index contents =>
{id := Lean.Name.mkSimple s!"post{index}", contents}
-- A root blog is not registered by Verso's root-site traversal. An empty blog child keeps the
-- archive at `/` while using the normal directory-blog path that registers categories correctly.
let site : Site := .page `home home #[.blog "" `blog home blogPosts]
let status ← blogMain (Theme.make css config.site) site (codeLinkTargets index)
["--output", config.output]
if status != 0 then
throw <| IO.userError s!"Verso failed to build {sourcePath}"
injectRelatedPosts config.output posts
writeRawPages config.output config.site posts
writeSearchAssets config posts
versionCssLinks config.output css
if ← copyGeneratedDocs config then
IO.println s!"copied local API docs to {joinUrlPath ⟨config.output⟩ config.docsDirectory}"
IO.println s!"built {config.output}"private
def checkLinks
(options : CheckOptions)
: IO LinkConfig
:= do
let site ← loadSiteConfig options.config
pure {
targets := options.targets
xref := options.xref
docsRoot := options.docsRoot.getD site.docsRoot
}private
def buildConfig
(options : BuildOptions)
: IO BuildConfig
:= do
let site ← loadSiteConfig options.config
pure {
links := {
targets := options.targets
xref := options.xref
docsRoot := options.docsRoot.getD site.docsRoot
}
site
output := options.output
css := options.css
docsDirectory := options.docsDirectory.getD site.docsDirectory
}private
def runAction
: Action →
IO UInt32
| .init options => do
initBlog (options.directory.getD ".")
pure 0
| .check options => do
checkSource options.source (← checkLinks options)
pure 0
| .build options => do
buildSource options.source (← buildConfig options)
pure 0private
def reportError
(error : String)
: IO UInt32
:= do
let stderr ← IO.getStderr
stderr.putStrLn s!"leanblog: {error}"
pure 2def main
(args : List String)
: IO UInt32
:= do
match args with
| ["--help"] | ["-h"] =>
IO.println usage
pure 0
| ["--version"] =>
IO.println "leanblog 0.1.0"
pure 0
| _ =>
match parseAction args with
| .ok action =>
try runAction action
catch error => reportError error.toString
| .error "help" =>
IO.println usage
pure 0
| .error "version" =>
IO.println "leanblog 0.1.0"
pure 0
| .error error => reportError errorend LeanBlog.Cli