Sync local packages into the registry repo and update index.json and README.md to include all 9 packages.
375 lines
16 KiB
Plaintext
375 lines
16 KiB
Plaintext
// 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<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), , <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 + "<"),
|
|
}
|
|
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 <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)
|