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. ISSUES.md documents 15 Lux language limitations found during the project. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
730 lines
34 KiB
Plaintext
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, "&", "&"), "<", "<"), ">", ">")
|
|
|
|
fn escapeHtmlCode(s: String): String = String.replace(String.replace(String.replace(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<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 + "<"),
|
|
} else processInlineFrom(text, i + 1, len, acc + "<") else acc + "<" else if ch == ">" then processInlineFrom(text, i + 1, len, acc + ">") 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 & Scalability @ payjoin.org.</span>" + "<span>Love sovereign software & 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 {}
|