diff --git a/README.md b/README.md index 3218138..b8c93df 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ lux pkg add locallib --path ../mylib | [json](./packages/json/) | 1.0.0 | JSON parsing and serialization | | [http-client](./packages/http-client/) | 0.1.0 | HTTP client utilities | | [testing](./packages/testing/) | 0.1.0 | Testing utilities and assertions | +| [markdown](./packages/markdown/) | 0.1.0 | Markdown to HTML converter | +| [frontmatter](./packages/frontmatter/) | 0.1.0 | YAML-like frontmatter parser | +| [path](./packages/path/) | 0.1.0 | File path utilities | +| [xml](./packages/xml/) | 0.1.0 | XML builder | +| [rss](./packages/rss/) | 0.1.0 | RSS 2.0 feed generator | +| [web](./packages/web/) | 0.1.0 | Full-stack web framework | ## Publishing Packages diff --git a/index.json b/index.json index 447b824..dafcabc 100644 --- a/index.json +++ b/index.json @@ -47,6 +47,30 @@ "versions": [ {"version": "0.1.0", "checksum": "", "published_at": "2026-02-17", "yanked": false} ] + }, + { + "name": "xml", + "description": "XML builder for Lux", + "latest_version": "0.1.0", + "versions": [ + {"version": "0.1.0", "checksum": "", "published_at": "2026-02-24", "yanked": false} + ] + }, + { + "name": "rss", + "description": "RSS 2.0 feed generator for Lux", + "latest_version": "0.1.0", + "versions": [ + {"version": "0.1.0", "checksum": "", "published_at": "2026-02-24", "yanked": false} + ] + }, + { + "name": "web", + "description": "Full-stack web framework for Lux", + "latest_version": "0.1.0", + "versions": [ + {"version": "0.1.0", "checksum": "", "published_at": "2026-02-24", "yanked": false} + ] } ] } diff --git a/packages/frontmatter/.gitignore b/packages/frontmatter/.gitignore new file mode 100644 index 0000000..689f6f5 --- /dev/null +++ b/packages/frontmatter/.gitignore @@ -0,0 +1,3 @@ +_site/ +.lux_packages/ +*.bak diff --git a/packages/frontmatter/lib.lux b/packages/frontmatter/lib.lux new file mode 100644 index 0000000..7c46085 --- /dev/null +++ b/packages/frontmatter/lib.lux @@ -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, String) + +// Internal parser state +type PState = + | PState(Bool, Bool, List, 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 = + 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, line: String): List = + 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 = + 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 = { + let es = entries(doc) + getFromEntries(es, key) +} + +fn getFromEntries(es: List, key: String): Option = + 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 = + 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 = parseTags(getOrDefault(doc, "tags", "")) diff --git a/packages/frontmatter/lux.toml b/packages/frontmatter/lux.toml new file mode 100644 index 0000000..865f708 --- /dev/null +++ b/packages/frontmatter/lux.toml @@ -0,0 +1,8 @@ +[project] +name = "frontmatter" +version = "0.1.0" +description = "YAML-like frontmatter parser for Lux" +authors = ["Brandon Lucas"] +license = "MIT" + +[dependencies] diff --git a/packages/frontmatter/test_frontmatter.lux b/packages/frontmatter/test_frontmatter.lux new file mode 100644 index 0000000..d33ef1a --- /dev/null +++ b/packages/frontmatter/test_frontmatter.lux @@ -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") +} diff --git a/packages/frontmatter/test_integration.lux b/packages/frontmatter/test_integration.lux new file mode 100644 index 0000000..13808e1 --- /dev/null +++ b/packages/frontmatter/test_integration.lux @@ -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") +} diff --git a/packages/frontmatter/test_snapshot.lux b/packages/frontmatter/test_snapshot.lux new file mode 100644 index 0000000..cb8eca2 --- /dev/null +++ b/packages/frontmatter/test_snapshot.lux @@ -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"), + } +} diff --git a/packages/frontmatter/test_unit.lux b/packages/frontmatter/test_unit.lux new file mode 100644 index 0000000..d1bd694 --- /dev/null +++ b/packages/frontmatter/test_unit.lux @@ -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 & \"friends\"\n---\n") + Test.assertEqualMsg("Hello & \"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") +} diff --git a/packages/markdown/.gitignore b/packages/markdown/.gitignore new file mode 100644 index 0000000..c1b3da8 --- /dev/null +++ b/packages/markdown/.gitignore @@ -0,0 +1,2 @@ +test +.lux_packages/ diff --git a/packages/markdown/README.md b/packages/markdown/README.md new file mode 100644 index 0000000..80d2137 --- /dev/null +++ b/packages/markdown/README.md @@ -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 `

