Add frontmatter, markdown, path, xml, rss, and web packages

Sync local packages into the registry repo and update index.json
and README.md to include all 9 packages.
This commit is contained in:
2026-02-24 21:04:20 -05:00
parent c5a2276f6e
commit cbb66fbb73
42 changed files with 3844 additions and 0 deletions

3
packages/frontmatter/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
_site/
.lux_packages/
*.bak

View File

@@ -0,0 +1,168 @@
// frontmatter - YAML-like frontmatter parser for Lux
//
// Parses documents with --- delimited frontmatter:
// ---
// title: My Post
// date: 2025-01-29
// tags: web blog
// ---
// Body content here...
// A single key-value pair from the frontmatter
type Entry =
| Entry(String, String)
// Parse result: list of key-value entries and the body text
type Document =
| Document(List<Entry>, String)
// Internal parser state
type PState =
| PState(Bool, Bool, List<Entry>, String)
fn psInFront(s: PState): Bool =
match s {
PState(f, _, _, _) => f,
}
fn psPastFront(s: PState): Bool =
match s {
PState(_, p, _, _) => p,
}
fn psEntries(s: PState): List<Entry> =
match s {
PState(_, _, e, _) => e,
}
fn psBody(s: PState): String =
match s {
PState(_, _, _, b) => b,
}
// Strip surrounding quotes from a value if present
fn stripQuotes(s: String): String =
if String.length(s) >= 2 then
if String.startsWith(s, "\"") then
if String.endsWith(s, "\"") then
String.substring(s, 1, String.length(s) - 1)
else s
else if String.startsWith(s, "'") then
if String.endsWith(s, "'") then
String.substring(s, 1, String.length(s) - 1)
else s
else s
else s
// Parse a single line within the frontmatter block
fn parseFrontLine(entries: List<Entry>, line: String): List<Entry> =
match String.indexOf(line, ": ") {
Some(idx) => {
let key = String.trim(String.substring(line, 0, idx))
let rawVal = String.trim(String.substring(line, idx + 2, String.length(line)))
let val = stripQuotes(rawVal)
List.concat(entries, [Entry(key, val)])
},
None => {
// Handle "key:" with no value (treat as empty string)
let trimmed = String.trim(line)
if String.endsWith(trimmed, ":") then {
let key = String.substring(trimmed, 0, String.length(trimmed) - 1)
List.concat(entries, [Entry(key, "")])
} else
entries
},
}
// Fold function that processes one line at a time
fn foldLine(acc: PState, line: String): PState = {
let inFront = psInFront(acc)
let pastFront = psPastFront(acc)
let entries = psEntries(acc)
let body = psBody(acc)
if pastFront then
PState(inFront, pastFront, entries, body + line + "\n")
else if String.trim(line) == "---" then
if inFront then
PState(false, true, entries, body)
else
PState(true, false, entries, body)
else if inFront then
PState(inFront, pastFront, parseFrontLine(entries, line), body)
else
acc
}
// Parse a document string into frontmatter entries and body content.
//
// Returns a Document with an empty entry list if no frontmatter is found.
pub fn parse(content: String): Document = {
let lines = String.lines(content)
let init = PState(false, false, [], "")
let result = List.fold(lines, init, foldLine)
Document(psEntries(result), psBody(result))
}
// Get the list of key-value entries from a Document
pub fn entries(doc: Document): List<Entry> =
match doc {
Document(e, _) => e,
}
// Get the body text from a Document
pub fn body(doc: Document): String =
match doc {
Document(_, b) => b,
}
// Look up a value by key. Returns the first matching entry.
pub fn get(doc: Document, key: String): Option<String> = {
let es = entries(doc)
getFromEntries(es, key)
}
fn getFromEntries(es: List<Entry>, key: String): Option<String> =
match List.head(es) {
Some(entry) => match entry {
Entry(k, v) => if k == key then Some(v)
else match List.tail(es) {
Some(rest) => getFromEntries(rest, key),
None => None,
},
},
None => None,
}
// Get a value by key, returning a default if not found
pub fn getOrDefault(doc: Document, key: String, default: String): String =
match get(doc, key) {
Some(v) => v,
None => default,
}
// Get the entry key
pub fn entryKey(e: Entry): String =
match e {
Entry(k, _) => k,
}
// Get the entry value
pub fn entryValue(e: Entry): String =
match e {
Entry(_, v) => v,
}
// Check if a document has frontmatter (at least one entry)
pub fn hasFrontmatter(doc: Document): Bool =
List.length(entries(doc)) > 0
// Split a tags string into a list of tags (space-separated)
pub fn parseTags(tagsStr: String): List<String> =
if tagsStr == "" then []
else String.split(tagsStr, " ")
// Convenience: parse and get common blog fields
pub fn title(doc: Document): String = getOrDefault(doc, "title", "")
pub fn date(doc: Document): String = getOrDefault(doc, "date", "")
pub fn description(doc: Document): String = getOrDefault(doc, "description", "")
pub fn tags(doc: Document): List<String> = parseTags(getOrDefault(doc, "tags", ""))

View File

@@ -0,0 +1,8 @@
[project]
name = "frontmatter"
version = "0.1.0"
description = "YAML-like frontmatter parser for Lux"
authors = ["Brandon Lucas"]
license = "MIT"
[dependencies]

View File

@@ -0,0 +1,90 @@
import lib
fn test_basic_parsing(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello World\ndate: 2025-01-29\n---\nBody here.")
Test.assertEqualMsg("Hello World", lib.title(doc), "basic title")
Test.assertEqualMsg("2025-01-29", lib.date(doc), "basic date")
Test.assert(String.startsWith(lib.body(doc), "Body here."), "body starts with expected")
}
fn test_quoted_values(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: \"Quoted Title\"\n---\nBody")
Test.assertEqualMsg("Quoted Title", lib.title(doc), "double-quoted values")
let doc2 = lib.parse("---\ntitle: 'Single Quoted'\n---\nBody")
Test.assertEqualMsg("Single Quoted", lib.title(doc2), "single-quoted values")
}
fn test_tags(): Unit with {Test} = {
let doc = lib.parse("---\ntags: web blog lux\n---\nBody")
let tagList = lib.tags(doc)
Test.assertEqualMsg(3, List.length(tagList), "tag count")
let doc2 = lib.parse("---\ntitle: No Tags\n---\nBody")
Test.assertEqualMsg(0, List.length(lib.tags(doc2)), "empty tags")
let singleTags = lib.parseTags("solo")
Test.assertEqualMsg(1, List.length(singleTags), "parseTags single")
let noTags = lib.parseTags("")
Test.assertEqualMsg(0, List.length(noTags), "parseTags empty")
}
fn test_description(): Unit with {Test} = {
let doc = lib.parse("---\ndescription: A great post about things\n---\nBody")
Test.assertEqualMsg("A great post about things", lib.description(doc), "description field")
}
fn test_no_frontmatter(): Unit with {Test} = {
let doc = lib.parse("Just some text\nwith no frontmatter")
Test.assertEqualMsg(false, lib.hasFrontmatter(doc), "no frontmatter flag")
Test.assertEqualMsg("", lib.title(doc), "no frontmatter title")
}
fn test_get(): Unit with {Test} = {
let doc = lib.parse("---\nauthor: Brandon\nlicense: MIT\n---\nBody")
Test.assertEqualMsg("Brandon", lib.getOrDefault(doc, "author", ""), "get author")
Test.assertEqualMsg("MIT", lib.getOrDefault(doc, "license", ""), "get license")
Test.assert(match lib.get(doc, "missing") { Some(_) => false, None => true }, "get missing returns None")
}
fn test_get_or_default(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Present\n---\nBody")
Test.assertEqualMsg("Present", lib.getOrDefault(doc, "title", "default"), "getOrDefault present")
Test.assertEqualMsg("fallback", lib.getOrDefault(doc, "missing", "fallback"), "getOrDefault missing")
}
fn test_multiline_body(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Post\n---\nLine 1\nLine 2\nLine 3")
Test.assert(String.startsWith(lib.body(doc), "Line 1\n"), "multiline body starts correctly")
}
fn test_empty_value(): Unit with {Test} = {
let doc = lib.parse("---\ntitle:\n---\nBody")
Test.assertEqualMsg("", lib.title(doc), "empty value key")
}
fn test_entries(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: T\ndate: D\ndescription: Desc\ntags: a b\n---\nBody")
Test.assertEqualMsg(4, List.length(lib.entries(doc)), "four entries")
}
fn test_entry_accessors(): Unit with {Test} = {
let doc = lib.parse("---\nfoo: bar\n---\nBody")
let es = lib.entries(doc)
match List.head(es) {
Some(e) => {
Test.assertEqualMsg("foo", lib.entryKey(e), "entry key")
Test.assertEqualMsg("bar", lib.entryValue(e), "entry value")
},
None => Test.assert(false, "should have entry"),
}
}
fn test_has_frontmatter(): Unit with {Test} = {
let docWith = lib.parse("---\ntitle: Yes\n---\nBody")
let docWithout = lib.parse("No frontmatter here")
Test.assertEqualMsg(true, lib.hasFrontmatter(docWith), "has frontmatter true")
Test.assertEqualMsg(false, lib.hasFrontmatter(docWithout), "has frontmatter false")
}
fn test_value_with_colon(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello: World\n---\nBody")
Test.assertEqualMsg("Hello: World", lib.title(doc), "value with colon")
}

View File

@@ -0,0 +1,87 @@
import lib
// Integration tests: realistic document processing workflows
// Parse a complete blog post and extract all metadata
fn test_complete_blog_post(): Unit with {Test} = {
let content = "---\ntitle: My First Post\ndate: 2025-01-29\ndescription: A great introduction\ntags: web lux programming\nauthor: Brandon\n---\nThis is the body of my post.\n\nIt has multiple paragraphs."
let doc = lib.parse(content)
Test.assertEqualMsg(true, lib.hasFrontmatter(doc), "has frontmatter")
Test.assertEqualMsg("My First Post", lib.title(doc), "title")
Test.assertEqualMsg("2025-01-29", lib.date(doc), "date")
Test.assertEqualMsg("A great introduction", lib.description(doc), "description")
Test.assertEqualMsg(3, List.length(lib.tags(doc)), "tag count")
Test.assertEqualMsg("Brandon", lib.getOrDefault(doc, "author", ""), "author field")
Test.assert(String.startsWith(lib.body(doc), "This is the body"), "body starts correctly")
}
// Parse multiple documents and compare metadata
fn test_parse_multiple_documents(): Unit with {Test} = {
let doc1 = lib.parse("---\ntitle: Post A\ndate: 2025-01-01\n---\nBody A")
let doc2 = lib.parse("---\ntitle: Post B\ndate: 2025-02-01\n---\nBody B")
let doc3 = lib.parse("---\ntitle: Post C\ndate: 2025-03-01\n---\nBody C")
Test.assertEqualMsg("Post A", lib.title(doc1), "doc1 title")
Test.assertEqualMsg("Post B", lib.title(doc2), "doc2 title")
Test.assertEqualMsg("Post C", lib.title(doc3), "doc3 title")
Test.assertEqualMsg(true, lib.hasFrontmatter(doc1), "doc1 has frontmatter")
Test.assertEqualMsg(true, lib.hasFrontmatter(doc2), "doc2 has frontmatter")
}
// Simulate a static site generator reading a page
fn test_ssg_page_workflow(): Unit with {Test} = {
let page = "---\ntitle: About Me\ndescription: Learn about the author\nlayout: page\n---\n# About\n\nI write software."
let doc = lib.parse(page)
let pageTitle = lib.title(doc)
let pageDesc = lib.description(doc)
let layout = lib.getOrDefault(doc, "layout", "default")
let body = lib.body(doc)
Test.assertEqualMsg("About Me", pageTitle, "page title")
Test.assertEqualMsg("Learn about the author", pageDesc, "page description")
Test.assertEqualMsg("page", layout, "layout field")
Test.assert(String.startsWith(body, "# About"), "body starts with heading")
}
// Extract custom fields beyond the standard ones
fn test_custom_fields(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Post\nslug: custom-slug\ndraft: true\nweight: 10\n---\nContent")
Test.assertEqualMsg("custom-slug", lib.getOrDefault(doc, "slug", ""), "custom slug field")
Test.assertEqualMsg("true", lib.getOrDefault(doc, "draft", "false"), "custom draft field")
Test.assertEqualMsg("10", lib.getOrDefault(doc, "weight", "0"), "custom weight field")
}
// Iterate over entries and collect all keys
fn test_iterate_entries(): Unit with {Test} = {
let doc = lib.parse("---\na: 1\nb: 2\nc: 3\n---\nBody")
let es = lib.entries(doc)
Test.assertEqualMsg(3, List.length(es), "three entries")
match List.head(es) {
Some(e) => Test.assertEqualMsg("a", lib.entryKey(e), "first entry key"),
None => Test.assert(false, "should have entries"),
}
}
// Document with frontmatter but no body
fn test_frontmatter_only_document(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Metadata Only\ntags: meta\n---")
Test.assertEqualMsg("Metadata Only", lib.title(doc), "title from frontmatter-only doc")
Test.assertEqualMsg(1, List.length(lib.tags(doc)), "one tag")
Test.assertEqualMsg("", lib.body(doc), "empty body")
}
// Document with all convenience fields
fn test_all_convenience_fields(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Full Post\ndate: 2025-06-15\ndescription: Complete metadata\ntags: a b c d\n---\nBody text")
Test.assertEqualMsg("Full Post", lib.title(doc), "title convenience")
Test.assertEqualMsg("2025-06-15", lib.date(doc), "date convenience")
Test.assertEqualMsg("Complete metadata", lib.description(doc), "description convenience")
Test.assertEqualMsg(4, List.length(lib.tags(doc)), "tags convenience")
}
// Fallback behavior when fields are missing
fn test_missing_field_defaults(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Minimal\n---\nBody")
Test.assertEqualMsg("Minimal", lib.title(doc), "title present")
Test.assertEqualMsg("", lib.date(doc), "date defaults to empty")
Test.assertEqualMsg("", lib.description(doc), "description defaults to empty")
Test.assertEqualMsg(0, List.length(lib.tags(doc)), "tags defaults to empty")
}

View File

@@ -0,0 +1,79 @@
import lib
// Snapshot tests: verify complete parse output against golden values
// Snapshot: full blog post parse
fn test_snapshot_blog_post(): Unit with {Test} = {
let input = "---\ntitle: Understanding Algebraic Effects\ndate: 2025-03-15\ndescription: A deep dive into effect systems\ntags: lux effects programming\nauthor: Brandon\nlayout: post\n---\n# Understanding Algebraic Effects\n\nAlgebraic effects are a powerful way to handle side effects.\n\n## What are they?\n\nThey let you declare what effects your code uses."
let doc = lib.parse(input)
Test.assertEqualMsg("Understanding Algebraic Effects", lib.title(doc), "snap: title")
Test.assertEqualMsg("2025-03-15", lib.date(doc), "snap: date")
Test.assertEqualMsg("A deep dive into effect systems", lib.description(doc), "snap: description")
Test.assertEqualMsg(3, List.length(lib.tags(doc)), "snap: tag count")
Test.assertEqualMsg("Brandon", lib.getOrDefault(doc, "author", ""), "snap: author")
Test.assertEqualMsg("post", lib.getOrDefault(doc, "layout", ""), "snap: layout")
Test.assertEqualMsg(6, List.length(lib.entries(doc)), "snap: entry count")
Test.assert(String.startsWith(lib.body(doc), "# Understanding"), "snap: body starts with heading")
Test.assert(String.contains(lib.body(doc), "## What are they?"), "snap: body contains subheading")
}
// Snapshot: minimal frontmatter
fn test_snapshot_minimal(): Unit with {Test} = {
let input = "---\ntitle: Hello\n---\nWorld"
let doc = lib.parse(input)
Test.assertEqualMsg("Hello", lib.title(doc), "snap: minimal title")
Test.assertEqualMsg("", lib.date(doc), "snap: minimal no date")
Test.assertEqualMsg("", lib.description(doc), "snap: minimal no description")
Test.assertEqualMsg(0, List.length(lib.tags(doc)), "snap: minimal no tags")
Test.assertEqualMsg(1, List.length(lib.entries(doc)), "snap: minimal one entry")
Test.assert(String.startsWith(lib.body(doc), "World"), "snap: minimal body")
}
// Snapshot: no frontmatter at all
fn test_snapshot_no_frontmatter(): Unit with {Test} = {
let input = "This is just plain text.\nNo frontmatter here."
let doc = lib.parse(input)
Test.assertEqualMsg(false, lib.hasFrontmatter(doc), "snap: no frontmatter")
Test.assertEqualMsg("", lib.title(doc), "snap: no fm title")
Test.assertEqualMsg("", lib.date(doc), "snap: no fm date")
Test.assertEqualMsg(0, List.length(lib.entries(doc)), "snap: no fm entries")
}
// Snapshot: quoted values with various quote styles
fn test_snapshot_quoted_values(): Unit with {Test} = {
let input = "---\ntitle: \"Double Quoted: Title\"\nsubtitle: 'Single Quoted: Subtitle'\nplain: Just a plain value\n---\nBody"
let doc = lib.parse(input)
Test.assertEqualMsg("Double Quoted: Title", lib.title(doc), "snap: double-quoted")
Test.assertEqualMsg("Single Quoted: Subtitle", lib.getOrDefault(doc, "subtitle", ""), "snap: single-quoted")
Test.assertEqualMsg("Just a plain value", lib.getOrDefault(doc, "plain", ""), "snap: unquoted")
}
// Snapshot: document with many entries
fn test_snapshot_many_entries(): Unit with {Test} = {
let input = "---\ntitle: Complex Post\ndate: 2025-06-01\ndescription: A complex post\ntags: a b c d e\nauthor: Alice\nlayout: page\ncategory: tech\nslug: complex-post\ndraft: false\nweight: 42\n---\nBody content here."
let doc = lib.parse(input)
Test.assertEqualMsg(10, List.length(lib.entries(doc)), "snap: ten entries")
Test.assertEqualMsg("Complex Post", lib.title(doc), "snap: title")
Test.assertEqualMsg("2025-06-01", lib.date(doc), "snap: date")
Test.assertEqualMsg(5, List.length(lib.tags(doc)), "snap: five tags")
Test.assertEqualMsg("Alice", lib.getOrDefault(doc, "author", ""), "snap: author")
Test.assertEqualMsg("page", lib.getOrDefault(doc, "layout", ""), "snap: layout")
Test.assertEqualMsg("tech", lib.getOrDefault(doc, "category", ""), "snap: category")
Test.assertEqualMsg("complex-post", lib.getOrDefault(doc, "slug", ""), "snap: slug")
Test.assertEqualMsg("false", lib.getOrDefault(doc, "draft", ""), "snap: draft")
Test.assertEqualMsg("42", lib.getOrDefault(doc, "weight", ""), "snap: weight")
}
// Snapshot: entry iteration order
fn test_snapshot_entry_order(): Unit with {Test} = {
let input = "---\nfirst: 1\nsecond: 2\nthird: 3\n---\n"
let doc = lib.parse(input)
let es = lib.entries(doc)
match List.head(es) {
Some(e) => {
Test.assertEqualMsg("first", lib.entryKey(e), "snap: first entry key")
Test.assertEqualMsg("1", lib.entryValue(e), "snap: first entry value")
},
None => Test.assert(false, "snap: should have entries"),
}
}

View File

