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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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