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.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 15:43:05 -05:00
commit 0b0cf2f8a2
95 changed files with 5112 additions and 0 deletions

91
util.lux Normal file
View File

@@ -0,0 +1,91 @@
// Utility functions for the static site generator
// Escape HTML special characters
pub fn escapeHtml(s: String): String =
String.replace(String.replace(String.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
// Extract slug from a filename like "2025-01-29-blog-revamped.md"
// Returns "2025-01-29-blog-revamped"
pub fn slugFromFilename(filename: String): String = {
if String.endsWith(filename, ".md") then
String.substring(filename, 0, String.length(filename) - 3)
else
filename
}
// Format ISO date "2025-01-29" to "2025 Jan 29"
pub 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
}
}
// Recursively walk a directory, returning all file paths
pub fn walkDir(path: String): List<String> with {File} = {
let entries = File.readDir(path);
List.fold(entries, [], fn(acc: List<String>, entry: String): List<String> => {
let fullPath = path + "/" + entry;
if File.isDir(fullPath) then
List.concat(acc, walkDir(fullPath))
else
List.concat(acc, [fullPath])
})
}
// Insertion sort by date string (descending — newest first)
// Each item is (date, item) tuple
pub fn sortByDateDesc(items: List<(String, String, String, String, String)>): List<(String, String, String, String, String)> = {
List.fold(items, [], fn(sorted: List<(String, String, String, String, String)>, item: (String, String, String, String, String)): List<(String, String, String, String, String)> =>
insertByDate(sorted, item)
)
}
fn insertByDate(sorted: List<(String, String, String, String, String)>, item: (String, String, String, String, String)): List<(String, String, String, String, String)> = {
let itemDate = item.0;
match List.head(sorted) {
None => [item],
Some(first) => {
let firstDate = first.0;
// Compare dates as strings — ISO format sorts lexicographically
if itemDate >= firstDate then
List.concat([item], sorted)
else
match List.tail(sorted) {
Some(rest) => List.concat([first], insertByDate(rest, item)),
None => [first, item]
}
}
}
}
// Get just the filename from a path
pub fn basename(path: String): String = {
match String.lastIndexOf(path, "/") {
Some(idx) => String.substring(path, idx + 1, String.length(path)),
None => path
}
}
// Get the directory part of a path
pub fn dirname(path: String): String = {
match String.lastIndexOf(path, "/") {
Some(idx) => String.substring(path, 0, idx),
None => "."
}
}