@@ -0,0 +1,126 @@
import lib
// --- parse edge cases ---
fn test_parse_empty_string(): Unit with {Test} = {
let doc = lib.parse("")
Test.assertEqualMsg(false, lib.hasFrontmatter(doc), "empty string has no frontmatter")
Test.assertEqualMsg("", lib.body(doc), "empty string has empty body")
}
fn test_parse_only_body(): Unit with {Test} = {
let doc = lib.parse("Just text\nwith lines")
Test.assertEqualMsg(false, lib.hasFrontmatter(doc), "plain text has no frontmatter")
}
fn test_parse_only_frontmatter(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello\n---")
Test.assertEqualMsg(true, lib.hasFrontmatter(doc), "has frontmatter")
Test.assertEqualMsg("Hello", lib.title(doc), "title extracted")
Test.assertEqualMsg("", lib.body(doc), "no body")
}
fn test_parse_empty_frontmatter(): Unit with {Test} = {
let doc = lib.parse("---\n---\nBody here")
Test.assertEqualMsg(false, lib.hasFrontmatter(doc), "empty frontmatter has no entries")
Test.assert(String.startsWith(lib.body(doc), "Body here"), "body after empty frontmatter")
}
fn test_parse_unclosed_frontmatter(): Unit with {Test} = {
// Parser treats opening --- as starting frontmatter; entries are parsed even without closing ---
let doc = lib.parse("---\ntitle: Test\nNo closing marker")
Test.assertEqualMsg(true, lib.hasFrontmatter(doc), "unclosed frontmatter still parses entries")
Test.assertEqualMsg("Test", lib.title(doc), "unclosed frontmatter title still extracted")
}
// --- value formats ---
fn test_parse_double_quoted(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: \"Hello World\"\n---\n")
Test.assertEqualMsg("Hello World", lib.title(doc), "double-quoted value")
}
fn test_parse_single_quoted(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: 'Hello World'\n---\n")
Test.assertEqualMsg("Hello World", lib.title(doc), "single-quoted value")
}
fn test_parse_value_with_colon(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello: World: Again\n---\n")
Test.assertEqualMsg("Hello: World: Again", lib.title(doc), "value with multiple colons")
}
fn test_parse_value_empty(): Unit with {Test} = {
let doc = lib.parse("---\ntitle:\n---\n")
Test.assertEqualMsg("", lib.title(doc), "empty value after colon")
}
fn test_parse_value_with_special_chars(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello <world> & \"friends\"\n---\n")
Test.assertEqualMsg("Hello <world> & \"friends\"", lib.title(doc), "value with HTML special chars")
}
// --- get / getOrDefault edge cases ---
fn test_get_missing_key(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: Hello\n---\n")
Test.assert(match lib.get(doc, "nonexistent") { Some(_) => false, None => true }, "missing key returns None")
}
fn test_get_first_match(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: First\ntitle: Second\n---\n")
Test.assertEqualMsg("First", lib.getOrDefault(doc, "title", ""), "get returns first matching key")
}
fn test_get_or_default_present(): Unit with {Test} = {
let doc = lib.parse("---\nkey: value\n---\n")
Test.assertEqualMsg("value", lib.getOrDefault(doc, "key", "fallback"), "getOrDefault returns value when present")
}
fn test_get_or_default_missing(): Unit with {Test} = {
let doc = lib.parse("---\nkey: value\n---\n")
Test.assertEqualMsg("fallback", lib.getOrDefault(doc, "other", "fallback"), "getOrDefault returns default when missing")
}
// --- parseTags edge cases ---
fn test_parse_tags_single(): Unit with {Test} =
Test.assertEqualMsg(1, List.length(lib.parseTags("solo")), "single tag")
fn test_parse_tags_multiple(): Unit with {Test} =
Test.assertEqualMsg(3, List.length(lib.parseTags("a b c")), "three tags")
fn test_parse_tags_empty(): Unit with {Test} =
Test.assertEqualMsg(0, List.length(lib.parseTags("")), "empty tags")
// --- entries / entryKey / entryValue ---
fn test_entries_count(): Unit with {Test} = {
let doc = lib.parse("---\na: 1\nb: 2\nc: 3\n---\n")
Test.assertEqualMsg(3, List.length(lib.entries(doc)), "three entries")
}
fn test_entry_accessors(): Unit with {Test} = {
let doc = lib.parse("---\nfoo: bar\n---\n")
match List.head(lib.entries(doc)) {
Some(e) => {
Test.assertEqualMsg("foo", lib.entryKey(e), "entry key")
Test.assertEqualMsg("bar", lib.entryValue(e), "entry value")
},
None => Test.assert(false, "should have at least one entry"),
}
}
// --- body edge cases ---
fn test_body_multiline(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: T\n---\nLine 1\nLine 2\nLine 3")
let b = lib.body(doc)
Test.assert(String.startsWith(b, "Line 1\n"), "multiline body starts correctly")
Test.assert(String.contains(b, "Line 2"), "multiline body contains middle line")
}
fn test_body_preserves_blank_lines(): Unit with {Test} = {
let doc = lib.parse("---\ntitle: T\n---\nPara 1\n\nPara 2")
Test.assert(String.contains(lib.body(doc), "\n\n"), "body preserves blank lines")
}

2
packages/markdown/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
test
.lux_packages/

View File

@@ -0,0 +1,82 @@
# markdown
A Markdown to HTML converter for [Lux](https://github.com/thebrandonlucas/lux).
## Install
```bash
lux pkg add markdown --git https://git.qrty.ink/blu/markdown
```
## Usage
```lux
import markdown
fn main(): Unit with {Console} = {
let html = markdown.toHtml("# Hello **world**")
Console.print(html)
}
let _ = run main() with {}
```
## API
### `toHtml(markdown: String): String`
Convert a full markdown document to HTML.
### `inlineToHtml(text: String): String`
Convert inline markdown only (bold, italic, links, etc.) without block-level processing.
### `escapeHtml(s: String): String`
Escape HTML entities (`&`, `<`, `>`).
## Supported Markdown
### Block elements
- Headings (`# h1` through `#### h4`)
- Paragraphs (auto-wrapped in `<p>`)
- Fenced code blocks (` ``` ` with optional language)
- Blockquotes (`> text`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Horizontal rules (`---`, `***`, `___`)
- Images on their own line (`![alt](src)`)
- Raw HTML pass-through (lines starting with `<`)
### Inline elements
- **Bold** (`**text**`)
- *Italic* (`*text*` or `_text_`)
- ~~Strikethrough~~ (`~~text~~`)
- `Code` (`` `code` ``)
- [Links](url) (`[text](url)`)
- Images (`![alt](src)`)
- Raw HTML tags pass through
### Special features
- Headings inside list items (`- ### Title` renders correctly)
- Nested inline formatting (`**[bold link](url)**`)
- Code blocks with syntax highlighting class (`language-*`)
- Recursive blockquote content processing
## Running Tests
```bash
lux test.lux
```
## Known Limitations
- No nested lists (indented sub-items)
- No reference-style links (`[text][ref]`)
- No tables
- No task lists (`- [ ] item`)
- The C backend does not support module imports; use the interpreter (`lux`) or include the source directly for compiled binaries
## License
MIT

374
packages/markdown/lib.lux Normal file
View File

@@ -0,0 +1,374 @@
// markdown - A Markdown to HTML converter for Lux
//
// Public API:
// toHtml(markdown: String): String - Convert full markdown document to HTML
// inlineToHtml(text: String): String - Convert inline markdown only
// escapeHtml(s: String): String - Escape HTML entities
// Block parser state: tracks where we are while folding over lines
// Fields: html, para, inCode, codeLang, codeLines, inBq, bqLines, inList, listItems, ordered
type BState =
| BState(String, String, Bool, String, String, Bool, String, Bool, String, Bool)
// --- BState accessors ---
fn bsHtml(s: BState): String =
match s { BState(h, _, _, _, _, _, _, _, _, _) => h }
fn bsPara(s: BState): String =
match s { BState(_, p, _, _, _, _, _, _, _, _) => p }
fn bsInCode(s: BState): Bool =
match s { BState(_, _, c, _, _, _, _, _, _, _) => c }
fn bsCodeLang(s: BState): String =
match s { BState(_, _, _, l, _, _, _, _, _, _) => l }
fn bsCodeLines(s: BState): String =
match s { BState(_, _, _, _, cl, _, _, _, _, _) => cl }
fn bsInBq(s: BState): Bool =
match s { BState(_, _, _, _, _, bq, _, _, _, _) => bq }
fn bsBqLines(s: BState): String =
match s { BState(_, _, _, _, _, _, bl, _, _, _) => bl }
fn bsInList(s: BState): Bool =
match s { BState(_, _, _, _, _, _, _, il, _, _) => il }
fn bsListItems(s: BState): String =
match s { BState(_, _, _, _, _, _, _, _, li, _) => li }
fn bsOrdered(s: BState): Bool =
match s { BState(_, _, _, _, _, _, _, _, _, o) => o }
// --- HTML escaping ---
pub fn escapeHtml(s: String): String =
String.replace(String.replace(String.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
// --- Delimiter finding ---
fn findClosingFrom(text: String, i: Int, len: Int, delim: String, delimLen: Int): Option<Int> =
if i + delimLen > len then None
else if String.substring(text, i, i + delimLen) == delim then Some(i)
else findClosingFrom(text, i + 1, len, delim, delimLen)
fn findClosing(text: String, start: Int, len: Int, delim: String): Option<Int> =
findClosingFrom(text, start, len, delim, String.length(delim))
// --- Character classification for HTML pass-through ---
fn isLetterOrSlash(ch: String): Bool =
ch == "/" || ch == "!" ||
ch == "a" || ch == "b" || ch == "c" || ch == "d" || ch == "e" ||
ch == "f" || ch == "g" || ch == "h" || ch == "i" || ch == "j" ||
ch == "k" || ch == "l" || ch == "m" || ch == "n" || ch == "o" ||
ch == "p" || ch == "q" || ch == "r" || ch == "s" || ch == "t" ||
ch == "u" || ch == "v" || ch == "w" || ch == "x" || ch == "y" || ch == "z" ||
ch == "A" || ch == "B" || ch == "C" || ch == "D" || ch == "E" ||
ch == "F" || ch == "G" || ch == "H" || ch == "I" || ch == "J" ||
ch == "K" || ch == "L" || ch == "M" || ch == "N" || ch == "O" ||
ch == "P" || ch == "Q" || ch == "R" || ch == "S" || ch == "T" ||
ch == "U" || ch == "V" || ch == "W" || ch == "X" || ch == "Y" || ch == "Z"
// --- Inline markdown processing ---
// Handles: **bold**, *italic*, _italic_, `code`, ~~strikethrough~~,
// [links](url), ![images](url), <html> pass-through
fn processInlineFrom(text: String, i: Int, len: Int, acc: String): String = {
if i >= len then acc
else {
let ch = String.substring(text, i, i + 1)
if ch == "*" then
if i + 1 < len then
if String.substring(text, i + 1, i + 2) == "*" then
match findClosing(text, i + 2, len, "**") {
Some(end) => processInlineFrom(text, end + 2, len, acc + "<strong>" + processInline(String.substring(text, i + 2, end)) + "</strong>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else
match findClosing(text, i + 1, len, "*") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<em>" + processInline(String.substring(text, i + 1, end)) + "</em>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else acc + ch
else if ch == "_" then
if i + 1 < len then
match findClosing(text, i + 1, len, "_") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<em>" + processInline(String.substring(text, i + 1, end)) + "</em>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else acc + ch
else if ch == "~" then
if i + 1 < len then
if String.substring(text, i + 1, i + 2) == "~" then
match findClosing(text, i + 2, len, "~~") {
Some(end) => processInlineFrom(text, end + 2, len, acc + "<del>" + processInline(String.substring(text, i + 2, end)) + "</del>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else processInlineFrom(text, i + 1, len, acc + ch)
else acc + ch
else if ch == "`" then
match findClosing(text, i + 1, len, "`") {
Some(end) => processInlineFrom(text, end + 1, len, acc + "<code>" + escapeHtml(String.substring(text, i + 1, end)) + "</code>"),
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else if ch == "!" then
if i + 1 < len then
if String.substring(text, i + 1, i + 2) == "[" then
match findClosing(text, i + 2, len, "](") {
Some(end) => match findClosing(text, end + 2, len, ")") {
Some(urlEnd) => {
let alt = String.substring(text, i + 2, end)
let src = String.substring(text, end + 2, urlEnd)
let imgTag = acc + "<img src=\"" + src + "\" alt=\"" + alt + "\">"
processInlineFrom(text, urlEnd + 1, len, imgTag)
},
None => processInlineFrom(text, i + 1, len, acc + ch),
},
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else processInlineFrom(text, i + 1, len, acc + ch)
else acc + ch
else if ch == "[" then
match findClosing(text, i + 1, len, "](") {
Some(end) => match findClosing(text, end + 2, len, ")") {
Some(urlEnd) => {
let linkText = String.substring(text, i + 1, end)
let href = String.substring(text, end + 2, urlEnd)
let linkTag = acc + "<a href=\"" + href + "\">" + processInline(linkText) + "</a>"
processInlineFrom(text, urlEnd + 1, len, linkTag)
},
None => processInlineFrom(text, i + 1, len, acc + ch),
},
None => processInlineFrom(text, i + 1, len, acc + ch),
}
else if ch == "<" then
if i + 1 < len then
if isLetterOrSlash(String.substring(text, i + 1, i + 2)) then
match findClosing(text, i + 1, len, ">") {
Some(end) => processInlineFrom(text, end + 1, len, acc + String.substring(text, i, end + 1)),
None => processInlineFrom(text, i + 1, len, acc + "&lt;"),
}
else processInlineFrom(text, i + 1, len, acc + "&lt;")
else acc + "&lt;"
else if ch == ">" then
processInlineFrom(text, i + 1, len, acc + "&gt;")
else
processInlineFrom(text, i + 1, len, acc + ch)
}
}
fn processInline(text: String): String =
processInlineFrom(text, 0, String.length(text), "")
// --- List item processing ---
// Handles heading prefixes inside list items: "- ### Title" renders as <li><h3>Title</h3></li>
fn processListItemContent(content: String): String = {
let trimmed = String.trim(content)
if String.startsWith(trimmed, "#### ") then
"<h4>" + processInline(String.substring(trimmed, 5, String.length(trimmed))) + "</h4>"
else if String.startsWith(trimmed, "### ") then
"<h3>" + processInline(String.substring(trimmed, 4, String.length(trimmed))) + "</h3>"
else if String.startsWith(trimmed, "## ") then
"<h2>" + processInline(String.substring(trimmed, 3, String.length(trimmed))) + "</h2>"
else if String.startsWith(trimmed, "# ") then
"<h1>" + processInline(String.substring(trimmed, 2, String.length(trimmed))) + "</h1>"
else
processInline(trimmed)
}
// --- Block-level flush helpers ---
fn flushPara(html: String, para: String): String =
if para == "" then html
else html + "<p>" + processInline(String.trim(para)) + "</p>
"
fn flushBq(html: String, bqLines: String): String =
if bqLines == "" then html
else html + "<blockquote>
" + parseBlocks(bqLines) + "</blockquote>
"
fn flushList(html: String, listItems: String, ordered: Bool): String =
if listItems == "" then html
else {
let tag = if ordered then "ol" else "ul"
html + "<" + tag + ">
" + listItems + "</" + tag + ">
"
}
// --- Ordered list detection ---
fn isOrderedListItem(line: String): Bool = {
let trimmed = String.trim(line)
if String.length(trimmed) < 3 then false
else {
let first = String.substring(trimmed, 0, 1)
let isDigit = first == "0" || first == "1" || first == "2" || first == "3" || first == "4" || first == "5" || first == "6" || first == "7" || first == "8" || first == "9"
if isDigit then String.contains(trimmed, ". ")
else false
}
}
// --- Non-list block line processing ---
// Called when a line doesn't match code, blockquote, or list patterns
fn processBlockLine(html: String, para: String, inList: Bool, listItems: String, ordered: Bool, line: String): BState = {
let trimmed = String.trim(line)
if trimmed == "" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3, "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "#### ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h4>" + processInline(String.substring(trimmed, 5, String.length(trimmed))) + "</h4>
", "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "### ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h3>" + processInline(String.substring(trimmed, 4, String.length(trimmed))) + "</h3>
", "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "## ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h2>" + processInline(String.substring(trimmed, 3, String.length(trimmed))) + "</h2>
", "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "# ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<h1>" + processInline(String.substring(trimmed, 2, String.length(trimmed))) + "</h1>
", "", false, "", "", false, "", false, "", false)
}
else if trimmed == "---" || trimmed == "***" || trimmed == "___" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<hr>
", "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "<") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + trimmed + "
", "", false, "", "", false, "", false, "", false)
}
else if String.startsWith(trimmed, "![") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3 + "<p>" + processInline(trimmed) + "</p>
", "", false, "", "", false, "", false, "", false)
}
else if inList then {
let h2 = flushList(html, listItems, ordered)
BState(h2, para + trimmed + " ", false, "", "", false, "", false, "", false)
}
else
BState(html, para + trimmed + " ", false, "", "", false, "", false, "", false)
}
// --- Main block fold function ---
// Processes one line at a time, maintaining state across the fold
fn blockFoldLine(state: BState, line: String): BState = {
let html = bsHtml(state)
let para = bsPara(state)
let inCode = bsInCode(state)
let codeLang = bsCodeLang(state)
let codeLines = bsCodeLines(state)
let inBq = bsInBq(state)
let bqLines = bsBqLines(state)
let inList = bsInList(state)
let listItems = bsListItems(state)
let ordered = bsOrdered(state)
// Inside a fenced code block
if inCode then
if String.startsWith(line, "```") then {
let codeHtml = if codeLang == "" then
"<pre><code>" + codeLines + "</code></pre>
"
else
"<pre><code class=\"language-" + codeLang + "\">" + codeLines + "</code></pre>
"
BState(html + codeHtml, "", false, "", "", false, "", false, "", false)
}
else
BState(html, para, true, codeLang, codeLines + escapeHtml(line) + "
", false, "", false, "", false)
// Opening a fenced code block
else if String.startsWith(line, "```") then {
let h2 = flushPara(html, para)
let h3 = flushBq(h2, bqLines)
let h4 = flushList(h3, listItems, ordered)
let lang = String.trim(String.substring(line, 3, String.length(line)))
BState(h4, "", true, lang, "", false, "", false, "", false)
}
// Blockquote line
else if String.startsWith(line, "> ") then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
let bqContent = String.substring(line, 2, String.length(line))
BState(h3, "", false, "", "", true, bqLines + bqContent + "
", false, "", false)
}
// Empty blockquote continuation
else if String.trim(line) == ">" then {
let h2 = flushPara(html, para)
let h3 = flushList(h2, listItems, ordered)
BState(h3, "", false, "", "", true, bqLines + "
", false, "", false)
}
// Exiting a blockquote
else if inBq then {
let h2 = flushBq(html, bqLines)
processBlockLine(h2, para, inList, listItems, ordered, line)
}
// Unordered list item
else if String.startsWith(line, "- ") then {
let h2 = flushPara(html, para)
let item = String.substring(line, 2, String.length(line))
BState(h2, "", false, "", "", false, "", true, listItems + "<li>" + processListItemContent(item) + "</li>
", false)
}
// Ordered list item
else if isOrderedListItem(line) then {
let h2 = flushPara(html, para)
let dotIdx = match String.indexOf(line, ". ") {
Some(idx) => idx,
None => 0,
}
let item = String.substring(line, dotIdx + 2, String.length(line))
BState(h2, "", false, "", "", false, "", true, listItems + "<li>" + processListItemContent(item) + "</li>
", true)
}
// Everything else (headings, paragraphs, hr, html, images)
else
processBlockLine(html, para, inList, listItems, ordered, line)
}
// --- Core block parser ---
fn parseBlocks(text: String): String = {
let lines = String.lines(text)
let init = BState("", "", false, "", "", false, "", false, "", false)
let final = List.fold(lines, init, blockFoldLine)
let h = flushPara(bsHtml(final), bsPara(final))
let h2 = flushBq(h, bsBqLines(final))
flushList(h2, bsListItems(final), bsOrdered(final))
}
// --- Public API ---
// Convert a full markdown document to HTML
pub fn toHtml(markdown: String): String = parseBlocks(markdown)
// Convert inline markdown only (no block-level elements)
pub fn inlineToHtml(text: String): String = processInline(text)

View File

@@ -0,0 +1,8 @@
[project]
name = "markdown"
version = "0.1.0"
description = "Markdown to HTML converter for Lux"
authors = ["Brandon Lucas"]
license = "MIT"
[dependencies]

View File

@@ -0,0 +1,92 @@
import lib
// Integration tests: converting complex markdown documents
// Full blog post with mixed content
fn test_full_blog_post(): Unit with {Test} = {
let md = "# Welcome to My Blog\n\nThis is my **first post** about *Lux*.\n\n## Getting Started\n\nHere's some code:\n\n```lux\nfn main(): Unit = Console.print(\"hello\")\n```\n\nCheck out [the docs](https://example.com) for more.\n\n---\n\nThanks for reading!"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<h1>Welcome to My Blog</h1>"), "has h1")
Test.assert(String.contains(html, "<strong>first post</strong>"), "has bold")
Test.assert(String.contains(html, "<em>Lux</em>"), "has italic")
Test.assert(String.contains(html, "<h2>Getting Started</h2>"), "has h2")
Test.assert(String.contains(html, "<pre><code class=\"language-lux\">"), "has code block with lang")
Test.assert(String.contains(html, "<a href=\"https://example.com\">the docs</a>"), "has link")
Test.assert(String.contains(html, "<hr>"), "has horizontal rule")
Test.assert(String.contains(html, "Thanks for reading!"), "has closing paragraph")
}
// Document with various list types
fn test_mixed_lists(): Unit with {Test} = {
let md = "Shopping list:\n\n- Apples\n- Bananas\n- Cherries\n\nSteps:\n\n1. Preheat oven\n2. Mix ingredients\n3. Bake for 30 min"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<ul>"), "has unordered list")
Test.assert(String.contains(html, "<ol>"), "has ordered list")
Test.assert(String.contains(html, "<li>Apples</li>"), "has UL item")
Test.assert(String.contains(html, "<li>Preheat oven</li>"), "has OL item")
}
// Nested inline formatting
fn test_nested_inline(): Unit with {Test} = {
let result = lib.inlineToHtml("**bold and *italic* inside**")
Test.assert(String.contains(result, "<strong>"), "has strong tag")
Test.assert(String.contains(result, "<em>italic</em>"), "has nested italic")
}
// Multiple paragraphs
fn test_multiple_paragraphs(): Unit with {Test} = {
let md = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<p>First paragraph.</p>"), "first para")
Test.assert(String.contains(html, "<p>Second paragraph.</p>"), "second para")
Test.assert(String.contains(html, "<p>Third paragraph.</p>"), "third para")
}
// Code block preserves content exactly
fn test_code_block_preserves_content(): Unit with {Test} = {
let md = "```html\n<div>\n <p>Hello & world</p>\n</div>\n```"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "language-html"), "has language class")
Test.assert(String.contains(html, "&lt;div&gt;"), "HTML tags in code are escaped")
Test.assert(String.contains(html, "&amp; world"), "ampersand in code is escaped")
}
// Heading with various inline elements
fn test_heading_with_link(): Unit with {Test} = {
let md = "## [Lux](https://example.com) Language"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<h2>"), "has h2")
Test.assert(String.contains(html, "<a href=\"https://example.com\">Lux</a>"), "has link in heading")
}
// Blockquote with inline formatting
fn test_blockquote_with_formatting(): Unit with {Test} = {
let md = "> This is a **bold** quote"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<blockquote>"), "has blockquote")
Test.assert(String.contains(html, "<strong>bold</strong>"), "has bold in blockquote")
}
// Document starting with code block
fn test_leading_code_block(): Unit with {Test} = {
let md = "```\ncode first\n```\n\nThen text."
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<pre><code>code first"), "code block first")
Test.assert(String.contains(html, "<p>Then text.</p>"), "paragraph after code")
}
// Images in context
fn test_image_in_paragraph(): Unit with {Test} = {
let md = "Here is a photo:\n\n![Sunset](sunset.jpg)\n\nBeautiful, right?"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<img src=\"sunset.jpg\" alt=\"Sunset\">"), "has image")
}
// List items with headings
fn test_list_with_headings(): Unit with {Test} = {
let md = "- ## Chapter 1\n- ## Chapter 2\n- ## Chapter 3"
let html = lib.toHtml(md)
Test.assert(String.contains(html, "<li><h2>Chapter 1</h2></li>"), "heading in list item 1")
Test.assert(String.contains(html, "<li><h2>Chapter 2</h2></li>"), "heading in list item 2")
Test.assert(String.contains(html, "<li><h2>Chapter 3</h2></li>"), "heading in list item 3")
}

View File

@@ -0,0 +1,64 @@
import lib
fn test_escape_html(): Unit with {Test} = {
Test.assertEqualMsg("&lt;div&gt;", lib.escapeHtml("<div>"), "angle brackets")
Test.assertEqualMsg("a &amp; b", lib.escapeHtml("a & b"), "ampersand")
Test.assertEqualMsg("hello", lib.escapeHtml("hello"), "no special chars")
}
fn test_inline_formatting(): Unit with {Test} = {
Test.assertEqualMsg("<strong>bold</strong>", lib.inlineToHtml("**bold**"), "bold")
Test.assertEqualMsg("<em>italic</em>", lib.inlineToHtml("*italic*"), "italic with asterisk")
Test.assertEqualMsg("<em>italic</em>", lib.inlineToHtml("_italic_"), "italic with underscore")
Test.assertEqualMsg("<code>code</code>", lib.inlineToHtml("`code`"), "inline code")
Test.assertEqualMsg("<del>strike</del>", lib.inlineToHtml("~~strike~~"), "strikethrough")
Test.assertEqualMsg("plain text", lib.inlineToHtml("plain text"), "plain text unchanged")
}
fn test_links(): Unit with {Test} = {
Test.assertEqualMsg("<a href=\"https://example.com\">click</a>", lib.inlineToHtml("[click](https://example.com)"), "link")
Test.assertEqualMsg("<img src=\"img.png\" alt=\"alt\">", lib.inlineToHtml("![alt](img.png)"), "image")
Test.assertEqualMsg("<strong><a href=\"url\">bold link</a></strong>", lib.inlineToHtml("**[bold link](url)**"), "bold link")
}
fn test_headings(): Unit with {Test} = {
Test.assertEqualMsg("<h1>Hello</h1>\n", lib.toHtml("# Hello"), "h1")
Test.assertEqualMsg("<h2>World</h2>\n", lib.toHtml("## World"), "h2")
Test.assertEqualMsg("<h3>Sub</h3>\n", lib.toHtml("### Sub"), "h3")
Test.assertEqualMsg("<h4>Deep</h4>\n", lib.toHtml("#### Deep"), "h4")
}
fn test_paragraphs(): Unit with {Test} = {
Test.assertEqualMsg("<p>hello world</p>\n", lib.toHtml("hello world"), "single line paragraph")
}
fn test_code_blocks(): Unit with {Test} = {
let result = lib.toHtml("```\nhello\n```")
Test.assertEqualMsg("<pre><code>hello\n</code></pre>\n", result, "simple code block")
}
fn test_lists(): Unit with {Test} = {
Test.assertEqualMsg("<ul>\n<li>item one</li>\n<li>item two</li>\n</ul>\n", lib.toHtml("- item one\n- item two"), "unordered list")
Test.assertEqualMsg("<ol>\n<li>first</li>\n<li>second</li>\n</ol>\n", lib.toHtml("1. first\n2. second"), "ordered list")
}
fn test_heading_in_list(): Unit with {Test} = {
Test.assertEqualMsg("<ul>\n<li><h3>Title</h3></li>\n</ul>\n", lib.toHtml("- ### Title"), "h3 inside list item")
Test.assertEqualMsg("<ul>\n<li><h2>Big Title</h2></li>\n</ul>\n", lib.toHtml("- ## Big Title"), "h2 inside list item")
Test.assertEqualMsg("<ul>\n<li><h3><a href=\"/url\">Link</a></h3></li>\n</ul>\n", lib.toHtml("- ### [Link](/url)"), "h3 with link inside list item")
}
fn test_blockquotes(): Unit with {Test} = {
Test.assertEqualMsg("<blockquote>\n<p>quoted text</p>\n</blockquote>\n", lib.toHtml("> quoted text"), "simple blockquote")
}
fn test_horizontal_rule(): Unit with {Test} = {
Test.assertEqualMsg("<hr>\n", lib.toHtml("---"), "hr with dashes")
Test.assertEqualMsg("<hr>\n", lib.toHtml("***"), "hr with asterisks")
Test.assertEqualMsg("<hr>\n", lib.toHtml("___"), "hr with underscores")
}
fn test_html_passthrough(): Unit with {Test} = {
Test.assertEqualMsg("<div class=\"foo\">bar</div>\n", lib.toHtml("<div class=\"foo\">bar</div>"), "html element passes through")
Test.assertEqualMsg("<picture>\n<source srcset=\"img.avif\">\n</picture>\n", lib.toHtml("<picture>\n<source srcset=\"img.avif\">\n</picture>"), "multi-line html passthrough")
}

View File

@@ -0,0 +1,112 @@
import lib
// Snapshot tests: compare full markdown→HTML conversion against golden output
// Snapshot: simple document with heading and paragraph
fn test_snapshot_heading_and_para(): Unit with {Test} = {
let md = "# Hello World\n\nThis is a paragraph."
let expected = "<h1>Hello World</h1>\n<p>This is a paragraph.</p>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: heading + paragraph")
}
// Snapshot: all heading levels
fn test_snapshot_all_headings(): Unit with {Test} = {
Test.assertEqualMsg("<h1>H1</h1>\n", lib.toHtml("# H1"), "snap: h1")
Test.assertEqualMsg("<h2>H2</h2>\n", lib.toHtml("## H2"), "snap: h2")
Test.assertEqualMsg("<h3>H3</h3>\n", lib.toHtml("### H3"), "snap: h3")
Test.assertEqualMsg("<h4>H4</h4>\n", lib.toHtml("#### H4"), "snap: h4")
}
// Snapshot: inline formatting combinations
fn test_snapshot_inline_combos(): Unit with {Test} = {
Test.assertEqualMsg("<strong>bold</strong>", lib.inlineToHtml("**bold**"), "snap: bold")
Test.assertEqualMsg("<em>italic</em>", lib.inlineToHtml("*italic*"), "snap: italic")
Test.assertEqualMsg("<code>code</code>", lib.inlineToHtml("`code`"), "snap: code")
Test.assertEqualMsg("<del>strike</del>", lib.inlineToHtml("~~strike~~"), "snap: strike")
Test.assertEqualMsg("<a href=\"/\">link</a>", lib.inlineToHtml("[link](/)"), "snap: link")
Test.assertEqualMsg("<img src=\"i.png\" alt=\"a\">", lib.inlineToHtml("![a](i.png)"), "snap: image")
}
// Snapshot: unordered list
fn test_snapshot_unordered_list(): Unit with {Test} = {
let md = "- Alpha\n- Beta\n- Gamma"
let expected = "<ul>\n<li>Alpha</li>\n<li>Beta</li>\n<li>Gamma</li>\n</ul>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: unordered list")
}
// Snapshot: ordered list
fn test_snapshot_ordered_list(): Unit with {Test} = {
let md = "1. First\n2. Second\n3. Third"
let expected = "<ol>\n<li>First</li>\n<li>Second</li>\n<li>Third</li>\n</ol>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: ordered list")
}
// Snapshot: code block with language
fn test_snapshot_code_block(): Unit with {Test} = {
let md = "```rust\nlet x = 1;\nlet y = 2;\n```"
let expected = "<pre><code class=\"language-rust\">let x = 1;\nlet y = 2;\n</code></pre>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: code block with rust lang")
}
// Snapshot: code block without language
fn test_snapshot_code_block_no_lang(): Unit with {Test} = {
let md = "```\nplain code\n```"
let expected = "<pre><code>plain code\n</code></pre>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: code block no lang")
}
// Snapshot: blockquote
fn test_snapshot_blockquote(): Unit with {Test} = {
let md = "> This is quoted text"
let expected = "<blockquote>\n<p>This is quoted text</p>\n</blockquote>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: blockquote")
}
// Snapshot: horizontal rules
fn test_snapshot_horizontal_rules(): Unit with {Test} = {
Test.assertEqualMsg("<hr>\n", lib.toHtml("---"), "snap: hr dashes")
Test.assertEqualMsg("<hr>\n", lib.toHtml("***"), "snap: hr asterisks")
Test.assertEqualMsg("<hr>\n", lib.toHtml("___"), "snap: hr underscores")
}
// Snapshot: HTML passthrough
fn test_snapshot_html_passthrough(): Unit with {Test} = {
Test.assertEqualMsg("<div class=\"box\">content</div>\n", lib.toHtml("<div class=\"box\">content</div>"), "snap: div passthrough")
Test.assertEqualMsg("<video src=\"v.mp4\"></video>\n", lib.toHtml("<video src=\"v.mp4\"></video>"), "snap: video passthrough")
}
// Snapshot: heading with inline formatting inside list
fn test_snapshot_list_with_headings(): Unit with {Test} = {
let md = "- ### Chapter 1\n- ### Chapter 2"
let expected = "<ul>\n<li><h3>Chapter 1</h3></li>\n<li><h3>Chapter 2</h3></li>\n</ul>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: list with headings")
}
// Snapshot: complete blog post conversion
fn test_snapshot_blog_post(): Unit with {Test} = {
let md = "# My Post\n\nIntro paragraph with **bold** and *italic*.\n\n## Code Example\n\n```js\nconsole.log(\"hi\");\n```\n\n- Item one\n- Item two\n\n> A quote\n\n---\n\nThe end."
let html = lib.toHtml(md)
Test.assert(String.startsWith(html, "<h1>My Post</h1>"), "snap: blog starts with h1")
Test.assert(String.contains(html, "<strong>bold</strong>"), "snap: blog has bold")
Test.assert(String.contains(html, "<em>italic</em>"), "snap: blog has italic")
Test.assert(String.contains(html, "<h2>Code Example</h2>"), "snap: blog has h2")
Test.assert(String.contains(html, "language-js"), "snap: blog has js code block")
Test.assert(String.contains(html, "<li>Item one</li>"), "snap: blog has list item")
Test.assert(String.contains(html, "<blockquote>"), "snap: blog has blockquote")
Test.assert(String.contains(html, "<hr>"), "snap: blog has hr")
Test.assert(String.contains(html, "The end."), "snap: blog has closing")
}
// Snapshot: inline code with HTML entities
fn test_snapshot_code_html_entities(): Unit with {Test} = {
let result = lib.inlineToHtml("Use `<div>` and `&amp;` in HTML")
let expected = "Use <code>&lt;div&gt;</code> and <code>&amp;amp;</code> in HTML"
Test.assertEqualMsg(expected, result, "snap: code with HTML entities")
}
// Snapshot: multiple paragraphs
fn test_snapshot_multiple_paragraphs(): Unit with {Test} = {
let md = "First.\n\nSecond.\n\nThird."
let expected = "<p>First.</p>\n<p>Second.</p>\n<p>Third.</p>\n"
Test.assertEqualMsg(expected, lib.toHtml(md), "snap: three paragraphs")
}

