Files
blu-lux/main.lux
Brandon Lucas 325f72e0e0 fix: add Welcome section and fix homepage snippet layout
Add the hardcoded Welcome card matching the live elmstatic site.
Put all snippets into the 2-column grid instead of making the
first one full-width. Sort snippets by filename to ensure correct
display order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:27:17 -05:00

288 lines
17 KiB
Plaintext

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 = "<!doctype html><html lang=\"en\"><head>" + "<title>" + title + "</title>" + "<meta charset=\"utf-8\">" + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" + "<meta name=\"description\" content=\"" + description + "\">" + "<meta property=\"og:title\" content=\"" + title + "\">" + "<meta property=\"og:description\" content=\"" + description + "\">" + "<meta property=\"og:type\" content=\"website\">" + "<meta property=\"og:url\" content=\"https://blu.cx\">" + "<meta property=\"og:image\" content=\"https://blu.cx/images/social-card.png\">" + "<meta property=\"og:site_name\" content=\"Brandon Lucas\">" + "<meta name=\"twitter:card\" content=\"summary_large_image\">" + "<meta name=\"twitter:title\" content=\"" + title + "\">" + "<meta name=\"twitter:description\" content=\"" + description + "\">" + "<link rel=\"canonical\" href=\"https://blu.cx\">" + "<link rel=\"preload\" href=\"/fonts/EBGaramond-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin=\"\">" + "<link rel=\"preload\" href=\"/fonts/UnifrakturMaguntia-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin=\"\">" + "<link href=\"/styles.css\" rel=\"stylesheet\" type=\"text/css\">" + "<link href=\"/highlight/tokyo-night-dark.min.css\" rel=\"stylesheet\" type=\"text/css\">" + "<script src=\"/highlight/highlight.min.js\" defer=\"\"></script>" + "<script>document.addEventListener('DOMContentLoaded', function() \{ hljs.highlightAll(); \});</script>" + "</head>"
fn htmlNav(): String = "<a href=\"/\"><img src=\"/images/favicon.webp\" alt=\"Narsil Logo\" width=\"59\" height=\"80\"></a>"
fn htmlFooter(): String = "<div class=\"footer flex flex-col gap-2 items-center\">" + "<div><a href=\"/\"><img alt=\"Narsil Favicon\" src=\"/images/favicon.webp\" width=\"59\" height=\"80\"></a></div>" + "<div class=\"flex gap-2\">" + "<a href=\"https://github.com/thebrandonlucas\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path fill=\"#fff\" d=\"M7.999,0.431c-4.285,0-7.76,3.474-7.76,7.761 c0,3.428,2.223,6.337,5.307,7.363c0.388,0.071,0.53-0.168,0.53-0.374c0-0.184-0.007-0.672-0.01-1.32 c-2.159,0.469-2.614-1.04-2.614-1.04c-0.353-0.896-0.862-1.135-0.862-1.135c-0.705-0.481,0.053-0.472,0.053-0.472 c0.779,0.055,1.189,0.8,1.189,0.8c0.692,1.186,1.816,0.843,2.258,0.645c0.071-0.502,0.271-0.843,0.493-1.037 C4.86,11.425,3.049,10.76,3.049,7.786c0-0.847,0.302-1.54,0.799-2.082C3.768,5.507,3.501,4.718,3.924,3.65 c0,0,0.652-0.209,2.134,0.796C6.677,4.273,7.34,4.187,8,4.184c0.659,0.003,1.323,0.089,1.943,0.261 c1.482-1.004,2.132-0.796,2.132-0.796c0.423,1.068,0.157,1.857,0.077,2.054c0.497,0.542,0.798,1.235,0.798,2.082 c0,2.981-1.814,3.637-3.543,3.829c0.279,0.24,0.527,0.713,0.527,1.437c0,1.037-0.01,1.874-0.01,2.129 c0,0.208,0.14,0.449,0.534,0.373c3.081-1.028,5.302-3.935,5.302-7.362C15.76,3.906,12.285,0.431,7.999,0.431z\"></path></svg></a>" + "<a href=\"https://x.com/brandonstlucas\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path fill=\"#fff\" d=\"M15.969,3.058c-0.586,0.26-1.217,0.436-1.878,0.515c0.675-0.405,1.194-1.045,1.438-1.809 c-0.632,0.375-1.332,0.647-2.076,0.793c-0.596-0.636-1.446-1.033-2.387-1.033c-1.806,0-3.27,1.464-3.27,3.27 c0,0.256,0.029,0.506,0.085,0.745C5.163,5.404,2.753,4.102,1.14,2.124C0.859,2.607,0.698,3.168,0.698,3.767 c0,1.134,0.577,2.135,1.455,2.722C1.616,6.472,1.112,6.325,0.671,6.08c0,0.014,0,0.027,0,0.041c0,1.584,1.127,2.906,2.623,3.206 C3.02,9.402,2.731,9.442,2.433,9.442c-0.211,0-0.416-0.021-0.615-0.059c0.416,1.299,1.624,2.245,3.055,2.271 c-1.119,0.877-2.529,1.4-4.061,1.4c-0.264,0-0.524-0.015-0.78-0.046c1.447,0.928,3.166,1.469,5.013,1.469 c6.015,0,9.304-4.983,9.304-9.304c0-0.142-0.003-0.283-0.009-0.423C14.976,4.29,15.531,3.714,15.969,3.058z\"></path></svg></a>" + "</div>" + "<div>Copyright (c) 2025 Brandon Lucas. All Rights Reserved.</div>" + "</div>"
fn htmlDocument(title: String, description: String, body: String): String = htmlHead(title, description) + "<body><main class=\"flex flex-col gap-8 w-[80%] items-center mx-auto my-20\">" + htmlNav() + body + htmlFooter() + "</main></body></html>"
fn htmlPostPage(title: String, date: String, tagsHtml: String, content: String): String = "<div class=\"page w-[80%] flex flex-col gap-8\">" + "<h1 class=\"text-4xl text-center font-bold w-full\">" + title + "</h1>" + "<div class=\"post-content flex flex-col gap-4 pb-12 border-b\">" + "<div class=\"post-metadata border-b mb-8 pb-8 items-center flex flex-col\">" + "<span>" + date + "</span>" + "<span>-</span>" + "<div>" + tagsHtml + "</div>" + "</div>" + "<div class=\"markdown\">" + content + "</div>" + "</div></div>"
fn tagToLink(tag: String): String = "<a href=\"/tags/" + tag + "\">" + tag + "</a>"
fn renderTagLinks(tags: List<String>): String = String.join(List.map(tags, tagToLink), ", ")
fn htmlPostEntry(title: String, date: String, url: String): String = "<div class=\"flex flex-col gap-1 border-b pb-4\">" + "<a href=\"" + url + "\" class=\"text-xl\">" + title + "</a>" + "<span class=\"text-gray-400\">" + date + "</span>" + "</div>"
fn htmlPostList(sectionTitle: String, postsHtml: String): String = "<div class=\"page w-[80%] flex flex-col gap-8\">" + "<h1 class=\"text-4xl text-center font-bold w-full\">" + sectionTitle + "</h1>" + "<div class=\"flex flex-col gap-4\">" + postsHtml + "</div></div>"
fn htmlWelcome(): String = "<div class=\"flex flex-col gap-4 border border-gray-500 p-8 rounded-sm max-h-150 overflow-y-auto text-wrap break-words\"><span class=\"flex flex-col gap-4\"><span class=\"text-center\">Welcome!</span><span class=\"text-center\">I'm a software builder by trade who's interested in too many things for my own good.</span><span class=\"text-center\">Here's a sample:</span><ul class=\"list-outside ml-8\"><li class=\"list-disc\">Free and Open Source Software (FOSS): Bitcoin, Lightning Network, Payjoin, Linux, GrapheneOS, VPNs, etc.</li><li class=\"list-disc\">History: Ancient Greek, Roman, American Revolution, and more.)</li><li class=\"list-disc\">Biographies: Adams, Hamilton, Washington, Franklin, Oppenheimer, Ramanujan and more</li><li class=\"list-disc\">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.)</li><li class=\"list-disc\">Languages: I'm currently learning Ancient Greek and Latin.</li><li class=\"list-disc\">Fun: Bass guitar</li></ul></span></div>"
fn htmlHomePage(siteTitle: String, snippetsHtml: String): String = "<h1 class=\"unifrakturmaguntia-regular text-6xl text-center w-full\">" + siteTitle + "</h1>" + "<div class=\"font-bold text-4xl italic text-center w-full\">" + "Βράνδων Λουκᾶς" + "</div>" + "<h2 class=\"text-xl text-center flex flex-col\">" + "<span>Bitcoin Lightning Payments @ voltage.cloud</span>" + "<span>Bitcoin Privacy &amp; Scalability @ payjoin.org.</span>" + "<span>Love sovereign software &amp; history.</span>" + "<span>Learning Nix, Elm, Rust, Ancient Greek and Latin.</span>" + "</h2>" + "<div class=\"flex flex-col gap-4 w-full\">" + htmlWelcome() + snippetsHtml + "</div>"
fn htmlSnippetCard(content: String): String = "<div class=\"flex flex-col gap-4 border border-gray-500 p-8 rounded-sm max-h-150 overflow-y-auto text-wrap break-words\">" + "<div><div class=\"markdown\">" + content + "</div></div>" + "</div>"
fn htmlTagPage(tagName: String, postsHtml: String): String = "<div class=\"page w-[80%] flex flex-col gap-8\">" + "<h1 class=\"text-4xl text-center font-bold w-full\">Tag: " + tagName + "</h1>" + "<div class=\"flex flex-col gap-4\">" + postsHtml + "</div></div>"
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<Post>, 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<TagEntry>, 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<String>, allTagEntries: List<TagEntry>, 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<TagEntry>, 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<String>): List<String> 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<String>, item: String): List<String> = {
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<String>): List<String> = 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 = "<div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">" + String.join(snippetCards, "
") + "</div>"
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<Post>, 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 {}