Files
lux/projects/blu-site/main.lux
Brandon Lucas bac63bab2a feat: add blu-site static site generator and fix language issues
Build a complete static site generator in Lux that faithfully clones
blu.cx (elmstatic). Generates 14 post pages, section indexes, tag pages,
and a home page with snippets grid from markdown content.

Language fixes discovered during development:
- Add \{ and \} escape sequences in string literals (lexer)
- Register String.indexOf and String.lastIndexOf in type checker
- Fix formatter to preserve brace escapes in string literals
- Improve LSP hover to show documentation for let bindings and functions

ISSUES.md documents 15 Lux language limitations found during the project.

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

730 lines
34 KiB
Plaintext

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 BState =
| BState(String, String, Bool, String, String, Bool, String, Bool, String, Bool)
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<String> =
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 escapeHtml(s: String): String = String.replace(String.replace(String.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
fn escapeHtmlCode(s: String): String = String.replace(String.replace(String.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
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<Page>, item: Page): List<Page> = insertByDate(sorted, item)
fn sortByDateDesc(items: List<Page>): List<Page> = List.fold(items, [], sortInsert)
fn insertByDate(sorted: List<Page>, item: Page): List<Page> = {
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 findClosingFrom(text: String, i: Int, len: Int, delim: String, delimLen: Int): Option<Int> = if i + delimLen > len then None else if String.substring(text, i, i + delimLen) == delim then Some(i) else findClosingFrom(text, i + 1, len, delim, delimLen)
fn findClosing(text: String, start: Int, len: Int, delim: String): Option<Int> = findClosingFrom(text, start, len, delim, String.length(delim))
fn isLetterOrSlash(ch: String): Bool = ch == "/" || ch == "!" || ch == "a" || ch == "b" || ch == "c" || ch == "d" || ch == "e" || ch == "f" || ch == "g" || ch == "h" || ch == "i" || ch == "j" || ch == "k" || ch == "l" || ch == "m" || ch == "n" || ch == "o" || ch == "p" || ch == "q" || ch == "r" || ch == "s" || ch == "t" || ch == "u" || ch == "v" || ch == "w" || ch == "x" || ch == "y" || ch == "z" || ch == "A" || ch == "B" || ch == "C" || ch == "D" || ch == "E" || ch == "F" || ch == "G" || ch == "H" || ch == "I" || ch == "J" || ch == "K" || ch == "L" || ch == "M" || ch == "N" || ch == "O" || ch == "P" || ch == "Q" || ch == "R" || ch == "S" || ch == "T" || ch == "U" || ch == "V" || ch == "W" || ch == "X" || ch == "Y" || ch == "Z"
fn processInlineFrom(text: String, i: Int, len: Int, acc: String): String = {
if i >= len then acc else {
let ch = String.substring(text, i, i + 1)
if ch == "*" then if i + 1 < len then if String.substring(text, i + 1, i + 2) == "*" then match findClosing(text, i + 2, len, "**") {
Some(end) => processInlineFrom(text, end + 2, len, acc + "<strong>" + processInline(String.substring(text, i + 2, end)) + "</strong>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
} else match findClosing(text, i + 1, len, "*") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<em>" + processInline(String.substring(text, i + 1, end)) + "</em>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
} else acc + ch else if ch == "_" then if i + 1 < len then match findClosing(text, i + 1, len, "_") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<em>" + processInline(String.substring(text, i + 1, end)) + "</em>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
} else acc + ch else if ch == "`" then match findClosing(text, i + 1, len, "`") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<code>" + escapeHtml(String.substring(text, i + 1, end)) + "</code>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
} else if ch == "!" then if i + 1 < len then if String.substring(text, i + 1, i + 2) == "[" then match findClosing(text, i + 2, len, "](") {
Some(end) => match findClosing(text, end + 2, len, ")") {
Some(urlEnd) => {
let imgTag = acc + "<img src=\"" + String.substring(text, end + 2, urlEnd) + "\" alt=\"" + String.substring(text, i + 2, end) + "\">"
processInlineFrom(text, urlEnd + 1, len, imgTag)
},
None => processInlineFrom(text, i + 1, len, acc + ch),
},
None => processInlineFrom(text, i + 1, len, acc + ch),
} else processInlineFrom(text, i + 1, len, acc + ch) else acc + ch else if ch == "[" then match findClosing(text, i + 1, len, "](") {
Some(end) => match findClosing(text, end + 2, len, ")") {
Some(urlEnd) => {
let linkTag = acc + "<a href=\"" + String.substring(text, end + 2, urlEnd) + "\">" + processInline(String.substring(text, i + 1, end)) + "</a>"
processInlineFrom(text, urlEnd + 1, len, linkTag)
},
None => processInlineFrom(text, i + 1, len, acc + ch),
},
None => processInlineFrom(text, i + 1, len, acc + ch),
} else if ch == "<" then if i + 1 < len then if isLetterOrSlash(String.substring(text, i + 1, i + 2)) then match findClosing(text, i + 1, len, ">") {
Some(end) => processInlineFrom(text, end + 1, len, acc + String.substring(text, i, end + 1)),
None => processInlineFrom(text, i + 1, len, acc + "&lt;"),
} else processInlineFrom(text, i + 1, len, acc + "&lt;") else acc + "&lt;" else if ch == ">" then processInlineFrom(text, i + 1, len, acc + "&gt;") else processInlineFrom(text, i + 1, len, acc + ch)
}
}
fn processInline(text: String): String = processInlineFrom(text, 0, String.length(text), "")
fn bsHtml(s: BState): String =
match s {
BState(h, _, _, _, _, _, _, _, _, _) => h,
}
fn bsPara(s: BState): String =
match s {
BState(_, p, _, _, _, _, _, _, _, _) => p,
}
fn bsInCode(s: BState): Bool =
match s {
BState(_, _, c, _, _, _, _, _, _, _) => c,
}
fn bsCodeLang(s: BState): String =
match s {
BState(_, _, _, l, _, _, _, _, _, _) => l,
}
fn bsCodeLines(s: BState): String =
match s {
BState(_, _, _, _, cl, _, _, _, _, _) => cl,
}
fn bsInBq(s: BState): Bool =
match s {
BState(_, _, _, _, _, bq, _, _, _, _) => bq,
}
fn bsBqLines(s: BState): String =
match s {
BState(_, _, _, _, _, _, bl, _, _, _) => bl,
}
fn bsInList(s: BState): Bool =
match s {
BState(_, _, _, _, _, _, _, il, _, _) => il,
}
fn bsListItems(s: BState): String =
match s {
BState(_, _, _, _, _, _, _, _, li, _) => li,
}
fn bsOrdered(s: BState): Bool =
match s {
BState(_, _, _, _, _, _, _, _, _, o) => o,
}
fn flushPara(html: String, para: String): String =
if para == "" then html else html + "<p>" + processInline(String.trim(para)) + "</p>
"
fn flushBq(html: String, bqLines: String): String =
if bqLines == "" then html else html + "<blockquote>
" + convertMd(bqLines) + "</blockquote>
"
fn flushList(html: String, listItems: String, ordered: Bool): String =
if listItems == "" then html else {
let tag = if ordered then "ol" else "ul"
html + "<" + tag + ">
" + listItems + "</" + tag + ">
"
}
fn isOrderedListItem(line: String): Bool = {
let trimmed = String.trim(line)
if String.length(trimmed) < 3 then false else {
let first = String.substring(trimmed, 0, 1)
let isDigit = first == "0" || first == "1" || first == "2" || first == "3" || first == "4" || first == "5" || first == "6" || first == "7" || first == "8" || first == "9"
if isDigit then String.contains(trimmed, ". ") else false
}
}
fn processBlockLine(html: String, para: String, inList: Bool, listItems: String, ordered: Bool, line: String): BState = {
let trimmed = String.trim(line)
if trimmed == "" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3, "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "#### ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h4>" + processInline(String.substring(trimmed, 5, String.length(trimmed))) + "</h4>
", "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "### ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h3>" + processInline(String.substring(trimmed, 4, String.length(trimmed))) + "</h3>
", "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "## ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h2>" + processInline(String.substring(trimmed, 3, String.length(trimmed))) + "</h2>
", "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "# ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h1>" + processInline(String.substring(trimmed, 2, String.length(trimmed))) + "</h1>
", "", false, "", "", false, "", false, "", false)
} else if trimmed == "---" || trimmed == "***" || trimmed == "___" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<hr>
", "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "<") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + trimmed + "
", "", false, "", "", false, "", false, "", false)
} else if String.startsWith(trimmed, "![") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<p>" + processInline(trimmed) + "</p>
", "", false, "", "", false, "", false, "", false)
} else if inList then {
let h2 = flushList(html, listItems, ordered)
BState(h2, para + trimmed + " ", false, "", "", false, "", false, "", false)
} else BState(html, para + trimmed + " ", false, "", "", false, "", false, "", false)
}
fn blockFoldLine(state: BState, line: String): BState = {
let html = bsHtml(state)
let para = bsPara(state)
let inCode = bsInCode(state)
let codeLang = bsCodeLang(state)
let codeLines = bsCodeLines(state)
let inBq = bsInBq(state)
let bqLines = bsBqLines(state)
let inList = bsInList(state)
let listItems = bsListItems(state)
let ordered = bsOrdered(state)
if inCode then if String.startsWith(line, "```") then {
let codeHtml = if codeLang == "" then "<pre><code>" + codeLines + "</code></pre>
" else "<pre><code class=\"language-" + codeLang + "\">" + codeLines + "</code></pre>
"
BState(html + codeHtml, "", false, "", "", false, "", false, "", false)
} else BState(html, para, true, codeLang, codeLines + escapeHtmlCode(line) + "
", false, "", false, "", false) else if String.startsWith(line, "```") then {
let h2 = flushPara(html, para)
let h3 = flushBq(h2, bqLines)
let h4 = flushList(h3, listItems, ordered)
let lang = String.trim(String.substring(line, 3, String.length(line)))
BState(h4, "", true, lang, "", false, "", false, "", false)
} else if String.startsWith(line, "> ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
let bqContent = String.substring(line, 2, String.length(line))
BState(h3, "", false, "", "", true, bqLines + bqContent + "
", false, "", false)
} else if String.trim(line) == ">" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3, "", false, "", "", true, bqLines + "
", false, "", false)
} else if inBq then {
let h2 = flushBq(html, bqLines)
processBlockLine(h2, para, inList, listItems, ordered, line)
} else if String.startsWith(line, "- ") then {
let h2 = flushPara(html, para)
let item = String.substring(line, 2, String.length(line))
BState(h2, "", false, "", "", false, "", true, listItems + "<li>" + processInline(item) + "</li>
", false)
} else if isOrderedListItem(line) then {
let h2 = flushPara(html, para)
let dotIdx = match String.indexOf(line, ". ") {
Some(idx) => idx,
None => 0,
}
let item = String.substring(line, dotIdx + 2, String.length(line))
BState(h2, "", false, "", "", false, "", true, listItems + "<li>" + processInline(item) + "</li>
", true)
} else processBlockLine(html, para, inList, listItems, ordered, line)
}
fn parseBlocks(text: String): String = {
let lines = String.lines(text)
let init = BState("", "", false, "", "", false, "", false, "", false)
let final = List.fold(lines, init, blockFoldLine)
let h = flushPara(bsHtml(final), bsPara(final))
let h2 = flushBq(h, bsBqLines(final))
flushList(h2, bsListItems(final), bsOrdered(final))
}
fn convertMd(markdown: String): String = parseBlocks(markdown)
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 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\">" + 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 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<String>): List<Page> 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<Page> 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<Page>, 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<TagEntry> = {
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<Page>): List<TagEntry> = {
let nested = List.map(pages, fn(page: Page): List<TagEntry> => collectTagsForPage(section, page))
List.fold(nested, [], fn(acc: List<TagEntry>, entries: List<TagEntry>): List<TagEntry> => List.concat(acc, entries))
}
fn addIfUnique(acc: List<String>, e: TagEntry): List<String> = if List.any(acc, fn(t: String): Bool => t == teTag(e)) then acc else List.concat(acc, [teTag(e)])
fn getUniqueTags(entries: List<TagEntry>): List<String> = 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<TagEntry>, 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<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, 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<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 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 = "<div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">" + String.join(restCards, "
") + "</div>"
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<Page>, 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("projects/blu-site/config.json")
let siteTitle = cfgTitle(cfg)
let siteDesc = cfgDesc(cfg)
let contentDir = "projects/blu-site/" + cfgContentDir(cfg)
let outputDir = "projects/blu-site/" + cfgOutputDir(cfg)
let staticDir = "projects/blu-site/" + 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 {}