View File

@@ -0,0 +1,160 @@
import lib
// --- escapeHtml ---
fn test_escape_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.escapeHtml(""), "escape empty string")
fn test_escape_no_special(): Unit with {Test} =
Test.assertEqualMsg("hello world", lib.escapeHtml("hello world"), "no special chars unchanged")
fn test_escape_ampersand(): Unit with {Test} =
Test.assertEqualMsg("a &amp; b", lib.escapeHtml("a & b"), "escape ampersand")
fn test_escape_less_than(): Unit with {Test} =
Test.assertEqualMsg("&lt;div&gt;", lib.escapeHtml("<div>"), "escape angle brackets")
fn test_escape_all_three(): Unit with {Test} =
Test.assertEqualMsg("&lt;a&gt; &amp; &lt;b&gt;", lib.escapeHtml("<a> & <b>"), "escape all three")
// --- inline: bold ---
fn test_bold(): Unit with {Test} =
Test.assertEqualMsg("<strong>bold</strong>", lib.inlineToHtml("**bold**"), "bold text")
fn test_bold_in_sentence(): Unit with {Test} =
Test.assertEqualMsg("a <strong>bold</strong> b", lib.inlineToHtml("a **bold** b"), "bold in sentence")
fn test_bold_unclosed(): Unit with {Test} =
Test.assertEqualMsg("**unclosed", lib.inlineToHtml("**unclosed"), "unclosed bold")
// --- inline: italic ---
fn test_italic_asterisk(): Unit with {Test} =
Test.assertEqualMsg("<em>italic</em>", lib.inlineToHtml("*italic*"), "italic with asterisk")
fn test_italic_underscore(): Unit with {Test} =
Test.assertEqualMsg("<em>italic</em>", lib.inlineToHtml("_italic_"), "italic with underscore")
fn test_italic_unclosed(): Unit with {Test} =
Test.assertEqualMsg("*unclosed", lib.inlineToHtml("*unclosed"), "unclosed italic")
// --- inline: code ---
fn test_inline_code(): Unit with {Test} =
Test.assertEqualMsg("<code>code</code>", lib.inlineToHtml("`code`"), "inline code")
fn test_inline_code_with_html(): Unit with {Test} =
Test.assertEqualMsg("<code>&lt;div&gt;</code>", lib.inlineToHtml("`<div>`"), "code escapes HTML")
fn test_inline_code_unclosed(): Unit with {Test} =
Test.assertEqualMsg("`unclosed", lib.inlineToHtml("`unclosed"), "unclosed backtick")
// --- inline: strikethrough ---
fn test_strikethrough(): Unit with {Test} =
Test.assertEqualMsg("<del>strike</del>", lib.inlineToHtml("~~strike~~"), "strikethrough")
fn test_strikethrough_unclosed(): Unit with {Test} =
Test.assertEqualMsg("~~unclosed", lib.inlineToHtml("~~unclosed"), "unclosed strikethrough")
// --- inline: links ---
fn test_link(): Unit with {Test} =
Test.assertEqualMsg("<a href=\"/page\">text</a>", lib.inlineToHtml("[text](/page)"), "basic link")
fn test_link_with_formatting(): Unit with {Test} =
Test.assertEqualMsg("<strong><a href=\"/\">bold link</a></strong>", lib.inlineToHtml("**[bold link](/)**"), "bold link")
fn test_link_empty_text(): Unit with {Test} =
Test.assertEqualMsg("<a href=\"/page\"></a>", lib.inlineToHtml("[](/page)"), "link with empty text")
fn test_link_unclosed(): Unit with {Test} =
Test.assertEqualMsg("[unclosed", lib.inlineToHtml("[unclosed"), "unclosed bracket")
// --- inline: images ---
fn test_image(): Unit with {Test} =
Test.assertEqualMsg("<img src=\"img.png\" alt=\"alt text\">", lib.inlineToHtml("![alt text](img.png)"), "basic image")
fn test_image_empty_alt(): Unit with {Test} =
Test.assertEqualMsg("<img src=\"photo.jpg\" alt=\"\">", lib.inlineToHtml("![](photo.jpg)"), "image with empty alt")
// --- inline: plain text ---
fn test_plain_text(): Unit with {Test} =
Test.assertEqualMsg("hello world", lib.inlineToHtml("hello world"), "plain text unchanged")
fn test_empty_inline(): Unit with {Test} =
Test.assertEqualMsg("", lib.inlineToHtml(""), "empty input")
// --- block: headings ---
fn test_h1(): Unit with {Test} =
Test.assertEqualMsg("<h1>Hello</h1>\n", lib.toHtml("# Hello"), "h1")
fn test_h2(): Unit with {Test} =
Test.assertEqualMsg("<h2>World</h2>\n", lib.toHtml("## World"), "h2")
fn test_h3(): Unit with {Test} =
Test.assertEqualMsg("<h3>Sub</h3>\n", lib.toHtml("### Sub"), "h3")
fn test_h4(): Unit with {Test} =
Test.assertEqualMsg("<h4>Deep</h4>\n", lib.toHtml("#### Deep"), "h4")
fn test_heading_with_inline(): Unit with {Test} =
Test.assertEqualMsg("<h1><strong>Bold</strong> heading</h1>\n", lib.toHtml("# **Bold** heading"), "heading with inline formatting")
// --- block: paragraphs ---
fn test_single_paragraph(): Unit with {Test} =
Test.assertEqualMsg("<p>hello world</p>\n", lib.toHtml("hello world"), "single paragraph")
fn test_empty_input(): Unit with {Test} =
Test.assertEqualMsg("", lib.toHtml(""), "empty input produces empty output")
// --- block: code blocks ---
fn test_code_block(): Unit with {Test} =
Test.assertEqualMsg("<pre><code>hello\n</code></pre>\n", lib.toHtml("```\nhello\n```"), "code block")
fn test_code_block_with_lang(): Unit with {Test} =
Test.assertEqualMsg("<pre><code class=\"language-js\">let x = 1;\n</code></pre>\n", lib.toHtml("```js\nlet x = 1;\n```"), "code block with language")
fn test_code_block_escapes_html(): Unit with {Test} =
Test.assertEqualMsg("<pre><code>&lt;div&gt;\n</code></pre>\n", lib.toHtml("```\n<div>\n```"), "code block escapes HTML")
// --- block: lists ---
fn test_unordered_list(): Unit with {Test} =
Test.assertEqualMsg("<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n", lib.toHtml("- one\n- two"), "unordered list")
fn test_ordered_list(): Unit with {Test} =
Test.assertEqualMsg("<ol>\n<li>first</li>\n<li>second</li>\n</ol>\n", lib.toHtml("1. first\n2. second"), "ordered list")
fn test_single_item_list(): Unit with {Test} =
Test.assertEqualMsg("<ul>\n<li>only</li>\n</ul>\n", lib.toHtml("- only"), "single item list")
// --- block: blockquotes ---
fn test_blockquote(): Unit with {Test} =
Test.assertEqualMsg("<blockquote>\n<p>quoted</p>\n</blockquote>\n", lib.toHtml("> quoted"), "blockquote")
// --- block: horizontal rules ---
fn test_hr_dashes(): Unit with {Test} =
Test.assertEqualMsg("<hr>\n", lib.toHtml("---"), "hr with dashes")
fn test_hr_asterisks(): Unit with {Test} =
Test.assertEqualMsg("<hr>\n", lib.toHtml("***"), "hr with asterisks")
fn test_hr_underscores(): Unit with {Test} =
Test.assertEqualMsg("<hr>\n", lib.toHtml("___"), "hr with underscores")
// --- block: HTML passthrough ---
fn test_html_passthrough(): Unit with {Test} =
Test.assertEqualMsg("<div class=\"foo\">bar</div>\n", lib.toHtml("<div class=\"foo\">bar</div>"), "HTML passes through")
fn test_heading_in_list(): Unit with {Test} =
Test.assertEqualMsg("<ul>\n<li><h3>Title</h3></li>\n</ul>\n", lib.toHtml("- ### Title"), "heading inside list item")

