diff --git a/README.md b/README.md index b8c93df..834d40a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ lux pkg add locallib --path ../mylib | [xml](./packages/xml/) | 0.1.0 | XML builder | | [rss](./packages/rss/) | 0.1.0 | RSS 2.0 feed generator | | [web](./packages/web/) | 0.1.0 | Full-stack web framework | +| [ssg](./packages/ssg/) | 0.1.0 | Static site generator utilities | ## Publishing Packages diff --git a/index.json b/index.json index dafcabc..d73e532 100644 --- a/index.json +++ b/index.json @@ -71,6 +71,14 @@ "versions": [ {"version": "0.1.0", "checksum": "", "published_at": "2026-02-24", "yanked": false} ] + }, + { + "name": "ssg", + "description": "Static site generator utilities for Lux", + "latest_version": "0.1.0", + "versions": [ + {"version": "0.1.0", "checksum": "", "published_at": "2026-02-24", "yanked": false} + ] } ] } diff --git a/packages/ssg/lib.lux b/packages/ssg/lib.lux new file mode 100644 index 0000000..788fbc2 --- /dev/null +++ b/packages/ssg/lib.lux @@ -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 = { + 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, item: Post): List = { + 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, item: Post): List = insertByDate(sorted, item) + +pub fn sortByDateDesc(items: List): List = 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): List 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 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): List 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 = { + 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): List = { + let nested = List.map(posts, fn(page: Post): List => collectTagsForPage(section, page)) + List.fold(nested, [], fn(acc: List, entries: List): List => List.concat(acc, entries)) +} + +pub fn collectAllTags(sections: List, contentDir: String): List 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, e: TagEntry): List = + if List.any(acc, fn(t: String): Bool => t == tagName(e)) then acc else List.concat(acc, [tagName(e)]) + +pub fn uniqueTags(entries: List): List = List.fold(entries, [], addIfUnique) + +pub fn entriesForTag(entries: List, tag: String): List = + 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) +} diff --git a/packages/ssg/lux.toml b/packages/ssg/lux.toml new file mode 100644 index 0000000..6a6a6dc --- /dev/null +++ b/packages/ssg/lux.toml @@ -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" }