import ssg import markdown import frontmatter type SiteConfig = | SiteConfig(String, String, String, String, String, String, String) fn loadConfig(path: String): SiteConfig with {File} = { let raw = File.read(path) let json = match Json.parse(raw) { Ok(j) => j, Err(_) => match Json.parse("\{\}") { Ok(j2) => j2, }, } let title = match Json.get(json, "siteTitle") { Some(v) => match Json.asString(v) { Some(s) => s, None => "Site", }, None => "Site", } let url = match Json.get(json, "siteUrl") { Some(v) => match Json.asString(v) { Some(s) => s, None => "", }, None => "", } let author = match Json.get(json, "author") { Some(v) => match Json.asString(v) { Some(s) => s, None => "", }, None => "", } let desc = match Json.get(json, "description") { Some(v) => match Json.asString(v) { Some(s) => s, None => "", }, None => "", } let contentDir = match Json.get(json, "contentDir") { Some(v) => match Json.asString(v) { Some(s) => s, None => "content", }, None => "content", } let outputDir = match Json.get(json, "outputDir") { Some(v) => match Json.asString(v) { Some(s) => s, None => "_site", }, None => "_site", } let staticDir = match Json.get(json, "staticDir") { Some(v) => match Json.asString(v) { Some(s) => s, None => "static", }, None => "static", } SiteConfig(title, url, author, desc, contentDir, outputDir, staticDir) } fn cfgTitle(c: SiteConfig): String = match c { SiteConfig(t, _, _, _, _, _, _) => t, } fn cfgDesc(c: SiteConfig): String = match c { SiteConfig(_, _, _, d, _, _, _) => d, } fn cfgContentDir(c: SiteConfig): String = match c { SiteConfig(_, _, _, _, cd, _, _) => cd, } fn cfgOutputDir(c: SiteConfig): String = match c { SiteConfig(_, _, _, _, _, od, _) => od, } fn cfgStaticDir(c: SiteConfig): String = match c { SiteConfig(_, _, _, _, _, _, sd) => sd, } fn htmlHead(title: String, description: String): String = "" + "" + title + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" fn htmlNav(): String = "\"Narsil" fn htmlFooter(): String = "
" + "
\"Narsil
" + "
" + "" + "" + "
" + "
Copyright (c) 2025 Brandon Lucas. All Rights Reserved.
" + "
" fn htmlDocument(title: String, description: String, body: String): String = htmlHead(title, description) + "
" + htmlNav() + body + htmlFooter() + "
" fn htmlPostPage(title: String, date: String, tagsHtml: String, content: String): String = "
" + "

" + title + "

" + "
" + "" + "
" + content + "
" + "
" fn tagToLink(tag: String): String = "" + tag + "" fn renderTagLinks(tags: List): String = String.join(List.map(tags, tagToLink), ", ") fn htmlPostEntry(title: String, date: String, url: String): String = "
" + "" + title + "" + "" + date + "" + "
" fn htmlPostList(sectionTitle: String, postsHtml: String): String = "
" + "

" + sectionTitle + "

" + "
" + postsHtml + "
" fn htmlWelcome(): String = "
Welcome!I'm a software builder by trade who's interested in too many things for my own good.Here's a sample:
  • Free and Open Source Software (FOSS): Bitcoin, Lightning Network, Payjoin, Linux, GrapheneOS, VPNs, etc.
  • History: Ancient Greek, Roman, American Revolution, and more.)
  • Biographies: Adams, Hamilton, Washington, Franklin, Oppenheimer, Ramanujan and more
  • Philosophy, psychology, Christianity: Influenced by Cicero, Nietzsche, Karl Popper, Dostoevsky, Will Durant, Oliver Sacks, Jung, Seneca, and more. Attempting to read Kierkegaard, but finding it impenetrably difficult yet joyful.)
  • Languages: I'm currently learning Ancient Greek and Latin.
  • Fun: Bass guitar
" fn htmlHomePage(siteTitle: String, snippetsHtml: String): String = "

" + siteTitle + "

" + "
" + "Βράνδων Λουκᾶς" + "
" + "