3
packages/path/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
_site/
.lux_packages/
*.bak

93
packages/path/lib.lux Normal file
View File

@@ -0,0 +1,93 @@
// path - File path utilities for Lux
// Get the last component of a path (filename)
// basename("/foo/bar/baz.txt") => "baz.txt"
// basename("file.txt") => "file.txt"
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 portion of a path
// dirname("/foo/bar/baz.txt") => "/foo/bar"
// dirname("file.txt") => "."
pub fn dirname(path: String): String =
match String.lastIndexOf(path, "/") {
Some(idx) => if idx == 0 then "/" else String.substring(path, 0, idx),
None => ".",
}
// Get the file extension (without the dot)
// extension("file.txt") => Some("txt")
// extension("file") => None
// extension("file.tar.gz") => Some("gz")
pub fn extension(path: String): Option<String> = {
let name = basename(path)
match String.lastIndexOf(name, ".") {
Some(idx) => if idx == 0 then None
else Some(String.substring(name, idx + 1, String.length(name))),
None => None,
}
}
// Remove the file extension
// stripExtension("file.txt") => "file"
// stripExtension("file") => "file"
// stripExtension("/foo/bar.txt") => "/foo/bar"
pub fn stripExtension(path: String): String =
match String.lastIndexOf(path, ".") {
Some(dotIdx) => match String.lastIndexOf(path, "/") {
Some(slashIdx) => if dotIdx > slashIdx then String.substring(path, 0, dotIdx) else path,
None => String.substring(path, 0, dotIdx),
},
None => path,
}
// Join two path components with a separator
// join("/foo", "bar") => "/foo/bar"
// join("/foo/", "bar") => "/foo/bar"
// join("", "bar") => "bar"
pub fn join(a: String, b: String): String =
if a == "" then b
else if b == "" then a
else if String.endsWith(a, "/") then
if String.startsWith(b, "/") then a + String.substring(b, 1, String.length(b))
else a + b
else if String.startsWith(b, "/") then a + b
else a + "/" + b
// Check if a path has a given extension
// hasExtension("file.txt", "txt") => true
// hasExtension("file.md", "txt") => false
pub fn hasExtension(path: String, ext: String): Bool =
String.endsWith(path, "." + ext)
// Replace the file extension
// replaceExtension("file.txt", "md") => "file.md"
// replaceExtension("file", "md") => "file.md"
pub fn replaceExtension(path: String, newExt: String): String =
stripExtension(path) + "." + newExt
// Get the filename without extension (stem)
// stem("file.txt") => "file"
// stem("/foo/bar.txt") => "bar"
// stem("file") => "file"
pub fn stem(path: String): String = {
let name = basename(path)
match String.lastIndexOf(name, ".") {
Some(idx) => if idx == 0 then name
else String.substring(name, 0, idx),
None => name,
}
}
// Check if a path is absolute (starts with /)
pub fn isAbsolute(path: String): Bool =
String.startsWith(path, "/")
// Check if a path is relative (does not start with /)
pub fn isRelative(path: String): Bool =
if path == "" then true
else if String.startsWith(path, "/") then false
else true

8
packages/path/lux.toml Normal file
View File

@@ -0,0 +1,8 @@
[project]
name = "path"
version = "0.1.0"
description = "File path utilities for Lux"
authors = ["Brandon Lucas"]
license = "MIT"
[dependencies]

View File

@@ -0,0 +1,88 @@
import lib
// Integration tests: realistic path manipulation workflows
// Build a full output path from components
fn test_build_output_path(): Unit with {Test} = {
let srcFile = "/home/user/project/src/pages/about.md"
let outDir = "/home/user/project/_site"
let name = lib.stem(srcFile)
let outFile = lib.join(outDir, lib.join(name, "index.html"))
Test.assertEqualMsg("/home/user/project/_site/about/index.html", outFile, "build output path from source")
}
// Convert all markdown files to HTML paths
fn test_extension_swap_workflow(): Unit with {Test} = {
let file = "/content/posts/hello-world.md"
let result = lib.replaceExtension(file, "html")
Test.assertEqualMsg("/content/posts/hello-world.html", result, "swap .md to .html")
Test.assertEqualMsg("html", match lib.extension(result) { Some(e) => e, None => "" }, "verify new extension")
}
// Decompose a path and reconstruct it
fn test_decompose_reconstruct(): Unit with {Test} = {
let original = "/var/log/app/error.log"
let dir = lib.dirname(original)
let name = lib.basename(original)
let reconstructed = lib.join(dir, name)
Test.assertEqualMsg(original, reconstructed, "decompose and reconstruct path")
}
// Process a list of file paths: extract stems
fn test_batch_stem_extraction(): Unit with {Test} = {
let s1 = lib.stem("/posts/hello.md")
let s2 = lib.stem("/posts/world.md")
let s3 = lib.stem("/posts/readme.txt")
Test.assertEqualMsg("hello", s1, "batch stem 1")
Test.assertEqualMsg("world", s2, "batch stem 2")
Test.assertEqualMsg("readme", s3, "batch stem 3")
}
// Create sibling file path (same dir, different name)
fn test_sibling_file(): Unit with {Test} = {
let original = "/assets/css/main.css"
let dir = lib.dirname(original)
let sibling = lib.join(dir, "reset.css")
Test.assertEqualMsg("/assets/css/reset.css", sibling, "sibling file in same directory")
}
// Multi-level path construction
fn test_multi_level_join(): Unit with {Test} = {
let base = "/var/www"
let path = lib.join(lib.join(lib.join(base, "html"), "blog"), "index.html")
Test.assertEqualMsg("/var/www/html/blog/index.html", path, "multi-level path join")
Test.assertEqualMsg("/var/www/html/blog", lib.dirname(path), "dirname of multi-level")
Test.assertEqualMsg("index.html", lib.basename(path), "basename of multi-level")
Test.assert(lib.isAbsolute(path), "multi-level path is absolute")
}
// Verify path properties are consistent
fn test_path_property_consistency(): Unit with {Test} = {
let path = "src/components/Button.tsx"
Test.assertEqualMsg(true, lib.isRelative(path), "is relative")
Test.assertEqualMsg(false, lib.isAbsolute(path), "is not absolute")
Test.assertEqualMsg(true, lib.hasExtension(path, "tsx"), "has .tsx extension")
Test.assertEqualMsg(false, lib.hasExtension(path, "ts"), "does not have .ts extension")
Test.assertEqualMsg("Button", lib.stem(path), "stem is Button")
Test.assertEqualMsg("src/components", lib.dirname(path), "dirname is src/components")
}
// Swap extension and verify roundtrip
fn test_extension_roundtrip(): Unit with {Test} = {
let original = "report.csv"
let swapped = lib.replaceExtension(original, "json")
Test.assertEqualMsg("report.json", swapped, "csv -> json")
let back = lib.replaceExtension(swapped, "csv")
Test.assertEqualMsg("report.csv", back, "json -> csv roundtrip")
}
// Build a path for a static site generator output
fn test_ssg_path_pipeline(): Unit with {Test} = {
let inputFile = "content/blog/my-first-post.md"
let slug = lib.stem(inputFile)
let outputDir = lib.join("_site", slug)
let outputFile = lib.join(outputDir, "index.html")
Test.assertEqualMsg("my-first-post", slug, "extract slug")
Test.assertEqualMsg("_site/my-first-post", outputDir, "output directory")
Test.assertEqualMsg("_site/my-first-post/index.html", outputFile, "full output path")
}

View File

@@ -0,0 +1,72 @@
import lib
fn isSome(opt: Option<String>, expected: String): Bool =
match opt {
Some(v) => v == expected,
None => false,
}
fn isNone(opt: Option<String>): Bool =
match opt {
Some(_) => false,
None => true,
}
fn test_basename(): Unit with {Test} = {
Test.assertEqualMsg("baz.txt", lib.basename("/foo/bar/baz.txt"), "basename with dirs")
Test.assertEqualMsg("file.txt", lib.basename("file.txt"), "basename no dir")
Test.assertEqualMsg("", lib.basename("/foo/bar/"), "basename trailing slash")
Test.assertEqualMsg("", lib.basename(""), "basename empty")
}
fn test_dirname(): Unit with {Test} = {
Test.assertEqualMsg("/foo/bar", lib.dirname("/foo/bar/baz.txt"), "dirname with dirs")
Test.assertEqualMsg(".", lib.dirname("file.txt"), "dirname no dir")
Test.assertEqualMsg("/", lib.dirname("/file.txt"), "dirname root")
}
fn test_extension(): Unit with {Test} = {
Test.assert(isSome(lib.extension("file.txt"), "txt"), "extension simple")
Test.assert(isSome(lib.extension("file.tar.gz"), "gz"), "extension double")
Test.assert(isNone(lib.extension("file")), "extension none")
Test.assert(isSome(lib.extension("/foo/bar.txt"), "txt"), "extension with dir")
Test.assert(isNone(lib.extension(".hidden")), "extension dotfile")
}
fn test_strip_extension(): Unit with {Test} = {
Test.assertEqualMsg("file", lib.stripExtension("file.txt"), "stripExtension simple")
Test.assertEqualMsg("file", lib.stripExtension("file"), "stripExtension none")
Test.assertEqualMsg("/foo/bar", lib.stripExtension("/foo/bar.txt"), "stripExtension with dir")
}
fn test_join(): Unit with {Test} = {
Test.assertEqualMsg("/foo/bar", lib.join("/foo", "bar"), "join basic")
Test.assertEqualMsg("/foo/bar", lib.join("/foo/", "bar"), "join trailing slash")
Test.assertEqualMsg("/foo/bar", lib.join("/foo", "/bar"), "join leading slash")
Test.assertEqualMsg("/foo/bar", lib.join("/foo/", "/bar"), "join both slashes")
Test.assertEqualMsg("bar", lib.join("", "bar"), "join empty first")
Test.assertEqualMsg("foo", lib.join("foo", ""), "join empty second")
}
fn test_has_extension(): Unit with {Test} = {
Test.assertEqualMsg(true, lib.hasExtension("file.txt", "txt"), "hasExtension true")
Test.assertEqualMsg(false, lib.hasExtension("file.md", "txt"), "hasExtension false")
}
fn test_replace_extension(): Unit with {Test} = {
Test.assertEqualMsg("file.md", lib.replaceExtension("file.txt", "md"), "replaceExtension simple")
Test.assertEqualMsg("file.md", lib.replaceExtension("file", "md"), "replaceExtension no ext")
}
fn test_stem(): Unit with {Test} = {
Test.assertEqualMsg("file", lib.stem("file.txt"), "stem simple")
Test.assertEqualMsg("bar", lib.stem("/foo/bar.txt"), "stem with dir")
Test.assertEqualMsg("file", lib.stem("file"), "stem no ext")
}
fn test_absolute_relative(): Unit with {Test} = {
Test.assertEqualMsg(true, lib.isAbsolute("/foo/bar"), "isAbsolute true")
Test.assertEqualMsg(false, lib.isAbsolute("foo/bar"), "isAbsolute false")
Test.assertEqualMsg(true, lib.isRelative("foo/bar"), "isRelative true")
Test.assertEqualMsg(false, lib.isRelative("/foo/bar"), "isRelative false")
}

View File

@@ -0,0 +1,65 @@
import lib
// Snapshot tests: verify complete path decomposition output against golden values
// Snapshot: decompose a typical web project file
fn test_snapshot_web_project_file(): Unit with {Test} = {
let path = "/home/user/project/src/components/Button.tsx"
Test.assertEqualMsg("Button.tsx", lib.basename(path), "snap: basename")
Test.assertEqualMsg("/home/user/project/src/components", lib.dirname(path), "snap: dirname")
Test.assertEqualMsg("tsx", match lib.extension(path) { Some(e) => e, None => "" }, "snap: extension")
Test.assertEqualMsg("Button", lib.stem(path), "snap: stem")
Test.assertEqualMsg("/home/user/project/src/components/Button", lib.stripExtension(path), "snap: stripExtension")
Test.assertEqualMsg(true, lib.isAbsolute(path), "snap: isAbsolute")
Test.assertEqualMsg(false, lib.isRelative(path), "snap: isRelative")
Test.assertEqualMsg(true, lib.hasExtension(path, "tsx"), "snap: hasExtension tsx")
Test.assertEqualMsg(false, lib.hasExtension(path, "ts"), "snap: hasExtension ts")
Test.assertEqualMsg("/home/user/project/src/components/Button.jsx", lib.replaceExtension(path, "jsx"), "snap: replaceExtension")
}
// Snapshot: decompose a markdown blog post path
fn test_snapshot_blog_post(): Unit with {Test} = {
let path = "content/posts/2025/my-first-post.md"
Test.assertEqualMsg("my-first-post.md", lib.basename(path), "snap: basename")
Test.assertEqualMsg("content/posts/2025", lib.dirname(path), "snap: dirname")
Test.assertEqualMsg("md", match lib.extension(path) { Some(e) => e, None => "" }, "snap: extension")
Test.assertEqualMsg("my-first-post", lib.stem(path), "snap: stem")
Test.assertEqualMsg("content/posts/2025/my-first-post", lib.stripExtension(path), "snap: stripExtension")
Test.assertEqualMsg(false, lib.isAbsolute(path), "snap: isAbsolute")
Test.assertEqualMsg(true, lib.isRelative(path), "snap: isRelative")
Test.assertEqualMsg("content/posts/2025/my-first-post.html", lib.replaceExtension(path, "html"), "snap: replaceExtension")
}
// Snapshot: SSG path transformation pipeline
fn test_snapshot_ssg_pipeline(): Unit with {Test} = {
let input = "pages/about.md"
let slug = lib.stem(input)
let outputDir = lib.join("_site", slug)
let indexFile = lib.join(outputDir, "index.html")
Test.assertEqualMsg("about", slug, "snap: slug")
Test.assertEqualMsg("_site/about", outputDir, "snap: output dir")
Test.assertEqualMsg("_site/about/index.html", indexFile, "snap: index file")
Test.assertEqualMsg("_site/about", lib.dirname(indexFile), "snap: dirname of index")
Test.assertEqualMsg("index.html", lib.basename(indexFile), "snap: basename of index")
Test.assertEqualMsg("index", lib.stem(indexFile), "snap: stem of index")
}
// Snapshot: dotfile handling
fn test_snapshot_dotfiles(): Unit with {Test} = {
Test.assertEqualMsg(".gitignore", lib.basename("/project/.gitignore"), "snap: dotfile basename")
Test.assertEqualMsg("/project", lib.dirname("/project/.gitignore"), "snap: dotfile dirname")
Test.assertEqualMsg(".gitignore", lib.stem(".gitignore"), "snap: dotfile stem")
Test.assertEqualMsg("json", match lib.extension(".eslintrc.json") { Some(e) => e, None => "" }, "snap: dotconfig extension")
Test.assertEqualMsg(".eslintrc", lib.stem(".eslintrc.json"), "snap: dotconfig stem")
}
// Snapshot: path join normalization
fn test_snapshot_join_normalization(): Unit with {Test} = {
Test.assertEqualMsg("/a/b", lib.join("/a", "b"), "snap: normal join")
Test.assertEqualMsg("/a/b", lib.join("/a/", "b"), "snap: trailing slash join")
Test.assertEqualMsg("/a/b", lib.join("/a", "/b"), "snap: leading slash join")
Test.assertEqualMsg("/a/b", lib.join("/a/", "/b"), "snap: both slash join")
Test.assertEqualMsg("b", lib.join("", "b"), "snap: empty first join")
Test.assertEqualMsg("a", lib.join("a", ""), "snap: empty second join")
Test.assertEqualMsg("", lib.join("", ""), "snap: both empty join")
}

144
packages/path/test_unit.lux Normal file
View File

