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:
207
packages/ssg/lib.lux
Normal file
207
packages/ssg/lib.lux
Normal 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)
|
||||
}
|
||||
11
packages/ssg/lux.toml
Normal file
11
packages/ssg/lux.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[project]
|
||||
name = "ssg"
|
||||
version = "0.1.0"
|
||||
description = "Static site generator utilities for Lux"
|
||||
authors = ["Brandon Lucas"]
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
frontmatter = { version = "0.1.0", path = "../frontmatter" }
|
||||
markdown = { version = "0.1.0", path = "../markdown" }
|
||||
path = { version = "0.1.0", path = "../path" }
|
||||
Reference in New Issue
Block a user