" + "Bitcoin Lightning Payments @ voltage.cloud" + "Bitcoin Privacy & Scalability @ payjoin.org." + "Love sovereign software & history." + "Learning Nix, Elm, Rust, Ancient Greek and Latin." + "

" + "
" + htmlWelcome() + snippetsHtml + "
" fn htmlSnippetCard(content: String): String = "
" + "
" + content + "
" + "
" fn htmlTagPage(tagName: String, postsHtml: String): String = "
" + "

Tag: " + tagName + "

" + "
" + postsHtml + "
" fn writePostPage(outputDir: String, section: String, page: Post, siteTitle: String, siteDesc: String): Unit with {File} = { let slug = ssg.postSlug(page) let title = ssg.postTitle(page) let date = ssg.postDate(page) let tagsRaw = ssg.postTags(page) let content = ssg.postContent(page) let tags = if tagsRaw == "" then [] else String.split(tagsRaw, " ") let tagsHtml = renderTagLinks(tags) let formattedDate = ssg.formatDate(date) let body = htmlPostPage(title, formattedDate, tagsHtml, content) let pageTitle = title + " | " + siteTitle let html = htmlDocument(pageTitle, siteDesc, body) let dir = outputDir + "/posts/" + section + "/" + slug ssg.ensureDir(dir) File.write(dir + "/index.html", html) } fn writeSectionIndex(outputDir: String, section: String, pages: List, siteTitle: String, siteDesc: String): Unit with {File} = { let sorted = ssg.sortByDateDesc(pages) let postEntries = List.map(sorted, fn(page: Post): String => htmlPostEntry(ssg.postTitle(page), ssg.formatDate(ssg.postDate(page)), "/posts/" + section + "/" + ssg.postSlug(page))) let postsHtml = String.join(postEntries, " ") let sectionName = if section == "articles" then "Articles" else if section == "blog" then "Blog" else if section == "journal" then "Journal" else section let body = htmlPostList(sectionName, postsHtml) let pageTitle = sectionName + " | " + siteTitle let html = htmlDocument(pageTitle, siteDesc, body) let dir = outputDir + "/posts/" + section ssg.ensureDir(dir) File.write(dir + "/index.html", html) } fn tagEntryToHtml(e: TagEntry): String = htmlPostEntry(ssg.tagTitle(e), ssg.formatDate(ssg.tagDate(e)), "/posts/" + ssg.tagSection(e) + "/" + ssg.tagSlug(e)) fn writeOneTagPage(outputDir: String, tag: String, allTagEntries: List, siteTitle: String, siteDesc: String): Unit with {File} = { let entries = ssg.entriesForTag(allTagEntries, tag) let postsHtml = String.join(List.map(entries, tagEntryToHtml), " ") let body = htmlTagPage(tag, postsHtml) let pageTitle = "Tag: " + tag + " | " + siteTitle let html = htmlDocument(pageTitle, siteDesc, body) let dir = outputDir + "/tags/" + tag ssg.ensureDir(dir) File.write(dir + "/index.html", html) } fn writeTagPagesLoop(outputDir: String, tags: List, allTagEntries: List, siteTitle: String, siteDesc: String): Unit with {File} = match List.head(tags) { None => (), Some(tag) => { writeOneTagPage(outputDir, tag, allTagEntries, siteTitle, siteDesc) match List.tail(tags) { Some(rest) => writeTagPagesLoop(outputDir, rest, allTagEntries, siteTitle, siteDesc), None => (), } }, } fn writeTagPages(outputDir: String, allTagEntries: List, siteTitle: String, siteDesc: String): Unit with {File} = { let uniqueTags = ssg.uniqueTags(allTagEntries) writeTagPagesLoop(outputDir, uniqueTags, allTagEntries, siteTitle, siteDesc) } fn renderSnippetFile(snippetDir: String, filename: String): String with {File} = { let raw = File.read(snippetDir + "/" + filename) let doc = frontmatter.parse(raw) htmlSnippetCard(markdown.toHtml(frontmatter.body(doc))) } fn renderSnippets(snippetDir: String, files: List): List with {File} = match List.head(files) { None => [], Some(f) => { let card = renderSnippetFile(snippetDir, f) match List.tail(files) { Some(rest) => List.concat([card], renderSnippets(snippetDir, rest)), None => [card], } }, } fn insertString(sorted: List, item: String): List = { match List.head(sorted) { None => [item], Some(first) => if item <= first then List.concat([item], sorted) else match List.tail(sorted) { Some(rest) => List.concat([first], insertString(rest, item)), None => [first, item], } } } fn sortStrings(items: List): List = List.fold(items, [], insertString) fn writeHomePage(outputDir: String, contentDir: String, siteTitle: String, siteDesc: String): Unit with {File} = { let snippetDir = contentDir + "/snippets" let snippetEntries = if File.exists(snippetDir) then { let entries = File.readDir(snippetDir) let mdFiles = List.filter(entries, fn(e: String): Bool => String.endsWith(e, ".md")) sortStrings(mdFiles) } else [] let snippetCards = renderSnippets(snippetDir, snippetEntries) let gridHtml = "
" + String.join(snippetCards, " ") + "
" let body = htmlHomePage(siteTitle, gridHtml) let pageTitle = "Bitcoin Lightning Developer & Privacy Advocate | " + siteTitle let html = htmlDocument(pageTitle, siteDesc, body) File.write(outputDir + "/index.html", html) } fn writeAllPostPages(outputDir: String, section: String, pages: List, siteTitle: String, siteDesc: String): Unit with {File} = match List.head(pages) { None => (), Some(page) => { writePostPage(outputDir, section, page, siteTitle, siteDesc) match List.tail(pages) { Some(rest) => writeAllPostPages(outputDir, section, rest, siteTitle, siteDesc), None => (), } }, } fn main(): Unit with {File, Console, Process} = { Console.print("=== blu-site: Static Site Generator in Lux ===") Console.print("") let cfg = loadConfig("config.json") let siteTitle = cfgTitle(cfg) let siteDesc = cfgDesc(cfg) let contentDir = cfgContentDir(cfg) let outputDir = cfgOutputDir(cfg) let staticDir = cfgStaticDir(cfg) Console.print("Site: " + siteTitle) Console.print("Content: " + contentDir) Console.print("Output: " + outputDir) Console.print("") ssg.ensureDir(outputDir) Console.print("Reading content...") let articles = ssg.readSection(contentDir, "articles") let blogPosts = ssg.readSection(contentDir, "blog") let journalPosts = ssg.readSection(contentDir, "journal") Console.print(" Articles: " + toString(List.length(articles))) Console.print(" Blog posts: " + toString(List.length(blogPosts))) Console.print(" Journal entries: " + toString(List.length(journalPosts))) Console.print("") Console.print("Writing post pages...") writeAllPostPages(outputDir, "articles", articles, siteTitle, siteDesc) writeAllPostPages(outputDir, "blog", blogPosts, siteTitle, siteDesc) writeAllPostPages(outputDir, "journal", journalPosts, siteTitle, siteDesc) Console.print("Writing section indexes...") writeSectionIndex(outputDir, "articles", articles, siteTitle, siteDesc) writeSectionIndex(outputDir, "blog", blogPosts, siteTitle, siteDesc) writeSectionIndex(outputDir, "journal", journalPosts, siteTitle, siteDesc) Console.print("Writing tag pages...") let articleTags = ssg.collectTags("articles", articles) let blogTags = ssg.collectTags("blog", blogPosts) let journalTags = ssg.collectTags("journal", journalPosts) let allTags = List.concat(List.concat(articleTags, blogTags), journalTags) writeTagPages(outputDir, allTags, siteTitle, siteDesc) Console.print("Writing home page...") writeHomePage(outputDir, contentDir, siteTitle, siteDesc) Console.print("Copying static assets...") let copyCmd = "cp -r " + staticDir + "/* " + outputDir + "/" Process.exec(copyCmd) Console.print("") Console.print("=== Build complete! ===") let totalPages = List.length(articles) + List.length(blogPosts) + List.length(journalPosts) Console.print("Total post pages: " + toString(totalPages)) Console.print("Output directory: " + outputDir) } let _ = run main() with {}