@@ -0,0 +1,144 @@
import lib
// --- basename edge cases ---
fn test_basename_root(): Unit with {Test} =
Test.assertEqualMsg("", lib.basename("/"), "basename of root")
fn test_basename_multiple_slashes(): Unit with {Test} =
Test.assertEqualMsg("", lib.basename("///"), "basename of ///")
fn test_basename_dotfile(): Unit with {Test} =
Test.assertEqualMsg(".hidden", lib.basename("/home/.hidden"), "basename of dotfile")
fn test_basename_dots_in_name(): Unit with {Test} =
Test.assertEqualMsg("file.tar.gz", lib.basename("/path/to/file.tar.gz"), "basename with multiple dots")
fn test_basename_single_char(): Unit with {Test} =
Test.assertEqualMsg("x", lib.basename("x"), "basename of single char")
fn test_basename_deep_path(): Unit with {Test} =
Test.assertEqualMsg("deep.txt", lib.basename("/a/b/c/d/e/deep.txt"), "basename of deep path")
fn test_basename_space_in_name(): Unit with {Test} =
Test.assertEqualMsg("my file.txt", lib.basename("/path/my file.txt"), "basename with space")
// --- dirname edge cases ---
fn test_dirname_root(): Unit with {Test} =
Test.assertEqualMsg("/", lib.dirname("/"), "dirname of root")
fn test_dirname_root_file(): Unit with {Test} =
Test.assertEqualMsg("/", lib.dirname("/a"), "dirname of root-level file")
fn test_dirname_deep(): Unit with {Test} =
Test.assertEqualMsg("/a/b/c", lib.dirname("/a/b/c/d"), "dirname of deep path")
fn test_dirname_empty(): Unit with {Test} =
Test.assertEqualMsg(".", lib.dirname(""), "dirname of empty")
fn test_dirname_relative(): Unit with {Test} =
Test.assertEqualMsg("a/b", lib.dirname("a/b/c"), "dirname of relative path")
// --- extension edge cases ---
fn isSome(opt: Option<String>, expected: String): Bool =
match opt {
Some(v) => v == expected,
None => false,
}
fn isNone(opt: Option<String>): Bool =
match opt {
Some(_) => false,
None => true,
}
fn test_extension_dotfile(): Unit with {Test} =
Test.assert(isNone(lib.extension(".bashrc")), "extension of .bashrc is None")
fn test_extension_trailing_dot(): Unit with {Test} =
Test.assert(isSome(lib.extension("file."), ""), "extension of file. is empty string")
fn test_extension_multiple_dots(): Unit with {Test} =
Test.assert(isSome(lib.extension("archive.tar.gz"), "gz"), "extension of .tar.gz is gz")
fn test_extension_empty(): Unit with {Test} =
Test.assert(isNone(lib.extension("")), "extension of empty is None")
fn test_extension_no_dot(): Unit with {Test} =
Test.assert(isNone(lib.extension("Makefile")), "extension of Makefile is None")
fn test_extension_hidden_with_ext(): Unit with {Test} =
Test.assert(isSome(lib.extension(".config.json"), "json"), "extension of .config.json is json")
fn test_extension_dot_in_dir(): Unit with {Test} =
Test.assert(isSome(lib.extension("/path.d/file.txt"), "txt"), "extension through dotted dir")
// --- stripExtension edge cases ---
fn test_strip_extension_dotfile(): Unit with {Test} =
Test.assertEqualMsg("", lib.stripExtension(".bashrc"), "stripExtension of dotfile")
fn test_strip_extension_trailing_dot(): Unit with {Test} =
Test.assertEqualMsg("file", lib.stripExtension("file."), "stripExtension trailing dot")
fn test_strip_extension_double(): Unit with {Test} =
Test.assertEqualMsg("file.tar", lib.stripExtension("file.tar.gz"), "stripExtension only strips last")
fn test_strip_extension_dot_in_dir(): Unit with {Test} =
Test.assertEqualMsg("/usr/local.d/config", lib.stripExtension("/usr/local.d/config.ini"), "stripExtension with dot in dir")
// --- join edge cases ---
fn test_join_both_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.join("", ""), "join empty + empty")
fn test_join_double_slash(): Unit with {Test} =
Test.assertEqualMsg("/foo/bar", lib.join("/foo/", "/bar"), "join normalizes double slash")
fn test_join_root(): Unit with {Test} =
Test.assertEqualMsg("/bar", lib.join("/", "bar"), "join root + relative")
fn test_join_three_parts(): Unit with {Test} = {
let result = lib.join(lib.join("/a", "b"), "c")
Test.assertEqualMsg("/a/b/c", result, "three-part join")
}
// --- stem edge cases ---
fn test_stem_dotfile(): Unit with {Test} =
Test.assertEqualMsg(".hidden", lib.stem(".hidden"), "stem of dotfile preserved")
fn test_stem_multiple_dots(): Unit with {Test} =
Test.assertEqualMsg("file.tar", lib.stem("file.tar.gz"), "stem of .tar.gz")
fn test_stem_no_dir_no_ext(): Unit with {Test} =
Test.assertEqualMsg("README", lib.stem("README"), "stem of extensionless file")
// --- isAbsolute / isRelative edge cases ---
fn test_is_absolute_empty(): Unit with {Test} =
Test.assertEqualMsg(false, lib.isAbsolute(""), "empty is not absolute")
fn test_is_relative_empty(): Unit with {Test} =
Test.assertEqualMsg(true, lib.isRelative(""), "empty is relative")
fn test_is_absolute_just_slash(): Unit with {Test} =
Test.assertEqualMsg(true, lib.isAbsolute("/"), "/ is absolute")
// --- hasExtension / replaceExtension edge cases ---
fn test_has_extension_case_sensitive(): Unit with {Test} =
Test.assertEqualMsg(false, lib.hasExtension("file.TXT", "txt"), "hasExtension is case sensitive")
fn test_has_extension_partial(): Unit with {Test} =
Test.assertEqualMsg(false, lib.hasExtension("file.txta", "txt"), "hasExtension no partial match")
fn test_replace_extension_dotfile(): Unit with {Test} =
Test.assertEqualMsg(".md", lib.replaceExtension(".bashrc", "md"), "replaceExtension on dotfile")
fn test_replace_extension_chain(): Unit with {Test} = {
let result = lib.replaceExtension(lib.replaceExtension("file.txt", "md"), "html")
Test.assertEqualMsg("file.html", result, "chained replaceExtension")
}

3
packages/rss/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
_site/
.lux_packages/
*.bak

101
packages/rss/lib.lux Normal file
View File

@@ -0,0 +1,101 @@
// rss - RSS 2.0 feed generator for Lux
//
// Generates valid RSS 2.0 XML feeds from structured data.
import xml
// An RSS feed item
type Item =
| Item(String, String, String, String, String)
// Item(title, link, description, pubDate, guid)
// An RSS channel/feed
type Feed =
| Feed(String, String, String, String, List<Item>)
// Feed(title, link, description, language, items)
// Create a feed item
pub fn item(title: String, link: String, description: String, pubDate: String): Item =
Item(title, link, description, pubDate, link)
// Create a feed item with custom GUID
pub fn itemWithGuid(title: String, link: String, description: String, pubDate: String, guid: String): Item =
Item(title, link, description, pubDate, guid)
// Create an RSS feed
pub fn feed(title: String, link: String, description: String, items: List<Item>): Feed =
Feed(title, link, description, "en", items)
// Create an RSS feed with language
pub fn feedWithLang(title: String, link: String, description: String, language: String, items: List<Item>): Feed =
Feed(title, link, description, language, items)
// Access item fields
pub fn itemTitle(i: Item): String = match i { Item(t, _, _, _, _) => t, }
pub fn itemLink(i: Item): String = match i { Item(_, l, _, _, _) => l, }
pub fn itemDescription(i: Item): String = match i { Item(_, _, d, _, _) => d, }
pub fn itemPubDate(i: Item): String = match i { Item(_, _, _, p, _) => p, }
pub fn itemGuid(i: Item): String = match i { Item(_, _, _, _, g) => g, }
// Convert ISO date (2025-01-29) to RFC 822 format (29 Jan 2025 00:00:00 GMT)
pub fn isoToRfc822(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"
day + " " + monthName + " " + year + " 00:00:00 GMT"
}
// Render a single item to XML string
fn renderItemXml(i: Item): String =
match i {
Item(title, link, desc, pubDate, guid) =>
xml.render(xml.el("item", [
xml.textEl("title", title),
xml.textEl("link", link),
xml.textEl("description", desc),
xml.textEl("pubDate", isoToRfc822(pubDate)),
xml.textElAttr("guid", [xml.attr("isPermaLink", "true")], guid)
])),
}
// Render items to concatenated XML string
fn renderItemsXml(items: List<Item>): String =
match List.head(items) {
None => "",
Some(i) => renderItemXml(i) + match List.tail(items) {
Some(rest) => renderItemsXml(rest),
None => "",
},
}
// Render an RSS feed to XML string
pub fn render(f: Feed): String =
match f {
Feed(title, link, desc, lang, items) => {
let itemsXml = renderItemsXml(items)
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">" +
"<channel>" +
"<title>" + xml.escape(title) + "</title>" +
"<link>" + xml.escape(link) + "</link>" +
"<description>" + xml.escape(desc) + "</description>" +
"<language>" + lang + "</language>" +
"<generator>Lux RSS Package</generator>" +
itemsXml +
"</channel></rss>"
},
}

7
packages/rss/lux.lock Normal file
View File

@@ -0,0 +1,7 @@
# This file is auto-generated by lux pkg. Do not edit manually.
[[package]]
name = "xml"
version = "0.1.0"
source = "path:../xml"

9
packages/rss/lux.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "rss"
version = "0.1.0"
description = "RSS 2.0 feed generator for Lux"
authors = ["Brandon Lucas"]
license = "MIT"
[dependencies]
xml = { version = "0.1.0", path = "../xml" }

View File

@@ -0,0 +1,106 @@
import lib
fn contains(haystack: String, needle: String): Bool =
match String.indexOf(haystack, needle) {
Some(_) => true,
None => false,
}
// Build a complete blog RSS feed with multiple posts
fn test_full_blog_feed(): Unit with {Test} = {
let items = [
lib.item("Getting Started with Lux", "https://blog.example.com/getting-started", "Learn the basics of the Lux programming language", "2025-01-15"),
lib.item("Advanced Effects in Lux", "https://blog.example.com/advanced-effects", "Deep dive into algebraic effects", "2025-02-01"),
lib.item("Building a Static Site", "https://blog.example.com/static-site", "Create a blog with Lux", "2025-02-15")
]
let f = lib.feed("Lux Programming Blog", "https://blog.example.com", "Tips and tutorials about Lux", items)
let xml = lib.render(f)
Test.assert(contains(xml, "<?xml version=\"1.0\""), "has XML declaration")
Test.assert(contains(xml, "<rss version=\"2.0\""), "has RSS 2.0 tag")
Test.assert(contains(xml, "<title>Lux Programming Blog</title>"), "feed title")
Test.assert(contains(xml, "<link>https://blog.example.com</link>"), "feed link")
Test.assert(contains(xml, "<title>Getting Started with Lux</title>"), "first item title")
Test.assert(contains(xml, "<title>Advanced Effects in Lux</title>"), "second item title")
Test.assert(contains(xml, "<title>Building a Static Site</title>"), "third item title")
Test.assert(contains(xml, "15 Jan 2025 00:00:00 GMT"), "first item date in RFC 822")
Test.assert(contains(xml, "01 Feb 2025 00:00:00 GMT"), "second item date in RFC 822")
Test.assert(contains(xml, "<generator>Lux RSS Package</generator>"), "has generator tag")
}
// Feed with items containing custom GUIDs
fn test_feed_with_custom_guids(): Unit with {Test} = {
let items = [
lib.itemWithGuid("Post 1", "https://example.com/1", "Desc 1", "2025-01-01", "urn:uuid:1234"),
lib.itemWithGuid("Post 2", "https://example.com/2", "Desc 2", "2025-02-01", "urn:uuid:5678")
]
let f = lib.feed("Blog", "https://example.com", "A blog", items)
let xml = lib.render(f)
Test.assert(contains(xml, "urn:uuid:1234"), "first custom guid")
Test.assert(contains(xml, "urn:uuid:5678"), "second custom guid")
}
// Feed with special characters in all fields
fn test_feed_with_special_chars(): Unit with {Test} = {
let i = lib.item(
"Tom & Jerry's <Adventure>",
"https://example.com/tom&jerry",
"A \"great\" show with <characters>",
"2025-01-01"
)
let f = lib.feed(
"Kids' TV & More",
"https://example.com",
"Shows for <everyone>",
[i]
)
let xml = lib.render(f)
Test.assert(contains(xml, "&amp;"), "ampersand escaped somewhere")
Test.assert(contains(xml, "&lt;"), "angle bracket escaped somewhere")
}
// Multilingual feed
fn test_multilingual_feed(): Unit with {Test} = {
let f = lib.feedWithLang(
"Mon Blog",
"https://exemple.fr",
"Un blog en francais",
"fr",
[lib.item("Premier Article", "https://exemple.fr/premier", "Le debut", "2025-01-01")]
)
let xml = lib.render(f)
Test.assert(contains(xml, "<language>fr</language>"), "french language tag")
Test.assert(contains(xml, "<title>Premier Article</title>"), "french item title")
}
// Empty feed is still valid XML
fn test_empty_feed_structure(): Unit with {Test} = {
let f = lib.feed("Empty Blog", "https://example.com", "Nothing here yet", [])
let xml = lib.render(f)
Test.assert(contains(xml, "<?xml"), "has declaration")
Test.assert(contains(xml, "<rss"), "has rss tag")
Test.assert(contains(xml, "<channel>"), "has channel open")
Test.assert(contains(xml, "</channel>"), "has channel close")
Test.assert(contains(xml, "</rss>"), "has rss close")
Test.assert(contains(xml, "<title>Empty Blog</title>"), "has title")
}
// Feed with many items (stress test)
fn test_feed_many_items(): Unit with {Test} = {
let items = [
lib.item("Post 1", "https://example.com/1", "D1", "2025-01-01"),
lib.item("Post 2", "https://example.com/2", "D2", "2025-01-02"),
lib.item("Post 3", "https://example.com/3", "D3", "2025-01-03"),
lib.item("Post 4", "https://example.com/4", "D4", "2025-01-04"),
lib.item("Post 5", "https://example.com/5", "D5", "2025-01-05"),
lib.item("Post 6", "https://example.com/6", "D6", "2025-01-06"),
lib.item("Post 7", "https://example.com/7", "D7", "2025-01-07"),
lib.item("Post 8", "https://example.com/8", "D8", "2025-01-08"),
lib.item("Post 9", "https://example.com/9", "D9", "2025-01-09"),
lib.item("Post 10", "https://example.com/10", "D10", "2025-01-10")
]
let f = lib.feed("Big Blog", "https://example.com", "Many posts", items)
let xml = lib.render(f)
Test.assert(contains(xml, "<title>Post 1</title>"), "first item")
Test.assert(contains(xml, "<title>Post 10</title>"), "last item")
Test.assert(contains(xml, "10 Jan 2025"), "last item date")
}

67
packages/rss/test_rss.lux Normal file
View File

@@ -0,0 +1,67 @@
import lib
fn contains(haystack: String, needle: String): Bool =
match String.indexOf(haystack, needle) {
Some(_) => true,
None => false,
}
fn test_create_item(): Unit with {Test} = {
let i = lib.item("My Post", "https://example.com/post", "A description", "2025-01-29")
Test.assertEqualMsg("My Post", lib.itemTitle(i), "item title")
Test.assertEqualMsg("https://example.com/post", lib.itemLink(i), "item link")
Test.assertEqualMsg("A description", lib.itemDescription(i), "item description")
Test.assertEqualMsg("2025-01-29", lib.itemPubDate(i), "item pubDate")
Test.assertEqualMsg("https://example.com/post", lib.itemGuid(i), "item guid defaults to link")
}
fn test_item_with_guid(): Unit with {Test} = {
let i = lib.itemWithGuid("Post", "https://example.com", "Desc", "2025-01-29", "custom-guid-123")
Test.assertEqualMsg("custom-guid-123", lib.itemGuid(i), "item custom guid")
}
fn test_date_conversion(): Unit with {Test} = {
Test.assertEqualMsg("29 Jan 2025 00:00:00 GMT", lib.isoToRfc822("2025-01-29"), "date jan")
Test.assertEqualMsg("31 Dec 2025 00:00:00 GMT", lib.isoToRfc822("2025-12-31"), "date dec")
Test.assertEqualMsg("15 Jun 2025 00:00:00 GMT", lib.isoToRfc822("2025-06-15"), "date jun")
Test.assertEqualMsg("bad", lib.isoToRfc822("bad"), "date short string passthrough")
}
fn test_empty_feed(): Unit with {Test} = {
let emptyFeed = lib.feed("My Blog", "https://example.com", "Blog about things", [])
let xml = lib.render(emptyFeed)
Test.assert(contains(xml, "<?xml version=\"1.0\""), "xml declaration")
Test.assert(contains(xml, "<rss version=\"2.0\""), "rss tag")
Test.assert(contains(xml, "<title>My Blog</title>"), "feed title")
Test.assert(contains(xml, "<link>https://example.com</link>"), "feed link")
Test.assert(contains(xml, "<description>Blog about things</description>"), "feed description")
Test.assert(contains(xml, "<language>en</language>"), "feed language")
}
fn test_feed_with_items(): Unit with {Test} = {
let items = [
lib.item("First Post", "https://example.com/first", "First!", "2025-01-29"),
lib.item("Second Post", "https://example.com/second", "Second!", "2025-02-01")
]
let fullFeed = lib.feed("Blog", "https://example.com", "A blog", items)
let xml = lib.render(fullFeed)
Test.assert(contains(xml, "<item>"), "has item tag")
Test.assert(contains(xml, "<title>First Post</title>"), "first item title")
Test.assert(contains(xml, "<title>Second Post</title>"), "second item title")
Test.assert(contains(xml, "29 Jan 2025 00:00:00 GMT"), "rfc822 date in feed")
Test.assert(contains(xml, "<guid isPermaLink=\"true\">"), "guid with permalink")
}
fn test_feed_with_language(): Unit with {Test} = {
let frFeed = lib.feedWithLang("Mon Blog", "https://example.fr", "Un blog", "fr", [])
let xml = lib.render(frFeed)
Test.assert(contains(xml, "<language>fr</language>"), "custom language")
}
fn test_special_characters(): Unit with {Test} = {
let specialItem = lib.item("A & B <C>", "https://example.com", "\"quoted\" & <tagged>", "2025-01-01")
let specialFeed = lib.feed("Test", "https://example.com", "Test", [specialItem])
let xml = lib.render(specialFeed)
Test.assert(contains(xml, "&amp;"), "ampersand escaped")
Test.assert(contains(xml, "&lt;"), "angle bracket escaped")
}

View File

