Replace ~200 lines of inline SSG logic (types, accessors, parsing, sorting, tags, file I/O) with imports from the new ssg package. Sync updated Lyceum article, images, snippet, and CSS fixes (h3/h4 font-bold, ol list-decimal, blockquote/li spacing) from blu-elmstatic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
16 KiB
Plaintext
283 lines
16 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 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 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 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<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 {}
|