import markdown type SiteConfig = | SiteConfig(String, String, String, String, String, String, String) type Frontmatter = | Frontmatter(String, String, String, String) type ParseResult = | ParseResult(Frontmatter, String) type Page = | Page(String, String, String, String, String) type FMState = | FMState(Bool, Bool, String, String, String, String, String) type TagEntry = | TagEntry(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 fmFoldLine(acc: FMState, line: String): FMState = match acc { FMState(inFront, pastFront, title, date, desc, tags, body) => if pastFront then FMState(inFront, pastFront, title, date, desc, tags, body + line + " ") else if String.trim(line) == "---" then if inFront then FMState(false, true, title, date, desc, tags, body) else FMState(true, false, title, date, desc, tags, body) else if inFront then match String.indexOf(line, ": ") { Some(idx) => { let key = String.trim(String.substring(line, 0, idx)) let rawVal = String.trim(String.substring(line, idx + 2, String.length(line))) let val = if String.startsWith(rawVal, "\"") then String.substring(rawVal, 1, String.length(rawVal) - 1) else rawVal if key == "title" then FMState(inFront, pastFront, val, date, desc, tags, body) else if key == "date" then FMState(inFront, pastFront, title, val, desc, tags, body) else if key == "description" then FMState(inFront, pastFront, title, date, val, tags, body) else if key == "tags" then FMState(inFront, pastFront, title, date, desc, val, body) else acc }, None => acc, } else acc, } fn parseFrontmatter(content: String): ParseResult = { let lines = String.lines(content) let init = FMState(false, false, "", "", "", "", "") let result = List.fold(lines, init, fmFoldLine) match result { FMState(_, _, title, date, desc, tags, body) => ParseResult(Frontmatter(title, date, desc, tags), body), } } fn fmTitle(f: Frontmatter): String = match f { Frontmatter(t, _, _, _) => t, } fn fmDate(f: Frontmatter): String = match f { Frontmatter(_, d, _, _) => d, } fn fmTagsRaw(f: Frontmatter): String = match f { Frontmatter(_, _, _, t) => t, } fn fmTags(f: Frontmatter): List = match f { Frontmatter(_, _, _, t) => if t == "" then [] else String.split(t, " "), } fn pgDate(p: Page): String = match p { Page(d, _, _, _, _) => d, } fn pgTitle(p: Page): String = match p { Page(_, t, _, _, _) => t, } fn pgSlug(p: Page): String = match p { Page(_, _, s, _, _) => s, } fn pgTags(p: Page): String = match p { Page(_, _, _, t, _) => t, } fn pgContent(p: Page): String = match p { Page(_, _, _, _, c) => c, } fn teTag(e: TagEntry): String = match e { TagEntry(t, _, _, _, _) => t, } fn teTitle(e: TagEntry): String = match e { TagEntry(_, t, _, _, _) => t, } fn teDate(e: TagEntry): String = match e { TagEntry(_, _, d, _, _) => d, } fn teSlug(e: TagEntry): String = match e { TagEntry(_, _, _, s, _) => s, } fn teSection(e: TagEntry): String = match e { TagEntry(_, _, _, _, s) => s, } fn slugFromFilename(filename: String): String = if String.endsWith(filename, ".md") then String.substring(filename, 0, String.length(filename) - 3) else filename fn formatDate(isoDate: String): String = { if String.length(isoDate) < 10 then isoDate else { let year = String.substring(isoDate, 0, 4) let month = String.substring(isoDate, 5, 7) let day = String.substring(isoDate, 8, 10) let monthName = if month == "01" then "Jan" else if month == "02" then "Feb" else if month == "03" then "Mar" else if month == "04" then "Apr" else if month == "05" then "May" else if month == "06" then "Jun" else if month == "07" then "Jul" else if month == "08" then "Aug" else if month == "09" then "Sep" else if month == "10" then "Oct" else if month == "11" then "Nov" else "Dec" year + " " + monthName + " " + day } } fn basename(path: String): String = match String.lastIndexOf(path, "/") { Some(idx) => String.substring(path, idx + 1, String.length(path)), None => path, } fn dirname(path: String): String = match String.lastIndexOf(path, "/") { Some(idx) => String.substring(path, 0, idx), None => ".", } fn sortInsert(sorted: List, item: Page): List = insertByDate(sorted, item) fn sortByDateDesc(items: List): List = List.fold(items, [], sortInsert) fn insertByDate(sorted: List, item: Page): List = { match List.head(sorted) { None => [item], Some(first) => if pgDate(item) >= pgDate(first) then List.concat([item], sorted) else match List.tail(sorted) { Some(rest) => List.concat([first], insertByDate(rest, item)), None => [first, item], }, } } fn convertMd(text: String): String = markdown.toHtml(text) 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 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." + "

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

Tag: " + tagName + "

" + "
" + postsHtml + "
" fn parseFile(path: String): Page with {File} = { let raw = File.read(path) let parsed = parseFrontmatter(raw) match parsed { ParseResult(front, body) => { let title = fmTitle(front) let date = fmDate(front) let tags = fmTagsRaw(front) let htmlContent = convertMd(body) let filename = basename(path) let slug = slugFromFilename(filename) Page(date, title, slug, tags, htmlContent) }, } } fn mapParseFiles(dir: String, files: List): List with {File} = match List.head(files) { None => [], Some(filename) => { let page = parseFile(dir + "/" + filename) match List.tail(files) { Some(rest) => List.concat([page], mapParseFiles(dir, rest)), None => [page], } }, } fn readSection(contentDir: String, section: String): List with {File} = { let dir = contentDir + "/" + section if File.exists(dir) then { let entries = File.readDir(dir) let mdFiles = List.filter(entries, fn(e: String): Bool => String.endsWith(e, ".md")) mapParseFiles(dir, mdFiles) } else [] } fn ensureDir(path: String): Unit with {File} = { if File.exists(path) then () else { let parent = dirname(path) if parent != "." then if parent != path then ensureDir(parent) else () else () File.mkdir(path) } } fn writePostPage(outputDir: String, section: String, page: Page, siteTitle: String, siteDesc: String): Unit with {File} = { let slug = pgSlug(page) let title = pgTitle(page) let date = pgDate(page) let tagsRaw = pgTags(page) let content = pgContent(page) let tags = if tagsRaw == "" then [] else String.split(tagsRaw, " ") let tagsHtml = renderTagLinks(tags) let formattedDate = 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 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 = sortByDateDesc(pages) let postEntries = List.map(sorted, fn(page: Page): String => htmlPostEntry(pgTitle(page), formatDate(pgDate(page)), "/posts/" + section + "/" + pgSlug(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 ensureDir(dir) File.write(dir + "/index.html", html) } fn collectTagsForPage(section: String, page: Page): List = { let tagsRaw = pgTags(page) let tags = if tagsRaw == "" then [] else String.split(tagsRaw, " ") List.map(tags, fn(tag: String): TagEntry => TagEntry(tag, pgTitle(page), pgDate(page), pgSlug(page), section)) } fn collectTags(section: String, pages: List): List = { let nested = List.map(pages, fn(page: Page): List => collectTagsForPage(section, page)) List.fold(nested, [], fn(acc: List, entries: List): List => List.concat(acc, entries)) } fn addIfUnique(acc: List, e: TagEntry): List = if List.any(acc, fn(t: String): Bool => t == teTag(e)) then acc else List.concat(acc, [teTag(e)]) fn getUniqueTags(entries: List): List = List.fold(entries, [], addIfUnique) fn tagEntryToHtml(e: TagEntry): String = htmlPostEntry(teTitle(e), formatDate(teDate(e)), "/posts/" + teSection(e) + "/" + teSlug(e)) fn writeOneTagPage(outputDir: String, tag: String, allTagEntries: List, siteTitle: String, siteDesc: String): Unit with {File} = { let entries = List.filter(allTagEntries, fn(e: TagEntry): Bool => teTag(e) == 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 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, Console} = { let uniqueTags = getUniqueTags(allTagEntries) writeTagPagesLoop(outputDir, uniqueTags, allTagEntries, siteTitle, siteDesc) } fn renderSnippetFile(snippetDir: String, filename: String): String with {File} = { let raw = File.read(snippetDir + "/" + filename) let parsed = parseFrontmatter(raw) match parsed { ParseResult(_, body) => htmlSnippetCard(convertMd(body)), } } 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 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) List.filter(entries, fn(e: String): Bool => String.endsWith(e, ".md")) } else [] let snippetCards = renderSnippets(snippetDir, snippetEntries) let firstCard = match List.head(snippetCards) { Some(c) => c, None => "", } let restCards = match List.tail(snippetCards) { Some(rest) => rest, None => [], } let gridHtml = "
" + String.join(restCards, " ") + "
" let snippetsHtml = firstCard + " " + gridHtml let body = htmlHomePage(siteTitle, snippetsHtml) 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("") ensureDir(outputDir) Console.print("Reading content...") let articles = readSection(contentDir, "articles") let blogPosts = readSection(contentDir, "blog") let journalPosts = 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 = collectTags("articles", articles) let blogTags = collectTags("blog", blogPosts) let journalTags = 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 {}