@@ -0,0 +1,103 @@
import lib
fn contains(haystack: String, needle: String): Bool =
match String.indexOf(haystack, needle) {
Some(_) => true,
None => false,
}
// Snapshot: complete single-item feed
fn test_snapshot_single_item_feed(): Unit with {Test} = {
let f = lib.feed(
"My Blog",
"https://example.com",
"A personal blog",
[lib.item("Hello World", "https://example.com/hello", "My first post", "2025-01-29")]
)
let xml = lib.render(f)
// Verify exact structure of the feed
Test.assert(String.startsWith(xml, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"), "snap: XML declaration")
Test.assert(contains(xml, "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">"), "snap: RSS opening tag")
Test.assert(contains(xml, "<channel>"), "snap: channel open")
Test.assert(contains(xml, "<title>My Blog</title>"), "snap: feed title")
Test.assert(contains(xml, "<link>https://example.com</link>"), "snap: feed link")
Test.assert(contains(xml, "<description>A personal blog</description>"), "snap: feed desc")
Test.assert(contains(xml, "<language>en</language>"), "snap: language")
Test.assert(contains(xml, "<generator>Lux RSS Package</generator>"), "snap: generator")
Test.assert(contains(xml, "<item>"), "snap: item open")
Test.assert(contains(xml, "<title>Hello World</title>"), "snap: item title")
Test.assert(contains(xml, "<link>https://example.com/hello</link>"), "snap: item link")
Test.assert(contains(xml, "<description>My first post</description>"), "snap: item desc")
Test.assert(contains(xml, "<pubDate>29 Jan 2025 00:00:00 GMT</pubDate>"), "snap: item pubDate")
Test.assert(contains(xml, "<guid isPermaLink=\"true\">https://example.com/hello</guid>"), "snap: item guid")
Test.assert(contains(xml, "</item>"), "snap: item close")
Test.assert(contains(xml, "</channel></rss>"), "snap: channel and rss close")
}
// Snapshot: empty feed
fn test_snapshot_empty_feed(): Unit with {Test} = {
let f = lib.feed("Empty", "https://example.com", "Nothing", [])
let xml = lib.render(f)
Test.assert(String.startsWith(xml, "<?xml version=\"1.0\""), "snap: starts with declaration")
// Empty feed should have no <item> tags
Test.assertEqualMsg(false, contains(xml, "<item>"), "snap: no item tags")
Test.assert(contains(xml, "<title>Empty</title>"), "snap: title present")
Test.assert(String.endsWith(xml, "</channel></rss>"), "snap: ends properly")
}
// Snapshot: feed with custom language
fn test_snapshot_french_feed(): Unit with {Test} = {
let f = lib.feedWithLang("Blog Francais", "https://exemple.fr", "Un blog", "fr", [
lib.item("Bonjour", "https://exemple.fr/bonjour", "Premier article", "2025-06-15")
])
let xml = lib.render(f)
Test.assert(contains(xml, "<language>fr</language>"), "snap: french language")
Test.assert(contains(xml, "<title>Bonjour</title>"), "snap: french item title")
Test.assert(contains(xml, "15 Jun 2025 00:00:00 GMT"), "snap: french item date")
}
// Snapshot: feed with escaped content
fn test_snapshot_escaped_feed(): Unit with {Test} = {
let f = lib.feed(
"Tom & Jerry",
"https://example.com",
"A <fun> show",
[lib.item("Episode: \"The Chase\"", "https://example.com/1", "Tom & Jerry's adventure", "2025-01-01")]
)
let xml = lib.render(f)
Test.assert(contains(xml, "<title>Tom &amp; Jerry</title>"), "snap: escaped feed title")
Test.assert(contains(xml, "<description>A &lt;fun&gt; show</description>"), "snap: escaped feed desc")
}
// Snapshot: multi-item feed order
fn test_snapshot_multi_item_order(): Unit with {Test} = {
let f = lib.feed("Blog", "https://example.com", "A blog", [
lib.item("First", "https://example.com/1", "D1", "2025-01-01"),
lib.item("Second", "https://example.com/2", "D2", "2025-02-01"),
lib.item("Third", "https://example.com/3", "D3", "2025-03-01")
])
let xml = lib.render(f)
// Items should appear in order
let firstIdx = match String.indexOf(xml, "<title>First</title>") { Some(i) => i, None => -1 }
let secondIdx = match String.indexOf(xml, "<title>Second</title>") { Some(i) => i, None => -1 }
let thirdIdx = match String.indexOf(xml, "<title>Third</title>") { Some(i) => i, None => -1 }
Test.assert(firstIdx > 0, "snap: first item found")
Test.assert(secondIdx > firstIdx, "snap: second after first")
Test.assert(thirdIdx > secondIdx, "snap: third after second")
}
// Snapshot: date conversion across all months
fn test_snapshot_all_month_dates(): Unit with {Test} = {
Test.assertEqualMsg("01 Jan 2025 00:00:00 GMT", lib.isoToRfc822("2025-01-01"), "snap: Jan")
Test.assertEqualMsg("01 Feb 2025 00:00:00 GMT", lib.isoToRfc822("2025-02-01"), "snap: Feb")
Test.assertEqualMsg("01 Mar 2025 00:00:00 GMT", lib.isoToRfc822("2025-03-01"), "snap: Mar")
Test.assertEqualMsg("01 Apr 2025 00:00:00 GMT", lib.isoToRfc822("2025-04-01"), "snap: Apr")
Test.assertEqualMsg("01 May 2025 00:00:00 GMT", lib.isoToRfc822("2025-05-01"), "snap: May")
Test.assertEqualMsg("01 Jun 2025 00:00:00 GMT", lib.isoToRfc822("2025-06-01"), "snap: Jun")
Test.assertEqualMsg("01 Jul 2025 00:00:00 GMT", lib.isoToRfc822("2025-07-01"), "snap: Jul")
Test.assertEqualMsg("01 Aug 2025 00:00:00 GMT", lib.isoToRfc822("2025-08-01"), "snap: Aug")
Test.assertEqualMsg("01 Sep 2025 00:00:00 GMT", lib.isoToRfc822("2025-09-01"), "snap: Sep")
Test.assertEqualMsg("01 Oct 2025 00:00:00 GMT", lib.isoToRfc822("2025-10-01"), "snap: Oct")
Test.assertEqualMsg("01 Nov 2025 00:00:00 GMT", lib.isoToRfc822("2025-11-01"), "snap: Nov")
Test.assertEqualMsg("01 Dec 2025 00:00:00 GMT", lib.isoToRfc822("2025-12-01"), "snap: Dec")
}

111
packages/rss/test_unit.lux Normal file
View File

@@ -0,0 +1,111 @@
import lib
// --- item creation ---
fn test_item_defaults_guid_to_link(): Unit with {Test} = {
let i = lib.item("Title", "https://example.com/post", "Desc", "2025-01-01")
Test.assertEqualMsg("https://example.com/post", lib.itemGuid(i), "guid defaults to link")
}
fn test_item_with_custom_guid(): Unit with {Test} = {
let i = lib.itemWithGuid("Title", "https://example.com", "Desc", "2025-01-01", "custom-123")
Test.assertEqualMsg("custom-123", lib.itemGuid(i), "custom guid preserved")
}
fn test_item_empty_fields(): Unit with {Test} = {
let i = lib.item("", "", "", "")
Test.assertEqualMsg("", lib.itemTitle(i), "empty title")
Test.assertEqualMsg("", lib.itemLink(i), "empty link")
Test.assertEqualMsg("", lib.itemDescription(i), "empty description")
Test.assertEqualMsg("", lib.itemPubDate(i), "empty pubDate")
}
fn test_item_special_chars(): Unit with {Test} = {
let i = lib.item("A & B <C>", "https://example.com?a=1&b=2", "\"quotes\" & <tags>", "2025-01-01")
Test.assertEqualMsg("A & B <C>", lib.itemTitle(i), "special chars in title preserved")
Test.assertEqualMsg("\"quotes\" & <tags>", lib.itemDescription(i), "special chars in desc preserved")
}
// --- all accessor functions ---
fn test_item_accessors(): Unit with {Test} = {
let i = lib.item("T", "L", "D", "P")
Test.assertEqualMsg("T", lib.itemTitle(i), "itemTitle")
Test.assertEqualMsg("L", lib.itemLink(i), "itemLink")
Test.assertEqualMsg("D", lib.itemDescription(i), "itemDescription")
Test.assertEqualMsg("P", lib.itemPubDate(i), "itemPubDate")
Test.assertEqualMsg("L", lib.itemGuid(i), "itemGuid defaults to link")
}
// --- date conversion for all months ---
fn test_date_january(): Unit with {Test} =
Test.assertEqualMsg("15 Jan 2025 00:00:00 GMT", lib.isoToRfc822("2025-01-15"), "January")
fn test_date_february(): Unit with {Test} =
Test.assertEqualMsg("28 Feb 2025 00:00:00 GMT", lib.isoToRfc822("2025-02-28"), "February")
fn test_date_march(): Unit with {Test} =
Test.assertEqualMsg("01 Mar 2025 00:00:00 GMT", lib.isoToRfc822("2025-03-01"), "March")
fn test_date_april(): Unit with {Test} =
Test.assertEqualMsg("30 Apr 2025 00:00:00 GMT", lib.isoToRfc822("2025-04-30"), "April")
fn test_date_may(): Unit with {Test} =
Test.assertEqualMsg("01 May 2025 00:00:00 GMT", lib.isoToRfc822("2025-05-01"), "May")
fn test_date_june(): Unit with {Test} =
Test.assertEqualMsg("15 Jun 2025 00:00:00 GMT", lib.isoToRfc822("2025-06-15"), "June")
fn test_date_july(): Unit with {Test} =
Test.assertEqualMsg("04 Jul 2025 00:00:00 GMT", lib.isoToRfc822("2025-07-04"), "July")
fn test_date_august(): Unit with {Test} =
Test.assertEqualMsg("20 Aug 2025 00:00:00 GMT", lib.isoToRfc822("2025-08-20"), "August")
fn test_date_september(): Unit with {Test} =
Test.assertEqualMsg("10 Sep 2025 00:00:00 GMT", lib.isoToRfc822("2025-09-10"), "September")
fn test_date_october(): Unit with {Test} =
Test.assertEqualMsg("31 Oct 2025 00:00:00 GMT", lib.isoToRfc822("2025-10-31"), "October")
fn test_date_november(): Unit with {Test} =
Test.assertEqualMsg("11 Nov 2025 00:00:00 GMT", lib.isoToRfc822("2025-11-11"), "November")
fn test_date_december(): Unit with {Test} =
Test.assertEqualMsg("25 Dec 2025 00:00:00 GMT", lib.isoToRfc822("2025-12-25"), "December")
fn test_date_short_string(): Unit with {Test} =
Test.assertEqualMsg("bad", lib.isoToRfc822("bad"), "short string passthrough")
fn test_date_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.isoToRfc822(""), "empty date passthrough")
fn test_date_partial(): Unit with {Test} =
Test.assertEqualMsg("2025-01", lib.isoToRfc822("2025-01"), "partial date passthrough")
// --- feed creation ---
fn test_feed_default_language(): Unit with {Test} = {
let f = lib.feed("Title", "https://example.com", "Desc", [])
let xml = lib.render(f)
Test.assert(String.contains(xml, "<language>en</language>"), "default language is en")
}
fn test_feed_custom_language(): Unit with {Test} = {
let f = lib.feedWithLang("Title", "https://example.com", "Desc", "fr", [])
let xml = lib.render(f)
Test.assert(String.contains(xml, "<language>fr</language>"), "custom language fr")
}
fn test_feed_escapes_title(): Unit with {Test} = {
let f = lib.feed("A & B", "https://example.com", "Desc", [])
let xml = lib.render(f)
Test.assert(String.contains(xml, "<title>A &amp; B</title>"), "feed title is escaped")
}
fn test_feed_escapes_description(): Unit with {Test} = {
let f = lib.feed("Title", "https://example.com", "<b>Bold</b> desc", [])
let xml = lib.render(f)
Test.assert(String.contains(xml, "&lt;b&gt;Bold&lt;/b&gt;"), "feed description is escaped")
}

204
packages/web/README.md Normal file
View File

@@ -0,0 +1,204 @@
# Lux Web Framework
A full-stack web framework for building websites with Lux. Provides routing, layouts, a blog engine, form processing, SEO helpers, static file serving, and middleware — all built on Lux's effect system.
## Quick Start
```lux
import web
fn main(): Unit with {Console, HttpServer, File} = {
let app = web.app("My Site", "A Lux website", "./static", "./content")
let app = web.addRoute(app, web.get("/", homePage))
let app = web.addRoute(app, web.get("/about", aboutPage))
web.serve(app, 8080)
}
fn homePage(req: web.Request): web.Response =
web.htmlResponse(200,
web.layout(myLayout, "Home | My Site", "Welcome",
web.hero("Welcome", "Build something great", "/about", "Learn More")
)
)
```
## Features
| Feature | Description |
|---------|-------------|
| **Effects-based** | Every handler declares its side effects (`File`, `Sql`, `HttpServer`) |
| **Type-safe** | Elm-style Html builder catches broken markup at compile time |
| **Zero JS** | Server-rendered HTML. No client bundle. Pages work without JavaScript |
| **Blog engine** | Markdown + frontmatter blog with zero config |
| **SQLite built-in** | `Sql` effect is a language primitive. No ORM setup |
| **Single binary** | Compile to C via `lux compile` for single binary deployment |
## API Reference
### `web.app` — Application Builder
```lux
web.app(title, description, staticDir, contentDir): App
web.addRoute(app, route): App
web.addMiddleware(app, middleware): App
web.serve(app, port): Unit with {Console, HttpServer, File}
```
### Route Builders
```lux
web.get(pattern, handler): Route
web.post(pattern, handler): Route
web.put(pattern, handler): Route
web.delete(pattern, handler): Route
```
### `web.page` — Layout & Components
```lux
web.layout(config, title, description, body): String
web.hero(title, subtitle, ctaUrl, ctaText): String
web.miniHero(title, subtitle): String
web.card(icon, title, description): String
web.cardGrid(cards): String
web.statsBar(stats): String
web.emailSignup(heading, buttonText, action): String
web.ctaSection(heading, ctaUrl, ctaText): String
```
### `web.blog` — Blog Engine
```lux
web.loadPosts(contentDir, section): List<Post> with {File}
web.latestPosts(contentDir, section, count): List<Post> with {File}
web.loadPost(contentDir, section, slug): Option<Post> with {File}
web.blogIndexHtml(posts): String
web.blogPostHtml(post): String
web.rssFeed(title, url, description, posts): String
```
### `web.form` — Form Processing
```lux
web.parseBody(body): List<(String, String)>
web.getField(fields, name): Option<String>
web.requireField(fields, name): Result<String, String>
web.validateEmail(email): Result<String, String>
web.sanitize(input): String
```
### `web.db` — Database Helpers
```lux
web.saveSubscriber(dbPath, email): Result<Unit, String> with {Sql}
web.getSubscribers(dbPath): List<String> with {Sql}
```
### `web.seo` — SEO Helpers
```lux
web.metaTags(config): String
web.structuredData(name, description, url): String
web.sitemap(pages): String
web.robots(sitemapUrl): String
```
### `web.static` — Static File Serving
```lux
web.serveStatic(basePath, requestPath): Response with {File}
web.mimeType(path): String
web.cacheHeaders(maxAge): List<(String, String)>
```
### `web.middleware` — Composable Middleware
```lux
web.logging(): Middleware with {Console}
web.cors(origin): Middleware
web.securityHeaders(): Middleware
```
### Response Helpers
```lux
web.htmlResponse(status, body): Response
web.jsonResponse(status, body): Response
web.textResponse(status, body): Response
web.redirect(location): Response
web.redirectPermanent(location): Response
```
## Guide: Build a Marketing Site
1. Create project structure:
```
my-site/
├── lux.toml
├── main.lux
├── content/blog/
├── static/styles.css
```
2. Add dependency in `lux.toml`:
```toml
[dependencies]
web = { version = "0.1.0", path = "../lux/packages/web" }
```
3. Define your routes and pages in `main.lux`
4. Add blog posts as markdown files in `content/blog/`
5. Run: `lux main.lux`
## Guide: Add a Blog
```lux
// Load and display blog posts
let app = web.addRoute(app, web.get("/blog", fn(req: web.Request): web.Response with {File} => {
let posts = web.loadPosts("./content", "blog")
web.htmlResponse(200, web.layout(myLayout, "Blog", "Our blog",
web.miniHero("Blog", "Latest posts") + web.blogIndexHtml(posts)
))
}))
// Individual post pages
let app = web.addRoute(app, web.get("/blog/:slug", fn(req: web.Request): web.Response with {File} => {
let slug = web.getPathParam(req.path, "/blog/:slug", "slug")
match slug {
Some(s) => match web.loadPost("./content", "blog", s) {
Some(post) => web.htmlResponse(200, web.layout(myLayout, post.title, post.excerpt, web.blogPostHtml(post))),
None => web.htmlResponse(404, "Not found")
},
None => web.htmlResponse(404, "Not found")
}
}))
```
## Guide: Handle Form Submissions
```lux
let app = web.addRoute(app, web.post("/api/subscribe", fn(req: web.Request): web.Response with {Sql} => {
let fields = web.parseBody(req.body)
match web.getField(fields, "email") {
Some(email) => match web.validateEmail(email) {
Ok(validEmail) => {
web.saveSubscriber("data/subscribers.db", validEmail)
web.redirect("/thanks")
},
Err(msg) => web.htmlResponse(400, msg)
},
None => web.htmlResponse(400, "Email required")
}
}))
```
## Architecture
The framework is built on Lux's algebraic effect system:
- **`HttpServer` effect** — Accepts connections and sends responses
- **`File` effect** — Reads static files and markdown content
- **`Sql` effect** — SQLite database for form submissions
- **`Console` effect** — Request logging
Effects are declared in function signatures, making it clear what each handler does. For testing, swap effect handlers to mock the database, filesystem, or HTTP server.

9
packages/web/lux.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "web"
version = "0.1.0"
description = "Full-stack web framework for Lux — routing, layouts, blog engine, forms, SEO, and static file serving"
[dependencies]
markdown = { version = "0.1.0", path = "../markdown" }
frontmatter = { version = "0.1.0", path = "../frontmatter" }
path = { version = "0.1.0", path = "../path" }

654
packages/web/web.lux Normal file
View File

@@ -0,0 +1,654 @@
// Lux Web Framework
//
// A full-stack web framework for building websites with Lux.
// Provides routing, layouts, blog engine, forms, SEO, static file serving,
// and middleware — all built on Lux's effect system.
//
// Usage:
// import web
//
// fn main(): Unit with {Console, HttpServer, File} = {
// let app = web.app("My Site", "A Lux website", "./static", "./content")
// let app = web.addRoute(app, web.get("/", homePage))
// web.serve(app, 8080)
// }
import markdown
import frontmatter
import path
// ============================================================
// Core Types
// ============================================================
pub type App =
| App(String, String, String, String, List<Route>, List<Middleware>)
// title desc staticDir contentDir routes middleware
pub type Route =
| Route(String, String, fn(Request): Response)
// method pattern handler
pub type Request = {
method: String,
path: String,
query: List<(String, String)>,
headers: List<(String, String)>,
body: String
}
pub type Response = {
status: Int,
headers: List<(String, String)>,
body: String
}
pub type Middleware = fn(fn(Request): Response): fn(Request): Response
pub type Post = {
title: String,
date: String,
slug: String,
tags: List<String>,
excerpt: String,
content: String
}
pub type SeoConfig = {
title: String,
description: String,
url: String,
ogImage: String,
siteName: String
}
pub type LayoutConfig = {
siteTitle: String,
navLinks: List<(String, String)>,
footerHtml: String,
cssPath: String
}
// ============================================================
// web.app — Application Builder
// ============================================================
pub fn app(title: String, description: String, staticDir: String, contentDir: String): App =
App(title, description, staticDir, contentDir, [], [])
pub fn addRoute(application: App, route: Route): App =
match application {
App(t, d, s, c, routes, mw) => App(t, d, s, c, List.concat(routes, [route]), mw)
}
pub fn addMiddleware(application: App, middleware: Middleware): App =
match application {
App(t, d, s, c, routes, mw) => App(t, d, s, c, routes, List.concat(mw, [middleware]))
}
pub fn serve(application: App, port: Int): Unit with {Console, HttpServer, File} = {
match application {
App(title, _, _, _, _, _) => {
Console.print("=== " + title + " ===")
HttpServer.listen(port)
Console.print("Listening on http://localhost:" + toString(port))
appLoop(application)
}
}
}
fn appLoop(application: App): Unit with {Console, HttpServer, File} = {
let rawReq = HttpServer.accept()
let req = parseRequest(rawReq.method, rawReq.path, rawReq.headers, rawReq.body)
Console.print(req.method + " " + req.path)
let handler = buildHandler(application)
let resp = handler(req)
HttpServer.respondWithHeaders(resp.status, resp.body, resp.headers)
appLoop(application)
}
fn buildHandler(application: App): fn(Request): Response with {File} =
match application {
App(_, _, staticDir, _, routes, middleware) => {
let baseHandler = fn(req: Request): Response with {File} => {
// Check static files first
if String.startsWith(req.path, "/static/") then
serveStatic(staticDir, req.path)
else
matchAndHandle(req, routes)
}
applyMiddleware(baseHandler, middleware)
}
}
fn applyMiddleware(handler: fn(Request): Response, middleware: List<Middleware>): fn(Request): Response =
List.fold(middleware, handler, fn(h: fn(Request): Response, mw: Middleware): fn(Request): Response => mw(h))
fn matchAndHandle(req: Request, routes: List<Route>): Response =
match List.head(routes) {
None => htmlResponse(404, "<h1>Not Found</h1>"),
Some(route) => match route {
Route(method, pattern, handler) =>
if method == req.method && pathMatches(req.path, pattern) then
handler(req)
else
matchAndHandle(req, Option.getOrElse(List.tail(routes), []))
}
}
// ============================================================
// Route Builders
// ============================================================
pub fn get(pattern: String, handler: fn(Request): Response): Route =
Route("GET", pattern, handler)
pub fn post(pattern: String, handler: fn(Request): Response): Route =
Route("POST", pattern, handler)
pub fn put(pattern: String, handler: fn(Request): Response): Route =
Route("PUT", pattern, handler)
pub fn delete(pattern: String, handler: fn(Request): Response): Route =
Route("DELETE", pattern, handler)
// ============================================================
// web.page — Page Builder / Layout System
// ============================================================
pub fn layout(config: LayoutConfig, title: String, description: String, bodyContent: String): String = {
let navLinksHtml = List.fold(config.navLinks, "", fn(acc: String, link: (String, String)): String =>
match link {
(href, label) => acc + "<a href=\"" + href + "\" class=\"hover:text-[#f97316] transition-colors\">" + label + "</a>"
}
)
"<!doctype html><html lang=\"en\"><head>" +
"<meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
"<title>" + title + "</title>" +
"<meta name=\"description\" content=\"" + description + "\">" +
"<link href=\"" + config.cssPath + "\" rel=\"stylesheet\" type=\"text/css\">" +
"</head><body class=\"bg-white text-[#1f2937] min-h-screen flex flex-col\">" +
"<nav class=\"w-full bg-[#1e3a5f] text-white sticky top-0 z-50\">" +
"<div class=\"max-w-6xl mx-auto px-6 py-4 flex items-center justify-between\">" +
"<a href=\"/\" class=\"text-2xl font-bold tracking-tight\">" + config.siteTitle + "</a>" +
"<div class=\"hidden md:flex items-center gap-6 text-sm font-medium\">" + navLinksHtml + "</div>" +
"</div></nav>" +
"<main class=\"flex-1\">" + bodyContent + "</main>" +
config.footerHtml +
"</body></html>"
}
pub fn hero(title: String, subtitle: String, ctaUrl: String, ctaText: String): String =
"<section class=\"w-full bg-gradient-to-br from-[#1e3a5f] to-[#0f1f36] text-white py-20\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h1 class=\"text-4xl md:text-6xl font-bold leading-tight\">" + title + "</h1>" +
"<p class=\"text-xl md:text-2xl text-gray-300\">" + subtitle + "</p>" +
"<a href=\"" + ctaUrl + "\" class=\"inline-block bg-[#f97316] hover:bg-[#ea580c] text-white font-bold py-3 px-8 rounded-lg transition-colors text-lg mx-auto\">" + ctaText + "</a>" +
"</div></section>"
pub fn miniHero(title: String, subtitle: String): String =
"<section class=\"w-full bg-gradient-to-br from-[#1e3a5f] to-[#0f1f36] text-white py-16\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-4\">" +
"<h1 class=\"text-3xl md:text-5xl font-bold\">" + title + "</h1>" +
"<p class=\"text-lg md:text-xl text-gray-300\">" + subtitle + "</p>" +
"</div></section>"
pub fn cardGrid(cards: List<String>): String = {
let cardsHtml = String.join(cards, "")
"<div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\">" + cardsHtml + "</div>"
}
pub fn card(icon: String, title: String, description: String): String =
"<div class=\"bg-white border border-gray-200 rounded-xl p-6 flex flex-col gap-4 shadow-sm hover:shadow-md transition-shadow\">" +
"<div class=\"text-4xl\">" + icon + "</div>" +
"<h3 class=\"text-xl font-bold text-[#1e3a5f]\">" + title + "</h3>" +
"<p class=\"text-gray-600\">" + description + "</p>" +
"</div>"
pub fn statsBar(stats: List<(String, String)>): String = {
let statsHtml = List.fold(stats, "", fn(acc: String, stat: (String, String)): String =>
match stat {
(number, label) => acc + "<div class=\"text-center\"><div class=\"text-3xl font-bold text-[#f97316]\">" + number + "</div><div class=\"text-sm text-gray-300 mt-1\">" + label + "</div></div>"
}
)
"<section class=\"w-full py-12 bg-[#1e3a5f] text-white\">" +
"<div class=\"max-w-4xl mx-auto px-6\">" +
"<div class=\"grid grid-cols-3 gap-8 text-center\">" + statsHtml + "</div>" +
"</div></section>"
}
pub fn emailSignup(heading: String, buttonText: String, action: String): String =
"<section class=\"w-full bg-[#1e3a5f] text-white py-16\">" +
"<div class=\"max-w-2xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h2 class=\"text-3xl font-bold\">" + heading + "</h2>" +
"<form action=\"" + action + "\" method=\"POST\" class=\"flex flex-col sm:flex-row gap-3 max-w-md mx-auto w-full\">" +
"<input type=\"email\" name=\"email\" placeholder=\"your@email.com\" required=\"\" class=\"flex-1 px-4 py-3 rounded-lg text-gray-900 placeholder-gray-400\">" +
"<button type=\"submit\" class=\"bg-[#f97316] hover:bg-[#ea580c] text-white font-bold py-3 px-6 rounded-lg transition-colors\">" + buttonText + "</button>" +
"</form></div></section>"
pub fn ctaSection(heading: String, ctaUrl: String, ctaText: String): String =
"<section class=\"w-full bg-[#f97316] text-white py-16\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h2 class=\"text-3xl font-bold\">" + heading + "</h2>" +
"<a href=\"" + ctaUrl + "\" class=\"inline-block bg-white text-[#f97316] font-bold py-3 px-8 rounded-lg hover:bg-gray-100 transition-colors text-lg mx-auto\">" + ctaText + "</a>" +
"</div></section>"
// ============================================================
// web.blog — Blog Engine
// ============================================================
pub fn loadPosts(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"))
let posts = loadPostFiles(dir, mdFiles)
sortPosts(posts)
} else []
}
pub fn latestPosts(contentDir: String, section: String, count: Int): List<Post> with {File} =
List.take(loadPosts(contentDir, section), count)
pub fn loadPost(contentDir: String, section: String, slug: String): Option<Post> with {File} = {
let filePath = contentDir + "/" + section + "/" + slug + ".md"
if File.exists(filePath) then {
let raw = File.read(filePath)
let doc = frontmatter.parse(raw)
let title = frontmatter.title(doc)
let date = frontmatter.date(doc)
let excerpt = frontmatter.getOrDefault(doc, "description", "")
let tagsStr = frontmatter.getOrDefault(doc, "tags", "")
let tags = if tagsStr == "" then [] else String.split(tagsStr, " ")
let htmlContent = markdown.toHtml(frontmatter.body(doc))
Some({ title: title, date: date, slug: slug, tags: tags, excerpt: excerpt, content: htmlContent })
} else None
}
fn loadPostFiles(dir: String, files: List<String>): List<Post> with {File} =
match List.head(files) {
None => [],
Some(filename) => {
let raw = File.read(dir + "/" + filename)
let doc = frontmatter.parse(raw)
let title = frontmatter.title(doc)
let date = frontmatter.date(doc)
let excerpt = frontmatter.getOrDefault(doc, "description", "")
let tagsStr = frontmatter.getOrDefault(doc, "tags", "")
let tags = if tagsStr == "" then [] else String.split(tagsStr, " ")
let slug = path.stripExtension(filename)
let htmlContent = markdown.toHtml(frontmatter.body(doc))
let post = { title: title, date: date, slug: slug, tags: tags, excerpt: excerpt, content: htmlContent }
match List.tail(files) {
Some(rest) => List.concat([post], loadPostFiles(dir, rest)),
None => [post]
}
}
}
fn sortPosts(posts: List<Post>): List<Post> =
List.fold(posts, [], fn(sorted: List<Post>, item: Post): List<Post> =>
insertPost(sorted, item)
)
fn insertPost(sorted: List<Post>, item: Post): List<Post> = {
match List.head(sorted) {
None => [item],
Some(first) =>
if item.date >= first.date then
List.concat([item], sorted)
else
match List.tail(sorted) {
Some(rest) => List.concat([first], insertPost(rest, item)),
None => [first, item]
}
}
}
pub fn blogIndexHtml(posts: List<Post>): String = {
let postsHtml = List.fold(posts, "", fn(acc: String, post: Post): String =>
acc + "<div class=\"border-b border-gray-200 pb-6\">" +
"<a href=\"/blog/" + post.slug + "\" class=\"block hover:bg-gray-50 rounded-lg p-4 -mx-4 transition-colors\">" +
"<div class=\"flex flex-col gap-2\">" +
"<span class=\"text-sm text-gray-400\">" + post.date + "</span>" +
"<h2 class=\"text-xl font-bold text-[#1e3a5f]\">" + post.title + "</h2>" +
"<p class=\"text-gray-600\">" + post.excerpt + "</p>" +
"</div></a></div>"
)
"<div class=\"max-w-3xl mx-auto px-6 py-16 flex flex-col gap-6\">" + postsHtml + "</div>"
}
pub fn blogPostHtml(post: Post): String =
"<article class=\"w-full py-16\">" +
"<div class=\"max-w-3xl mx-auto px-6\">" +
"<div class=\"mb-8\"><span class=\"text-sm text-gray-400\">" + post.date + "</span>" +
"<h1 class=\"text-3xl font-bold text-[#1e3a5f] mt-2\">" + post.title + "</h1></div>" +
"<div class=\"prose prose-lg max-w-none\">" + post.content + "</div>" +
"</div></article>"
pub fn rssFeed(siteTitle: String, siteUrl: String, description: String, posts: List<Post>): String = {
let items = List.fold(posts, "", fn(acc: String, post: Post): String =>
acc + "<item>" +
"<title>" + escapeXml(post.title) + "</title>" +
"<link>" + siteUrl + "/blog/" + post.slug + "</link>" +
"<description>" + escapeXml(post.excerpt) + "</description>" +
"<pubDate>" + post.date + "</pubDate>" +
"</item>"
)
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<rss version=\"2.0\"><channel>" +
"<title>" + escapeXml(siteTitle) + "</title>" +
"<link>" + siteUrl + "</link>" +
"<description>" + escapeXml(description) + "</description>" +
items +
"</channel></rss>"
}
fn escapeXml(s: String): String = {
let s1 = String.replace(s, "&", "&amp;")
let s2 = String.replace(s1, "<", "&lt;")
let s3 = String.replace(s2, ">", "&gt;")
s3
}
// ============================================================
// web.form — Form Processing
// ============================================================
pub fn parseBody(body: String): List<(String, String)> = {
let pairs = String.split(body, "&")
List.filterMap(pairs, fn(pair: String): Option<(String, String)> => {
match String.indexOf(pair, "=") {
None => None,
Some(idx) => {
let key = String.substring(pair, 0, idx)
let value = String.substring(pair, idx + 1, String.length(pair))
Some((urlDecode(key), urlDecode(value)))
}
}
})
}
pub fn getField(fields: List<(String, String)>, name: String): Option<String> =
match List.head(fields) {
None => None,
Some(pair) => match pair {
(k, v) => if k == name then Some(v)
else getField(Option.getOrElse(List.tail(fields), []), name)
}
}
pub fn requireField(fields: List<(String, String)>, name: String): Result<String, String> =
match getField(fields, name) {
Some(v) => if String.length(v) > 0 then Ok(v) else Err(name + " is required"),
None => Err(name + " is required")
}
pub fn validateEmail(email: String): Result<String, String> =
if String.contains(email, "@") && String.contains(email, ".") then Ok(email)
else Err("Invalid email address")
pub fn sanitize(input: String): String = {
let s1 = String.replace(input, "'", "''")
let s2 = String.replace(s1, ";", "")
let s3 = String.replace(s2, "--", "")
let s4 = String.replace(s3, "<", "&lt;")
let s5 = String.replace(s4, ">", "&gt;")
s5
}
fn urlDecode(s: String): String =
String.replace(s, "+", " ")
// ============================================================
// web.db — Database Helpers
// ============================================================
pub fn initDb(dbPath: String): Unit with {Sql} = {
let db = Sql.open(dbPath)
db
}
pub fn saveSubscriber(dbPath: String, email: String): Result<Unit, String> with {Sql} = {
let sanitized = sanitize(email)
let db = Sql.open(dbPath)
Sql.execute(db, "CREATE TABLE IF NOT EXISTS subscribers (email TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP)")
Sql.execute(db, "INSERT OR IGNORE INTO subscribers (email) VALUES ('" + sanitized + "')")
Sql.close(db)
Ok(())
}
pub fn getSubscribers(dbPath: String): List<String> with {Sql} = {
let db = Sql.open(dbPath)
Sql.execute(db, "CREATE TABLE IF NOT EXISTS subscribers (email TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP)")
let rows = Sql.query(db, "SELECT email FROM subscribers ORDER BY created_at DESC")
Sql.close(db)
List.map(rows, fn(row: List<String>): String =>
match List.head(row) {
Some(email) => email,
None => ""
}
)
}
// ============================================================
// web.seo — SEO Helpers
// ============================================================
pub fn metaTags(config: SeoConfig): String =
"<meta name=\"description\" content=\"" + config.description + "\">" +
"<meta property=\"og:title\" content=\"" + config.title + "\">" +
"<meta property=\"og:description\" content=\"" + config.description + "\">" +
"<meta property=\"og:type\" content=\"website\">" +
"<meta property=\"og:url\" content=\"" + config.url + "\">" +
"<meta property=\"og:image\" content=\"" + config.ogImage + "\">" +
"<meta property=\"og:site_name\" content=\"" + config.siteName + "\">" +
"<meta name=\"twitter:card\" content=\"summary_large_image\">" +
"<meta name=\"twitter:title\" content=\"" + config.title + "\">" +
"<meta name=\"twitter:description\" content=\"" + config.description + "\">" +
"<link rel=\"canonical\" href=\"" + config.url + "\">"
pub fn structuredData(name: String, description: String, url: String): String =
"<script type=\"application/ld+json\">" +
"{\"@context\":\"https://schema.org\"," +
"\"@type\":\"Organization\"," +
"\"name\":\"" + name + "\"," +
"\"description\":\"" + description + "\"," +
"\"url\":\"" + url + "\"}" +
"</script>"
pub fn sitemap(pages: List<(String, String)>): String = {
let entries = List.fold(pages, "", fn(acc: String, page: (String, String)): String =>
match page {
(url, lastmod) => acc + "<url><loc>" + escapeXml(url) + "</loc><lastmod>" + lastmod + "</lastmod></url>"
}
)
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">" +
entries +
"</urlset>"
}
pub fn robots(sitemapUrl: String): String =
"User-agent: *\nAllow: /\nSitemap: " + sitemapUrl
// ============================================================
// web.static — Static Asset Serving
// ============================================================
pub fn serveStatic(basePath: String, requestPath: String): Response with {File} = {
let relPath = if String.startsWith(requestPath, "/static/") then
String.substring(requestPath, 8, String.length(requestPath))
else requestPath
let filePath = basePath + "/" + relPath
if File.exists(filePath) then {
let content = File.read(filePath)
let mime = mimeType(filePath)
{ status: 200, headers: [("Content-Type", mime)], body: content }
} else
{ status: 404, headers: [("Content-Type", "text/plain")], body: "Not Found" }
}
pub fn mimeType(filePath: String): String = {
let ext = fileExtension(filePath)
if ext == "css" then "text/css; charset=utf-8"
else if ext == "js" then "application/javascript; charset=utf-8"
else if ext == "json" then "application/json; charset=utf-8"
else if ext == "svg" then "image/svg+xml"
else if ext == "png" then "image/png"
else if ext == "jpg" then "image/jpeg"
else if ext == "jpeg" then "image/jpeg"
else if ext == "gif" then "image/gif"
else if ext == "ico" then "image/x-icon"
else if ext == "woff" then "font/woff"
else if ext == "woff2" then "font/woff2"
else if ext == "ttf" then "font/ttf"
else if ext == "html" then "text/html; charset=utf-8"
else if ext == "xml" then "application/xml"
else if ext == "txt" then "text/plain; charset=utf-8"
else "application/octet-stream"
}
pub fn cacheHeaders(maxAge: Int): List<(String, String)> =
[("Cache-Control", "public, max-age=" + toString(maxAge))]
fn fileExtension(filePath: String): String =
match String.lastIndexOf(filePath, ".") {
None => "",
Some(idx) => String.toLower(String.substring(filePath, idx + 1, String.length(filePath)))
}
// ============================================================
// web.middleware — Composable Middleware
// ============================================================
pub fn logging(): Middleware with {Console} =
fn(handler: fn(Request): Response): fn(Request): Response =>
fn(req: Request): Response => {
Console.print("[HTTP] " + req.method + " " + req.path)
let resp = handler(req)
Console.print("[HTTP] " + toString(resp.status))
resp
}
pub fn cors(origin: String): Middleware =
fn(handler: fn(Request): Response): fn(Request): Response =>
fn(req: Request): Response => {
if req.method == "OPTIONS" then
{ status: 204, headers: [
("Access-Control-Allow-Origin", origin),
("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
("Access-Control-Allow-Headers", "Content-Type, Authorization")
], body: "" }
else {
let resp = handler(req)
{ status: resp.status, headers: List.concat(resp.headers, [("Access-Control-Allow-Origin", origin)]), body: resp.body }
}
}
pub fn securityHeaders(): Middleware =
fn(handler: fn(Request): Response): fn(Request): Response =>
fn(req: Request): Response => {
let resp = handler(req)
{ status: resp.status, headers: List.concat(resp.headers, [
("X-Frame-Options", "DENY"),
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "strict-origin-when-cross-origin")
]), body: resp.body }
}
// ============================================================
// Response Helpers
// ============================================================
pub fn htmlResponse(status: Int, body: String): Response =
{ status: status, headers: [("Content-Type", "text/html; charset=utf-8")], body: body }
pub fn jsonResponse(status: Int, body: String): Response =
{ status: status, headers: [("Content-Type", "application/json; charset=utf-8")], body: body }
pub fn textResponse(status: Int, body: String): Response =
{ status: status, headers: [("Content-Type", "text/plain; charset=utf-8")], body: body }
pub fn redirect(location: String): Response =
{ status: 302, headers: [("Location", location)], body: "" }
pub fn redirectPermanent(location: String): Response =
{ status: 301, headers: [("Location", location)], body: "" }
// ============================================================
// Path Matching (from http.lux stdlib)
// ============================================================
fn pathMatches(reqPath: String, pattern: String): Bool = {
let pathParts = String.split(reqPath, "/")
let patternParts = String.split(pattern, "/")
if List.length(pathParts) != List.length(patternParts) then false
else matchParts(pathParts, patternParts)
}
fn matchParts(pathParts: List<String>, patternParts: List<String>): Bool = {
if List.length(pathParts) == 0 then true
else {
match List.head(pathParts) {
None => true,
Some(pp) => match List.head(patternParts) {
None => true,
Some(pat) => {
let isMatch = if String.startsWith(pat, ":") then true else pp == pat
if isMatch then {
let restPath = Option.getOrElse(List.tail(pathParts), [])
let restPattern = Option.getOrElse(List.tail(patternParts), [])
matchParts(restPath, restPattern)
} else false
}
}
}
}
}
pub fn getPathParam(reqPath: String, pattern: String, paramName: String): Option<String> = {
let pathParts = String.split(reqPath, "/")
let patternParts = String.split(pattern, "/")
extractParam(pathParts, patternParts, paramName)
}
fn extractParam(pathParts: List<String>, patternParts: List<String>, paramName: String): Option<String> = {
if List.length(pathParts) == 0 || List.length(patternParts) == 0 then None
else {
match List.head(pathParts) {
None => None,
Some(pp) => match List.head(patternParts) {
None => None,
Some(pat) => {
if String.startsWith(pat, ":") then {
let name = String.substring(pat, 1, String.length(pat))
if name == paramName then Some(pp)
else extractParam(Option.getOrElse(List.tail(pathParts), []), Option.getOrElse(List.tail(patternParts), []), paramName)
} else
extractParam(Option.getOrElse(List.tail(pathParts), []), Option.getOrElse(List.tail(patternParts), []), paramName)
}
}
}
}
}
// ============================================================
// Request Parsing
// ============================================================
fn parseRequest(method: String, fullPath: String, headers: List<(String, String)>, body: String): Request = {
let (cleanPath, query) = parseQueryString(fullPath)
{ method: method, path: cleanPath, query: query, headers: headers, body: body }
}
fn parseQueryString(fullPath: String): (String, List<(String, String)>) =
match String.indexOf(fullPath, "?") {
None => (fullPath, []),
Some(idx) => {
let p = String.substring(fullPath, 0, idx)
let qs = String.substring(fullPath, idx + 1, String.length(fullPath))
(p, parseBody(qs))
}
}

3
packages/xml/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
_site/
.lux_packages/
*.bak

109
packages/xml/lib.lux Normal file
View File

@@ -0,0 +1,109 @@
// xml - XML builder for Lux
//
// Provides a simple API for constructing well-formed XML documents.
// Designed for generating RSS feeds, sitemaps, and other XML output.
// An XML attribute (key-value pair)
type Attr =
| Attr(String, String)
// An XML node: element with tag, attributes, and children, or text content
type Node =
| Element(String, List<Attr>, List<Node>)
| Text(String)
| CData(String)
| Raw(String)
// Escape special XML characters in text content
pub fn escape(s: String): String =
String.replace(
String.replace(
String.replace(
String.replace(
String.replace(s, "&", "&amp;"),
"<", "&lt;"),
">", "&gt;"),
"\"", "&quot;"),
"'", "&apos;")
// Create a text node
pub fn text(content: String): Node = Text(content)
// Create a CDATA section
pub fn cdata(content: String): Node = CData(content)
// Create a raw (unescaped) node
pub fn raw(content: String): Node = Raw(content)
// Create an element with tag, attributes, and children
pub fn element(tag: String, attrs: List<Attr>, children: List<Node>): Node =
Element(tag, attrs, children)
// Create an element with no attributes
pub fn el(tag: String, children: List<Node>): Node =
Element(tag, [], children)
// Create a self-closing element with attributes and no children
pub fn selfClosing(tag: String, attrs: List<Attr>): Node =
Element(tag, attrs, [])
// Create an attribute
pub fn attr(key: String, value: String): Attr =
Attr(key, value)
// Create an element with text content
pub fn textEl(tag: String, content: String): Node =
Element(tag, [], [Text(content)])
// Create an element with attributes and text content
pub fn textElAttr(tag: String, attrs: List<Attr>, content: String): Node =
Element(tag, attrs, [Text(content)])
// Render a single attribute to string
fn renderAttr(a: Attr): String =
match a {
Attr(k, v) => " " + k + "=\"" + escape(v) + "\"",
}
// Render a list of attributes to string
fn renderAttrs(attrs: List<Attr>): String =
match List.head(attrs) {
None => "",
Some(a) => renderAttr(a) + match List.tail(attrs) {
Some(rest) => renderAttrs(rest),
None => "",
},
}
// Render child nodes to string
fn renderChildren(nodes: List<Node>): String =
match List.head(nodes) {
None => "",
Some(node) => render(node) + match List.tail(nodes) {
Some(rest) => renderChildren(rest),
None => "",
},
}
// Render a node to an XML string
pub fn render(node: Node): String =
match node {
Element(tag, attrs, children) => {
let attrStr = renderAttrs(attrs)
if List.length(children) == 0 then
"<" + tag + attrStr + "/>"
else
"<" + tag + attrStr + ">" + renderChildren(children) + "</" + tag + ">"
},
Text(content) => escape(content),
CData(content) => "<![CDATA[" + content + "]]>",
Raw(content) => content,
}
// Render a complete XML document with declaration
pub fn document(root: Node): String =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + render(root)
// Render with a custom declaration (e.g., for RSS stylesheets)
pub fn documentWithDecl(decl: String, root: Node): String =
decl + "\n" + render(root)

8
packages/xml/lux.toml Normal file
View File

@@ -0,0 +1,8 @@
[project]
name = "xml"
version = "0.1.0"
description = "XML builder for Lux"
authors = ["Brandon Lucas"]
license = "MIT"
[dependencies]

View File

@@ -0,0 +1,105 @@
import lib
// Integration tests: building real-world XML documents
// Build an HTML page structure
fn test_build_html_page(): Unit with {Test} = {
let head = lib.el("head", [
lib.textEl("title", "My Page"),
lib.selfClosing("meta", [lib.attr("charset", "utf-8")]),
lib.selfClosing("link", [lib.attr("rel", "stylesheet"), lib.attr("href", "style.css")])
])
let body = lib.el("body", [
lib.el("header", [lib.textEl("h1", "Welcome")]),
lib.el("main", [lib.textEl("p", "Hello world")]),
lib.el("footer", [lib.textEl("p", "Copyright 2025")])
])
let html = lib.el("html", [head, body])
let result = lib.render(html)
Test.assert(String.contains(result, "<title>My Page</title>"), "page has title")
Test.assert(String.contains(result, "<meta charset=\"utf-8\"/>"), "page has meta charset")
Test.assert(String.contains(result, "<h1>Welcome</h1>"), "page has h1")
Test.assert(String.contains(result, "<p>Hello world</p>"), "page has paragraph")
Test.assert(String.startsWith(result, "<html>"), "starts with html tag")
Test.assert(String.endsWith(result, "</html>"), "ends with html tag")
}
// Build a sitemap.xml
fn test_build_sitemap(): Unit with {Test} = {
let url1 = lib.el("url", [
lib.textEl("loc", "https://example.com/"),
lib.textEl("lastmod", "2025-01-29"),
lib.textEl("priority", "1.0")
])
let url2 = lib.el("url", [
lib.textEl("loc", "https://example.com/about"),
lib.textEl("lastmod", "2025-01-15"),
lib.textEl("priority", "0.8")
])
let urlset = lib.element("urlset", [lib.attr("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")], [url1, url2])
let sitemap = lib.document(urlset)
Test.assert(String.startsWith(sitemap, "<?xml version=\"1.0\""), "sitemap has XML declaration")
Test.assert(String.contains(sitemap, "xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\""), "sitemap has namespace")
Test.assert(String.contains(sitemap, "<loc>https://example.com/</loc>"), "sitemap has first URL")
Test.assert(String.contains(sitemap, "<loc>https://example.com/about</loc>"), "sitemap has second URL")
}
// Build a form with various input types
fn test_build_form(): Unit with {Test} = {
let form = lib.element("form", [lib.attr("action", "/submit"), lib.attr("method", "post")], [
lib.selfClosing("input", [lib.attr("type", "text"), lib.attr("name", "username")]),
lib.selfClosing("input", [lib.attr("type", "password"), lib.attr("name", "pass")]),
lib.textElAttr("button", [lib.attr("type", "submit")], "Login")
])
let result = lib.render(form)
Test.assert(String.contains(result, "action=\"/submit\""), "form has action")
Test.assert(String.contains(result, "type=\"text\""), "has text input")
Test.assert(String.contains(result, "type=\"password\""), "has password input")
Test.assert(String.contains(result, "<button type=\"submit\">Login</button>"), "has submit button")
}
// Build nested table structure
fn test_build_table(): Unit with {Test} = {
let headerRow = lib.el("tr", [lib.textEl("th", "Name"), lib.textEl("th", "Age")])
let row1 = lib.el("tr", [lib.textEl("td", "Alice"), lib.textEl("td", "30")])
let row2 = lib.el("tr", [lib.textEl("td", "Bob"), lib.textEl("td", "25")])
let table = lib.el("table", [lib.el("thead", [headerRow]), lib.el("tbody", [row1, row2])])
let result = lib.render(table)
Test.assert(String.contains(result, "<th>Name</th>"), "has header Name")
Test.assert(String.contains(result, "<td>Alice</td>"), "has cell Alice")
Test.assert(String.contains(result, "<td>25</td>"), "has cell 25")
}
// documentWithDecl for custom processing instructions
fn test_custom_declaration(): Unit with {Test} = {
let decl = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?xml-stylesheet type=\"text/xsl\" href=\"transform.xsl\"?>"
let root = lib.textEl("data", "value")
let result = lib.documentWithDecl(decl, root)
Test.assert(String.contains(result, "xml-stylesheet"), "has stylesheet PI")
Test.assert(String.contains(result, "<data>value</data>"), "has root element")
}
// Build SVG-like structure
fn test_build_svg(): Unit with {Test} = {
let svg = lib.element("svg", [lib.attr("xmlns", "http://www.w3.org/2000/svg"), lib.attr("width", "100"), lib.attr("height", "100")], [
lib.selfClosing("circle", [lib.attr("cx", "50"), lib.attr("cy", "50"), lib.attr("r", "40")]),
lib.selfClosing("rect", [lib.attr("x", "10"), lib.attr("y", "10"), lib.attr("width", "80"), lib.attr("height", "80")])
])
let result = lib.render(svg)
Test.assert(String.contains(result, "<svg xmlns=\"http://www.w3.org/2000/svg\""), "svg has namespace")
Test.assert(String.contains(result, "<circle"), "has circle")
Test.assert(String.contains(result, "<rect"), "has rect")
}
// Content with special characters throughout
fn test_special_chars_in_content(): Unit with {Test} = {
let node = lib.el("message", [
lib.textEl("from", "Alice & Bob"),
lib.textEl("subject", "Re: <Important>"),
lib.el("body", [lib.cdata("Use <html> & \"xml\" freely")])
])
let result = lib.render(node)
Test.assert(String.contains(result, "Alice &amp; Bob"), "from is escaped")
Test.assert(String.contains(result, "&lt;Important&gt;"), "subject is escaped")
Test.assert(String.contains(result, "<![CDATA[Use <html> & \"xml\" freely]]>"), "cdata preserves content")
}

View File

@@ -0,0 +1,95 @@
import lib
// Snapshot tests: compare full XML output against golden strings
// Snapshot: simple HTML page
fn test_snapshot_html_page(): Unit with {Test} = {
let page = lib.el("html", [
lib.el("head", [lib.textEl("title", "Test")]),
lib.el("body", [lib.textEl("p", "Hello")])
])
let expected = "<html><head><title>Test</title></head><body><p>Hello</p></body></html>"
Test.assertEqualMsg(expected, lib.render(page), "snap: HTML page")
}
// Snapshot: self-closing tags
fn test_snapshot_self_closing(): Unit with {Test} = {
let node = lib.el("head", [
lib.selfClosing("meta", [lib.attr("charset", "utf-8")]),
lib.selfClosing("link", [lib.attr("rel", "stylesheet"), lib.attr("href", "/css/main.css")])
])
let expected = "<head><meta charset=\"utf-8\"/><link rel=\"stylesheet\" href=\"/css/main.css\"/></head>"
Test.assertEqualMsg(expected, lib.render(node), "snap: self-closing tags")
}
// Snapshot: XML document with declaration
fn test_snapshot_xml_document(): Unit with {Test} = {
let root = lib.el("config", [
lib.textEl("name", "MyApp"),
lib.textEl("version", "1.0")
])
let expected = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<config><name>MyApp</name><version>1.0</version></config>"
Test.assertEqualMsg(expected, lib.document(root), "snap: XML document")
}
// Snapshot: element with escaped content
fn test_snapshot_escaped_content(): Unit with {Test} = {
let node = lib.textEl("message", "Hello <world> & \"everyone\"")
let expected = "<message>Hello &lt;world&gt; &amp; &quot;everyone&quot;</message>"
Test.assertEqualMsg(expected, lib.render(node), "snap: escaped content")
}
// Snapshot: CDATA section
fn test_snapshot_cdata(): Unit with {Test} = {
let node = lib.el("script", [lib.cdata("if (a < b && c > d) then run()")])
let expected = "<script><![CDATA[if (a < b && c > d) then run()]]></script>"
Test.assertEqualMsg(expected, lib.render(node), "snap: CDATA in script")
}
// Snapshot: mixed node types
fn test_snapshot_mixed_nodes(): Unit with {Test} = {
let node = lib.el("content", [
lib.text("Normal "),
lib.raw("<br/>"),
lib.text(" text & "),
lib.cdata("<special>")
])
let expected = "<content>Normal <br/> text &amp; <![CDATA[<special>]]></content>"
Test.assertEqualMsg(expected, lib.render(node), "snap: mixed node types")
}
// Snapshot: deeply nested structure
fn test_snapshot_deep_nesting(): Unit with {Test} = {
let node = lib.el("l1", [lib.el("l2", [lib.el("l3", [lib.el("l4", [lib.el("l5", [lib.text("deep")])])])])])
let expected = "<l1><l2><l3><l4><l5>deep</l5></l4></l3></l2></l1>"
Test.assertEqualMsg(expected, lib.render(node), "snap: 5-level nesting")
}
// Snapshot: attribute escaping
fn test_snapshot_attr_escaping(): Unit with {Test} = {
let node = lib.selfClosing("div", [
lib.attr("title", "a \"test\" & <more>"),
lib.attr("class", "a&b")
])
let expected = "<div title=\"a &quot;test&quot; &amp; &lt;more&gt;\" class=\"a&amp;b\"/>"
Test.assertEqualMsg(expected, lib.render(node), "snap: attribute escaping")
}
// Snapshot: empty elements
fn test_snapshot_empty_elements(): Unit with {Test} = {
Test.assertEqualMsg("<br/>", lib.render(lib.el("br", [])), "snap: empty el")
Test.assertEqualMsg("<hr/>", lib.render(lib.selfClosing("hr", [])), "snap: selfClosing")
Test.assertEqualMsg("<p></p>", lib.render(lib.textEl("p", "")), "snap: textEl with empty content")
}
// Snapshot: sitemap entry
fn test_snapshot_sitemap_entry(): Unit with {Test} = {
let url = lib.el("url", [
lib.textEl("loc", "https://example.com/page"),
lib.textEl("lastmod", "2025-01-29"),
lib.textEl("changefreq", "weekly"),
lib.textEl("priority", "0.8")
])
let expected = "<url><loc>https://example.com/page</loc><lastmod>2025-01-29</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>"
Test.assertEqualMsg(expected, lib.render(url), "snap: sitemap URL entry")
}

118
packages/xml/test_unit.lux Normal file
View File

@@ -0,0 +1,118 @@
import lib
// --- escape edge cases ---
fn test_escape_all_chars(): Unit with {Test} = {
Test.assertEqualMsg("&amp;", lib.escape("&"), "escape ampersand")
Test.assertEqualMsg("&lt;", lib.escape("<"), "escape less-than")
Test.assertEqualMsg("&gt;", lib.escape(">"), "escape greater-than")
Test.assertEqualMsg("&quot;", lib.escape("\""), "escape double quote")
Test.assertEqualMsg("&apos;", lib.escape("'"), "escape single quote")
}
fn test_escape_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.escape(""), "escape empty string")
fn test_escape_no_special(): Unit with {Test} =
Test.assertEqualMsg("hello world 123", lib.escape("hello world 123"), "escape plain text unchanged")
fn test_escape_all_special(): Unit with {Test} =
Test.assertEqualMsg("&lt;&amp;&gt;&quot;&apos;", lib.escape("<&>\"'"), "escape all special chars together")
fn test_escape_mixed(): Unit with {Test} =
Test.assertEqualMsg("a &amp; b &lt; c", lib.escape("a & b < c"), "escape mixed content")
// --- node constructors ---
fn test_text_node_simple(): Unit with {Test} =
Test.assertEqualMsg("hello", lib.render(lib.text("hello")), "text node renders content")
fn test_text_node_escapes(): Unit with {Test} =
Test.assertEqualMsg("&lt;script&gt;", lib.render(lib.text("<script>")), "text node escapes HTML")
fn test_text_node_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.render(lib.text("")), "empty text node")
fn test_cdata_preserves_special(): Unit with {Test} =
Test.assertEqualMsg("<![CDATA[<b>bold & stuff</b>]]>", lib.render(lib.cdata("<b>bold & stuff</b>")), "cdata preserves special chars")
fn test_raw_no_escape(): Unit with {Test} =
Test.assertEqualMsg("<br/>", lib.render(lib.raw("<br/>")), "raw passes through unchanged")
fn test_raw_empty(): Unit with {Test} =
Test.assertEqualMsg("", lib.render(lib.raw("")), "empty raw node")
// --- element rendering ---
fn test_self_closing_no_attrs(): Unit with {Test} =
Test.assertEqualMsg("<br/>", lib.render(lib.selfClosing("br", [])), "self-closing no attrs")
fn test_self_closing_with_attr(): Unit with {Test} =
Test.assertEqualMsg("<img src=\"photo.jpg\"/>", lib.render(lib.selfClosing("img", [lib.attr("src", "photo.jpg")])), "self-closing with attr")
fn test_self_closing_multiple_attrs(): Unit with {Test} = {
let node = lib.selfClosing("input", [lib.attr("type", "text"), lib.attr("name", "q"), lib.attr("value", "")])
Test.assertEqualMsg("<input type=\"text\" name=\"q\" value=\"\"/>", lib.render(node), "self-closing with multiple attrs")
}
fn test_el_empty_children(): Unit with {Test} =
Test.assertEqualMsg("<div/>", lib.render(lib.el("div", [])), "el with no children self-closes")
fn test_el_with_children(): Unit with {Test} = {
let node = lib.el("ul", [lib.textEl("li", "a"), lib.textEl("li", "b")])
Test.assertEqualMsg("<ul><li>a</li><li>b</li></ul>", lib.render(node), "el with children")
}
fn test_text_el(): Unit with {Test} =
Test.assertEqualMsg("<title>Hello</title>", lib.render(lib.textEl("title", "Hello")), "textEl renders correctly")
fn test_text_el_escapes(): Unit with {Test} =
Test.assertEqualMsg("<p>a &amp; b</p>", lib.render(lib.textEl("p", "a & b")), "textEl escapes content")
fn test_text_el_attr(): Unit with {Test} = {
let node = lib.textElAttr("a", [lib.attr("href", "/page")], "click")
Test.assertEqualMsg("<a href=\"/page\">click</a>", lib.render(node), "textElAttr renders correctly")
}
// --- attribute edge cases ---
fn test_attr_value_escaping(): Unit with {Test} = {
let node = lib.selfClosing("div", [lib.attr("data", "a&b<c\"d'e")])
Test.assertEqualMsg("<div data=\"a&amp;b&lt;c&quot;d&apos;e\"/>", lib.render(node), "attr value escapes all special chars")
}
fn test_attr_empty_value(): Unit with {Test} = {
let node = lib.selfClosing("input", [lib.attr("disabled", "")])
Test.assertEqualMsg("<input disabled=\"\"/>", lib.render(node), "attr with empty value")
}
// --- nesting depth ---
fn test_deeply_nested(): Unit with {Test} = {
let deep = lib.el("a", [lib.el("b", [lib.el("c", [lib.el("d", [lib.text("deep")])])])])
Test.assertEqualMsg("<a><b><c><d>deep</d></c></b></a>", lib.render(deep), "4 levels deep")
}
// --- document ---
fn test_document_declaration(): Unit with {Test} = {
let doc = lib.document(lib.textEl("root", ""))
Test.assert(String.startsWith(doc, "<?xml version=\"1.0\""), "document starts with XML declaration")
}
fn test_document_with_custom_decl(): Unit with {Test} = {
let doc = lib.documentWithDecl("<?xml version=\"1.1\"?>", lib.textEl("root", ""))
Test.assert(String.startsWith(doc, "<?xml version=\"1.1\"?>"), "custom declaration")
}
// --- mixed children ---
fn test_mixed_children_types(): Unit with {Test} = {
let node = lib.el("div", [
lib.text("Hello "),
lib.raw("<strong>world</strong>"),
lib.text(" & "),
lib.cdata("raw data")
])
Test.assertEqualMsg("<div>Hello <strong>world</strong> &amp; <![CDATA[raw data]]></div>", lib.render(node), "mixed children types")
}

