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