// 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) }