74
packages/xml/test_xml.lux Normal file
View File

@@ -0,0 +1,74 @@
import lib
fn test_escape(): Unit with {Test} = {
Test.assertEqualMsg("&lt;b&gt;hello&lt;/b&gt;", lib.escape("<b>hello</b>"), "escape angle brackets")
Test.assertEqualMsg("a &amp; b", lib.escape("a & b"), "escape ampersand")
Test.assertEqualMsg("&quot;quoted&quot;", lib.escape("\"quoted\""), "escape quotes")
}
fn test_text_node(): Unit with {Test} = {
Test.assertEqualMsg("hello", lib.render(lib.text("hello")), "text node simple")
Test.assertEqualMsg("&lt;b&gt;", lib.render(lib.text("<b>")), "text node escaped")
}
fn test_cdata(): Unit with {Test} = {
Test.assertEqualMsg("<![CDATA[hello <b>world</b>]]>", lib.render(lib.cdata("hello <b>world</b>")), "cdata node")
}
fn test_raw(): Unit with {Test} = {
Test.assertEqualMsg("<br/>", lib.render(lib.raw("<br/>")), "raw node")
}
fn test_text_element(): Unit with {Test} = {
Test.assertEqualMsg("<title>Hello</title>", lib.render(lib.textEl("title", "Hello")), "text element")
}
fn test_element_with_attrs(): Unit with {Test} = {
let linkEl = lib.element("link", [lib.attr("href", "http://example.com"), lib.attr("rel", "alternate")], [])
Test.assertEqualMsg("<link href=\"http://example.com\" rel=\"alternate\"/>", lib.render(linkEl), "element with attrs self-closing")
}
fn test_self_closing(): Unit with {Test} = {
Test.assertEqualMsg("<br/>", lib.render(lib.selfClosing("br", [])), "self-closing no attrs")
Test.assertEqualMsg("<img src=\"photo.jpg\"/>", lib.render(lib.selfClosing("img", [lib.attr("src", "photo.jpg")])), "self-closing with attr")
}
fn test_nested_elements(): Unit with {Test} = {
let nested = lib.el("root", [lib.textEl("child1", "a"), lib.textEl("child2", "b")])
Test.assertEqualMsg("<root><child1>a</child1><child2>b</child2></root>", lib.render(nested), "nested elements")
}
fn test_element_with_attrs_and_children(): Unit with {Test} = {
let withAttr = lib.element("item", [lib.attr("id", "1")], [lib.textEl("name", "Test")])
Test.assertEqualMsg("<item id=\"1\"><name>Test</name></item>", lib.render(withAttr), "element with attrs and children")
}
fn test_text_element_with_attrs(): Unit with {Test} = {
let attrText = lib.textElAttr("a", [lib.attr("href", "/page")], "Click")
Test.assertEqualMsg("<a href=\"/page\">Click</a>", lib.render(attrText), "text element with attrs")
}
fn test_xml_document(): Unit with {Test} = {
let doc = lib.document(lib.textEl("root", "content"))
let expected = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>content</root>"
Test.assertEqualMsg(expected, doc, "xml document")
}
fn test_deeply_nested(): Unit with {Test} = {
let deep = lib.el("a", [lib.el("b", [lib.el("c", [lib.text("deep")])])])
Test.assertEqualMsg("<a><b><c>deep</c></b></a>", lib.render(deep), "deeply nested")
}
fn test_empty_element(): Unit with {Test} = {
Test.assertEqualMsg("<empty/>", lib.render(lib.el("empty", [])), "el empty children self-closes")
}
fn test_attribute_value_escaping(): Unit with {Test} = {
let escapedAttr = lib.selfClosing("div", [lib.attr("data", "a&b<c")])
Test.assertEqualMsg("<div data=\"a&amp;b&lt;c\"/>", lib.render(escapedAttr), "attribute value escaping")
}
fn test_mixed_children(): Unit with {Test} = {
let mixed = lib.el("p", [lib.text("Hello "), lib.raw("<b>world</b>")])
Test.assertEqualMsg("<p>Hello <b>world</b></p>", lib.render(mixed), "mixed text and raw children")
}