`) +- 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 diff --git a/packages/markdown/lib.lux b/packages/markdown/lib.lux new file mode 100644 index 0000000..1d116b8 --- /dev/null +++ b/packages/markdown/lib.lux @@ -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, "&", "&"), "<", "<"), ">", ">") + +// --- Delimiter finding --- + +fn findClosingFrom(text: String, i: Int, len: Int, delim: String, delimLen: Int): Option = + 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 = + 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), 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 + "" + processInline(String.substring(text, i + 2, end)) + ""), + None => processInlineFrom(text, i + 1, len, acc + ch), + } + else + match findClosing(text, i + 1, len, "*") { + Some(end) => processInlineFrom(text, end + 1, len, acc + "" + processInline(String.substring(text, i + 1, end)) + ""), + 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 + "" + processInline(String.substring(text, i + 1, end)) + ""), + 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 + "" + processInline(String.substring(text, i + 2, end)) + ""), + 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 + "" + escapeHtml(String.substring(text, i + 1, end)) + ""), + 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 + "\""" + 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 + "" + processInline(linkText) + "" + 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 + "<"), + } + else processInlineFrom(text, i + 1, len, acc + "<") + else acc + "<" + else if ch == ">" then + processInlineFrom(text, i + 1, len, acc + ">") + 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

  • Title

  • + +fn processListItemContent(content: String): String = { + let trimmed = String.trim(content) + if String.startsWith(trimmed, "#### ") then + "

    " + processInline(String.substring(trimmed, 5, String.length(trimmed))) + "

    " + else if String.startsWith(trimmed, "### ") then + "

    " + processInline(String.substring(trimmed, 4, String.length(trimmed))) + "

    " + else if String.startsWith(trimmed, "## ") then + "

    " + processInline(String.substring(trimmed, 3, String.length(trimmed))) + "

    " + else if String.startsWith(trimmed, "# ") then + "

    " + processInline(String.substring(trimmed, 2, String.length(trimmed))) + "

    " + else + processInline(trimmed) +} + +// --- Block-level flush helpers --- + +fn flushPara(html: String, para: String): String = + if para == "" then html + else html + "

    " + processInline(String.trim(para)) + "

    +" + +fn flushBq(html: String, bqLines: String): String = + if bqLines == "" then html + else html + "
    +" + parseBlocks(bqLines) + "
    +" + +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 + " +" + } + +// --- 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 + "

    " + processInline(String.substring(trimmed, 5, String.length(trimmed))) + "

    +", "", false, "", "", false, "", false, "", false) + } + else if String.startsWith(trimmed, "### ") then { + let h2 = flushPara(html, para) + let h3 = flushList(h2, listItems, ordered) + BState(h3 + "

    " + processInline(String.substring(trimmed, 4, String.length(trimmed))) + "

    +", "", false, "", "", false, "", false, "", false) + } + else if String.startsWith(trimmed, "## ") then { + let h2 = flushPara(html, para) + let h3 = flushList(h2, listItems, ordered) + BState(h3 + "

    " + processInline(String.substring(trimmed, 3, String.length(trimmed))) + "

    +", "", false, "", "", false, "", false, "", false) + } + else if String.startsWith(trimmed, "# ") then { + let h2 = flushPara(html, para) + let h3 = flushList(h2, listItems, ordered) + BState(h3 + "

    " + processInline(String.substring(trimmed, 2, String.length(trimmed))) + "

    +", "", false, "", "", false, "", false, "", false) + } + else if trimmed == "---" || trimmed == "***" || 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 + trimmed + " +", "", false, "", "", false, "", false, "", false) + } + else if String.startsWith(trimmed, "![") then { + let h2 = flushPara(html, para) + let h3 = flushList(h2, listItems, ordered) + BState(h3 + "

    " + processInline(trimmed) + "

    +", "", 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 + "
    " + codeLines + "
    +" + else + "
    " + codeLines + "
    +" + 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 + "
  • " + processListItemContent(item) + "
  • +", 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 + "
  • " + processListItemContent(item) + "
  • +", 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) diff --git a/packages/markdown/lux.toml b/packages/markdown/lux.toml new file mode 100644 index 0000000..8029b0c --- /dev/null +++ b/packages/markdown/lux.toml @@ -0,0 +1,8 @@ +[project] +name = "markdown" +version = "0.1.0" +description = "Markdown to HTML converter for Lux" +authors = ["Brandon Lucas"] +license = "MIT" + +[dependencies] diff --git a/packages/markdown/test_integration.lux b/packages/markdown/test_integration.lux new file mode 100644 index 0000000..6621cad --- /dev/null +++ b/packages/markdown/test_integration.lux @@ -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, "

    Welcome to My Blog

    "), "has h1") + Test.assert(String.contains(html, "first post"), "has bold") + Test.assert(String.contains(html, "Lux"), "has italic") + Test.assert(String.contains(html, "

    Getting Started

    "), "has h2") + Test.assert(String.contains(html, "
    "), "has code block with lang")
    +    Test.assert(String.contains(html, "the docs"), "has link")
    +    Test.assert(String.contains(html, "
    "), "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, "
      "), "has unordered list") + Test.assert(String.contains(html, "
        "), "has ordered list") + Test.assert(String.contains(html, "
      1. Apples
      2. "), "has UL item") + Test.assert(String.contains(html, "
      3. Preheat oven
      4. "), "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, ""), "has strong tag") + Test.assert(String.contains(result, "italic"), "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, "

        First paragraph.

        "), "first para") + Test.assert(String.contains(html, "

        Second paragraph.

        "), "second para") + Test.assert(String.contains(html, "

        Third paragraph.

        "), "third para") +} + +// Code block preserves content exactly +fn test_code_block_preserves_content(): Unit with {Test} = { + let md = "```html\n
        \n

        Hello & world

        \n
        \n```" + let html = lib.toHtml(md) + Test.assert(String.contains(html, "language-html"), "has language class") + Test.assert(String.contains(html, "<div>"), "HTML tags in code are escaped") + Test.assert(String.contains(html, "& 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, "

        "), "has h2") + Test.assert(String.contains(html, "Lux"), "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, "
        "), "has blockquote") + Test.assert(String.contains(html, "bold"), "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, "
        code first"), "code block first")
        +    Test.assert(String.contains(html, "

        Then text.

        "), "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, "\"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, "
      5. Chapter 1

      6. "), "heading in list item 1") + Test.assert(String.contains(html, "
      7. Chapter 2

      8. "), "heading in list item 2") + Test.assert(String.contains(html, "
      9. Chapter 3

      10. "), "heading in list item 3") +} diff --git a/packages/markdown/test_markdown.lux b/packages/markdown/test_markdown.lux new file mode 100644 index 0000000..72b598e --- /dev/null +++ b/packages/markdown/test_markdown.lux @@ -0,0 +1,64 @@ +import lib + +fn test_escape_html(): Unit with {Test} = { + Test.assertEqualMsg("<div>", lib.escapeHtml("
        "), "angle brackets") + Test.assertEqualMsg("a & b", lib.escapeHtml("a & b"), "ampersand") + Test.assertEqualMsg("hello", lib.escapeHtml("hello"), "no special chars") +} + +fn test_inline_formatting(): Unit with {Test} = { + Test.assertEqualMsg("bold", lib.inlineToHtml("**bold**"), "bold") + Test.assertEqualMsg("italic", lib.inlineToHtml("*italic*"), "italic with asterisk") + Test.assertEqualMsg("italic", lib.inlineToHtml("_italic_"), "italic with underscore") + Test.assertEqualMsg("code", lib.inlineToHtml("`code`"), "inline code") + Test.assertEqualMsg("strike", lib.inlineToHtml("~~strike~~"), "strikethrough") + Test.assertEqualMsg("plain text", lib.inlineToHtml("plain text"), "plain text unchanged") +} + +fn test_links(): Unit with {Test} = { + Test.assertEqualMsg("click", lib.inlineToHtml("[click](https://example.com)"), "link") + Test.assertEqualMsg("\"alt\"", lib.inlineToHtml("![alt](img.png)"), "image") + Test.assertEqualMsg("bold link", lib.inlineToHtml("**[bold link](url)**"), "bold link") +} + +fn test_headings(): Unit with {Test} = { + Test.assertEqualMsg("

        Hello

        \n", lib.toHtml("# Hello"), "h1") + Test.assertEqualMsg("

        World

        \n", lib.toHtml("## World"), "h2") + Test.assertEqualMsg("

        Sub

        \n", lib.toHtml("### Sub"), "h3") + Test.assertEqualMsg("

        Deep

        \n", lib.toHtml("#### Deep"), "h4") +} + +fn test_paragraphs(): Unit with {Test} = { + Test.assertEqualMsg("

        hello world

        \n", lib.toHtml("hello world"), "single line paragraph") +} + +fn test_code_blocks(): Unit with {Test} = { + let result = lib.toHtml("```\nhello\n```") + Test.assertEqualMsg("
        hello\n
        \n", result, "simple code block") +} + +fn test_lists(): Unit with {Test} = { + Test.assertEqualMsg("
          \n
        • item one
        • \n
        • item two
        • \n
        \n", lib.toHtml("- item one\n- item two"), "unordered list") + Test.assertEqualMsg("
          \n
        1. first
        2. \n
        3. second
        4. \n
        \n", lib.toHtml("1. first\n2. second"), "ordered list") +} + +fn test_heading_in_list(): Unit with {Test} = { + Test.assertEqualMsg("
          \n
        • Title

        • \n
        \n", lib.toHtml("- ### Title"), "h3 inside list item") + Test.assertEqualMsg("
          \n
        • Big Title

        • \n
        \n", lib.toHtml("- ## Big Title"), "h2 inside list item") + Test.assertEqualMsg("\n", lib.toHtml("- ### [Link](/url)"), "h3 with link inside list item") +} + +fn test_blockquotes(): Unit with {Test} = { + Test.assertEqualMsg("
        \n

        quoted text

        \n
        \n", lib.toHtml("> quoted text"), "simple blockquote") +} + +fn test_horizontal_rule(): Unit with {Test} = { + Test.assertEqualMsg("
        \n", lib.toHtml("---"), "hr with dashes") + Test.assertEqualMsg("
        \n", lib.toHtml("***"), "hr with asterisks") + Test.assertEqualMsg("
        \n", lib.toHtml("___"), "hr with underscores") +} + +fn test_html_passthrough(): Unit with {Test} = { + Test.assertEqualMsg("
        bar
        \n", lib.toHtml("
        bar
        "), "html element passes through") + Test.assertEqualMsg("\n\n\n", lib.toHtml("\n\n"), "multi-line html passthrough") +} diff --git a/packages/markdown/test_snapshot.lux b/packages/markdown/test_snapshot.lux new file mode 100644 index 0000000..b32046d --- /dev/null +++ b/packages/markdown/test_snapshot.lux @@ -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 = "

        Hello World

        \n

        This is a paragraph.

        \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

        \n", lib.toHtml("# H1"), "snap: h1") + Test.assertEqualMsg("

        H2

        \n", lib.toHtml("## H2"), "snap: h2") + Test.assertEqualMsg("

        H3

        \n", lib.toHtml("### H3"), "snap: h3") + Test.assertEqualMsg("

        H4

        \n", lib.toHtml("#### H4"), "snap: h4") +} + +// Snapshot: inline formatting combinations +fn test_snapshot_inline_combos(): Unit with {Test} = { + Test.assertEqualMsg("bold", lib.inlineToHtml("**bold**"), "snap: bold") + Test.assertEqualMsg("italic", lib.inlineToHtml("*italic*"), "snap: italic") + Test.assertEqualMsg("code", lib.inlineToHtml("`code`"), "snap: code") + Test.assertEqualMsg("strike", lib.inlineToHtml("~~strike~~"), "snap: strike") + Test.assertEqualMsg("link", lib.inlineToHtml("[link](/)"), "snap: link") + Test.assertEqualMsg("\"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 = "
          \n
        • Alpha
        • \n
        • Beta
        • \n
        • Gamma
        • \n
        \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 = "
          \n
        1. First
        2. \n
        3. Second
        4. \n
        5. Third
        6. \n
        \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 = "
        let x = 1;\nlet y = 2;\n
        \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 = "
        plain code\n
        \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 = "
        \n

        This is quoted text

        \n
        \n" + Test.assertEqualMsg(expected, lib.toHtml(md), "snap: blockquote") +} + +// Snapshot: horizontal rules +fn test_snapshot_horizontal_rules(): Unit with {Test} = { + Test.assertEqualMsg("
        \n", lib.toHtml("---"), "snap: hr dashes") + Test.assertEqualMsg("
        \n", lib.toHtml("***"), "snap: hr asterisks") + Test.assertEqualMsg("
        \n", lib.toHtml("___"), "snap: hr underscores") +} + +// Snapshot: HTML passthrough +fn test_snapshot_html_passthrough(): Unit with {Test} = { + Test.assertEqualMsg("
        content
        \n", lib.toHtml("
        content
        "), "snap: div passthrough") + Test.assertEqualMsg("\n", lib.toHtml(""), "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 = "
          \n
        • Chapter 1

        • \n
        • Chapter 2

        • \n
        \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, "

        My Post

        "), "snap: blog starts with h1") + Test.assert(String.contains(html, "bold"), "snap: blog has bold") + Test.assert(String.contains(html, "italic"), "snap: blog has italic") + Test.assert(String.contains(html, "

        Code Example

        "), "snap: blog has h2") + Test.assert(String.contains(html, "language-js"), "snap: blog has js code block") + Test.assert(String.contains(html, "
      11. Item one
      12. "), "snap: blog has list item") + Test.assert(String.contains(html, "
        "), "snap: blog has blockquote") + Test.assert(String.contains(html, "
        "), "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 `
        ` and `&` in HTML") + let expected = "Use <div> and &amp; 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 = "

        First.

        \n

        Second.

        \n

        Third.

        \n" + Test.assertEqualMsg(expected, lib.toHtml(md), "snap: three paragraphs") +} diff --git a/packages/markdown/test_unit.lux b/packages/markdown/test_unit.lux new file mode 100644 index 0000000..b41b515 --- /dev/null +++ b/packages/markdown/test_unit.lux @@ -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 & b", lib.escapeHtml("a & b"), "escape ampersand") + +fn test_escape_less_than(): Unit with {Test} = + Test.assertEqualMsg("<div>", lib.escapeHtml("
        "), "escape angle brackets") + +fn test_escape_all_three(): Unit with {Test} = + Test.assertEqualMsg("<a> & <b>", lib.escapeHtml(" & "), "escape all three") + +// --- inline: bold --- + +fn test_bold(): Unit with {Test} = + Test.assertEqualMsg("bold", lib.inlineToHtml("**bold**"), "bold text") + +fn test_bold_in_sentence(): Unit with {Test} = + Test.assertEqualMsg("a bold 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("italic", lib.inlineToHtml("*italic*"), "italic with asterisk") + +fn test_italic_underscore(): Unit with {Test} = + Test.assertEqualMsg("italic", 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", lib.inlineToHtml("`code`"), "inline code") + +fn test_inline_code_with_html(): Unit with {Test} = + Test.assertEqualMsg("<div>", lib.inlineToHtml("`
        `"), "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("strike", 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("text", lib.inlineToHtml("[text](/page)"), "basic link") + +fn test_link_with_formatting(): Unit with {Test} = + Test.assertEqualMsg("bold link", lib.inlineToHtml("**[bold link](/)**"), "bold link") + +fn test_link_empty_text(): Unit with {Test} = + Test.assertEqualMsg("", 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("\"alt", lib.inlineToHtml("![alt text](img.png)"), "basic image") + +fn test_image_empty_alt(): Unit with {Test} = + Test.assertEqualMsg("\"\"", 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("

        Hello

        \n", lib.toHtml("# Hello"), "h1") + +fn test_h2(): Unit with {Test} = + Test.assertEqualMsg("

        World

        \n", lib.toHtml("## World"), "h2") + +fn test_h3(): Unit with {Test} = + Test.assertEqualMsg("

        Sub

        \n", lib.toHtml("### Sub"), "h3") + +fn test_h4(): Unit with {Test} = + Test.assertEqualMsg("

        Deep

        \n", lib.toHtml("#### Deep"), "h4") + +fn test_heading_with_inline(): Unit with {Test} = + Test.assertEqualMsg("

        Bold heading

        \n", lib.toHtml("# **Bold** heading"), "heading with inline formatting") + +// --- block: paragraphs --- + +fn test_single_paragraph(): Unit with {Test} = + Test.assertEqualMsg("

        hello world

        \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("
        hello\n
        \n", lib.toHtml("```\nhello\n```"), "code block") + +fn test_code_block_with_lang(): Unit with {Test} = + Test.assertEqualMsg("
        let x = 1;\n
        \n", lib.toHtml("```js\nlet x = 1;\n```"), "code block with language") + +fn test_code_block_escapes_html(): Unit with {Test} = + Test.assertEqualMsg("
        <div>\n
        \n", lib.toHtml("```\n
        \n```"), "code block escapes HTML") + +// --- block: lists --- + +fn test_unordered_list(): Unit with {Test} = + Test.assertEqualMsg("
          \n
        • one
        • \n
        • two
        • \n
        \n", lib.toHtml("- one\n- two"), "unordered list") + +fn test_ordered_list(): Unit with {Test} = + Test.assertEqualMsg("
          \n
        1. first
        2. \n
        3. second
        4. \n
        \n", lib.toHtml("1. first\n2. second"), "ordered list") + +fn test_single_item_list(): Unit with {Test} = + Test.assertEqualMsg("
          \n
        • only
        • \n
        \n", lib.toHtml("- only"), "single item list") + +// --- block: blockquotes --- + +fn test_blockquote(): Unit with {Test} = + Test.assertEqualMsg("
        \n

        quoted

        \n
        \n", lib.toHtml("> quoted"), "blockquote") + +// --- block: horizontal rules --- + +fn test_hr_dashes(): Unit with {Test} = + Test.assertEqualMsg("
        \n", lib.toHtml("---"), "hr with dashes") + +fn test_hr_asterisks(): Unit with {Test} = + Test.assertEqualMsg("
        \n", lib.toHtml("***"), "hr with asterisks") + +fn test_hr_underscores(): Unit with {Test} = + Test.assertEqualMsg("
        \n", lib.toHtml("___"), "hr with underscores") + +// --- block: HTML passthrough --- + +fn test_html_passthrough(): Unit with {Test} = + Test.assertEqualMsg("
        bar
        \n", lib.toHtml("
        bar
        "), "HTML passes through") + +fn test_heading_in_list(): Unit with {Test} = + Test.assertEqualMsg("
          \n
        • Title

        • \n
        \n", lib.toHtml("- ### Title"), "heading inside list item") diff --git a/packages/path/.gitignore b/packages/path/.gitignore new file mode 100644 index 0000000..689f6f5 --- /dev/null +++ b/packages/path/.gitignore @@ -0,0 +1,3 @@ +_site/ +.lux_packages/ +*.bak diff --git a/packages/path/lib.lux b/packages/path/lib.lux new file mode 100644 index 0000000..7c564ae --- /dev/null +++ b/packages/path/lib.lux @@ -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 = { + 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 diff --git a/packages/path/lux.toml b/packages/path/lux.toml new file mode 100644 index 0000000..e226e7a --- /dev/null +++ b/packages/path/lux.toml @@ -0,0 +1,8 @@ +[project] +name = "path" +version = "0.1.0" +description = "File path utilities for Lux" +authors = ["Brandon Lucas"] +license = "MIT" + +[dependencies] diff --git a/packages/path/test_integration.lux b/packages/path/test_integration.lux new file mode 100644 index 0000000..ce52c08 --- /dev/null +++ b/packages/path/test_integration.lux @@ -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") +} diff --git a/packages/path/test_path.lux b/packages/path/test_path.lux new file mode 100644 index 0000000..0ca9680 --- /dev/null +++ b/packages/path/test_path.lux @@ -0,0 +1,72 @@ +import lib + +fn isSome(opt: Option, expected: String): Bool = + match opt { + Some(v) => v == expected, + None => false, + } + +fn isNone(opt: Option): 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") +} diff --git a/packages/path/test_snapshot.lux b/packages/path/test_snapshot.lux new file mode 100644 index 0000000..17ecb3a --- /dev/null +++ b/packages/path/test_snapshot.lux @@ -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") +} diff --git a/packages/path/test_unit.lux b/packages/path/test_unit.lux new file mode 100644 index 0000000..ce2c070 --- /dev/null +++ b/packages/path/test_unit.lux @@ -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, expected: String): Bool = + match opt { + Some(v) => v == expected, + None => false, + } + +fn isNone(opt: Option): 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") +} diff --git a/packages/rss/.gitignore b/packages/rss/.gitignore new file mode 100644 index 0000000..689f6f5 --- /dev/null +++ b/packages/rss/.gitignore @@ -0,0 +1,3 @@ +_site/ +.lux_packages/ +*.bak diff --git a/packages/rss/lib.lux b/packages/rss/lib.lux new file mode 100644 index 0000000..b945907 --- /dev/null +++ b/packages/rss/lib.lux @@ -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) + // 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): 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): 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): 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) + "\n" + + "" + + "" + + "" + xml.escape(title) + "" + + "" + xml.escape(link) + "" + + "" + xml.escape(desc) + "" + + "" + lang + "" + + "Lux RSS Package" + + itemsXml + + "" + }, +} diff --git a/packages/rss/lux.lock b/packages/rss/lux.lock new file mode 100644 index 0000000..3d61dcf --- /dev/null +++ b/packages/rss/lux.lock @@ -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" + diff --git a/packages/rss/lux.toml b/packages/rss/lux.toml new file mode 100644 index 0000000..73b9fc8 --- /dev/null +++ b/packages/rss/lux.toml @@ -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" } diff --git a/packages/rss/test_integration.lux b/packages/rss/test_integration.lux new file mode 100644 index 0000000..85f8c0e --- /dev/null +++ b/packages/rss/test_integration.lux @@ -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, "Lux Programming Blog"), "feed title") + Test.assert(contains(xml, "https://blog.example.com"), "feed link") + Test.assert(contains(xml, "Getting Started with Lux"), "first item title") + Test.assert(contains(xml, "Advanced Effects in Lux"), "second item title") + Test.assert(contains(xml, "Building a Static Site"), "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, "Lux RSS Package"), "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 ", + "https://example.com/tom&jerry", + "A \"great\" show with ", + "2025-01-01" + ) + let f = lib.feed( + "Kids' TV & More", + "https://example.com", + "Shows for ", + [i] + ) + let xml = lib.render(f) + Test.assert(contains(xml, "&"), "ampersand escaped somewhere") + Test.assert(contains(xml, "<"), "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, "fr"), "french language tag") + Test.assert(contains(xml, "Premier Article"), "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, ""), "has channel open") + Test.assert(contains(xml, ""), "has channel close") + Test.assert(contains(xml, ""), "has rss close") + Test.assert(contains(xml, "Empty Blog"), "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, "Post 1"), "first item") + Test.assert(contains(xml, "Post 10"), "last item") + Test.assert(contains(xml, "10 Jan 2025"), "last item date") +} diff --git a/packages/rss/test_rss.lux b/packages/rss/test_rss.lux new file mode 100644 index 0000000..d396f7f --- /dev/null +++ b/packages/rss/test_rss.lux @@ -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, "My Blog"), "feed title") + Test.assert(contains(xml, "https://example.com"), "feed link") + Test.assert(contains(xml, "Blog about things"), "feed description") + Test.assert(contains(xml, "en"), "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, ""), "has item tag") + Test.assert(contains(xml, "First Post"), "first item title") + Test.assert(contains(xml, "Second Post"), "second item title") + Test.assert(contains(xml, "29 Jan 2025 00:00:00 GMT"), "rfc822 date in feed") + Test.assert(contains(xml, ""), "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, "fr"), "custom language") +} + +fn test_special_characters(): Unit with {Test} = { + let specialItem = lib.item("A & B ", "https://example.com", "\"quoted\" & ", "2025-01-01") + let specialFeed = lib.feed("Test", "https://example.com", "Test", [specialItem]) + let xml = lib.render(specialFeed) + Test.assert(contains(xml, "&"), "ampersand escaped") + Test.assert(contains(xml, "<"), "angle bracket escaped") +} diff --git a/packages/rss/test_snapshot.lux b/packages/rss/test_snapshot.lux new file mode 100644 index 0000000..8507cbe --- /dev/null +++ b/packages/rss/test_snapshot.lux @@ -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, ""), "snap: XML declaration") + Test.assert(contains(xml, ""), "snap: RSS opening tag") + Test.assert(contains(xml, ""), "snap: channel open") + Test.assert(contains(xml, "My Blog"), "snap: feed title") + Test.assert(contains(xml, "https://example.com"), "snap: feed link") + Test.assert(contains(xml, "A personal blog"), "snap: feed desc") + Test.assert(contains(xml, "en"), "snap: language") + Test.assert(contains(xml, "Lux RSS Package"), "snap: generator") + Test.assert(contains(xml, ""), "snap: item open") + Test.assert(contains(xml, "Hello World"), "snap: item title") + Test.assert(contains(xml, "https://example.com/hello"), "snap: item link") + Test.assert(contains(xml, "My first post"), "snap: item desc") + Test.assert(contains(xml, "29 Jan 2025 00:00:00 GMT"), "snap: item pubDate") + Test.assert(contains(xml, "https://example.com/hello"), "snap: item guid") + Test.assert(contains(xml, ""), "snap: item close") + Test.assert(contains(xml, ""), "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, " tags + Test.assertEqualMsg(false, contains(xml, ""), "snap: no item tags") + Test.assert(contains(xml, "Empty"), "snap: title present") + Test.assert(String.endsWith(xml, ""), "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, "fr"), "snap: french language") + Test.assert(contains(xml, "Bonjour"), "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 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, "Tom & Jerry"), "snap: escaped feed title") + Test.assert(contains(xml, "A <fun> show"), "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, "First") { Some(i) => i, None => -1 } + let secondIdx = match String.indexOf(xml, "Second") { Some(i) => i, None => -1 } + let thirdIdx = match String.indexOf(xml, "Third") { 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") +} diff --git a/packages/rss/test_unit.lux b/packages/rss/test_unit.lux new file mode 100644 index 0000000..2d3e542 --- /dev/null +++ b/packages/rss/test_unit.lux @@ -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 ", "https://example.com?a=1&b=2", "\"quotes\" & ", "2025-01-01") + Test.assertEqualMsg("A & B ", lib.itemTitle(i), "special chars in title preserved") + Test.assertEqualMsg("\"quotes\" & ", 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, "en"), "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, "fr"), "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, "A & B"), "feed title is escaped") +} + +fn test_feed_escapes_description(): Unit with {Test} = { + let f = lib.feed("Title", "https://example.com", "Bold desc", []) + let xml = lib.render(f) + Test.assert(String.contains(xml, "<b>Bold</b>"), "feed description is escaped") +} diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000..99fcef3 --- /dev/null +++ b/packages/web/README.md @@ -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 with {File} +web.latestPosts(contentDir, section, count): List with {File} +web.loadPost(contentDir, section, slug): Option 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 +web.requireField(fields, name): Result +web.validateEmail(email): Result +web.sanitize(input): String +``` + +### `web.db` — Database Helpers + +```lux +web.saveSubscriber(dbPath, email): Result with {Sql} +web.getSubscribers(dbPath): List 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. diff --git a/packages/web/lux.toml b/packages/web/lux.toml new file mode 100644 index 0000000..d977ac8 --- /dev/null +++ b/packages/web/lux.toml @@ -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" } diff --git a/packages/web/web.lux b/packages/web/web.lux new file mode 100644 index 0000000..6040772 --- /dev/null +++ b/packages/web/web.lux @@ -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, List) + // 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, + 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): 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): Response = + match List.head(routes) { + None => htmlResponse(404, "

        Not Found

        "), + 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 + "" + label + "" + } + ) + "" + + "" + + "" + + "" + title + "" + + "" + + "" + + "" + + "" + + "
        " + bodyContent + "
        " + + config.footerHtml + + "" +} + +pub fn hero(title: String, subtitle: String, ctaUrl: String, ctaText: String): String = + "
        " + + "
        " + + "

        " + title + "

        " + + "

        " + subtitle + "

        " + + "" + ctaText + "" + + "
        " + +pub fn miniHero(title: String, subtitle: String): String = + "
        " + + "
        " + + "

        " + title + "

        " + + "

        " + subtitle + "

        " + + "
        " + +pub fn cardGrid(cards: List): String = { + let cardsHtml = String.join(cards, "") + "
        " + cardsHtml + "
        " +} + +pub fn card(icon: String, title: String, description: String): String = + "
        " + + "
        " + icon + "
        " + + "

        " + title + "

        " + + "

        " + description + "

        " + + "
        " + +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 + "
        " + number + "
        " + label + "
        " + } + ) + "
        " + + "
        " + + "
        " + statsHtml + "
        " + + "
        " +} + +pub fn emailSignup(heading: String, buttonText: String, action: String): String = + "
        " + + "
        " + + "

        " + heading + "

        " + + "
        " + + "" + + "" + + "
        " + +pub fn ctaSection(heading: String, ctaUrl: String, ctaText: String): String = + "
        " + + "
        " + + "

        " + heading + "

        " + + "" + ctaText + "" + + "
        " + +// ============================================================ +// web.blog — Blog Engine +// ============================================================ + +pub fn loadPosts(contentDir: String, section: String): List with {File} = { + let dir = contentDir + "/" + section + if File.exists(dir) then { + let entries = File.readDir(dir) + let mdFiles = List.filter(entries, fn(e: String): Bool => String.endsWith(e, ".md")) + let posts = loadPostFiles(dir, mdFiles) + sortPosts(posts) + } else [] +} + +pub fn latestPosts(contentDir: String, section: String, count: Int): List with {File} = + List.take(loadPosts(contentDir, section), count) + +pub fn loadPost(contentDir: String, section: String, slug: String): Option 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): List 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): List = + List.fold(posts, [], fn(sorted: List, item: Post): List => + insertPost(sorted, item) + ) + +fn insertPost(sorted: List, item: Post): List = { + 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): String = { + let postsHtml = List.fold(posts, "", fn(acc: String, post: Post): String => + acc + "" + ) + "
        " + postsHtml + "
        " +} + +pub fn blogPostHtml(post: Post): String = + "
        " + + "
        " + + "
        " + post.date + "" + + "

        " + post.title + "

        " + + "
        " + post.content + "
        " + + "
        " + +pub fn rssFeed(siteTitle: String, siteUrl: String, description: String, posts: List): String = { + let items = List.fold(posts, "", fn(acc: String, post: Post): String => + acc + "" + + "" + escapeXml(post.title) + "" + + "" + siteUrl + "/blog/" + post.slug + "" + + "" + escapeXml(post.excerpt) + "" + + "" + post.date + "" + + "" + ) + "" + + "" + + "" + escapeXml(siteTitle) + "" + + "" + siteUrl + "" + + "" + escapeXml(description) + "" + + items + + "" +} + +fn escapeXml(s: String): String = { + let s1 = String.replace(s, "&", "&") + let s2 = String.replace(s1, "<", "<") + let s3 = String.replace(s2, ">", ">") + 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 = + 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 = + 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 = + 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, "<", "<") + let s5 = String.replace(s4, ">", ">") + 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 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 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 => + match List.head(row) { + Some(email) => email, + None => "" + } + ) +} + +// ============================================================ +// web.seo — SEO Helpers +// ============================================================ + +pub fn metaTags(config: SeoConfig): String = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + +pub fn structuredData(name: String, description: String, url: String): String = + "" + +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 + "" + escapeXml(url) + "" + lastmod + "" + } + ) + "" + + "" + + entries + + "" +} + +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, patternParts: List): 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 = { + let pathParts = String.split(reqPath, "/") + let patternParts = String.split(pattern, "/") + extractParam(pathParts, patternParts, paramName) +} + +fn extractParam(pathParts: List, patternParts: List, paramName: String): Option = { + 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)) + } + } diff --git a/packages/xml/.gitignore b/packages/xml/.gitignore new file mode 100644 index 0000000..689f6f5 --- /dev/null +++ b/packages/xml/.gitignore @@ -0,0 +1,3 @@ +_site/ +.lux_packages/ +*.bak diff --git a/packages/xml/lib.lux b/packages/xml/lib.lux new file mode 100644 index 0000000..e9749ef --- /dev/null +++ b/packages/xml/lib.lux @@ -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, List) + | 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, "&", "&"), + "<", "<"), + ">", ">"), + "\"", """), + "'", "'") + +// 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, children: List): Node = + Element(tag, attrs, children) + +// Create an element with no attributes +pub fn el(tag: String, children: List): Node = + Element(tag, [], children) + +// Create a self-closing element with attributes and no children +pub fn selfClosing(tag: String, attrs: List): 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, 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): 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): 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) + "" + }, + Text(content) => escape(content), + CData(content) => "", + Raw(content) => content, +} + +// Render a complete XML document with declaration +pub fn document(root: Node): String = + "\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) diff --git a/packages/xml/lux.toml b/packages/xml/lux.toml new file mode 100644 index 0000000..c8822a5 --- /dev/null +++ b/packages/xml/lux.toml @@ -0,0 +1,8 @@ +[project] +name = "xml" +version = "0.1.0" +description = "XML builder for Lux" +authors = ["Brandon Lucas"] +license = "MIT" + +[dependencies] diff --git a/packages/xml/test_integration.lux b/packages/xml/test_integration.lux new file mode 100644 index 0000000..fb1ba8d --- /dev/null +++ b/packages/xml/test_integration.lux @@ -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, "My Page"), "page has title") + Test.assert(String.contains(result, ""), "page has meta charset") + Test.assert(String.contains(result, "

        Welcome

        "), "page has h1") + Test.assert(String.contains(result, "

        Hello world

        "), "page has paragraph") + Test.assert(String.startsWith(result, ""), "starts with html tag") + Test.assert(String.endsWith(result, ""), "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, "https://example.com/"), "sitemap has first URL") + Test.assert(String.contains(sitemap, "https://example.com/about"), "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, ""), "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, "Name"), "has header Name") + Test.assert(String.contains(result, "Alice"), "has cell Alice") + Test.assert(String.contains(result, "25"), "has cell 25") +} + +// documentWithDecl for custom processing instructions +fn test_custom_declaration(): Unit with {Test} = { + let decl = "\n" + 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, "value"), "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, ""), + lib.el("body", [lib.cdata("Use & \"xml\" freely")]) + ]) + let result = lib.render(node) + Test.assert(String.contains(result, "Alice & Bob"), "from is escaped") + Test.assert(String.contains(result, "<Important>"), "subject is escaped") + Test.assert(String.contains(result, "Use <html> & \"xml\" freely"), "cdata preserves content") +} diff --git a/packages/xml/test_snapshot.lux b/packages/xml/test_snapshot.lux new file mode 100644 index 0000000..f457607 --- /dev/null +++ b/packages/xml/test_snapshot.lux @@ -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 = "Test

        Hello

        " + 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 = "" + 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 = "\nMyApp1.0" + 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 & \"everyone\"") + let expected = "Hello <world> & "everyone"" + 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 = "" + 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("
        "), + lib.text(" text & "), + lib.cdata("") + ]) + let expected = "Normal
        text & ]]>
        " + 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 = "deep" + 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\" & "), + lib.attr("class", "a&b") + ]) + let expected = "
        " + Test.assertEqualMsg(expected, lib.render(node), "snap: attribute escaping") +} + +// Snapshot: empty elements +fn test_snapshot_empty_elements(): Unit with {Test} = { + Test.assertEqualMsg("
        ", lib.render(lib.el("br", [])), "snap: empty el") + Test.assertEqualMsg("
        ", lib.render(lib.selfClosing("hr", [])), "snap: selfClosing") + Test.assertEqualMsg("

        ", 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 = "https://example.com/page2025-01-29weekly0.8" + Test.assertEqualMsg(expected, lib.render(url), "snap: sitemap URL entry") +} diff --git a/packages/xml/test_unit.lux b/packages/xml/test_unit.lux new file mode 100644 index 0000000..b4a5cf4 --- /dev/null +++ b/packages/xml/test_unit.lux @@ -0,0 +1,118 @@ +import lib + +// --- escape edge cases --- + +fn test_escape_all_chars(): Unit with {Test} = { + Test.assertEqualMsg("&", lib.escape("&"), "escape ampersand") + Test.assertEqualMsg("<", lib.escape("<"), "escape less-than") + Test.assertEqualMsg(">", lib.escape(">"), "escape greater-than") + Test.assertEqualMsg(""", lib.escape("\""), "escape double quote") + Test.assertEqualMsg("'", 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("<&>"'", lib.escape("<&>\"'"), "escape all special chars together") + +fn test_escape_mixed(): Unit with {Test} = + Test.assertEqualMsg("a & b < 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("<script>", lib.render(lib.text("