Add ssg package to registry

Static site generator utilities extracted from blu-site, providing
reusable post/tag types, content reading, date sorting, and file I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 21:41:18 -05:00
parent cbb66fbb73
commit f171c7ca89
4 changed files with 227 additions and 0 deletions

207
packages/ssg/lib.lux Normal file
View File

@@ -0,0 +1,207 @@
// ssg - Static site generator utilities for Lux
//
// Provides reusable types and functions for building static sites:
// - Post/TagEntry types for content modeling
// - File reading and frontmatter parsing
// - Date sorting and formatting
// - Tag collection and filtering
// - File I/O helpers
import markdown
import frontmatter
import path
// A parsed post: date, title, slug, tags (raw string), content (HTML)
pub type Post = | Post(String, String, String, String, String)
// A tag entry: tag name, post title, post date, post slug, section name
pub type TagEntry = | TagEntry(String, String, String, String, String)
// --- Post Accessors ---
pub fn postDate(p: Post): String =
match p {
Post(d, _, _, _, _) => d,
}
pub fn postTitle(p: Post): String =
match p {
Post(_, t, _, _, _) => t,
}
pub fn postSlug(p: Post): String =
match p {
Post(_, _, s, _, _) => s,
}
pub fn postTags(p: Post): String =
match p {
Post(_, _, _, t, _) => t,
}
pub fn postContent(p: Post): String =
match p {
Post(_, _, _, _, c) => c,
}
pub fn postTagList(p: Post): List<String> = {
let raw = postTags(p)
if raw == "" then [] else String.split(raw, " ")
}
// --- TagEntry Accessors ---
pub fn tagName(e: TagEntry): String =
match e {
TagEntry(t, _, _, _, _) => t,
}
pub fn tagTitle(e: TagEntry): String =
match e {
TagEntry(_, t, _, _, _) => t,
}
pub fn tagDate(e: TagEntry): String =
match e {
TagEntry(_, _, d, _, _) => d,
}
pub fn tagSlug(e: TagEntry): String =
match e {
TagEntry(_, _, _, s, _) => s,
}
pub fn tagSection(e: TagEntry): String =
match e {
TagEntry(_, _, _, _, s) => s,
}
// --- Slug / Filename ---
pub fn slugFromFilename(filename: String): String = path.stripExtension(filename)
// --- Date Formatting ---
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
}
}
// --- Sorting ---
fn insertByDate(sorted: List<Post>, item: Post): List<Post> = {
match List.head(sorted) {
None => [item],
Some(first) => if postDate(item) >= postDate(first) then List.concat([item], sorted) else match List.tail(sorted) {
Some(rest) => List.concat([first], insertByDate(rest, item)),
None => [first, item],
},
}
}
fn sortInsert(sorted: List<Post>, item: Post): List<Post> = insertByDate(sorted, item)
pub fn sortByDateDesc(items: List<Post>): List<Post> = List.fold(items, [], sortInsert)
// --- Content Reading ---
pub fn parseFile(filepath: String): Post with {File} = {
let raw = File.read(filepath)
let doc = frontmatter.parse(raw)
let title = frontmatter.title(doc)
let date = frontmatter.date(doc)
let tags = frontmatter.getOrDefault(doc, "tags", "")
let body = frontmatter.body(doc)
let htmlContent = markdown.toHtml(body)
let filename = path.basename(filepath)
let slug = slugFromFilename(filename)
Post(date, title, slug, tags, htmlContent)
}
fn mapParseFiles(dir: String, files: List<String>): List<Post> 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],
}
},
}
pub fn readSection(contentDir: String, section: String): List<Post> 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 []
}
pub fn readAllSections(contentDir: String, sections: List<String>): List<Post> with {File} =
match List.head(sections) {
None => [],
Some(section) => {
let posts = readSection(contentDir, section)
match List.tail(sections) {
Some(rest) => List.concat(posts, readAllSections(contentDir, rest)),
None => posts,
}
},
}
// --- Tags ---
fn collectTagsForPage(section: String, page: Post): List<TagEntry> = {
let tagsRaw = postTags(page)
let tags = if tagsRaw == "" then [] else String.split(tagsRaw, " ")
List.map(tags, fn(tag: String): TagEntry => TagEntry(tag, postTitle(page), postDate(page), postSlug(page), section))
}
pub fn collectTags(section: String, posts: List<Post>): List<TagEntry> = {
let nested = List.map(posts, fn(page: Post): List<TagEntry> => collectTagsForPage(section, page))
List.fold(nested, [], fn(acc: List<TagEntry>, entries: List<TagEntry>): List<TagEntry> => List.concat(acc, entries))
}
pub fn collectAllTags(sections: List<String>, contentDir: String): List<TagEntry> with {File} =
match List.head(sections) {
None => [],
Some(section) => {
let posts = readSection(contentDir, section)
let tags = collectTags(section, posts)
match List.tail(sections) {
Some(rest) => List.concat(tags, collectAllTags(rest, contentDir)),
None => tags,
}
},
}
fn addIfUnique(acc: List<String>, e: TagEntry): List<String> =
if List.any(acc, fn(t: String): Bool => t == tagName(e)) then acc else List.concat(acc, [tagName(e)])
pub fn uniqueTags(entries: List<TagEntry>): List<String> = List.fold(entries, [], addIfUnique)
pub fn entriesForTag(entries: List<TagEntry>, tag: String): List<TagEntry> =
List.filter(entries, fn(e: TagEntry): Bool => tagName(e) == tag)
// --- File I/O ---
pub fn ensureDir(dirPath: String): Unit with {File} = {
if File.exists(dirPath) then () else {
let parent = path.dirname(dirPath)
if parent != "." then if parent != dirPath then ensureDir(parent) else () else ()
File.mkdir(dirPath)
}
}
pub fn writePage(outputPath: String, content: String): Unit with {File} = {
let dir = path.dirname(outputPath)
if dir != "." then ensureDir(dir) else ()
File.write(outputPath, content)
}